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

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

View File

@@ -12,6 +12,7 @@ import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_s
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
import { up as up0014ChatReadState } from "./migrations/0014_chat_read_state.ts";
interface Migration {
name: string;
@@ -38,6 +39,7 @@ const MIGRATIONS: Migration[] = [
up: up0012FixFaviconReclassification,
},
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
{ name: "0014_chat_read_state", up: up0014ChatReadState },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,28 @@
import type { DatabaseSync } from "node:sqlite";
// Chat unread state lives on the server so it survives reloads and agrees
// across tabs and devices: `users.chat_read_at` is the created_at of the newest
// message the user has seen. Existing users start caught up (now) rather than
// with the whole history unread; new users fall back to their own created_at.
//
// The history cursor now pages on (created_at, id) so two messages sharing a
// millisecond can't be skipped at a page boundary; the index follows suit.
//
// Idempotent: also runs against fresh databases created from schema.sql, where
// the column and index already exist.
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string;
}[];
if (!cols.some((c) => c.name === "chat_read_at")) {
db.exec(`ALTER TABLE users ADD COLUMN chat_read_at TEXT;`);
db.prepare(`UPDATE users SET chat_read_at = ?;`).run(
new Date().toISOString(),
);
}
db.exec(`DROP INDEX IF EXISTS idx_chat_messages_created;`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_chat_messages_created_id ON chat_messages(created_at, id);`,
);
}

View File

@@ -30,7 +30,8 @@ CREATE TABLE users (
avatar_mime TEXT,
description TEXT,
invited_by TEXT REFERENCES users(id),
email TEXT NOT NULL
email TEXT NOT NULL,
chat_read_at TEXT
);
CREATE TABLE votes (
@@ -205,4 +206,4 @@ CREATE TABLE chat_messages (
reply_to_id TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_chat_messages_created ON chat_messages(created_at);
CREATE INDEX idx_chat_messages_created_id ON chat_messages(created_at, id);

View File

@@ -101,7 +101,8 @@ export type Role = "user" | "moderator" | "admin";
export const ROLES: readonly Role[] = ["user", "moderator", "admin"];
export function isRole(value: unknown): value is Role {
return typeof value === "string" && (ROLES as readonly string[]).includes(value);
return typeof value === "string" &&
(ROLES as readonly string[]).includes(value);
}
export interface User {
@@ -595,6 +596,9 @@ export interface ChatSendMessage {
body: string;
/** Id of the message being replied to, if this is a reply. */
replyToId?: string;
/** Client-chosen id echoed on the broadcast (or on the error), so the
* sender can match the outcome to its pending message. */
nonce?: string;
}
// Sent while the user is composing (true) and when they stop (false). The
@@ -622,6 +626,12 @@ export interface ChatDeleteMessage {
id: string;
}
// The user has seen the chat up to the message created at `at`.
export interface ChatReadMessage {
type: "chat_read";
at: string;
}
export type ClientToServerMessage =
| PingMessage
| PongMessage
@@ -633,7 +643,8 @@ export type ClientToServerMessage =
| ChatTypingMessage
| ChatFocusMessage
| ChatEditMessage
| ChatDeleteMessage;
| ChatDeleteMessage
| ChatReadMessage;
// ── Server → Client ──────────────────────────────────────────────────────────
@@ -650,6 +661,10 @@ export interface WelcomeMessage {
myVotes: string[];
myCommentLikes: string[];
unreadNotificationCount: number;
/** The user's chat read marker and the unread count behind it (signed-in
* users only). */
chatReadAt?: string;
unreadChatCount: number;
}
export interface PresenceUpdateMessage {
@@ -758,6 +773,10 @@ export interface ErrorMessage {
dumpId?: string;
commentId?: string;
action?: "cast" | "remove";
/** Set when a chat action failed, so the client can surface it in the chat. */
scope?: "chat";
/** Echo of the failed chat_send's nonce. */
nonce?: string;
}
export interface ForceLogoutMessage {
@@ -767,6 +786,8 @@ export interface ForceLogoutMessage {
export interface ChatMessageMessage {
type: "chat_message";
message: ChatMessage;
/** The sender's nonce, so its pending copy can be retired. */
nonce?: string;
}
export interface ChatMessageUpdatedMessage {
@@ -786,6 +807,13 @@ export interface ChatTypingUpdateMessage {
users: OnlineUser[];
}
// The user's read marker moved (from any of their tabs or devices).
export interface ChatReadUpdateMessage {
type: "chat_read_update";
readAt: string;
unread: number;
}
export type ServerToClientMessage =
| PingMessage
| WelcomeMessage
@@ -811,7 +839,8 @@ export type ServerToClientMessage =
| ChatMessageMessage
| ChatMessageUpdatedMessage
| ChatMessageDeletedMessage
| ChatTypingUpdateMessage;
| ChatTypingUpdateMessage
| ChatReadUpdateMessage;
/**
* Follows

View File

@@ -1,21 +1,36 @@
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";
import {
getChatMessagesAfter,
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.
// GET /api/chat — recent messages (ascending). `limit` caps the page size.
// Cursors (ISO created_at, plus the message id to break ties):
// before/beforeId — the page of older messages preceding it ("load older");
// after/afterId — the messages following it (resync after a reconnect);
// without afterId the timestamp itself is included.
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 limit = limitParam ? Number(limitParam) : NaN;
const cappedLimit = Number.isFinite(limit) ? limit : undefined;
const before = params.get("before");
const after = params.get("after");
const messages = after
? getChatMessagesAfter(
{ createdAt: after, id: params.get("afterId") ?? undefined },
cappedLimit,
)
: getRecentChatMessages(
cappedLimit,
before
? { createdAt: before, id: params.get("beforeId") ?? undefined }
: undefined,
);
const responseBody: APIResponse<ChatMessage[]> = {
success: true,
data: messages,

View File

@@ -12,14 +12,19 @@ import {
getOnlineUsers,
handleClientPong,
isUserChatOpen,
type RateLimit,
register,
sendToUser,
setClientTyping,
takeToken,
unregister,
type WsClient,
} from "../services/ws-service.ts";
import {
createChatMessage,
deleteChatMessage,
getChatReadState,
markChatRead,
updateChatMessage,
} from "../services/chat-service.ts";
import { notifyMentions } from "../services/notification-service.ts";
@@ -89,12 +94,17 @@ router.get("/ws", async (ctx) => {
const unreadNotificationCount = authPayload
? getUnreadCount(authPayload.userId)
: 0;
const chatRead = authPayload
? getChatReadState(authPayload.userId)
: undefined;
socket.send(JSON.stringify({
type: "welcome",
users: getOnlineUsers(),
myVotes,
myCommentLikes,
unreadNotificationCount,
chatReadAt: chatRead?.readAt,
unreadChatCount: chatRead?.unread ?? 0,
}));
} catch (err) {
console.error("[ws] welcome send failed:", err);
@@ -129,13 +139,19 @@ router.get("/ws", async (ctx) => {
handleCommentLike(client, msg.commentId, "remove");
break;
case "chat_send":
handleChatSend(client, msg.body, msg.replyToId);
handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
break;
case "chat_typing":
if (client.userId) setClientTyping(client, msg.typing);
// Typing is cosmetic: past the limit it's just dropped.
if (
client.userId &&
takeToken(`typing:${client.userId}`, TYPING_LIMIT)
) {
setClientTyping(client, msg.typing === true);
}
break;
case "chat_focus":
client.chatOpen = msg.open;
client.chatOpen = msg.open === true;
break;
case "chat_edit":
handleChatEdit(client, msg.id, msg.body);
@@ -143,6 +159,9 @@ router.get("/ws", async (ctx) => {
case "chat_delete":
handleChatDelete(client, msg.id);
break;
case "chat_read":
handleChatRead(client, msg.at);
break;
}
});
@@ -221,32 +240,79 @@ function handleCommentLike(
}
}
// Sending, editing and deleting share one bucket: a burst of 8, then one every
// 1.5s — room for a quick back-and-forth, not for a flood.
const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 };
const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 };
// Read markers are cheap for the client to send often; past this they're
// dropped, and the next one catches up.
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
function sendChatError(
client: WsClient,
message: string,
nonce?: string,
): void {
if (client.socket.readyState !== WebSocket.OPEN) return;
client.socket.send(
JSON.stringify({ type: "error", scope: "chat", message, nonce }),
);
}
// Validates the common preconditions of a chat write: signed in and under the
// rate limit. Returns the user id, or undefined after reporting why not.
function chatWriter(client: WsClient, nonce?: string): string | undefined {
if (!client.userId) {
sendChatError(client, "Authentication required", nonce);
return undefined;
}
if (!takeToken(`chat:${client.userId}`, CHAT_WRITE_LIMIT)) {
sendChatError(
client,
"You're sending messages too fast — wait a moment",
nonce,
);
return undefined;
}
return client.userId;
}
function optionalString(value: unknown, maxLength: number): string | undefined {
return typeof value === "string" && value.length <= maxLength
? value
: undefined;
}
function handleChatSend(
client: WsClient,
body: string,
replyToId?: string,
body: unknown,
replyToId: unknown,
rawNonce: unknown,
): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
const nonce = optionalString(rawNonce, 64);
const userId = chatWriter(client, nonce);
if (!userId) return;
if (typeof body !== "string") {
sendChatError(client, "Message cannot be empty", nonce);
return;
}
try {
const message = createChatMessage(client.userId, body, replyToId);
const message = createChatMessage(
userId,
body,
optionalString(replyToId, 64),
);
// Sending a message ends composition — clear the typing flag before the
// message broadcast so the indicator doesn't linger next to it.
setClientTyping(client, false);
// Broadcast to everyone including the sender, so the sender's own message
// simply arrives over the socket — no optimistic-send machinery needed.
broadcastChatMessage(message);
// Broadcast to everyone including the sender, whose pending copy the nonce
// retires.
broadcastChatMessage(message, nonce);
// Skip mention notifications for users who already have the chat open —
// they've seen the message live.
notifyMentions(
client.userId,
userId,
message.body,
"chat",
message.id,
@@ -255,24 +321,32 @@ function handleChatSend(
isUserChatOpen,
);
} catch (err) {
const message = err instanceof APIException
? err.message
: "Failed to send message";
socket.send(JSON.stringify({ type: "error", message }));
sendChatError(
client,
err instanceof APIException ? err.message : "Failed to send message",
nonce,
);
}
}
// Reads the role from the DB on every moderation check rather than trusting the
// one captured at connect: a demoted moderator loses the power right away, not
// whenever their socket next reconnects.
function canModerateChat(client: WsClient): boolean {
if (!client.userId) return false;
try {
client.role = getUserById(client.userId).role;
} catch {
client.role = undefined;
}
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" }),
);
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
const userId = chatWriter(client);
if (!userId) return;
if (typeof id !== "string" || typeof body !== "string") {
sendChatError(client, "Invalid message");
return;
}
@@ -280,14 +354,14 @@ function handleChatEdit(client: WsClient, id: string, body: string): void {
const message = updateChatMessage(
id,
body,
client.userId,
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,
userId,
message.body,
"chat",
message.id,
@@ -296,31 +370,46 @@ function handleChatEdit(client: WsClient, id: string, body: string): void {
isUserChatOpen,
);
} catch (err) {
const message = err instanceof APIException
? err.message
: "Failed to edit message";
socket.send(JSON.stringify({ type: "error", message }));
sendChatError(
client,
err instanceof APIException ? err.message : "Failed to edit message",
);
}
}
function handleChatDelete(client: WsClient, id: string): void {
const { socket } = client;
if (!client.userId) {
socket.send(
JSON.stringify({ type: "error", message: "Authentication required" }),
);
function handleChatDelete(client: WsClient, id: unknown): void {
const userId = chatWriter(client);
if (!userId) return;
if (typeof id !== "string") {
sendChatError(client, "Invalid message");
return;
}
try {
deleteChatMessage(id, canModerateChat(client));
deleteChatMessage(id, userId, canModerateChat(client));
broadcastChatMessageDeleted(id);
} catch (err) {
const message = err instanceof APIException
? err.message
: "Failed to delete message";
socket.send(JSON.stringify({ type: "error", message }));
sendChatError(
client,
err instanceof APIException ? err.message : "Failed to delete message",
);
}
}
// Moves the user's read marker and tells all their sockets, so every tab and
// device drops its badge together.
function handleChatRead(client: WsClient, at: unknown): void {
if (!client.userId || typeof at !== "string") return;
if (!takeToken(`read:${client.userId}`, CHAT_READ_LIMIT)) return;
try {
const state = markChatRead(client.userId, at);
sendToUser(client.userId, {
type: "chat_read_update",
readAt: state.readAt,
unread: state.unread,
});
} catch {
// A malformed marker changes nothing; there's nothing to report.
}
}

View File

@@ -11,8 +11,10 @@ const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
// Length cap for the replied-to preview snippet, so a reply to a long message
// doesn't ship the whole 2000-char body over the wire for every reply.
const REPLY_SNIPPET_LEN = 140;
// doesn't ship the whole 2000-char body over the wire for every reply. This is
// raw markdown: the client strips it to text before showing a line of it, so
// it's cut generously enough that an image URL or link doesn't eat it all.
const REPLY_SNIPPET_LEN = 300;
const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
@@ -21,8 +23,7 @@ const SELECT_COLS =
// Common joins: author (required) plus the optional replied-to message and its
// author (LEFT — null when there's no reply or the target was deleted).
const FROM_JOINS =
`FROM chat_messages m
const FROM_JOINS = `FROM chat_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id`;
@@ -124,39 +125,34 @@ export function updateChatMessage(
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) {
// Owners may delete their own messages; moderators may delete any.
export function deleteChatMessage(
id: string,
requestingUserId: string,
canModerate: boolean,
): void {
const ownerId = getMessageOwner(id); // 404s if the message doesn't exist
if (ownerId !== requestingUserId && !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_JOINS}
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
).all(before, capped)
: db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
ORDER BY m.created_at DESC LIMIT ?;`,
).all(capped);
/** A position in the chat stream. Messages are ordered by (createdAt, id), so
* two sharing a millisecond still have a strict order and a page boundary
* between them can't skip either. */
export interface ChatCursor {
createdAt: string;
/** Omitted: the cursor sits before (for `after`) or after (for `before`)
* every message sharing that timestamp. */
id?: string;
}
function toMessages(rows: unknown[]): ChatMessage[] {
if (!rows.every(isChatMessageRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
@@ -164,6 +160,103 @@ export function getRecentChatMessages(
"Malformed chat message data",
);
}
// Newest-first from SQL; reverse to ascending for display.
return rows.map(chatMessageRowToApi).reverse();
return rows.map(chatMessageRowToApi);
}
function capLimit(limit: number): number {
return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT);
}
/**
* Returns up to `limit` most recent messages in ascending (display) order.
* When `before` is given, returns the page of older messages immediately
* preceding it — used for "load older" history.
*/
export function getRecentChatMessages(
limit = DEFAULT_LIMIT,
before?: ChatCursor,
): ChatMessage[] {
const capped = capLimit(limit);
const rows = before
? db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
WHERE m.created_at < ? OR (m.created_at = ? AND m.id < ?)
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
).all(
before.createdAt,
before.createdAt,
// No id: nothing at that timestamp sorts below the empty string.
before.id ?? "",
capped,
)
: db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
).all(capped);
// Newest-first from SQL; reverse to ascending for display.
return toMessages(rows).reverse();
}
/**
* Returns up to `limit` messages following `after`, in ascending order — used
* to resync the chat after a reconnect. Without an id the cursor is inclusive
* of its timestamp.
*/
export function getChatMessagesAfter(
after: ChatCursor,
limit = DEFAULT_LIMIT,
): ChatMessage[] {
const rows = db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS}
WHERE m.created_at > ? OR (m.created_at = ? AND m.id > ?)
ORDER BY m.created_at ASC, m.id ASC LIMIT ?;`,
).all(after.createdAt, after.createdAt, after.id ?? "", capLimit(limit));
return toMessages(rows);
}
export interface ChatReadState {
/** created_at of the newest message the user has seen (ISO). */
readAt: string;
/** Messages from other people since then. */
unread: number;
}
export function getChatReadState(userId: string): ChatReadState {
const row = db.prepare(
`SELECT COALESCE(u.chat_read_at, u.created_at) AS read_at,
(SELECT COUNT(*) FROM chat_messages m
WHERE m.user_id != u.id
AND m.created_at > COALESCE(u.chat_read_at, u.created_at)) AS unread
FROM users u WHERE u.id = ?;`,
).get(userId) as { read_at: string; unread: number } | undefined;
if (!row) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
}
return { readAt: row.read_at, unread: row.unread };
}
/**
* Moves the user's read marker forward to `at` (a message's created_at) —
* never backward, so a lagging tab can't undo a newer tab's read, and never
* past the newest message, so a bogus timestamp can't pre-read the future.
*/
export function markChatRead(userId: string, at: string): ChatReadState {
const parsed = new Date(at);
if (Number.isNaN(parsed.getTime())) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid read marker",
);
}
const newest = db.prepare(
`SELECT MAX(created_at) AS newest FROM chat_messages;`,
).get() as { newest: string | null };
const iso = parsed.toISOString();
const target = newest.newest && iso > newest.newest ? newest.newest : iso;
db.prepare(
`UPDATE users SET chat_read_at = ?
WHERE id = ? AND (chat_read_at IS NULL OR chat_read_at < ?);`,
).run(target, userId, target);
return getChatReadState(userId);
}

