From 6c4f410d9b998dc107a3f02518320b4d245a23be Mon Sep 17 00:00:00 2001 From: khannurien Date: Mon, 29 Jun 2026 20:25:10 +0000 Subject: [PATCH] v3: added a chatbox --- api/db/migrate.ts | 4 + api/db/migrations/0008_chat_messages.ts | 18 ++ api/db/schema.sql | 10 + api/lib/permissions.ts | 9 +- api/main.ts | 5 + api/model/db.ts | 38 +++ api/model/interfaces.ts | 70 ++++- api/routes/chat.ts | 26 ++ api/routes/ws.ts | 120 ++++++++ api/services/chat-service.ts | 137 +++++++++ api/services/notification-service.ts | 6 +- api/services/ws-service.ts | 33 +++ public/favicon.svg | 1 - public/manifest.webmanifest | 35 --- src/App.css | 340 +++++++++++++++++++++ src/App.tsx | 9 +- src/components/AppHeader.tsx | 2 + src/components/ChatButton.tsx | 27 ++ src/components/ChatFab.tsx | 74 +++++ src/components/ChatModal.tsx | 375 ++++++++++++++++++++++++ src/contexts/ChatContext.ts | 13 + src/contexts/ChatProvider.tsx | 57 ++++ src/contexts/WSContext.ts | 22 ++ src/contexts/WSProvider.tsx | 120 ++++++++ src/hooks/useChat.ts | 6 + src/locales/en.js | 2 +- src/locales/en.po | 140 ++++++--- src/locales/fr.js | 2 +- src/locales/fr.po | 140 ++++++--- src/model.ts | 73 ++++- src/pages/Notifications.tsx | 7 +- src/themes/brutalist.css | 7 +- src/themes/geocities.css | 10 +- src/themes/nyt.css | 5 +- src/utils/permissions.ts | 9 +- 35 files changed, 1800 insertions(+), 152 deletions(-) create mode 100644 api/db/migrations/0008_chat_messages.ts create mode 100644 api/routes/chat.ts create mode 100644 api/services/chat-service.ts delete mode 100644 public/favicon.svg delete mode 100644 public/manifest.webmanifest create mode 100644 src/components/ChatButton.tsx create mode 100644 src/components/ChatFab.tsx create mode 100644 src/components/ChatModal.tsx create mode 100644 src/contexts/ChatContext.ts create mode 100644 src/contexts/ChatProvider.tsx create mode 100644 src/hooks/useChat.ts diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 9509303..45a8569 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -6,6 +6,8 @@ import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts"; 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 up0009ChatMessageUpdatedAt } from "./migrations/0009_chat_message_updated_at.ts"; interface Migration { name: string; @@ -23,6 +25,8 @@ const MIGRATIONS: Migration[] = [ { name: "0005_drop_is_admin", up: up0005DropIsAdmin }, { name: "0006_categories", up: up0006Categories }, { name: "0007_password_reset_tokens", up: up0007PasswordResetTokens }, + { name: "0008_chat_messages", up: up0008ChatMessages }, + { name: "0009_chat_message_updated_at", up: up0009ChatMessageUpdatedAt }, ]; export function runMigrations(db: DatabaseSync): void { diff --git a/api/db/migrations/0008_chat_messages.ts b/api/db/migrations/0008_chat_messages.ts new file mode 100644 index 0000000..a6fea61 --- /dev/null +++ b/api/db/migrations/0008_chat_messages.ts @@ -0,0 +1,18 @@ +import type { DatabaseSync } from "node:sqlite"; + +// Idempotent: safe to run against a fresh db (already created from schema.sql +// with this table) or an existing one that predates it. +export function up(db: DatabaseSync): void { + db.exec(`CREATE TABLE IF NOT EXISTS 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, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + );`); + + db.exec( + `CREATE INDEX IF NOT EXISTS idx_chat_messages_created ON chat_messages(created_at);`, + ); +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 78c0983..7910d4a 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -192,3 +192,13 @@ CREATE INDEX idx_notifications_user ON notifications(user_id, created_at); CREATE UNIQUE INDEX idx_notifications_dedup ON notifications(user_id, source_key) 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, + 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/lib/permissions.ts b/api/lib/permissions.ts index 1ee506c..66d723f 100644 --- a/api/lib/permissions.ts +++ b/api/lib/permissions.ts @@ -6,6 +6,7 @@ export type Permission = | "dump:moderate" | "comment:moderate" | "playlist:moderate" + | "chat:moderate" | "user:manage" | "category:manage"; @@ -13,6 +14,7 @@ const ALL_PERMISSIONS: readonly Permission[] = [ "dump:moderate", "comment:moderate", "playlist:moderate", + "chat:moderate", "user:manage", "category:manage", ]; @@ -21,7 +23,12 @@ const ALL_PERMISSIONS: readonly Permission[] = [ // and future); `moderator` gets the content-moderation permissions only. export const ROLE_PERMISSIONS: Record = { admin: ALL_PERMISSIONS, - moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"], + moderator: [ + "dump:moderate", + "comment:moderate", + "playlist:moderate", + "chat:moderate", + ], user: [], }; diff --git a/api/main.ts b/api/main.ts index f100628..286c3cf 100644 --- a/api/main.ts +++ b/api/main.ts @@ -11,6 +11,7 @@ import wsRouter from "./routes/ws.ts"; import previewRouter from "./routes/preview.ts"; import playlistsRouter from "./routes/playlists.ts"; import commentsRouter from "./routes/comments.ts"; +import chatRouter from "./routes/chat.ts"; import followsRouter from "./routes/follows.ts"; import notificationsRouter from "./routes/notifications.ts"; import invitesRouter from "./routes/invites.ts"; @@ -78,6 +79,10 @@ app.use( commentsRouter.routes(), commentsRouter.allowedMethods(), ); +app.use( + chatRouter.routes(), + chatRouter.allowedMethods(), +); app.use( followsRouter.routes(), followsRouter.allowedMethods(), diff --git a/api/model/db.ts b/api/model/db.ts index fb1836e..a3b74da 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -2,6 +2,7 @@ import { randomBytes, scryptSync } from "node:crypto"; import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { type Category, + type ChatMessage, type Comment, Dump, isRole, @@ -304,6 +305,43 @@ export function commentRowToApi(row: CommentRow): Comment { }; } +export interface ChatMessageRow { + id: string; + user_id: string; + body: string; + created_at: string; + updated_at: string | null; + author_username: string; + author_avatar_mime: string | null; + [key: string]: SQLOutputValue; +} + +export function isChatMessageRow(obj: unknown): obj is ChatMessageRow { + return !!obj && typeof obj === "object" && + "id" in obj && typeof obj.id === "string" && + "user_id" in obj && typeof obj.user_id === "string" && + "body" in obj && typeof obj.body === "string" && + "created_at" in obj && typeof obj.created_at === "string" && + "updated_at" in obj && + (typeof obj.updated_at === "string" || obj.updated_at === null) && + "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); +} + +export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage { + return { + id: row.id, + userId: row.user_id, + body: row.body, + createdAt: new Date(row.created_at), + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, + authorUsername: row.author_username, + authorAvatarMime: row.author_avatar_mime ?? undefined, + }; +} + export interface PlaylistRow { id: string; user_id: string; diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 9714e4b..4f04764 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -309,6 +309,25 @@ export interface CreateCommentRequest { parentId?: string; } +/** + * Chat + */ + +export interface ChatMessage { + id: string; + userId: string; + body: string; + createdAt: Date; + updatedAt?: Date; + authorUsername: string; + authorAvatarMime?: string; +} + +/** Wire format — createdAt arrives as an ISO string over JSON. */ +export type RawChatMessage = Omit & { + createdAt: string; +}; + export function isCreateCommentRequest( obj: unknown, ): obj is CreateCommentRequest { @@ -532,13 +551,40 @@ export interface CommentLikeRemoveMessage { commentId: string; } +export interface ChatSendMessage { + type: "chat_send"; + body: string; +} + +// Tells the server whether this client currently has the chatbox open, so that +// mention notifications can be skipped for users who are already reading chat. +export interface ChatFocusMessage { + type: "chat_focus"; + open: boolean; +} + +export interface ChatEditMessage { + type: "chat_edit"; + id: string; + body: string; +} + +export interface ChatDeleteMessage { + type: "chat_delete"; + id: string; +} + export type ClientToServerMessage = | PingMessage | PongMessage | VoteCastMessage | VoteRemoveMessage | CommentLikeCastMessage - | CommentLikeRemoveMessage; + | CommentLikeRemoveMessage + | ChatSendMessage + | ChatFocusMessage + | ChatEditMessage + | ChatDeleteMessage; // ── Server → Client ────────────────────────────────────────────────────────── @@ -669,6 +715,21 @@ export interface ForceLogoutMessage { type: "force_logout"; } +export interface ChatMessageMessage { + type: "chat_message"; + message: ChatMessage; +} + +export interface ChatMessageUpdatedMessage { + type: "chat_message_updated"; + message: ChatMessage; +} + +export interface ChatMessageDeletedMessage { + type: "chat_message_deleted"; + id: string; +} + export type ServerToClientMessage = | PingMessage | WelcomeMessage @@ -690,7 +751,10 @@ export type ServerToClientMessage = | CommentDeletedMessage | NotificationCreatedMessage | ErrorMessage - | ForceLogoutMessage; + | ForceLogoutMessage + | ChatMessageMessage + | ChatMessageUpdatedMessage + | ChatMessageDeletedMessage; /** * Follows @@ -751,7 +815,7 @@ export interface DumpUpvotedData { export interface UserMentionedData { mentionerId: string; mentionerUsername: string; - contextType: "comment" | "dump" | "playlist"; + contextType: "comment" | "dump" | "playlist" | "chat"; contextId: string; contextTitle: string; dumpId?: string; diff --git a/api/routes/chat.ts b/api/routes/chat.ts new file mode 100644 index 0000000..368bd8a --- /dev/null +++ b/api/routes/chat.ts @@ -0,0 +1,26 @@ +import { Router } from "@oak/oak"; +import type { APIResponse, ChatMessage } from "../model/interfaces.ts"; +import { authMiddleware } from "../middleware/auth.ts"; +import { getRecentChatMessages } from "../services/chat-service.ts"; + +const router = new Router({ prefix: "/api/chat" }); + +// GET /api/chat — recent messages (ascending). Optional `before` (ISO created_at) +// cursor loads the page of older messages, and `limit` caps the page size. +router.get("/", authMiddleware, (ctx) => { + const params = ctx.request.url.searchParams; + const before = params.get("before") ?? undefined; + const limitParam = params.get("limit"); + const limit = limitParam ? Number(limitParam) : undefined; + const messages = getRecentChatMessages( + Number.isFinite(limit) ? limit : undefined, + before, + ); + const responseBody: APIResponse = { + success: true, + data: messages, + }; + ctx.response.body = responseBody; +}); + +export default router; diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 9696604..81f93a6 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -2,15 +2,26 @@ import { Router } from "@oak/oak"; import { ALLOWED_ORIGINS } from "../config.ts"; import { verifyJWT } from "../lib/jwt.ts"; import { + broadcastChatMessage, + broadcastChatMessageDeleted, + broadcastChatMessageUpdated, broadcastCommentLikeUpdate, broadcastPresence, broadcastVoteUpdate, getOnlineUsers, handleClientPong, + isUserChatOpen, register, unregister, type WsClient, } from "../services/ws-service.ts"; +import { + createChatMessage, + deleteChatMessage, + updateChatMessage, +} from "../services/chat-service.ts"; +import { notifyMentions } from "../services/notification-service.ts"; +import { hasPermission } from "../lib/permissions.ts"; import { castVote, getUserVotes, @@ -59,6 +70,7 @@ router.get("/ws", async (ctx) => { socket, userId: authPayload?.userId, username: authPayload?.username, + role: authPayload?.role, avatarMime, }; @@ -114,6 +126,18 @@ router.get("/ws", async (ctx) => { case "comment_like_remove": handleCommentLike(client, msg.commentId, "remove"); break; + case "chat_send": + handleChatSend(client, msg.body); + break; + case "chat_focus": + client.chatOpen = msg.open; + break; + case "chat_edit": + handleChatEdit(client, msg.id, msg.body); + break; + case "chat_delete": + handleChatDelete(client, msg.id); + break; } }); @@ -190,4 +214,100 @@ function handleCommentLike( } } +function handleChatSend(client: WsClient, body: string): void { + const { socket } = client; + + if (!client.userId) { + socket.send( + JSON.stringify({ type: "error", message: "Authentication required" }), + ); + return; + } + + try { + const message = createChatMessage(client.userId, body); + // Broadcast to everyone including the sender, so the sender's own message + // simply arrives over the socket — no optimistic-send machinery needed. + broadcastChatMessage(message); + // Skip mention notifications for users who already have the chat open — + // they've seen the message live. + notifyMentions( + client.userId, + message.body, + "chat", + message.id, + "", + undefined, + isUserChatOpen, + ); + } catch (err) { + const message = err instanceof APIException + ? err.message + : "Failed to send message"; + socket.send(JSON.stringify({ type: "error", message })); + } +} + +function canModerateChat(client: WsClient): boolean { + return !!client.role && hasPermission(client.role, "chat:moderate"); +} + +function handleChatEdit(client: WsClient, id: string, body: string): void { + const { socket } = client; + + if (!client.userId) { + socket.send( + JSON.stringify({ type: "error", message: "Authentication required" }), + ); + return; + } + + try { + const message = updateChatMessage( + id, + body, + client.userId, + canModerateChat(client), + ); + broadcastChatMessageUpdated(message); + // A newly added @mention in an edit should still notify (deduped per + // message+user), skipping anyone already reading the chat. + notifyMentions( + client.userId, + message.body, + "chat", + message.id, + "", + undefined, + isUserChatOpen, + ); + } catch (err) { + const message = err instanceof APIException + ? err.message + : "Failed to edit message"; + socket.send(JSON.stringify({ type: "error", message })); + } +} + +function handleChatDelete(client: WsClient, id: string): void { + const { socket } = client; + + if (!client.userId) { + socket.send( + JSON.stringify({ type: "error", message: "Authentication required" }), + ); + return; + } + + try { + deleteChatMessage(id, canModerateChat(client)); + broadcastChatMessageDeleted(id); + } catch (err) { + const message = err instanceof APIException + ? err.message + : "Failed to delete message"; + socket.send(JSON.stringify({ type: "error", message })); + } +} + export default router; diff --git a/api/services/chat-service.ts b/api/services/chat-service.ts new file mode 100644 index 0000000..98724f8 --- /dev/null +++ b/api/services/chat-service.ts @@ -0,0 +1,137 @@ +import { + APIErrorCode, + APIException, + type ChatMessage, +} from "../model/interfaces.ts"; +import { chatMessageRowToApi, db, isChatMessageRow } from "../model/db.ts"; + +export const MAX_CHAT_LENGTH = 2000; +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +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`; + +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 = ?;`, + ).get(id); + if (!row || !isChatMessageRow(row)) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found"); + } + return chatMessageRowToApi(row); +} + +export function createChatMessage(userId: string, body: string): ChatMessage { + const trimmed = body.trim(); + if (!trimmed) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Message cannot be empty", + ); + } + if (trimmed.length > MAX_CHAT_LENGTH) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + `Message exceeds ${MAX_CHAT_LENGTH} characters`, + ); + } + 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); + return fetchMessage(id); +} + +function getMessageOwner(id: string): string { + const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get( + id, + ) as { user_id: string } | undefined; + if (!row) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found"); + } + return row.user_id; +} + +// Owners may edit their own messages; moderators may edit any. +export function updateChatMessage( + id: string, + body: string, + requestingUserId: string, + canModerate: boolean, +): ChatMessage { + const ownerId = getMessageOwner(id); + if (ownerId !== requestingUserId && !canModerate) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to edit this message", + ); + } + const trimmed = body.trim(); + if (!trimmed) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Message cannot be empty", + ); + } + if (trimmed.length > MAX_CHAT_LENGTH) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + `Message exceeds ${MAX_CHAT_LENGTH} characters`, + ); + } + db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`) + .run(trimmed, new Date().toISOString(), id); + return fetchMessage(id); +} + +// Deletion is a moderation action only (owners can edit but not delete). +export function deleteChatMessage(id: string, canModerate: boolean): void { + if (!canModerate) { + throw new APIException( + APIErrorCode.UNAUTHORIZED, + 401, + "Not authorized to delete this message", + ); + } + getMessageOwner(id); // 404s if the message doesn't exist + db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id); +} + +/** + * Returns up to `limit` most recent messages in ascending (display) order. + * When `before` (a message created_at ISO string) is given, returns the page + * of older messages immediately preceding it — used for "load older" history. + */ +export function getRecentChatMessages( + limit = DEFAULT_LIMIT, + before?: string, +): ChatMessage[] { + 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 + 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 + ORDER BY m.created_at DESC LIMIT ?;`, + ).all(capped); + + if (!rows.every(isChatMessageRow)) { + throw new APIException( + APIErrorCode.SERVER_ERROR, + 500, + "Malformed chat message data", + ); + } + // Newest-first from SQL; reverse to ascending for display. + return rows.map(chatMessageRowToApi).reverse(); +} diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index 51a8868..8c0111d 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -235,10 +235,13 @@ export function notifyDumpOwnerUpvote( export function notifyMentions( mentionerUserId: string, body: string, - contextType: "comment" | "dump" | "playlist", + contextType: "comment" | "dump" | "playlist" | "chat", contextId: string, contextTitle: string, dumpId?: string, + // Optional: skip notifying a mentioned user (e.g. they're already reading the + // chat live, so the persistent notification would be redundant). + skipUserId?: (userId: string) => boolean, ): void { const mentionerRow = db.prepare( `SELECT username FROM users WHERE id = ?;`, @@ -256,6 +259,7 @@ export function notifyMentions( `SELECT id FROM users WHERE lower(username) = ?;`, ).get(username) as { id: string } | undefined; if (!mentionedRow || mentionedRow.id === mentionerUserId) continue; + if (skipUserId?.(mentionedRow.id)) continue; createNotification( mentionedRow.id, diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 00d6c66..4711e7d 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -1,8 +1,10 @@ import type { + ChatMessage, Comment, Dump, OnlineUser, Playlist, + Role, ServerToClientMessage, User, } from "../model/interfaces.ts"; @@ -11,9 +13,12 @@ export interface WsClient { socket: WebSocket; userId?: string; username?: string; + role?: Role; avatarMime?: string; avatarVersion?: number; pongReceived?: boolean; + /** Whether this client currently has the chatbox open (is reading chat). */ + chatOpen?: boolean; } const clients = new Set(); @@ -209,6 +214,34 @@ export function broadcastCommentUpdated(comment: Comment): void { } } +// Global chat room: every connected client receives every message. +export function broadcastChatMessage(message: ChatMessage): void { + for (const client of clients) { + send(client.socket, { type: "chat_message", message }); + } +} + +export function broadcastChatMessageUpdated(message: ChatMessage): void { + for (const client of clients) { + send(client.socket, { type: "chat_message_updated", message }); + } +} + +export function broadcastChatMessageDeleted(id: string): void { + for (const client of clients) { + send(client.socket, { type: "chat_message_deleted", id }); + } +} + +// 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 { + for (const client of clients) { + if (client.userId === userId && client.chatOpen) return true; + } + return false; +} + export function handleClientPong(client: WsClient): void { client.pongReceived = true; } diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index 961f67e..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest deleted file mode 100644 index 5c2019f..0000000 --- a/public/manifest.webmanifest +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "coucou", - "short_name": "coucou", - "start_url": "/", - "display": "standalone", - "background_color": "#111827", - "theme_color": "#111827", - "icons": [ - { - "src": "/favicon.svg", - "type": "image/svg+xml", - "sizes": "any" - }, - { - "src": "/favicon-192x192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "/favicon-512x512.png", - "type": "image/png", - "sizes": "512x512", - "purpose": "any maskable" - } - ], - "share_target": { - "action": "/", - "method": "GET", - "params": { - "url": "share_url", - "title": "share_title", - "text": "share_text" - } - } -} diff --git a/src/App.css b/src/App.css index d33f942..3408cca 100644 --- a/src/App.css +++ b/src/App.css @@ -1162,6 +1162,75 @@ body.has-fab .page-content { padding-bottom: 5rem; } +/* Chat FAB — mirrors .dump-fab and stacks directly above it (the "+" dump + button keeps the bottom slot; chat sits one button-height up) so the two + never overlap. */ +.chat-fab { + position: fixed; + --fab-size: 3.5rem; + /* Share the dump-fab's column. */ + right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem)); + /* One button-height + gap above the dump-fab's bottom-anchored resting spot. */ + top: calc(100vh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom, 0px)); + top: calc(100dvh - var(--fab-size) * 2 - 2rem - env(safe-area-inset-bottom, 0px)); + z-index: 900; + width: var(--fab-size); + height: var(--fab-size); + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: var(--fab-radius, 50%); + background: var(--color-accent); + color: var(--color-on-accent); + cursor: pointer; + box-shadow: 0 4px 16px color-mix(in srgb, var(--color-accent) 45%, transparent), + 0 2px 6px rgba(0, 0, 0, 0.3); + opacity: 0; + transform: translateY(1rem) scale(0.9); + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease, background 0.15s ease, + top 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.chat-fab-icon { + width: 1.6rem; + height: 1.6rem; +} + +.chat-fab--visible { + opacity: 1; + transform: translateY(0) scale(1); + pointer-events: auto; +} + +.chat-fab:hover { + background: var(--color-accent-hover); + transform: translateY(-2px) scale(1); + box-shadow: 0 6px 20px color-mix(in srgb, var(--color-accent) 55%, transparent), + 0 2px 6px rgba(0, 0, 0, 0.3); +} + +.chat-fab:active { + transform: translateY(0) scale(0.96); +} + +/* Match the dump-fab's clearance shift when a player is mounted. */ +body.has-player .chat-fab { + top: calc(1.25rem + env(safe-area-inset-top, 0px)); + /* Stack below the dump-fab in the top-right corner to avoid overlap. */ + right: max(1.25rem, calc(50% - var(--fab-lane, 860px) / 2 - var(--fab-size) - 1rem)); + top: calc(1.25rem + var(--fab-size) + 0.75rem + env(safe-area-inset-top, 0px)); +} + +/* The badge sits on the circular FAB; nudge it onto the rim. */ +.chat-fab .notification-badge { + top: 2px; + right: 2px; + box-shadow: 0 0 0 2px var(--color-accent); +} + .rich-content-thumbnail-btn { position: relative; padding: 0; @@ -2148,6 +2217,7 @@ body.has-fab .page-content { /* Icon-only controls are square, so the row reads as a tidy set. */ .app-header-nav .nav-search-btn, .app-header-nav .notification-bell, +.app-header-nav .chat-button, .app-header-nav .user-menu-trigger { width: 2.25rem; padding: 0; @@ -3221,6 +3291,255 @@ body.has-fab .page-content { gap: 0.75rem; } +/* ── Live chat ── */ +.chat { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + height: min(60vh, 480px); + gap: 0.75rem; +} + +.chat-messages { + flex: 1 1 auto; + 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. */ + scrollbar-gutter: stable; + display: flex; + flex-direction: column; + gap: 0.85rem; + padding-right: 0.5rem; +} + +.chat-empty { + margin: auto 0; + text-align: center; + color: var(--color-text-muted); +} + +.chat-load-older { + text-align: center; + padding-bottom: 0.25rem; +} + +.chat-message { + display: flex; + gap: 0.6rem; + align-items: flex-start; + /* Each message animates once, when its element first mounts — so newly + arriving messages slide in while existing ones stay put (React reuses + keyed DOM nodes). */ + animation: chat-msg-in 0.18s ease-out; +} + +@keyframes chat-msg-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-message { + animation: none; + } +} + +.chat-message-body { + flex: 1; + min-width: 0; +} + +.chat-message-meta { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.1rem; +} + +.chat-message-author { + font-weight: 700; + font-size: 0.85rem; + color: inherit; + text-decoration: none; +} + +.chat-message-author:hover { + color: var(--color-accent); +} + +.chat-message-time { + font-size: 0.7rem; + color: var(--color-text-muted); +} + +.chat-message-text { + font-size: 0.9rem; +} + +.chat-message-text.md { + line-height: 1.45; +} + +.chat-message-edited { + font-size: 0.72rem; + color: var(--color-text-muted); + opacity: 0.7; + font-style: italic; +} + +/* Edit/Delete actions: bare inline icons that follow the author/time in the + meta row, sitting right next to the message they belong to. Faded in on hover + (or focus, for keyboard use) so they stay unobtrusive. */ +.chat-message-actions { + margin-left: 0.35rem; + display: inline-flex; + gap: 0.15rem; + opacity: 0; + transition: opacity 0.15s; +} + +.chat-message:hover .chat-message-actions, +.chat-message:focus-within .chat-message-actions { + opacity: 1; +} + +@media (hover: none) { + /* No hover on touch devices — keep the actions visible. */ + .chat-message-actions { + opacity: 1; + } +} + +.chat-icon-btn { + background: none; + border: none; + padding: 0 0.1rem; + font-size: 0.8rem; + line-height: 1; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s; +} + +.chat-icon-btn:hover { + color: var(--color-text); +} + +.chat-icon-btn--delete:hover { + color: var(--color-danger); +} + +.chat-edit { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.chat-edit-actions { + display: flex; + justify-content: flex-end; + gap: 0.4rem; +} + +/* Compact buttons for the inline edit box — the full-size form buttons are too + bulky for the narrow message column. */ +.chat-edit-actions .btn-primary, +.chat-edit-actions .btn-secondary { + font-size: 0.8rem; + padding: 0.25rem 0.7rem; + box-shadow: none; +} + +.chat-composer { + display: flex; + gap: 0.5rem; + align-items: flex-end; + flex-shrink: 0; +} + +.chat-composer .mention-textarea-wrap { + flex: 1 1 auto; + 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; +} + +/* 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. */ +.chat-composer .text-editor-count { + position: absolute; + right: 0.55rem; + bottom: 0.4rem; + margin: 0; +} + +.chat-send-btn { + flex-shrink: 0; + /* Match the input's resting height so the two line up exactly (both edges), + while the input is free to grow taller as you type. */ + height: 2.4rem; + box-sizing: border-box; +} + +/* 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 + list keeps its own scroll, so nothing else relies on the body clipping. */ +.modal-card:has(.chat) .modal-body { + overflow: visible; +} + +/* Open the mention list upward, into the roomy message area above the input. */ +.chat-composer .mention-dropdown { + top: auto; + bottom: 100%; + margin: 0 0 4px; +} + +/* Very narrow viewports: stack the input and Send button. */ +@media (max-width: 400px) { + .chat-composer { + flex-direction: column; + align-items: stretch; + } + + .chat-send-btn { + height: auto; + width: 100%; + } + + /* The author/time/edited row gets cramped when squeezed side-by-side here — + stack the metadata instead so each piece stays readable. Keep the stacked + lines tight so they don't balloon the message height. */ + .chat-message-meta { + flex-direction: column; + align-items: flex-start; + gap: 0; + line-height: 1.25; + } + + /* No longer trailing the time inline; align it with the stacked metadata. */ + .chat-message-actions { + margin-left: 0; + } +} + .confirm-modal-message { margin: 0; font-size: 0.95rem; @@ -4359,6 +4678,27 @@ body.has-fab .page-content { box-shadow: 0 0 0 2px var(--color-bg); } +/* ── Header chat button ── */ +.chat-button { + position: relative; + background: var(--color-header-user-bg); + border: none; + cursor: pointer; + font-size: 0.95rem; + font-weight: 600; + padding: 0.35rem 0.85rem; + border-radius: 8px; + transition: background 0.15s; + display: inline-flex; + align-items: center; +} +.chat-button:hover { + background: var(--color-header-user-bg-hover); +} +.chat-button-icon { + display: inline-block; +} + /* ── Notifications page ── */ .notifications-page { max-width: 680px; diff --git a/src/App.tsx b/src/App.tsx index dcaf022..f118797 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,8 +18,10 @@ import { WSProvider } from "./contexts/WSProvider.tsx"; import { FollowProvider } from "./contexts/FollowProvider.tsx"; import { CategoriesProvider } from "./contexts/CategoriesProvider.tsx"; import { ThemeProvider } from "./contexts/ThemeProvider.tsx"; +import { ChatProvider } from "./contexts/ChatProvider.tsx"; import { GlobalPlayer } from "./components/GlobalPlayer.tsx"; import { DumpFab } from "./components/DumpFab.tsx"; +import { ChatFab } from "./components/ChatFab.tsx"; import "./App.css"; @@ -162,6 +164,7 @@ function AppRoutes() { + ); } @@ -175,8 +178,10 @@ function App() { - - + + + + diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 90f7222..4e80c8f 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -5,6 +5,7 @@ import { Trans } from "@lingui/react/macro"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { NotificationBell } from "./NotificationBell.tsx"; +import { ChatButton } from "./ChatButton.tsx"; import { UserMenu } from "./UserMenu.tsx"; import { SearchBar } from "./SearchBar.tsx"; import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts"; @@ -91,6 +92,7 @@ export function AppHeader( + + ); +} diff --git a/src/components/ChatFab.tsx b/src/components/ChatFab.tsx new file mode 100644 index 0000000..d775f61 --- /dev/null +++ b/src/components/ChatFab.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import { useLocation } from "react-router"; +import { t } from "@lingui/core/macro"; +import { useAuth } from "../hooks/useAuth.ts"; +import { useWS } from "../hooks/useWS.ts"; +import { useChat } from "../hooks/useChat.ts"; + +/** + * Floating chat button shown once the header has scrolled out of view, mirroring + * {@link DumpFab}. Opens the single app-wide chat modal (owned by ChatProvider). + * Only rendered for signed-in users. + */ +export function ChatFab() { + const { user } = useAuth(); + const { unreadChatCount } = useWS(); + const { openChat } = useChat(); + const location = useLocation(); + const [headerHidden, setHeaderHidden] = useState(false); + + // Reveal the button once the header's bottom edge scrolls above the viewport. + // Re-reading the header on every tick keeps this correct across navigation + // and lazily-mounted pages, where the header element is replaced. + useEffect(() => { + if (!user) return; + const update = () => { + const header = document.querySelector(".app-header"); + setHeaderHidden(!!header && header.getBoundingClientRect().bottom <= 0); + }; + update(); + globalThis.addEventListener("scroll", update, { passive: true }); + globalThis.addEventListener("resize", update); + const ro = new ResizeObserver(update); + ro.observe(document.body); + return () => { + globalThis.removeEventListener("scroll", update); + globalThis.removeEventListener("resize", update); + ro.disconnect(); + }; + }, [user, location.pathname]); + + if (!user) return null; + + return ( + + ); +} diff --git a/src/components/ChatModal.tsx b/src/components/ChatModal.tsx new file mode 100644 index 0000000..ed27b80 --- /dev/null +++ b/src/components/ChatModal.tsx @@ -0,0 +1,375 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Link } from "react-router"; +import { Modal } from "./Modal.tsx"; +import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx"; +import { Markdown } from "./Markdown.tsx"; +import { Avatar } from "./Avatar.tsx"; +import { ConfirmModal } from "./ConfirmModal.tsx"; +import { Tooltip } from "./Tooltip.tsx"; +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 { API_URL } from "../config/api.ts"; +import { + type ChatMessage, + deserializeChatMessage, + type PublicUser, + type RawChatMessage, +} from "../model.ts"; + +// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts. +const MAX_CHAT_LENGTH = 2000; +// Matches the server's default page size; a full page means there may be more. +const PAGE_SIZE = 50; + +interface ChatMessageItemProps { + message: ChatMessage; + currentUser: PublicUser | null; + canModerate: boolean; + onEdit: (id: string, body: string) => void; + onDelete: (id: string) => void; +} + +function ChatMessageItem( + { message, currentUser, canModerate, onEdit, onDelete }: ChatMessageItemProps, +) { + const [editing, setEditing] = useState(false); + const [editDraft, setEditDraft] = useState(message.body); + const [confirmDelete, setConfirmDelete] = useState(false); + const editorRef = useRef(null); + + // Owners may edit their own messages; moderators may edit any. Deletion is a + // moderation action only (mirrors api/services/chat-service.ts). + const isOwn = !!currentUser && currentUser.id === message.userId; + const canEdit = isOwn || canModerate; + const canDelete = canModerate; + + const openEdit = () => { + setEditDraft(message.body); + setEditing(true); + setTimeout(() => editorRef.current?.focus(), 0); + }; + + const saveEdit = () => { + const body = editDraft.trim(); + if (!body) return; + onEdit(message.id, body); + setEditing(false); + }; + + return ( +
+ +
+
+ + {message.authorUsername} + + + + + {message.updatedAt && ( + + + edited {relativeTime(message.updatedAt)} + + + )} + {!editing && (canEdit || canDelete) && ( + + {canEdit && ( + + )} + {canDelete && ( + + )} + + )} +
+ + {editing + ? ( +
+ { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + saveEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + setEditing(false); + } + }} + /> +
+ + +
+
+ ) + : {message.body}} + + {confirmDelete && ( + { + setConfirmDelete(false); + onDelete(message.id); + }} + onCancel={() => setConfirmDelete(false)} + /> + )} +
+
+ ); +} + +export function ChatModal({ onClose }: { onClose: () => void }) { + const { + chatMessages, + deletedChatIds, + sendChatMessage, + editChatMessage, + deleteChatMessage, + clearUnreadChat, + setChatOpen, + } = useWS(); + const { authFetch, user } = useAuth(); + const canModerate = can(user, "chat:moderate"); + + const [history, setHistory] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [draft, setDraft] = useState(""); + + const listRef = useRef(null); + const composerRef = useRef(null); + // Whether the view is pinned to the bottom — controls auto-scroll on new + // messages so reading older history isn't yanked away. + const stickToBottomRef = useRef(true); + + // Focus the composer when the chat opens so you can type straight away. + useEffect(() => { + composerRef.current?.focus(); + }, []); + + // Merge fetched history with the live buffer, drop deleted ones, dedupe by id + // (live buffer wins so edits show), sort ascending. + const messages = useMemo(() => { + const byId = new Map(); + for (const m of history) byId.set(m.id, m); + for (const m of chatMessages) byId.set(m.id, m); + return [...byId.values()] + .filter((m) => !deletedChatIds.has(m.id)) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + }, [history, chatMessages, deletedChatIds]); + + // Initial history load. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await authFetch(`${API_URL}/api/chat?limit=${PAGE_SIZE}`); + const json = await res.json(); + if (cancelled || !json.success) return; + const items = (json.data as RawChatMessage[]).map(deserializeChatMessage); + setHistory(items); + setHasMore(items.length === PAGE_SIZE); + } catch { + // Live buffer still renders; the user can retry by reopening. + } + })(); + return () => { + cancelled = true; + }; + }, [authFetch]); + + // Clear the unread badge on open and as new messages arrive while open. + useEffect(() => { + clearUnreadChat(); + }, [chatMessages, clearUnreadChat]); + + // Tell the server the chatbox is open so it skips redundant mention + // notifications while the user is reading; clear the flag on close. + useEffect(() => { + setChatOpen(true); + return () => setChatOpen(false); + }, [setChatOpen]); + + // Auto-scroll to the bottom when pinned (initial load + new messages). + useEffect(() => { + if (!stickToBottomRef.current) return; + const el = listRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages]); + + const handleScroll = useCallback(() => { + const el = listRef.current; + if (!el) return; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = distanceFromBottom < 80; + }, []); + + const loadOlder = useCallback(async () => { + const oldest = messages[0]; + if (!oldest || loadingOlder) return; + setLoadingOlder(true); + const el = listRef.current; + const prevHeight = el?.scrollHeight ?? 0; + try { + const res = await authFetch( + `${API_URL}/api/chat?limit=${PAGE_SIZE}&before=${ + encodeURIComponent(oldest.createdAt.toISOString()) + }`, + ); + const json = await res.json(); + if (!json.success) return; + const older = (json.data as RawChatMessage[]).map(deserializeChatMessage); + setHasMore(older.length === PAGE_SIZE); + if (older.length > 0) { + stickToBottomRef.current = false; + setHistory((prev) => [...older, ...prev]); + // Preserve scroll position after older messages are prepended. + requestAnimationFrame(() => { + const node = listRef.current; + if (node) node.scrollTop = node.scrollHeight - prevHeight; + }); + } + } catch { + // ignore — user can retry + } finally { + setLoadingOlder(false); + } + }, [authFetch, messages, loadingOlder]); + + const handleSend = useCallback(() => { + const body = draft.trim(); + if (!body) return; + sendChatMessage(body); + setDraft(""); + stickToBottomRef.current = true; + }, [draft, sendChatMessage]); + + return ( + +
+
+ {hasMore && ( +
+ +
+ )} + {messages.length === 0 + ? ( +

+ No messages yet. Say hello! +

+ ) + : ( + messages.map((m) => ( + + )) + )} +
+ +
+ { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + /> + +
+
+
+ ); +} diff --git a/src/contexts/ChatContext.ts b/src/contexts/ChatContext.ts new file mode 100644 index 0000000..19b7e10 --- /dev/null +++ b/src/contexts/ChatContext.ts @@ -0,0 +1,13 @@ +import { createContext } from "react"; + +export interface ChatContextValue { + isChatOpen: boolean; + openChat: () => void; + closeChat: () => void; +} + +export const ChatContext = createContext({ + isChatOpen: false, + openChat: () => {}, + closeChat: () => {}, +}); diff --git a/src/contexts/ChatProvider.tsx b/src/contexts/ChatProvider.tsx new file mode 100644 index 0000000..04405fc --- /dev/null +++ b/src/contexts/ChatProvider.tsx @@ -0,0 +1,57 @@ +import { + lazy, + type ReactNode, + Suspense, + useCallback, + useMemo, + useState, +} from "react"; +import { useSearchParams } from "react-router"; +import { useAuth } from "../hooks/useAuth.ts"; +import { ChatContext, type ChatContextValue } from "./ChatContext.ts"; + +const ChatModal = lazy(() => + import("../components/ChatModal.tsx").then((m) => ({ default: m.ChatModal })) +); + +/** + * Owns the single live-chat modal instance so the header button, the floating + * button, and notification deep-links all drive one modal. Open state is derived + * (no effects): a manual open, or a `?chat=open` query param (used by mention + * notifications), opens it — and it's only ever open for signed-in users. + */ +export function ChatProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + const [manuallyOpen, setManuallyOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + + const deepLinkOpen = searchParams.get("chat") === "open"; + const isChatOpen = !!user && (manuallyOpen || deepLinkOpen); + + const openChat = useCallback(() => setManuallyOpen(true), []); + const closeChat = useCallback(() => { + setManuallyOpen(false); + // Strip the deep-link param so a reload/back-nav doesn't reopen the modal. + if (searchParams.get("chat") === "open") { + const next = new URLSearchParams(searchParams); + next.delete("chat"); + setSearchParams(next, { replace: true }); + } + }, [searchParams, setSearchParams]); + + const value: ChatContextValue = useMemo( + () => ({ isChatOpen, openChat, closeChat }), + [isChatOpen, openChat, closeChat], + ); + + return ( + + {children} + {isChatOpen && ( + + + + )} + + ); +} diff --git a/src/contexts/WSContext.ts b/src/contexts/WSContext.ts index b8fa3b7..2093e7c 100644 --- a/src/contexts/WSContext.ts +++ b/src/contexts/WSContext.ts @@ -1,5 +1,6 @@ import { createContext } from "react"; import type { + ChatMessage, Comment, Dump, Notification, @@ -58,6 +59,12 @@ export interface WSContextValue { lastUserEvent: UserEvent | null; unreadNotificationCount: number; lastNotification: Notification | null; + /** Live buffer of chat messages received this session (capped). */ + chatMessages: ChatMessage[]; + /** Ids of chat messages deleted live this session, so fetched history can + * filter them out. */ + deletedChatIds: Set; + unreadChatCount: number; /** Increments on each WS reconnect so pages can backfill missed events. */ connectionEpoch: number; castVote: (dumpId: string) => void; @@ -66,6 +73,13 @@ export interface WSContextValue { removeCommentLike: (commentId: string) => void; injectDump: (dump: Dump) => void; clearUnreadNotifications: () => void; + sendChatMessage: (body: string) => void; + editChatMessage: (id: string, body: string) => void; + deleteChatMessage: (id: string) => void; + clearUnreadChat: () => void; + /** Inform the server whether the chatbox is open (suppresses redundant + * mention notifications while the user is reading chat). */ + setChatOpen: (open: boolean) => void; } export const WSContext = createContext({ @@ -87,6 +101,9 @@ export const WSContext = createContext({ lastUserEvent: null, unreadNotificationCount: 0, lastNotification: null, + chatMessages: [], + deletedChatIds: new Set(), + unreadChatCount: 0, connectionEpoch: 0, castVote: () => {}, removeVote: () => {}, @@ -94,4 +111,9 @@ export const WSContext = createContext({ removeCommentLike: () => {}, injectDump: () => {}, clearUnreadNotifications: () => {}, + sendChatMessage: () => {}, + editChatMessage: () => {}, + deleteChatMessage: () => {}, + clearUnreadChat: () => {}, + setChatOpen: () => {}, }); diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx index 7a78cc9..da4fc5f 100644 --- a/src/contexts/WSProvider.tsx +++ b/src/contexts/WSProvider.tsx @@ -22,6 +22,7 @@ import { } from "./AvatarOverrideContext.ts"; import { WS_URL } from "../config/api.ts"; import type { + ChatMessage, Dump, IncomingWSMessage, Notification, @@ -29,6 +30,7 @@ import type { OutgoingWSMessage, } from "../model.ts"; import { + deserializeChatMessage, deserializeComment, deserializeDump, deserializeNotification, @@ -49,6 +51,9 @@ const CONNECT_TIMEOUT = 2_500; // bound. Feeds merge these on top of their own paginated list, so a generous // cap is plenty — older live arrivals are reachable via normal pagination. 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; interface PendingVote { timeout: ReturnType; @@ -111,6 +116,9 @@ export function WSProvider({ children }: WSProviderProps) { const [lastNotification, setLastNotification] = useState( null, ); + const [chatMessages, setChatMessages] = useState([]); + const [deletedChatIds, setDeletedChatIds] = useState>(new Set()); + const [unreadChatCount, setUnreadChatCount] = useState(0); // Live avatar overrides for site-wide Avatar refresh (own narrow context). const [avatarOverrides, setAvatarOverrides] = useState< Record @@ -145,6 +153,9 @@ export function WSProvider({ children }: WSProviderProps) { setLastUserEvent(null); setUnreadNotificationCount(0); setLastNotification(null); + setChatMessages([]); + setDeletedChatIds(new Set()); + setUnreadChatCount(0); setAvatarOverrides({}); } @@ -167,6 +178,9 @@ export function WSProvider({ children }: WSProviderProps) { }); const socketRef = useRef(null); + // Tracks whether the chatbox is open so it can be re-announced to the server + // after a reconnect (the fresh socket starts with the flag cleared). + const chatOpenRef = useRef(false); // Tracks pending optimistic votes: dumpId → pending rollback handler const pendingRef = useRef>( new Map(), @@ -291,6 +305,15 @@ export function WSProvider({ children }: WSProviderProps) { // dumps/comments they couldn't receive while disconnected. welcomeCount += 1; if (welcomeCount > 1) setConnectionEpoch((n) => n + 1); + // Restore the chat-open flag on the fresh connection so mention + // suppression keeps working across reconnects. + if (chatOpenRef.current) { + ws.send( + JSON.stringify( + { type: "chat_focus", open: true } satisfies OutgoingWSMessage, + ), + ); + } break; case "presence_update": @@ -474,6 +497,38 @@ export function WSProvider({ children }: WSProviderProps) { break; } + case "chat_message": { + const message = deserializeChatMessage(msg.message); + setChatMessages((prev) => { + 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 (message.userId !== userIdRef.current) { + setUnreadChatCount((prev) => prev + 1); + } + break; + } + + case "chat_message_updated": { + const message = deserializeChatMessage(msg.message); + setChatMessages((prev) => { + const idx = prev.findIndex((m) => m.id === message.id); + if (idx === -1) return [...prev, message]; + const next = [...prev]; + next[idx] = message; + return next; + }); + break; + } + + case "chat_message_deleted": { + const { id } = msg; + setDeletedChatIds((prev) => new Set(prev).add(id)); + setChatMessages((prev) => prev.filter((m) => m.id !== id)); + break; + } + case "force_logout": onForceLogoutRef.current(); break; @@ -704,6 +759,55 @@ export function WSProvider({ children }: WSProviderProps) { setUnreadNotificationCount(0); }, []); + const sendChatMessage = useCallback((body: 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, + ), + ); + } + }, []); + + const editChatMessage = useCallback((id: string, body: string) => { + const trimmed = body.trim(); + if (!trimmed) return; + if (socketRef.current?.readyState === WebSocket.OPEN) { + socketRef.current.send( + JSON.stringify( + { type: "chat_edit", id, body: trimmed } satisfies OutgoingWSMessage, + ), + ); + } + }, []); + + const deleteChatMessage = useCallback((id: string) => { + if (socketRef.current?.readyState === WebSocket.OPEN) { + socketRef.current.send( + JSON.stringify( + { type: "chat_delete", id } satisfies OutgoingWSMessage, + ), + ); + } + }, []); + + const clearUnreadChat = useCallback(() => { + setUnreadChatCount(0); + }, []); + + const setChatOpen = useCallback((open: boolean) => { + chatOpenRef.current = open; + if (socketRef.current?.readyState === WebSocket.OPEN) { + socketRef.current.send( + JSON.stringify( + { type: "chat_focus", open } satisfies OutgoingWSMessage, + ), + ); + } + }, []); + const value: WSContextValue = useMemo(() => ({ wsStatus, wsErrorMessage, @@ -723,6 +827,9 @@ export function WSProvider({ children }: WSProviderProps) { lastUserEvent, unreadNotificationCount, lastNotification, + chatMessages, + deletedChatIds, + unreadChatCount, connectionEpoch, castVote, removeVote, @@ -730,6 +837,11 @@ export function WSProvider({ children }: WSProviderProps) { removeCommentLike, injectDump, clearUnreadNotifications, + sendChatMessage, + editChatMessage, + deleteChatMessage, + clearUnreadChat, + setChatOpen, }), [ wsStatus, wsErrorMessage, @@ -749,6 +861,9 @@ export function WSProvider({ children }: WSProviderProps) { lastUserEvent, unreadNotificationCount, lastNotification, + chatMessages, + deletedChatIds, + unreadChatCount, connectionEpoch, castVote, removeVote, @@ -756,6 +871,11 @@ export function WSProvider({ children }: WSProviderProps) { removeCommentLike, injectDump, clearUnreadNotifications, + sendChatMessage, + editChatMessage, + deleteChatMessage, + clearUnreadChat, + setChatOpen, ]); return ( diff --git a/src/hooks/useChat.ts b/src/hooks/useChat.ts new file mode 100644 index 0000000..fb08f79 --- /dev/null +++ b/src/hooks/useChat.ts @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { ChatContext } from "../contexts/ChatContext.ts"; + +export function useChat() { + return useContext(ChatContext); +} diff --git a/src/locales/en.js b/src/locales/en.js index 114c1ce..a8494d4 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\"],\"AQbgNR\":[\"Follow \",[\"targetUsername\"]],\"ATGYL1\":[\"Email address\"],\"AZctoV\":[[\"0\",\"plural\",{\"one\":[\"#\",\" comment\"],\"other\":[\"#\",\" comments\"]}]],\"Ade-6d\":[\"Live updates unavailable.\"],\"AeXO77\":[\"Account\"],\"CI50ct\":[\"Upvoted\"],\"Cj24wt\":[\"Registering…\"],\"DPfwMq\":[\"Done\"],\"DdeHXH\":[\"Delete this dump? This cannot be undone.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" upvoted <1>\",[\"1\"],\"\"],\"ECiS12\":[\"View dump →\"],\"ExR0Fr\":[\"URL is required.\"],\"F5Js1v\":[\"Unfollow playlist\"],\"FgAxTj\":[\"Log out\"],\"Fxf4jq\":[\"Description (optional)\"],\"GDvlUT\":[\"Role\"],\"GLukaH\":[\"Moderator\"],\"GNSsCc\":[\"Failed to create playlist\"],\"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\"],\"K7tIrx\":[\"Category\"],\"KDGWg5\":[\"Remove from playlist\"],\"K_F6pa\":[\"Saving…\"],\"L-rMC9\":[\"Reset to default\"],\"LLyMkV\":[\"Followed (\",[\"0\"],[\"1\"],\")\"],\"MHrjPM\":[\"Title\"],\"MKEPCY\":[\"Follow\"],\"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!\"],\"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.\"],\"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\"],\"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 diff --git a/src/locales/en.po b/src/locales/en.po index 4897360..57b52db 100644 --- a/src/locales/en.po +++ b/src/locales/en.po @@ -47,8 +47,8 @@ msgstr "{visibleCount, plural, one {# comment} other {# comments}}" msgid "← Back" msgstr "← Back" -#: src/pages/Dump.tsx:261 -#: src/pages/Dump.tsx:491 +#: src/pages/Dump.tsx:264 +#: src/pages/Dump.tsx:494 #: src/pages/DumpEdit.tsx:179 msgid "← Back to all dumps" msgstr "← Back to all dumps" @@ -67,61 +67,61 @@ msgstr "+ Invite someone" msgid "+ New playlist" msgstr "+ New playlist" -#: src/pages/Dump.tsx:332 +#: src/pages/Dump.tsx:335 msgid "+ Playlist" msgstr "+ Playlist" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:195 +#: src/pages/Notifications.tsx:196 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} commented on <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:155 +#: src/pages/Notifications.tsx:156 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} followed your playlist <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:205 +#: src/pages/Notifications.tsx:206 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} liked your comment on <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:217 +#: src/pages/Notifications.tsx:222 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} mentioned you in <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:165 +#: src/pages/Notifications.tsx:166 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} posted <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:146 +#: src/pages/Notifications.tsx:147 msgid "<0>{0} started following you" msgstr "<0>{0} started following you" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:185 +#: src/pages/Notifications.tsx:186 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} upvoted <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:175 +#: src/pages/Notifications.tsx:176 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} was added to <1>{1}" -#: src/pages/Notifications.tsx:215 +#: src/pages/Notifications.tsx:217 msgid "a comment" msgstr "a comment" -#: src/pages/Notifications.tsx:215 +#: src/pages/Notifications.tsx:220 msgid "a post" msgstr "a post" @@ -180,17 +180,18 @@ msgstr "Auto" msgid "Back to login" msgstr "Back to login" -#: src/contexts/WSProvider.tsx:211 -#: src/contexts/WSProvider.tsx:438 +#: src/contexts/WSProvider.tsx:268 +#: src/contexts/WSProvider.tsx:570 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/CommentThread.tsx:124 #: src/components/ConfirmModal.tsx:32 #: src/components/form/FormActions.tsx:32 -#: src/pages/Dump.tsx:373 +#: src/pages/Dump.tsx:376 #: src/pages/DumpEdit.tsx:463 #: src/pages/PlaylistDetail.tsx:920 #: src/pages/UserPublicProfile.tsx:1674 @@ -202,7 +203,7 @@ msgstr "Cancel" msgid "Cancel removal" msgstr "Cancel removal" -#: src/components/AppHeader.tsx:65 +#: src/components/AppHeader.tsx:66 msgid "Cancel search" msgstr "Cancel search" @@ -228,6 +229,18 @@ msgstr "Change password" msgid "Change password…" msgstr "Change password…" +#: src/components/ChatButton.tsx:17 +#: src/components/ChatFab.tsx:49 +#: src/components/ChatFab.tsx:50 +#: src/components/ChatModal.tsx:308 +msgid "Chat" +msgstr "Chat" + +#: src/components/ChatButton.tsx:16 +#: src/components/ChatFab.tsx:48 +msgid "Chat ({unreadChatCount} unread)" +msgstr "Chat ({unreadChatCount} unread)" + #: src/pages/UserRegister.tsx:85 msgid "Checking invite…" msgstr "Checking invite…" @@ -298,6 +311,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/CommentThread.tsx:376 #: src/components/CommentThread.tsx:382 #: src/components/ConfirmModal.tsx:16 @@ -333,6 +349,10 @@ 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 +msgid "Delete this message?" +msgstr "Delete this message?" + #: src/pages/PlaylistDetail.tsx:757 #: src/pages/UserPlaylists.tsx:467 msgid "Delete this playlist? This cannot be undone." @@ -355,7 +375,7 @@ msgstr "Drop a file here" msgid "Drop a replacement here" msgstr "Drop a replacement here" -#: src/components/AppHeader.tsx:103 +#: src/components/AppHeader.tsx:105 msgid "Dump" msgstr "Dump" @@ -379,12 +399,14 @@ msgstr "Dumps" msgid "Dumps ({0}{1})" msgstr "Dumps ({0}{1})" -#: src/pages/Notifications.tsx:395 +#: src/pages/Notifications.tsx:400 msgid "Earlier" msgstr "Earlier" +#: src/components/ChatModal.tsx:101 +#: src/components/ChatModal.tsx:102 #: src/components/CommentThread.tsx:367 -#: src/pages/Dump.tsx:487 +#: src/pages/Dump.tsx:490 #: src/pages/PlaylistDetail.tsx:625 msgid "Edit" msgstr "Edit" @@ -400,18 +422,20 @@ msgstr "Edit title" #. placeholder {0}: relativeTime(comment.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt) -#. placeholder {0}: relativeTime(playlist.updatedAt) +#. placeholder {0}: relativeTime(message.updatedAt) +#: src/components/ChatModal.tsx:90 #: src/components/CommentThread.tsx:317 -#: src/pages/Dump.tsx:426 +#: src/pages/Dump.tsx:429 #: src/pages/PlaylistDetail.tsx:664 msgid "edited {0}" msgstr "edited {0}" #. placeholder {0}: comment.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString() -#. placeholder {0}: playlist.updatedAt.toLocaleString() +#. placeholder {0}: message.updatedAt.toLocaleString() +#: src/components/ChatModal.tsx:88 #: src/components/CommentThread.tsx:315 -#: src/pages/Dump.tsx:424 +#: src/pages/Dump.tsx:427 #: src/pages/PlaylistDetail.tsx:661 msgid "Edited {0}" msgstr "Edited {0}" @@ -455,7 +479,7 @@ msgstr "Failed to generate invite" #: src/pages/index/HotFeed.tsx:36 #: src/pages/index/JournalFeed.tsx:33 #: src/pages/index/NewFeed.tsx:36 -#: src/pages/Notifications.tsx:369 +#: src/pages/Notifications.tsx:374 #: src/pages/UserPublicProfile.tsx:1022 #: src/pages/UserPublicProfile.tsx:1064 #: src/pages/UserPublicProfile.tsx:1109 @@ -619,20 +643,24 @@ msgstr "Light" msgid "Like" msgstr "Like" -#: src/contexts/WSProvider.tsx:437 +#: src/contexts/WSProvider.tsx:569 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…" -#: src/components/AppHeader.tsx:130 +#: src/components/AppHeader.tsx:132 msgid "Live updates unavailable." msgstr "Live updates unavailable." #: src/components/UserListPopover.tsx:231 -#: src/pages/Notifications.tsx:442 +#: src/pages/Notifications.tsx:447 msgid "Load more" msgstr "Load more" -#: src/pages/Dump.tsx:237 +#: src/components/ChatModal.tsx:321 +msgid "Load older messages" +msgstr "Load older messages" + +#: src/pages/Dump.tsx:240 #: src/pages/DumpEdit.tsx:155 msgid "Loading dump…" msgstr "Loading dump…" @@ -658,6 +686,7 @@ msgid "Loading profile…" msgstr "Loading profile…" #: src/components/CategoryManager.tsx:52 +#: src/components/ChatModal.tsx:320 #: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/TextEditor.tsx:289 #: src/components/UserListPopover.tsx:192 @@ -666,8 +695,8 @@ msgstr "Loading profile…" #: src/pages/index/HotFeed.tsx:32 #: src/pages/index/JournalFeed.tsx:29 #: src/pages/index/NewFeed.tsx:32 -#: src/pages/Notifications.tsx:365 -#: src/pages/Notifications.tsx:441 +#: src/pages/Notifications.tsx:370 +#: src/pages/Notifications.tsx:446 #: src/pages/UserDumps.tsx:54 #: src/pages/UserPlaylists.tsx:345 #: src/pages/UserPublicProfile.tsx:1016 @@ -677,7 +706,7 @@ msgstr "Loading profile…" msgid "Loading…" msgstr "Loading…" -#: src/components/AppHeader.tsx:111 +#: src/components/AppHeader.tsx:113 #: src/pages/UserLogin.tsx:34 #: src/pages/UserLogin.tsx:59 #: src/pages/UserLogin.tsx:78 @@ -722,7 +751,7 @@ msgstr "Moderator" msgid "Name" msgstr "Name" -#: src/pages/Notifications.tsx:358 +#: src/pages/Notifications.tsx:363 msgid "new" msgstr "new" @@ -776,6 +805,10 @@ msgstr "No followed playlists yet." msgid "No invitees yet." msgstr "No invitees yet." +#: src/components/ChatModal.tsx:328 +msgid "No messages yet. Say hello!" +msgstr "No messages yet. Say hello!" + #: src/components/UserListPopover.tsx:200 msgid "No one yet." msgstr "No one yet." @@ -798,7 +831,7 @@ msgstr "No users match \"{q}\"." msgid "Not following anyone yet." msgstr "Not following anyone yet." -#: src/pages/Notifications.tsx:376 +#: src/pages/Notifications.tsx:381 #: src/pages/UserDumps.tsx:99 #: src/pages/UserPublicProfile.tsx:1338 #: src/pages/UserPublicProfile.tsx:1460 @@ -807,8 +840,8 @@ msgid "Nothing here yet." msgstr "Nothing here yet." #: src/components/NotificationBell.tsx:42 -#: src/pages/Notifications.tsx:260 -#: src/pages/Notifications.tsx:354 +#: src/pages/Notifications.tsx:265 +#: src/pages/Notifications.tsx:359 msgid "Notifications" msgstr "Notifications" @@ -855,7 +888,7 @@ msgstr "Passwords do not match" msgid "Playlist title" msgstr "Playlist title" -#: src/components/AppHeader.tsx:85 +#: src/components/AppHeader.tsx:86 #: src/components/UserMenu.tsx:62 #: src/pages/Search.tsx:177 #: src/pages/UserPlaylists.tsx:371 @@ -890,7 +923,7 @@ msgstr "Posting…" #: src/components/JournalCard.tsx:121 #: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistMembershipPanel.tsx:55 -#: src/pages/Dump.tsx:432 +#: src/pages/Dump.tsx:435 #: src/pages/PlaylistDetail.tsx:644 msgid "private" msgstr "private" @@ -932,7 +965,7 @@ msgstr "Registering…" msgid "Registration failed" msgstr "Registration failed" -#: src/pages/Dump.tsx:499 +#: src/pages/Dump.tsx:502 msgid "Related" msgstr "Related" @@ -977,7 +1010,7 @@ msgstr "Reset password" msgid "Reset to default" msgstr "Reset to default" -#: src/pages/Dump.tsx:254 +#: src/pages/Dump.tsx:257 #: src/pages/DumpEdit.tsx:172 msgid "Retry" msgstr "Retry" @@ -986,8 +1019,9 @@ msgstr "Retry" msgid "Role" msgstr "Role" +#: src/components/ChatModal.tsx:151 #: src/components/CommentThread.tsx:328 -#: src/pages/Dump.tsx:365 +#: src/pages/Dump.tsx:368 #: src/pages/DumpEdit.tsx:466 #: src/pages/PlaylistDetail.tsx:927 #: src/pages/UserPublicProfile.tsx:1666 @@ -997,7 +1031,7 @@ msgstr "Save" #: src/components/ChangePasswordModal.tsx:100 #: src/components/CommentThread.tsx:329 -#: src/pages/Dump.tsx:364 +#: src/pages/Dump.tsx:367 #: src/pages/PlaylistDetail.tsx:923 #: src/pages/ResetPassword.tsx:126 #: src/pages/UserPublicProfile.tsx:1663 @@ -1005,7 +1039,7 @@ msgstr "Save" msgid "Saving…" msgstr "Saving…" -#: src/components/AppHeader.tsx:65 +#: src/components/AppHeader.tsx:66 #: src/components/SearchBar.tsx:78 #: src/pages/Search.tsx:50 msgid "Search" @@ -1023,6 +1057,10 @@ msgstr "Search failed" msgid "Searching…" msgstr "Searching…" +#: src/components/ChatModal.tsx:369 +msgid "Send" +msgstr "Send" + #: src/pages/UserLogin.tsx:141 msgid "Send reset link" msgstr "Send reset link" @@ -1031,7 +1069,7 @@ msgstr "Send reset link" msgid "Sending…" msgstr "Sending…" -#: src/components/AppHeader.tsx:100 +#: src/components/AppHeader.tsx:102 msgid "Server unreachable" msgstr "Server unreachable" @@ -1061,6 +1099,10 @@ msgstr "Style" msgid "Submit search" msgstr "Submit search" +#: src/pages/Notifications.tsx:219 +msgid "the chat" +msgstr "the chat" + #: src/pages/UserRegister.tsx:97 msgid "This invite link is missing, expired, or already used." msgstr "This invite link is missing, expired, or already used." @@ -1091,10 +1133,14 @@ msgstr "Title" msgid "Title is required." msgstr "Title is required." -#: src/pages/Notifications.tsx:392 +#: src/pages/Notifications.tsx:397 msgid "Today" msgstr "Today" +#: src/components/ChatModal.tsx:350 +msgid "Type a message…" +msgstr "Type a message…" + #: src/pages/PlaylistDetail.tsx:747 msgid "Undo" msgstr "Undo" @@ -1194,11 +1240,11 @@ msgstr "Why?" msgid "Write a reply…" msgstr "Write a reply…" -#: src/pages/Notifications.tsx:394 +#: src/pages/Notifications.tsx:399 msgid "Yesterday" msgstr "Yesterday" -#: src/pages/Notifications.tsx:379 +#: src/pages/Notifications.tsx:384 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." diff --git a/src/locales/fr.js b/src/locales/fr.js index f9b8853..11847b3 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\"],\"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…\"],\"DPfwMq\":[\"Terminé\"],\"DdeHXH\":[\"Supprimer cette reco ? Cette action est irréversible.\"],\"Dp1JhP\":[\"<0>\",[\"0\"],\" a voté pour <1>\",[\"1\"],\"\"],\"ECiS12\":[\"Voir la reco →\"],\"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\"],\"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\"],\"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\"],\"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 !\"],\"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é.\"],\"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\"],\"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 diff --git a/src/locales/fr.po b/src/locales/fr.po index 47c8c53..ae17623 100644 --- a/src/locales/fr.po +++ b/src/locales/fr.po @@ -47,8 +47,8 @@ msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" msgid "← Back" msgstr "← Retour" -#: src/pages/Dump.tsx:261 -#: src/pages/Dump.tsx:491 +#: src/pages/Dump.tsx:264 +#: src/pages/Dump.tsx:494 #: src/pages/DumpEdit.tsx:179 msgid "← Back to all dumps" msgstr "← Retour à toutes les recos" @@ -67,61 +67,61 @@ msgstr "+ Inviter quelqu'un" msgid "+ New playlist" msgstr "+ Nouvelle collection" -#: src/pages/Dump.tsx:332 +#: src/pages/Dump.tsx:335 msgid "+ Playlist" msgstr "+ Collection" #. placeholder {0}: d.commenterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:195 +#: src/pages/Notifications.tsx:196 msgid "<0>{0} commented on <1>{1}" msgstr "<0>{0} a commenté sur <1>{1}" #. placeholder {0}: d.followerUsername #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:155 +#: src/pages/Notifications.tsx:156 msgid "<0>{0} followed your playlist <1>{1}" msgstr "<0>{0} a suivi votre collection <1>{1}" #. placeholder {0}: d.likerUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:205 +#: src/pages/Notifications.tsx:206 msgid "<0>{0} liked your comment on <1>{1}" msgstr "<0>{0} a aimé votre commentaire sur <1>{1}" #. placeholder {0}: d.mentionerUsername -#: src/pages/Notifications.tsx:217 +#: src/pages/Notifications.tsx:222 msgid "<0>{0} mentioned you in <1>{where}" msgstr "<0>{0} vous a mentionné dans <1>{where}" #. placeholder {0}: d.dumperUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:165 +#: src/pages/Notifications.tsx:166 msgid "<0>{0} posted <1>{1}" msgstr "<0>{0} a publié <1>{1}" #. placeholder {0}: d.followerUsername -#: src/pages/Notifications.tsx:146 +#: src/pages/Notifications.tsx:147 msgid "<0>{0} started following you" msgstr "<0>{0} a commencé à vous suivre" #. placeholder {0}: d.voterUsername #. placeholder {1}: d.dumpTitle -#: src/pages/Notifications.tsx:185 +#: src/pages/Notifications.tsx:186 msgid "<0>{0} upvoted <1>{1}" msgstr "<0>{0} a voté pour <1>{1}" #. placeholder {0}: d.dumpTitle #. placeholder {1}: d.playlistTitle -#: src/pages/Notifications.tsx:175 +#: src/pages/Notifications.tsx:176 msgid "<0>{0} was added to <1>{1}" msgstr "<0>{0} a été ajouté à <1>{1}" -#: src/pages/Notifications.tsx:215 +#: src/pages/Notifications.tsx:217 msgid "a comment" msgstr "un commentaire" -#: src/pages/Notifications.tsx:215 +#: src/pages/Notifications.tsx:220 msgid "a post" msgstr "une publication" @@ -180,17 +180,18 @@ msgstr "Auto" msgid "Back to login" msgstr "Retour à la connexion" -#: src/contexts/WSProvider.tsx:211 -#: src/contexts/WSProvider.tsx:438 +#: src/contexts/WSProvider.tsx:268 +#: src/contexts/WSProvider.tsx:570 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/CommentThread.tsx:124 #: src/components/ConfirmModal.tsx:32 #: src/components/form/FormActions.tsx:32 -#: src/pages/Dump.tsx:373 +#: src/pages/Dump.tsx:376 #: src/pages/DumpEdit.tsx:463 #: src/pages/PlaylistDetail.tsx:920 #: src/pages/UserPublicProfile.tsx:1674 @@ -202,7 +203,7 @@ msgstr "Annuler" msgid "Cancel removal" msgstr "Annuler la suppression" -#: src/components/AppHeader.tsx:65 +#: src/components/AppHeader.tsx:66 msgid "Cancel search" msgstr "Annuler la recherche" @@ -228,6 +229,18 @@ msgstr "Changer le mot de passe" msgid "Change password…" msgstr "Changer le mot de passe…" +#: src/components/ChatButton.tsx:17 +#: src/components/ChatFab.tsx:49 +#: src/components/ChatFab.tsx:50 +#: src/components/ChatModal.tsx:308 +msgid "Chat" +msgstr "Tribune" + +#: src/components/ChatButton.tsx:16 +#: src/components/ChatFab.tsx:48 +msgid "Chat ({unreadChatCount} unread)" +msgstr "{unreadChatCount} messages non lus" + #: src/pages/UserRegister.tsx:85 msgid "Checking invite…" msgstr "Vérification de l'invitation…" @@ -298,6 +311,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/CommentThread.tsx:376 #: src/components/CommentThread.tsx:382 #: src/components/ConfirmModal.tsx:16 @@ -333,6 +349,10 @@ 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 +msgid "Delete this message?" +msgstr "Supprimer ce message ?" + #: src/pages/PlaylistDetail.tsx:757 #: src/pages/UserPlaylists.tsx:467 msgid "Delete this playlist? This cannot be undone." @@ -355,7 +375,7 @@ msgstr "Déposez un fichier ici" msgid "Drop a replacement here" msgstr "Déposez un fichier de remplacement ici" -#: src/components/AppHeader.tsx:103 +#: src/components/AppHeader.tsx:105 msgid "Dump" msgstr "Reco" @@ -379,12 +399,14 @@ msgstr "Recos" msgid "Dumps ({0}{1})" msgstr "Recos ({0}{1})" -#: src/pages/Notifications.tsx:395 +#: src/pages/Notifications.tsx:400 msgid "Earlier" msgstr "Plus tôt" +#: src/components/ChatModal.tsx:101 +#: src/components/ChatModal.tsx:102 #: src/components/CommentThread.tsx:367 -#: src/pages/Dump.tsx:487 +#: src/pages/Dump.tsx:490 #: src/pages/PlaylistDetail.tsx:625 msgid "Edit" msgstr "Modifier" @@ -400,18 +422,20 @@ msgstr "Modifier le titre" #. placeholder {0}: relativeTime(comment.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt) -#. placeholder {0}: relativeTime(playlist.updatedAt) +#. placeholder {0}: relativeTime(message.updatedAt) +#: src/components/ChatModal.tsx:90 #: src/components/CommentThread.tsx:317 -#: src/pages/Dump.tsx:426 +#: src/pages/Dump.tsx:429 #: src/pages/PlaylistDetail.tsx:664 msgid "edited {0}" msgstr "modifié {0}" #. placeholder {0}: comment.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString() -#. placeholder {0}: playlist.updatedAt.toLocaleString() +#. placeholder {0}: message.updatedAt.toLocaleString() +#: src/components/ChatModal.tsx:88 #: src/components/CommentThread.tsx:315 -#: src/pages/Dump.tsx:424 +#: src/pages/Dump.tsx:427 #: src/pages/PlaylistDetail.tsx:661 msgid "Edited {0}" msgstr "Modifié le {0}" @@ -455,7 +479,7 @@ msgstr "Impossible de générer une invitation" #: src/pages/index/HotFeed.tsx:36 #: src/pages/index/JournalFeed.tsx:33 #: src/pages/index/NewFeed.tsx:36 -#: src/pages/Notifications.tsx:369 +#: src/pages/Notifications.tsx:374 #: src/pages/UserPublicProfile.tsx:1022 #: src/pages/UserPublicProfile.tsx:1064 #: src/pages/UserPublicProfile.tsx:1109 @@ -619,20 +643,24 @@ msgstr "Clair" msgid "Like" msgstr "Aimer" -#: src/contexts/WSProvider.tsx:437 +#: src/contexts/WSProvider.tsx:569 msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" -#: src/components/AppHeader.tsx:130 +#: src/components/AppHeader.tsx:132 msgid "Live updates unavailable." msgstr "Mises à jour en direct indisponibles." #: src/components/UserListPopover.tsx:231 -#: src/pages/Notifications.tsx:442 +#: src/pages/Notifications.tsx:447 msgid "Load more" msgstr "Charger plus" -#: src/pages/Dump.tsx:237 +#: src/components/ChatModal.tsx:321 +msgid "Load older messages" +msgstr "Charger les messages plus anciens" + +#: src/pages/Dump.tsx:240 #: src/pages/DumpEdit.tsx:155 msgid "Loading dump…" msgstr "Chargement de la reco…" @@ -658,6 +686,7 @@ msgid "Loading profile…" msgstr "Chargement du profil…" #: src/components/CategoryManager.tsx:52 +#: src/components/ChatModal.tsx:320 #: src/components/PlaylistMembershipPanel.tsx:28 #: src/components/TextEditor.tsx:289 #: src/components/UserListPopover.tsx:192 @@ -666,8 +695,8 @@ msgstr "Chargement du profil…" #: src/pages/index/HotFeed.tsx:32 #: src/pages/index/JournalFeed.tsx:29 #: src/pages/index/NewFeed.tsx:32 -#: src/pages/Notifications.tsx:365 -#: src/pages/Notifications.tsx:441 +#: src/pages/Notifications.tsx:370 +#: src/pages/Notifications.tsx:446 #: src/pages/UserDumps.tsx:54 #: src/pages/UserPlaylists.tsx:345 #: src/pages/UserPublicProfile.tsx:1016 @@ -677,7 +706,7 @@ msgstr "Chargement du profil…" msgid "Loading…" msgstr "Chargement…" -#: src/components/AppHeader.tsx:111 +#: src/components/AppHeader.tsx:113 #: src/pages/UserLogin.tsx:34 #: src/pages/UserLogin.tsx:59 #: src/pages/UserLogin.tsx:78 @@ -722,7 +751,7 @@ msgstr "Modérateur" msgid "Name" msgstr "Nom" -#: src/pages/Notifications.tsx:358 +#: src/pages/Notifications.tsx:363 msgid "new" msgstr "nouveau" @@ -776,6 +805,10 @@ msgstr "Pas encore de collections suivies." msgid "No invitees yet." msgstr "Aucun invité pour le moment." +#: src/components/ChatModal.tsx:328 +msgid "No messages yet. Say hello!" +msgstr "Aucun message pour l'instant. Dites bonjour !" + #: src/components/UserListPopover.tsx:200 msgid "No one yet." msgstr "Personne pour le moment." @@ -798,7 +831,7 @@ msgstr "Aucun utilisateur ne correspond à « {q} »." msgid "Not following anyone yet." msgstr "Aucun abonnement pour le moment." -#: src/pages/Notifications.tsx:376 +#: src/pages/Notifications.tsx:381 #: src/pages/UserDumps.tsx:99 #: src/pages/UserPublicProfile.tsx:1338 #: src/pages/UserPublicProfile.tsx:1460 @@ -807,8 +840,8 @@ msgid "Nothing here yet." msgstr "Rien ici pour l'instant." #: src/components/NotificationBell.tsx:42 -#: src/pages/Notifications.tsx:260 -#: src/pages/Notifications.tsx:354 +#: src/pages/Notifications.tsx:265 +#: src/pages/Notifications.tsx:359 msgid "Notifications" msgstr "Notifications" @@ -855,7 +888,7 @@ msgstr "Les mots de passe ne correspondent pas" msgid "Playlist title" msgstr "Titre de la collection" -#: src/components/AppHeader.tsx:85 +#: src/components/AppHeader.tsx:86 #: src/components/UserMenu.tsx:62 #: src/pages/Search.tsx:177 #: src/pages/UserPlaylists.tsx:371 @@ -890,7 +923,7 @@ msgstr "Publication…" #: src/components/JournalCard.tsx:121 #: src/components/PlaylistCard.tsx:73 #: src/components/PlaylistMembershipPanel.tsx:55 -#: src/pages/Dump.tsx:432 +#: src/pages/Dump.tsx:435 #: src/pages/PlaylistDetail.tsx:644 msgid "private" msgstr "privé" @@ -932,7 +965,7 @@ msgstr "Inscription…" msgid "Registration failed" msgstr "Inscription échouée" -#: src/pages/Dump.tsx:499 +#: src/pages/Dump.tsx:502 msgid "Related" msgstr "Connexe" @@ -977,7 +1010,7 @@ msgstr "Réinitialiser le mot de passe" msgid "Reset to default" msgstr "Réinitialiser par défaut" -#: src/pages/Dump.tsx:254 +#: src/pages/Dump.tsx:257 #: src/pages/DumpEdit.tsx:172 msgid "Retry" msgstr "Réessayer" @@ -986,8 +1019,9 @@ msgstr "Réessayer" msgid "Role" msgstr "Rôle" +#: src/components/ChatModal.tsx:151 #: src/components/CommentThread.tsx:328 -#: src/pages/Dump.tsx:365 +#: src/pages/Dump.tsx:368 #: src/pages/DumpEdit.tsx:466 #: src/pages/PlaylistDetail.tsx:927 #: src/pages/UserPublicProfile.tsx:1666 @@ -997,7 +1031,7 @@ msgstr "Enregistrer" #: src/components/ChangePasswordModal.tsx:100 #: src/components/CommentThread.tsx:329 -#: src/pages/Dump.tsx:364 +#: src/pages/Dump.tsx:367 #: src/pages/PlaylistDetail.tsx:923 #: src/pages/ResetPassword.tsx:126 #: src/pages/UserPublicProfile.tsx:1663 @@ -1005,7 +1039,7 @@ msgstr "Enregistrer" msgid "Saving…" msgstr "Enregistrement…" -#: src/components/AppHeader.tsx:65 +#: src/components/AppHeader.tsx:66 #: src/components/SearchBar.tsx:78 #: src/pages/Search.tsx:50 msgid "Search" @@ -1023,6 +1057,10 @@ msgstr "Recherche échouée" msgid "Searching…" msgstr "Recherche…" +#: src/components/ChatModal.tsx:369 +msgid "Send" +msgstr "Envoyer" + #: src/pages/UserLogin.tsx:141 msgid "Send reset link" msgstr "Envoyer le lien de réinitialisation" @@ -1031,7 +1069,7 @@ msgstr "Envoyer le lien de réinitialisation" msgid "Sending…" msgstr "Envoi…" -#: src/components/AppHeader.tsx:100 +#: src/components/AppHeader.tsx:102 msgid "Server unreachable" msgstr "Serveur inaccessible" @@ -1061,6 +1099,10 @@ msgstr "Style" msgid "Submit search" msgstr "Lancer la recherche" +#: src/pages/Notifications.tsx:219 +msgid "the chat" +msgstr "la tribune" + #: src/pages/UserRegister.tsx:97 msgid "This invite link is missing, expired, or already used." msgstr "Ce lien d'invitation est manquant, expiré ou déjà utilisé." @@ -1091,10 +1133,14 @@ msgstr "Titre" msgid "Title is required." msgstr "Un titre est requis." -#: src/pages/Notifications.tsx:392 +#: src/pages/Notifications.tsx:397 msgid "Today" msgstr "Aujourd'hui" +#: src/components/ChatModal.tsx:350 +msgid "Type a message…" +msgstr "Écrivez un message…" + #: src/pages/PlaylistDetail.tsx:747 msgid "Undo" msgstr "Annuler" @@ -1194,11 +1240,11 @@ msgstr "Pourquoi ?" msgid "Write a reply…" msgstr "Écrire une réponse…" -#: src/pages/Notifications.tsx:394 +#: src/pages/Notifications.tsx:399 msgid "Yesterday" msgstr "Hier" -#: src/pages/Notifications.tsx:379 +#: src/pages/Notifications.tsx:384 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." diff --git a/src/model.ts b/src/model.ts index d5ceb33..16f1af6 100644 --- a/src/model.ts +++ b/src/model.ts @@ -216,6 +216,30 @@ export function deserializeComment(raw: RawComment): Comment { }; } +/** + * Chat + */ + +export interface ChatMessage { + id: string; + userId: string; + body: string; + createdAt: Date; + updatedAt?: Date; + authorUsername: string; + authorAvatarMime?: string; +} + +export type RawChatMessage = WithStringDate; + +export function deserializeChatMessage(raw: RawChatMessage): ChatMessage { + return { + ...raw, + createdAt: new Date(raw.createdAt), + updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined, + }; +} + /** * Playlists */ @@ -328,7 +352,7 @@ export interface DumpUpvotedData { export interface UserMentionedData { mentionerId: string; mentionerUsername: string; - contextType: "comment" | "dump" | "playlist"; + contextType: "comment" | "dump" | "playlist" | "chat"; contextId: string; contextTitle: string; dumpId?: string; @@ -498,6 +522,21 @@ export interface WSForceLogoutMessage { type: "force_logout"; } +export interface WSChatMessageMessage { + type: "chat_message"; + message: RawChatMessage; +} + +export interface WSChatMessageUpdatedMessage { + type: "chat_message_updated"; + message: RawChatMessage; +} + +export interface WSChatMessageDeletedMessage { + type: "chat_message_deleted"; + id: string; +} + export type IncomingWSMessage = | WSPingMessage | WSWelcomeMessage @@ -519,7 +558,10 @@ export type IncomingWSMessage = | WSCommentDeletedMessage | WSNotificationCreatedMessage | WSErrorMessage - | WSForceLogoutMessage; + | WSForceLogoutMessage + | WSChatMessageMessage + | WSChatMessageUpdatedMessage + | WSChatMessageDeletedMessage; /** * WebSocket messages — client → server (outgoing) @@ -545,12 +587,37 @@ export interface WSCommentLikeRemoveMessage { commentId: string; } +export interface WSChatSendMessage { + type: "chat_send"; + body: string; +} + +export interface WSChatFocusMessage { + type: "chat_focus"; + open: boolean; +} + +export interface WSChatEditMessage { + type: "chat_edit"; + id: string; + body: string; +} + +export interface WSChatDeleteMessage { + type: "chat_delete"; + id: string; +} + export type OutgoingWSMessage = | WSPongMessage | WSVoteCastMessage | WSVoteRemoveMessage | WSCommentLikeCastMessage - | WSCommentLikeRemoveMessage; + | WSCommentLikeRemoveMessage + | WSChatSendMessage + | WSChatFocusMessage + | WSChatEditMessage + | WSChatDeleteMessage; /** * Follows diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index ab4124c..a63116f 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -132,6 +132,7 @@ 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`; return `/playlists/${d.contextId}`; } } @@ -212,7 +213,11 @@ function notificationContent(n: Notification): React.ReactNode { case "user_mentioned": { const d = data as UserMentionedData; const where = d.contextTitle || - (d.contextType === "comment" ? t`a comment` : t`a post`); + (d.contextType === "comment" + ? t`a comment` + : d.contextType === "chat" + ? t`the chat` + : t`a post`); return ( {d.mentionerUsername} diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css index 9985610..9948282 100644 --- a/src/themes/brutalist.css +++ b/src/themes/brutalist.css @@ -151,11 +151,12 @@ /* ── Buttons — square corners, modest hard shadow ────────────────── */ /* `.dump-fab` is exempt from the generic button chrome — it keeps its own shape, shadow and animations — but this hard-edged theme squares it off. */ -[data-style="brutalist"] .dump-fab { +[data-style="brutalist"] .dump-fab, +[data-style="brutalist"] .chat-fab { --fab-radius: 0; } -[data-style="brutalist"] button:not(.dump-fab) { +[data-style="brutalist"] button:not(.dump-fab, .chat-fab) { border-radius: 0; border: 2px solid var(--color-border); font-weight: 700; @@ -163,7 +164,7 @@ transition: box-shadow 0.08s ease, transform 0.08s ease, border-color 0.1s; } -[data-style="brutalist"] button:not(.dump-fab):hover:not(:disabled) { +[data-style="brutalist"] button:not(.dump-fab, .chat-fab):hover:not(:disabled) { border-color: var(--color-border); box-shadow: none; transform: translate(2px, 2px); diff --git a/src/themes/geocities.css b/src/themes/geocities.css index 547f393..27b8928 100644 --- a/src/themes/geocities.css +++ b/src/themes/geocities.css @@ -254,11 +254,12 @@ /* ── Buttons — chunky Win95 outset bevel, press = inset ──────────── */ /* `.dump-fab` is exempt from the generic button chrome — it keeps its own shape, glow and animations — but this squared-off retro theme squares it. */ -[data-style="geocities"] .dump-fab { +[data-style="geocities"] .dump-fab, +[data-style="geocities"] .chat-fab { --fab-radius: 0; } -[data-style="geocities"] button:not(.dump-fab) { +[data-style="geocities"] button:not(.dump-fab, .chat-fab) { border-radius: 0; border: 3px outset var(--color-border); box-shadow: none; @@ -325,7 +326,10 @@ [data-style="geocities"] .visibility-toggle button { border-radius: 0; - border: none; + /* Zero the width (not just the style): the generic `button:active` rule flips + border-style to `inset`, and a `none` border keeps its `medium` width — so + on press a 3px border would reappear inside the toggle's own frame. */ + border: 0; } /* ── Cards & panels — ridged "table border" with a soft neon halo ─── */ diff --git a/src/themes/nyt.css b/src/themes/nyt.css index 25b2d5a..8b341a4 100644 --- a/src/themes/nyt.css +++ b/src/themes/nyt.css @@ -150,7 +150,7 @@ } /* Sharp, rectilinear corners throughout — newsprint has no rounding. */ -[data-style="nyt"] button:not(.dump-fab), +[data-style="nyt"] button:not(.dump-fab, .chat-fab), [data-style="nyt"] .dump-card, [data-style="nyt"] .playlist-card, [data-style="nyt"] .journal-card, @@ -184,7 +184,8 @@ /* This theme squares everything off — opt the FAB into a square too (it keeps its animations; only the corners change). */ -[data-style="nyt"] .dump-fab { +[data-style="nyt"] .dump-fab, +[data-style="nyt"] .chat-fab { --fab-radius: 0; } diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index 64b0262..5fa1258 100644 --- a/src/utils/permissions.ts +++ b/src/utils/permissions.ts @@ -5,6 +5,7 @@ export type Permission = | "dump:moderate" | "comment:moderate" | "playlist:moderate" + | "chat:moderate" | "user:manage" | "category:manage"; @@ -12,13 +13,19 @@ const ALL_PERMISSIONS: readonly Permission[] = [ "dump:moderate", "comment:moderate", "playlist:moderate", + "chat:moderate", "user:manage", "category:manage", ]; const ROLE_PERMISSIONS: Record = { admin: ALL_PERMISSIONS, - moderator: ["dump:moderate", "comment:moderate", "playlist:moderate"], + moderator: [ + "dump:moderate", + "comment:moderate", + "playlist:moderate", + "chat:moderate", + ], user: [], };