v3: chat mentions stop going missing, and live events get a light sound — the server skips a mention notification for anyone with the chatbox open, on the grounds that they're reading it, but "open" also meant a chatbox left open in a background tab, and the same modal cleared the unread badge on every arrival, so a mention there left no bell, no badge and no trace. Reading the chat now means open and on screen: the focus flag is re-announced on every visibilitychange, and the unread count only clears while the tab is visible. A reconnect with the chatbox open refetches the latest page, since whatever was said while the socket was down never reached the live buffer. The server's mention regex excluded a backslash and the letter w rather than word characters (\\w inside a class), so an email address mentioned its domain; it now matches the autocomplete's rule. On top of that, sounds synthesised with Web Audio — a soft blip for chat you aren't looking at, throttled, and a two-note chime for notifications and for a mention you're reading live, which gets no notification of its own — claimed through localStorage so several tabs chime once, with an on/off toggle under a new Notifications section in settings. The tab title carries the unread count, composed with the page title in useDocumentTitle so navigation doesn't drop it.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-22 19:25:19 +00:00
parent c9fb4450ad
commit b313f70d31
11 changed files with 520 additions and 206 deletions

View File

@@ -9,7 +9,8 @@ import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
import { sendToUser } from "./ws-service.ts";
// Regex: matches @username not already inside a markdown link ([...] or (...)
const MENTION_RE = /(?<![[(\\w])@([\w]+)/g;
// and not mid-word, so an email address doesn't mention its domain.
const MENTION_RE = /(?<![[(\w])@(\w+)/g;
// ── Core CRUD ─────────────────────────────────────────────────────────────────

View File

@@ -284,6 +284,7 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
clearUnreadChat,
setChatTyping,
setChatOpen,
connectionEpoch,
} = useWS();
const { authFetch, user } = useAuth();
const canModerate = can(user, "chat:moderate");
@@ -352,10 +353,43 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
};
}, [authFetch]);
// Clear the unread badge on open and as new messages arrive while open.
// Messages sent while the socket was down never reached the live buffer:
// after a reconnect, fetch the latest page again and fold it into history
// (the merge above dedupes against what's already shown).
const seenEpochRef = useRef(connectionEpoch);
useEffect(() => {
clearUnreadChat();
}, [chatMessages, clearUnreadChat]);
if (connectionEpoch === seenEpochRef.current) return;
seenEpochRef.current = connectionEpoch;
let cancelled = false;
(async () => {
try {
const res = await authFetch(`${API_URL}/api/chat?limit=${PAGE_SIZE}`);
const json = await res.json();
if (cancelled || !json.success) return;
const items = (json.data as RawChatMessage[]).map(deserializeChatMessage);
setHistory((prev) => [...prev, ...items]);
} catch {
// The next reconnect or reopen will try again.
}
})();
return () => {
cancelled = true;
};
}, [authFetch, connectionEpoch]);
// Whether the tab is on screen: a chatbox left open in a background tab isn't
// being read, so its messages should stay unread until the user comes back.
const [tabVisible, setTabVisible] = useState(() => !document.hidden);
useEffect(() => {
const onVisibility = () => setTabVisible(!document.hidden);
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, []);
// Clear the unread badge on open and as new messages arrive while read.
useEffect(() => {
if (tabVisible) clearUnreadChat();
}, [chatMessages, clearUnreadChat, tabVisible]);
// Tell the server the chatbox is open so it skips redundant mention
// notifications while the user is reading; clear the flag on close.

View File

@@ -39,6 +39,8 @@ import {
} from "../model.ts";
import { t } from "@lingui/core/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { setTitleBadge } from "../hooks/useDocumentTitle.ts";
import { playChime } from "../utils/sound.ts";
interface WSProviderProps {
children: ReactNode;
@@ -55,6 +57,17 @@ const MAX_RECENT_DUMPS = 100;
// Older messages remain reachable via the /api/chat history endpoint.
const MAX_CHAT_MESSAGES = 200;
// Mirrors MENTION_RE in api/services/notification-service.ts.
const MENTION_RE = /(?<![[(\w])@(\w+)/g;
function mentionsUser(body: string, username: string): boolean {
const target = username.toLowerCase();
for (const m of body.matchAll(MENTION_RE)) {
if (m[1].toLowerCase() === target) return true;
}
return false;
}
interface PendingVote {
timeout: ReturnType<typeof setTimeout>;
rollback: () => void;
@@ -167,6 +180,7 @@ export function WSProvider({ children }: WSProviderProps) {
const likeCountsRef = useRef(likeCounts);
const myLikesRef = useRef(myLikes);
const userIdRef = useRef(userId);
const usernameRef = useRef(user?.username ?? null);
// Stable ref for logout so the effect doesn't reconnect when the function
// reference changes on re-renders.
const onForceLogoutRef = useRef(logout);
@@ -176,6 +190,7 @@ export function WSProvider({ children }: WSProviderProps) {
likeCountsRef.current = likeCounts;
myLikesRef.current = myLikes;
userIdRef.current = userId;
usernameRef.current = user?.username ?? null;
onForceLogoutRef.current = logout;
});
@@ -183,6 +198,13 @@ export function WSProvider({ children }: WSProviderProps) {
// Tracks whether the chatbox is open so it can be re-announced to the server
// after a reconnect (the fresh socket starts with the flag cleared).
const chatOpenRef = useRef(false);
// The user is only actually reading the chat when the chatbox is open *and*
// the tab is on screen: a chatbox left open in a background tab must not
// swallow mention notifications or unread counts.
const isViewingChat = useCallback(
() => chatOpenRef.current && !document.hidden,
[],
);
// Tracks pending optimistic votes: dumpId → pending rollback handler
const pendingRef = useRef<Map<string, PendingVote>>(
new Map(),
@@ -309,7 +331,7 @@ export function WSProvider({ children }: WSProviderProps) {
if (welcomeCount > 1) setConnectionEpoch((n) => n + 1);
// Restore the chat-open flag on the fresh connection so mention
// suppression keeps working across reconnects.
if (chatOpenRef.current) {
if (isViewingChat()) {
ws.send(
JSON.stringify(
{ type: "chat_focus", open: true } satisfies OutgoingWSMessage,
@@ -496,6 +518,7 @@ export function WSProvider({ children }: WSProviderProps) {
const notification = deserializeNotification(msg.notification);
setLastNotification(notification);
setUnreadNotificationCount((prev) => prev + 1);
playChime("notify", notification.id);
break;
}
@@ -508,6 +531,15 @@ export function WSProvider({ children }: WSProviderProps) {
// Count as unread unless it's the current user's own message.
if (message.userId !== userIdRef.current) {
setUnreadChatCount((prev) => prev + 1);
const username = usernameRef.current;
if (username && mentionsUser(message.body, username)) {
// Someone reading the chat gets no notification for a mention
// (the server skips it), so the chime has to come from here;
// otherwise the notification that follows brings its own.
if (isViewingChat()) playChime("notify", message.id);
} else if (!isViewingChat()) {
playChime("chat", message.id);
}
}
break;
}
@@ -610,6 +642,7 @@ export function WSProvider({ children }: WSProviderProps) {
clearPendingVote,
clearAllPendingLikes,
clearPendingLike,
isViewingChat,
token,
]);
@@ -817,16 +850,39 @@ export function WSProvider({ children }: WSProviderProps) {
}
}, []);
const setChatOpen = useCallback((open: boolean) => {
chatOpenRef.current = open;
// Tell the server whether the user is reading the chat right now (chatbox
// open and tab visible), so it knows when a mention notification is moot.
const sendChatFocus = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_focus", open } satisfies OutgoingWSMessage,
{
type: "chat_focus",
open: isViewingChat(),
} satisfies OutgoingWSMessage,
),
);
}
}, []);
}, [isViewingChat]);
const setChatOpen = useCallback((open: boolean) => {
chatOpenRef.current = open;
sendChatFocus();
}, [sendChatFocus]);
// Re-announce chat focus whenever the tab is hidden or shown.
useEffect(() => {
const onVisibility = () => {
if (chatOpenRef.current) sendChatFocus();
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [sendChatFocus]);
// Unread count in the tab title, so a background tab shows there's news.
useEffect(() => {
setTitleBadge(unreadNotificationCount + unreadChatCount);
}, [unreadNotificationCount, unreadChatCount]);
const value: WSContextValue = useMemo(() => ({
wsStatus,

View File

@@ -23,6 +23,19 @@ function readSiteEmoji(): string {
export const SITE_EMOJI = readSiteEmoji();
// The page title and the unread badge are set from different places (each
// page, and the live-updates provider), so both are kept here and composed.
// `null` base means no page has set one yet: keep the server-rendered title.
let baseTitle: string | null = null;
let badge = 0;
function applyTitle() {
const base = baseTitle ?? document.title.replace(/^\(\d+\+?\) /, "");
document.title = badge > 0
? `(${badge > 99 ? "99+" : badge}) ${base}`
: base;
}
/**
* Keeps document.title in sync with the current page during client-side
* navigation. Without this the tab title only ever reflects the server-rendered
@@ -37,6 +50,14 @@ export const SITE_EMOJI = readSiteEmoji();
export function useDocumentTitle(title: string | null | undefined): void {
useEffect(() => {
if (title === undefined) return;
document.title = title ? `${title} · ${SITE_NAME}` : SITE_NAME;
baseTitle = title ? `${title} · ${SITE_NAME}` : SITE_NAME;
applyTitle();
}, [title]);
}
/** Prefixes the tab title with an unread count, e.g. "(3) Chat · gerbeur". */
export function setTitleBadge(count: number): void {
if (count === badge) return;
badge = count;
applyTitle();
}

View File

@@ -0,0 +1,18 @@
import { useCallback, useState } from "react";
import { readSoundsEnabled, writeSoundsEnabled } from "../utils/sound.ts";
/**
* User preference for notification and chat sounds, persisted to localStorage
* (on by default). The chime itself reads the stored value at play time, so
* this hook only drives the settings toggle.
*/
export function useSoundsEnabled(): [boolean, (enabled: boolean) => void] {
const [enabled, setEnabledState] = useState(readSoundsEnabled);
const setEnabled = useCallback((next: boolean) => {
writeSoundsEnabled(next);
setEnabledState(next);
}, []);
return [enabled, setEnabled];
}

File diff suppressed because one or more lines are too long

View File

@@ -35,12 +35,12 @@ msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
#: src/components/ChatModal.tsx:534
#: src/components/ChatModal.tsx:568
msgid "{0} and {1} are typing…"
msgstr "{0} and {1} are typing…"
#. placeholder {0}: typingNames[0]
#: src/components/ChatModal.tsx:532
#: src/components/ChatModal.tsx:566
msgid "{0} is typing…"
msgstr "{0} is typing…"
@@ -60,7 +60,7 @@ msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
#: src/components/DumpCreateModal.tsx:936
#: src/pages/PlaylistDetail.tsx:572
#: src/pages/UserPublicProfile.tsx:798
#: src/pages/UserPublicProfile.tsx:801
msgid "← Back"
msgstr "← Back"
@@ -76,7 +76,7 @@ msgstr "← Back to all dumps"
msgid "← Back to profile"
msgstr "← Back to profile"
#: src/pages/UserPublicProfile.tsx:134
#: src/pages/UserPublicProfile.tsx:136
msgid "+ Invite someone"
msgstr "+ Invite someone"
@@ -142,7 +142,7 @@ msgstr "a comment"
msgid "a post"
msgstr "a post"
#: src/pages/UserPublicProfile.tsx:1179
#: src/pages/UserPublicProfile.tsx:1182
msgid "Account"
msgstr "Account"
@@ -155,7 +155,7 @@ msgstr "Add a comment…"
msgid "Add category"
msgstr "Add category"
#: src/pages/UserPublicProfile.tsx:876
#: src/pages/UserPublicProfile.tsx:879
msgid "Add email…"
msgstr "Add email…"
@@ -164,7 +164,7 @@ msgstr "Add email…"
msgid "Add to playlist"
msgstr "Add to playlist"
#: src/pages/UserPublicProfile.tsx:1604
#: src/pages/UserPublicProfile.tsx:1646
msgid "Admin"
msgstr "Admin"
@@ -183,7 +183,7 @@ msgstr "Already dumped by {0} {1}"
msgid "Already have an account? <0>Log in</0>"
msgstr "Already have an account? <0>Log in</0>"
#: src/pages/UserPublicProfile.tsx:1198
#: src/pages/UserPublicProfile.tsx:1201
msgid "Appearance"
msgstr "Appearance"
@@ -194,7 +194,7 @@ msgstr "Appearance"
msgid "At least {0} characters"
msgstr "At least {0} characters"
#: src/pages/UserPublicProfile.tsx:1253
#: src/pages/UserPublicProfile.tsx:1263
msgid "Auto"
msgstr "Auto"
@@ -203,8 +203,8 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Back to login"
#: src/contexts/WSProvider.tsx:270
#: src/contexts/WSProvider.tsx:576
#: src/contexts/WSProvider.tsx:292
#: src/contexts/WSProvider.tsx:608
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
@@ -218,8 +218,8 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/pages/Dump.tsx:406
#: src/pages/DumpEdit.tsx:484
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/UserPublicProfile.tsx:1733
#: src/pages/UserPublicProfile.tsx:1806
#: src/pages/UserPublicProfile.tsx:1775
#: src/pages/UserPublicProfile.tsx:1848
msgid "Cancel"
msgstr "Cancel"
@@ -227,8 +227,8 @@ msgstr "Cancel"
msgid "Cancel removal"
msgstr "Cancel removal"
#: src/components/ChatModal.tsx:624
#: src/components/ChatModal.tsx:625
#: src/components/ChatModal.tsx:658
#: src/components/ChatModal.tsx:659
msgid "Cancel reply"
msgstr "Cancel reply"
@@ -237,7 +237,7 @@ msgid "Cancel search"
msgstr "Cancel search"
#: src/components/CategorySelect.tsx:22
#: src/pages/UserPublicProfile.tsx:1336
#: src/pages/UserPublicProfile.tsx:1378
msgid "Categories"
msgstr "Categories"
@@ -245,7 +245,7 @@ msgstr "Categories"
msgid "Category"
msgstr "Category"
#: src/pages/UserPublicProfile.tsx:825
#: src/pages/UserPublicProfile.tsx:828
msgid "Change avatar"
msgstr "Change avatar"
@@ -254,14 +254,14 @@ msgstr "Change avatar"
msgid "Change password"
msgstr "Change password"
#: src/pages/UserPublicProfile.tsx:1191
#: src/pages/UserPublicProfile.tsx:1194
msgid "Change password…"
msgstr "Change password…"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:538
#: src/components/ChatModal.tsx:572
msgid "Chat"
msgstr "Chat"
@@ -295,7 +295,7 @@ msgstr "Close without posting? The attached file won't be kept — everything yo
msgid "Collapse player"
msgstr "Collapse player"
#: src/pages/UserPublicProfile.tsx:1245
#: src/pages/UserPublicProfile.tsx:1255
msgid "Color scheme"
msgstr "Color scheme"
@@ -304,11 +304,11 @@ msgstr "Color scheme"
msgid "Confirm new password"
msgstr "Confirm new password"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:161
msgid "Copied!"
msgstr "Copied!"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:161
msgid "Copy"
msgstr "Copy"
@@ -350,11 +350,11 @@ msgstr "Creating…"
msgid "Current password"
msgstr "Current password"
#: src/pages/UserPublicProfile.tsx:1267
#: src/pages/UserPublicProfile.tsx:1277
msgid "Dark"
msgstr "Dark"
#: src/pages/UserPublicProfile.tsx:1280
#: src/pages/UserPublicProfile.tsx:1290
msgid "Default tab"
msgstr "Default tab"
@@ -446,13 +446,13 @@ msgstr "Dumped!"
#: src/pages/Search.tsx:174
#: src/pages/UserDumps.tsx:80
#: src/pages/UserPublicProfile.tsx:948
#: src/pages/UserPublicProfile.tsx:951
msgid "Dumps"
msgstr "Dumps"
#. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:968
#: src/pages/UserPublicProfile.tsx:971
msgid "Dumps ({0}{1})"
msgstr "Dumps ({0}{1})"
@@ -530,9 +530,9 @@ msgstr "Failed to create playlist"
msgid "Failed to delete"
msgstr "Failed to delete"
#: src/pages/UserPublicProfile.tsx:104
#: src/pages/UserPublicProfile.tsx:107
#: src/pages/UserPublicProfile.tsx:168
#: src/pages/UserPublicProfile.tsx:106
#: src/pages/UserPublicProfile.tsx:109
#: src/pages/UserPublicProfile.tsx:170
msgid "Failed to generate invite"
msgstr "Failed to generate invite"
@@ -541,9 +541,9 @@ msgstr "Failed to generate invite"
#: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:374
#: src/pages/UserPublicProfile.tsx:1070
#: src/pages/UserPublicProfile.tsx:1112
#: src/pages/UserPublicProfile.tsx:1157
#: src/pages/UserPublicProfile.tsx:1073
#: src/pages/UserPublicProfile.tsx:1115
#: src/pages/UserPublicProfile.tsx:1160
msgid "Failed to load"
msgstr "Failed to load"
@@ -560,8 +560,8 @@ msgid "Failed to post reply"
msgstr "Failed to post reply"
#: src/pages/PlaylistDetail.tsx:911
#: src/pages/UserPublicProfile.tsx:1736
#: src/pages/UserPublicProfile.tsx:1808
#: src/pages/UserPublicProfile.tsx:1778
#: src/pages/UserPublicProfile.tsx:1850
msgid "Failed to save"
msgstr "Failed to save"
@@ -573,15 +573,15 @@ msgstr "Failed to save category"
msgid "Failed to save edit"
msgstr "Failed to save edit"
#: src/pages/UserPublicProfile.tsx:885
#: src/pages/UserPublicProfile.tsx:888
msgid "Failed to update avatar"
msgstr "Failed to update avatar"
#: src/pages/UserPublicProfile.tsx:1674
#: src/pages/UserPublicProfile.tsx:1716
msgid "Failed to update role"
msgstr "Failed to update role"
#: src/pages/UserPublicProfile.tsx:1275
#: src/pages/UserPublicProfile.tsx:1285
msgid "Feeds"
msgstr "Feeds"
@@ -615,8 +615,8 @@ msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here."
#: src/components/FeedTabBar.tsx:34
#: src/pages/UserPublicProfile.tsx:950
#: src/pages/UserPublicProfile.tsx:1309
#: src/pages/UserPublicProfile.tsx:953
#: src/pages/UserPublicProfile.tsx:1319
msgid "Followed"
msgstr "Followed"
@@ -626,13 +626,13 @@ msgstr "Followed"
msgid "Followed ({0}{1})"
msgstr "Followed ({0}{1})"
#: src/pages/UserPublicProfile.tsx:1101
#: src/pages/UserPublicProfile.tsx:1104
msgid "Followed playlists"
msgstr "Followed playlists"
#: src/components/FollowButton.tsx:37
#: src/components/FollowButton.tsx:64
#: src/pages/UserPublicProfile.tsx:1059
#: src/pages/UserPublicProfile.tsx:1062
msgid "Following"
msgstr "Following"
@@ -657,7 +657,7 @@ msgid "Go to login"
msgstr "Go to login"
#: src/components/FeedTabBar.tsx:30
#: src/pages/UserPublicProfile.tsx:1288
#: src/pages/UserPublicProfile.tsx:1298
msgid "Hot"
msgstr "Hot"
@@ -677,22 +677,22 @@ msgstr "Invalid invite"
msgid "Invalid link"
msgstr "Invalid link"
#: src/pages/UserPublicProfile.tsx:142
#: src/pages/UserPublicProfile.tsx:149
#: src/pages/UserPublicProfile.tsx:144
#: src/pages/UserPublicProfile.tsx:151
msgid "Invite link"
msgstr "Invite link"
#: src/pages/UserPublicProfile.tsx:847
#: src/pages/UserPublicProfile.tsx:850
msgid "invited by"
msgstr "invited by"
#: src/pages/UserPublicProfile.tsx:951
#: src/pages/UserPublicProfile.tsx:1146
#: src/pages/UserPublicProfile.tsx:954
#: src/pages/UserPublicProfile.tsx:1149
msgid "Invitees"
msgstr "Invitees"
#: src/components/FeedTabBar.tsx:32
#: src/pages/UserPublicProfile.tsx:1302
#: src/pages/UserPublicProfile.tsx:1312
msgid "Journal"
msgstr "Journal"
@@ -700,7 +700,7 @@ msgstr "Journal"
msgid "Keep editing"
msgstr "Keep editing"
#: src/pages/UserPublicProfile.tsx:1260
#: src/pages/UserPublicProfile.tsx:1270
msgid "Light"
msgstr "Light"
@@ -716,7 +716,7 @@ msgstr "Link"
msgid "Link or file"
msgstr "Link or file"
#: src/contexts/WSProvider.tsx:575
#: src/contexts/WSProvider.tsx:607
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -729,7 +729,7 @@ msgstr "Live updates unavailable."
msgid "Load more"
msgstr "Load more"
#: src/components/ChatModal.tsx:551
#: src/components/ChatModal.tsx:585
msgid "Load older messages"
msgstr "Load older messages"
@@ -740,7 +740,7 @@ msgstr "Loading dump…"
#: src/pages/index/FollowedFeed.tsx:111
#: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:62
#: src/pages/index/JournalFeed.tsx:73
#: src/pages/index/NewFeed.tsx:64
#: src/pages/Search.tsx:244
#: src/pages/UserDumps.tsx:121
@@ -754,12 +754,12 @@ msgstr "Loading more…"
msgid "Loading playlist…"
msgstr "Loading playlist…"
#: src/pages/UserPublicProfile.tsx:781
#: src/pages/UserPublicProfile.tsx:784
msgid "Loading profile…"
msgstr "Loading profile…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:550
#: src/components/ChatModal.tsx:584
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:311
#: src/components/UserListPopover.tsx:192
@@ -772,9 +772,9 @@ msgstr "Loading profile…"
#: src/pages/Notifications.tsx:446
#: src/pages/UserDumps.tsx:54
#: src/pages/UserPlaylists.tsx:345
#: src/pages/UserPublicProfile.tsx:1064
#: src/pages/UserPublicProfile.tsx:1106
#: src/pages/UserPublicProfile.tsx:1151
#: src/pages/UserPublicProfile.tsx:1067
#: src/pages/UserPublicProfile.tsx:1109
#: src/pages/UserPublicProfile.tsx:1154
#: src/pages/UserUpvoted.tsx:125
msgid "Loading…"
msgstr "Loading…"
@@ -794,8 +794,8 @@ msgstr "Log in to like"
msgid "Log in to vote"
msgstr "Log in to vote"
#: src/pages/UserPublicProfile.tsx:802
#: src/pages/UserPublicProfile.tsx:898
#: src/pages/UserPublicProfile.tsx:805
#: src/pages/UserPublicProfile.tsx:901
msgid "Log out"
msgstr "Log out"
@@ -807,7 +807,7 @@ msgstr "Logging in…"
msgid "Login failed"
msgstr "Login failed"
#: src/pages/UserPublicProfile.tsx:956
#: src/pages/UserPublicProfile.tsx:959
msgid "Manage"
msgstr "Manage"
@@ -815,7 +815,7 @@ msgstr "Manage"
msgid "Max 50 MB"
msgstr "Max 50 MB"
#: src/pages/UserPublicProfile.tsx:1603
#: src/pages/UserPublicProfile.tsx:1645
msgid "Moderator"
msgstr "Moderator"
@@ -829,7 +829,7 @@ msgid "new"
msgstr "new"
#: src/components/FeedTabBar.tsx:31
#: src/pages/UserPublicProfile.tsx:1295
#: src/pages/UserPublicProfile.tsx:1305
msgid "New"
msgstr "New"
@@ -837,7 +837,7 @@ msgstr "New"
#: src/components/DumpFab.tsx:65
#: src/components/DumpFab.tsx:66
#: src/pages/UserDumps.tsx:88
#: src/pages/UserPublicProfile.tsx:1382
#: src/pages/UserPublicProfile.tsx:1424
msgid "New dump"
msgstr "New dump"
@@ -878,15 +878,15 @@ msgid "No emoji found."
msgstr "No emoji found."
#: src/pages/UserPlaylists.tsx:442
#: src/pages/UserPublicProfile.tsx:1119
#: src/pages/UserPublicProfile.tsx:1122
msgid "No followed playlists yet."
msgstr "No followed playlists yet."
#: src/pages/UserPublicProfile.tsx:1164
#: src/pages/UserPublicProfile.tsx:1167
msgid "No invitees yet."
msgstr "No invitees yet."
#: src/components/ChatModal.tsx:558
#: src/components/ChatModal.tsx:592
msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!"
@@ -900,7 +900,7 @@ msgstr "No playlists match \"{q}\"."
#: src/components/PlaylistMembershipPanel.tsx:34
#: src/pages/UserPlaylists.tsx:400
#: src/pages/UserPublicProfile.tsx:1030
#: src/pages/UserPublicProfile.tsx:1033
msgid "No playlists yet."
msgstr "No playlists yet."
@@ -908,14 +908,14 @@ msgstr "No playlists yet."
msgid "No users match \"{q}\"."
msgstr "No users match \"{q}\"."
#: src/pages/UserPublicProfile.tsx:1077
#: src/pages/UserPublicProfile.tsx:1080
msgid "Not following anyone yet."
msgstr "Not following anyone yet."
#: src/pages/Notifications.tsx:381
#: src/pages/UserDumps.tsx:99
#: src/pages/UserPublicProfile.tsx:1393
#: src/pages/UserPublicProfile.tsx:1515
#: src/pages/UserPublicProfile.tsx:1435
#: src/pages/UserPublicProfile.tsx:1557
#: src/pages/UserUpvoted.tsx:159
msgid "Nothing here yet."
msgstr "Nothing here yet."
@@ -923,6 +923,7 @@ msgstr "Nothing here yet."
#: src/components/NotificationBell.tsx:42
#: src/pages/Notifications.tsx:265
#: src/pages/Notifications.tsx:359
#: src/pages/UserPublicProfile.tsx:1327
msgid "Notifications"
msgstr "Notifications"
@@ -930,6 +931,14 @@ msgstr "Notifications"
msgid "Notifications ({unreadNotificationCount} unread)"
msgstr "Notifications ({unreadNotificationCount} unread)"
#: src/pages/UserPublicProfile.tsx:1351
msgid "Off"
msgstr "Off"
#: src/pages/UserPublicProfile.tsx:1344
msgid "On"
msgstr "On"
#: src/components/SearchBar.tsx:96
msgid "Open search"
msgstr "Open search"
@@ -943,7 +952,7 @@ msgid "Page not found"
msgstr "Page not found"
#: src/pages/UserLogin.tsx:76
#: src/pages/UserPublicProfile.tsx:1184
#: src/pages/UserPublicProfile.tsx:1187
msgid "Password"
msgstr "Password"
@@ -982,13 +991,13 @@ msgstr "Playlist title"
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371
#: src/pages/UserPublicProfile.tsx:949
#: src/pages/UserPublicProfile.tsx:952
msgid "Playlists"
msgstr "Playlists"
#. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:999
#: src/pages/UserPublicProfile.tsx:1002
msgid "Playlists ({0}{1})"
msgstr "Playlists ({0}{1})"
@@ -1090,7 +1099,7 @@ msgstr "Replace file"
msgid "Reply"
msgstr "Reply"
#: src/components/ChatModal.tsx:614
#: src/components/ChatModal.tsx:648
msgid "Replying to"
msgstr "Replying to"
@@ -1121,7 +1130,7 @@ msgstr "Reset to the suggested title"
msgid "Retry"
msgstr "Retry"
#: src/pages/UserPublicProfile.tsx:1658
#: src/pages/UserPublicProfile.tsx:1700
msgid "Role"
msgstr "Role"
@@ -1130,8 +1139,8 @@ msgstr "Role"
#: src/pages/Dump.tsx:398
#: src/pages/DumpEdit.tsx:487
#: src/pages/PlaylistDetail.tsx:930
#: src/pages/UserPublicProfile.tsx:1725
#: src/pages/UserPublicProfile.tsx:1798
#: src/pages/UserPublicProfile.tsx:1767
#: src/pages/UserPublicProfile.tsx:1840
msgid "Save"
msgstr "Save"
@@ -1140,8 +1149,8 @@ msgstr "Save"
#: src/pages/Dump.tsx:397
#: src/pages/PlaylistDetail.tsx:926
#: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1722
#: src/pages/UserPublicProfile.tsx:1795
#: src/pages/UserPublicProfile.tsx:1764
#: src/pages/UserPublicProfile.tsx:1837
msgid "Saving…"
msgstr "Saving…"
@@ -1163,7 +1172,7 @@ msgstr "Search failed"
msgid "Searching…"
msgstr "Searching…"
#: src/components/ChatModal.tsx:660
#: src/components/ChatModal.tsx:694
msgid "Send"
msgstr "Send"
@@ -1184,15 +1193,15 @@ msgstr "Server unreachable"
msgid "Set new password"
msgstr "Set new password"
#: src/pages/UserPublicProfile.tsx:953
#: src/pages/UserPublicProfile.tsx:956
msgid "Settings"
msgstr "Settings"
#: src/components/ChatModal.tsx:535
#: src/components/ChatModal.tsx:569
msgid "Several people are typing…"
msgstr "Several people are typing…"
#: src/pages/UserPublicProfile.tsx:162
#: src/pages/UserPublicProfile.tsx:164
msgid "Single use, valid for 7 days."
msgstr "Single use, valid for 7 days."
@@ -1205,11 +1214,15 @@ msgstr "slug"
msgid "Something went wrong"
msgstr "Something went wrong"
#: src/pages/UserPublicProfile.tsx:1332
msgid "Sounds"
msgstr "Sounds"
#: src/components/DumpCreateModal.tsx:707
msgid "Start fresh"
msgstr "Start fresh"
#: src/pages/UserPublicProfile.tsx:1203
#: src/pages/UserPublicProfile.tsx:1206
msgid "Style"
msgstr "Style"
@@ -1261,7 +1274,7 @@ msgstr "Today"
msgid "Track {0}"
msgstr "Track {0}"
#: src/components/ChatModal.tsx:637
#: src/components/ChatModal.tsx:671
msgid "Type a message…"
msgstr "Type a message…"
@@ -1277,7 +1290,7 @@ msgstr "Unfollow {targetUsername}"
msgid "Unfollow playlist"
msgstr "Unfollow playlist"
#: src/pages/UserPublicProfile.tsx:748
#: src/pages/UserPublicProfile.tsx:751
msgid "Upload failed"
msgstr "Upload failed"
@@ -1300,7 +1313,7 @@ msgstr "Upvoted"
#. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:979
#: src/pages/UserPublicProfile.tsx:982
msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})"
@@ -1316,11 +1329,11 @@ msgstr "URL is required."
msgid "URL slug"
msgstr "URL slug"
#: src/pages/UserPublicProfile.tsx:1602
#: src/pages/UserPublicProfile.tsx:1644
msgid "User"
msgstr "User"
#: src/pages/UserPublicProfile.tsx:1321
#: src/pages/UserPublicProfile.tsx:1363
msgid "User management"
msgstr "User management"
@@ -1337,11 +1350,11 @@ msgstr "Username"
msgid "Users"
msgstr "Users"
#: src/pages/UserPublicProfile.tsx:1049
#: src/pages/UserPublicProfile.tsx:1092
#: src/pages/UserPublicProfile.tsx:1134
#: src/pages/UserPublicProfile.tsx:1414
#: src/pages/UserPublicProfile.tsx:1545
#: src/pages/UserPublicProfile.tsx:1052
#: src/pages/UserPublicProfile.tsx:1095
#: src/pages/UserPublicProfile.tsx:1137
#: src/pages/UserPublicProfile.tsx:1456
#: src/pages/UserPublicProfile.tsx:1587
msgid "View all →"
msgstr "View all →"
@@ -1354,8 +1367,8 @@ msgstr "View dump →"
msgid "What makes it worth it?"
msgstr "What makes it worth it?"
#: src/pages/UserPublicProfile.tsx:933
#: src/pages/UserPublicProfile.tsx:1787
#: src/pages/UserPublicProfile.tsx:936
#: src/pages/UserPublicProfile.tsx:1829
msgid "Who am I?"
msgstr "Who am I?"
@@ -1391,7 +1404,7 @@ msgstr "You'll be notified when someone follows your playlists, upvotes your dum
#: src/pages/index/FollowedFeed.tsx:116
#: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:67
#: src/pages/index/JournalFeed.tsx:78
#: src/pages/index/NewFeed.tsx:69
#: src/pages/Search.tsx:249
#: src/pages/UserDumps.tsx:126

File diff suppressed because one or more lines are too long

View File

@@ -35,12 +35,12 @@ msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus ancienn
#. placeholder {0}: typingNames[0]
#. placeholder {1}: typingNames[1]
#: src/components/ChatModal.tsx:534
#: src/components/ChatModal.tsx:568
msgid "{0} and {1} are typing…"
msgstr "{0} et {1} sont en train d'écrire…"
#. placeholder {0}: typingNames[0]
#: src/components/ChatModal.tsx:532
#: src/components/ChatModal.tsx:566
msgid "{0} is typing…"
msgstr "{0} est en train d'écrire…"
@@ -60,7 +60,7 @@ msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
#: src/components/DumpCreateModal.tsx:936
#: src/pages/PlaylistDetail.tsx:572
#: src/pages/UserPublicProfile.tsx:798
#: src/pages/UserPublicProfile.tsx:801
msgid "← Back"
msgstr "← Retour"
@@ -76,7 +76,7 @@ msgstr "← Retour à toutes les recos"
msgid "← Back to profile"
msgstr "← Retour au profil"
#: src/pages/UserPublicProfile.tsx:134
#: src/pages/UserPublicProfile.tsx:136
msgid "+ Invite someone"
msgstr "+ Inviter quelqu'un"
@@ -142,7 +142,7 @@ msgstr "un commentaire"
msgid "a post"
msgstr "une publication"
#: src/pages/UserPublicProfile.tsx:1179
#: src/pages/UserPublicProfile.tsx:1182
msgid "Account"
msgstr "Compte"
@@ -155,7 +155,7 @@ msgstr "Ajouter un commentaire…"
msgid "Add category"
msgstr "Ajouter une catégorie"
#: src/pages/UserPublicProfile.tsx:876
#: src/pages/UserPublicProfile.tsx:879
msgid "Add email…"
msgstr "Ajouter un e-mail…"
@@ -164,7 +164,7 @@ msgstr "Ajouter un e-mail…"
msgid "Add to playlist"
msgstr "Ajouter à la collection"
#: src/pages/UserPublicProfile.tsx:1604
#: src/pages/UserPublicProfile.tsx:1646
msgid "Admin"
msgstr "Administrateur"
@@ -183,7 +183,7 @@ msgstr "Déjà recommandé par {0} {1}"
msgid "Already have an account? <0>Log in</0>"
msgstr "Vous avez déjà un compte ? <0>Se connecter</0>"
#: src/pages/UserPublicProfile.tsx:1198
#: src/pages/UserPublicProfile.tsx:1201
msgid "Appearance"
msgstr "Apparence"
@@ -194,7 +194,7 @@ msgstr "Apparence"
msgid "At least {0} characters"
msgstr "Au moins {0} caractères"
#: src/pages/UserPublicProfile.tsx:1253
#: src/pages/UserPublicProfile.tsx:1263
msgid "Auto"
msgstr "Auto"
@@ -203,8 +203,8 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Retour à la connexion"
#: src/contexts/WSProvider.tsx:270
#: src/contexts/WSProvider.tsx:576
#: src/contexts/WSProvider.tsx:292
#: src/contexts/WSProvider.tsx:608
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
@@ -218,8 +218,8 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/pages/Dump.tsx:406
#: src/pages/DumpEdit.tsx:484
#: src/pages/PlaylistDetail.tsx:923
#: src/pages/UserPublicProfile.tsx:1733
#: src/pages/UserPublicProfile.tsx:1806
#: src/pages/UserPublicProfile.tsx:1775
#: src/pages/UserPublicProfile.tsx:1848
msgid "Cancel"
msgstr "Annuler"
@@ -227,8 +227,8 @@ msgstr "Annuler"
msgid "Cancel removal"
msgstr "Annuler la suppression"
#: src/components/ChatModal.tsx:624
#: src/components/ChatModal.tsx:625
#: src/components/ChatModal.tsx:658
#: src/components/ChatModal.tsx:659
msgid "Cancel reply"
msgstr "Annuler la réponse"
@@ -237,7 +237,7 @@ msgid "Cancel search"
msgstr "Annuler la recherche"
#: src/components/CategorySelect.tsx:22
#: src/pages/UserPublicProfile.tsx:1336
#: src/pages/UserPublicProfile.tsx:1378
msgid "Categories"
msgstr "Catégories"
@@ -245,7 +245,7 @@ msgstr "Catégories"
msgid "Category"
msgstr "Catégorie"
#: src/pages/UserPublicProfile.tsx:825
#: src/pages/UserPublicProfile.tsx:828
msgid "Change avatar"
msgstr "Changer l'avatar"
@@ -254,14 +254,14 @@ msgstr "Changer l'avatar"
msgid "Change password"
msgstr "Changer le mot de passe"
#: src/pages/UserPublicProfile.tsx:1191
#: src/pages/UserPublicProfile.tsx:1194
msgid "Change password…"
msgstr "Changer le mot de passe…"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:538
#: src/components/ChatModal.tsx:572
msgid "Chat"
msgstr "Tribune"
@@ -295,7 +295,7 @@ msgstr "Fermer sans publier ? Le fichier joint sera perdu — ce que vous avez
msgid "Collapse player"
msgstr "Réduire le lecteur"
#: src/pages/UserPublicProfile.tsx:1245
#: src/pages/UserPublicProfile.tsx:1255
msgid "Color scheme"
msgstr "Thème de couleur"
@@ -304,11 +304,11 @@ msgstr "Thème de couleur"
msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:161
msgid "Copied!"
msgstr "Copié !"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:161
msgid "Copy"
msgstr "Copier"
@@ -350,11 +350,11 @@ msgstr "Création…"
msgid "Current password"
msgstr "Mot de passe actuel"
#: src/pages/UserPublicProfile.tsx:1267
#: src/pages/UserPublicProfile.tsx:1277
msgid "Dark"
msgstr "Sombre"
#: src/pages/UserPublicProfile.tsx:1280
#: src/pages/UserPublicProfile.tsx:1290
msgid "Default tab"
msgstr "Onglet par défaut"
@@ -446,13 +446,13 @@ msgstr "Recommandé !"
#: src/pages/Search.tsx:174
#: src/pages/UserDumps.tsx:80
#: src/pages/UserPublicProfile.tsx:948
#: src/pages/UserPublicProfile.tsx:951
msgid "Dumps"
msgstr "Recos"
#. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:968
#: src/pages/UserPublicProfile.tsx:971
msgid "Dumps ({0}{1})"
msgstr "Recos ({0}{1})"
@@ -530,9 +530,9 @@ msgstr "Impossible de créer la collection"
msgid "Failed to delete"
msgstr "Suppression échouée"
#: src/pages/UserPublicProfile.tsx:104
#: src/pages/UserPublicProfile.tsx:107
#: src/pages/UserPublicProfile.tsx:168
#: src/pages/UserPublicProfile.tsx:106
#: src/pages/UserPublicProfile.tsx:109
#: src/pages/UserPublicProfile.tsx:170
msgid "Failed to generate invite"
msgstr "Impossible de générer une invitation"
@@ -541,9 +541,9 @@ msgstr "Impossible de générer une invitation"
#: src/pages/index/JournalFeed.tsx:33
#: src/pages/index/NewFeed.tsx:36
#: src/pages/Notifications.tsx:374
#: src/pages/UserPublicProfile.tsx:1070
#: src/pages/UserPublicProfile.tsx:1112
#: src/pages/UserPublicProfile.tsx:1157
#: src/pages/UserPublicProfile.tsx:1073
#: src/pages/UserPublicProfile.tsx:1115
#: src/pages/UserPublicProfile.tsx:1160
msgid "Failed to load"
msgstr "Chargement échoué"
@@ -560,8 +560,8 @@ msgid "Failed to post reply"
msgstr "Impossible de publier la réponse"
#: src/pages/PlaylistDetail.tsx:911
#: src/pages/UserPublicProfile.tsx:1736
#: src/pages/UserPublicProfile.tsx:1808
#: src/pages/UserPublicProfile.tsx:1778
#: src/pages/UserPublicProfile.tsx:1850
msgid "Failed to save"
msgstr "Enregistrement échoué"
@@ -573,15 +573,15 @@ msgstr "Échec de l’enregistrement de la catégorie"
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
#: src/pages/UserPublicProfile.tsx:885
#: src/pages/UserPublicProfile.tsx:888
msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar"
#: src/pages/UserPublicProfile.tsx:1674
#: src/pages/UserPublicProfile.tsx:1716
msgid "Failed to update role"
msgstr "Erreur lors de la mise à jour du rôle"
#: src/pages/UserPublicProfile.tsx:1275
#: src/pages/UserPublicProfile.tsx:1285
msgid "Feeds"
msgstr "Flux"
@@ -615,8 +615,8 @@ msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/FeedTabBar.tsx:34
#: src/pages/UserPublicProfile.tsx:950
#: src/pages/UserPublicProfile.tsx:1309
#: src/pages/UserPublicProfile.tsx:953
#: src/pages/UserPublicProfile.tsx:1319
msgid "Followed"
msgstr "Suivi"
@@ -626,13 +626,13 @@ msgstr "Suivi"
msgid "Followed ({0}{1})"
msgstr "Suivies ({0}{1})"
#: src/pages/UserPublicProfile.tsx:1101
#: src/pages/UserPublicProfile.tsx:1104
msgid "Followed playlists"
msgstr "Collections suivies"
#: src/components/FollowButton.tsx:37
#: src/components/FollowButton.tsx:64
#: src/pages/UserPublicProfile.tsx:1059
#: src/pages/UserPublicProfile.tsx:1062
msgid "Following"
msgstr "Abonné"
@@ -657,7 +657,7 @@ msgid "Go to login"
msgstr "Aller à la connexion"
#: src/components/FeedTabBar.tsx:30
#: src/pages/UserPublicProfile.tsx:1288
#: src/pages/UserPublicProfile.tsx:1298
msgid "Hot"
msgstr "Tendances"
@@ -677,22 +677,22 @@ msgstr "Invitation invalide"
msgid "Invalid link"
msgstr "Lien invalide"
#: src/pages/UserPublicProfile.tsx:142
#: src/pages/UserPublicProfile.tsx:149
#: src/pages/UserPublicProfile.tsx:144
#: src/pages/UserPublicProfile.tsx:151
msgid "Invite link"
msgstr "Lien d'invitation"
#: src/pages/UserPublicProfile.tsx:847
#: src/pages/UserPublicProfile.tsx:850
msgid "invited by"
msgstr "invité par"
#: src/pages/UserPublicProfile.tsx:951
#: src/pages/UserPublicProfile.tsx:1146
#: src/pages/UserPublicProfile.tsx:954
#: src/pages/UserPublicProfile.tsx:1149
msgid "Invitees"
msgstr "Invités"
#: src/components/FeedTabBar.tsx:32
#: src/pages/UserPublicProfile.tsx:1302
#: src/pages/UserPublicProfile.tsx:1312
msgid "Journal"
msgstr "Journal"
@@ -700,7 +700,7 @@ msgstr "Journal"
msgid "Keep editing"
msgstr "Continuer l'édition"
#: src/pages/UserPublicProfile.tsx:1260
#: src/pages/UserPublicProfile.tsx:1270
msgid "Light"
msgstr "Clair"
@@ -716,7 +716,7 @@ msgstr "Lien"
msgid "Link or file"
msgstr "Lien ou fichier"
#: src/contexts/WSProvider.tsx:575
#: src/contexts/WSProvider.tsx:607
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -729,7 +729,7 @@ msgstr "Mises à jour en direct indisponibles."
msgid "Load more"
msgstr "Charger plus"
#: src/components/ChatModal.tsx:551
#: src/components/ChatModal.tsx:585
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
@@ -740,7 +740,7 @@ msgstr "Chargement de la reco…"
#: src/pages/index/FollowedFeed.tsx:111
#: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:62
#: src/pages/index/JournalFeed.tsx:73
#: src/pages/index/NewFeed.tsx:64
#: src/pages/Search.tsx:244
#: src/pages/UserDumps.tsx:121
@@ -754,12 +754,12 @@ msgstr "Chargement…"
msgid "Loading playlist…"
msgstr "Chargement de la collection…"
#: src/pages/UserPublicProfile.tsx:781
#: src/pages/UserPublicProfile.tsx:784
msgid "Loading profile…"
msgstr "Chargement du profil…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:550
#: src/components/ChatModal.tsx:584
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:311
#: src/components/UserListPopover.tsx:192
@@ -772,9 +772,9 @@ msgstr "Chargement du profil…"
#: src/pages/Notifications.tsx:446
#: src/pages/UserDumps.tsx:54
#: src/pages/UserPlaylists.tsx:345
#: src/pages/UserPublicProfile.tsx:1064
#: src/pages/UserPublicProfile.tsx:1106
#: src/pages/UserPublicProfile.tsx:1151
#: src/pages/UserPublicProfile.tsx:1067
#: src/pages/UserPublicProfile.tsx:1109
#: src/pages/UserPublicProfile.tsx:1154
#: src/pages/UserUpvoted.tsx:125
msgid "Loading…"
msgstr "Chargement…"
@@ -794,8 +794,8 @@ msgstr "Se connecter pour aimer"
msgid "Log in to vote"
msgstr "Se connecter pour voter"
#: src/pages/UserPublicProfile.tsx:802
#: src/pages/UserPublicProfile.tsx:898
#: src/pages/UserPublicProfile.tsx:805
#: src/pages/UserPublicProfile.tsx:901
msgid "Log out"
msgstr "Se déconnecter"
@@ -807,7 +807,7 @@ msgstr "Connexion…"
msgid "Login failed"
msgstr "Connexion échouée"
#: src/pages/UserPublicProfile.tsx:956
#: src/pages/UserPublicProfile.tsx:959
msgid "Manage"
msgstr "Administration"
@@ -815,7 +815,7 @@ msgstr "Administration"
msgid "Max 50 MB"
msgstr "Max 50 Mo"
#: src/pages/UserPublicProfile.tsx:1603
#: src/pages/UserPublicProfile.tsx:1645
msgid "Moderator"
msgstr "Modérateur"
@@ -829,7 +829,7 @@ msgid "new"
msgstr "nouveau"
#: src/components/FeedTabBar.tsx:31
#: src/pages/UserPublicProfile.tsx:1295
#: src/pages/UserPublicProfile.tsx:1305
msgid "New"
msgstr "Nouveau"
@@ -837,7 +837,7 @@ msgstr "Nouveau"
#: src/components/DumpFab.tsx:65
#: src/components/DumpFab.tsx:66
#: src/pages/UserDumps.tsx:88
#: src/pages/UserPublicProfile.tsx:1382
#: src/pages/UserPublicProfile.tsx:1424
msgid "New dump"
msgstr "Nouvelle reco"
@@ -878,15 +878,15 @@ msgid "No emoji found."
msgstr "Aucun emoji trouvé."
#: src/pages/UserPlaylists.tsx:442
#: src/pages/UserPublicProfile.tsx:1119
#: src/pages/UserPublicProfile.tsx:1122
msgid "No followed playlists yet."
msgstr "Pas encore de collections suivies."
#: src/pages/UserPublicProfile.tsx:1164
#: src/pages/UserPublicProfile.tsx:1167
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
#: src/components/ChatModal.tsx:558
#: src/components/ChatModal.tsx:592
msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !"
@@ -900,7 +900,7 @@ msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/PlaylistMembershipPanel.tsx:34
#: src/pages/UserPlaylists.tsx:400
#: src/pages/UserPublicProfile.tsx:1030
#: src/pages/UserPublicProfile.tsx:1033
msgid "No playlists yet."
msgstr "Pas encore de collections."
@@ -908,14 +908,14 @@ msgstr "Pas encore de collections."
msgid "No users match \"{q}\"."
msgstr "Aucun utilisateur ne correspond à « {q} »."
#: src/pages/UserPublicProfile.tsx:1077
#: src/pages/UserPublicProfile.tsx:1080
msgid "Not following anyone yet."
msgstr "Aucun abonnement pour le moment."
#: src/pages/Notifications.tsx:381
#: src/pages/UserDumps.tsx:99
#: src/pages/UserPublicProfile.tsx:1393
#: src/pages/UserPublicProfile.tsx:1515
#: src/pages/UserPublicProfile.tsx:1435
#: src/pages/UserPublicProfile.tsx:1557
#: src/pages/UserUpvoted.tsx:159
msgid "Nothing here yet."
msgstr "Rien ici pour l'instant."
@@ -923,6 +923,7 @@ msgstr "Rien ici pour l'instant."
#: src/components/NotificationBell.tsx:42
#: src/pages/Notifications.tsx:265
#: src/pages/Notifications.tsx:359
#: src/pages/UserPublicProfile.tsx:1327
msgid "Notifications"
msgstr "Notifications"
@@ -930,6 +931,14 @@ msgstr "Notifications"
msgid "Notifications ({unreadNotificationCount} unread)"
msgstr "Notifications ({unreadNotificationCount} non lues)"
#: src/pages/UserPublicProfile.tsx:1351
msgid "Off"
msgstr "Désactivés"
#: src/pages/UserPublicProfile.tsx:1344
msgid "On"
msgstr "Activés"
#: src/components/SearchBar.tsx:96
msgid "Open search"
msgstr "Ouvrir la recherche"
@@ -943,7 +952,7 @@ msgid "Page not found"
msgstr "Page introuvable"
#: src/pages/UserLogin.tsx:76
#: src/pages/UserPublicProfile.tsx:1184
#: src/pages/UserPublicProfile.tsx:1187
msgid "Password"
msgstr "Mot de passe"
@@ -982,13 +991,13 @@ msgstr "Titre de la collection"
#: src/components/UserMenu.tsx:62
#: src/pages/Search.tsx:177
#: src/pages/UserPlaylists.tsx:371
#: src/pages/UserPublicProfile.tsx:949
#: src/pages/UserPublicProfile.tsx:952
msgid "Playlists"
msgstr "Collections"
#. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:999
#: src/pages/UserPublicProfile.tsx:1002
msgid "Playlists ({0}{1})"
msgstr "Collections ({0}{1})"
@@ -1090,7 +1099,7 @@ msgstr "Remplacer le fichier"
msgid "Reply"
msgstr "Répondre"
#: src/components/ChatModal.tsx:614
#: src/components/ChatModal.tsx:648
msgid "Replying to"
msgstr "En réponse à"
@@ -1121,7 +1130,7 @@ msgstr "Rétablir le titre suggéré"
msgid "Retry"
msgstr "Réessayer"
#: src/pages/UserPublicProfile.tsx:1658
#: src/pages/UserPublicProfile.tsx:1700
msgid "Role"
msgstr "Rôle"
@@ -1130,8 +1139,8 @@ msgstr "Rôle"
#: src/pages/Dump.tsx:398
#: src/pages/DumpEdit.tsx:487
#: src/pages/PlaylistDetail.tsx:930
#: src/pages/UserPublicProfile.tsx:1725
#: src/pages/UserPublicProfile.tsx:1798
#: src/pages/UserPublicProfile.tsx:1767
#: src/pages/UserPublicProfile.tsx:1840
msgid "Save"
msgstr "Enregistrer"
@@ -1140,8 +1149,8 @@ msgstr "Enregistrer"
#: src/pages/Dump.tsx:397
#: src/pages/PlaylistDetail.tsx:926
#: src/pages/ResetPassword.tsx:126
#: src/pages/UserPublicProfile.tsx:1722
#: src/pages/UserPublicProfile.tsx:1795
#: src/pages/UserPublicProfile.tsx:1764
#: src/pages/UserPublicProfile.tsx:1837
msgid "Saving…"
msgstr "Enregistrement…"
@@ -1163,7 +1172,7 @@ msgstr "Recherche échouée"
msgid "Searching…"
msgstr "Recherche…"
#: src/components/ChatModal.tsx:660
#: src/components/ChatModal.tsx:694
msgid "Send"
msgstr "Envoyer"
@@ -1184,15 +1193,15 @@ msgstr "Serveur inaccessible"
msgid "Set new password"
msgstr "Définir un nouveau mot de passe"
#: src/pages/UserPublicProfile.tsx:953
#: src/pages/UserPublicProfile.tsx:956
msgid "Settings"
msgstr "Paramètres"
#: src/components/ChatModal.tsx:535
#: src/components/ChatModal.tsx:569
msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…"
#: src/pages/UserPublicProfile.tsx:162
#: src/pages/UserPublicProfile.tsx:164
msgid "Single use, valid for 7 days."
msgstr "Usage unique, valable 7 jours."
@@ -1205,11 +1214,15 @@ msgstr "identifiant"
msgid "Something went wrong"
msgstr "Une erreur est survenue"
#: src/pages/UserPublicProfile.tsx:1332
msgid "Sounds"
msgstr "Sons"
#: src/components/DumpCreateModal.tsx:707
msgid "Start fresh"
msgstr "Repartir de zéro"
#: src/pages/UserPublicProfile.tsx:1203
#: src/pages/UserPublicProfile.tsx:1206
msgid "Style"
msgstr "Style"
@@ -1261,7 +1274,7 @@ msgstr "Aujourd'hui"
msgid "Track {0}"
msgstr "Piste {0}"
#: src/components/ChatModal.tsx:637
#: src/components/ChatModal.tsx:671
msgid "Type a message…"
msgstr "Écrivez un message…"
@@ -1277,7 +1290,7 @@ msgstr "Ne plus suivre {targetUsername}"
msgid "Unfollow playlist"
msgstr "Ne plus suivre la collection"
#: src/pages/UserPublicProfile.tsx:748
#: src/pages/UserPublicProfile.tsx:751
msgid "Upload failed"
msgstr "Envoi échoué"
@@ -1300,7 +1313,7 @@ msgstr "Voté"
#. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : ""
#: src/pages/UserPublicProfile.tsx:979
#: src/pages/UserPublicProfile.tsx:982
msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})"
@@ -1316,11 +1329,11 @@ msgstr "L'URL est obligatoire."
msgid "URL slug"
msgstr "Identifiant d’URL"
#: src/pages/UserPublicProfile.tsx:1602
#: src/pages/UserPublicProfile.tsx:1644
msgid "User"
msgstr "Utilisateur"
#: src/pages/UserPublicProfile.tsx:1321
#: src/pages/UserPublicProfile.tsx:1363
msgid "User management"
msgstr "Gestion utilisateur"
@@ -1337,11 +1350,11 @@ msgstr "Nom d'utilisateur"
msgid "Users"
msgstr "Utilisateurs"
#: src/pages/UserPublicProfile.tsx:1049
#: src/pages/UserPublicProfile.tsx:1092
#: src/pages/UserPublicProfile.tsx:1134
#: src/pages/UserPublicProfile.tsx:1414
#: src/pages/UserPublicProfile.tsx:1545
#: src/pages/UserPublicProfile.tsx:1052
#: src/pages/UserPublicProfile.tsx:1095
#: src/pages/UserPublicProfile.tsx:1137
#: src/pages/UserPublicProfile.tsx:1456
#: src/pages/UserPublicProfile.tsx:1587
msgid "View all →"
msgstr "Tout voir →"
@@ -1354,8 +1367,8 @@ msgstr "Voir la reco →"
msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?"
#: src/pages/UserPublicProfile.tsx:933
#: src/pages/UserPublicProfile.tsx:1787
#: src/pages/UserPublicProfile.tsx:936
#: src/pages/UserPublicProfile.tsx:1829
msgid "Who am I?"
msgstr "Qui suis-je ?"
@@ -1391,7 +1404,7 @@ msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vo
#: src/pages/index/FollowedFeed.tsx:116
#: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:67
#: src/pages/index/JournalFeed.tsx:78
#: src/pages/index/NewFeed.tsx:69
#: src/pages/Search.tsx:249
#: src/pages/UserDumps.tsx:126

View File

@@ -42,6 +42,8 @@ import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useTheme } from "../hooks/useTheme.ts";
import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { useSoundsEnabled } from "../hooks/useSoundsEnabled.ts";
import { previewChime } from "../utils/sound.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
@@ -235,6 +237,7 @@ export function UserPublicProfile() {
const navigate = useNavigate();
const { user: me, authFetch, login, logout, token } = useAuth();
const { style, colorScheme, setStyle, setColorScheme } = useTheme();
const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled();
const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
const {
voteCounts,
@@ -1319,6 +1322,38 @@ export function UserPublicProfile() {
</div>
</div>
</section>
<section className="profile-section">
<h2 className="profile-section-title">
<Trans>Notifications</Trans>
</h2>
<div className="profile-appearance-grid">
<div className="profile-appearance-row">
<span className="profile-appearance-label">
<Trans>Sounds</Trans>
</span>
<div className="visibility-toggle">
<button
type="button"
className={soundsEnabled ? "active" : ""}
onClick={() => {
setSoundsEnabled(true);
// Let the user hear what they just turned on.
previewChime("notify");
}}
>
<Trans>On</Trans>
</button>
<button
type="button"
className={soundsEnabled ? "" : "active"}
onClick={() => setSoundsEnabled(false)}
>
<Trans>Off</Trans>
</button>
</div>
</div>
</div>
</section>
</>
)}

123
src/utils/sound.ts Normal file
View File

@@ -0,0 +1,123 @@
// Light UI sounds for live notifications and chat, synthesised with Web Audio
// so there's no asset to ship or cache. Two voices: a single soft blip for a
// chat message, and a two-note rising chime for a notification (a mention, a
// follow, an upvote…).
const STORAGE_KEY = "sounds";
// Shared across tabs: the id of the last event that chimed, so a user with the
// site open in several tabs hears one chime per event rather than one per tab.
const CLAIM_KEY = "sounds-last-claim";
// A burst of chat messages shouldn't machine-gun blips; one per window is
// enough to say "the chat is moving".
const CHAT_THROTTLE = 4_000;
export type ChimeKind = "chat" | "notify";
export function readSoundsEnabled(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) !== "off";
} catch {
return true;
}
}
export function writeSoundsEnabled(enabled: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, enabled ? "on" : "off");
} catch {
// Private mode / blocked storage: the choice just won't persist.
}
}
let ctx: AudioContext | null = null;
let lastChatAt = 0;
function audio(): AudioContext | null {
if (ctx) return ctx;
try {
ctx = new AudioContext();
} catch {
return null;
}
return ctx;
}
// Browsers keep an AudioContext suspended until the page sees a user gesture.
// Resume it on the first one so a chime arriving later — possibly while the
// tab is in the background — can actually be heard.
function unlock() {
const ac = audio();
if (ac?.state === "suspended") void ac.resume();
globalThis.removeEventListener("pointerdown", unlock);
globalThis.removeEventListener("keydown", unlock);
}
globalThis.addEventListener("pointerdown", unlock);
globalThis.addEventListener("keydown", unlock);
function claim(eventId: string): boolean {
try {
if (localStorage.getItem(CLAIM_KEY) === eventId) return false;
localStorage.setItem(CLAIM_KEY, eventId);
} catch {
// No storage: every tab chimes, which is the lesser evil.
}
return true;
}
function tone(
ac: AudioContext,
freq: number,
start: number,
duration: number,
peak: number,
) {
const osc = ac.createOscillator();
const gain = ac.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(freq, start);
// Fast attack, exponential tail: a struck, glassy note rather than a beep.
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
osc.connect(gain).connect(ac.destination);
osc.start(start);
osc.stop(start + duration + 0.02);
}
function render(kind: ChimeKind) {
const ac = audio();
if (!ac || ac.state !== "running") return;
const now = ac.currentTime;
if (kind === "chat") {
tone(ac, 1046.5, now, 0.14, 0.05); // C6
} else {
tone(ac, 659.25, now, 0.22, 0.07); // E5
tone(ac, 987.77, now + 0.09, 0.32, 0.06); // B5
}
}
/**
* Plays a chime for a live event, unless sounds are off, another tab already
* chimed for the same event, or (for chat) one played a moment ago.
*/
export function playChime(kind: ChimeKind, eventId: string): void {
if (!readSoundsEnabled()) return;
if (kind === "chat") {
const now = Date.now();
if (now - lastChatAt < CHAT_THROTTLE) return;
lastChatAt = now;
}
if (!claim(`${kind}:${eventId}`)) return;
render(kind);
}
/** Plays a chime unconditionally — used to preview it from the settings. */
export function previewChime(kind: ChimeKind): void {
const ac = audio();
if (!ac) return;
if (ac.state === "suspended") {
void ac.resume().then(() => render(kind));
} else {
render(kind);
}
}