v3: the chat stops dropping things and holds up to long messages — a message sent while the socket was down vanished with the draft already cleared, and a rejected one fared no better since the client ignored any error that wasn't about a vote or a like. Sends now carry a nonce and sit dimmed in the list until their echo retires them; the server's refusal, an offline socket or ten seconds of silence turns one into a failed row with retry, edit and discard, and a failed row that turns up in the history after a reconnect is retired as having landed after all. Failed edits and deletes show in a bar above the composer. The live stream only goes to signed-in sockets, matching the history behind auth; sends, edits and deletes share a per-user token bucket (typing and read markers get their own, and are just dropped past it); and moderation re-reads the role from the database on every check rather than trusting the one a week-old socket captured. Unread now lives on the server as users.chat_read_at (migration 0014, existing users start caught up), so the count survives a reload and every tab and device clears together; opening onto unread history lands on a "new" divider rather than the bottom, and marks it read only once you've scrolled down to it. After a reconnect the chat re-reads everything from its oldest loaded message and takes that as the truth, edits and deletions included, or reloads the latest page if too much happened. History pages on (created_at, id) so a shared millisecond can't hide a message at a boundary. In the modal: a run of messages from one author shares a header with the time in the gutter on hover, mentions and replies to you get an accent edge, a pill counts what arrived while you were scrolled up, the draft survives closing and reloading, relative times tick, reply previews are flattened from markdown, owners can delete their own messages, ↑ in an empty composer edits your last one, and there's a proper loading and retry state instead of a flash of "No messages yet". Long messages: the composer and edit box grow only so far and then scroll, instead of squeezing the list away; the counter shows only near the limit and sits above the box, since .comment-reply-textarea's padding shorthand had quietly cancelled the room kept for it inside; TextEditor sizes itself before paint, so opening an edit animates to the right height instead of dipping and snapping; and the list follows the bottom through any change in its content's height while pinned there, so ending an edit returns you to the end. A mention notification opens the chat over the page you're on rather than the home page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-22 20:36:49 +00:00
parent b313f70d31
commit 73b80af08e
20 changed files with 2091 additions and 548 deletions

View File