View File

@@ -217,23 +217,28 @@ export function broadcastCommentUpdated(comment: Comment): void {
}
}
// Global chat room: every connected client receives every message.
export function broadcastChatMessage(message: ChatMessage): void {
// Global chat room for members: history is behind auth, so the live stream is
// too — an anonymous socket gets nothing chat-related.
function sendToMembers(data: ServerToClientMessage): void {
for (const client of clients) {
send(client.socket, { type: "chat_message", message });
if (client.userId) send(client.socket, data);
}
}
// `nonce` is the sender's id for its pending copy; harmless to everyone else.
export function broadcastChatMessage(
message: ChatMessage,
nonce?: string,
): void {
sendToMembers({ type: "chat_message", message, nonce });
}
export function broadcastChatMessageUpdated(message: ChatMessage): void {
for (const client of clients) {
send(client.socket, { type: "chat_message_updated", message });
}
sendToMembers({ type: "chat_message_updated", message });
}
export function broadcastChatMessageDeleted(id: string): void {
for (const client of clients) {
send(client.socket, { type: "chat_message_deleted", id });
}
sendToMembers({ type: "chat_message_deleted", id });
}
// Typing indicator: a client's "typing" signal is considered live for this long
@@ -263,10 +268,7 @@ export function getTypingUsers(): OnlineUser[] {
}
export function broadcastTyping(): void {
const users = getTypingUsers();
for (const client of clients) {
send(client.socket, { type: "chat_typing_update", users });
}
sendToMembers({ type: "chat_typing_update", users: getTypingUsers() });
}
// Record (or clear) a client's typing state and tell everyone. The client also
@@ -303,6 +305,45 @@ export function isUserChatOpen(userId: string): boolean {
return false;
}
// Token buckets keyed by `${kind}:${userId}` — per user rather than per socket,
// so opening more tabs doesn't buy more throughput.
interface Bucket {
tokens: number;
at: number;
}
const buckets = new Map<string, Bucket>();
export interface RateLimit {
/** Burst size. */
capacity: number;
/** Tokens regained per second. */
refill: number;
}
/** Takes one token for `key`; false when the bucket is empty. */
export function takeToken(key: string, limit: RateLimit): boolean {
const now = Date.now();
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
bucket.tokens = Math.min(
limit.capacity,
bucket.tokens + ((now - bucket.at) / 1000) * limit.refill,
);
bucket.at = now;
buckets.set(key, bucket);
if (bucket.tokens < 1) return false;
bucket.tokens -= 1;
return true;
}
// Full buckets carry no information; drop them so the map tracks only users
// who've been active lately.
setInterval(() => {
const cutoff = Date.now() - 5 * 60_000;
for (const [key, bucket] of buckets) {
if (bucket.at < cutoff) buckets.delete(key);
}
}, 60_000);
export function handleClientPong(client: WsClient): void {
client.pongReceived = true;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

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

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