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

@@ -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);
}
}}
/>