v3: chatbox improvements (typing indicators, answer to previous messages, backlog days separators, scroll to last message position, visual tweaks)
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

This commit is contained in:
2026-06-30 12:27:03 +02:00
parent 124866ad4f
commit d2d086831e
17 changed files with 857 additions and 86 deletions

View File

@@ -7,6 +7,7 @@ import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
import { up as up0006Categories } from "./migrations/0006_categories.ts";
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
interface Migration {
name: string;
@@ -25,6 +26,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0006_categories", up: up0006Categories },
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
{ name: "0008_chat_messages", up: up0008ChatMessages },
{ name: "0009_chat_reply", up: up0009ChatReply },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,17 @@
import type { DatabaseSync } from "node:sqlite";
// Adds an optional self-reference so a chat message can reply to another.
// Kept as a plain column (no FK): when the replied-to message is later deleted
// the id is retained but the join yields null, letting the UI show a subtle
// "deleted message" reference instead of breaking the insert.
//
// Idempotent: also runs against fresh databases created from schema.sql, where
// the column already exists.
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
name: string;
}[];
if (!cols.some((c) => c.name === "reply_to_id")) {
db.exec(`ALTER TABLE chat_messages ADD COLUMN reply_to_id TEXT;`);
}
}

View File

@@ -199,6 +199,7 @@ CREATE TABLE chat_messages (
body TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT,
reply_to_id TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_chat_messages_created ON chat_messages(created_at);

View File

@@ -313,6 +313,12 @@ export interface ChatMessageRow {
updated_at: string | null;
author_username: string;
author_avatar_mime: string | null;
// Reply columns: reply_to_id is the stored target; the author/body preview are
// LEFT-JOINed from the target and go null when there's no reply (or it was
// since deleted).
reply_to_id: string | null;
reply_to_author: string | null;
reply_to_body: string | null;
[key: string]: SQLOutputValue;
}
@@ -327,7 +333,13 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null);
obj.author_avatar_mime === null) &&
"reply_to_id" in obj &&
(typeof obj.reply_to_id === "string" || obj.reply_to_id === null) &&
"reply_to_author" in obj &&
(typeof obj.reply_to_author === "string" || obj.reply_to_author === null) &&
"reply_to_body" in obj &&
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
}
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
@@ -339,6 +351,9 @@ export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
replyToId: row.reply_to_id ?? undefined,
replyToAuthor: row.reply_to_author ?? undefined,
replyToBody: row.reply_to_body ?? undefined,
};
}

View File

