diff --git a/api/db/migrate.ts b/api/db/migrate.ts index f5180311..5606523d 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -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 { diff --git a/api/db/migrations/0014_chat_read_state.ts b/api/db/migrations/0014_chat_read_state.ts new file mode 100644 index 00000000..f72b42f1 --- /dev/null +++ b/api/db/migrations/0014_chat_read_state.ts @@ -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);`, + ); +} diff --git a/api/db/schema.sql b/api/db/schema.sql index 9daf7214..6e4d74d5 100644 --- a/api/db/schema.sql +++ b/api/db/schema.sql @@ -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); diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index d3eaafb0..0deea7ef 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -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 diff --git a/api/routes/chat.ts b/api/routes/chat.ts index 368bd8a5..8dc5311f 100644 --- a/api/routes/chat.ts +++ b/api/routes/chat.ts @@ -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 = { success: true, data: messages, diff --git a/api/routes/ws.ts b/api/routes/ws.ts index a4b4b132..14b4435d 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -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. } } diff --git a/api/services/chat-service.ts b/api/services/chat-service.ts index 5de9424c..7030efb9 100644 --- a/api/services/chat-service.ts +++ b/api/services/chat-service.ts @@ -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); } diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index d093ca1e..53d4ab29 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -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(); + +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; } diff --git a/src/App.css b/src/App.css index 879b1930..7dade3c1 100644 --- a/src/App.css +++ b/src/App.css @@ -4169,10 +4169,19 @@ body.has-player .chat-fab { gap: 0.75rem; } +.chat-messages-wrap { + position: relative; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + .chat-messages { flex: 1 1 auto; min-height: 0; overflow-y: auto; + overscroll-behavior: contain; /* Reserve a gutter for the scrollbar so it never sits on top of the messages — without this, overlay scrollbars overlap the content. The extra padding keeps the text clear of the scrollbar rather than right against it. */ @@ -4183,10 +4192,19 @@ body.has-player .chat-fab { scrollbar-color: transparent transparent; display: flex; flex-direction: column; - gap: 0.85rem; padding-right: 0.6rem; } +/* One box around everything the list scrolls, so its height can be watched as + a whole (see the ResizeObserver in ChatModal). Grows to fill the list, which + keeps the empty state centered. */ +.chat-messages-content { + flex: 1 0 auto; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + .chat-messages:hover, .chat-messages:focus-within { scrollbar-color: color-mix(in srgb, var(--color-text-muted) 45%, transparent) @@ -4215,6 +4233,14 @@ body.has-player .chat-fab { margin: auto 0; text-align: center; color: var(--color-text-muted); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +.chat-empty p { + margin: 0; } .chat-load-older { @@ -4251,6 +4277,7 @@ body.has-player .chat-fab { /* Brief highlight when a message is jumped to via a reply reference. */ .chat-message { + position: relative; border-radius: var(--radius-lg); transition: background-color 0.4s ease; } @@ -4259,6 +4286,100 @@ body.has-player .chat-fab { background-color: color-mix(in srgb, var(--color-accent) 18%, transparent); } +/* A run of messages from one author: the follow-ups drop the avatar and the + author line and sit close under the first, pulling back most of the list's + gap. The avatar column shows the time instead, on hover. */ +.chat-message--grouped { + margin-top: -0.6rem; +} + +.chat-message-gutter { + flex: 0 0 32px; + /* The time may be wider than the column; let it spill evenly both sides + rather than widen the column and push the text out of line. */ + min-width: 0; + display: flex; + justify-content: center; + /* Drawn, but no height of its own: the tooltip wrapper around the time is + taller than a line of text and would pad every follow-up message. */ + height: 0; + overflow: visible; + white-space: nowrap; + padding-top: 0.2rem; + font-size: var(--text-2xs); + line-height: 1.2; + color: var(--color-text-muted); + opacity: 0; + transition: opacity var(--dur-base); +} + +.chat-message:hover .chat-message-gutter, +.chat-message:focus-within .chat-message-gutter { + opacity: 1; +} + +/* With no meta row to trail, a grouped message's actions float at its top + right, over a patch of surface so they stay legible over the text. */ +.chat-message-actions--float { + position: absolute; + top: -0.35rem; + right: 0.25rem; + margin: 0; + padding: 0.1rem 0.25rem; + border-radius: var(--radius-sm); + background: var(--color-surface); + box-shadow: 0 0 0 1px var(--color-border-subtle); +} + +/* Messages that mention you (or reply to you) carry an accent edge. */ +.chat-message--mention { + background-color: color-mix(in srgb, var(--color-accent) 7%, transparent); + box-shadow: inset 2px 0 0 var(--color-accent); + padding: 0.25rem 0.35rem 0.25rem 0.5rem; + margin-left: -0.5rem; +} + +.chat-message--grouped.chat-message--mention { + margin-top: -0.85rem; +} + +/* A message on its way: dimmed until the server echoes it back. */ +.chat-message--pending .chat-message-text { + opacity: 0.55; +} + +.chat-message--failed .chat-message-text { + opacity: 0.75; +} + +.chat-pending-status { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.2rem 0.6rem; + margin-top: 0.15rem; + font-size: var(--text-2xs); + color: var(--color-text-muted); +} + +.chat-pending-error { + color: var(--color-danger); +} + +.chat-text-btn { + padding: 0; + background: none; + border: none; + font: inherit; + font-weight: 600; + color: var(--color-accent); + cursor: pointer; +} + +.chat-text-btn:hover { + text-decoration: underline; +} + /* Subtle day separator: a hairline rule with a centered, muted label. */ .chat-day-sep { display: flex; @@ -4283,6 +4404,70 @@ body.has-player .chat-fab { letter-spacing: 0.03em; } +/* Where the unread messages start — the day separator's rule, in accent. */ +.chat-new-sep { + color: var(--color-accent); +} + +.chat-new-sep::before, +.chat-new-sep::after { + background: currentColor; + opacity: 0.6; +} + +/* Scrolled up while messages arrive: a pill over the list's foot says how + many, and takes you down to them. */ +.chat-new-pill { + position: absolute; + left: 50%; + bottom: 0.5rem; + transform: translateX(-50%); + padding: 0.3rem 0.8rem; + border: none; + border-radius: 999px; + background: var(--color-accent); + color: var(--color-on-accent); + font: inherit; + font-size: var(--text-xs); + font-weight: 600; + white-space: nowrap; + cursor: pointer; + box-shadow: var(--shadow-2); + animation: chat-msg-in 0.18s ease-out; +} + +.chat-new-pill:hover { + background: var(--color-accent-hover); +} + +/* Connection trouble and failed edits/deletes, in a slim bar. */ +.chat-status, +.chat-error { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + padding: 0.35rem 0.6rem; + border-radius: var(--radius-sm); + font-size: var(--text-xs); +} + +.chat-status { + background: color-mix(in srgb, var(--color-text-muted) 12%, transparent); + color: var(--color-text-muted); +} + +.chat-error { + border-left: 2px solid var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 10%, transparent); + color: var(--color-text); +} + +.chat-error-text { + flex: 1; + min-width: 0; +} + .chat-message-body { flex: 1; min-width: 0; @@ -4402,22 +4587,28 @@ body.has-player .chat-fab { min-width: 0; } -/* Reserve room on the input's bottom-right for the char counter. `display: - block` removes the inline-block baseline descender gap below the textarea — - that phantom space was making the wrapper taller than the textarea, so the - bottom-aligned Send button landed a few px low. */ -.chat-input { - padding-right: 3.5rem; +/* `display: block` removes the inline-block baseline descender gap below the + textarea — that phantom space was making the wrapper taller than the + textarea, so the bottom-aligned Send button landed a few px low. + A long message grows the box only so far, then scrolls inside it, so the + composer can't squeeze the message list away and an edit box can't outgrow + the chat. (Qualified to outrank .comment-reply-textarea's overflow.) */ +.comment-reply-textarea.chat-input { display: block; + max-height: min(35vh, 15rem); + overflow-y: auto; + scrollbar-width: thin; } -/* Pin the counter inside the textarea instead of letting it add a row below — - that extra height is what knocked the Send button out of alignment. */ +/* The counter (only shown near the limit) sits just above the input's right + edge, in the gap over the composer: inside the textarea, the text ran under + it, and in a row below it would knock the Send button out of alignment. */ .chat-composer .text-editor-count { position: absolute; - right: 0.55rem; - bottom: 0.4rem; + right: 0.25rem; + bottom: calc(100% + 1px); margin: 0; + line-height: 1; } .chat-send-btn { diff --git a/src/components/ChatModal.tsx b/src/components/ChatModal.tsx index 8e0c77a2..ce5dd5ee 100644 --- a/src/components/ChatModal.tsx +++ b/src/components/ChatModal.tsx @@ -1,4 +1,5 @@ import { + type KeyboardEvent, useCallback, useEffect, useLayoutEffect, @@ -20,8 +21,13 @@ 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 { markdownToPlainText, mentionsUser } from "../utils/chatText.ts"; import { i18n } from "../i18n.ts"; import { API_URL } from "../config/api.ts"; +import type { + ChatReplyTarget, + PendingChatMessage, +} from "../contexts/WSContext.ts"; import { type ChatMessage, deserializeChatMessage, @@ -31,224 +37,87 @@ import { // Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts. const MAX_CHAT_LENGTH = 2000; +// The counter only shows once a message is getting near the limit. +const COUNT_FROM = 1800; // Matches the server's default page size; a full page means there may be more. const PAGE_SIZE = 50; +// Resync after a reconnect walks forward from the oldest loaded message in +// pages of this size; past the cap it gives up and reloads the latest page. +const RESYNC_PAGE = 100; +const RESYNC_MAX_PAGES = 5; +// Consecutive messages from one author within this window share a header. +const GROUP_WINDOW = 5 * 60_000; +// Relative timestamps ("2 minutes ago") are refreshed this often. +const TICK = 30_000; +// How long a failed edit/delete notice stays up. +const ERROR_TTL = 6_000; +// How close to the bottom still counts as "at the bottom" (px). +const BOTTOM_SLACK = 80; // How long the composer stays "typing" after the last keystroke, and how often // we refresh that signal while typing continuously. The refresh interval must // stay below the server's TYPING_TTL so a steady typist never flickers out. const TYPING_STOP_DELAY = 3_500; const TYPING_REFRESH = 3_000; -interface ReplyTarget { - id: string; - author: string; - body: string; +type AuthFetch = (input: RequestInfo, init?: RequestInit) => Promise; + +async function fetchChat( + authFetch: AuthFetch, + query: string, +): Promise { + const res = await authFetch(`${API_URL}/api/chat?${query}`); + const json = await res.json(); + if (!json.success) throw new Error("chat fetch failed"); + return (json.data as RawChatMessage[]).map(deserializeChatMessage); } -interface ChatMessageItemProps { - message: ChatMessage; - currentUser: PublicUser | null; - canModerate: boolean; - flashed: boolean; - onEdit: (id: string, body: string) => void; - onDelete: (id: string) => void; - onReply: (message: ChatMessage) => void; - onJumpTo: (id: string) => void; +// Stream order, matching the server's: by time, then id to break ties. +function compareMessages(a: ChatMessage, b: ChatMessage): number { + return a.createdAt.getTime() - b.createdAt.getTime() || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); } -function ChatMessageItem( - { - message, - currentUser, - canModerate, - flashed, - onEdit, - onDelete, - onReply, - onJumpTo, - }: ChatMessageItemProps, -) { - const [editing, setEditing] = useState(false); - const bodyRef = useHeightTransition(editing); - const [editDraft, setEditDraft] = useState(message.body); - const [confirmDelete, setConfirmDelete] = useState(false); - const editorRef = useRef(null); +function snippet(body: string): string { + return markdownToPlainText(body, t`image`); +} - // 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; +// The draft and reply target outlive the chat — closing it, or reloading the +// tab, keeps a half-written message. +interface StoredDraft { + draft: string; + replyTarget: ChatReplyTarget | null; +} - const openEdit = () => { - setEditDraft(message.body); - setEditing(true); - setTimeout(() => editorRef.current?.focus(), 0); - }; +function draftKey(userId: string): string { + return `chat-draft:${userId}`; +} - const saveEdit = () => { - const body = editDraft.trim(); - if (!body) return; - onEdit(message.id, body); - setEditing(false); - }; +function readDraft(userId: string | undefined): StoredDraft { + if (userId) { + try { + const raw = sessionStorage.getItem(draftKey(userId)); + const parsed = raw ? JSON.parse(raw) : null; + if (parsed && typeof parsed.draft === "string") { + return { draft: parsed.draft, replyTarget: parsed.replyTarget ?? null }; + } + } catch { + // Blocked storage or a mangled entry: start empty. + } + } + return { draft: "", replyTarget: null }; +} - return ( -
- -
- {message.replyToId && ( - // Subtle one-line reference to the replied-to message; clicking jumps - // to it. Falls back to a muted note when the target was deleted. - - )} -
- - {message.authorUsername} - - - - - {message.updatedAt && ( - - - edited {relativeTime(message.updatedAt)} - - - )} - {!editing && ( - - - {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)} - /> - )} -
-
- ); +function writeDraft(userId: string | undefined, value: StoredDraft): void { + if (!userId) return; + try { + if (value.draft || value.replyTarget) { + sessionStorage.setItem(draftKey(userId), JSON.stringify(value)); + } else { + sessionStorage.removeItem(draftKey(userId)); + } + } catch { + // The draft just won't survive a reload. + } } function isSameDay(a: Date, b: Date): boolean { @@ -273,89 +142,527 @@ function dayLabel(date: Date): string { }); } +// Hours and minutes only, for the narrow gutter beside a grouped message — the +// day period ("PM") is dropped to fit; its tooltip has the full timestamp. +function clockTime(date: Date): string { + return new Intl.DateTimeFormat(i18n.locale, { + hour: "numeric", + minute: "2-digit", + }).formatToParts(date) + .filter((part) => part.type !== "dayPeriod") + .map((part) => part.value) + .join("") + .trim(); +} + +interface ChatEditBoxProps { + initial: string; + onSave: (body: string) => void; + onCancel: () => void; +} + +function ChatEditBox({ initial, onSave, onCancel }: ChatEditBoxProps) { + const [draft, setDraft] = useState(initial); + const editorRef = useRef(null); + const boxRef = useRef(null); + + // Focus without letting the browser scroll the list mid-transition (the + // message box is still easing to its new height); once it has settled, bring + // the whole edit box, Save included, into view. + useEffect(() => { + editorRef.current?.focus({ atEnd: true, preventScroll: true }); + const box = boxRef.current; + if (!box) return; + const settle = parseFloat( + getComputedStyle(box).getPropertyValue("--dur-reveal"), + ) * 1000 || 250; + const id = setTimeout( + () => box.scrollIntoView({ block: "nearest", behavior: "smooth" }), + settle + 20, + ); + return () => clearTimeout(id); + }, []); + + const save = () => { + const body = draft.trim(); + if (!body) return; + onSave(body); + }; + + return ( +
+ { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + save(); + } else if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + /> +
+ + +
+
+ ); +} + +interface ReplyRefProps { + replyToId: string; + author?: string; + body?: string; + onJumpTo: (id: string) => void; +} + +// Subtle one-line reference to the replied-to message; clicking jumps to it. +// Falls back to a muted note when the target was deleted. +function ReplyRef({ replyToId, author, body, onJumpTo }: ReplyRefProps) { + return ( + + ); +} + +interface ChatMessageItemProps { + message: ChatMessage; + /** Continues the previous message's run: no avatar or author line. */ + grouped: boolean; + mentionsMe: boolean; + /** The replied-to message has been deleted since this one loaded. */ + replyDeleted: boolean; + canEdit: boolean; + canDelete: boolean; + flashed: boolean; + editing: boolean; + onStartEdit: (id: string) => void; + onEndEdit: () => void; + onEdit: (id: string, body: string) => void; + onDelete: (id: string) => void; + onReply: (message: ChatMessage) => void; + onJumpTo: (id: string) => void; +} + +function ChatMessageItem( + { + message, + grouped, + mentionsMe, + replyDeleted, + canEdit, + canDelete, + flashed, + editing, + onStartEdit, + onEndEdit, + onEdit, + onDelete, + onReply, + onJumpTo, + }: ChatMessageItemProps, +) { + const bodyRef = useHeightTransition(editing); + const [confirmDelete, setConfirmDelete] = useState(false); + + const actions = !editing && ( + + + {canEdit && ( + + )} + {canDelete && ( + + )} + + ); + + const edited = message.updatedAt && ( + + + {grouped + ? edited + : edited {relativeTime(message.updatedAt)}} + + + ); + + return ( +
+ {grouped + ? ( + // The avatar column holds the time instead, shown on hover. + + + + + + ) + : ( + + )} +
+ {message.replyToId && ( + + )} + {!grouped && ( +
+ + {message.authorUsername} + + + + + {edited} + {actions} +
+ )} + + {editing + ? ( + { + if (body !== message.body) onEdit(message.id, body); + onEndEdit(); + }} + onCancel={onEndEdit} + /> + ) + : {message.body}} + + {grouped && !editing && edited} +
+ {grouped && actions} + + {confirmDelete && ( + { + setConfirmDelete(false); + onDelete(message.id); + }} + onCancel={() => setConfirmDelete(false)} + /> + )} +
+ ); +} + +interface PendingItemProps { + pending: PendingChatMessage; + grouped: boolean; + user: PublicUser; + onRetry: (nonce: string) => void; + onDiscard: (nonce: string) => void; + onRestore: (pending: PendingChatMessage) => void; + onJumpTo: (id: string) => void; +} + +// A message on its way: dimmed while sending; if it fails, it says why and +// offers to try again, take it back into the composer, or drop it. +function PendingItem( + { pending, grouped, user, onRetry, onDiscard, onRestore, onJumpTo }: + PendingItemProps, +) { + const failed = pending.status === "failed"; + return ( +
+ {grouped ? : ( + + )} +
+ {pending.replyTo && ( + + )} + {!grouped && ( +
+ {user.username} +
+ )} + {pending.body} +
+ {failed + ? ( + <> + {pending.error} + + + + + ) + : Sending…} +
+
+
+ ); +} + +type Row = + | { kind: "sep"; key: string; label: string } + | { kind: "new"; key: string } + | { kind: "msg"; key: string; message: ChatMessage; grouped: boolean } + | { + kind: "pending"; + key: string; + pending: PendingChatMessage; + grouped: boolean; + }; + export function ChatModal({ onClose }: { onClose: () => void }) { const { + wsStatus, chatMessages, deletedChatIds, + pendingChatMessages, + chatError, + chatReadAt, typingUsers, sendChatMessage, + retryChatMessage, + discardChatMessage, editChatMessage, deleteChatMessage, - clearUnreadChat, + dismissChatError, + markChatRead, + reconcileChat, setChatTyping, setChatOpen, connectionEpoch, } = useWS(); const { authFetch, user } = useAuth(); const canModerate = can(user, "chat:moderate"); + const userId = user?.id; const [history, setHistory] = useState([]); const [hasMore, setHasMore] = useState(false); + const [loadState, setLoadState] = useState<"loading" | "ready" | "error">( + "loading", + ); + const [loadAttempt, setLoadAttempt] = useState(0); const [loadingOlder, setLoadingOlder] = useState(false); - const [draft, setDraft] = useState(""); - const [replyTarget, setReplyTarget] = useState(null); + const [stored] = useState(() => readDraft(userId)); + const [draft, setDraft] = useState(stored.draft); + const [replyTarget, setReplyTarget] = useState( + stored.replyTarget, + ); + const [editingId, setEditingId] = useState(null); const [flashId, setFlashId] = useState(null); + // False until the first placement decides: opening onto the "new" divider + // must not count as having read down to the bottom. + const [atBottom, setAtBottom] = useState(false); 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); - // Set once the first history load has scrolled into place, so the layout - // effect can force the initial jump to the bottom even before the user has - // interacted. + // Set once the first history load has been positioned, so the layout effect + // does the initial placement exactly once. const didInitialScrollRef = useRef(false); + // Where the view was before older messages were prepended, so the layout + // effect can keep the same messages under the reader's eye. + const prependAnchorRef = useRef<{ height: number; top: number } | null>( + null, + ); // Typing-signal bookkeeping: timer that sends "stopped" after a pause, and // the timestamp of the last "typing" refresh we sent. const typingStopTimerRef = useRef | null>(null); const lastTypingSentRef = useRef(0); const flashTimerRef = useRef | null>(null); + // Relative timestamps and "Today"/"Yesterday" go stale while the chat sits + // open; a periodic re-render keeps them honest. + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((n) => n + 1), TICK); + return () => clearInterval(id); + }, []); + + useEffect(() => { + writeDraft(userId, { draft, replyTarget }); + }, [userId, draft, replyTarget]); + // 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. + // (live buffer wins so edits show), sort into stream order. 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()); + .sort(compareMessages); }, [history, chatMessages, deletedChatIds]); + const messagesRef = useRef(messages); + useLayoutEffect(() => { + messagesRef.current = messages; + }); // Others (not me) currently composing — drives the typing indicator. const typingOthers = useMemo( - () => typingUsers.filter((u) => u.userId !== user?.id), - [typingUsers, user?.id], + () => typingUsers.filter((u) => u.userId !== userId), + [typingUsers, userId], ); - // Initial history load. + // Initial history load (and its retry). 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); + const items = await fetchChat(authFetch, `limit=${PAGE_SIZE}`); + if (cancelled) return; setHistory(items); setHasMore(items.length === PAGE_SIZE); + setLoadState("ready"); } catch { - // Live buffer still renders; the user can retry by reopening. + if (!cancelled) setLoadState("error"); } })(); return () => { cancelled = true; }; - }, [authFetch]); + }, [authFetch, loadAttempt]); - // Messages sent while the socket was down never reached the live buffer: - // after a reconnect, fetch the latest page again and fold it into history - // (the merge above dedupes against what's already shown). + const retryLoad = useCallback(() => { + setLoadState("loading"); + setLoadAttempt((n) => n + 1); + }, []); + + // Whatever happened while the socket was down — new messages, edits, + // deletions — never reached the live buffer. After a reconnect, re-read the + // stream from the oldest loaded message onward and take it as the truth for + // that stretch; if too much happened for that, start over from the latest. const seenEpochRef = useRef(connectionEpoch); useEffect(() => { if (connectionEpoch === seenEpochRef.current) return; @@ -363,11 +670,43 @@ export function ChatModal({ onClose }: { onClose: () => void }) { 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((prev) => [...prev, ...items]); + const from = messagesRef.current[0]?.createdAt ?? new Date(0); + const collected: ChatMessage[] = []; + let cursor = `after=${encodeURIComponent(from.toISOString())}`; + let complete = false; + for (let page = 0; page < RESYNC_MAX_PAGES; page++) { + const items = await fetchChat( + authFetch, + `limit=${RESYNC_PAGE}&${cursor}`, + ); + if (cancelled) return; + collected.push(...items); + if (items.length < RESYNC_PAGE) { + complete = true; + break; + } + const last = items[items.length - 1]; + cursor = `after=${ + encodeURIComponent(last.createdAt.toISOString()) + }&afterId=${encodeURIComponent(last.id)}`; + } + if (complete) { + setHistory((prev) => [ + ...prev.filter((m) => m.createdAt < from), + ...collected, + ]); + reconcileChat(collected, from, collected.at(-1)?.createdAt ?? from); + return; + } + const latest = await fetchChat(authFetch, `limit=${PAGE_SIZE}`); + if (cancelled) return; + setHistory(latest); + setHasMore(latest.length === PAGE_SIZE); + reconcileChat( + latest, + new Date(0), + latest.at(-1)?.createdAt ?? new Date(0), + ); } catch { // The next reconnect or reopen will try again. } @@ -375,9 +714,9 @@ export function ChatModal({ onClose }: { onClose: () => void }) { return () => { cancelled = true; }; - }, [authFetch, connectionEpoch]); + }, [authFetch, connectionEpoch, reconcileChat]); - // Whether the tab is on screen: a chatbox left open in a background tab isn't + // Whether the tab is on screen: a chat left open in a background tab isn't // being read, so its messages should stay unread until the user comes back. const [tabVisible, setTabVisible] = useState(() => !document.hidden); useEffect(() => { @@ -386,11 +725,6 @@ export function ChatModal({ onClose }: { onClose: () => void }) { return () => document.removeEventListener("visibilitychange", onVisibility); }, []); - // Clear the unread badge on open and as new messages arrive while read. - useEffect(() => { - if (tabVisible) clearUnreadChat(); - }, [chatMessages, clearUnreadChat, tabVisible]); - // Tell the server the chatbox is open so it skips redundant mention // notifications while the user is reading; clear the flag on close. useEffect(() => { @@ -407,18 +741,80 @@ export function ChatModal({ onClose }: { onClose: () => void }) { }; }, [setChatTyping]); - // Auto-scroll to the bottom when pinned (initial load + new messages + the - // typing row appearing). A layout effect runs before paint so there's no - // visible flash, and a follow-up frame catches late height changes (e.g. an - // avatar/image that resizes after first paint). + // A failed edit/delete notice clears itself after a while. + useEffect(() => { + if (!chatError) return; + const id = setTimeout(dismissChatError, ERROR_TTL); + return () => clearTimeout(id); + }, [chatError, dismissChatError]); + + // The read marker as it stood when the chat opened: the "new" divider goes + // before the first message from someone else after it, and stays put while + // the chat is open even as the marker moves on. + const [openedReadAt, setOpenedReadAt] = useState(chatReadAt); + if (!openedReadAt && chatReadAt) setOpenedReadAt(chatReadAt); + const [dividerId, setDividerId] = useState( + undefined, + ); + if (dividerId === undefined && loadState === "ready" && openedReadAt) { + const first = messages.find((m) => + m.userId !== userId && m.createdAt > openedReadAt + ); + setDividerId(first?.id ?? null); + } + + // What the reader has scrolled past at the bottom, for the "new messages" + // pill. Starts at the read marker so unread messages below the divider count. + const [seenUpTo, setSeenUpTo] = useState(0); + if (seenUpTo === 0 && openedReadAt) setSeenUpTo(openedReadAt.getTime()); + const newest = messages.at(-1); + if (atBottom && newest && newest.createdAt.getTime() > seenUpTo) { + setSeenUpTo(newest.createdAt.getTime()); + } + // At the bottom of a visible chat, everything is read. + useEffect(() => { + if (atBottom && newest && tabVisible) markChatRead(newest.createdAt); + }, [atBottom, newest, tabVisible, markChatRead]); + const unseenCount = useMemo( + () => + messages.filter((m) => + m.userId !== userId && m.createdAt.getTime() > seenUpTo + ).length, + [messages, userId, seenUpTo], + ); + + // Keep the view where it belongs as content changes: the first placement + // lands on the "new" divider when there's more unread than fits (else the + // bottom), prepending older history keeps the same messages in view, and + // otherwise new content follows the bottom while pinned there. A layout + // effect runs before paint so there's no visible jump, and a follow-up frame + // catches late height changes (e.g. an image that loads after first paint). useLayoutEffect(() => { const el = listRef.current; if (!el) return; - // Force the very first positioning to the bottom even though no scrolling - // has happened yet, then honour the pin afterwards. + const anchor = prependAnchorRef.current; + if (anchor) { + prependAnchorRef.current = null; + el.scrollTop = el.scrollHeight - anchor.height + anchor.top; + return; + } if (!didInitialScrollRef.current) { - if (messages.length === 0) return; + if (loadState !== "ready" || (dividerId === undefined && openedReadAt)) { + return; + } didInitialScrollRef.current = true; + const divider = el.querySelector(".chat-new-sep"); + if (divider) { + const target = divider.offsetTop - el.offsetTop - 8; + if (target < el.scrollHeight - el.clientHeight) { + el.scrollTop = target; + stickToBottomRef.current = false; + setAtBottom(false); + return; + } + } + // A list too short to scroll never fires the event that would say so. + setAtBottom(true); } else if (!stickToBottomRef.current) { return; } @@ -427,42 +823,75 @@ export function ChatModal({ onClose }: { onClose: () => void }) { const node = listRef.current; if (node && stickToBottomRef.current) node.scrollTop = node.scrollHeight; }); - // `replyTarget` is a dependency because the reply bar inserts itself - // between the list and the composer, shrinking the list: when pinned we - // must re-anchor to the bottom so the last message isn't hidden behind it. - }, [messages, typingOthers.length, replyTarget]); + // `replyTarget` and `chatError` are dependencies because their bars insert + // themselves between the list and the composer, shrinking the list: when + // pinned we must re-anchor so the last message isn't hidden behind them. + }, [ + messages, + pendingChatMessages, + typingOthers.length, + replyTarget, + chatError, + loadState, + dividerId, + openedReadAt, + ]); + + // Follow the bottom through any change in the content's height while pinned + // there — not just new messages, but a message growing back after an edit + // (frame by frame, through its height transition), an image loading, an + // embed unfolding. Without this the list stays put and the end slides out + // of view below. + const contentRef = useRef(null); + useEffect(() => { + const content = contentRef.current; + if (!content) return; + const ro = new ResizeObserver(() => { + const el = listRef.current; + if (el && stickToBottomRef.current) el.scrollTop = el.scrollHeight; + }); + ro.observe(content); + return () => ro.disconnect(); + }, []); const handleScroll = useCallback(() => { const el = listRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - stickToBottomRef.current = distanceFromBottom < 80; + const bottom = distanceFromBottom < BOTTOM_SLACK; + stickToBottomRef.current = bottom; + setAtBottom(bottom); + }, []); + + const scrollToBottom = useCallback(() => { + const el = listRef.current; + if (!el) return; + stickToBottomRef.current = true; + el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); }, []); 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=${ + const older = await fetchChat( + authFetch, + `limit=${PAGE_SIZE}&before=${ encodeURIComponent(oldest.createdAt.toISOString()) - }`, + }&beforeId=${encodeURIComponent(oldest.id)}`, ); - 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) { + const el = listRef.current; + if (el) { + prependAnchorRef.current = { + height: el.scrollHeight, + top: el.scrollTop, + }; + } 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 @@ -529,37 +958,103 @@ export function ChatModal({ onClose }: { onClose: () => void }) { const handleSend = useCallback(() => { const body = draft.trim(); if (!body) return; - sendChatMessage(body, replyTarget?.id); + sendChatMessage(body, replyTarget ?? undefined); setDraft(""); setReplyTarget(null); stopTyping(); stickToBottomRef.current = true; }, [draft, replyTarget, sendChatMessage, stopTyping]); - // Build the rendered stream, inserting a day separator whenever the calendar - // day changes between consecutive messages. - const rows = useMemo(() => { - const out: Array< - { kind: "sep"; key: string; label: string } | { - kind: "msg"; - key: string; - message: ChatMessage; + // Take a failed message back into the composer to rework it. An unfinished + // draft already there is kept, with the failed text after it. + const restorePending = useCallback((pending: PendingChatMessage) => { + setDraft((prev) => + prev.trim() ? `${prev}\n\n${pending.body}` : pending.body + ); + if (pending.replyTo) setReplyTarget(pending.replyTo); + discardChatMessage(pending.nonce); + composerRef.current?.focus(); + }, [discardChatMessage]); + + // An edit swaps a message's text for a box of a different height, and the + // edit box scrolls itself into view: whether the list was following the + // bottom when the edit began is remembered, so ending it returns there. + const editPinnedRef = useRef(false); + const startEdit = useCallback((id: string) => { + editPinnedRef.current = stickToBottomRef.current; + setEditingId(id); + }, []); + + const endEdit = useCallback(() => { + if (editPinnedRef.current) stickToBottomRef.current = true; + setEditingId(null); + composerRef.current?.focus({ preventScroll: true }); + }, []); + + const handleComposerKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } else if (e.key === "Escape" && replyTarget) { + // Cancel the reply first; a second Escape closes the modal. + e.preventDefault(); + setReplyTarget(null); + } else if (e.key === "ArrowUp" && !draft) { + // Up in an empty composer edits your last message, as in most chats. + const mine = messages.findLast((m) => m.userId === userId); + if (mine) { + e.preventDefault(); + startEdit(mine.id); + jumpToMessage(mine.id); } - > = []; - let prev: Date | null = null; + } + }; + + const deletedIds = deletedChatIds; + const username = user?.username; + + // Build the rendered stream: day separators whenever the calendar day + // changes, the "new" divider, and consecutive messages from one author run + // together under a single header. Pending sends go last. + const rows = useMemo(() => { + const out: Row[] = []; + let prev: { userId: string; at: Date } | null = null; + const continues = (authorId: string, at: Date, isReply: boolean) => + !!prev && !isReply && prev.userId === authorId && + isSameDay(prev.at, at) && + at.getTime() - prev.at.getTime() < GROUP_WINDOW; for (const m of messages) { - if (!prev || !isSameDay(prev, m.createdAt)) { + if (!prev || !isSameDay(prev.at, m.createdAt)) { out.push({ kind: "sep", key: `sep-${m.id}`, label: dayLabel(m.createdAt), }); + prev = null; } - out.push({ kind: "msg", key: m.id, message: m }); - prev = m.createdAt; + if (m.id === dividerId) { + out.push({ kind: "new", key: "new" }); + prev = null; + } + out.push({ + kind: "msg", + key: m.id, + message: m, + grouped: continues(m.userId, m.createdAt, !!m.replyToId), + }); + prev = { userId: m.userId, at: m.createdAt }; + } + for (const p of pendingChatMessages) { + out.push({ + kind: "pending", + key: p.nonce, + pending: p, + grouped: !!userId && continues(userId, p.createdAt, !!p.replyTo), + }); + if (userId) prev = { userId, at: p.createdAt }; } return out; - }, [messages]); + }, [messages, pendingChatMessages, dividerId, userId]); const typingNames = typingOthers.map((u) => u.username); const typingLabel = typingNames.length === 1 @@ -568,53 +1063,135 @@ export function ChatModal({ onClose }: { onClose: () => void }) { ? t`${typingNames[0]} and ${typingNames[1]} are typing…` : t`Several people are typing…`; + const renderList = () => { + if (loadState === "loading" && messages.length === 0) { + return ( +

+ Loading messages… +

+ ); + } + if (loadState === "error" && messages.length === 0) { + return ( +
+

+ Couldn't load the chat. +

+ +
+ ); + } + if (rows.length === 0) { + return ( +

+ No messages yet. Say hello! +

+ ); + } + return rows.map((row) => { + switch (row.kind) { + case "sep": + return ( +
+ {row.label} +
+ ); + case "new": + return ( +
+ + New + +
+ ); + case "msg": { + const m = row.message; + const isOwn = m.userId === userId; + return ( + + ); + } + case "pending": + return user && ( + + ); + } + }); + }; + return (
-
- {hasMore && ( -
- + {wsStatus === "disconnected" && ( +
+ Disconnected — reconnecting… +
+ )} + +
+
+
+ {hasMore && ( +
+ +
+ )} + {renderList()}
+
+ {!atBottom && unseenCount > 0 && ( + )} - {messages.length === 0 - ? ( -

- No messages yet. Say hello! -

- ) - : ( - rows.map((row) => - row.kind === "sep" - ? ( -
- {row.label} -
- ) - : ( - - ) - ) - )}
{typingOthers.length > 0 && ( @@ -641,6 +1218,21 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
)} + {chatError && ( +
+ {chatError.message} + +
+ )} + {replyTarget && (
↩ @@ -649,7 +1241,9 @@ export function ChatModal({ onClose }: { onClose: () => void }) { {replyTarget.author} - {replyTarget.body} + + {snippet(replyTarget.body)} +