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(editing); const [editDraft, setEditDraft] = useState(message.body); const [confirmDelete, setConfirmDelete] = useState(false); const editorRef = useRef(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 (
{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. )}
{message.authorUsername} {message.updatedAt && ( edited {relativeTime(message.updatedAt)} )} {!editing && ( {canEdit && ( )} {canDelete && ( )} )}
{editing ? (
{ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); saveEdit(); } else if (e.key === "Escape") { e.preventDefault(); setEditing(false); } }} />
) : {message.body}} {confirmDelete && ( { setConfirmDelete(false); onDelete(message.id); }} onCancel={() => setConfirmDelete(false)} /> )}
); } 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([]); const [hasMore, setHasMore] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); const [draft, setDraft] = useState(""); const [replyTarget, setReplyTarget] = useState(null); const [flashId, setFlashId] = useState(null); const listRef = useRef(null); const composerRef = useRef(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 | null>(null); const lastTypingSentRef = useRef(0); const flashTimerRef = useRef | 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(); 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( `[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 (
{hasMore && (
)} {messages.length === 0 ? (

No messages yet. Say hello!

) : ( rows.map((row) => row.kind === "sep" ? (
{row.label}
) : ( ) ) )}
{typingOthers.length > 0 && (
{typingOthers.slice(0, 3).map((u) => ( ))} {typingLabel}
)} {replyTarget && (
↩ Replying to{" "} {replyTarget.author} {replyTarget.body}
)}
{ 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); } }} />
); }