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
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:
213
src/App.css
213
src/App.css
@@ -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
@@ -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)
|
||||
|
||||
@@ -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: () => {},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
25
src/model.ts
25
src/model.ts
@@ -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
|
||||
|
||||
@@ -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
34
src/utils/chatText.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user