@@ -4169,10 +4169,19 @@ body.has-player .chat-fab {
gap: 0.75rem;
}
.chat-messages-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat-messages {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
/* Reserve a gutter for the scrollbar so it never sits on top of the
messages — without this, overlay scrollbars overlap the content. The extra
padding keeps the text clear of the scrollbar rather than right against it. */
@@ -4183,10 +4192,19 @@ body.has-player .chat-fab {
scrollbar-color: transparent transparent;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding-right: 0.6rem;
}
/* One box around everything the list scrolls, so its height can be watched as
a whole (see the ResizeObserver in ChatModal). Grows to fill the list, which
keeps the empty state centered. */
.chat-messages-content {
flex: 1 0 auto;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.chat-messages:hover,
.chat-messages:focus-within {
scrollbar-color: color-mix(in srgb, var(--color-text-muted) 45%, transparent)
@@ -4215,6 +4233,14 @@ body.has-player .chat-fab {
margin: auto 0;
text-align: center;
color: var(--color-text-muted);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
.chat-empty p {
margin: 0;
}
.chat-load-older {
@@ -4251,6 +4277,7 @@ body.has-player .chat-fab {
/* Brief highlight when a message is jumped to via a reply reference. */
.chat-message {
position: relative;
border-radius: var(--radius-lg);
transition: background-color 0.4s ease;
}
@@ -4259,6 +4286,100 @@ body.has-player .chat-fab {
background-color: color-mix(in srgb, var(--color-accent) 18%, transparent);
}
/* A run of messages from one author: the follow-ups drop the avatar and the
author line and sit close under the first, pulling back most of the list's
gap. The avatar column shows the time instead, on hover. */
.chat-message--grouped {
margin-top: -0.6rem;
}
.chat-message-gutter {
flex: 0 0 32px;
/* The time may be wider than the column; let it spill evenly both sides
rather than widen the column and push the text out of line. */
min-width: 0;
display: flex;
justify-content: center;
/* Drawn, but no height of its own: the tooltip wrapper around the time is
taller than a line of text and would pad every follow-up message. */
height: 0;
overflow: visible;
white-space: nowrap;
padding-top: 0.2rem;
font-size: var(--text-2xs);
line-height: 1.2;
color: var(--color-text-muted);
opacity: 0;
transition: opacity var(--dur-base);
}
.chat-message:hover .chat-message-gutter,
.chat-message:focus-within .chat-message-gutter {
opacity: 1;
}
/* With no meta row to trail, a grouped message's actions float at its top
right, over a patch of surface so they stay legible over the text. */
.chat-message-actions--float {
position: absolute;
top: -0.35rem;
right: 0.25rem;
margin: 0;
padding: 0.1rem 0.25rem;
border-radius: var(--radius-sm);
background: var(--color-surface);
box-shadow: 0 0 0 1px var(--color-border-subtle);
}
/* Messages that mention you (or reply to you) carry an accent edge. */
.chat-message--mention {
background-color: color-mix(in srgb, var(--color-accent) 7%, transparent);
box-shadow: inset 2px 0 0 var(--color-accent);
padding: 0.25rem 0.35rem 0.25rem 0.5rem;
margin-left: -0.5rem;
}
.chat-message--grouped.chat-message--mention {
margin-top: -0.85rem;
}
/* A message on its way: dimmed until the server echoes it back. */
.chat-message--pending .chat-message-text {
opacity: 0.55;
}
.chat-message--failed .chat-message-text {
opacity: 0.75;
}
.chat-pending-status {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.2rem 0.6rem;
margin-top: 0.15rem;
font-size: var(--text-2xs);
color: var(--color-text-muted);
}
.chat-pending-error {
color: var(--color-danger);
}
.chat-text-btn {
padding: 0;
background: none;
border: none;
font: inherit;
font-weight: 600;
color: var(--color-accent);
cursor: pointer;
}
.chat-text-btn:hover {
text-decoration: underline;
}
/* Subtle day separator: a hairline rule with a centered, muted label. */
.chat-day-sep {
display: flex;
@@ -4283,6 +4404,70 @@ body.has-player .chat-fab {
letter-spacing: 0.03em;
}
/* Where the unread messages start — the day separator's rule, in accent. */
.chat-new-sep {
color: var(--color-accent);
}
.chat-new-sep::before,
.chat-new-sep::after {
background: currentColor;
opacity: 0.6;
}
/* Scrolled up while messages arrive: a pill over the list's foot says how
many, and takes you down to them. */
.chat-new-pill {
position: absolute;
left: 50%;
bottom: 0.5rem;
transform: translateX(-50%);
padding: 0.3rem 0.8rem;
border: none;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-on-accent);
font: inherit;
font-size: var(--text-xs);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
box-shadow: var(--shadow-2);
animation: chat-msg-in 0.18s ease-out;
}
.chat-new-pill:hover {
background: var(--color-accent-hover);
}
/* Connection trouble and failed edits/deletes, in a slim bar. */
.chat-status,
.chat-error {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
padding: 0.35rem 0.6rem;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
}
.chat-status {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
color: var(--color-text-muted);
}
.chat-error {
border-left: 2px solid var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
color: var(--color-text);
}
.chat-error-text {
flex: 1;
min-width: 0;
}
.chat-message-body {
flex: 1;
min-width: 0;
@@ -4402,22 +4587,28 @@ body.has-player .chat-fab {
min-width: 0;
}
/* Reserve room on the input's bottom-right for the char counter. `display:
block` removes the inline-block baseline descender gap below the textarea —
that phantom space was making the wrapper taller than the textarea, so the
bottom-aligned Send button landed a few px low. */
.chat-input {
padding-right: 3.5rem;
/* `display: block` removes the inline-block baseline descender gap below the
textarea — that phantom space was making the wrapper taller than the
textarea, so the bottom-aligned Send button landed a few px low.
A long message grows the box only so far, then scrolls inside it, so the
composer can't squeeze the message list away and an edit box can't outgrow
the chat. (Qualified to outrank .comment-reply-textarea's overflow.) */
.comment-reply-textarea.chat-input {
display: block;
max-height: min(35vh, 15rem);
overflow-y: auto;
scrollbar-width: thin;
}
/* Pin the counter inside the textarea instead of letting it add a row below —
that extra height is what knocked the Send button out of alignment. */
/* The counter (only shown near the limit) sits just above the input's right
edge, in the gap over the composer: inside the textarea, the text ran under
it, and in a row below it would knock the Send button out of alignment. */
.chat-composer .text-editor-count {
position: absolute;
right: 0.55rem;
bottom: 0.4rem;
right: 0.25rem;
bottom: calc(100% + 1px);
margin: 0;
line-height: 1;
}
.chat-send-btn {

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import {
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
@@ -19,7 +20,9 @@ import { useAuth } from "../hooks/useAuth.ts";
import { API_URL } from "../config/api.ts";
export interface TextEditorHandle {
focus(): void;
/** `atEnd` puts the caret after the last character (and scrolls the text
* there); `preventScroll` leaves the page and its scrollers where they are. */
focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void;
}
interface TextEditorProps {
@@ -34,6 +37,9 @@ interface TextEditorProps {
onSubmit?: () => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
maxLength?: number;
/** Hide the character counter until the text is at least this long, for
* places where a standing "0 / 2000" is just noise. */
countFrom?: number;
}
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
@@ -50,6 +56,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
onSubmit,
onKeyDown,
maxLength,
countFrom = 0,
},
ref,
) {
@@ -67,7 +74,15 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
}, [value]);
useImperativeHandle(ref, () => ({
focus: () => textareaRef.current?.focus(),
focus: (options) => {
const el = textareaRef.current;
if (!el) return;
el.focus({ preventScroll: options?.preventScroll });
if (options?.atEnd) {
el.setSelectionRange(el.value.length, el.value.length);
el.scrollTop = el.scrollHeight;
}
},
}), []);
const {
@@ -110,7 +125,9 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
maxContentHeight: 220,
});
useEffect(() => {
// Before paint, so the first frame — and anything measuring the layout in
// its own layout effect, like a height transition — sees the final size.
useLayoutEffect(() => {
if (!autoResize) return;
const el = textareaRef.current;
if (!el) return;
@@ -229,7 +246,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
id={id}
className={className}
/>
{maxLength != null && (
{maxLength != null && value.length >= countFrom && (
<span
className={`text-editor-count${
charCountClass(value.length, maxLength)

View File

@@ -40,6 +40,30 @@ export interface UserEvent {
user: PublicUser;
}
/** The replied-to message, as shown in the reply bar and on a pending send. */
export interface ChatReplyTarget {
id: string;
author: string;
body: string;
}
/** A message this client sent that the server hasn't echoed back yet — shown
* in the list as "sending", or "failed" with a way to retry or discard it. */
export interface PendingChatMessage {
nonce: string;
body: string;
replyTo?: ChatReplyTarget;
createdAt: Date;
status: "sending" | "failed";
error?: string;
}
/** A failed chat edit or delete, surfaced in the chat until dismissed. */
export interface ChatError {
id: number;
message: string;
}
export interface WSContextValue {
wsStatus: "connecting" | "connected" | "disconnected";
wsErrorMessage: string | null;
@@ -64,7 +88,14 @@ export interface WSContextValue {
/** Ids of chat messages deleted live this session, so fetched history can
* filter them out. */
deletedChatIds: Set<string>;
/** Messages from others since the read marker — kept by the server, so it
* survives reloads and agrees across tabs and devices. */
unreadChatCount: number;
/** The server's read marker: created_at of the newest message the user has
* seen. Null until the socket's welcome. */
chatReadAt: Date | null;
pendingChatMessages: PendingChatMessage[];
chatError: ChatError | null;
/** Users currently composing a chat message (excludes nobody — the chatbox
* filters out the current user itself). Shaped like presence for avatars. */
typingUsers: OnlineUser[];
@@ -76,10 +107,18 @@ export interface WSContextValue {
removeCommentLike: (commentId: string) => void;
injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void;
sendChatMessage: (body: string, replyToId?: string) => void;
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
retryChatMessage: (nonce: string) => void;
discardChatMessage: (nonce: string) => void;
editChatMessage: (id: string, body: string) => void;
deleteChatMessage: (id: string) => void;
clearUnreadChat: () => void;
dismissChatError: () => void;
/** Moves the read marker up to the message created at `at`. */
markChatRead: (at: Date) => void;
/** Folds an authoritative fetch of the stream between `from` and `to` into
* the live buffer after a reconnect: buffered messages in that range which
* the server no longer has were deleted while the socket was down. */
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
/** Inform the server whether the user is currently composing a message, so it
* can show/hide the "is typing" indicator to other clients. */
setChatTyping: (typing: boolean) => void;
@@ -110,6 +149,9 @@ export const WSContext = createContext<WSContextValue>({
chatMessages: [],
deletedChatIds: new Set(),
unreadChatCount: 0,
chatReadAt: null,
pendingChatMessages: [],
chatError: null,
typingUsers: [],
connectionEpoch: 0,
castVote: () => {},
@@ -119,9 +161,13 @@ export const WSContext = createContext<WSContextValue>({
injectDump: () => {},
clearUnreadNotifications: () => {},
sendChatMessage: () => {},
retryChatMessage: () => {},
discardChatMessage: () => {},
editChatMessage: () => {},
deleteChatMessage: () => {},
clearUnreadChat: () => {},
dismissChatError: () => {},
markChatRead: () => {},
reconcileChat: () => {},
setChatTyping: () => {},
setChatOpen: () => {},
});

View File

@@ -8,8 +8,11 @@ import {
useState,
} from "react";
import {
type ChatError,
type ChatReplyTarget,
type CommentEvent,
type LikeEvent,
type PendingChatMessage,
type PlaylistEvent,
type UserEvent,
type VoteEvent,
@@ -41,6 +44,7 @@ import { t } from "@lingui/core/macro";
import { useAuth } from "../hooks/useAuth.ts";
import { setTitleBadge } from "../hooks/useDocumentTitle.ts";
import { playChime } from "../utils/sound.ts";
import { mentionsUser } from "../utils/chatText.ts";
interface WSProviderProps {
children: ReactNode;
@@ -56,17 +60,13 @@ const MAX_RECENT_DUMPS = 100;
// Cap the live chat buffer so a long-lived session can't grow it without bound.
// 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;
}
// How long a sent chat message may go without its echo before it's shown as
// failed, and how often the read marker may go out.
const CHAT_SEND_TIMEOUT = 10_000;
const CHAT_READ_THROTTLE = 1_000;
// A failed send whose text turns up in the resynced history within this long
// of being sent did reach the server — only its echo was lost.
const CHAT_ECHO_WINDOW = 60_000;
interface PendingVote {
timeout: ReturnType<typeof setTimeout>;
@@ -132,6 +132,11 @@ export function WSProvider({ children }: WSProviderProps) {
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
const [unreadChatCount, setUnreadChatCount] = useState(0);
const [chatReadAt, setChatReadAt] = useState<Date | null>(null);
const [pendingChatMessages, setPendingChatMessages] = useState<
PendingChatMessage[]
>([]);
const [chatError, setChatError] = useState<ChatError | null>(null);
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
const [avatarOverrides, setAvatarOverrides] = useState<
@@ -170,6 +175,9 @@ export function WSProvider({ children }: WSProviderProps) {
setChatMessages([]);
setDeletedChatIds(new Set());
setUnreadChatCount(0);
setChatReadAt(null);
setPendingChatMessages([]);
setChatError(null);
setTypingUsers([]);
setAvatarOverrides({});
}
@@ -205,6 +213,34 @@ export function WSProvider({ children }: WSProviderProps) {
() => chatOpenRef.current && !document.hidden,
[],
);
// Echo timeouts of in-flight chat sends, by nonce.
const chatSendTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
);
const chatErrorSeqRef = useRef(0);
// Read marker bookkeeping: the newest marker sent (or learned from the
// server), and a trailing timer so a burst of arrivals sends one marker.
const chatReadSentRef = useRef(0);
const chatReadQueuedRef = useRef(0);
const chatReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const chatReadLastSendRef = useRef(0);
const failPendingChat = useCallback((nonce: string, error: string) => {
const timer = chatSendTimersRef.current.get(nonce);
if (timer) clearTimeout(timer);
chatSendTimersRef.current.delete(nonce);
setPendingChatMessages((prev) =>
prev.map((p) => p.nonce === nonce ? { ...p, status: "failed", error } : p)
);
}, []);
const retirePendingChat = useCallback((nonce: string) => {
const timer = chatSendTimersRef.current.get(nonce);
if (timer) clearTimeout(timer);
chatSendTimersRef.current.delete(nonce);
setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce));
}, []);
// Tracks pending optimistic votes: dumpId → pending rollback handler
const pendingRef = useRef<Map<string, PendingVote>>(
new Map(),
@@ -321,6 +357,15 @@ export function WSProvider({ children }: WSProviderProps) {
setMyVotes(new Set(msg.myVotes));
setMyLikes(new Set(msg.myCommentLikes));
setUnreadNotificationCount(msg.unreadNotificationCount);
setUnreadChatCount(msg.unreadChatCount);
if (msg.chatReadAt) {
const readAt = new Date(msg.chatReadAt);
setChatReadAt(readAt);
chatReadSentRef.current = Math.max(
chatReadSentRef.current,
readAt.getTime(),
);
}
// welcome provides authoritative server state — cancel any
// in-flight revert timers, they are now superseded.
clearAllPendingVotes();
@@ -328,13 +373,37 @@ export function WSProvider({ children }: WSProviderProps) {
// Reconnect (not the first welcome): signal pages to backfill the
// dumps/comments they couldn't receive while disconnected.
welcomeCount += 1;
if (welcomeCount > 1) setConnectionEpoch((n) => n + 1);
if (welcomeCount > 1) {
setConnectionEpoch((n) => n + 1);
// With the chatbox closed nothing will resync the live buffer,
// and whatever was edited or deleted meanwhile would win over
// the fresh history it fetches on open — so just drop it.
if (!chatOpenRef.current) {
setChatMessages([]);
setDeletedChatIds(new Set());
}
}
// A marker that was waiting on the socket goes out now.
if (chatReadQueuedRef.current > chatReadSentRef.current) {
ws.send(
JSON.stringify(
{
type: "chat_read",
at: new Date(chatReadQueuedRef.current).toISOString(),
} satisfies OutgoingWSMessage,
),
);
chatReadSentRef.current = chatReadQueuedRef.current;
}
// Restore the chat-open flag on the fresh connection so mention
// suppression keeps working across reconnects.
if (isViewingChat()) {
ws.send(
JSON.stringify(
{ type: "chat_focus", open: true } satisfies OutgoingWSMessage,
{
type: "chat_focus",
open: true,
} satisfies OutgoingWSMessage,
),
);
}
@@ -528,9 +597,11 @@ export function WSProvider({ children }: WSProviderProps) {
if (prev.some((m) => m.id === message.id)) return prev;
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
});
// Count as unread unless it's the current user's own message.
if (msg.nonce) retirePendingChat(msg.nonce);
// Count as unread unless it's the current user's own message, or
// they're looking at it (the chatbox then moves the read marker).
if (message.userId !== userIdRef.current) {
setUnreadChatCount((prev) => prev + 1);
if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1);
const username = usernameRef.current;
if (username && mentionsUser(message.body, username)) {
// Someone reading the chat gets no notification for a mention
@@ -567,11 +638,28 @@ export function WSProvider({ children }: WSProviderProps) {
setTypingUsers(msg.users);
break;
case "chat_read_update": {
const readAt = new Date(msg.readAt);
chatReadSentRef.current = Math.max(
chatReadSentRef.current,
readAt.getTime(),
);
setChatReadAt(readAt);
setUnreadChatCount(msg.unread);
break;
}
case "force_logout":
onForceLogoutRef.current();
break;
case "error": {
if (msg.scope === "chat") {
const message = msg.message ?? t`Something went wrong`;
if (msg.nonce) failPendingChat(msg.nonce, message);
else setChatError({ id: ++chatErrorSeqRef.current, message });
break;
}
// When the server identifies the failed optimistic action, revert
// its pending vote/like right away instead of waiting out the ACK
// timeout. Without context, the per-action timer handles cleanup.
@@ -601,6 +689,15 @@ export function WSProvider({ children }: WSProviderProps) {
connectTimeout = null;
}
if (closed) return;
// Sends still waiting on their echo won't get one on this socket.
// They may have landed, though: the resync after reconnect retires
// any whose text shows up in the history.
for (const nonce of chatSendTimersRef.current.keys()) {
failPendingChat(
nonce,
t`Connection lost — this message may not have been sent`,
);
}
setWSStatus("disconnected");
setWSErrorMessage(
everConnected
@@ -642,7 +739,9 @@ export function WSProvider({ children }: WSProviderProps) {
clearPendingVote,
clearAllPendingLikes,
clearPendingLike,
failPendingChat,
isViewingChat,
retirePendingChat,
token,
]);
@@ -798,21 +897,80 @@ export function WSProvider({ children }: WSProviderProps) {
setUnreadNotificationCount(0);
}, []);
const sendChatMessage = useCallback((body: string, replyToId?: string) => {
const trimmed = body.trim();
if (!trimmed) return;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{
type: "chat_send",
body: trimmed,
...(replyToId ? { replyToId } : {}),
} satisfies OutgoingWSMessage,
),
);
// Sends (or resends) a pending message: it shows as "sending" until its echo
// arrives, or as failed if the socket is down, the server rejects it, or no
// echo comes back in time.
const transmitPendingChat = useCallback((pending: PendingChatMessage) => {
const socket = socketRef.current;
if (socket?.readyState !== WebSocket.OPEN) {
failPendingChat(pending.nonce, t`You're offline — not sent`);
return;
}
}, []);
socket.send(
JSON.stringify(
{
type: "chat_send",
body: pending.body,
nonce: pending.nonce,
...(pending.replyTo ? { replyToId: pending.replyTo.id } : {}),
} satisfies OutgoingWSMessage,
),
);
chatSendTimersRef.current.set(
pending.nonce,
setTimeout(
() =>
failPendingChat(
pending.nonce,
t`No reply from the server — this message may not have been sent`,
),
CHAT_SEND_TIMEOUT,
),
);
}, [failPendingChat]);
const sendChatMessage = useCallback(
(body: string, replyTo?: ChatReplyTarget) => {
const trimmed = body.trim();
if (!trimmed) return;
const pending: PendingChatMessage = {
nonce: crypto.randomUUID(),
body: trimmed,
replyTo,
createdAt: new Date(),
status: "sending",
};
setPendingChatMessages((prev) => [...prev, pending]);
transmitPendingChat(pending);
},
[transmitPendingChat],
);
const pendingChatRef = useRef(pendingChatMessages);
useLayoutEffect(() => {
pendingChatRef.current = pendingChatMessages;
});
const retryChatMessage = useCallback((nonce: string) => {
const pending = pendingChatRef.current.find((p) => p.nonce === nonce);
if (!pending) return;
// A fresh nonce: should the first attempt's echo still turn up, it mustn't
// retire the retry.
const retry: PendingChatMessage = {
...pending,
nonce: crypto.randomUUID(),
status: "sending",
error: undefined,
};
setPendingChatMessages((prev) =>
prev.map((p) => (p.nonce === nonce ? retry : p))
);
transmitPendingChat(retry);
}, [transmitPendingChat]);
const discardChatMessage = useCallback((nonce: string) => {
retirePendingChat(nonce);
}, [retirePendingChat]);
const editChatMessage = useCallback((id: string, body: string) => {
const trimmed = body.trim();
@@ -836,10 +994,71 @@ export function WSProvider({ children }: WSProviderProps) {
}
}, []);
const clearUnreadChat = useCallback(() => {
setUnreadChatCount(0);
const dismissChatError = useCallback(() => setChatError(null), []);
const flushChatRead = useCallback(() => {
chatReadTimerRef.current = null;
const at = chatReadQueuedRef.current;
if (at <= chatReadSentRef.current) return;
const socket = socketRef.current;
// Offline: stays queued, and the next welcome sends it.
if (socket?.readyState !== WebSocket.OPEN) return;
socket.send(
JSON.stringify(
{
type: "chat_read",
at: new Date(at).toISOString(),
} satisfies OutgoingWSMessage,
),
);
chatReadSentRef.current = at;
chatReadLastSendRef.current = Date.now();
}, []);
const markChatRead = useCallback((at: Date) => {
const time = at.getTime();
if (time <= Math.max(chatReadSentRef.current, chatReadQueuedRef.current)) {
return;
}
chatReadQueuedRef.current = time;
// The badge goes at once; the server's count follows with the marker.
setUnreadChatCount(0);
if (chatReadTimerRef.current) return;
const wait = Math.max(
0,
CHAT_READ_THROTTLE - (Date.now() - chatReadLastSendRef.current),
);
chatReadTimerRef.current = setTimeout(flushChatRead, wait);
}, [flushChatRead]);
useEffect(() => () => {
if (chatReadTimerRef.current) clearTimeout(chatReadTimerRef.current);
for (const timer of chatSendTimersRef.current.values()) clearTimeout(timer);
}, []);
const reconcileChat = useCallback(
(messages: ChatMessage[], from: Date, to: Date) => {
const ids = new Set(messages.map((m) => m.id));
setChatMessages((prev) =>
prev.filter((m) =>
ids.has(m.id) || m.createdAt < from || m.createdAt > to
)
);
// Failed sends that did land (only the echo was lost) are retired.
const mine = messages.filter((m) => m.userId === userIdRef.current);
for (const pending of pendingChatRef.current) {
if (pending.status !== "failed") continue;
const landed = mine.some((m) =>
m.body === pending.body &&
Math.abs(m.createdAt.getTime() - pending.createdAt.getTime()) <
CHAT_ECHO_WINDOW
);
if (landed) retirePendingChat(pending.nonce);
}
},
[retirePendingChat],
);
const setChatTyping = useCallback((typing: boolean) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
@@ -906,6 +1125,9 @@ export function WSProvider({ children }: WSProviderProps) {
chatMessages,
deletedChatIds,
unreadChatCount,
chatReadAt,
pendingChatMessages,
chatError,
typingUsers,
connectionEpoch,
castVote,
@@ -915,9 +1137,13 @@ export function WSProvider({ children }: WSProviderProps) {
injectDump,
clearUnreadNotifications,
sendChatMessage,
retryChatMessage,
discardChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
dismissChatError,
markChatRead,
reconcileChat,
setChatTyping,
setChatOpen,
}), [
@@ -942,6 +1168,9 @@ export function WSProvider({ children }: WSProviderProps) {
chatMessages,
deletedChatIds,
unreadChatCount,
chatReadAt,
pendingChatMessages,
chatError,
typingUsers,
connectionEpoch,
castVote,
@@ -951,9 +1180,13 @@ export function WSProvider({ children }: WSProviderProps) {
injectDump,
clearUnreadNotifications,
sendChatMessage,
retryChatMessage,
discardChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
dismissChatError,
markChatRead,
reconcileChat,
setChatTyping,
setChatOpen,
]);

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:568
#: src/components/ChatModal.tsx:1022
msgid "{0} and {1} are typing…"
msgstr "{0} and {1} are typing…"
#. placeholder {0}: typingNames[0]
#: src/components/ChatModal.tsx:566
#: src/components/ChatModal.tsx:1020
msgid "{0} is typing…"
msgstr "{0} is typing…"
@@ -54,6 +54,10 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
msgid "{label} ({count})"
msgstr "{label} ({count})"
#: src/components/ChatModal.tsx:1149
msgid "{unseenCount} new messages"
msgstr "{unseenCount} new messages"
#: src/components/CommentThread.tsx:470
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
@@ -134,6 +138,10 @@ msgstr "<0>{0}</0> upvoted <1>{1}</1>"
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> was added to <1>{1}</1>"
#: src/components/ChatModal.tsx:1148
msgid "1 new message"
msgstr "1 new message"
#: src/pages/Notifications.tsx:217
msgid "a comment"
msgstr "a comment"
@@ -203,14 +211,14 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Back to login"
#: src/contexts/WSProvider.tsx:292
#: src/contexts/WSProvider.tsx:608
#: src/contexts/WSProvider.tsx:328
#: src/contexts/WSProvider.tsx:705
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."
#: src/components/CategoryManager.tsx:210
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:231
#: src/components/ChatModal.tsx:210
#: src/components/CommentThread.tsx:127
#: src/components/ConfirmModal.tsx:34
#: src/components/DumpCreateModal.tsx:946
@@ -227,8 +235,8 @@ msgstr "Cancel"
msgid "Cancel removal"
msgstr "Cancel removal"
#: src/components/ChatModal.tsx:658
#: src/components/ChatModal.tsx:659
#: src/components/ChatModal.tsx:1209
#: src/components/ChatModal.tsx:1210
msgid "Cancel reply"
msgstr "Cancel reply"
@@ -261,7 +269,7 @@ msgstr "Change password…"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:572
#: src/components/ChatModal.tsx:1111
msgid "Chat"
msgstr "Chat"
@@ -304,6 +312,10 @@ msgstr "Color scheme"
msgid "Confirm new password"
msgstr "Confirm new password"
#: src/contexts/WSProvider.tsx:698
msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent"
#: src/pages/UserPublicProfile.tsx:161
msgid "Copied!"
msgstr "Copied!"
@@ -328,6 +340,10 @@ msgstr "Could not save"
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
#: src/components/ChatModal.tsx:1037
msgid "Couldn't load the chat."
msgstr "Couldn't load the chat."
#: src/components/PlaylistCreateForm.tsx:91
msgid "Create"
msgstr "Create"
@@ -360,9 +376,9 @@ msgstr "Default tab"
#: src/components/CategoryManager.tsx:221
#: src/components/CategoryManager.tsx:222
#: src/components/ChatModal.tsx:185
#: src/components/ChatModal.tsx:186
#: src/components/ChatModal.tsx:241
#: src/components/ChatModal.tsx:322
#: src/components/ChatModal.tsx:323
#: src/components/ChatModal.tsx:417
#: src/components/CommentThread.tsx:390
#: src/components/CommentThread.tsx:396
#: src/components/ConfirmModal.tsx:17
@@ -398,7 +414,7 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
#: src/components/ChatModal.tsx:240
#: src/components/ChatModal.tsx:416
msgid "Delete this message?"
msgstr "Delete this message?"
@@ -407,7 +423,7 @@ msgstr "Delete this message?"
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
#: src/components/ChatModal.tsx:131
#: src/components/ChatModal.tsx:244
msgid "deleted message"
msgstr "deleted message"
@@ -416,6 +432,19 @@ msgstr "deleted message"
msgid "Description (optional)"
msgstr "Description (optional)"
#: src/components/ChatModal.tsx:502
msgid "Discard"
msgstr "Discard"
#: src/components/ChatModal.tsx:1115
msgid "Disconnected — reconnecting…"
msgstr "Disconnected — reconnecting…"
#: src/components/ChatModal.tsx:1185
#: src/components/ChatModal.tsx:1186
msgid "Dismiss"
msgstr "Dismiss"
#: src/components/DumpCreateModal.tsx:742
msgid "Done"
msgstr "Done"
@@ -460,8 +489,9 @@ msgstr "Dumps ({0}{1})"
msgid "Earlier"
msgstr "Earlier"
#: src/components/ChatModal.tsx:174
#: src/components/ChatModal.tsx:175
#: src/components/ChatModal.tsx:311
#: src/components/ChatModal.tsx:312
#: src/components/ChatModal.tsx:495
#: src/components/CommentThread.tsx:381
#: src/pages/Dump.tsx:520
#: src/pages/PlaylistDetail.tsx:627
@@ -477,10 +507,14 @@ msgstr "Edit {0}"
msgid "Edit title"
msgstr "Edit title"
#: src/components/ChatModal.tsx:335
msgid "edited"
msgstr "edited"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:154
#: src/components/ChatModal.tsx:336
#: src/components/CommentThread.tsx:330
#: src/pages/Dump.tsx:459
#: src/pages/PlaylistDetail.tsx:666
@@ -490,7 +524,7 @@ msgstr "edited {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:152
#: src/components/ChatModal.tsx:332
#: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:457
#: src/pages/PlaylistDetail.tsx:663
@@ -665,6 +699,10 @@ msgstr "Hot"
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "If that address is registered you'll receive a reset link shortly."
#: src/components/ChatModal.tsx:81
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:554
msgid "In collections"
msgstr "In collections"
@@ -716,7 +754,7 @@ msgstr "Link"
msgid "Link or file"
msgstr "Link or file"
#: src/contexts/WSProvider.tsx:607
#: src/contexts/WSProvider.tsx:704
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -729,7 +767,7 @@ msgstr "Live updates unavailable."
msgid "Load more"
msgstr "Load more"
#: src/components/ChatModal.tsx:585
#: src/components/ChatModal.tsx:1135
msgid "Load older messages"
msgstr "Load older messages"
@@ -738,6 +776,10 @@ msgstr "Load older messages"
msgid "Loading dump…"
msgstr "Loading dump…"
#: src/components/ChatModal.tsx:1029
msgid "Loading messages…"
msgstr "Loading messages…"
#: src/pages/index/FollowedFeed.tsx:111
#: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:73
@@ -759,9 +801,9 @@ msgid "Loading profile…"
msgstr "Loading profile…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:584
#: src/components/ChatModal.tsx:1134
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:311
#: src/components/TextEditor.tsx:315
#: src/components/UserListPopover.tsx:192
#: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:78
@@ -828,6 +870,7 @@ msgstr "Name"
msgid "new"
msgstr "new"
#: src/components/ChatModal.tsx:1064
#: src/components/FeedTabBar.tsx:31
#: src/pages/UserPublicProfile.tsx:1305
msgid "New"
@@ -873,7 +916,7 @@ msgstr "No dumps match \"{q}\"."
msgid "No dumps yet. Be the first!"
msgstr "No dumps yet. Be the first!"
#: src/components/TextEditor.tsx:314
#: src/components/TextEditor.tsx:318
msgid "No emoji found."
msgstr "No emoji found."
@@ -886,7 +929,7 @@ msgstr "No followed playlists yet."
msgid "No invitees yet."
msgstr "No invitees yet."
#: src/components/ChatModal.tsx:592
#: src/components/ChatModal.tsx:1048
msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!"
@@ -904,6 +947,10 @@ msgstr "No playlists match \"{q}\"."
msgid "No playlists yet."
msgstr "No playlists yet."
#: src/contexts/WSProvider.tsx:925
msgid "No reply from the server — this message may not have been sent"
msgstr "No reply from the server — this message may not have been sent"
#: src/pages/Search.tsx:257
msgid "No users match \"{q}\"."
msgstr "No users match \"{q}\"."
@@ -1093,13 +1140,13 @@ msgstr "Remove vote"
msgid "Replace file"
msgstr "Replace file"
#: src/components/ChatModal.tsx:164
#: src/components/ChatModal.tsx:165
#: src/components/ChatModal.tsx:301
#: src/components/ChatModal.tsx:302
#: src/components/CommentThread.tsx:369
msgid "Reply"
msgstr "Reply"
#: src/components/ChatModal.tsx:648
#: src/components/ChatModal.tsx:1197
msgid "Replying to"
msgstr "Replying to"
@@ -1125,6 +1172,7 @@ msgstr "Reset to default"
msgid "Reset to the suggested title"
msgstr "Reset to the suggested title"
#: src/components/ChatModal.tsx:488
#: src/pages/Dump.tsx:287
#: src/pages/DumpEdit.tsx:180
msgid "Retry"
@@ -1134,7 +1182,7 @@ msgstr "Retry"
msgid "Role"
msgstr "Role"
#: src/components/ChatModal.tsx:224
#: src/components/ChatModal.tsx:207
#: src/components/CommentThread.tsx:341
#: src/pages/Dump.tsx:398
#: src/pages/DumpEdit.tsx:487
@@ -1172,7 +1220,7 @@ msgstr "Search failed"
msgid "Searching…"
msgstr "Searching…"
#: src/components/ChatModal.tsx:694
#: src/components/ChatModal.tsx:1237
msgid "Send"
msgstr "Send"
@@ -1180,6 +1228,7 @@ msgstr "Send"
msgid "Send reset link"
msgstr "Send reset link"
#: src/components/ChatModal.tsx:506
#: src/pages/UserLogin.tsx:142
msgid "Sending…"
msgstr "Sending…"
@@ -1197,7 +1246,7 @@ msgstr "Set new password"
msgid "Settings"
msgstr "Settings"
#: src/components/ChatModal.tsx:569
#: src/components/ChatModal.tsx:1023
msgid "Several people are typing…"
msgstr "Several people are typing…"
@@ -1211,6 +1260,7 @@ msgstr "slug"
#: src/components/form/FormError.tsx:15
#: src/components/PageError.tsx:13
#: src/contexts/WSProvider.tsx:658
msgid "Something went wrong"
msgstr "Something went wrong"
@@ -1264,7 +1314,7 @@ msgstr "Title"
msgid "Title is required."
msgstr "Title is required."
#: src/components/ChatModal.tsx:264
#: src/components/ChatModal.tsx:133
#: src/pages/Notifications.tsx:397
msgid "Today"
msgstr "Today"
@@ -1274,7 +1324,11 @@ msgstr "Today"
msgid "Track {0}"
msgstr "Track {0}"
#: src/components/ChatModal.tsx:671
#: src/components/ChatModal.tsx:1040
msgid "Try again"
msgstr "Try again"
#: src/components/ChatModal.tsx:1222
msgid "Type a message…"
msgstr "Type a message…"
@@ -1389,7 +1443,7 @@ msgstr "Why?"
msgid "Write a reply…"
msgstr "Write a reply…"
#: src/components/ChatModal.tsx:267
#: src/components/ChatModal.tsx:136
#: src/pages/Notifications.tsx:399
msgid "Yesterday"
msgstr "Yesterday"
@@ -1402,6 +1456,10 @@ msgstr "You can post it anyway."
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
#: src/contexts/WSProvider.tsx:906
msgid "You're offline — not sent"
msgstr "You're offline — not sent"
#: src/pages/index/FollowedFeed.tsx:116
#: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:78

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:568
#: src/components/ChatModal.tsx:1022
msgid "{0} and {1} are typing…"
msgstr "{0} et {1} sont en train d'écrire…"
#. placeholder {0}: typingNames[0]
#: src/components/ChatModal.tsx:566
#: src/components/ChatModal.tsx:1020
msgid "{0} is typing…"
msgstr "{0} est en train d'écrire…"
@@ -54,6 +54,10 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
msgid "{label} ({count})"
msgstr "{label} ({count})"
#: src/components/ChatModal.tsx:1149
msgid "{unseenCount} new messages"
msgstr "{unseenCount} nouveaux messages"
#: src/components/CommentThread.tsx:470
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
@@ -134,6 +138,10 @@ msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
#: src/components/ChatModal.tsx:1148
msgid "1 new message"
msgstr "1 nouveau message"
#: src/pages/Notifications.tsx:217
msgid "a comment"
msgstr "un commentaire"
@@ -203,14 +211,14 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Retour à la connexion"
#: src/contexts/WSProvider.tsx:292
#: src/contexts/WSProvider.tsx:608
#: src/contexts/WSProvider.tsx:328
#: src/contexts/WSProvider.tsx:705
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."
#: src/components/CategoryManager.tsx:210
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:231
#: src/components/ChatModal.tsx:210
#: src/components/CommentThread.tsx:127
#: src/components/ConfirmModal.tsx:34
#: src/components/DumpCreateModal.tsx:946
@@ -227,8 +235,8 @@ msgstr "Annuler"
msgid "Cancel removal"
msgstr "Annuler la suppression"
#: src/components/ChatModal.tsx:658
#: src/components/ChatModal.tsx:659
#: src/components/ChatModal.tsx:1209
#: src/components/ChatModal.tsx:1210
msgid "Cancel reply"
msgstr "Annuler la réponse"
@@ -261,7 +269,7 @@ 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:572
#: src/components/ChatModal.tsx:1111
msgid "Chat"
msgstr "Tribune"
@@ -304,6 +312,10 @@ msgstr "Thème de couleur"
msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe"
#: src/contexts/WSProvider.tsx:698
msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
#: src/pages/UserPublicProfile.tsx:161
msgid "Copied!"
msgstr "Copié !"
@@ -328,6 +340,10 @@ msgstr "Sauvegarde impossible"
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
#: src/components/ChatModal.tsx:1037
msgid "Couldn't load the chat."
msgstr "Impossible de charger le chat."
#: src/components/PlaylistCreateForm.tsx:91
msgid "Create"
msgstr "Créer"
@@ -360,9 +376,9 @@ msgstr "Onglet par défaut"
#: src/components/CategoryManager.tsx:221
#: src/components/CategoryManager.tsx:222
#: src/components/ChatModal.tsx:185
#: src/components/ChatModal.tsx:186
#: src/components/ChatModal.tsx:241
#: src/components/ChatModal.tsx:322
#: src/components/ChatModal.tsx:323
#: src/components/ChatModal.tsx:417
#: src/components/CommentThread.tsx:390
#: src/components/CommentThread.tsx:396
#: src/components/ConfirmModal.tsx:17
@@ -398,7 +414,7 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/components/ChatModal.tsx:240
#: src/components/ChatModal.tsx:416
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
@@ -407,7 +423,7 @@ msgstr "Supprimer ce message ?"
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
#: src/components/ChatModal.tsx:131
#: src/components/ChatModal.tsx:244
msgid "deleted message"
msgstr "message supprimé"
@@ -416,6 +432,19 @@ msgstr "message supprimé"
msgid "Description (optional)"
msgstr "Description (facultatif)"
#: src/components/ChatModal.tsx:502
msgid "Discard"
msgstr "Abandonner"
#: src/components/ChatModal.tsx:1115
msgid "Disconnected — reconnecting…"
msgstr "Déconnecté — reconnexion…"
#: src/components/ChatModal.tsx:1185
#: src/components/ChatModal.tsx:1186
msgid "Dismiss"
msgstr "Fermer"
#: src/components/DumpCreateModal.tsx:742
msgid "Done"
msgstr "Terminé"
@@ -460,8 +489,9 @@ msgstr "Recos ({0}{1})"
msgid "Earlier"
msgstr "Plus tôt"
#: src/components/ChatModal.tsx:174
#: src/components/ChatModal.tsx:175
#: src/components/ChatModal.tsx:311
#: src/components/ChatModal.tsx:312
#: src/components/ChatModal.tsx:495
#: src/components/CommentThread.tsx:381
#: src/pages/Dump.tsx:520
#: src/pages/PlaylistDetail.tsx:627
@@ -477,10 +507,14 @@ msgstr "Modifier {0}"
msgid "Edit title"
msgstr "Modifier le titre"
#: src/components/ChatModal.tsx:335
msgid "edited"
msgstr "modifié"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:154
#: src/components/ChatModal.tsx:336
#: src/components/CommentThread.tsx:330
#: src/pages/Dump.tsx:459
#: src/pages/PlaylistDetail.tsx:666
@@ -490,7 +524,7 @@ msgstr "modifié {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:152
#: src/components/ChatModal.tsx:332
#: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:457
#: src/pages/PlaylistDetail.tsx:663
@@ -665,6 +699,10 @@ msgstr "Tendances"
msgid "If that address is registered you'll receive a reset link shortly."
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
#: src/components/ChatModal.tsx:81
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:554
msgid "In collections"
msgstr "Dans les collections"
@@ -716,7 +754,7 @@ msgstr "Lien"
msgid "Link or file"
msgstr "Lien ou fichier"
#: src/contexts/WSProvider.tsx:607
#: src/contexts/WSProvider.tsx:704
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -729,7 +767,7 @@ msgstr "Mises à jour en direct indisponibles."
msgid "Load more"
msgstr "Charger plus"
#: src/components/ChatModal.tsx:585
#: src/components/ChatModal.tsx:1135
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
@@ -738,6 +776,10 @@ msgstr "Charger les messages plus anciens"
msgid "Loading dump…"
msgstr "Chargement de la reco…"
#: src/components/ChatModal.tsx:1029
msgid "Loading messages…"
msgstr "Chargement des messages…"
#: src/pages/index/FollowedFeed.tsx:111
#: src/pages/index/HotFeed.tsx:64
#: src/pages/index/JournalFeed.tsx:73
@@ -759,9 +801,9 @@ msgid "Loading profile…"
msgstr "Chargement du profil…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:584
#: src/components/ChatModal.tsx:1134
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:311
#: src/components/TextEditor.tsx:315
#: src/components/UserListPopover.tsx:192
#: src/components/UserListPopover.tsx:230
#: src/pages/index/FollowedFeed.tsx:78
@@ -828,6 +870,7 @@ msgstr "Nom"
msgid "new"
msgstr "nouveau"
#: src/components/ChatModal.tsx:1064
#: src/components/FeedTabBar.tsx:31
#: src/pages/UserPublicProfile.tsx:1305
msgid "New"
@@ -873,7 +916,7 @@ msgstr "Aucune reco ne correspond à « {q} »."
msgid "No dumps yet. Be the first!"
msgstr "Pas encore de recos. Soyez le premier !"
#: src/components/TextEditor.tsx:314
#: src/components/TextEditor.tsx:318
msgid "No emoji found."
msgstr "Aucun emoji trouvé."
@@ -886,7 +929,7 @@ msgstr "Pas encore de collections suivies."
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
#: src/components/ChatModal.tsx:592
#: src/components/ChatModal.tsx:1048
msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !"
@@ -904,6 +947,10 @@ msgstr "Aucune collection ne correspond à « {q} »."
msgid "No playlists yet."
msgstr "Pas encore de collections."
#: src/contexts/WSProvider.tsx:925
msgid "No reply from the server — this message may not have been sent"
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
#: src/pages/Search.tsx:257
msgid "No users match \"{q}\"."
msgstr "Aucun utilisateur ne correspond à « {q} »."
@@ -1093,13 +1140,13 @@ msgstr "Retirer le vote"
msgid "Replace file"
msgstr "Remplacer le fichier"
#: src/components/ChatModal.tsx:164
#: src/components/ChatModal.tsx:165
#: src/components/ChatModal.tsx:301
#: src/components/ChatModal.tsx:302
#: src/components/CommentThread.tsx:369
msgid "Reply"
msgstr "Répondre"
#: src/components/ChatModal.tsx:648
#: src/components/ChatModal.tsx:1197
msgid "Replying to"
msgstr "En réponse à"
@@ -1125,6 +1172,7 @@ msgstr "Réinitialiser par défaut"
msgid "Reset to the suggested title"
msgstr "Rétablir le titre suggéré"
#: src/components/ChatModal.tsx:488
#: src/pages/Dump.tsx:287
#: src/pages/DumpEdit.tsx:180
msgid "Retry"
@@ -1134,7 +1182,7 @@ msgstr "Réessayer"
msgid "Role"
msgstr "Rôle"
#: src/components/ChatModal.tsx:224
#: src/components/ChatModal.tsx:207
#: src/components/CommentThread.tsx:341
#: src/pages/Dump.tsx:398
#: src/pages/DumpEdit.tsx:487
@@ -1172,7 +1220,7 @@ msgstr "Recherche échouée"
msgid "Searching…"
msgstr "Recherche…"
#: src/components/ChatModal.tsx:694
#: src/components/ChatModal.tsx:1237
msgid "Send"
msgstr "Envoyer"
@@ -1180,6 +1228,7 @@ msgstr "Envoyer"
msgid "Send reset link"
msgstr "Envoyer le lien de réinitialisation"
#: src/components/ChatModal.tsx:506
#: src/pages/UserLogin.tsx:142
msgid "Sending…"
msgstr "Envoi…"
@@ -1197,7 +1246,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "Settings"
msgstr "Paramètres"
#: src/components/ChatModal.tsx:569
#: src/components/ChatModal.tsx:1023
msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…"
@@ -1211,6 +1260,7 @@ msgstr "identifiant"
#: src/components/form/FormError.tsx:15
#: src/components/PageError.tsx:13
#: src/contexts/WSProvider.tsx:658
msgid "Something went wrong"
msgstr "Une erreur est survenue"
@@ -1264,7 +1314,7 @@ msgstr "Titre"
msgid "Title is required."
msgstr "Un titre est requis."
#: src/components/ChatModal.tsx:264
#: src/components/ChatModal.tsx:133
#: src/pages/Notifications.tsx:397
msgid "Today"
msgstr "Aujourd'hui"
@@ -1274,7 +1324,11 @@ msgstr "Aujourd'hui"
msgid "Track {0}"
msgstr "Piste {0}"
#: src/components/ChatModal.tsx:671
#: src/components/ChatModal.tsx:1040
msgid "Try again"
msgstr "Réessayer"
#: src/components/ChatModal.tsx:1222
msgid "Type a message…"
msgstr "Écrivez un message…"
@@ -1389,7 +1443,7 @@ msgstr "Pourquoi ?"
msgid "Write a reply…"
msgstr "Écrire une réponse…"
#: src/components/ChatModal.tsx:267
#: src/components/ChatModal.tsx:136
#: src/pages/Notifications.tsx:399
msgid "Yesterday"
msgstr "Hier"
@@ -1402,6 +1456,10 @@ msgstr "Vous pouvez la publier quand même."
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
#: src/contexts/WSProvider.tsx:906
msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé"
#: src/pages/index/FollowedFeed.tsx:116
#: src/pages/index/HotFeed.tsx:69
#: src/pages/index/JournalFeed.tsx:78

View File

@@ -464,6 +464,8 @@ export interface WSWelcomeMessage {
myVotes: string[];
myCommentLikes: string[];
unreadNotificationCount: number;
chatReadAt?: string;
unreadChatCount: number;
}
export interface WSPresenceUpdateMessage {
type: "presence_update";
@@ -554,6 +556,10 @@ export interface WSErrorMessage {
dumpId?: string;
commentId?: string;
action?: "cast" | "remove";
/** Set when a chat action failed, so it can be surfaced in the chat. */
scope?: "chat";
/** Echo of the failed chat_send's nonce. */
nonce?: string;
}
export interface WSForceLogoutMessage {
@@ -563,6 +569,7 @@ export interface WSForceLogoutMessage {
export interface WSChatMessageMessage {
type: "chat_message";
message: RawChatMessage;
nonce?: string;
}
export interface WSChatMessageUpdatedMessage {
@@ -580,6 +587,12 @@ export interface WSChatTypingUpdateMessage {
users: OnlineUser[];
}
export interface WSChatReadUpdateMessage {
type: "chat_read_update";
readAt: string;
unread: number;
}
export type IncomingWSMessage =
| WSPingMessage
| WSWelcomeMessage
@@ -605,7 +618,8 @@ export type IncomingWSMessage =
| WSChatMessageMessage
| WSChatMessageUpdatedMessage
| WSChatMessageDeletedMessage
| WSChatTypingUpdateMessage;
| WSChatTypingUpdateMessage
| WSChatReadUpdateMessage;
/**
* WebSocket messages — client → server (outgoing)
@@ -635,6 +649,7 @@ export interface WSChatSendMessage {
type: "chat_send";
body: string;
replyToId?: string;
nonce?: string;
}
export interface WSChatTypingMessage {
@@ -658,6 +673,11 @@ export interface WSChatDeleteMessage {
id: string;
}
export interface WSChatReadMessage {
type: "chat_read";
at: string;
}
export type OutgoingWSMessage =
| WSPongMessage
| WSVoteCastMessage
@@ -668,7 +688,8 @@ export type OutgoingWSMessage =
| WSChatTypingMessage
| WSChatFocusMessage
| WSChatEditMessage
| WSChatDeleteMessage;
| WSChatDeleteMessage
| WSChatReadMessage;
/**
* Follows

View File

@@ -132,7 +132,8 @@ function notificationLink(n: Notification): string {
return `/dumps/${d.dumpId}#comment-${d.contextId}`;
}
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
if (d.contextType === "chat") return `/?chat=open`;
// Search only: the chat opens over whatever page you're on.
if (d.contextType === "chat") return `?chat=open`;
return `/playlists/${d.contextId}`;
}
}

34
src/utils/chatText.ts Normal file
View File

@@ -0,0 +1,34 @@
// Text helpers for the live chat.
// Mirrors MENTION_RE in api/services/notification-service.ts.
const MENTION_RE = /(?<![[(\w])@(\w+)/g;
/** Whether `body` @-mentions `username` (case-insensitively), by the same rule
* the server uses to send mention notifications. */
export 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;
}
/**
* Flattens chat markdown to one line of plain text, for the reply previews:
* images become a label, links their text, and markup characters go. Also
* copes with a body the server cut short mid-link, which would otherwise show
* a dangling half-URL.
*/
export function markdownToPlainText(md: string, imageLabel: string): string {
return md
// A truncated image or link at the very end: drop the unfinished target.
.replace(/!\[([^\]]*)\]\([^)]*$/, (_, alt: string) => alt || imageLabel)
.replace(/(^|[^!])\[([^\]]*)\]\([^)]*$/, "$1$2")
.replace(/!\[[^\]]*$/, "")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_, alt: string) => alt || imageLabel)
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
.replace(/```\w*|\*\*|__|~~|[*`]/g, "")
.replace(/\s+/g, " ")
.trim();
}