diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 8d74a23..e5459bb 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -7,6 +7,7 @@ import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts"; import { up as up0006Categories } from "./migrations/0006_categories.ts"; import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts"; import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts"; +import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts"; interface Migration { name: string; @@ -25,6 +26,7 @@ const MIGRATIONS: Migration[] = [ { name: "0006_categories", up: up0006Categories }, { name: "0007_password_reset_tokens", up: up0007PasswordResetTokens }, { name: "0008_chat_messages", up: up0008ChatMessages }, + { name: "0009_chat_reply", up: up0009ChatReply }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0009_chat_reply.ts b/api/db/migrations/0009_chat_reply.ts new file mode 100644 index 0000000..3b0226e --- /dev/null +++ b/api/db/migrations/0009_chat_reply.ts @@ -0,0 +1,17 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Adds an optional self-reference so a chat message can reply to another. +// Kept as a plain column (no FK): when the replied-to message is later deleted +// the id is retained but the join yields null, letting the UI show a subtle +// "deleted message" reference instead of breaking the insert. +// +// Idempotent: also runs against fresh databases created from schema.sql, where +// the column already exists. +export function up(db: DatabaseSync): void { + const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as { + name: string; + }[]; + if (!cols.some((c) => c.name === "reply_to_id")) { + db.exec(`ALTER TABLE chat_messages ADD COLUMN reply_to_id TEXT;`); + } +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 7910d4a..86e4375 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -194,11 +194,12 @@ CREATE UNIQUE INDEX idx_notifications_dedup WHERE source_key IS NOT NULL; CREATE TABLE chat_messages ( - id TEXT NOT NULL PRIMARY KEY, - user_id TEXT NOT NULL, - body TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT, + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + reply_to_id TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX idx_chat_messages_created ON chat_messages(created_at); diff --git a/api/model/db.ts b/api/model/db.ts index a3b74da..3508d09 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -313,6 +313,12 @@ export interface ChatMessageRow { updated_at: string | null; author_username: string; author_avatar_mime: string | null; + // Reply columns: reply_to_id is the stored target; the author/body preview are + // LEFT-JOINed from the target and go null when there's no reply (or it was + // since deleted). + reply_to_id: string | null; + reply_to_author: string | null; + reply_to_body: string | null; [key: string]: SQLOutputValue; } @@ -327,7 +333,13 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow { "author_username" in obj && typeof obj.author_username === "string" && "author_avatar_mime" in obj && (typeof obj.author_avatar_mime === "string" || - obj.author_avatar_mime === null); + obj.author_avatar_mime === null) && + "reply_to_id" in obj && + (typeof obj.reply_to_id === "string" || obj.reply_to_id === null) && + "reply_to_author" in obj && + (typeof obj.reply_to_author === "string" || obj.reply_to_author === null) && + "reply_to_body" in obj && + (typeof obj.reply_to_body === "string" || obj.reply_to_body === null); } export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage { @@ -339,6 +351,9 @@ export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage { updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, authorUsername: row.author_username, authorAvatarMime: row.author_avatar_mime ?? undefined, + replyToId: row.reply_to_id ?? undefined, + replyToAuthor: row.reply_to_author ?? undefined, + replyToBody: row.reply_to_body ?? undefined, }; } diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 4f04764..18ece27 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -321,6 +321,13 @@ export interface ChatMessage { updatedAt?: Date; authorUsername: string; authorAvatarMime?: string; + /** Id of the message this one replies to, if any. Retained even when the + * target is later deleted (the preview fields below then go undefined). */ + replyToId?: string; + /** Author/snippet of the replied-to message, for a subtle inline reference. + * Absent when there's no reply, or the target has since been deleted. */ + replyToAuthor?: string; + replyToBody?: string; } /** Wire format — createdAt arrives as an ISO string over JSON. */ @@ -554,6 +561,15 @@ export interface CommentLikeRemoveMessage { export interface ChatSendMessage { type: "chat_send"; body: string; + /** Id of the message being replied to, if this is a reply. */ + replyToId?: string; +} + +// Sent while the user is composing (true) and when they stop (false). The +// server rebroadcasts the live set of typers to everyone as ChatTypingUpdate. +export interface ChatTypingMessage { + type: "chat_typing"; + typing: boolean; } // Tells the server whether this client currently has the chatbox open, so that @@ -582,6 +598,7 @@ export type ClientToServerMessage = | CommentLikeCastMessage | CommentLikeRemoveMessage | ChatSendMessage + | ChatTypingMessage | ChatFocusMessage | ChatEditMessage | ChatDeleteMessage; @@ -730,6 +747,13 @@ export interface ChatMessageDeletedMessage { id: string; } +// The current set of users composing a chat message. Reuses OnlineUser so the +// client can render the same avatars as the presence row. +export interface ChatTypingUpdateMessage { + type: "chat_typing_update"; + users: OnlineUser[]; +} + export type ServerToClientMessage = | PingMessage | WelcomeMessage @@ -754,7 +778,8 @@ export type ServerToClientMessage = | ForceLogoutMessage | ChatMessageMessage | ChatMessageUpdatedMessage - | ChatMessageDeletedMessage; + | ChatMessageDeletedMessage + | ChatTypingUpdateMessage; /** * Follows diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 81f93a6..9939267 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -7,11 +7,13 @@ import { broadcastChatMessageUpdated, broadcastCommentLikeUpdate, broadcastPresence, + broadcastTyping, broadcastVoteUpdate, getOnlineUsers, handleClientPong, isUserChatOpen, register, + setClientTyping, unregister, type WsClient, } from "../services/ws-service.ts"; @@ -127,7 +129,10 @@ router.get("/ws", async (ctx) => { handleCommentLike(client, msg.commentId, "remove"); break; case "chat_send": - handleChatSend(client, msg.body); + handleChatSend(client, msg.body, msg.replyToId); + break; + case "chat_typing": + if (client.userId) setClientTyping(client, msg.typing); break; case "chat_focus": client.chatOpen = msg.open; @@ -144,6 +149,8 @@ router.get("/ws", async (ctx) => { socket.addEventListener("close", () => { unregister(client); broadcastPresence(); + // If they were mid-compose, drop them from the typing set right away. + if (client.typingAt) broadcastTyping(); }); }); @@ -214,7 +221,11 @@ function handleCommentLike( } } -function handleChatSend(client: WsClient, body: string): void { +function handleChatSend( + client: WsClient, + body: string, + replyToId?: string, +): void { const { socket } = client; if (!client.userId) { @@ -225,7 +236,10 @@ function handleChatSend(client: WsClient, body: string): void { } try { - const message = createChatMessage(client.userId, body); + const message = createChatMessage(client.userId, body, replyToId); + // Sending a message ends composition — clear the typing flag before the + // message broadcast so the indicator doesn't linger next to it. + setClientTyping(client, false); // Broadcast to everyone including the sender, so the sender's own message // simply arrives over the socket — no optimistic-send machinery needed. broadcastChatMessage(message); diff --git a/api/services/chat-service.ts b/api/services/chat-service.ts index 98724f8..317f5e1 100644 --- a/api/services/chat-service.ts +++ b/api/services/chat-service.ts @@ -9,13 +9,26 @@ export const MAX_CHAT_LENGTH = 2000; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 100; +// Length cap for the replied-to preview snippet, so a reply to a long message +// doesn't ship the whole 2000-char body over the wire for every reply. +const REPLY_SNIPPET_LEN = 140; + const SELECT_COLS = - `m.id, m.user_id, m.body, m.created_at, m.updated_at, - u.username as author_username, u.avatar_mime as author_avatar_mime`; + `m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id, + u.username as author_username, u.avatar_mime as author_avatar_mime, + ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`; + +// Common joins: author (required) plus the optional replied-to message and its +// author (LEFT — null when there's no reply or the target was deleted). +const FROM_JOINS = + `FROM chat_messages m + JOIN users u ON m.user_id = u.id + LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id + LEFT JOIN users ru ON rm.user_id = ru.id`; function fetchMessage(id: string): ChatMessage { const row = db.prepare( - `SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?;`, + `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`, ).get(id); if (!row || !isChatMessageRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found"); @@ -23,7 +36,11 @@ function fetchMessage(id: string): ChatMessage { return chatMessageRowToApi(row); } -export function createChatMessage(userId: string, body: string): ChatMessage { +export function createChatMessage( + userId: string, + body: string, + replyToId?: string, +): ChatMessage { const trimmed = body.trim(); if (!trimmed) { throw new APIException( @@ -39,11 +56,20 @@ export function createChatMessage(userId: string, body: string): ChatMessage { `Message exceeds ${MAX_CHAT_LENGTH} characters`, ); } + // Only store the reply target if it actually exists — drop dangling ids so a + // message never references a phantom parent. + let replyTo: string | null = null; + if (replyToId) { + const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get( + replyToId, + ); + if (exists) replyTo = replyToId; + } const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); db.prepare( - `INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`, - ).run(id, userId, trimmed, createdAt); + `INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`, + ).run(id, userId, trimmed, createdAt, replyTo); return fetchMessage(id); } @@ -117,11 +143,11 @@ export function getRecentChatMessages( const capped = Math.min(Math.max(limit, 1), MAX_LIMIT); const rows = before ? db.prepare( - `SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id + `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`, ).all(before, capped) : db.prepare( - `SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id + `SELECT ${SELECT_COLS} ${FROM_JOINS} ORDER BY m.created_at DESC LIMIT ?;`, ).all(capped); diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 4711e7d..d093ca1 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -19,6 +19,9 @@ export interface WsClient { pongReceived?: boolean; /** Whether this client currently has the chatbox open (is reading chat). */ chatOpen?: boolean; + /** Timestamp (ms) of the client's last "typing" signal; undefined when not + * composing. Entries older than TYPING_TTL are treated as stale. */ + typingAt?: number; } const clients = new Set(); @@ -233,6 +236,64 @@ export function broadcastChatMessageDeleted(id: string): void { } } +// Typing indicator: a client's "typing" signal is considered live for this long +// after its last refresh. Must exceed the client's typing-ping cadence so a +// steady typist never flickers out between pings. +const TYPING_TTL = 6_000; + +// Deduped set of users currently composing a chat message (typing signal within +// the TTL), shaped like OnlineUser so the client renders the same avatars. +export function getTypingUsers(): OnlineUser[] { + const now = Date.now(); + const seen = new Map(); + for (const client of clients) { + if ( + client.userId && client.typingAt && now - client.typingAt < TYPING_TTL && + !seen.has(client.userId) + ) { + seen.set(client.userId, { + userId: client.userId, + username: client.username!, + hasAvatar: !!client.avatarMime, + avatarVersion: client.avatarVersion, + }); + } + } + return Array.from(seen.values()); +} + +export function broadcastTyping(): void { + const users = getTypingUsers(); + for (const client of clients) { + send(client.socket, { type: "chat_typing_update", users }); + } +} + +// Record (or clear) a client's typing state and tell everyone. The client also +// resends "true" periodically while composing; the sweep below expires it if +// they go quiet without a clean "false" (e.g. they closed the tab). +export function setClientTyping(client: WsClient, typing: boolean): void { + const wasTyping = !!client.typingAt; + client.typingAt = typing ? Date.now() : undefined; + // Only rebroadcast on an actual edge to avoid a flood of identical updates + // from the periodic typing pings. + if (typing !== wasTyping) broadcastTyping(); +} + +// Sweep stale typing entries (clients that stopped pinging without a clean +// stop) and rebroadcast when the visible set shrinks. +setInterval(() => { + const now = Date.now(); + let changed = false; + for (const client of clients) { + if (client.typingAt && now - client.typingAt >= TYPING_TTL) { + client.typingAt = undefined; + changed = true; + } + } + if (changed) broadcastTyping(); +}, 2_000); + // True if the user has the chatbox open on any of their connected clients — i.e. // they're already reading chat, so a mention notification would be redundant. export function isUserChatOpen(userId: string): boolean { diff --git a/src/App.css b/src/App.css index 3408cca..97950e8 100644 --- a/src/App.css +++ b/src/App.css @@ -3292,12 +3292,14 @@ body.has-player .chat-fab { } /* ── Live chat ── */ +/* The chat fills the (now taller) modal so it makes better use of the viewport + on desktop — see the .modal-card:has(.chat) height override below. */ .chat { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; - height: min(60vh, 480px); + height: 100%; gap: 0.75rem; } @@ -3306,12 +3308,13 @@ body.has-player .chat-fab { min-height: 0; overflow-y: auto; /* Reserve a gutter for the scrollbar so it never sits on top of the - messages — without this, overlay scrollbars overlap the content. */ + messages — without this, overlay scrollbars overlap the content. The extra + padding keeps the text clear of the scrollbar rather than right against it. */ scrollbar-gutter: stable; display: flex; flex-direction: column; gap: 0.85rem; - padding-right: 0.5rem; + padding-right: 1rem; } .chat-empty { @@ -3352,6 +3355,40 @@ body.has-player .chat-fab { } } +/* Brief highlight when a message is jumped to via a reply reference. */ +.chat-message { + border-radius: 8px; + transition: background-color 0.4s ease; +} + +.chat-message--flash { + background-color: color-mix(in srgb, var(--color-accent) 18%, transparent); +} + +/* Subtle day separator: a hairline rule with a centered, muted label. */ +.chat-day-sep { + display: flex; + align-items: center; + gap: 0.6rem; + margin: 0.15rem 0; + color: var(--color-text-muted); +} + +.chat-day-sep::before, +.chat-day-sep::after { + content: ""; + flex: 1; + height: 1px; + background: var(--color-border); +} + +.chat-day-sep-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + .chat-message-body { flex: 1; min-width: 0; @@ -3497,6 +3534,177 @@ body.has-player .chat-fab { box-sizing: border-box; } +/* ── Reply reference (subtle, above a message's body) ── */ +.chat-reply-ref { + display: flex; + align-items: baseline; + gap: 0.3rem; + max-width: 100%; + margin-bottom: 0.15rem; + padding: 0; + background: none; + border: none; + font: inherit; + font-size: 0.74rem; + color: var(--color-text-muted); + cursor: pointer; + text-align: left; +} + +.chat-reply-ref:disabled { + cursor: default; + font-style: italic; +} + +.chat-reply-ref:not(:disabled):hover .chat-reply-ref-author, +.chat-reply-ref:not(:disabled):hover .chat-reply-ref-text { + color: var(--color-text); +} + +.chat-reply-ref-mark { + opacity: 0.7; + flex-shrink: 0; +} + +.chat-reply-ref-author { + font-weight: 700; + flex-shrink: 0; + transition: color 0.15s; +} + +.chat-reply-ref-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.85; + transition: color 0.15s; +} + +/* ── Reply composer bar (subtle, above the input) ── */ +.chat-reply-bar { + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 0; + padding: 0.35rem 0.6rem; + border-left: 2px solid var(--color-accent); + border-radius: 4px; + background: color-mix(in srgb, var(--color-accent) 8%, transparent); + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.chat-reply-bar-mark { + opacity: 0.7; + flex-shrink: 0; +} + +.chat-reply-bar-text { + display: flex; + align-items: baseline; + gap: 0.3rem; + min-width: 0; + flex: 1; +} + +.chat-reply-bar-author { + font-weight: 700; + color: var(--color-text); + flex-shrink: 0; +} + +.chat-reply-bar-snippet { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; +} + +.chat-reply-bar-cancel { + margin-left: auto; + flex-shrink: 0; + font-size: 0.85rem; +} + +/* ── "Is typing" indicator (reuses presence avatars) ── */ +.chat-typing { + display: flex; + align-items: center; + gap: 0.45rem; + flex-shrink: 0; + min-height: 1.25rem; + font-size: 0.76rem; + color: var(--color-text-muted); +} + +.chat-typing-avatars { + display: inline-flex; +} + +/* Overlap multiple typers' avatars slightly, like a stacked presence cluster. */ +.chat-typing-avatar:not(:first-child) { + margin-left: -0.4rem; +} + +.chat-typing-avatar { + border-radius: 50%; + box-shadow: 0 0 0 2px var(--color-surface); +} + +.chat-typing-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-typing-dots { + display: inline-flex; + gap: 2px; + flex-shrink: 0; +} + +.chat-typing-dots span { + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + opacity: 0.4; + animation: chat-typing-bounce 1.2s infinite ease-in-out; +} + +.chat-typing-dots span:nth-child(2) { + animation-delay: 0.15s; +} + +.chat-typing-dots span:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes chat-typing-bounce { + 0%, 60%, 100% { + opacity: 0.3; + transform: translateY(0); + } + 30% { + opacity: 0.9; + transform: translateY(-2px); + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-typing-dots span { + animation: none; + } +} + +/* Give the chat a tall, stable panel so it uses the viewport well on desktop + instead of floating as a small box. Bounded by max-height so it never spills + off short screens; the inner message list scrolls within it. */ +.modal-card:has(.chat) { + height: min(86vh, 820px); + max-height: 92vh; +} + /* The composer's popovers (mention list, emoji picker) anchor to the input, which sits at the very bottom of the modal. The modal body must not clip them — let them escape the body entirely so they're never cropped. The message diff --git a/src/components/ChatModal.tsx b/src/components/ChatModal.tsx index ed27b80..dd5df29 100644 --- a/src/components/ChatModal.tsx +++ b/src/components/ChatModal.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Link } from "react-router"; @@ -12,6 +19,7 @@ import { useWS } from "../hooks/useWS.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { can } from "../utils/permissions.ts"; import { relativeTime } from "../utils/relativeTime.ts"; +import { i18n } from "../i18n.ts"; import { API_URL } from "../config/api.ts"; import { type ChatMessage, @@ -24,17 +32,40 @@ import { const MAX_CHAT_LENGTH = 2000; // Matches the server's default page size; a full page means there may be more. const PAGE_SIZE = 50; +// How long the composer stays "typing" after the last keystroke, and how often +// we refresh that signal while typing continuously. The refresh interval must +// stay below the server's TYPING_TTL so a steady typist never flickers out. +const TYPING_STOP_DELAY = 3_500; +const TYPING_REFRESH = 3_000; + +interface ReplyTarget { + id: string; + author: string; + body: string; +} interface ChatMessageItemProps { message: ChatMessage; currentUser: PublicUser | null; canModerate: boolean; + flashed: boolean; onEdit: (id: string, body: string) => void; onDelete: (id: string) => void; + onReply: (message: ChatMessage) => void; + onJumpTo: (id: string) => void; } function ChatMessageItem( - { message, currentUser, canModerate, onEdit, onDelete }: ChatMessageItemProps, + { + message, + currentUser, + canModerate, + flashed, + onEdit, + onDelete, + onReply, + onJumpTo, + }: ChatMessageItemProps, ) { const [editing, setEditing] = useState(false); const [editDraft, setEditDraft] = useState(message.body); @@ -61,7 +92,10 @@ function ChatMessageItem( }; return ( -
+
+ {message.replyToId && ( + // Subtle one-line reference to the replied-to message; clicking jumps + // to it. Falls back to a muted note when the target was deleted. + + )}
)} - {!editing && (canEdit || canDelete) && ( + {!editing && ( + {canEdit && (
+ {typingOthers.length > 0 && ( +
+ + {typingOthers.slice(0, 3).map((u) => ( + + + + ))} + + {typingLabel} + +
+ )} + + {replyTarget && ( +
+ + + Replying to{" "} + + {replyTarget.author} + + {replyTarget.body} + + +
+ )} +
void }) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); + } else if (e.key === "Escape" && replyTarget) { + // Cancel the reply first; a second Escape closes the modal. + e.preventDefault(); + setReplyTarget(null); } }} /> diff --git a/src/contexts/WSContext.ts b/src/contexts/WSContext.ts index 2093e7c..dee2c13 100644 --- a/src/contexts/WSContext.ts +++ b/src/contexts/WSContext.ts @@ -65,6 +65,9 @@ export interface WSContextValue { * filter them out. */ deletedChatIds: Set; unreadChatCount: number; + /** Users currently composing a chat message (excludes nobody — the chatbox + * filters out the current user itself). Shaped like presence for avatars. */ + typingUsers: OnlineUser[]; /** Increments on each WS reconnect so pages can backfill missed events. */ connectionEpoch: number; castVote: (dumpId: string) => void; @@ -73,10 +76,13 @@ export interface WSContextValue { removeCommentLike: (commentId: string) => void; injectDump: (dump: Dump) => void; clearUnreadNotifications: () => void; - sendChatMessage: (body: string) => void; + sendChatMessage: (body: string, replyToId?: string) => void; editChatMessage: (id: string, body: string) => void; deleteChatMessage: (id: string) => void; clearUnreadChat: () => 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; /** Inform the server whether the chatbox is open (suppresses redundant * mention notifications while the user is reading chat). */ setChatOpen: (open: boolean) => void; @@ -104,6 +110,7 @@ export const WSContext = createContext({ chatMessages: [], deletedChatIds: new Set(), unreadChatCount: 0, + typingUsers: [], connectionEpoch: 0, castVote: () => {}, removeVote: () => {}, @@ -115,5 +122,6 @@ export const WSContext = createContext({ editChatMessage: () => {}, deleteChatMessage: () => {}, clearUnreadChat: () => {}, + setChatTyping: () => {}, setChatOpen: () => {}, }); diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx index da4fc5f..d58004e 100644 --- a/src/contexts/WSProvider.tsx +++ b/src/contexts/WSProvider.tsx @@ -119,6 +119,7 @@ export function WSProvider({ children }: WSProviderProps) { const [chatMessages, setChatMessages] = useState([]); const [deletedChatIds, setDeletedChatIds] = useState>(new Set()); const [unreadChatCount, setUnreadChatCount] = useState(0); + const [typingUsers, setTypingUsers] = useState([]); // Live avatar overrides for site-wide Avatar refresh (own narrow context). const [avatarOverrides, setAvatarOverrides] = useState< Record @@ -156,6 +157,7 @@ export function WSProvider({ children }: WSProviderProps) { setChatMessages([]); setDeletedChatIds(new Set()); setUnreadChatCount(0); + setTypingUsers([]); setAvatarOverrides({}); } @@ -529,6 +531,10 @@ export function WSProvider({ children }: WSProviderProps) { break; } + case "chat_typing_update": + setTypingUsers(msg.users); + break; + case "force_logout": onForceLogoutRef.current(); break; @@ -759,13 +765,17 @@ export function WSProvider({ children }: WSProviderProps) { setUnreadNotificationCount(0); }, []); - const sendChatMessage = useCallback((body: string) => { + 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 } satisfies OutgoingWSMessage, + { + type: "chat_send", + body: trimmed, + ...(replyToId ? { replyToId } : {}), + } satisfies OutgoingWSMessage, ), ); } @@ -797,6 +807,16 @@ export function WSProvider({ children }: WSProviderProps) { setUnreadChatCount(0); }, []); + const setChatTyping = useCallback((typing: boolean) => { + if (socketRef.current?.readyState === WebSocket.OPEN) { + socketRef.current.send( + JSON.stringify( + { type: "chat_typing", typing } satisfies OutgoingWSMessage, + ), + ); + } + }, []); + const setChatOpen = useCallback((open: boolean) => { chatOpenRef.current = open; if (socketRef.current?.readyState === WebSocket.OPEN) { @@ -830,6 +850,7 @@ export function WSProvider({ children }: WSProviderProps) { chatMessages, deletedChatIds, unreadChatCount, + typingUsers, connectionEpoch, castVote, removeVote, @@ -841,6 +862,7 @@ export function WSProvider({ children }: WSProviderProps) { editChatMessage, deleteChatMessage, clearUnreadChat, + setChatTyping, setChatOpen, }), [ wsStatus, @@ -864,6 +886,7 @@ export function WSProvider({ children }: WSProviderProps) { chatMessages, deletedChatIds, unreadChatCount, + typingUsers, connectionEpoch, castVote, removeVote, @@ -875,6 +898,7 @@ export function WSProvider({ children }: WSProviderProps) { editChatMessage, deleteChatMessage, clearUnreadChat, + setChatTyping, setChatOpen, ]); diff --git a/src/locales/en.js b/src/locales/en.js index a8494d4..0c77c96 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -1 +1 @@ -/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Add email…\"],\"-OxI15\":[\"Followed playlists\"],\"-Ya-b9\":[\"Failed to save\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1cbYY_\":[\"Upvoted (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2ygf_L\":[\"← Back\"],\"3KKSM4\":[\"private\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4HH9iB\":[\"Not following anyone yet.\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4c-qBx\":[\"Your password has been changed. You can now log in.\"],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Username\"],\"8F1i42\":[\"Page not found\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"8pxhI8\":[\"Please select a file.\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"9xDZu_\":[\"Could not load.\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AHZflp\":[\"Chat\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"AeXO77\":[\"Account\"],\"CI50ct\":[\"Upvoted\"],\"Cj24wt\":[\"Registering…\"],\"D8DRYV\":[\"Delete this message?\"],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"ECiS12\":[\"View dump →\"],\"ED8n0z\":[\"the chat\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GDvlUT\":[\"Role\"],\"GLukaH\":[\"Moderator\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GQ38dl\":[\"Chat (\",[\"unreadChatCount\"],\" unread)\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GptGxg\":[\"Change password\"],\"H4o4sk\":[\"No one yet.\"],\"H8pzW-\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"I-x669\":[\"Invitees\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"ImOQa9\":[\"Reply\"],\"J2eKUI\":[\"File\"],\"JJ-Bhk\":[\"Your email address\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"K7tIrx\":[\"Category\"],\"KDGWg5\":[\"Remove from playlist\"],\"K_F6pa\":[\"Saving…\"],\"L-rMC9\":[\"Reset to default\"],\"LLyMkV\":[\"Followed (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"Oz0N9s\":[\"new\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"RfwZxd\":[\"Reset password\"],\"Rrp6-J\":[\"Log in to like\"],\"SBTElJ\":[\"Searching…\"],\"Sad2tK\":[\"Sending…\"],\"StovX6\":[\"This page does not exist.\"],\"Sxm8rQ\":[\"Users\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TM1ZbA\":[\"Playlists (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Invalid link\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOZith\":[\"Failed to post\"],\"URAieT\":[\"Log in to vote\"],\"UTiUFs\":[\"Fetching…\"],\"VCoEm-\":[\"Back to login\"],\"V_e7nf\":[\"Set new password\"],\"VnNJbN\":[\"From playlists\"],\"VskHIx\":[\"Delete category\"],\"VyTYmS\":[\"Change avatar\"],\"W9FRBT\":[\"Like\"],\"WE_r9K\":[\"Failed to create category\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XJy2oN\":[\"Logging in…\"],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"Xi0Mn4\":[\"← Back to profile\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"Xs2Lez\":[\"This reset link is missing or malformed.\"],\"YK1Dhc\":[\"a post\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <1>\",[\"1\"],\"\"],\"YpkCca\":[\"No followed playlists yet.\"],\"ZBdbv9\":[\"Edit title\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZmD2o6\":[\"Create & Add\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_nT6AE\":[\"New password\"],\"_t4W-i\":[\"From people\"],\"aAIQg2\":[\"Appearance\"],\"aDvLhk\":[\"Add a comment…\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"cILfnJ\":[\"Remove file\"],\"cYP9Sb\":[\"+ Playlist\"],\"cbeBbZ\":[\"At least \",[\"0\"],\" characters\"],\"cnGeoo\":[\"Delete\"],\"d8DZWS\":[\"Open search\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dMizp8\":[\"New playlist\"],\"eFSqvc\":[\"Failed to post reply\"],\"eOfXq3\":[\"Title is required.\"],\"ePK91l\":[\"Edit\"],\"eaUTwS\":[\"Send reset link\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"f_akpP\":[\"Max 50 MB\"],\"fgLNSM\":[\"Register\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ-sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"hBuUKa\":[\"Change password…\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"i7K_Te\":[\"Who am I?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Go home\"],\"ipYn7W\":[\"If that address is registered you'll receive a reset link shortly.\"],\"isRobC\":[\"New\"],\"jbernk\":[\"Loading profile…\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"k112DD\":[\"Load older messages\"],\"k36iJ-\":[\"Failed to save category\"],\"kLttbL\":[\"Registration failed\"],\"lUDifl\":[\"Created (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr_\":[\"Delete this comment?\"],\"lpIMne\":[\"Passwords do not match\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pvnfJD\":[\"Dark\"],\"qIMfNQ\":[\"Delete playlist\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Dump\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rDMzDU\":[\"Type a message…\"],\"rg9pXu\":[\"Search failed\"],\"rtpJqV\":[\"Dumps (\",[\"0\"],[\"1\"],\")\"],\"sGeXL3\":[\"Thumbnail\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"shHs8T\":[\"Enter a query to search.\"],\"smeBfS\":[\"Invalid invite\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"u1lDX2\":[\"Fetching preview…\"],\"uD0qXQ\":[\"Drop a file here\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"uomLas\":[\"Add category\"],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vQMkHu\":[\"Remove vote\"],\"vuosjb\":[\"User menu\"],\"wbXKOv\":[\"File too large (max 50 MB).\"],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y6sq5j\":[\"Following\"],\"y7oaHj\":[\"Playlist title\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yz7wBu\":[\"Close\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}")}; \ No newline at end of file +/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Add email…\"],\"-OxI15\":[\"Followed playlists\"],\"-Ya-b9\":[\"Failed to save\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Search dumps, users, playlists…\"],\"1cbYY_\":[\"Upvoted (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Light\"],\"1utXA6\":[\"Dumps\"],\"26iNma\":[\"Post comment\"],\"2DoBvq\":[\"Feeds\"],\"2Hlmdt\":[\"Write a reply…\"],\"2_DHgA\":[\"URL slug\"],\"2nrsEn\":[\"deleted message\"],\"2ygf_L\":[\"← Back\"],\"3KKSM4\":[\"private\"],\"3QuNUr\":[\"Several people are typing…\"],\"3SEwY1\":[\"Delete category \\\"\",[\"0\"],\"\\\"? This cannot be undone.\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" followed your playlist <1>\",[\"1\"],\"\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Dumped!\"],\"4GKuCs\":[\"Login failed\"],\"4HH9iB\":[\"Not following anyone yet.\"],\"4RtQ1k\":[\"Unfollow \",[\"targetUsername\"]],\"4c-qBx\":[\"Your password has been changed. You can now log in.\"],\"4yj9xV\":[\"Live updates are temporarily disconnected. Trying to reconnect…\"],\"5KHDCi\":[[\"0\"],\" and \",[\"1\"],\" are typing…\"],\"5TviPn\":[\"Cancel search\"],\"5cC8f2\":[\"Edited \",[\"0\"]],\"5oD9f_\":[\"Earlier\"],\"6Qly-0\":[\"a comment\"],\"6YE0P6\":[\"Editing \",[\"0\"]],\"6YtxFj\":[\"Name\"],\"6gRgw8\":[\"Retry\"],\"7PHCIN\":[\"Cancel removal\"],\"7PzzBU\":[\"User\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Username\"],\"8F1i42\":[\"Page not found\"],\"8Ug9jB\":[\"Related\"],\"8ZsakT\":[\"Password\"],\"8pxhI8\":[\"Please select a file.\"],\"92o1-U\":[\"Failed to delete\"],\"9BruTc\":[\"Why?\"],\"9l4qcT\":[\"Drop a replacement here\"],\"9uI_rE\":[\"Undo\"],\"9xDZu_\":[\"Could not load.\"],\"A0y396\":[\"+ Invite someone\"],\"A1taO8\":[\"Search\"],\"AHZflp\":[\"Chat\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"AeXO77\":[\"Account\"],\"AnRu_j\":[\"Cancel reply\"],\"CI50ct\":[\"Upvoted\"],\"Cj24wt\":[\"Registering…\"],\"D8DRYV\":[\"Delete this message?\"],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"ECiS12\":[\"View dump →\"],\"ED8n0z\":[\"the chat\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GDvlUT\":[\"Role\"],\"GLukaH\":[\"Moderator\"],\"GNSsCc\":[\"Failed to create playlist\"],\"GQ38dl\":[\"Chat (\",[\"unreadChatCount\"],\" unread)\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" characters: letters, numbers, or underscores\"],\"GptGxg\":[\"Change password\"],\"H4o4sk\":[\"No one yet.\"],\"H8pzW-\":[\"Failed to update avatar\"],\"HTLDA4\":[\"Loading more…\"],\"I-x669\":[\"Invitees\"],\"IZX7TO\":[\"Failed to generate invite\"],\"IagCbF\":[\"URL\"],\"ImOQa9\":[\"Reply\"],\"J2eKUI\":[\"File\"],\"JJ-Bhk\":[\"Your email address\"],\"JRQitQ\":[\"Confirm new password\"],\"JXr41k\":[\"<0>\",[\"0\"],\" commented on <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Hot\"],\"Jf0PuK\":[\"Go to login\"],\"JlFcis\":[\"Send\"],\"K7tIrx\":[\"Category\"],\"KDGWg5\":[\"Remove from playlist\"],\"K_F6pa\":[\"Saving…\"],\"L-rMC9\":[\"Reset to default\"],\"LLyMkV\":[\"Followed (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"MTb2Ot\":[\"No messages yet. Say hello!\"],\"Mj6oCJ\":[[\"0\"],\" is typing…\"],\"N40H-G\":[\"All\"],\"N6j2JH\":[\"Edit \",[\"0\"]],\"NUrY9o\":[\"Categories\"],\"Oprv1v\":[\"Password (min. \",[\"0\"],\" characters)\"],\"Oz0N9s\":[\"new\"],\"PiH3UR\":[\"Copied!\"],\"Pn2B7_\":[\"Current password\"],\"Pwqkdw\":[\"Loading…\"],\"Q6n4F4\":[\"Refresh metadata\"],\"QGaQRo\":[\"Failed to update role\"],\"QKsaQr\":[\"or <0>browse files\"],\"QLtPBd\":[\"No dumps in this playlist yet.\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Delete this playlist? This cannot be undone.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" started following you\"],\"RaKjrM\":[\"Failed to save edit\"],\"RcUHRT\":[\"Followed\"],\"RfwZxd\":[\"Reset password\"],\"Rrp6-J\":[\"Log in to like\"],\"SBTElJ\":[\"Searching…\"],\"Sad2tK\":[\"Sending…\"],\"StovX6\":[\"This page does not exist.\"],\"Sxm8rQ\":[\"Users\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" was added to <1>\",[\"1\"],\"\"],\"TM1ZbA\":[\"Playlists (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Invalid link\"],\"Tv9vbB\":[\"Follow playlist\"],\"Tz0i8g\":[\"Settings\"],\"U3pytU\":[\"Admin\"],\"UNMVei\":[\"Forgot password?\"],\"UOZith\":[\"Failed to post\"],\"URAieT\":[\"Log in to vote\"],\"UTiUFs\":[\"Fetching…\"],\"VCoEm-\":[\"Back to login\"],\"VKsaTi\":[\"Replying to\"],\"V_e7nf\":[\"Set new password\"],\"VnNJbN\":[\"From playlists\"],\"VskHIx\":[\"Delete category\"],\"VyTYmS\":[\"Change avatar\"],\"W9FRBT\":[\"Like\"],\"WE_r9K\":[\"Failed to create category\"],\"WfffrI\":[\"slug\"],\"WhimMi\":[\"Reset failed\"],\"WpXcBJ\":[\"Nothing here yet.\"],\"XJy2oN\":[\"Logging in…\"],\"Xan6QP\":[\"New dump\"],\"XgRtUf\":[\"Could not change password\"],\"Xi0Mn4\":[\"← Back to profile\"],\"XnL-Eu\":[\"No users match \\\"\",[\"q\"],\"\\\".\"],\"Xs2Lez\":[\"This reset link is missing or malformed.\"],\"YK1Dhc\":[\"a post\"],\"YcjhhX\":[\"User management\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" liked your comment on <1>\",[\"1\"],\"\"],\"YpkCca\":[\"No followed playlists yet.\"],\"ZBdbv9\":[\"Edit title\"],\"ZCpU0u\":[\"No playlists match \\\"\",[\"q\"],\"\\\".\"],\"ZmD2o6\":[\"Create & Add\"],\"_3O5R_\":[\"Request failed\"],\"_DwR-n\":[\"Creating…\"],\"_R_sGB\":[\"Password changed successfully.\"],\"_aept4\":[\"Post reply\"],\"_nT6AE\":[\"New password\"],\"_t4W-i\":[\"From people\"],\"aAIQg2\":[\"Appearance\"],\"aDvLhk\":[\"Add a comment…\"],\"b3Thhd\":[\"Upload failed\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"bQhwn-\":[\"Loading playlist…\"],\"cILfnJ\":[\"Remove file\"],\"cYP9Sb\":[\"+ Playlist\"],\"cbeBbZ\":[\"At least \",[\"0\"],\" characters\"],\"cnGeoo\":[\"Delete\"],\"d8DZWS\":[\"Open search\"],\"dAs22m\":[\"Replace file\"],\"dEgA5A\":[\"Cancel\"],\"dMizp8\":[\"New playlist\"],\"eFSqvc\":[\"Failed to post reply\"],\"eOfXq3\":[\"Title is required.\"],\"ePK91l\":[\"Edit\"],\"eaUTwS\":[\"Send reset link\"],\"ecUA8p\":[\"Today\"],\"ef9nPf\":[\"Loading dump…\"],\"en9o7K\":[\"Failed to post comment\"],\"etFQQS\":[\"What makes it worth it?\"],\"fC6mXb\":[\"Upvote\"],\"fI-mNw\":[\"Playlists\"],\"f_akpP\":[\"Max 50 MB\"],\"fgLNSM\":[\"Register\"],\"gANddk\":[\"Uploading…\"],\"gGx5tM\":[\"Editing\"],\"gIQQwD\":[\"Failed to load\"],\"gLfZlz\":[\"Add to playlist\"],\"gjJ-sb\":[\"Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects.\"],\"hBuUKa\":[\"Change password…\"],\"hD7w09\":[\"You've reached the end.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" posted <1>\",[\"1\"],\"\"],\"hYgDIe\":[\"Create\"],\"he3ygx\":[\"Copy\"],\"i7K_Te\":[\"Who am I?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Go home\"],\"ipYn7W\":[\"If that address is registered you'll receive a reset link shortly.\"],\"isRobC\":[\"New\"],\"jbernk\":[\"Loading profile…\"],\"joEmfT\":[\"Server unreachable\"],\"jrZTZl\":[\"No dumps yet. Be the first!\"],\"k112DD\":[\"Load older messages\"],\"k36iJ-\":[\"Failed to save category\"],\"kLttbL\":[\"Registration failed\"],\"lUDifl\":[\"Created (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" dump\"],\"other\":[\"#\",\" dumps\"]}]],\"lcfvr_\":[\"Delete this comment?\"],\"lpIMne\":[\"Passwords do not match\"],\"mt6O6E\":[\"This is a mirage.\"],\"nbm5sI\":[\"No dumps match \\\"\",[\"q\"],\"\\\".\"],\"nrjqON\":[\"Checking invite…\"],\"nwtY4N\":[\"Something went wrong\"],\"nx4kaN\":[\"Could not save\"],\"ogtYkT\":[\"Password updated\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" mentioned you in <1>\",[\"where\"],\"\"],\"pSheLH\":[\"No invitees yet.\"],\"pvnfJD\":[\"Dark\"],\"qIMfNQ\":[\"Delete playlist\"],\"qbDAcy\":[\"Dump it\"],\"qgx_78\":[\"Follow some public playlists to see their dumps here.\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Dump\"],\"rCbqPX\":[\"This invite link is missing, expired, or already used.\"],\"rDMzDU\":[\"Type a message…\"],\"rg9pXu\":[\"Search failed\"],\"rtpJqV\":[\"Dumps (\",[\"0\"],[\"1\"],\")\"],\"sGeXL3\":[\"Thumbnail\"],\"sQia9P\":[\"Log in\"],\"sTiqbm\":[\"invited by\"],\"sdP5Aa\":[\"[deleted]\"],\"shHs8T\":[\"Enter a query to search.\"],\"smeBfS\":[\"Invalid invite\"],\"tfDRzk\":[\"Save\"],\"tvmuQ0\":[\"Color scheme\"],\"u1lDX2\":[\"Fetching preview…\"],\"uD0qXQ\":[\"Drop a file here\"],\"uMGUnV\":[\"No playlists yet.\"],\"ub1EEL\":[\"edited \",[\"0\"]],\"uomLas\":[\"Add category\"],\"vJBF1r\":[\"Posting…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" unread)\"],\"vQMkHu\":[\"Remove vote\"],\"vuosjb\":[\"User menu\"],\"wbXKOv\":[\"File too large (max 50 MB).\"],\"wckWOP\":[\"Manage\"],\"wixIgH\":[\"Already have an account? <0>Log in\"],\"x6tjuK\":[\"Default tab\"],\"xEWkgZ\":[\"← Back to all dumps\"],\"xPHtx0\":[\"Submit search\"],\"xVuNgt\":[\"+ New playlist\"],\"xc9O_u\":[\"Delete dump\"],\"y6sq5j\":[\"Following\"],\"y7oaHj\":[\"Playlist title\"],\"yA_6BX\":[\"View all →\"],\"yBBtRm\":[\"Follow some users to see their dumps here.\"],\"yQ2kGp\":[\"Load more\"],\"y_0uwd\":[\"Yesterday\"],\"yz7wBu\":[\"Close\"],\"z0ROB3\":[\"Remove like\"],\"z1uNN0\":[\"No emoji found.\"],\"zVuxvN\":[\"Refreshing…\"],\"zwBp5t\":[\"Private\"]}")}; \ No newline at end of file diff --git a/src/locales/en.po b/src/locales/en.po index 57b52db..906a1e8 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -28,6 +28,17 @@ msgstr "{0, plural, one {# comment} other {# comments}}" msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# dump} other {# dumps}}" +#. placeholder {0}: names[0] +#. placeholder {1}: names[1] +#: src/components/ChatModal.tsx:528 +msgid "{0} and {1} are typing…" +msgstr "{0} and {1} are typing…" + +#. placeholder {0}: names[0] +#: src/components/ChatModal.tsx:527 +msgid "{0} is typing…" +msgstr "{0} is typing…" + #. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {1}: VALIDATION.USERNAME_MAX #: src/pages/UserRegister.tsx:126 @@ -180,14 +191,14 @@ msgstr "Auto" msgid "Back to login" msgstr "Back to login" -#: src/contexts/WSProvider.tsx:268 -#: src/contexts/WSProvider.tsx:570 +#: src/contexts/WSProvider.tsx:270 +#: src/contexts/WSProvider.tsx:576 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:158 +#: src/components/ChatModal.tsx:229 #: src/components/CommentThread.tsx:124 #: src/components/ConfirmModal.tsx:32 #: src/components/form/FormActions.tsx:32 @@ -203,6 +214,11 @@ msgstr "Cancel" msgid "Cancel removal" msgstr "Cancel removal" +#: src/components/ChatModal.tsx:619 +#: src/components/ChatModal.tsx:620 +msgid "Cancel reply" +msgstr "Cancel reply" + #: src/components/AppHeader.tsx:66 msgid "Cancel search" msgstr "Cancel search" @@ -232,7 +248,7 @@ msgstr "Change password…" #: src/components/ChatButton.tsx:17 #: src/components/ChatFab.tsx:49 #: src/components/ChatFab.tsx:50 -#: src/components/ChatModal.tsx:308 +#: src/components/ChatModal.tsx:533 msgid "Chat" msgstr "Chat" @@ -311,9 +327,9 @@ msgstr "Default tab" #: src/components/CategoryManager.tsx:221 #: src/components/CategoryManager.tsx:222 -#: src/components/ChatModal.tsx:112 -#: src/components/ChatModal.tsx:113 -#: src/components/ChatModal.tsx:168 +#: src/components/ChatModal.tsx:183 +#: src/components/ChatModal.tsx:184 +#: src/components/ChatModal.tsx:239 #: src/components/CommentThread.tsx:376 #: src/components/CommentThread.tsx:382 #: src/components/ConfirmModal.tsx:16 @@ -349,7 +365,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:167 +#: src/components/ChatModal.tsx:238 msgid "Delete this message?" msgstr "Delete this message?" @@ -358,6 +374,10 @@ msgstr "Delete this message?" msgid "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone." +#: src/components/ChatModal.tsx:129 +msgid "deleted message" +msgstr "deleted message" + #: src/components/PlaylistCreateForm.tsx:78 #: src/pages/PlaylistDetail.tsx:877 msgid "Description (optional)" @@ -403,8 +423,8 @@ msgstr "Dumps ({0}{1})" msgid "Earlier" msgstr "Earlier" -#: src/components/ChatModal.tsx:101 -#: src/components/ChatModal.tsx:102 +#: src/components/ChatModal.tsx:172 +#: src/components/ChatModal.tsx:173 #: src/components/CommentThread.tsx:367 #: src/pages/Dump.tsx:490 #: src/pages/PlaylistDetail.tsx:625 @@ -423,7 +443,7 @@ msgstr "Edit title" #. placeholder {0}: relativeTime(comment.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt) -#: src/components/ChatModal.tsx:90 +#: src/components/ChatModal.tsx:152 #: src/components/CommentThread.tsx:317 #: src/pages/Dump.tsx:429 #: src/pages/PlaylistDetail.tsx:664 @@ -433,7 +453,7 @@ msgstr "edited {0}" #. placeholder {0}: comment.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString() -#: src/components/ChatModal.tsx:88 +#: src/components/ChatModal.tsx:150 #: src/components/CommentThread.tsx:315 #: src/pages/Dump.tsx:427 #: src/pages/PlaylistDetail.tsx:661 @@ -643,7 +663,7 @@ msgstr "Light" msgid "Like" msgstr "Like" -#: src/contexts/WSProvider.tsx:569 +#: src/contexts/WSProvider.tsx:575 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" @@ -656,7 +676,7 @@ msgstr "Live updates unavailable." msgid "Load more" msgstr "Load more" -#: src/components/ChatModal.tsx:321 +#: src/components/ChatModal.tsx:546 msgid "Load older messages" msgstr "Load older messages" @@ -686,7 +706,7 @@ msgid "Loading profile…" msgstr "Loading profile…" #: src/components/CategoryManager.tsx:52 -#: src/components/ChatModal.tsx:320 +#: src/components/ChatModal.tsx:545 #: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/TextEditor.tsx:289 #: src/components/UserListPopover.tsx:192 @@ -805,7 +825,7 @@ msgstr "No followed playlists yet." msgid "No invitees yet." msgstr "No invitees yet." -#: src/components/ChatModal.tsx:328 +#: src/components/ChatModal.tsx:553 msgid "No messages yet. Say hello!" msgstr "No messages yet. Say hello!" @@ -989,10 +1009,16 @@ msgstr "Remove vote" msgid "Replace file" msgstr "Replace file" +#: src/components/ChatModal.tsx:162 +#: src/components/ChatModal.tsx:163 #: src/components/CommentThread.tsx:355 msgid "Reply" msgstr "Reply" +#: src/components/ChatModal.tsx:609 +msgid "Replying to" +msgstr "Replying to" + #: src/pages/UserLogin.tsx:132 msgid "Request failed" msgstr "Request failed" @@ -1019,7 +1045,7 @@ msgstr "Retry" msgid "Role" msgstr "Role" -#: src/components/ChatModal.tsx:151 +#: src/components/ChatModal.tsx:222 #: src/components/CommentThread.tsx:328 #: src/pages/Dump.tsx:368 #: src/pages/DumpEdit.tsx:466 @@ -1057,7 +1083,7 @@ msgstr "Search failed" msgid "Searching…" msgstr "Searching…" -#: src/components/ChatModal.tsx:369 +#: src/components/ChatModal.tsx:655 msgid "Send" msgstr "Send" @@ -1082,6 +1108,10 @@ msgstr "Set new password" msgid "Settings" msgstr "Settings" +#: src/components/ChatModal.tsx:529 +msgid "Several people are typing…" +msgstr "Several people are typing…" + #: src/components/CategoryManager.tsx:191 msgid "slug" msgstr "slug" @@ -1133,11 +1163,12 @@ msgstr "Title" msgid "Title is required." msgstr "Title is required." +#: src/components/ChatModal.tsx:262 #: src/pages/Notifications.tsx:397 msgid "Today" msgstr "Today" -#: src/components/ChatModal.tsx:350 +#: src/components/ChatModal.tsx:632 msgid "Type a message…" msgstr "Type a message…" @@ -1240,6 +1271,7 @@ msgstr "Why?" msgid "Write a reply…" msgstr "Write a reply…" +#: src/components/ChatModal.tsx:265 #: src/pages/Notifications.tsx:399 msgid "Yesterday" msgstr "Yesterday" diff --git a/src/locales/fr.js b/src/locales/fr.js index 11847b3..0b5e59c 100644 --- a/src/locales/fr.js +++ b/src/locales/fr.js @@ -1 +1 @@ -/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-OxI15\":[\"Collections suivies\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1cbYY_\":[\"Votés (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2ygf_L\":[\"← Retour\"],\"3KKSM4\":[\"privé\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <1>\",[\"1\"],\"\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Recommandé !\"],\"4GKuCs\":[\"Connexion échouée\"],\"4HH9iB\":[\"Aucun abonnement pour le moment.\"],\"4RtQ1k\":[\"Ne plus suivre \",[\"targetUsername\"]],\"4c-qBx\":[\"Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter.\"],\"4yj9xV\":[\"Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8F1i42\":[\"Page introuvable\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"8pxhI8\":[\"Veuillez sélectionner un fichier.\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"9xDZu_\":[\"Impossible de charger.\"],\"A0y396\":[\"+ Inviter quelqu'un\"],\"A1taO8\":[\"Rechercher\"],\"AHZflp\":[\"Tribune\"],\"AQbgNR\":[\"Suivre \",[\"targetUsername\"]],\"ATGYL1\":[\"Adresse e-mail\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"Ade-6d\":[\"Mises à jour en direct indisponibles.\"],\"AeXO77\":[\"Compte\"],\"CI50ct\":[\"Voté\"],\"Cj24wt\":[\"Inscription…\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a voté pour <1>\",[\"1\"],\"\"],\"ECiS12\":[\"Voir la reco →\"],\"ED8n0z\":[\"la tribune\"],\"ExR0Fr\":[\"L'URL est obligatoire.\"],\"F5Js1v\":[\"Ne plus suivre la collection\"],\"FgAxTj\":[\"Se déconnecter\"],\"Fxf4jq\":[\"Description (facultatif)\"],\"GDvlUT\":[\"Rôle\"],\"GLukaH\":[\"Modérateur\"],\"GNSsCc\":[\"Impossible de créer la collection\"],\"GQ38dl\":[[\"unreadChatCount\"],\" messages non lus\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" caractères : lettres, chiffres ou tirets bas\"],\"GptGxg\":[\"Changer le mot de passe\"],\"H4o4sk\":[\"Personne pour le moment.\"],\"H8pzW-\":[\"Impossible de mettre à jour l'avatar\"],\"HTLDA4\":[\"Chargement…\"],\"I-x669\":[\"Invités\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"ImOQa9\":[\"Répondre\"],\"J2eKUI\":[\"Fichier\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"K7tIrx\":[\"Catégorie\"],\"KDGWg5\":[\"Retirer de la collection\"],\"K_F6pa\":[\"Enregistrement…\"],\"L-rMC9\":[\"Réinitialiser par défaut\"],\"LLyMkV\":[\"Suivies (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"Oz0N9s\":[\"nouveau\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" a commencé à vous suivre\"],\"RaKjrM\":[\"Impossible d'enregistrer la modification\"],\"RcUHRT\":[\"Suivi\"],\"RfwZxd\":[\"Réinitialiser le mot de passe\"],\"Rrp6-J\":[\"Se connecter pour aimer\"],\"SBTElJ\":[\"Recherche…\"],\"Sad2tK\":[\"Envoi…\"],\"StovX6\":[\"Rien à voir, circulez.\"],\"Sxm8rQ\":[\"Utilisateurs\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TM1ZbA\":[\"Collections (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Lien invalide\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour voter\"],\"UTiUFs\":[\"Récupération…\"],\"VCoEm-\":[\"Retour à la connexion\"],\"V_e7nf\":[\"Définir un nouveau mot de passe\"],\"VnNJbN\":[\"De collections\"],\"VskHIx\":[\"Supprimer la catégorie\"],\"VyTYmS\":[\"Changer l'avatar\"],\"W9FRBT\":[\"Aimer\"],\"WE_r9K\":[\"Échec de la création de la catégorie\"],\"WfffrI\":[\"identifiant\"],\"WhimMi\":[\"Échec de la réinitialisation\"],\"WpXcBJ\":[\"Rien ici pour l'instant.\"],\"XJy2oN\":[\"Connexion…\"],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"Xi0Mn4\":[\"← Retour au profil\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"Xs2Lez\":[\"Ce lien de réinitialisation est absent ou malformé.\"],\"YK1Dhc\":[\"une publication\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <1>\",[\"1\"],\"\"],\"YpkCca\":[\"Pas encore de collections suivies.\"],\"ZBdbv9\":[\"Modifier le titre\"],\"ZCpU0u\":[\"Aucune collection ne correspond à « \",[\"q\"],\" ».\"],\"ZmD2o6\":[\"Créer et ajouter\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_nT6AE\":[\"Nouveau mot de passe\"],\"_t4W-i\":[\"De personnes\"],\"aAIQg2\":[\"Apparence\"],\"aDvLhk\":[\"Ajouter un commentaire…\"],\"b3Thhd\":[\"Envoi échoué\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"bQhwn-\":[\"Chargement de la collection…\"],\"cILfnJ\":[\"Supprimer le fichier\"],\"cYP9Sb\":[\"+ Collection\"],\"cbeBbZ\":[\"Au moins \",[\"0\"],\" caractères\"],\"cnGeoo\":[\"Supprimer\"],\"d8DZWS\":[\"Ouvrir la recherche\"],\"dAs22m\":[\"Remplacer le fichier\"],\"dEgA5A\":[\"Annuler\"],\"dMizp8\":[\"Nouvelle collection\"],\"eFSqvc\":[\"Impossible de publier la réponse\"],\"eOfXq3\":[\"Un titre est requis.\"],\"ePK91l\":[\"Modifier\"],\"eaUTwS\":[\"Envoyer le lien de réinitialisation\"],\"ecUA8p\":[\"Aujourd'hui\"],\"ef9nPf\":[\"Chargement de la reco…\"],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"fC6mXb\":[\"Voter\"],\"fI-mNw\":[\"Collections\"],\"f_akpP\":[\"Max 50 Mo\"],\"fgLNSM\":[\"S'inscrire\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gLfZlz\":[\"Ajouter à la collection\"],\"gjJ-sb\":[\"Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion.\"],\"hBuUKa\":[\"Changer le mot de passe…\"],\"hD7w09\":[\"Vous avez tout lu, tout vu, tout bu.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" a publié <1>\",[\"1\"],\"\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"i7K_Te\":[\"Qui suis-je ?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Accueil\"],\"ipYn7W\":[\"Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu.\"],\"isRobC\":[\"Nouveau\"],\"jbernk\":[\"Chargement du profil…\"],\"joEmfT\":[\"Serveur inaccessible\"],\"jrZTZl\":[\"Pas encore de recos. Soyez le premier !\"],\"k112DD\":[\"Charger les messages plus anciens\"],\"k36iJ-\":[\"Échec de l’enregistrement de la catégorie\"],\"kLttbL\":[\"Inscription échouée\"],\"lUDifl\":[\"Créées (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"lcfvr_\":[\"Supprimer ce commentaire ?\"],\"lpIMne\":[\"Les mots de passe ne correspondent pas\"],\"mt6O6E\":[\"C'est un mirage.\"],\"nbm5sI\":[\"Aucune reco ne correspond à « \",[\"q\"],\" ».\"],\"nrjqON\":[\"Vérification de l'invitation…\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pvnfJD\":[\"Sombre\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Reco\"],\"rCbqPX\":[\"Ce lien d'invitation est manquant, expiré ou déjà utilisé.\"],\"rDMzDU\":[\"Écrivez un message…\"],\"rg9pXu\":[\"Recherche échouée\"],\"rtpJqV\":[\"Recos (\",[\"0\"],[\"1\"],\")\"],\"sGeXL3\":[\"Miniature\"],\"sQia9P\":[\"Se connecter\"],\"sTiqbm\":[\"invité par\"],\"sdP5Aa\":[\"[supprimé]\"],\"shHs8T\":[\"Saisissez une recherche.\"],\"smeBfS\":[\"Invitation invalide\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"uD0qXQ\":[\"Déposez un fichier ici\"],\"uMGUnV\":[\"Pas encore de collections.\"],\"ub1EEL\":[\"modifié \",[\"0\"]],\"uomLas\":[\"Ajouter une catégorie\"],\"vJBF1r\":[\"Publication…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" non lues)\"],\"vQMkHu\":[\"Retirer le vote\"],\"vuosjb\":[\"Menu utilisateur\"],\"wbXKOv\":[\"Fichier trop volumineux (max 50 Mo).\"],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y6sq5j\":[\"Abonné\"],\"y7oaHj\":[\"Titre de la collection\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yz7wBu\":[\"Fermer\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}")}; \ No newline at end of file +/*eslint-disable*/module.exports={messages:JSON.parse("{\"-K9EZb\":[\"Ajouter un e-mail…\"],\"-OxI15\":[\"Collections suivies\"],\"-Ya-b9\":[\"Enregistrement échoué\"],\"-siMqD\":[\"Journal\"],\"1CalO6\":[\"Style\"],\"1HfJWf\":[\"Rechercher des recos, utilisateurs, collections…\"],\"1cbYY_\":[\"Votés (\",[\"0\"],[\"1\"],\")\"],\"1njn7W\":[\"Clair\"],\"1utXA6\":[\"Recos\"],\"26iNma\":[\"Publier le commentaire\"],\"2DoBvq\":[\"Flux\"],\"2Hlmdt\":[\"Écrire une réponse…\"],\"2_DHgA\":[\"Identifiant d’URL\"],\"2nrsEn\":[\"message supprimé\"],\"2ygf_L\":[\"← Retour\"],\"3KKSM4\":[\"privé\"],\"3QuNUr\":[\"Plusieurs personnes écrivent…\"],\"3SEwY1\":[\"Supprimer la catégorie \\\"\",[\"0\"],\"\\\" ? Cette action est irréversible.\"],\"3yfh3D\":[\"<0>\",[\"0\"],\" a suivi votre collection <1>\",[\"1\"],\"\"],\"49voTZ\":[[\"label\"],\" (\",[\"count\"],\")\"],\"4B6w_o\":[\"Recommandé !\"],\"4GKuCs\":[\"Connexion échouée\"],\"4HH9iB\":[\"Aucun abonnement pour le moment.\"],\"4RtQ1k\":[\"Ne plus suivre \",[\"targetUsername\"]],\"4c-qBx\":[\"Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter.\"],\"4yj9xV\":[\"Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…\"],\"5KHDCi\":[[\"0\"],\" et \",[\"1\"],\" sont en train d'écrire…\"],\"5TviPn\":[\"Annuler la recherche\"],\"5cC8f2\":[\"Modifié le \",[\"0\"]],\"5oD9f_\":[\"Plus tôt\"],\"6Qly-0\":[\"un commentaire\"],\"6YE0P6\":[\"Édition de \",[\"0\"]],\"6YtxFj\":[\"Nom\"],\"6gRgw8\":[\"Réessayer\"],\"7PHCIN\":[\"Annuler la suppression\"],\"7PzzBU\":[\"Utilisateur\"],\"7d1a0d\":[\"Public\"],\"7sNhEz\":[\"Nom d'utilisateur\"],\"8F1i42\":[\"Page introuvable\"],\"8Ug9jB\":[\"Connexe\"],\"8ZsakT\":[\"Mot de passe\"],\"8pxhI8\":[\"Veuillez sélectionner un fichier.\"],\"92o1-U\":[\"Suppression échouée\"],\"9BruTc\":[\"Pourquoi ?\"],\"9l4qcT\":[\"Déposez un fichier de remplacement ici\"],\"9uI_rE\":[\"Annuler\"],\"9xDZu_\":[\"Impossible de charger.\"],\"A0y396\":[\"+ Inviter quelqu'un\"],\"A1taO8\":[\"Rechercher\"],\"AHZflp\":[\"Tribune\"],\"AQbgNR\":[\"Suivre \",[\"targetUsername\"]],\"ATGYL1\":[\"Adresse e-mail\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"Ade-6d\":[\"Mises à jour en direct indisponibles.\"],\"AeXO77\":[\"Compte\"],\"AnRu_j\":[\"Annuler la réponse\"],\"CI50ct\":[\"Voté\"],\"Cj24wt\":[\"Inscription…\"],\"D8DRYV\":[\"Supprimer ce message ?\"],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a voté pour <1>\",[\"1\"],\"\"],\"ECiS12\":[\"Voir la reco →\"],\"ED8n0z\":[\"la tribune\"],\"ExR0Fr\":[\"L'URL est obligatoire.\"],\"F5Js1v\":[\"Ne plus suivre la collection\"],\"FgAxTj\":[\"Se déconnecter\"],\"Fxf4jq\":[\"Description (facultatif)\"],\"GDvlUT\":[\"Rôle\"],\"GLukaH\":[\"Modérateur\"],\"GNSsCc\":[\"Impossible de créer la collection\"],\"GQ38dl\":[[\"unreadChatCount\"],\" messages non lus\"],\"GbqhrN\":[[\"0\"],\"–\",[\"1\"],\" caractères : lettres, chiffres ou tirets bas\"],\"GptGxg\":[\"Changer le mot de passe\"],\"H4o4sk\":[\"Personne pour le moment.\"],\"H8pzW-\":[\"Impossible de mettre à jour l'avatar\"],\"HTLDA4\":[\"Chargement…\"],\"I-x669\":[\"Invités\"],\"IZX7TO\":[\"Impossible de générer une invitation\"],\"IagCbF\":[\"URL\"],\"ImOQa9\":[\"Répondre\"],\"J2eKUI\":[\"Fichier\"],\"JJ-Bhk\":[\"Votre adresse e-mail\"],\"JRQitQ\":[\"Confirmer le nouveau mot de passe\"],\"JXr41k\":[\"<0>\",[\"0\"],\" a commenté sur <1>\",[\"1\"],\"\"],\"Jd58Fo\":[\"Tendances\"],\"Jf0PuK\":[\"Aller à la connexion\"],\"JlFcis\":[\"Envoyer\"],\"K7tIrx\":[\"Catégorie\"],\"KDGWg5\":[\"Retirer de la collection\"],\"K_F6pa\":[\"Enregistrement…\"],\"L-rMC9\":[\"Réinitialiser par défaut\"],\"LLyMkV\":[\"Suivies (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Titre\"],\"MKEPCY\":[\"Suivre\"],\"MTb2Ot\":[\"Aucun message pour l'instant. Dites bonjour !\"],\"Mj6oCJ\":[[\"0\"],\" est en train d'écrire…\"],\"N40H-G\":[\"Tout\"],\"N6j2JH\":[\"Modifier \",[\"0\"]],\"NUrY9o\":[\"Catégories\"],\"Oprv1v\":[\"Mot de passe (min. \",[\"0\"],\" caractères)\"],\"Oz0N9s\":[\"nouveau\"],\"PiH3UR\":[\"Copié !\"],\"Pn2B7_\":[\"Mot de passe actuel\"],\"Pwqkdw\":[\"Chargement…\"],\"Q6n4F4\":[\"Actualiser les métadonnées\"],\"QGaQRo\":[\"Erreur lors de la mise à jour du rôle\"],\"QKsaQr\":[\"ou <0>parcourir les fichiers\"],\"QLtPBd\":[\"Aucune reco dans cette collection pour l'instant.\"],\"R9Khdg\":[\"Auto\"],\"RCcPrX\":[\"Supprimer cette collection ? Cette action est irréversible.\"],\"RTksSy\":[\"<0>\",[\"0\"],\" a commencé à vous suivre\"],\"RaKjrM\":[\"Impossible d'enregistrer la modification\"],\"RcUHRT\":[\"Suivi\"],\"RfwZxd\":[\"Réinitialiser le mot de passe\"],\"Rrp6-J\":[\"Se connecter pour aimer\"],\"SBTElJ\":[\"Recherche…\"],\"Sad2tK\":[\"Envoi…\"],\"StovX6\":[\"Rien à voir, circulez.\"],\"Sxm8rQ\":[\"Utilisateurs\"],\"T9bjWt\":[\"<0>\",[\"0\"],\" a été ajouté à <1>\",[\"1\"],\"\"],\"TM1ZbA\":[\"Collections (\",[\"0\"],[\"1\"],\")\"],\"TN382O\":[\"Lien invalide\"],\"Tv9vbB\":[\"Suivre la collection\"],\"Tz0i8g\":[\"Paramètres\"],\"U3pytU\":[\"Administrateur\"],\"UNMVei\":[\"Mot de passe oublié ?\"],\"UOZith\":[\"Publication échouée\"],\"URAieT\":[\"Se connecter pour voter\"],\"UTiUFs\":[\"Récupération…\"],\"VCoEm-\":[\"Retour à la connexion\"],\"VKsaTi\":[\"En réponse à\"],\"V_e7nf\":[\"Définir un nouveau mot de passe\"],\"VnNJbN\":[\"De collections\"],\"VskHIx\":[\"Supprimer la catégorie\"],\"VyTYmS\":[\"Changer l'avatar\"],\"W9FRBT\":[\"Aimer\"],\"WE_r9K\":[\"Échec de la création de la catégorie\"],\"WfffrI\":[\"identifiant\"],\"WhimMi\":[\"Échec de la réinitialisation\"],\"WpXcBJ\":[\"Rien ici pour l'instant.\"],\"XJy2oN\":[\"Connexion…\"],\"Xan6QP\":[\"Nouvelle reco\"],\"XgRtUf\":[\"Impossible de changer le mot de passe\"],\"Xi0Mn4\":[\"← Retour au profil\"],\"XnL-Eu\":[\"Aucun utilisateur ne correspond à « \",[\"q\"],\" ».\"],\"Xs2Lez\":[\"Ce lien de réinitialisation est absent ou malformé.\"],\"YK1Dhc\":[\"une publication\"],\"YcjhhX\":[\"Gestion utilisateur\"],\"Ye9RMF\":[\"<0>\",[\"0\"],\" a aimé votre commentaire sur <1>\",[\"1\"],\"\"],\"YpkCca\":[\"Pas encore de collections suivies.\"],\"ZBdbv9\":[\"Modifier le titre\"],\"ZCpU0u\":[\"Aucune collection ne correspond à « \",[\"q\"],\" ».\"],\"ZmD2o6\":[\"Créer et ajouter\"],\"_3O5R_\":[\"Échec de la demande\"],\"_DwR-n\":[\"Création…\"],\"_R_sGB\":[\"Mot de passe modifié avec succès.\"],\"_aept4\":[\"Publier la réponse\"],\"_nT6AE\":[\"Nouveau mot de passe\"],\"_t4W-i\":[\"De personnes\"],\"aAIQg2\":[\"Apparence\"],\"aDvLhk\":[\"Ajouter un commentaire…\"],\"b3Thhd\":[\"Envoi échoué\"],\"b8XMJ8\":[[\"visibleCount\",\"plural\",{\"one\":[\"#\",\" commentaire\"],\"other\":[\"#\",\" commentaires\"]}]],\"bQhwn-\":[\"Chargement de la collection…\"],\"cILfnJ\":[\"Supprimer le fichier\"],\"cYP9Sb\":[\"+ Collection\"],\"cbeBbZ\":[\"Au moins \",[\"0\"],\" caractères\"],\"cnGeoo\":[\"Supprimer\"],\"d8DZWS\":[\"Ouvrir la recherche\"],\"dAs22m\":[\"Remplacer le fichier\"],\"dEgA5A\":[\"Annuler\"],\"dMizp8\":[\"Nouvelle collection\"],\"eFSqvc\":[\"Impossible de publier la réponse\"],\"eOfXq3\":[\"Un titre est requis.\"],\"ePK91l\":[\"Modifier\"],\"eaUTwS\":[\"Envoyer le lien de réinitialisation\"],\"ecUA8p\":[\"Aujourd'hui\"],\"ef9nPf\":[\"Chargement de la reco…\"],\"en9o7K\":[\"Impossible de publier le commentaire\"],\"etFQQS\":[\"Pourquoi on en voudrait ?\"],\"fC6mXb\":[\"Voter\"],\"fI-mNw\":[\"Collections\"],\"f_akpP\":[\"Max 50 Mo\"],\"fgLNSM\":[\"S'inscrire\"],\"gANddk\":[\"Envoi…\"],\"gGx5tM\":[\"Modification\"],\"gIQQwD\":[\"Chargement échoué\"],\"gLfZlz\":[\"Ajouter à la collection\"],\"gjJ-sb\":[\"Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion.\"],\"hBuUKa\":[\"Changer le mot de passe…\"],\"hD7w09\":[\"Vous avez tout lu, tout vu, tout bu.\"],\"hJSliC\":[\"<0>\",[\"0\"],\" a publié <1>\",[\"1\"],\"\"],\"hYgDIe\":[\"Créer\"],\"he3ygx\":[\"Copier\"],\"i7K_Te\":[\"Qui suis-je ?\"],\"iDNBZe\":[\"Notifications\"],\"iWpEwy\":[\"Accueil\"],\"ipYn7W\":[\"Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu.\"],\"isRobC\":[\"Nouveau\"],\"jbernk\":[\"Chargement du profil…\"],\"joEmfT\":[\"Serveur inaccessible\"],\"jrZTZl\":[\"Pas encore de recos. Soyez le premier !\"],\"k112DD\":[\"Charger les messages plus anciens\"],\"k36iJ-\":[\"Échec de l’enregistrement de la catégorie\"],\"kLttbL\":[\"Inscription échouée\"],\"lUDifl\":[\"Créées (\",[\"0\"],[\"1\"],\")\"],\"lUanmi\":[\"Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu.\"],\"lY5h1V\":[[\"0\",\"plural\",{\"one\":[\"#\",\" reco\"],\"other\":[\"#\",\" recos\"]}]],\"lcfvr_\":[\"Supprimer ce commentaire ?\"],\"lpIMne\":[\"Les mots de passe ne correspondent pas\"],\"mt6O6E\":[\"C'est un mirage.\"],\"nbm5sI\":[\"Aucune reco ne correspond à « \",[\"q\"],\" ».\"],\"nrjqON\":[\"Vérification de l'invitation…\"],\"nwtY4N\":[\"Une erreur est survenue\"],\"nx4kaN\":[\"Sauvegarde impossible\"],\"ogtYkT\":[\"Mot de passe mis à jour\"],\"pCpd9p\":[\"<0>\",[\"0\"],\" vous a mentionné dans <1>\",[\"where\"],\"\"],\"pSheLH\":[\"Aucun invité pour le moment.\"],\"pvnfJD\":[\"Sombre\"],\"qIMfNQ\":[\"Supprimer la collection\"],\"qbDAcy\":[\"Recommander\"],\"qgx_78\":[\"Suivez des collections publiques pour voir leurs recos ici.\"],\"qvFa8r\":[\"public\"],\"qvz_Pp\":[\"Reco\"],\"rCbqPX\":[\"Ce lien d'invitation est manquant, expiré ou déjà utilisé.\"],\"rDMzDU\":[\"Écrivez un message…\"],\"rg9pXu\":[\"Recherche échouée\"],\"rtpJqV\":[\"Recos (\",[\"0\"],[\"1\"],\")\"],\"sGeXL3\":[\"Miniature\"],\"sQia9P\":[\"Se connecter\"],\"sTiqbm\":[\"invité par\"],\"sdP5Aa\":[\"[supprimé]\"],\"shHs8T\":[\"Saisissez une recherche.\"],\"smeBfS\":[\"Invitation invalide\"],\"tfDRzk\":[\"Enregistrer\"],\"tvmuQ0\":[\"Thème de couleur\"],\"u1lDX2\":[\"Récupération de l'aperçu…\"],\"uD0qXQ\":[\"Déposez un fichier ici\"],\"uMGUnV\":[\"Pas encore de collections.\"],\"ub1EEL\":[\"modifié \",[\"0\"]],\"uomLas\":[\"Ajouter une catégorie\"],\"vJBF1r\":[\"Publication…\"],\"vLhLLO\":[\"Notifications (\",[\"unreadNotificationCount\"],\" non lues)\"],\"vQMkHu\":[\"Retirer le vote\"],\"vuosjb\":[\"Menu utilisateur\"],\"wbXKOv\":[\"Fichier trop volumineux (max 50 Mo).\"],\"wckWOP\":[\"Administration\"],\"wixIgH\":[\"Vous avez déjà un compte ? <0>Se connecter\"],\"x6tjuK\":[\"Onglet par défaut\"],\"xEWkgZ\":[\"← Retour à toutes les recos\"],\"xPHtx0\":[\"Lancer la recherche\"],\"xVuNgt\":[\"+ Nouvelle collection\"],\"xc9O_u\":[\"Supprimer la reco\"],\"y6sq5j\":[\"Abonné\"],\"y7oaHj\":[\"Titre de la collection\"],\"yA_6BX\":[\"Tout voir →\"],\"yBBtRm\":[\"Suivez des utilisateurs pour voir leurs recos ici.\"],\"yQ2kGp\":[\"Charger plus\"],\"y_0uwd\":[\"Hier\"],\"yz7wBu\":[\"Fermer\"],\"z0ROB3\":[\"Retirer le j'aime\"],\"z1uNN0\":[\"Aucun emoji trouvé.\"],\"zVuxvN\":[\"Actualisation…\"],\"zwBp5t\":[\"Privé\"]}")}; \ No newline at end of file diff --git a/src/locales/fr.po b/src/locales/fr.po index ae17623..0af0e55 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -28,6 +28,17 @@ msgstr "{0, plural, one {# commentaire} other {# commentaires}}" msgid "{0, plural, one {# dump} other {# dumps}}" msgstr "{0, plural, one {# reco} other {# recos}}" +#. placeholder {0}: names[0] +#. placeholder {1}: names[1] +#: src/components/ChatModal.tsx:528 +msgid "{0} and {1} are typing…" +msgstr "{0} et {1} sont en train d'écrire…" + +#. placeholder {0}: names[0] +#: src/components/ChatModal.tsx:527 +msgid "{0} is typing…" +msgstr "{0} est en train d'écrire…" + #. placeholder {0}: VALIDATION.USERNAME_MIN #. placeholder {1}: VALIDATION.USERNAME_MAX #: src/pages/UserRegister.tsx:126 @@ -180,14 +191,14 @@ msgstr "Auto" msgid "Back to login" msgstr "Retour à la connexion" -#: src/contexts/WSProvider.tsx:268 -#: src/contexts/WSProvider.tsx:570 +#: src/contexts/WSProvider.tsx:270 +#: src/contexts/WSProvider.tsx:576 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:158 +#: src/components/ChatModal.tsx:229 #: src/components/CommentThread.tsx:124 #: src/components/ConfirmModal.tsx:32 #: src/components/form/FormActions.tsx:32 @@ -203,6 +214,11 @@ msgstr "Annuler" msgid "Cancel removal" msgstr "Annuler la suppression" +#: src/components/ChatModal.tsx:619 +#: src/components/ChatModal.tsx:620 +msgid "Cancel reply" +msgstr "Annuler la réponse" + #: src/components/AppHeader.tsx:66 msgid "Cancel search" msgstr "Annuler la recherche" @@ -232,7 +248,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:308 +#: src/components/ChatModal.tsx:533 msgid "Chat" msgstr "Tribune" @@ -311,9 +327,9 @@ msgstr "Onglet par défaut" #: src/components/CategoryManager.tsx:221 #: src/components/CategoryManager.tsx:222 -#: src/components/ChatModal.tsx:112 -#: src/components/ChatModal.tsx:113 -#: src/components/ChatModal.tsx:168 +#: src/components/ChatModal.tsx:183 +#: src/components/ChatModal.tsx:184 +#: src/components/ChatModal.tsx:239 #: src/components/CommentThread.tsx:376 #: src/components/CommentThread.tsx:382 #: src/components/ConfirmModal.tsx:16 @@ -349,7 +365,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:167 +#: src/components/ChatModal.tsx:238 msgid "Delete this message?" msgstr "Supprimer ce message ?" @@ -358,6 +374,10 @@ 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:129 +msgid "deleted message" +msgstr "message supprimé" + #: src/components/PlaylistCreateForm.tsx:78 #: src/pages/PlaylistDetail.tsx:877 msgid "Description (optional)" @@ -403,8 +423,8 @@ msgstr "Recos ({0}{1})" msgid "Earlier" msgstr "Plus tôt" -#: src/components/ChatModal.tsx:101 -#: src/components/ChatModal.tsx:102 +#: src/components/ChatModal.tsx:172 +#: src/components/ChatModal.tsx:173 #: src/components/CommentThread.tsx:367 #: src/pages/Dump.tsx:490 #: src/pages/PlaylistDetail.tsx:625 @@ -423,7 +443,7 @@ msgstr "Modifier le titre" #. placeholder {0}: relativeTime(comment.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt) -#: src/components/ChatModal.tsx:90 +#: src/components/ChatModal.tsx:152 #: src/components/CommentThread.tsx:317 #: src/pages/Dump.tsx:429 #: src/pages/PlaylistDetail.tsx:664 @@ -433,7 +453,7 @@ msgstr "modifié {0}" #. placeholder {0}: comment.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString() -#: src/components/ChatModal.tsx:88 +#: src/components/ChatModal.tsx:150 #: src/components/CommentThread.tsx:315 #: src/pages/Dump.tsx:427 #: src/pages/PlaylistDetail.tsx:661 @@ -643,7 +663,7 @@ msgstr "Clair" msgid "Like" msgstr "Aimer" -#: src/contexts/WSProvider.tsx:569 +#: src/contexts/WSProvider.tsx:575 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" @@ -656,7 +676,7 @@ msgstr "Mises à jour en direct indisponibles." msgid "Load more" msgstr "Charger plus" -#: src/components/ChatModal.tsx:321 +#: src/components/ChatModal.tsx:546 msgid "Load older messages" msgstr "Charger les messages plus anciens" @@ -686,7 +706,7 @@ msgid "Loading profile…" msgstr "Chargement du profil…" #: src/components/CategoryManager.tsx:52 -#: src/components/ChatModal.tsx:320 +#: src/components/ChatModal.tsx:545 #: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/TextEditor.tsx:289 #: src/components/UserListPopover.tsx:192 @@ -805,7 +825,7 @@ msgstr "Pas encore de collections suivies." msgid "No invitees yet." msgstr "Aucun invité pour le moment." -#: src/components/ChatModal.tsx:328 +#: src/components/ChatModal.tsx:553 msgid "No messages yet. Say hello!" msgstr "Aucun message pour l'instant. Dites bonjour !" @@ -989,10 +1009,16 @@ msgstr "Retirer le vote" msgid "Replace file" msgstr "Remplacer le fichier" +#: src/components/ChatModal.tsx:162 +#: src/components/ChatModal.tsx:163 #: src/components/CommentThread.tsx:355 msgid "Reply" msgstr "Répondre" +#: src/components/ChatModal.tsx:609 +msgid "Replying to" +msgstr "En réponse à" + #: src/pages/UserLogin.tsx:132 msgid "Request failed" msgstr "Échec de la demande" @@ -1019,7 +1045,7 @@ msgstr "Réessayer" msgid "Role" msgstr "Rôle" -#: src/components/ChatModal.tsx:151 +#: src/components/ChatModal.tsx:222 #: src/components/CommentThread.tsx:328 #: src/pages/Dump.tsx:368 #: src/pages/DumpEdit.tsx:466 @@ -1057,7 +1083,7 @@ msgstr "Recherche échouée" msgid "Searching…" msgstr "Recherche…" -#: src/components/ChatModal.tsx:369 +#: src/components/ChatModal.tsx:655 msgid "Send" msgstr "Envoyer" @@ -1082,6 +1108,10 @@ msgstr "Définir un nouveau mot de passe" msgid "Settings" msgstr "Paramètres" +#: src/components/ChatModal.tsx:529 +msgid "Several people are typing…" +msgstr "Plusieurs personnes écrivent…" + #: src/components/CategoryManager.tsx:191 msgid "slug" msgstr "identifiant" @@ -1133,11 +1163,12 @@ msgstr "Titre" msgid "Title is required." msgstr "Un titre est requis." +#: src/components/ChatModal.tsx:262 #: src/pages/Notifications.tsx:397 msgid "Today" msgstr "Aujourd'hui" -#: src/components/ChatModal.tsx:350 +#: src/components/ChatModal.tsx:632 msgid "Type a message…" msgstr "Écrivez un message…" @@ -1240,6 +1271,7 @@ msgstr "Pourquoi ?" msgid "Write a reply…" msgstr "Écrire une réponse…" +#: src/components/ChatModal.tsx:265 #: src/pages/Notifications.tsx:399 msgid "Yesterday" msgstr "Hier" diff --git a/src/model.ts b/src/model.ts index 16f1af6..a529896 100644 --- a/src/model.ts +++ b/src/model.ts @@ -228,6 +228,13 @@ export interface ChatMessage { updatedAt?: Date; authorUsername: string; authorAvatarMime?: string; + /** Id of the message this one replies to, if any. Kept even when the target + * was deleted (the preview fields below then go undefined). */ + replyToId?: string; + /** Author/snippet of the replied-to message, for a subtle inline reference. + * Absent when there's no reply, or the target has since been deleted. */ + replyToAuthor?: string; + replyToBody?: string; } export type RawChatMessage = WithStringDate; @@ -537,6 +544,11 @@ export interface WSChatMessageDeletedMessage { id: string; } +export interface WSChatTypingUpdateMessage { + type: "chat_typing_update"; + users: OnlineUser[]; +} + export type IncomingWSMessage = | WSPingMessage | WSWelcomeMessage @@ -561,7 +573,8 @@ export type IncomingWSMessage = | WSForceLogoutMessage | WSChatMessageMessage | WSChatMessageUpdatedMessage - | WSChatMessageDeletedMessage; + | WSChatMessageDeletedMessage + | WSChatTypingUpdateMessage; /** * WebSocket messages — client → server (outgoing) @@ -590,6 +603,12 @@ export interface WSCommentLikeRemoveMessage { export interface WSChatSendMessage { type: "chat_send"; body: string; + replyToId?: string; +} + +export interface WSChatTypingMessage { + type: "chat_typing"; + typing: boolean; } export interface WSChatFocusMessage { @@ -615,6 +634,7 @@ export type OutgoingWSMessage = | WSCommentLikeCastMessage | WSCommentLikeRemoveMessage | WSChatSendMessage + | WSChatTypingMessage | WSChatFocusMessage | WSChatEditMessage | WSChatDeleteMessage;