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

@@ -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) => (
<ChatMessageItem
key={m.id}
message={m}
currentUser={user}
canModerate={canModerate}
onEdit={editChatMessage}
onDelete={deleteChatMessage}
/>
))
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={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;