v3: the chat stops dropping things and holds up to long messages — a message sent while the socket was down vanished with the draft already cleared, and a rejected one fared no better since the client ignored any error that wasn't about a vote or a like. Sends now carry a nonce and sit dimmed in the list until their echo retires them; the server's refusal, an offline socket or ten seconds of silence turns one into a failed row with retry, edit and discard, and a failed row that turns up in the history after a reconnect is retired as having landed after all. Failed edits and deletes show in a bar above the composer. The live stream only goes to signed-in sockets, matching the history behind auth; sends, edits and deletes share a per-user token bucket (typing and read markers get their own, and are just dropped past it); and moderation re-reads the role from the database on every check rather than trusting the one a week-old socket captured. Unread now lives on the server as users.chat_read_at (migration 0014, existing users start caught up), so the count survives a reload and every tab and device clears together; opening onto unread history lands on a "new" divider rather than the bottom, and marks it read only once you've scrolled down to it. After a reconnect the chat re-reads everything from its oldest loaded message and takes that as the truth, edits and deletions included, or reloads the latest page if too much happened. History pages on (created_at, id) so a shared millisecond can't hide a message at a boundary. In the modal: a run of messages from one author shares a header with the time in the gutter on hover, mentions and replies to you get an accent edge, a pill counts what arrived while you were scrolled up, the draft survives closing and reloading, relative times tick, reply previews are flattened from markdown, owners can delete their own messages, ↑ in an empty composer edits your last one, and there's a proper loading and retry state instead of a flash of "No messages yet". Long messages: the composer and edit box grow only so far and then scroll, instead of squeezing the list away; the counter shows only near the limit and sits above the box, since .comment-reply-textarea's padding shorthand had quietly cancelled the room kept for it inside; TextEditor sizes itself before paint, so opening an edit animates to the right height instead of dipping and snapping; and the list follows the bottom through any change in its content's height while pinned there, so ending an edit returns you to the end. A mention notification opens the chat over the page you're on rather than the home page.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
28
api/db/migrations/0014_chat_read_state.ts
Normal file
28
api/db/migrations/0014_chat_read_state.ts
Normal 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);`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
179
api/routes/ws.ts
179
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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
213
src/App.css
213
src/App.css
@@ -4169,10 +4169,19 @@ body.has-player .chat-fab {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-messages-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
/* Reserve a gutter for the scrollbar so it never sits on top of the
|
||||
messages — without this, overlay scrollbars overlap the content. The extra
|
||||
padding keeps the text clear of the scrollbar rather than right against it. */
|
||||
@@ -4183,10 +4192,19 @@ body.has-player .chat-fab {
|
||||
scrollbar-color: transparent transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding-right: 0.6rem;
|
||||
}
|
||||
|
||||
/* One box around everything the list scrolls, so its height can be watched as
|
||||
a whole (see the ResizeObserver in ChatModal). Grows to fill the list, which
|
||||
keeps the empty state centered. */
|
||||
.chat-messages-content {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.chat-messages:hover,
|
||||
.chat-messages:focus-within {
|
||||
scrollbar-color: color-mix(in srgb, var(--color-text-muted) 45%, transparent)
|
||||
@@ -4215,6 +4233,14 @@ body.has-player .chat-fab {
|
||||
margin: auto 0;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.chat-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-load-older {
|
||||
@@ -4251,6 +4277,7 @@ body.has-player .chat-fab {
|
||||
|
||||
/* Brief highlight when a message is jumped to via a reply reference. */
|
||||
.chat-message {
|
||||
position: relative;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background-color 0.4s ease;
|
||||
}
|
||||
@@ -4259,6 +4286,100 @@ body.has-player .chat-fab {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
/* A run of messages from one author: the follow-ups drop the avatar and the
|
||||
author line and sit close under the first, pulling back most of the list's
|
||||
gap. The avatar column shows the time instead, on hover. */
|
||||
.chat-message--grouped {
|
||||
margin-top: -0.6rem;
|
||||
}
|
||||
|
||||
.chat-message-gutter {
|
||||
flex: 0 0 32px;
|
||||
/* The time may be wider than the column; let it spill evenly both sides
|
||||
rather than widen the column and push the text out of line. */
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* Drawn, but no height of its own: the tooltip wrapper around the time is
|
||||
taller than a line of text and would pad every follow-up message. */
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
padding-top: 0.2rem;
|
||||
font-size: var(--text-2xs);
|
||||
line-height: 1.2;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0;
|
||||
transition: opacity var(--dur-base);
|
||||
}
|
||||
|
||||
.chat-message:hover .chat-message-gutter,
|
||||
.chat-message:focus-within .chat-message-gutter {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* With no meta row to trail, a grouped message's actions float at its top
|
||||
right, over a patch of surface so they stay legible over the text. */
|
||||
.chat-message-actions--float {
|
||||
position: absolute;
|
||||
top: -0.35rem;
|
||||
right: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
box-shadow: 0 0 0 1px var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* Messages that mention you (or reply to you) carry an accent edge. */
|
||||
.chat-message--mention {
|
||||
background-color: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||
box-shadow: inset 2px 0 0 var(--color-accent);
|
||||
padding: 0.25rem 0.35rem 0.25rem 0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
}
|
||||
|
||||
.chat-message--grouped.chat-message--mention {
|
||||
margin-top: -0.85rem;
|
||||
}
|
||||
|
||||
/* A message on its way: dimmed until the server echoes it back. */
|
||||
.chat-message--pending .chat-message-text {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.chat-message--failed .chat-message-text {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.chat-pending-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.2rem 0.6rem;
|
||||
margin-top: 0.15rem;
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-pending-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.chat-text-btn {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-text-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Subtle day separator: a hairline rule with a centered, muted label. */
|
||||
.chat-day-sep {
|
||||
display: flex;
|
||||
@@ -4283,6 +4404,70 @@ body.has-player .chat-fab {
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Where the unread messages start — the day separator's rule, in accent. */
|
||||
.chat-new-sep {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.chat-new-sep::before,
|
||||
.chat-new-sep::after {
|
||||
background: currentColor;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Scrolled up while messages arrive: a pill over the list's foot says how
|
||||
many, and takes you down to them. */
|
||||
.chat-new-pill {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0.5rem;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.3rem 0.8rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-2);
|
||||
animation: chat-msg-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.chat-new-pill:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* Connection trouble and failed edits/deletes, in a slim bar. */
|
||||
.chat-status,
|
||||
.chat-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-error {
|
||||
border-left: 2px solid var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.chat-error-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-message-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -4402,22 +4587,28 @@ body.has-player .chat-fab {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Reserve room on the input's bottom-right for the char counter. `display:
|
||||
block` removes the inline-block baseline descender gap below the textarea —
|
||||
that phantom space was making the wrapper taller than the textarea, so the
|
||||
bottom-aligned Send button landed a few px low. */
|
||||
.chat-input {
|
||||
padding-right: 3.5rem;
|
||||
/* `display: block` removes the inline-block baseline descender gap below the
|
||||
textarea — that phantom space was making the wrapper taller than the
|
||||
textarea, so the bottom-aligned Send button landed a few px low.
|
||||
A long message grows the box only so far, then scrolls inside it, so the
|
||||
composer can't squeeze the message list away and an edit box can't outgrow
|
||||
the chat. (Qualified to outrank .comment-reply-textarea's overflow.) */
|
||||
.comment-reply-textarea.chat-input {
|
||||
display: block;
|
||||
max-height: min(35vh, 15rem);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* Pin the counter inside the textarea instead of letting it add a row below —
|
||||
that extra height is what knocked the Send button out of alignment. */
|
||||
/* The counter (only shown near the limit) sits just above the input's right
|
||||
edge, in the gap over the composer: inside the textarea, the text ran under
|
||||
it, and in a row below it would knock the Send button out of alignment. */
|
||||
.chat-composer .text-editor-count {
|
||||
position: absolute;
|
||||
right: 0.55rem;
|
||||
bottom: 0.4rem;
|
||||
right: 0.25rem;
|
||||
bottom: calc(100% + 1px);
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -19,7 +20,9 @@ import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
export interface TextEditorHandle {
|
||||
focus(): void;
|
||||
/** `atEnd` puts the caret after the last character (and scrolls the text
|
||||
* there); `preventScroll` leaves the page and its scrollers where they are. */
|
||||
focus(options?: { atEnd?: boolean; preventScroll?: boolean }): void;
|
||||
}
|
||||
|
||||
interface TextEditorProps {
|
||||
@@ -34,6 +37,9 @@ interface TextEditorProps {
|
||||
onSubmit?: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
maxLength?: number;
|
||||
/** Hide the character counter until the text is at least this long, for
|
||||
* places where a standing "0 / 2000" is just noise. */
|
||||
countFrom?: number;
|
||||
}
|
||||
|
||||
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
@@ -50,6 +56,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
onSubmit,
|
||||
onKeyDown,
|
||||
maxLength,
|
||||
countFrom = 0,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -67,7 +74,15 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
}, [value]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
focus: (options) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus({ preventScroll: options?.preventScroll });
|
||||
if (options?.atEnd) {
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
},
|
||||
}), []);
|
||||
|
||||
const {
|
||||
@@ -110,7 +125,9 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
maxContentHeight: 220,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Before paint, so the first frame — and anything measuring the layout in
|
||||
// its own layout effect, like a height transition — sees the final size.
|
||||
useLayoutEffect(() => {
|
||||
if (!autoResize) return;
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
@@ -229,7 +246,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
id={id}
|
||||
className={className}
|
||||
/>
|
||||
{maxLength != null && (
|
||||
{maxLength != null && value.length >= countFrom && (
|
||||
<span
|
||||
className={`text-editor-count${
|
||||
charCountClass(value.length, maxLength)
|
||||
|
||||
@@ -40,6 +40,30 @@ export interface UserEvent {
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
/** The replied-to message, as shown in the reply bar and on a pending send. */
|
||||
export interface ChatReplyTarget {
|
||||
id: string;
|
||||
author: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** A message this client sent that the server hasn't echoed back yet — shown
|
||||
* in the list as "sending", or "failed" with a way to retry or discard it. */
|
||||
export interface PendingChatMessage {
|
||||
nonce: string;
|
||||
body: string;
|
||||
replyTo?: ChatReplyTarget;
|
||||
createdAt: Date;
|
||||
status: "sending" | "failed";
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A failed chat edit or delete, surfaced in the chat until dismissed. */
|
||||
export interface ChatError {
|
||||
id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WSContextValue {
|
||||
wsStatus: "connecting" | "connected" | "disconnected";
|
||||
wsErrorMessage: string | null;
|
||||
@@ -64,7 +88,14 @@ export interface WSContextValue {
|
||||
/** Ids of chat messages deleted live this session, so fetched history can
|
||||
* filter them out. */
|
||||
deletedChatIds: Set<string>;
|
||||
/** Messages from others since the read marker — kept by the server, so it
|
||||
* survives reloads and agrees across tabs and devices. */
|
||||
unreadChatCount: number;
|
||||
/** The server's read marker: created_at of the newest message the user has
|
||||
* seen. Null until the socket's welcome. */
|
||||
chatReadAt: Date | null;
|
||||
pendingChatMessages: PendingChatMessage[];
|
||||
chatError: ChatError | null;
|
||||
/** Users currently composing a chat message (excludes nobody — the chatbox
|
||||
* filters out the current user itself). Shaped like presence for avatars. */
|
||||
typingUsers: OnlineUser[];
|
||||
@@ -76,10 +107,18 @@ export interface WSContextValue {
|
||||
removeCommentLike: (commentId: string) => void;
|
||||
injectDump: (dump: Dump) => void;
|
||||
clearUnreadNotifications: () => void;
|
||||
sendChatMessage: (body: string, replyToId?: string) => void;
|
||||
sendChatMessage: (body: string, replyTo?: ChatReplyTarget) => void;
|
||||
retryChatMessage: (nonce: string) => void;
|
||||
discardChatMessage: (nonce: string) => void;
|
||||
editChatMessage: (id: string, body: string) => void;
|
||||
deleteChatMessage: (id: string) => void;
|
||||
clearUnreadChat: () => void;
|
||||
dismissChatError: () => void;
|
||||
/** Moves the read marker up to the message created at `at`. */
|
||||
markChatRead: (at: Date) => void;
|
||||
/** Folds an authoritative fetch of the stream between `from` and `to` into
|
||||
* the live buffer after a reconnect: buffered messages in that range which
|
||||
* the server no longer has were deleted while the socket was down. */
|
||||
reconcileChat: (messages: ChatMessage[], from: Date, to: Date) => void;
|
||||
/** Inform the server whether the user is currently composing a message, so it
|
||||
* can show/hide the "is typing" indicator to other clients. */
|
||||
setChatTyping: (typing: boolean) => void;
|
||||
@@ -110,6 +149,9 @@ export const WSContext = createContext<WSContextValue>({
|
||||
chatMessages: [],
|
||||
deletedChatIds: new Set(),
|
||||
unreadChatCount: 0,
|
||||
chatReadAt: null,
|
||||
pendingChatMessages: [],
|
||||
chatError: null,
|
||||
typingUsers: [],
|
||||
connectionEpoch: 0,
|
||||
castVote: () => {},
|
||||
@@ -119,9 +161,13 @@ export const WSContext = createContext<WSContextValue>({
|
||||
injectDump: () => {},
|
||||
clearUnreadNotifications: () => {},
|
||||
sendChatMessage: () => {},
|
||||
retryChatMessage: () => {},
|
||||
discardChatMessage: () => {},
|
||||
editChatMessage: () => {},
|
||||
deleteChatMessage: () => {},
|
||||
clearUnreadChat: () => {},
|
||||
dismissChatError: () => {},
|
||||
markChatRead: () => {},
|
||||
reconcileChat: () => {},
|
||||
setChatTyping: () => {},
|
||||
setChatOpen: () => {},
|
||||
});
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
type ChatError,
|
||||
type ChatReplyTarget,
|
||||
type CommentEvent,
|
||||
type LikeEvent,
|
||||
type PendingChatMessage,
|
||||
type PlaylistEvent,
|
||||
type UserEvent,
|
||||
type VoteEvent,
|
||||
@@ -41,6 +44,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { setTitleBadge } from "../hooks/useDocumentTitle.ts";
|
||||
import { playChime } from "../utils/sound.ts";
|
||||
import { mentionsUser } from "../utils/chatText.ts";
|
||||
|
||||
interface WSProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -56,17 +60,13 @@ const MAX_RECENT_DUMPS = 100;
|
||||
// Cap the live chat buffer so a long-lived session can't grow it without bound.
|
||||
// Older messages remain reachable via the /api/chat history endpoint.
|
||||
const MAX_CHAT_MESSAGES = 200;
|
||||
|
||||
// Mirrors MENTION_RE in api/services/notification-service.ts.
|
||||
const MENTION_RE = /(?<![[(\w])@(\w+)/g;
|
||||
|
||||
function mentionsUser(body: string, username: string): boolean {
|
||||
const target = username.toLowerCase();
|
||||
for (const m of body.matchAll(MENTION_RE)) {
|
||||
if (m[1].toLowerCase() === target) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// How long a sent chat message may go without its echo before it's shown as
|
||||
// failed, and how often the read marker may go out.
|
||||
const CHAT_SEND_TIMEOUT = 10_000;
|
||||
const CHAT_READ_THROTTLE = 1_000;
|
||||
// A failed send whose text turns up in the resynced history within this long
|
||||
// of being sent did reach the server — only its echo was lost.
|
||||
const CHAT_ECHO_WINDOW = 60_000;
|
||||
|
||||
interface PendingVote {
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
@@ -132,6 +132,11 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
|
||||
const [unreadChatCount, setUnreadChatCount] = useState(0);
|
||||
const [chatReadAt, setChatReadAt] = useState<Date | null>(null);
|
||||
const [pendingChatMessages, setPendingChatMessages] = useState<
|
||||
PendingChatMessage[]
|
||||
>([]);
|
||||
const [chatError, setChatError] = useState<ChatError | null>(null);
|
||||
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
|
||||
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
|
||||
const [avatarOverrides, setAvatarOverrides] = useState<
|
||||
@@ -170,6 +175,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setChatMessages([]);
|
||||
setDeletedChatIds(new Set());
|
||||
setUnreadChatCount(0);
|
||||
setChatReadAt(null);
|
||||
setPendingChatMessages([]);
|
||||
setChatError(null);
|
||||
setTypingUsers([]);
|
||||
setAvatarOverrides({});
|
||||
}
|
||||
@@ -205,6 +213,34 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
() => chatOpenRef.current && !document.hidden,
|
||||
[],
|
||||
);
|
||||
// Echo timeouts of in-flight chat sends, by nonce.
|
||||
const chatSendTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
const chatErrorSeqRef = useRef(0);
|
||||
// Read marker bookkeeping: the newest marker sent (or learned from the
|
||||
// server), and a trailing timer so a burst of arrivals sends one marker.
|
||||
const chatReadSentRef = useRef(0);
|
||||
const chatReadQueuedRef = useRef(0);
|
||||
const chatReadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const chatReadLastSendRef = useRef(0);
|
||||
|
||||
const failPendingChat = useCallback((nonce: string, error: string) => {
|
||||
const timer = chatSendTimersRef.current.get(nonce);
|
||||
if (timer) clearTimeout(timer);
|
||||
chatSendTimersRef.current.delete(nonce);
|
||||
setPendingChatMessages((prev) =>
|
||||
prev.map((p) => p.nonce === nonce ? { ...p, status: "failed", error } : p)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const retirePendingChat = useCallback((nonce: string) => {
|
||||
const timer = chatSendTimersRef.current.get(nonce);
|
||||
if (timer) clearTimeout(timer);
|
||||
chatSendTimersRef.current.delete(nonce);
|
||||
setPendingChatMessages((prev) => prev.filter((p) => p.nonce !== nonce));
|
||||
}, []);
|
||||
|
||||
// Tracks pending optimistic votes: dumpId → pending rollback handler
|
||||
const pendingRef = useRef<Map<string, PendingVote>>(
|
||||
new Map(),
|
||||
@@ -321,6 +357,15 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setMyVotes(new Set(msg.myVotes));
|
||||
setMyLikes(new Set(msg.myCommentLikes));
|
||||
setUnreadNotificationCount(msg.unreadNotificationCount);
|
||||
setUnreadChatCount(msg.unreadChatCount);
|
||||
if (msg.chatReadAt) {
|
||||
const readAt = new Date(msg.chatReadAt);
|
||||
setChatReadAt(readAt);
|
||||
chatReadSentRef.current = Math.max(
|
||||
chatReadSentRef.current,
|
||||
readAt.getTime(),
|
||||
);
|
||||
}
|
||||
// welcome provides authoritative server state — cancel any
|
||||
// in-flight revert timers, they are now superseded.
|
||||
clearAllPendingVotes();
|
||||
@@ -328,13 +373,37 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
// Reconnect (not the first welcome): signal pages to backfill the
|
||||
// dumps/comments they couldn't receive while disconnected.
|
||||
welcomeCount += 1;
|
||||
if (welcomeCount > 1) setConnectionEpoch((n) => n + 1);
|
||||
if (welcomeCount > 1) {
|
||||
setConnectionEpoch((n) => n + 1);
|
||||
// With the chatbox closed nothing will resync the live buffer,
|
||||
// and whatever was edited or deleted meanwhile would win over
|
||||
// the fresh history it fetches on open — so just drop it.
|
||||
if (!chatOpenRef.current) {
|
||||
setChatMessages([]);
|
||||
setDeletedChatIds(new Set());
|
||||
}
|
||||
}
|
||||
// A marker that was waiting on the socket goes out now.
|
||||
if (chatReadQueuedRef.current > chatReadSentRef.current) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "chat_read",
|
||||
at: new Date(chatReadQueuedRef.current).toISOString(),
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
chatReadSentRef.current = chatReadQueuedRef.current;
|
||||
}
|
||||
// Restore the chat-open flag on the fresh connection so mention
|
||||
// suppression keeps working across reconnects.
|
||||
if (isViewingChat()) {
|
||||
ws.send(
|
||||
JSON.stringify(
|
||||
{ type: "chat_focus", open: true } satisfies OutgoingWSMessage,
|
||||
{
|
||||
type: "chat_focus",
|
||||
open: true,
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -528,9 +597,11 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
if (prev.some((m) => m.id === message.id)) return prev;
|
||||
return [...prev, message].slice(-MAX_CHAT_MESSAGES);
|
||||
});
|
||||
// Count as unread unless it's the current user's own message.
|
||||
if (msg.nonce) retirePendingChat(msg.nonce);
|
||||
// Count as unread unless it's the current user's own message, or
|
||||
// they're looking at it (the chatbox then moves the read marker).
|
||||
if (message.userId !== userIdRef.current) {
|
||||
setUnreadChatCount((prev) => prev + 1);
|
||||
if (!isViewingChat()) setUnreadChatCount((prev) => prev + 1);
|
||||
const username = usernameRef.current;
|
||||
if (username && mentionsUser(message.body, username)) {
|
||||
// Someone reading the chat gets no notification for a mention
|
||||
@@ -567,11 +638,28 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setTypingUsers(msg.users);
|
||||
break;
|
||||
|
||||
case "chat_read_update": {
|
||||
const readAt = new Date(msg.readAt);
|
||||
chatReadSentRef.current = Math.max(
|
||||
chatReadSentRef.current,
|
||||
readAt.getTime(),
|
||||
);
|
||||
setChatReadAt(readAt);
|
||||
setUnreadChatCount(msg.unread);
|
||||
break;
|
||||
}
|
||||
|
||||
case "force_logout":
|
||||
onForceLogoutRef.current();
|
||||
break;
|
||||
|
||||
case "error": {
|
||||
if (msg.scope === "chat") {
|
||||
const message = msg.message ?? t`Something went wrong`;
|
||||
if (msg.nonce) failPendingChat(msg.nonce, message);
|
||||
else setChatError({ id: ++chatErrorSeqRef.current, message });
|
||||
break;
|
||||
}
|
||||
// When the server identifies the failed optimistic action, revert
|
||||
// its pending vote/like right away instead of waiting out the ACK
|
||||
// timeout. Without context, the per-action timer handles cleanup.
|
||||
@@ -601,6 +689,15 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
connectTimeout = null;
|
||||
}
|
||||
if (closed) return;
|
||||
// Sends still waiting on their echo won't get one on this socket.
|
||||
// They may have landed, though: the resync after reconnect retires
|
||||
// any whose text shows up in the history.
|
||||
for (const nonce of chatSendTimersRef.current.keys()) {
|
||||
failPendingChat(
|
||||
nonce,
|
||||
t`Connection lost — this message may not have been sent`,
|
||||
);
|
||||
}
|
||||
setWSStatus("disconnected");
|
||||
setWSErrorMessage(
|
||||
everConnected
|
||||
@@ -642,7 +739,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
clearPendingVote,
|
||||
clearAllPendingLikes,
|
||||
clearPendingLike,
|
||||
failPendingChat,
|
||||
isViewingChat,
|
||||
retirePendingChat,
|
||||
token,
|
||||
]);
|
||||
|
||||
@@ -798,21 +897,80 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
setUnreadNotificationCount(0);
|
||||
}, []);
|
||||
|
||||
const sendChatMessage = useCallback((body: string, replyToId?: string) => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "chat_send",
|
||||
body: trimmed,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
// Sends (or resends) a pending message: it shows as "sending" until its echo
|
||||
// arrives, or as failed if the socket is down, the server rejects it, or no
|
||||
// echo comes back in time.
|
||||
const transmitPendingChat = useCallback((pending: PendingChatMessage) => {
|
||||
const socket = socketRef.current;
|
||||
if (socket?.readyState !== WebSocket.OPEN) {
|
||||
failPendingChat(pending.nonce, t`You're offline — not sent`);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "chat_send",
|
||||
body: pending.body,
|
||||
nonce: pending.nonce,
|
||||
...(pending.replyTo ? { replyToId: pending.replyTo.id } : {}),
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
chatSendTimersRef.current.set(
|
||||
pending.nonce,
|
||||
setTimeout(
|
||||
() =>
|
||||
failPendingChat(
|
||||
pending.nonce,
|
||||
t`No reply from the server — this message may not have been sent`,
|
||||
),
|
||||
CHAT_SEND_TIMEOUT,
|
||||
),
|
||||
);
|
||||
}, [failPendingChat]);
|
||||
|
||||
const sendChatMessage = useCallback(
|
||||
(body: string, replyTo?: ChatReplyTarget) => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
const pending: PendingChatMessage = {
|
||||
nonce: crypto.randomUUID(),
|
||||
body: trimmed,
|
||||
replyTo,
|
||||
createdAt: new Date(),
|
||||
status: "sending",
|
||||
};
|
||||
setPendingChatMessages((prev) => [...prev, pending]);
|
||||
transmitPendingChat(pending);
|
||||
},
|
||||
[transmitPendingChat],
|
||||
);
|
||||
|
||||
const pendingChatRef = useRef(pendingChatMessages);
|
||||
useLayoutEffect(() => {
|
||||
pendingChatRef.current = pendingChatMessages;
|
||||
});
|
||||
|
||||
const retryChatMessage = useCallback((nonce: string) => {
|
||||
const pending = pendingChatRef.current.find((p) => p.nonce === nonce);
|
||||
if (!pending) return;
|
||||
// A fresh nonce: should the first attempt's echo still turn up, it mustn't
|
||||
// retire the retry.
|
||||
const retry: PendingChatMessage = {
|
||||
...pending,
|
||||
nonce: crypto.randomUUID(),
|
||||
status: "sending",
|
||||
error: undefined,
|
||||
};
|
||||
setPendingChatMessages((prev) =>
|
||||
prev.map((p) => (p.nonce === nonce ? retry : p))
|
||||
);
|
||||
transmitPendingChat(retry);
|
||||
}, [transmitPendingChat]);
|
||||
|
||||
const discardChatMessage = useCallback((nonce: string) => {
|
||||
retirePendingChat(nonce);
|
||||
}, [retirePendingChat]);
|
||||
|
||||
const editChatMessage = useCallback((id: string, body: string) => {
|
||||
const trimmed = body.trim();
|
||||
@@ -836,10 +994,71 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearUnreadChat = useCallback(() => {
|
||||
setUnreadChatCount(0);
|
||||
const dismissChatError = useCallback(() => setChatError(null), []);
|
||||
|
||||
const flushChatRead = useCallback(() => {
|
||||
chatReadTimerRef.current = null;
|
||||
const at = chatReadQueuedRef.current;
|
||||
if (at <= chatReadSentRef.current) return;
|
||||
const socket = socketRef.current;
|
||||
// Offline: stays queued, and the next welcome sends it.
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "chat_read",
|
||||
at: new Date(at).toISOString(),
|
||||
} satisfies OutgoingWSMessage,
|
||||
),
|
||||
);
|
||||
chatReadSentRef.current = at;
|
||||
chatReadLastSendRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
const markChatRead = useCallback((at: Date) => {
|
||||
const time = at.getTime();
|
||||
if (time <= Math.max(chatReadSentRef.current, chatReadQueuedRef.current)) {
|
||||
return;
|
||||
}
|
||||
chatReadQueuedRef.current = time;
|
||||
// The badge goes at once; the server's count follows with the marker.
|
||||
setUnreadChatCount(0);
|
||||
if (chatReadTimerRef.current) return;
|
||||
const wait = Math.max(
|
||||
0,
|
||||
CHAT_READ_THROTTLE - (Date.now() - chatReadLastSendRef.current),
|
||||
);
|
||||
chatReadTimerRef.current = setTimeout(flushChatRead, wait);
|
||||
}, [flushChatRead]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (chatReadTimerRef.current) clearTimeout(chatReadTimerRef.current);
|
||||
for (const timer of chatSendTimersRef.current.values()) clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const reconcileChat = useCallback(
|
||||
(messages: ChatMessage[], from: Date, to: Date) => {
|
||||
const ids = new Set(messages.map((m) => m.id));
|
||||
setChatMessages((prev) =>
|
||||
prev.filter((m) =>
|
||||
ids.has(m.id) || m.createdAt < from || m.createdAt > to
|
||||
)
|
||||
);
|
||||
// Failed sends that did land (only the echo was lost) are retired.
|
||||
const mine = messages.filter((m) => m.userId === userIdRef.current);
|
||||
for (const pending of pendingChatRef.current) {
|
||||
if (pending.status !== "failed") continue;
|
||||
const landed = mine.some((m) =>
|
||||
m.body === pending.body &&
|
||||
Math.abs(m.createdAt.getTime() - pending.createdAt.getTime()) <
|
||||
CHAT_ECHO_WINDOW
|
||||
);
|
||||
if (landed) retirePendingChat(pending.nonce);
|
||||
}
|
||||
},
|
||||
[retirePendingChat],
|
||||
);
|
||||
|
||||
const setChatTyping = useCallback((typing: boolean) => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
@@ -906,6 +1125,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
chatMessages,
|
||||
deletedChatIds,
|
||||
unreadChatCount,
|
||||
chatReadAt,
|
||||
pendingChatMessages,
|
||||
chatError,
|
||||
typingUsers,
|
||||
connectionEpoch,
|
||||
castVote,
|
||||
@@ -915,9 +1137,13 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
sendChatMessage,
|
||||
retryChatMessage,
|
||||
discardChatMessage,
|
||||
editChatMessage,
|
||||
deleteChatMessage,
|
||||
clearUnreadChat,
|
||||
dismissChatError,
|
||||
markChatRead,
|
||||
reconcileChat,
|
||||
setChatTyping,
|
||||
setChatOpen,
|
||||
}), [
|
||||
@@ -942,6 +1168,9 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
chatMessages,
|
||||
deletedChatIds,
|
||||
unreadChatCount,
|
||||
chatReadAt,
|
||||
pendingChatMessages,
|
||||
chatError,
|
||||
typingUsers,
|
||||
connectionEpoch,
|
||||
castVote,
|
||||
@@ -951,9 +1180,13 @@ export function WSProvider({ children }: WSProviderProps) {
|
||||
injectDump,
|
||||
clearUnreadNotifications,
|
||||
sendChatMessage,
|
||||
retryChatMessage,
|
||||
discardChatMessage,
|
||||
editChatMessage,
|
||||
deleteChatMessage,
|
||||
clearUnreadChat,
|
||||
dismissChatError,
|
||||
markChatRead,
|
||||
reconcileChat,
|
||||
setChatTyping,
|
||||
setChatOpen,
|
||||
]);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -35,12 +35,12 @@ msgstr "{0, plural, one {and # earlier dump} other {and # earlier dumps}}"
|
||||
|
||||
#. placeholder {0}: typingNames[0]
|
||||
#. placeholder {1}: typingNames[1]
|
||||
#: src/components/ChatModal.tsx:568
|
||||
#: src/components/ChatModal.tsx:1022
|
||||
msgid "{0} and {1} are typing…"
|
||||
msgstr "{0} and {1} are typing…"
|
||||
|
||||
#. placeholder {0}: typingNames[0]
|
||||
#: src/components/ChatModal.tsx:566
|
||||
#: src/components/ChatModal.tsx:1020
|
||||
msgid "{0} is typing…"
|
||||
msgstr "{0} is typing…"
|
||||
|
||||
@@ -54,6 +54,10 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
|
||||
msgid "{label} ({count})"
|
||||
msgstr "{label} ({count})"
|
||||
|
||||
#: src/components/ChatModal.tsx:1149
|
||||
msgid "{unseenCount} new messages"
|
||||
msgstr "{unseenCount} new messages"
|
||||
|
||||
#: src/components/CommentThread.tsx:470
|
||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
@@ -134,6 +138,10 @@ msgstr "<0>{0}</0> upvoted <1>{1}</1>"
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> was added to <1>{1}</1>"
|
||||
|
||||
#: src/components/ChatModal.tsx:1148
|
||||
msgid "1 new message"
|
||||
msgstr "1 new message"
|
||||
|
||||
#: src/pages/Notifications.tsx:217
|
||||
msgid "a comment"
|
||||
msgstr "a comment"
|
||||
@@ -203,14 +211,14 @@ msgstr "Auto"
|
||||
msgid "Back to login"
|
||||
msgstr "Back to login"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:292
|
||||
#: src/contexts/WSProvider.tsx:608
|
||||
#: src/contexts/WSProvider.tsx:328
|
||||
#: src/contexts/WSProvider.tsx:705
|
||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
|
||||
#: src/components/CategoryManager.tsx:210
|
||||
#: src/components/CategoryManager.tsx:211
|
||||
#: src/components/ChatModal.tsx:231
|
||||
#: src/components/ChatModal.tsx:210
|
||||
#: src/components/CommentThread.tsx:127
|
||||
#: src/components/ConfirmModal.tsx:34
|
||||
#: src/components/DumpCreateModal.tsx:946
|
||||
@@ -227,8 +235,8 @@ msgstr "Cancel"
|
||||
msgid "Cancel removal"
|
||||
msgstr "Cancel removal"
|
||||
|
||||
#: src/components/ChatModal.tsx:658
|
||||
#: src/components/ChatModal.tsx:659
|
||||
#: src/components/ChatModal.tsx:1209
|
||||
#: src/components/ChatModal.tsx:1210
|
||||
msgid "Cancel reply"
|
||||
msgstr "Cancel reply"
|
||||
|
||||
@@ -261,7 +269,7 @@ msgstr "Change password…"
|
||||
#: src/components/ChatButton.tsx:17
|
||||
#: src/components/ChatFab.tsx:49
|
||||
#: src/components/ChatFab.tsx:50
|
||||
#: src/components/ChatModal.tsx:572
|
||||
#: src/components/ChatModal.tsx:1111
|
||||
msgid "Chat"
|
||||
msgstr "Chat"
|
||||
|
||||
@@ -304,6 +312,10 @@ msgstr "Color scheme"
|
||||
msgid "Confirm new password"
|
||||
msgstr "Confirm new password"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:698
|
||||
msgid "Connection lost — this message may not have been sent"
|
||||
msgstr "Connection lost — this message may not have been sent"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:161
|
||||
msgid "Copied!"
|
||||
msgstr "Copied!"
|
||||
@@ -328,6 +340,10 @@ msgstr "Could not save"
|
||||
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
||||
msgstr "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
||||
|
||||
#: src/components/ChatModal.tsx:1037
|
||||
msgid "Couldn't load the chat."
|
||||
msgstr "Couldn't load the chat."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:91
|
||||
msgid "Create"
|
||||
msgstr "Create"
|
||||
@@ -360,9 +376,9 @@ msgstr "Default tab"
|
||||
|
||||
#: src/components/CategoryManager.tsx:221
|
||||
#: src/components/CategoryManager.tsx:222
|
||||
#: src/components/ChatModal.tsx:185
|
||||
#: src/components/ChatModal.tsx:186
|
||||
#: src/components/ChatModal.tsx:241
|
||||
#: src/components/ChatModal.tsx:322
|
||||
#: src/components/ChatModal.tsx:323
|
||||
#: src/components/ChatModal.tsx:417
|
||||
#: src/components/CommentThread.tsx:390
|
||||
#: src/components/CommentThread.tsx:396
|
||||
#: src/components/ConfirmModal.tsx:17
|
||||
@@ -398,7 +414,7 @@ msgstr "Delete this comment?"
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Delete this dump? This cannot be undone."
|
||||
|
||||
#: src/components/ChatModal.tsx:240
|
||||
#: src/components/ChatModal.tsx:416
|
||||
msgid "Delete this message?"
|
||||
msgstr "Delete this message?"
|
||||
|
||||
@@ -407,7 +423,7 @@ msgstr "Delete this message?"
|
||||
msgid "Delete this playlist? This cannot be undone."
|
||||
msgstr "Delete this playlist? This cannot be undone."
|
||||
|
||||
#: src/components/ChatModal.tsx:131
|
||||
#: src/components/ChatModal.tsx:244
|
||||
msgid "deleted message"
|
||||
msgstr "deleted message"
|
||||
|
||||
@@ -416,6 +432,19 @@ msgstr "deleted message"
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (optional)"
|
||||
|
||||
#: src/components/ChatModal.tsx:502
|
||||
msgid "Discard"
|
||||
msgstr "Discard"
|
||||
|
||||
#: src/components/ChatModal.tsx:1115
|
||||
msgid "Disconnected — reconnecting…"
|
||||
msgstr "Disconnected — reconnecting…"
|
||||
|
||||
#: src/components/ChatModal.tsx:1185
|
||||
#: src/components/ChatModal.tsx:1186
|
||||
msgid "Dismiss"
|
||||
msgstr "Dismiss"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:742
|
||||
msgid "Done"
|
||||
msgstr "Done"
|
||||
@@ -460,8 +489,9 @@ msgstr "Dumps ({0}{1})"
|
||||
msgid "Earlier"
|
||||
msgstr "Earlier"
|
||||
|
||||
#: src/components/ChatModal.tsx:174
|
||||
#: src/components/ChatModal.tsx:175
|
||||
#: src/components/ChatModal.tsx:311
|
||||
#: src/components/ChatModal.tsx:312
|
||||
#: src/components/ChatModal.tsx:495
|
||||
#: src/components/CommentThread.tsx:381
|
||||
#: src/pages/Dump.tsx:520
|
||||
#: src/pages/PlaylistDetail.tsx:627
|
||||
@@ -477,10 +507,14 @@ msgstr "Edit {0}"
|
||||
msgid "Edit title"
|
||||
msgstr "Edit title"
|
||||
|
||||
#: src/components/ChatModal.tsx:335
|
||||
msgid "edited"
|
||||
msgstr "edited"
|
||||
|
||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/ChatModal.tsx:154
|
||||
#: src/components/ChatModal.tsx:336
|
||||
#: src/components/CommentThread.tsx:330
|
||||
#: src/pages/Dump.tsx:459
|
||||
#: src/pages/PlaylistDetail.tsx:666
|
||||
@@ -490,7 +524,7 @@ msgstr "edited {0}"
|
||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/ChatModal.tsx:152
|
||||
#: src/components/ChatModal.tsx:332
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:457
|
||||
#: src/pages/PlaylistDetail.tsx:663
|
||||
@@ -665,6 +699,10 @@ msgstr "Hot"
|
||||
msgid "If that address is registered you'll receive a reset link shortly."
|
||||
msgstr "If that address is registered you'll receive a reset link shortly."
|
||||
|
||||
#: src/components/ChatModal.tsx:81
|
||||
msgid "image"
|
||||
msgstr "image"
|
||||
|
||||
#: src/pages/Dump.tsx:554
|
||||
msgid "In collections"
|
||||
msgstr "In collections"
|
||||
@@ -716,7 +754,7 @@ msgstr "Link"
|
||||
msgid "Link or file"
|
||||
msgstr "Link or file"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:607
|
||||
#: src/contexts/WSProvider.tsx:704
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
|
||||
@@ -729,7 +767,7 @@ msgstr "Live updates unavailable."
|
||||
msgid "Load more"
|
||||
msgstr "Load more"
|
||||
|
||||
#: src/components/ChatModal.tsx:585
|
||||
#: src/components/ChatModal.tsx:1135
|
||||
msgid "Load older messages"
|
||||
msgstr "Load older messages"
|
||||
|
||||
@@ -738,6 +776,10 @@ msgstr "Load older messages"
|
||||
msgid "Loading dump…"
|
||||
msgstr "Loading dump…"
|
||||
|
||||
#: src/components/ChatModal.tsx:1029
|
||||
msgid "Loading messages…"
|
||||
msgstr "Loading messages…"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:111
|
||||
#: src/pages/index/HotFeed.tsx:64
|
||||
#: src/pages/index/JournalFeed.tsx:73
|
||||
@@ -759,9 +801,9 @@ msgid "Loading profile…"
|
||||
msgstr "Loading profile…"
|
||||
|
||||
#: src/components/CategoryManager.tsx:52
|
||||
#: src/components/ChatModal.tsx:584
|
||||
#: src/components/ChatModal.tsx:1134
|
||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||
#: src/components/TextEditor.tsx:311
|
||||
#: src/components/TextEditor.tsx:315
|
||||
#: src/components/UserListPopover.tsx:192
|
||||
#: src/components/UserListPopover.tsx:230
|
||||
#: src/pages/index/FollowedFeed.tsx:78
|
||||
@@ -828,6 +870,7 @@ msgstr "Name"
|
||||
msgid "new"
|
||||
msgstr "new"
|
||||
|
||||
#: src/components/ChatModal.tsx:1064
|
||||
#: src/components/FeedTabBar.tsx:31
|
||||
#: src/pages/UserPublicProfile.tsx:1305
|
||||
msgid "New"
|
||||
@@ -873,7 +916,7 @@ msgstr "No dumps match \"{q}\"."
|
||||
msgid "No dumps yet. Be the first!"
|
||||
msgstr "No dumps yet. Be the first!"
|
||||
|
||||
#: src/components/TextEditor.tsx:314
|
||||
#: src/components/TextEditor.tsx:318
|
||||
msgid "No emoji found."
|
||||
msgstr "No emoji found."
|
||||
|
||||
@@ -886,7 +929,7 @@ msgstr "No followed playlists yet."
|
||||
msgid "No invitees yet."
|
||||
msgstr "No invitees yet."
|
||||
|
||||
#: src/components/ChatModal.tsx:592
|
||||
#: src/components/ChatModal.tsx:1048
|
||||
msgid "No messages yet. Say hello!"
|
||||
msgstr "No messages yet. Say hello!"
|
||||
|
||||
@@ -904,6 +947,10 @@ msgstr "No playlists match \"{q}\"."
|
||||
msgid "No playlists yet."
|
||||
msgstr "No playlists yet."
|
||||
|
||||
#: src/contexts/WSProvider.tsx:925
|
||||
msgid "No reply from the server — this message may not have been sent"
|
||||
msgstr "No reply from the server — this message may not have been sent"
|
||||
|
||||
#: src/pages/Search.tsx:257
|
||||
msgid "No users match \"{q}\"."
|
||||
msgstr "No users match \"{q}\"."
|
||||
@@ -1093,13 +1140,13 @@ msgstr "Remove vote"
|
||||
msgid "Replace file"
|
||||
msgstr "Replace file"
|
||||
|
||||
#: src/components/ChatModal.tsx:164
|
||||
#: src/components/ChatModal.tsx:165
|
||||
#: src/components/ChatModal.tsx:301
|
||||
#: src/components/ChatModal.tsx:302
|
||||
#: src/components/CommentThread.tsx:369
|
||||
msgid "Reply"
|
||||
msgstr "Reply"
|
||||
|
||||
#: src/components/ChatModal.tsx:648
|
||||
#: src/components/ChatModal.tsx:1197
|
||||
msgid "Replying to"
|
||||
msgstr "Replying to"
|
||||
|
||||
@@ -1125,6 +1172,7 @@ msgstr "Reset to default"
|
||||
msgid "Reset to the suggested title"
|
||||
msgstr "Reset to the suggested title"
|
||||
|
||||
#: src/components/ChatModal.tsx:488
|
||||
#: src/pages/Dump.tsx:287
|
||||
#: src/pages/DumpEdit.tsx:180
|
||||
msgid "Retry"
|
||||
@@ -1134,7 +1182,7 @@ msgstr "Retry"
|
||||
msgid "Role"
|
||||
msgstr "Role"
|
||||
|
||||
#: src/components/ChatModal.tsx:224
|
||||
#: src/components/ChatModal.tsx:207
|
||||
#: src/components/CommentThread.tsx:341
|
||||
#: src/pages/Dump.tsx:398
|
||||
#: src/pages/DumpEdit.tsx:487
|
||||
@@ -1172,7 +1220,7 @@ msgstr "Search failed"
|
||||
msgid "Searching…"
|
||||
msgstr "Searching…"
|
||||
|
||||
#: src/components/ChatModal.tsx:694
|
||||
#: src/components/ChatModal.tsx:1237
|
||||
msgid "Send"
|
||||
msgstr "Send"
|
||||
|
||||
@@ -1180,6 +1228,7 @@ msgstr "Send"
|
||||
msgid "Send reset link"
|
||||
msgstr "Send reset link"
|
||||
|
||||
#: src/components/ChatModal.tsx:506
|
||||
#: src/pages/UserLogin.tsx:142
|
||||
msgid "Sending…"
|
||||
msgstr "Sending…"
|
||||
@@ -1197,7 +1246,7 @@ msgstr "Set new password"
|
||||
msgid "Settings"
|
||||
msgstr "Settings"
|
||||
|
||||
#: src/components/ChatModal.tsx:569
|
||||
#: src/components/ChatModal.tsx:1023
|
||||
msgid "Several people are typing…"
|
||||
msgstr "Several people are typing…"
|
||||
|
||||
@@ -1211,6 +1260,7 @@ msgstr "slug"
|
||||
|
||||
#: src/components/form/FormError.tsx:15
|
||||
#: src/components/PageError.tsx:13
|
||||
#: src/contexts/WSProvider.tsx:658
|
||||
msgid "Something went wrong"
|
||||
msgstr "Something went wrong"
|
||||
|
||||
@@ -1264,7 +1314,7 @@ msgstr "Title"
|
||||
msgid "Title is required."
|
||||
msgstr "Title is required."
|
||||
|
||||
#: src/components/ChatModal.tsx:264
|
||||
#: src/components/ChatModal.tsx:133
|
||||
#: src/pages/Notifications.tsx:397
|
||||
msgid "Today"
|
||||
msgstr "Today"
|
||||
@@ -1274,7 +1324,11 @@ msgstr "Today"
|
||||
msgid "Track {0}"
|
||||
msgstr "Track {0}"
|
||||
|
||||
#: src/components/ChatModal.tsx:671
|
||||
#: src/components/ChatModal.tsx:1040
|
||||
msgid "Try again"
|
||||
msgstr "Try again"
|
||||
|
||||
#: src/components/ChatModal.tsx:1222
|
||||
msgid "Type a message…"
|
||||
msgstr "Type a message…"
|
||||
|
||||
@@ -1389,7 +1443,7 @@ msgstr "Why?"
|
||||
msgid "Write a reply…"
|
||||
msgstr "Write a reply…"
|
||||
|
||||
#: src/components/ChatModal.tsx:267
|
||||
#: src/components/ChatModal.tsx:136
|
||||
#: src/pages/Notifications.tsx:399
|
||||
msgid "Yesterday"
|
||||
msgstr "Yesterday"
|
||||
@@ -1402,6 +1456,10 @@ msgstr "You can post it anyway."
|
||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
|
||||
#: src/contexts/WSProvider.tsx:906
|
||||
msgid "You're offline — not sent"
|
||||
msgstr "You're offline — not sent"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:116
|
||||
#: src/pages/index/HotFeed.tsx:69
|
||||
#: src/pages/index/JournalFeed.tsx:78
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -35,12 +35,12 @@ msgstr "{0, plural, one {et # reco plus ancienne} other {et # recos plus ancienn
|
||||
|
||||
#. placeholder {0}: typingNames[0]
|
||||
#. placeholder {1}: typingNames[1]
|
||||
#: src/components/ChatModal.tsx:568
|
||||
#: src/components/ChatModal.tsx:1022
|
||||
msgid "{0} and {1} are typing…"
|
||||
msgstr "{0} et {1} sont en train d'écrire…"
|
||||
|
||||
#. placeholder {0}: typingNames[0]
|
||||
#: src/components/ChatModal.tsx:566
|
||||
#: src/components/ChatModal.tsx:1020
|
||||
msgid "{0} is typing…"
|
||||
msgstr "{0} est en train d'écrire…"
|
||||
|
||||
@@ -54,6 +54,10 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
|
||||
msgid "{label} ({count})"
|
||||
msgstr "{label} ({count})"
|
||||
|
||||
#: src/components/ChatModal.tsx:1149
|
||||
msgid "{unseenCount} new messages"
|
||||
msgstr "{unseenCount} nouveaux messages"
|
||||
|
||||
#: src/components/CommentThread.tsx:470
|
||||
msgid "{visibleCount, plural, one {# comment} other {# comments}}"
|
||||
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
|
||||
@@ -134,6 +138,10 @@ msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
|
||||
msgid "<0>{0}</0> was added to <1>{1}</1>"
|
||||
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
|
||||
|
||||
#: src/components/ChatModal.tsx:1148
|
||||
msgid "1 new message"
|
||||
msgstr "1 nouveau message"
|
||||
|
||||
#: src/pages/Notifications.tsx:217
|
||||
msgid "a comment"
|
||||
msgstr "un commentaire"
|
||||
@@ -203,14 +211,14 @@ msgstr "Auto"
|
||||
msgid "Back to login"
|
||||
msgstr "Retour à la connexion"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:292
|
||||
#: src/contexts/WSProvider.tsx:608
|
||||
#: src/contexts/WSProvider.tsx:328
|
||||
#: src/contexts/WSProvider.tsx:705
|
||||
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
|
||||
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
|
||||
|
||||
#: src/components/CategoryManager.tsx:210
|
||||
#: src/components/CategoryManager.tsx:211
|
||||
#: src/components/ChatModal.tsx:231
|
||||
#: src/components/ChatModal.tsx:210
|
||||
#: src/components/CommentThread.tsx:127
|
||||
#: src/components/ConfirmModal.tsx:34
|
||||
#: src/components/DumpCreateModal.tsx:946
|
||||
@@ -227,8 +235,8 @@ msgstr "Annuler"
|
||||
msgid "Cancel removal"
|
||||
msgstr "Annuler la suppression"
|
||||
|
||||
#: src/components/ChatModal.tsx:658
|
||||
#: src/components/ChatModal.tsx:659
|
||||
#: src/components/ChatModal.tsx:1209
|
||||
#: src/components/ChatModal.tsx:1210
|
||||
msgid "Cancel reply"
|
||||
msgstr "Annuler la réponse"
|
||||
|
||||
@@ -261,7 +269,7 @@ msgstr "Changer le mot de passe…"
|
||||
#: src/components/ChatButton.tsx:17
|
||||
#: src/components/ChatFab.tsx:49
|
||||
#: src/components/ChatFab.tsx:50
|
||||
#: src/components/ChatModal.tsx:572
|
||||
#: src/components/ChatModal.tsx:1111
|
||||
msgid "Chat"
|
||||
msgstr "Tribune"
|
||||
|
||||
@@ -304,6 +312,10 @@ msgstr "Thème de couleur"
|
||||
msgid "Confirm new password"
|
||||
msgstr "Confirmer le nouveau mot de passe"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:698
|
||||
msgid "Connection lost — this message may not have been sent"
|
||||
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
|
||||
|
||||
#: src/pages/UserPublicProfile.tsx:161
|
||||
msgid "Copied!"
|
||||
msgstr "Copié !"
|
||||
@@ -328,6 +340,10 @@ msgstr "Sauvegarde impossible"
|
||||
msgid "Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later."
|
||||
msgstr "Impossible de charger un aperçu pour ce lien — vérifiez-le, ou publiez-le tel quel et actualisez l'aperçu plus tard."
|
||||
|
||||
#: src/components/ChatModal.tsx:1037
|
||||
msgid "Couldn't load the chat."
|
||||
msgstr "Impossible de charger le chat."
|
||||
|
||||
#: src/components/PlaylistCreateForm.tsx:91
|
||||
msgid "Create"
|
||||
msgstr "Créer"
|
||||
@@ -360,9 +376,9 @@ msgstr "Onglet par défaut"
|
||||
|
||||
#: src/components/CategoryManager.tsx:221
|
||||
#: src/components/CategoryManager.tsx:222
|
||||
#: src/components/ChatModal.tsx:185
|
||||
#: src/components/ChatModal.tsx:186
|
||||
#: src/components/ChatModal.tsx:241
|
||||
#: src/components/ChatModal.tsx:322
|
||||
#: src/components/ChatModal.tsx:323
|
||||
#: src/components/ChatModal.tsx:417
|
||||
#: src/components/CommentThread.tsx:390
|
||||
#: src/components/CommentThread.tsx:396
|
||||
#: src/components/ConfirmModal.tsx:17
|
||||
@@ -398,7 +414,7 @@ msgstr "Supprimer ce commentaire ?"
|
||||
msgid "Delete this dump? This cannot be undone."
|
||||
msgstr "Supprimer cette reco ? Cette action est irréversible."
|
||||
|
||||
#: src/components/ChatModal.tsx:240
|
||||
#: src/components/ChatModal.tsx:416
|
||||
msgid "Delete this message?"
|
||||
msgstr "Supprimer ce message ?"
|
||||
|
||||
@@ -407,7 +423,7 @@ msgstr "Supprimer ce message ?"
|
||||
msgid "Delete this playlist? This cannot be undone."
|
||||
msgstr "Supprimer cette collection ? Cette action est irréversible."
|
||||
|
||||
#: src/components/ChatModal.tsx:131
|
||||
#: src/components/ChatModal.tsx:244
|
||||
msgid "deleted message"
|
||||
msgstr "message supprimé"
|
||||
|
||||
@@ -416,6 +432,19 @@ msgstr "message supprimé"
|
||||
msgid "Description (optional)"
|
||||
msgstr "Description (facultatif)"
|
||||
|
||||
#: src/components/ChatModal.tsx:502
|
||||
msgid "Discard"
|
||||
msgstr "Abandonner"
|
||||
|
||||
#: src/components/ChatModal.tsx:1115
|
||||
msgid "Disconnected — reconnecting…"
|
||||
msgstr "Déconnecté — reconnexion…"
|
||||
|
||||
#: src/components/ChatModal.tsx:1185
|
||||
#: src/components/ChatModal.tsx:1186
|
||||
msgid "Dismiss"
|
||||
msgstr "Fermer"
|
||||
|
||||
#: src/components/DumpCreateModal.tsx:742
|
||||
msgid "Done"
|
||||
msgstr "Terminé"
|
||||
@@ -460,8 +489,9 @@ msgstr "Recos ({0}{1})"
|
||||
msgid "Earlier"
|
||||
msgstr "Plus tôt"
|
||||
|
||||
#: src/components/ChatModal.tsx:174
|
||||
#: src/components/ChatModal.tsx:175
|
||||
#: src/components/ChatModal.tsx:311
|
||||
#: src/components/ChatModal.tsx:312
|
||||
#: src/components/ChatModal.tsx:495
|
||||
#: src/components/CommentThread.tsx:381
|
||||
#: src/pages/Dump.tsx:520
|
||||
#: src/pages/PlaylistDetail.tsx:627
|
||||
@@ -477,10 +507,14 @@ msgstr "Modifier {0}"
|
||||
msgid "Edit title"
|
||||
msgstr "Modifier le titre"
|
||||
|
||||
#: src/components/ChatModal.tsx:335
|
||||
msgid "edited"
|
||||
msgstr "modifié"
|
||||
|
||||
#. placeholder {0}: relativeTime(comment.updatedAt)
|
||||
#. placeholder {0}: relativeTime(dump.updatedAt)
|
||||
#. placeholder {0}: relativeTime(message.updatedAt)
|
||||
#: src/components/ChatModal.tsx:154
|
||||
#: src/components/ChatModal.tsx:336
|
||||
#: src/components/CommentThread.tsx:330
|
||||
#: src/pages/Dump.tsx:459
|
||||
#: src/pages/PlaylistDetail.tsx:666
|
||||
@@ -490,7 +524,7 @@ msgstr "modifié {0}"
|
||||
#. placeholder {0}: comment.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: dump.updatedAt.toLocaleString()
|
||||
#. placeholder {0}: message.updatedAt.toLocaleString()
|
||||
#: src/components/ChatModal.tsx:152
|
||||
#: src/components/ChatModal.tsx:332
|
||||
#: src/components/CommentThread.tsx:328
|
||||
#: src/pages/Dump.tsx:457
|
||||
#: src/pages/PlaylistDetail.tsx:663
|
||||
@@ -665,6 +699,10 @@ msgstr "Tendances"
|
||||
msgid "If that address is registered you'll receive a reset link shortly."
|
||||
msgstr "Si cette adresse est enregistrée, vous recevrez un lien de réinitialisation sous peu."
|
||||
|
||||
#: src/components/ChatModal.tsx:81
|
||||
msgid "image"
|
||||
msgstr "image"
|
||||
|
||||
#: src/pages/Dump.tsx:554
|
||||
msgid "In collections"
|
||||
msgstr "Dans les collections"
|
||||
@@ -716,7 +754,7 @@ msgstr "Lien"
|
||||
msgid "Link or file"
|
||||
msgstr "Lien ou fichier"
|
||||
|
||||
#: src/contexts/WSProvider.tsx:607
|
||||
#: src/contexts/WSProvider.tsx:704
|
||||
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
|
||||
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
|
||||
|
||||
@@ -729,7 +767,7 @@ msgstr "Mises à jour en direct indisponibles."
|
||||
msgid "Load more"
|
||||
msgstr "Charger plus"
|
||||
|
||||
#: src/components/ChatModal.tsx:585
|
||||
#: src/components/ChatModal.tsx:1135
|
||||
msgid "Load older messages"
|
||||
msgstr "Charger les messages plus anciens"
|
||||
|
||||
@@ -738,6 +776,10 @@ msgstr "Charger les messages plus anciens"
|
||||
msgid "Loading dump…"
|
||||
msgstr "Chargement de la reco…"
|
||||
|
||||
#: src/components/ChatModal.tsx:1029
|
||||
msgid "Loading messages…"
|
||||
msgstr "Chargement des messages…"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:111
|
||||
#: src/pages/index/HotFeed.tsx:64
|
||||
#: src/pages/index/JournalFeed.tsx:73
|
||||
@@ -759,9 +801,9 @@ msgid "Loading profile…"
|
||||
msgstr "Chargement du profil…"
|
||||
|
||||
#: src/components/CategoryManager.tsx:52
|
||||
#: src/components/ChatModal.tsx:584
|
||||
#: src/components/ChatModal.tsx:1134
|
||||
#: src/components/PlaylistMembershipPanel.tsx:28
|
||||
#: src/components/TextEditor.tsx:311
|
||||
#: src/components/TextEditor.tsx:315
|
||||
#: src/components/UserListPopover.tsx:192
|
||||
#: src/components/UserListPopover.tsx:230
|
||||
#: src/pages/index/FollowedFeed.tsx:78
|
||||
@@ -828,6 +870,7 @@ msgstr "Nom"
|
||||
msgid "new"
|
||||
msgstr "nouveau"
|
||||
|
||||
#: src/components/ChatModal.tsx:1064
|
||||
#: src/components/FeedTabBar.tsx:31
|
||||
#: src/pages/UserPublicProfile.tsx:1305
|
||||
msgid "New"
|
||||
@@ -873,7 +916,7 @@ msgstr "Aucune reco ne correspond à « {q} »."
|
||||
msgid "No dumps yet. Be the first!"
|
||||
msgstr "Pas encore de recos. Soyez le premier !"
|
||||
|
||||
#: src/components/TextEditor.tsx:314
|
||||
#: src/components/TextEditor.tsx:318
|
||||
msgid "No emoji found."
|
||||
msgstr "Aucun emoji trouvé."
|
||||
|
||||
@@ -886,7 +929,7 @@ msgstr "Pas encore de collections suivies."
|
||||
msgid "No invitees yet."
|
||||
msgstr "Aucun invité pour le moment."
|
||||
|
||||
#: src/components/ChatModal.tsx:592
|
||||
#: src/components/ChatModal.tsx:1048
|
||||
msgid "No messages yet. Say hello!"
|
||||
msgstr "Aucun message pour l'instant. Dites bonjour !"
|
||||
|
||||
@@ -904,6 +947,10 @@ msgstr "Aucune collection ne correspond à « {q} »."
|
||||
msgid "No playlists yet."
|
||||
msgstr "Pas encore de collections."
|
||||
|
||||
#: src/contexts/WSProvider.tsx:925
|
||||
msgid "No reply from the server — this message may not have been sent"
|
||||
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
|
||||
|
||||
#: src/pages/Search.tsx:257
|
||||
msgid "No users match \"{q}\"."
|
||||
msgstr "Aucun utilisateur ne correspond à « {q} »."
|
||||
@@ -1093,13 +1140,13 @@ msgstr "Retirer le vote"
|
||||
msgid "Replace file"
|
||||
msgstr "Remplacer le fichier"
|
||||
|
||||
#: src/components/ChatModal.tsx:164
|
||||
#: src/components/ChatModal.tsx:165
|
||||
#: src/components/ChatModal.tsx:301
|
||||
#: src/components/ChatModal.tsx:302
|
||||
#: src/components/CommentThread.tsx:369
|
||||
msgid "Reply"
|
||||
msgstr "Répondre"
|
||||
|
||||
#: src/components/ChatModal.tsx:648
|
||||
#: src/components/ChatModal.tsx:1197
|
||||
msgid "Replying to"
|
||||
msgstr "En réponse à"
|
||||
|
||||
@@ -1125,6 +1172,7 @@ msgstr "Réinitialiser par défaut"
|
||||
msgid "Reset to the suggested title"
|
||||
msgstr "Rétablir le titre suggéré"
|
||||
|
||||
#: src/components/ChatModal.tsx:488
|
||||
#: src/pages/Dump.tsx:287
|
||||
#: src/pages/DumpEdit.tsx:180
|
||||
msgid "Retry"
|
||||
@@ -1134,7 +1182,7 @@ msgstr "Réessayer"
|
||||
msgid "Role"
|
||||
msgstr "Rôle"
|
||||
|
||||
#: src/components/ChatModal.tsx:224
|
||||
#: src/components/ChatModal.tsx:207
|
||||
#: src/components/CommentThread.tsx:341
|
||||
#: src/pages/Dump.tsx:398
|
||||
#: src/pages/DumpEdit.tsx:487
|
||||
@@ -1172,7 +1220,7 @@ msgstr "Recherche échouée"
|
||||
msgid "Searching…"
|
||||
msgstr "Recherche…"
|
||||
|
||||
#: src/components/ChatModal.tsx:694
|
||||
#: src/components/ChatModal.tsx:1237
|
||||
msgid "Send"
|
||||
msgstr "Envoyer"
|
||||
|
||||
@@ -1180,6 +1228,7 @@ msgstr "Envoyer"
|
||||
msgid "Send reset link"
|
||||
msgstr "Envoyer le lien de réinitialisation"
|
||||
|
||||
#: src/components/ChatModal.tsx:506
|
||||
#: src/pages/UserLogin.tsx:142
|
||||
msgid "Sending…"
|
||||
msgstr "Envoi…"
|
||||
@@ -1197,7 +1246,7 @@ msgstr "Définir un nouveau mot de passe"
|
||||
msgid "Settings"
|
||||
msgstr "Paramètres"
|
||||
|
||||
#: src/components/ChatModal.tsx:569
|
||||
#: src/components/ChatModal.tsx:1023
|
||||
msgid "Several people are typing…"
|
||||
msgstr "Plusieurs personnes écrivent…"
|
||||
|
||||
@@ -1211,6 +1260,7 @@ msgstr "identifiant"
|
||||
|
||||
#: src/components/form/FormError.tsx:15
|
||||
#: src/components/PageError.tsx:13
|
||||
#: src/contexts/WSProvider.tsx:658
|
||||
msgid "Something went wrong"
|
||||
msgstr "Une erreur est survenue"
|
||||
|
||||
@@ -1264,7 +1314,7 @@ msgstr "Titre"
|
||||
msgid "Title is required."
|
||||
msgstr "Un titre est requis."
|
||||
|
||||
#: src/components/ChatModal.tsx:264
|
||||
#: src/components/ChatModal.tsx:133
|
||||
#: src/pages/Notifications.tsx:397
|
||||
msgid "Today"
|
||||
msgstr "Aujourd'hui"
|
||||
@@ -1274,7 +1324,11 @@ msgstr "Aujourd'hui"
|
||||
msgid "Track {0}"
|
||||
msgstr "Piste {0}"
|
||||
|
||||
#: src/components/ChatModal.tsx:671
|
||||
#: src/components/ChatModal.tsx:1040
|
||||
msgid "Try again"
|
||||
msgstr "Réessayer"
|
||||
|
||||
#: src/components/ChatModal.tsx:1222
|
||||
msgid "Type a message…"
|
||||
msgstr "Écrivez un message…"
|
||||
|
||||
@@ -1389,7 +1443,7 @@ msgstr "Pourquoi ?"
|
||||
msgid "Write a reply…"
|
||||
msgstr "Écrire une réponse…"
|
||||
|
||||
#: src/components/ChatModal.tsx:267
|
||||
#: src/components/ChatModal.tsx:136
|
||||
#: src/pages/Notifications.tsx:399
|
||||
msgid "Yesterday"
|
||||
msgstr "Hier"
|
||||
@@ -1402,6 +1456,10 @@ msgstr "Vous pouvez la publier quand même."
|
||||
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
|
||||
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
|
||||
|
||||
#: src/contexts/WSProvider.tsx:906
|
||||
msgid "You're offline — not sent"
|
||||
msgstr "Hors ligne — message non envoyé"
|
||||
|
||||
#: src/pages/index/FollowedFeed.tsx:116
|
||||
#: src/pages/index/HotFeed.tsx:69
|
||||
#: src/pages/index/JournalFeed.tsx:78
|
||||
|
||||
25
src/model.ts
25
src/model.ts
@@ -464,6 +464,8 @@ export interface WSWelcomeMessage {
|
||||
myVotes: string[];
|
||||
myCommentLikes: string[];
|
||||
unreadNotificationCount: number;
|
||||
chatReadAt?: string;
|
||||
unreadChatCount: number;
|
||||
}
|
||||
export interface WSPresenceUpdateMessage {
|
||||
type: "presence_update";
|
||||
@@ -554,6 +556,10 @@ export interface WSErrorMessage {
|
||||
dumpId?: string;
|
||||
commentId?: string;
|
||||
action?: "cast" | "remove";
|
||||
/** Set when a chat action failed, so it can be surfaced in the chat. */
|
||||
scope?: "chat";
|
||||
/** Echo of the failed chat_send's nonce. */
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface WSForceLogoutMessage {
|
||||
@@ -563,6 +569,7 @@ export interface WSForceLogoutMessage {
|
||||
export interface WSChatMessageMessage {
|
||||
type: "chat_message";
|
||||
message: RawChatMessage;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface WSChatMessageUpdatedMessage {
|
||||
@@ -580,6 +587,12 @@ export interface WSChatTypingUpdateMessage {
|
||||
users: OnlineUser[];
|
||||
}
|
||||
|
||||
export interface WSChatReadUpdateMessage {
|
||||
type: "chat_read_update";
|
||||
readAt: string;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
export type IncomingWSMessage =
|
||||
| WSPingMessage
|
||||
| WSWelcomeMessage
|
||||
@@ -605,7 +618,8 @@ export type IncomingWSMessage =
|
||||
| WSChatMessageMessage
|
||||
| WSChatMessageUpdatedMessage
|
||||
| WSChatMessageDeletedMessage
|
||||
| WSChatTypingUpdateMessage;
|
||||
| WSChatTypingUpdateMessage
|
||||
| WSChatReadUpdateMessage;
|
||||
|
||||
/**
|
||||
* WebSocket messages — client → server (outgoing)
|
||||
@@ -635,6 +649,7 @@ export interface WSChatSendMessage {
|
||||
type: "chat_send";
|
||||
body: string;
|
||||
replyToId?: string;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface WSChatTypingMessage {
|
||||
@@ -658,6 +673,11 @@ export interface WSChatDeleteMessage {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface WSChatReadMessage {
|
||||
type: "chat_read";
|
||||
at: string;
|
||||
}
|
||||
|
||||
export type OutgoingWSMessage =
|
||||
| WSPongMessage
|
||||
| WSVoteCastMessage
|
||||
@@ -668,7 +688,8 @@ export type OutgoingWSMessage =
|
||||
| WSChatTypingMessage
|
||||
| WSChatFocusMessage
|
||||
| WSChatEditMessage
|
||||
| WSChatDeleteMessage;
|
||||
| WSChatDeleteMessage
|
||||
| WSChatReadMessage;
|
||||
|
||||
/**
|
||||
* Follows
|
||||
|
||||
@@ -132,7 +132,8 @@ function notificationLink(n: Notification): string {
|
||||
return `/dumps/${d.dumpId}#comment-${d.contextId}`;
|
||||
}
|
||||
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
|
||||
if (d.contextType === "chat") return `/?chat=open`;
|
||||
// Search only: the chat opens over whatever page you're on.
|
||||
if (d.contextType === "chat") return `?chat=open`;
|
||||
return `/playlists/${d.contextId}`;
|
||||
}
|
||||
}
|
||||
|
||||
34
src/utils/chatText.ts
Normal file
34
src/utils/chatText.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Text helpers for the live chat.
|
||||
|
||||
// Mirrors MENTION_RE in api/services/notification-service.ts.
|
||||
const MENTION_RE = /(?<![[(\w])@(\w+)/g;
|
||||
|
||||
/** Whether `body` @-mentions `username` (case-insensitively), by the same rule
|
||||
* the server uses to send mention notifications. */
|
||||
export function mentionsUser(body: string, username: string): boolean {
|
||||
const target = username.toLowerCase();
|
||||
for (const m of body.matchAll(MENTION_RE)) {
|
||||
if (m[1].toLowerCase() === target) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens chat markdown to one line of plain text, for the reply previews:
|
||||
* images become a label, links their text, and markup characters go. Also
|
||||
* copes with a body the server cut short mid-link, which would otherwise show
|
||||
* a dangling half-URL.
|
||||
*/
|
||||
export function markdownToPlainText(md: string, imageLabel: string): string {
|
||||
return md
|
||||
// A truncated image or link at the very end: drop the unfinished target.
|
||||
.replace(/!\[([^\]]*)\]\([^)]*$/, (_, alt: string) => alt || imageLabel)
|
||||
.replace(/(^|[^!])\[([^\]]*)\]\([^)]*$/, "$1$2")
|
||||
.replace(/!\[[^\]]*$/, "")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_, alt: string) => alt || imageLabel)
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
|
||||
.replace(/```\w*|\*\*|__|~~|[*`]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
Reference in New Issue
Block a user