@@ -321,6 +321,13 @@ export interface ChatMessage {
updatedAt?: Date;
authorUsername: string;
authorAvatarMime?: string;
/** Id of the message this one replies to, if any. Retained even when the
* target is later deleted (the preview fields below then go undefined). */
replyToId?: string;
/** Author/snippet of the replied-to message, for a subtle inline reference.
* Absent when there's no reply, or the target has since been deleted. */
replyToAuthor?: string;
replyToBody?: string;
}
/** Wire format — createdAt arrives as an ISO string over JSON. */
@@ -554,6 +561,15 @@ export interface CommentLikeRemoveMessage {
export interface ChatSendMessage {
type: "chat_send";
body: string;
/** Id of the message being replied to, if this is a reply. */
replyToId?: string;
}
// Sent while the user is composing (true) and when they stop (false). The
// server rebroadcasts the live set of typers to everyone as ChatTypingUpdate.
export interface ChatTypingMessage {
type: "chat_typing";
typing: boolean;
}
// Tells the server whether this client currently has the chatbox open, so that
@@ -582,6 +598,7 @@ export type ClientToServerMessage =
| CommentLikeCastMessage
| CommentLikeRemoveMessage
| ChatSendMessage
| ChatTypingMessage
| ChatFocusMessage
| ChatEditMessage
| ChatDeleteMessage;
@@ -730,6 +747,13 @@ export interface ChatMessageDeletedMessage {
id: string;
}
// The current set of users composing a chat message. Reuses OnlineUser so the
// client can render the same avatars as the presence row.
export interface ChatTypingUpdateMessage {
type: "chat_typing_update";
users: OnlineUser[];
}
export type ServerToClientMessage =
| PingMessage
| WelcomeMessage
@@ -754,7 +778,8 @@ export type ServerToClientMessage =
| ForceLogoutMessage
| ChatMessageMessage
| ChatMessageUpdatedMessage
| ChatMessageDeletedMessage;
| ChatMessageDeletedMessage
| ChatTypingUpdateMessage;
/**
* Follows

View File

@@ -7,11 +7,13 @@ import {
broadcastChatMessageUpdated,
broadcastCommentLikeUpdate,
broadcastPresence,
broadcastTyping,
broadcastVoteUpdate,
getOnlineUsers,
handleClientPong,
isUserChatOpen,
register,
setClientTyping,
unregister,
type WsClient,
} from "../services/ws-service.ts";
@@ -127,7 +129,10 @@ router.get("/ws", async (ctx) => {
handleCommentLike(client, msg.commentId, "remove");
break;
case "chat_send":
handleChatSend(client, msg.body);
handleChatSend(client, msg.body, msg.replyToId);
break;
case "chat_typing":
if (client.userId) setClientTyping(client, msg.typing);
break;
case "chat_focus":
client.chatOpen = msg.open;
@@ -144,6 +149,8 @@ router.get("/ws", async (ctx) => {
socket.addEventListener("close", () => {
unregister(client);
broadcastPresence();
// If they were mid-compose, drop them from the typing set right away.
if (client.typingAt) broadcastTyping();
});
});
@@ -214,7 +221,11 @@ function handleCommentLike(
}
}
function handleChatSend(client: WsClient, body: string): void {
function handleChatSend(
client: WsClient,
body: string,
replyToId?: string,
): void {
const { socket } = client;
if (!client.userId) {
@@ -225,7 +236,10 @@ function handleChatSend(client: WsClient, body: string): void {
}
try {
const message = createChatMessage(client.userId, body);
const message = createChatMessage(client.userId, body, replyToId);
// Sending a message ends composition — clear the typing flag before the
// message broadcast so the indicator doesn't linger next to it.
setClientTyping(client, false);
// Broadcast to everyone including the sender, so the sender's own message
// simply arrives over the socket — no optimistic-send machinery needed.
broadcastChatMessage(message);

View File

@@ -9,13 +9,26 @@ export const MAX_CHAT_LENGTH = 2000;
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
// Length cap for the replied-to preview snippet, so a reply to a long message
// doesn't ship the whole 2000-char body over the wire for every reply.
const REPLY_SNIPPET_LEN = 140;
const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at,
u.username as author_username, u.avatar_mime as author_avatar_mime`;
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
u.username as author_username, u.avatar_mime as author_avatar_mime,
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
// Common joins: author (required) plus the optional replied-to message and its
// author (LEFT — null when there's no reply or the target was deleted).
const FROM_JOINS =
`FROM chat_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id`;
function fetchMessage(id: string): ChatMessage {
const row = db.prepare(
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?;`,
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
).get(id);
if (!row || !isChatMessageRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Message not found");
@@ -23,7 +36,11 @@ function fetchMessage(id: string): ChatMessage {
return chatMessageRowToApi(row);
}
export function createChatMessage(userId: string, body: string): ChatMessage {
export function createChatMessage(
userId: string,
body: string,
replyToId?: string,
): ChatMessage {
const trimmed = body.trim();
if (!trimmed) {
throw new APIException(
@@ -39,11 +56,20 @@ export function createChatMessage(userId: string, body: string): ChatMessage {
`Message exceeds ${MAX_CHAT_LENGTH} characters`,
);
}
// Only store the reply target if it actually exists — drop dangling ids so a
// message never references a phantom parent.
let replyTo: string | null = null;
if (replyToId) {
const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get(
replyToId,
);
if (exists) replyTo = replyToId;
}
const id = crypto.randomUUID();
const createdAt = new Date().toISOString();
db.prepare(
`INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt);
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt, replyTo);
return fetchMessage(id);
}
@@ -117,11 +143,11 @@ export function getRecentChatMessages(
const capped = Math.min(Math.max(limit, 1), MAX_LIMIT);
const rows = before
? db.prepare(
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id
`SELECT ${SELECT_COLS} ${FROM_JOINS}
WHERE m.created_at < ? ORDER BY m.created_at DESC LIMIT ?;`,
).all(before, capped)
: db.prepare(
`SELECT ${SELECT_COLS} FROM chat_messages m JOIN users u ON m.user_id = u.id
`SELECT ${SELECT_COLS} ${FROM_JOINS}
ORDER BY m.created_at DESC LIMIT ?;`,
).all(capped);

View File

@@ -19,6 +19,9 @@ export interface WsClient {
pongReceived?: boolean;
/** Whether this client currently has the chatbox open (is reading chat). */
chatOpen?: boolean;
/** Timestamp (ms) of the client's last "typing" signal; undefined when not
* composing. Entries older than TYPING_TTL are treated as stale. */
typingAt?: number;
}
const clients = new Set<WsClient>();
@@ -233,6 +236,64 @@ export function broadcastChatMessageDeleted(id: string): void {
}
}
// Typing indicator: a client's "typing" signal is considered live for this long
// after its last refresh. Must exceed the client's typing-ping cadence so a
// steady typist never flickers out between pings.
const TYPING_TTL = 6_000;
// Deduped set of users currently composing a chat message (typing signal within
// the TTL), shaped like OnlineUser so the client renders the same avatars.
export function getTypingUsers(): OnlineUser[] {
const now = Date.now();
const seen = new Map<string, OnlineUser>();
for (const client of clients) {
if (
client.userId && client.typingAt && now - client.typingAt < TYPING_TTL &&
!seen.has(client.userId)
) {
seen.set(client.userId, {
userId: client.userId,
username: client.username!,
hasAvatar: !!client.avatarMime,
avatarVersion: client.avatarVersion,
});
}
}
return Array.from(seen.values());
}
export function broadcastTyping(): void {
const users = getTypingUsers();
for (const client of clients) {
send(client.socket, { type: "chat_typing_update", users });
}
}
// Record (or clear) a client's typing state and tell everyone. The client also
// resends "true" periodically while composing; the sweep below expires it if
// they go quiet without a clean "false" (e.g. they closed the tab).
export function setClientTyping(client: WsClient, typing: boolean): void {
const wasTyping = !!client.typingAt;
client.typingAt = typing ? Date.now() : undefined;
// Only rebroadcast on an actual edge to avoid a flood of identical updates
// from the periodic typing pings.
if (typing !== wasTyping) broadcastTyping();
}
// Sweep stale typing entries (clients that stopped pinging without a clean
// stop) and rebroadcast when the visible set shrinks.
setInterval(() => {
const now = Date.now();
let changed = false;
for (const client of clients) {
if (client.typingAt && now - client.typingAt >= TYPING_TTL) {
client.typingAt = undefined;
changed = true;
}
}
if (changed) broadcastTyping();
}, 2_000);
// True if the user has the chatbox open on any of their connected clients — i.e.
// they're already reading chat, so a mention notification would be redundant.
export function isUserChatOpen(userId: string): boolean {

View File

@@ -3292,12 +3292,14 @@ body.has-player .chat-fab {
}
/* ── Live chat ── */
/* The chat fills the (now taller) modal so it makes better use of the viewport
on desktop — see the .modal-card:has(.chat) height override below. */
.chat {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
height: min(60vh, 480px);
height: 100%;
gap: 0.75rem;
}
@@ -3306,12 +3308,13 @@ body.has-player .chat-fab {
min-height: 0;
overflow-y: auto;
/* Reserve a gutter for the scrollbar so it never sits on top of the
messages — without this, overlay scrollbars overlap the content. */
messages — without this, overlay scrollbars overlap the content. The extra
padding keeps the text clear of the scrollbar rather than right against it. */
scrollbar-gutter: stable;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding-right: 0.5rem;
padding-right: 1rem;
}
.chat-empty {
@@ -3352,6 +3355,40 @@ body.has-player .chat-fab {
}
}
/* Brief highlight when a message is jumped to via a reply reference. */
.chat-message {
border-radius: 8px;
transition: background-color 0.4s ease;
}
.chat-message--flash {
background-color: color-mix(in srgb, var(--color-accent) 18%, transparent);
}
/* Subtle day separator: a hairline rule with a centered, muted label. */
.chat-day-sep {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0.15rem 0;
color: var(--color-text-muted);
}
.chat-day-sep::before,
.chat-day-sep::after {
content: "";
flex: 1;
height: 1px;
background: var(--color-border);
}
.chat-day-sep-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.chat-message-body {
flex: 1;
min-width: 0;
@@ -3497,6 +3534,177 @@ body.has-player .chat-fab {
box-sizing: border-box;
}
/* ── Reply reference (subtle, above a message's body) ── */
.chat-reply-ref {
display: flex;
align-items: baseline;
gap: 0.3rem;
max-width: 100%;
margin-bottom: 0.15rem;
padding: 0;
background: none;
border: none;
font: inherit;
font-size: 0.74rem;
color: var(--color-text-muted);
cursor: pointer;
text-align: left;
}
.chat-reply-ref:disabled {
cursor: default;
font-style: italic;
}
.chat-reply-ref:not(:disabled):hover .chat-reply-ref-author,
.chat-reply-ref:not(:disabled):hover .chat-reply-ref-text {
color: var(--color-text);
}
.chat-reply-ref-mark {
opacity: 0.7;
flex-shrink: 0;
}
.chat-reply-ref-author {
font-weight: 700;
flex-shrink: 0;
transition: color 0.15s;
}
.chat-reply-ref-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.85;
transition: color 0.15s;
}
/* ── Reply composer bar (subtle, above the input) ── */
.chat-reply-bar {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
padding: 0.35rem 0.6rem;
border-left: 2px solid var(--color-accent);
border-radius: 4px;
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
font-size: 0.78rem;
color: var(--color-text-muted);
}
.chat-reply-bar-mark {
opacity: 0.7;
flex-shrink: 0;
}
.chat-reply-bar-text {
display: flex;
align-items: baseline;
gap: 0.3rem;
min-width: 0;
flex: 1;
}
.chat-reply-bar-author {
font-weight: 700;
color: var(--color-text);
flex-shrink: 0;
}
.chat-reply-bar-snippet {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.8;
}
.chat-reply-bar-cancel {
margin-left: auto;
flex-shrink: 0;
font-size: 0.85rem;
}
/* ── "Is typing" indicator (reuses presence avatars) ── */
.chat-typing {
display: flex;
align-items: center;
gap: 0.45rem;
flex-shrink: 0;
min-height: 1.25rem;
font-size: 0.76rem;
color: var(--color-text-muted);
}
.chat-typing-avatars {
display: inline-flex;
}
/* Overlap multiple typers' avatars slightly, like a stacked presence cluster. */
.chat-typing-avatar:not(:first-child) {
margin-left: -0.4rem;
}
.chat-typing-avatar {
border-radius: 50%;
box-shadow: 0 0 0 2px var(--color-surface);
}
.chat-typing-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-typing-dots {
display: inline-flex;
gap: 2px;
flex-shrink: 0;
}
.chat-typing-dots span {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
animation: chat-typing-bounce 1.2s infinite ease-in-out;
}
.chat-typing-dots span:nth-child(2) {
animation-delay: 0.15s;
}
.chat-typing-dots span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes chat-typing-bounce {
0%, 60%, 100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 0.9;
transform: translateY(-2px);
}
}
@media (prefers-reduced-motion: reduce) {
.chat-typing-dots span {
animation: none;
}
}
/* Give the chat a tall, stable panel so it uses the viewport well on desktop
instead of floating as a small box. Bounded by max-height so it never spills
off short screens; the inner message list scrolls within it. */
.modal-card:has(.chat) {
height: min(86vh, 820px);
max-height: 92vh;
}
/* The composer's popovers (mention list, emoji picker) anchor to the input,
which sits at the very bottom of the modal. The modal body must not clip them
— let them escape the body entirely so they're never cropped. The message

View File

@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Link } from "react-router";
@@ -12,6 +19,7 @@ import { useWS } from "../hooks/useWS.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { can } from "../utils/permissions.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { i18n } from "../i18n.ts";
import { API_URL } from "../config/api.ts";
import {
type ChatMessage,
@@ -24,17 +32,40 @@ import {
const MAX_CHAT_LENGTH = 2000;
// Matches the server's default page size; a full page means there may be more.
const PAGE_SIZE = 50;
// How long the composer stays "typing" after the last keystroke, and how often
// we refresh that signal while typing continuously. The refresh interval must
// stay below the server's TYPING_TTL so a steady typist never flickers out.
const TYPING_STOP_DELAY = 3_500;
const TYPING_REFRESH = 3_000;
interface ReplyTarget {
id: string;
author: string;
body: string;
}
interface ChatMessageItemProps {
message: ChatMessage;
currentUser: PublicUser | null;
canModerate: boolean;
flashed: boolean;
onEdit: (id: string, body: string) => void;
onDelete: (id: string) => void;
onReply: (message: ChatMessage) => void;
onJumpTo: (id: string) => void;
}
function ChatMessageItem(
{ message, currentUser, canModerate, onEdit, onDelete }: ChatMessageItemProps,
{
message,
currentUser,
canModerate,
flashed,
onEdit,
onDelete,
onReply,
onJumpTo,
}: ChatMessageItemProps,
) {
const [editing, setEditing] = useState(false);
const [editDraft, setEditDraft] = useState(message.body);
@@ -61,7 +92,10 @@ function ChatMessageItem(
};
return (
<div className="chat-message">
<div
className={`chat-message${flashed ? " chat-message--flash" : ""}`}
data-mid={message.id}
>
<Avatar
userId={message.userId}
username={message.authorUsername}
@@ -69,6 +103,34 @@ function ChatMessageItem(
size={32}
/>
<div className="chat-message-body">
{message.replyToId && (
// Subtle one-line reference to the replied-to message; clicking jumps
// to it. Falls back to a muted note when the target was deleted.
<button
type="button"
className="chat-reply-ref"
onClick={() => onJumpTo(message.replyToId!)}
disabled={!message.replyToAuthor}
>
<span className="chat-reply-ref-mark"></span>
{message.replyToAuthor
? (
<>
<span className="chat-reply-ref-author">
{message.replyToAuthor}
</span>
<span className="chat-reply-ref-text">
{message.replyToBody}
</span>
</>
)
: (
<span className="chat-reply-ref-text">
<Trans>deleted message</Trans>
</span>
)}
</button>
)}
<div className="chat-message-meta">
<Link
to={`/users/${message.authorUsername}`}
@@ -91,8 +153,17 @@ function ChatMessageItem(
</span>
</Tooltip>
)}
{!editing && (canEdit || canDelete) && (
{!editing && (
<span className="chat-message-actions">
<button
type="button"
className="chat-icon-btn"
onClick={() => onReply(message)}
title={t`Reply`}
aria-label={t`Reply`}
>
</button>
{canEdit && (
<button
type="button"
@@ -178,14 +249,38 @@ function ChatMessageItem(
);
}
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
// Human label for a day separator: "Today"/"Yesterday" for the two most recent
// days, otherwise a localized full date.
function dayLabel(date: Date): string {
const now = new Date();
if (isSameDay(date, now)) return t`Today`;
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (isSameDay(date, yesterday)) return t`Yesterday`;
return date.toLocaleDateString(i18n.locale, {
weekday: "long",
day: "numeric",
month: "long",
year: now.getFullYear() === date.getFullYear() ? undefined : "numeric",
});
}
export function ChatModal({ onClose }: { onClose: () => void }) {
const {
chatMessages,
deletedChatIds,
typingUsers,
sendChatMessage,
editChatMessage,
deleteChatMessage,
clearUnreadChat,
setChatTyping,
setChatOpen,
} = useWS();
const { authFetch, user } = useAuth();
@@ -195,12 +290,23 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
const [hasMore, setHasMore] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [draft, setDraft] = useState("");
const [replyTarget, setReplyTarget] = useState<ReplyTarget | null>(null);
const [flashId, setFlashId] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<TextEditorHandle>(null);
// Whether the view is pinned to the bottom — controls auto-scroll on new
// messages so reading older history isn't yanked away.
const stickToBottomRef = useRef(true);
// Set once the first history load has scrolled into place, so the layout
// effect can force the initial jump to the bottom even before the user has
// interacted.
const didInitialScrollRef = useRef(false);
// Typing-signal bookkeeping: timer that sends "stopped" after a pause, and
// the timestamp of the last "typing" refresh we sent.
const typingStopTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastTypingSentRef = useRef(0);
const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Focus the composer when the chat opens so you can type straight away.
useEffect(() => {
@@ -218,6 +324,12 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}, [history, chatMessages, deletedChatIds]);
// Others (not me) currently composing — drives the typing indicator.
const typingOthers = useMemo(
() => typingUsers.filter((u) => u.userId !== user?.id),
[typingUsers, user?.id],
);
// Initial history load.
useEffect(() => {
let cancelled = false;
@@ -250,12 +362,36 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
return () => setChatOpen(false);
}, [setChatOpen]);
// Auto-scroll to the bottom when pinned (initial load + new messages).
// Stop announcing "typing" when the chatbox closes, whatever state we're in.
useEffect(() => {
if (!stickToBottomRef.current) return;
return () => {
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
if (lastTypingSentRef.current) setChatTyping(false);
};
}, [setChatTyping]);
// Auto-scroll to the bottom when pinned (initial load + new messages + the
// typing row appearing). A layout effect runs before paint so there's no
// visible flash, and a follow-up frame catches late height changes (e.g. an
// avatar/image that resizes after first paint).
useLayoutEffect(() => {
const el = listRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages]);
if (!el) return;
// Force the very first positioning to the bottom even though no scrolling
// has happened yet, then honour the pin afterwards.
if (!didInitialScrollRef.current) {
if (messages.length === 0) return;
didInitialScrollRef.current = true;
} else if (!stickToBottomRef.current) {
return;
}
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => {
const node = listRef.current;
if (node && stickToBottomRef.current) node.scrollTop = node.scrollHeight;
});
}, [messages, typingOthers.length]);
const handleScroll = useCallback(() => {
const el = listRef.current;
@@ -296,13 +432,102 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
}
}, [authFetch, messages, loadingOlder]);
// Announce that the user is composing, throttled, and schedule a "stopped"
// signal once they pause. Called on every keystroke via the composer.
const pingTyping = useCallback(() => {
const now = Date.now();
if (now - lastTypingSentRef.current > TYPING_REFRESH) {
lastTypingSentRef.current = now;
setChatTyping(true);
}
if (typingStopTimerRef.current) clearTimeout(typingStopTimerRef.current);
typingStopTimerRef.current = setTimeout(() => {
lastTypingSentRef.current = 0;
setChatTyping(false);
}, TYPING_STOP_DELAY);
}, [setChatTyping]);
const stopTyping = useCallback(() => {
if (typingStopTimerRef.current) {
clearTimeout(typingStopTimerRef.current);
typingStopTimerRef.current = null;
}
if (lastTypingSentRef.current) {
lastTypingSentRef.current = 0;
setChatTyping(false);
}
}, [setChatTyping]);
const handleDraftChange = useCallback((value: string) => {
setDraft(value);
if (value.trim()) pingTyping();
else stopTyping();
}, [pingTyping, stopTyping]);
const startReply = useCallback((message: ChatMessage) => {
setReplyTarget({
id: message.id,
author: message.authorUsername,
body: message.body,
});
composerRef.current?.focus();
}, []);
// Scroll a referenced message into view and flash it briefly. No-op if it's
// not currently loaded (older history the user hasn't paged in).
const jumpToMessage = useCallback((id: string) => {
const node = listRef.current?.querySelector<HTMLElement>(
`[data-mid="${CSS.escape(id)}"]`,
);
if (!node) return;
stickToBottomRef.current = false;
node.scrollIntoView({ block: "center", behavior: "smooth" });
setFlashId(id);
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
flashTimerRef.current = setTimeout(() => setFlashId(null), 1_200);
}, []);
const handleSend = useCallback(() => {
const body = draft.trim();
if (!body) return;
sendChatMessage(body);
sendChatMessage(body, replyTarget?.id);
setDraft("");
setReplyTarget(null);
stopTyping();
stickToBottomRef.current = true;
}, [draft, sendChatMessage]);
}, [draft, replyTarget, sendChatMessage, stopTyping]);
// Build the rendered stream, inserting a day separator whenever the calendar
// day changes between consecutive messages.
const rows = useMemo(() => {
const out: Array<
{ kind: "sep"; key: string; label: string } | {
kind: "msg";
key: string;
message: ChatMessage;
}
> = [];
let prev: Date | null = null;
for (const m of messages) {
if (!prev || !isSameDay(prev, m.createdAt)) {
out.push({
kind: "sep",
key: `sep-${m.id}`,
label: dayLabel(m.createdAt),
});
}
out.push({ kind: "msg", key: m.id, message: m });
prev = m.createdAt;
}
return out;
}, [messages]);
const typingLabel = useMemo(() => {
const names = typingOthers.map((u) => u.username);
if (names.length === 1) return t`${names[0]} is typing…`;
if (names.length === 2) return t`${names[0]} and ${names[1]} are typing…`;
return t`Several people are typing…`;
}, [typingOthers]);
return (
<Modal title={t`Chat`} onClose={onClose} wide>
@@ -329,24 +554,81 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
</p>
)
: (
messages.map((m) => (
rows.map((row) =>
row.kind === "sep"
? (
<div key={row.key} className="chat-day-sep">
<span className="chat-day-sep-label">{row.label}</span>
</div>
)
: (
<ChatMessageItem
key={m.id}
message={m}
key={row.key}
message={row.message}
currentUser={user}
canModerate={canModerate}
flashed={flashId === row.message.id}
onEdit={editChatMessage}
onDelete={deleteChatMessage}
onReply={startReply}
onJumpTo={jumpToMessage}
/>
))
)
)
)}
</div>
{typingOthers.length > 0 && (
<div className="chat-typing" aria-live="polite">
<span className="chat-typing-avatars">
{typingOthers.slice(0, 3).map((u) => (
<span key={u.userId} className="chat-typing-avatar">
<Avatar
userId={u.userId}
username={u.username}
hasAvatar={u.hasAvatar}
size={20}
version={u.avatarVersion}
/>
</span>
))}
</span>
<span className="chat-typing-label">{typingLabel}</span>
<span className="chat-typing-dots" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</span>
</div>
)}
{replyTarget && (
<div className="chat-reply-bar">
<span className="chat-reply-bar-mark"></span>
<span className="chat-reply-bar-text">
<Trans>Replying to</Trans>{" "}
<span className="chat-reply-bar-author">
{replyTarget.author}
</span>
<span className="chat-reply-bar-snippet">{replyTarget.body}</span>
</span>
<button
type="button"
className="chat-icon-btn chat-reply-bar-cancel"
onClick={() => setReplyTarget(null)}
title={t`Cancel reply`}
aria-label={t`Cancel reply`}
>
</button>
</div>
)}
<div className="chat-composer">
<TextEditor
ref={composerRef}
value={draft}
onChange={setDraft}
onChange={handleDraftChange}
placeholder={t`Type a message…`}
rows={2}
autoResize
@@ -357,6 +639,10 @@ export function ChatModal({ onClose }: { onClose: () => void }) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
} else if (e.key === "Escape" && replyTarget) {
// Cancel the reply first; a second Escape closes the modal.
e.preventDefault();
setReplyTarget(null);
}
}}
/>

View File

@@ -65,6 +65,9 @@ export interface WSContextValue {
* filter them out. */
deletedChatIds: Set<string>;
unreadChatCount: number;
/** Users currently composing a chat message (excludes nobody — the chatbox
* filters out the current user itself). Shaped like presence for avatars. */
typingUsers: OnlineUser[];
/** Increments on each WS reconnect so pages can backfill missed events. */
connectionEpoch: number;
castVote: (dumpId: string) => void;
@@ -73,10 +76,13 @@ export interface WSContextValue {
removeCommentLike: (commentId: string) => void;
injectDump: (dump: Dump) => void;
clearUnreadNotifications: () => void;
sendChatMessage: (body: string) => void;
sendChatMessage: (body: string, replyToId?: string) => void;
editChatMessage: (id: string, body: string) => void;
deleteChatMessage: (id: string) => void;
clearUnreadChat: () => void;
/** Inform the server whether the user is currently composing a message, so it
* can show/hide the "is typing" indicator to other clients. */
setChatTyping: (typing: boolean) => void;
/** Inform the server whether the chatbox is open (suppresses redundant
* mention notifications while the user is reading chat). */
setChatOpen: (open: boolean) => void;
@@ -104,6 +110,7 @@ export const WSContext = createContext<WSContextValue>({
chatMessages: [],
deletedChatIds: new Set(),
unreadChatCount: 0,
typingUsers: [],
connectionEpoch: 0,
castVote: () => {},
removeVote: () => {},
@@ -115,5 +122,6 @@ export const WSContext = createContext<WSContextValue>({
editChatMessage: () => {},
deleteChatMessage: () => {},
clearUnreadChat: () => {},
setChatTyping: () => {},
setChatOpen: () => {},
});

View File

@@ -119,6 +119,7 @@ export function WSProvider({ children }: WSProviderProps) {
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [deletedChatIds, setDeletedChatIds] = useState<Set<string>>(new Set());
const [unreadChatCount, setUnreadChatCount] = useState(0);
const [typingUsers, setTypingUsers] = useState<OnlineUser[]>([]);
// Live avatar overrides for site-wide Avatar refresh (own narrow context).
const [avatarOverrides, setAvatarOverrides] = useState<
Record<string, AvatarOverride>
@@ -156,6 +157,7 @@ export function WSProvider({ children }: WSProviderProps) {
setChatMessages([]);
setDeletedChatIds(new Set());
setUnreadChatCount(0);
setTypingUsers([]);
setAvatarOverrides({});
}
@@ -529,6 +531,10 @@ export function WSProvider({ children }: WSProviderProps) {
break;
}
case "chat_typing_update":
setTypingUsers(msg.users);
break;
case "force_logout":
onForceLogoutRef.current();
break;
@@ -759,13 +765,17 @@ export function WSProvider({ children }: WSProviderProps) {
setUnreadNotificationCount(0);
}, []);
const sendChatMessage = useCallback((body: string) => {
const sendChatMessage = useCallback((body: string, replyToId?: string) => {
const trimmed = body.trim();
if (!trimmed) return;
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_send", body: trimmed } satisfies OutgoingWSMessage,
{
type: "chat_send",
body: trimmed,
...(replyToId ? { replyToId } : {}),
} satisfies OutgoingWSMessage,
),
);
}
@@ -797,6 +807,16 @@ export function WSProvider({ children }: WSProviderProps) {
setUnreadChatCount(0);
}, []);
const setChatTyping = useCallback((typing: boolean) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify(
{ type: "chat_typing", typing } satisfies OutgoingWSMessage,
),
);
}
}, []);
const setChatOpen = useCallback((open: boolean) => {
chatOpenRef.current = open;
if (socketRef.current?.readyState === WebSocket.OPEN) {
@@ -830,6 +850,7 @@ export function WSProvider({ children }: WSProviderProps) {
chatMessages,
deletedChatIds,
unreadChatCount,
typingUsers,
connectionEpoch,
castVote,
removeVote,
@@ -841,6 +862,7 @@ export function WSProvider({ children }: WSProviderProps) {
editChatMessage,
deleteChatMessage,
clearUnreadChat,
setChatTyping,
setChatOpen,
}), [
wsStatus,
@@ -864,6 +886,7 @@ export function WSProvider({ children }: WSProviderProps) {
chatMessages,
deletedChatIds,
unreadChatCount,
typingUsers,
connectionEpoch,
castVote,
removeVote,
@@ -875,6 +898,7 @@ export function WSProvider({ children }: WSProviderProps) {
editChatMessage,
deleteChatMessage,
clearUnreadChat,
setChatTyping,
setChatOpen,
]);

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,17 @@ msgstr "{0, plural, one {# comment} other {# comments}}"
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}"
#. placeholder {0}: names[0]
#. placeholder {1}: names[1]
#: src/components/ChatModal.tsx:528
msgid "{0} and {1} are typing…"
msgstr "{0} and {1} are typing…"
#. placeholder {0}: names[0]
#: src/components/ChatModal.tsx:527
msgid "{0} is typing…"
msgstr "{0} is typing…"
#. placeholder {0}: VALIDATION.USERNAME_MIN
#. placeholder {1}: VALIDATION.USERNAME_MAX
#: src/pages/UserRegister.tsx:126
@@ -180,14 +191,14 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Back to login"
#: src/contexts/WSProvider.tsx:268
#: src/contexts/WSProvider.tsx:570
#: src/contexts/WSProvider.tsx:270
#: src/contexts/WSProvider.tsx:576
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
#: src/components/CategoryManager.tsx:210
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:158
#: src/components/ChatModal.tsx:229
#: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32
@@ -203,6 +214,11 @@ msgstr "Cancel"
msgid "Cancel removal"
msgstr "Cancel removal"
#: src/components/ChatModal.tsx:619
#: src/components/ChatModal.tsx:620
msgid "Cancel reply"
msgstr "Cancel reply"
#: src/components/AppHeader.tsx:66
msgid "Cancel search"
msgstr "Cancel search"
@@ -232,7 +248,7 @@ msgstr "Change password…"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:308
#: src/components/ChatModal.tsx:533
msgid "Chat"
msgstr "Chat"
@@ -311,9 +327,9 @@ msgstr "Default tab"
#: src/components/CategoryManager.tsx:221
#: src/components/CategoryManager.tsx:222
#: src/components/ChatModal.tsx:112
#: src/components/ChatModal.tsx:113
#: src/components/ChatModal.tsx:168
#: src/components/ChatModal.tsx:183
#: src/components/ChatModal.tsx:184
#: src/components/ChatModal.tsx:239
#: src/components/CommentThread.tsx:376
#: src/components/CommentThread.tsx:382
#: src/components/ConfirmModal.tsx:16
@@ -349,7 +365,7 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone."
#: src/components/ChatModal.tsx:167
#: src/components/ChatModal.tsx:238
msgid "Delete this message?"
msgstr "Delete this message?"
@@ -358,6 +374,10 @@ msgstr "Delete this message?"
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
#: src/components/ChatModal.tsx:129
msgid "deleted message"
msgstr "deleted message"
#: src/components/PlaylistCreateForm.tsx:78
#: src/pages/PlaylistDetail.tsx:877
msgid "Description (optional)"
@@ -403,8 +423,8 @@ msgstr "Dumps ({0}{1})"
msgid "Earlier"
msgstr "Earlier"
#: src/components/ChatModal.tsx:101
#: src/components/ChatModal.tsx:102
#: src/components/ChatModal.tsx:172
#: src/components/ChatModal.tsx:173
#: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:490
#: src/pages/PlaylistDetail.tsx:625
@@ -423,7 +443,7 @@ msgstr "Edit title"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:90
#: src/components/ChatModal.tsx:152
#: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:429
#: src/pages/PlaylistDetail.tsx:664
@@ -433,7 +453,7 @@ msgstr "edited {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:88
#: src/components/ChatModal.tsx:150
#: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:427
#: src/pages/PlaylistDetail.tsx:661
@@ -643,7 +663,7 @@ msgstr "Light"
msgid "Like"
msgstr "Like"
#: src/contexts/WSProvider.tsx:569
#: src/contexts/WSProvider.tsx:575
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -656,7 +676,7 @@ msgstr "Live updates unavailable."
msgid "Load more"
msgstr "Load more"
#: src/components/ChatModal.tsx:321
#: src/components/ChatModal.tsx:546
msgid "Load older messages"
msgstr "Load older messages"
@@ -686,7 +706,7 @@ msgid "Loading profile…"
msgstr "Loading profile…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:320
#: src/components/ChatModal.tsx:545
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192
@@ -805,7 +825,7 @@ msgstr "No followed playlists yet."
msgid "No invitees yet."
msgstr "No invitees yet."
#: src/components/ChatModal.tsx:328
#: src/components/ChatModal.tsx:553
msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!"
@@ -989,10 +1009,16 @@ msgstr "Remove vote"
msgid "Replace file"
msgstr "Replace file"
#: src/components/ChatModal.tsx:162
#: src/components/ChatModal.tsx:163
#: src/components/CommentThread.tsx:355
msgid "Reply"
msgstr "Reply"
#: src/components/ChatModal.tsx:609
msgid "Replying to"
msgstr "Replying to"
#: src/pages/UserLogin.tsx:132
msgid "Request failed"
msgstr "Request failed"
@@ -1019,7 +1045,7 @@ msgstr "Retry"
msgid "Role"
msgstr "Role"
#: src/components/ChatModal.tsx:151
#: src/components/ChatModal.tsx:222
#: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:368
#: src/pages/DumpEdit.tsx:466
@@ -1057,7 +1083,7 @@ msgstr "Search failed"
msgid "Searching…"
msgstr "Searching…"
#: src/components/ChatModal.tsx:369
#: src/components/ChatModal.tsx:655
msgid "Send"
msgstr "Send"
@@ -1082,6 +1108,10 @@ msgstr "Set new password"
msgid "Settings"
msgstr "Settings"
#: src/components/ChatModal.tsx:529
msgid "Several people are typing…"
msgstr "Several people are typing…"
#: src/components/CategoryManager.tsx:191
msgid "slug"
msgstr "slug"
@@ -1133,11 +1163,12 @@ msgstr "Title"
msgid "Title is required."
msgstr "Title is required."
#: src/components/ChatModal.tsx:262
#: src/pages/Notifications.tsx:397
msgid "Today"
msgstr "Today"
#: src/components/ChatModal.tsx:350
#: src/components/ChatModal.tsx:632
msgid "Type a message…"
msgstr "Type a message…"
@@ -1240,6 +1271,7 @@ msgstr "Why?"
msgid "Write a reply…"
msgstr "Write a reply…"
#: src/components/ChatModal.tsx:265
#: src/pages/Notifications.tsx:399
msgid "Yesterday"
msgstr "Yesterday"

File diff suppressed because one or more lines are too long

View File

@@ -28,6 +28,17 @@ msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}"
#. placeholder {0}: names[0]
#. placeholder {1}: names[1]
#: src/components/ChatModal.tsx:528
msgid "{0} and {1} are typing…"
msgstr "{0} et {1} sont en train d'écrire…"
#. placeholder {0}: names[0]
#: src/components/ChatModal.tsx:527
msgid "{0} is typing…"
msgstr "{0} est en train d'écrire…"
#. placeholder {0}: VALIDATION.USERNAME_MIN
#. placeholder {1}: VALIDATION.USERNAME_MAX
#: src/pages/UserRegister.tsx:126
@@ -180,14 +191,14 @@ msgstr "Auto"
msgid "Back to login"
msgstr "Retour à la connexion"
#: src/contexts/WSProvider.tsx:268
#: src/contexts/WSProvider.tsx:570
#: src/contexts/WSProvider.tsx:270
#: src/contexts/WSProvider.tsx:576
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
#: src/components/CategoryManager.tsx:210
#: src/components/CategoryManager.tsx:211
#: src/components/ChatModal.tsx:158
#: src/components/ChatModal.tsx:229
#: src/components/CommentThread.tsx:124
#: src/components/ConfirmModal.tsx:32
#: src/components/form/FormActions.tsx:32
@@ -203,6 +214,11 @@ msgstr "Annuler"
msgid "Cancel removal"
msgstr "Annuler la suppression"
#: src/components/ChatModal.tsx:619
#: src/components/ChatModal.tsx:620
msgid "Cancel reply"
msgstr "Annuler la réponse"
#: src/components/AppHeader.tsx:66
msgid "Cancel search"
msgstr "Annuler la recherche"
@@ -232,7 +248,7 @@ msgstr "Changer le mot de passe…"
#: src/components/ChatButton.tsx:17
#: src/components/ChatFab.tsx:49
#: src/components/ChatFab.tsx:50
#: src/components/ChatModal.tsx:308
#: src/components/ChatModal.tsx:533
msgid "Chat"
msgstr "Tribune"
@@ -311,9 +327,9 @@ msgstr "Onglet par défaut"
#: src/components/CategoryManager.tsx:221
#: src/components/CategoryManager.tsx:222
#: src/components/ChatModal.tsx:112
#: src/components/ChatModal.tsx:113
#: src/components/ChatModal.tsx:168
#: src/components/ChatModal.tsx:183
#: src/components/ChatModal.tsx:184
#: src/components/ChatModal.tsx:239
#: src/components/CommentThread.tsx:376
#: src/components/CommentThread.tsx:382
#: src/components/ConfirmModal.tsx:16
@@ -349,7 +365,7 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/components/ChatModal.tsx:167
#: src/components/ChatModal.tsx:238
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
@@ -358,6 +374,10 @@ msgstr "Supprimer ce message ?"
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
#: src/components/ChatModal.tsx:129
msgid "deleted message"
msgstr "message supprimé"
#: src/components/PlaylistCreateForm.tsx:78
#: src/pages/PlaylistDetail.tsx:877
msgid "Description (optional)"
@@ -403,8 +423,8 @@ msgstr "Recos ({0}{1})"
msgid "Earlier"
msgstr "Plus tôt"
#: src/components/ChatModal.tsx:101
#: src/components/ChatModal.tsx:102
#: src/components/ChatModal.tsx:172
#: src/components/ChatModal.tsx:173
#: src/components/CommentThread.tsx:367
#: src/pages/Dump.tsx:490
#: src/pages/PlaylistDetail.tsx:625
@@ -423,7 +443,7 @@ msgstr "Modifier le titre"
#. placeholder {0}: relativeTime(comment.updatedAt)
#. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/ChatModal.tsx:90
#: src/components/ChatModal.tsx:152
#: src/components/CommentThread.tsx:317
#: src/pages/Dump.tsx:429
#: src/pages/PlaylistDetail.tsx:664
@@ -433,7 +453,7 @@ msgstr "modifié {0}"
#. placeholder {0}: comment.updatedAt.toLocaleString()
#. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/ChatModal.tsx:88
#: src/components/ChatModal.tsx:150
#: src/components/CommentThread.tsx:315
#: src/pages/Dump.tsx:427
#: src/pages/PlaylistDetail.tsx:661
@@ -643,7 +663,7 @@ msgstr "Clair"
msgid "Like"
msgstr "Aimer"
#: src/contexts/WSProvider.tsx:569
#: src/contexts/WSProvider.tsx:575
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -656,7 +676,7 @@ msgstr "Mises à jour en direct indisponibles."
msgid "Load more"
msgstr "Charger plus"
#: src/components/ChatModal.tsx:321
#: src/components/ChatModal.tsx:546
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
@@ -686,7 +706,7 @@ msgid "Loading profile…"
msgstr "Chargement du profil…"
#: src/components/CategoryManager.tsx:52
#: src/components/ChatModal.tsx:320
#: src/components/ChatModal.tsx:545
#: src/components/PlaylistMembershipPanel.tsx:28
#: src/components/TextEditor.tsx:289
#: src/components/UserListPopover.tsx:192
@@ -805,7 +825,7 @@ msgstr "Pas encore de collections suivies."
msgid "No invitees yet."
msgstr "Aucun invité pour le moment."
#: src/components/ChatModal.tsx:328
#: src/components/ChatModal.tsx:553
msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !"
@@ -989,10 +1009,16 @@ msgstr "Retirer le vote"
msgid "Replace file"
msgstr "Remplacer le fichier"
#: src/components/ChatModal.tsx:162
#: src/components/ChatModal.tsx:163
#: src/components/CommentThread.tsx:355
msgid "Reply"
msgstr "Répondre"
#: src/components/ChatModal.tsx:609
msgid "Replying to"
msgstr "En réponse à"
#: src/pages/UserLogin.tsx:132
msgid "Request failed"
msgstr "Échec de la demande"
@@ -1019,7 +1045,7 @@ msgstr "Réessayer"
msgid "Role"
msgstr "Rôle"
#: src/components/ChatModal.tsx:151
#: src/components/ChatModal.tsx:222
#: src/components/CommentThread.tsx:328
#: src/pages/Dump.tsx:368
#: src/pages/DumpEdit.tsx:466
@@ -1057,7 +1083,7 @@ msgstr "Recherche échouée"
msgid "Searching…"
msgstr "Recherche…"
#: src/components/ChatModal.tsx:369
#: src/components/ChatModal.tsx:655
msgid "Send"
msgstr "Envoyer"
@@ -1082,6 +1108,10 @@ msgstr "Définir un nouveau mot de passe"
msgid "Settings"
msgstr "Paramètres"
#: src/components/ChatModal.tsx:529
msgid "Several people are typing…"
msgstr "Plusieurs personnes écrivent…"
#: src/components/CategoryManager.tsx:191
msgid "slug"
msgstr "identifiant"
@@ -1133,11 +1163,12 @@ msgstr "Titre"
msgid "Title is required."
msgstr "Un titre est requis."
#: src/components/ChatModal.tsx:262
#: src/pages/Notifications.tsx:397
msgid "Today"
msgstr "Aujourd'hui"
#: src/components/ChatModal.tsx:350
#: src/components/ChatModal.tsx:632
msgid "Type a message…"
msgstr "Écrivez un message…"
@@ -1240,6 +1271,7 @@ msgstr "Pourquoi ?"
msgid "Write a reply…"
msgstr "Écrire une réponse…"
#: src/components/ChatModal.tsx:265
#: src/pages/Notifications.tsx:399
msgid "Yesterday"
msgstr "Hier"

View File

@@ -228,6 +228,13 @@ export interface ChatMessage {
updatedAt?: Date;
authorUsername: string;
authorAvatarMime?: string;
/** Id of the message this one replies to, if any. Kept even when the target
* was deleted (the preview fields below then go undefined). */
replyToId?: string;
/** Author/snippet of the replied-to message, for a subtle inline reference.
* Absent when there's no reply, or the target has since been deleted. */
replyToAuthor?: string;
replyToBody?: string;
}
export type RawChatMessage = WithStringDate<ChatMessage>;
@@ -537,6 +544,11 @@ export interface WSChatMessageDeletedMessage {
id: string;
}
export interface WSChatTypingUpdateMessage {
type: "chat_typing_update";
users: OnlineUser[];
}
export type IncomingWSMessage =
| WSPingMessage
| WSWelcomeMessage
@@ -561,7 +573,8 @@ export type IncomingWSMessage =
| WSForceLogoutMessage
| WSChatMessageMessage
| WSChatMessageUpdatedMessage
| WSChatMessageDeletedMessage;
| WSChatMessageDeletedMessage
| WSChatTypingUpdateMessage;
/**
* WebSocket messages — client → server (outgoing)
@@ -590,6 +603,12 @@ export interface WSCommentLikeRemoveMessage {
export interface WSChatSendMessage {
type: "chat_send";
body: string;
replyToId?: string;
}
export interface WSChatTypingMessage {
type: "chat_typing";
typing: boolean;
}
export interface WSChatFocusMessage {
@@ -615,6 +634,7 @@ export type OutgoingWSMessage =
| WSCommentLikeCastMessage
| WSCommentLikeRemoveMessage
| WSChatSendMessage
| WSChatTypingMessage
| WSChatFocusMessage
| WSChatEditMessage
| WSChatDeleteMessage;