Files
gerbeur/src/components/ChatModal.tsx
khannurien 8c3efe6c30
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s
v3: inline edit forms and the narrow-viewport search row ease open instead of popping in
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:26:18 +00:00

667 lines
22 KiB
TypeScript

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";
import { Modal } from "./Modal.tsx";
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
import { Markdown } from "./Markdown.tsx";
import { Avatar } from "./Avatar.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx";
import { Tooltip } from "./Tooltip.tsx";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
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,
deserializeChatMessage,
type PublicUser,
type RawChatMessage,
} from "../model.ts";
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
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,
flashed,
onEdit,
onDelete,
onReply,
onJumpTo,
}: ChatMessageItemProps,
) {
const [editing, setEditing] = useState(false);
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
const [editDraft, setEditDraft] = useState(message.body);
const [confirmDelete, setConfirmDelete] = useState(false);
const editorRef = useRef<TextEditorHandle>(null);
// Owners may edit their own messages; moderators may edit any. Deletion is a
// moderation action only (mirrors api/services/chat-service.ts).
const isOwn = !!currentUser && currentUser.id === message.userId;
const canEdit = isOwn || canModerate;
const canDelete = canModerate;
const openEdit = () => {
setEditDraft(message.body);
setEditing(true);
setTimeout(() => editorRef.current?.focus(), 0);
};
const saveEdit = () => {
const body = editDraft.trim();
if (!body) return;
onEdit(message.id, body);
setEditing(false);
};
return (
<div
className={`chat-message${flashed ? " chat-message--flash" : ""}`}
data-mid={message.id}
>
<Avatar
userId={message.userId}
username={message.authorUsername}
hasAvatar={!!message.authorAvatarMime}
size={32}
/>
<div className="chat-message-body" ref={bodyRef}>
{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}`}
className="chat-message-author"
>
{message.authorUsername}
</Link>
<Tooltip text={message.createdAt.toLocaleString()}>
<time
className="chat-message-time"
dateTime={message.createdAt.toISOString()}
>
{relativeTime(message.createdAt)}
</time>
</Tooltip>
{message.updatedAt && (
<Tooltip text={t`Edited ${message.updatedAt.toLocaleString()}`}>
<span className="chat-message-edited">
<Trans>edited {relativeTime(message.updatedAt)}</Trans>
</span>
</Tooltip>
)}
{!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"
className="chat-icon-btn"
onClick={openEdit}
title={t`Edit`}
aria-label={t`Edit`}
>
✎
</button>
)}
{canDelete && (
<button
type="button"
className="chat-icon-btn chat-icon-btn--delete"
onClick={() => setConfirmDelete(true)}
title={t`Delete`}
aria-label={t`Delete`}
>
🗑
</button>
)}
</span>
)}
</div>
{editing
? (
<div className="chat-edit animate-in">
<TextEditor
ref={editorRef}
value={editDraft}
onChange={setEditDraft}
rows={2}
autoResize
maxLength={MAX_CHAT_LENGTH}
className="comment-reply-textarea chat-input"
onSubmit={saveEdit}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
saveEdit();
} else if (e.key === "Escape") {
e.preventDefault();
setEditing(false);
}
}}
/>
<div className="chat-edit-actions">
<button
type="button"
className="btn-primary"
onClick={saveEdit}
disabled={!editDraft.trim()}
>
<Trans>Save</Trans>
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setEditing(false)}
>
<Trans>Cancel</Trans>
</button>
</div>
</div>
)
: <Markdown className="chat-message-text">{message.body}</Markdown>}
{confirmDelete && (
<ConfirmModal
message={t`Delete this message?`}
confirmLabel={t`Delete`}
onConfirm={() => {
setConfirmDelete(false);
onDelete(message.id);
}}
onCancel={() => setConfirmDelete(false)}
/>
)}
</div>
</div>
);
}
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();
const canModerate = can(user, "chat:moderate");
const [history, setHistory] = useState<ChatMessage[]>([]);
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(() => {
composerRef.current?.focus();
}, []);
// Merge fetched history with the live buffer, drop deleted ones, dedupe by id
// (live buffer wins so edits show), sort ascending.
const messages = useMemo(() => {
const byId = new Map<string, ChatMessage>();
for (const m of history) byId.set(m.id, m);
for (const m of chatMessages) byId.set(m.id, m);
return [...byId.values()]
.filter((m) => !deletedChatIds.has(m.id))
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}, [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;
(async () => {
try {
const res = await authFetch(`${API_URL}/api/chat?limit=${PAGE_SIZE}`);
const json = await res.json();
if (cancelled || !json.success) return;
const items = (json.data as RawChatMessage[]).map(deserializeChatMessage);
setHistory(items);
setHasMore(items.length === PAGE_SIZE);
} catch {
// Live buffer still renders; the user can retry by reopening.
}
})();
return () => {
cancelled = true;
};
}, [authFetch]);
// Clear the unread badge on open and as new messages arrive while open.
useEffect(() => {
clearUnreadChat();
}, [chatMessages, clearUnreadChat]);
// Tell the server the chatbox is open so it skips redundant mention
// notifications while the user is reading; clear the flag on close.
useEffect(() => {
setChatOpen(true);
return () => setChatOpen(false);
}, [setChatOpen]);
// Stop announcing "typing" when the chatbox closes, whatever state we're in.
useEffect(() => {
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) 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;
});
// `replyTarget` is a dependency because the reply bar inserts itself
// between the list and the composer, shrinking the list: when pinned we
// must re-anchor to the bottom so the last message isn't hidden behind it.
}, [messages, typingOthers.length, replyTarget]);
const handleScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
stickToBottomRef.current = distanceFromBottom < 80;
}, []);
const loadOlder = useCallback(async () => {
const oldest = messages[0];
if (!oldest || loadingOlder) return;
setLoadingOlder(true);
const el = listRef.current;
const prevHeight = el?.scrollHeight ?? 0;
try {
const res = await authFetch(
`${API_URL}/api/chat?limit=${PAGE_SIZE}&before=${
encodeURIComponent(oldest.createdAt.toISOString())
}`,
);
const json = await res.json();
if (!json.success) return;
const older = (json.data as RawChatMessage[]).map(deserializeChatMessage);
setHasMore(older.length === PAGE_SIZE);
if (older.length > 0) {
stickToBottomRef.current = false;
setHistory((prev) => [...older, ...prev]);
// Preserve scroll position after older messages are prepended.
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight - prevHeight;
});
}
} catch {
// ignore — user can retry
} finally {
setLoadingOlder(false);
}
}, [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, replyTarget?.id);
setDraft("");
setReplyTarget(null);
stopTyping();
stickToBottomRef.current = true;
}, [draft, replyTarget, sendChatMessage, stopTyping]);
// Build the rendered stream, inserting a day separator whenever the calendar
// day changes between consecutive messages.
const rows = useMemo(() => {
const out: Array<
{ kind: "sep"; key: string; label: string } | {
kind: "msg";
key: string;
message: ChatMessage;
}
> = [];
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>
<div className="chat">
<div className="chat-messages" ref={listRef} onScroll={handleScroll}>
{hasMore && (
<div className="chat-load-older">
<button
type="button"
className="btn-secondary"
onClick={loadOlder}
disabled={loadingOlder}
>
{loadingOlder
? <Trans>Loading…</Trans>
: <Trans>Load older messages</Trans>}
</button>
</div>
)}
{messages.length === 0
? (
<p className="chat-empty">
<Trans>No messages yet. Say hello!</Trans>
</p>
)
: (
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={handleDraftChange}
placeholder={t`Type a message…`}
rows={2}
autoResize
maxLength={MAX_CHAT_LENGTH}
className="comment-reply-textarea chat-input"
onSubmit={handleSend}
onKeyDown={(e) => {
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);
}
}}
/>
<button
type="button"
className="btn-primary chat-send-btn"
onClick={handleSend}
disabled={!draft.trim()}
>
<Trans>Send</Trans>
</button>
</div>
</div>
</Modal>
);
}