/** * The message stream: grouping, date and unread dividers, live rows, optimistic * rows, per-message actions, scroll anchoring, and the two navigation * affordances ("加载更早" upward, "N 条新消息 / 回到最新" downward). * * Scroll rules, because this is where chat UIs usually break: * - the viewport follows the tail **only** while it is pinned there, so a row * that arrives while you read history never yanks the page; * - prepending an older page keeps the pixel offset of what you were looking at * (height delta applied in a layout effect, i.e. before paint); * - "pinned to the tail" is also what the read cursor is derived from, so a * scrolled-back window does not mark the room as read. */ import { type Attachment, type ChatMessage, clockStamp, dayKey, dayLabel, fullStamp, isGrouped, isSystemMessage, MAX_MESSAGE_LEN, type PendingMessage, type ThreadBrief, textLength, } from '@app/domains/chat/model' import { useT } from '@app/i18n' import { IconArrowDown as ArrowDown, IconCheck as Check, IconCopy as Copy, IconCornerUpLeft as CornerUpLeft, IconLoader2 as Loader2, IconMessage as MessageSquare, IconDots as MoreHorizontal, IconPencil as Pencil, IconPin as Pin, IconRefresh as RefreshCw, IconMoodPlus as SmilePlus, IconTrash as Trash2, IconAlertTriangle as TriangleAlert, IconX as X, } from '@tabler/icons-react' import { type ReactNode, type UIEvent, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { TranscriptState } from '../model/transcript' import { EmojiPicker } from './emoji-picker' import { AttachmentGrid, DayDivider, IconAction, Lightbox, MessageBody, Popover, UserAvatar } from './parts' import { systemLine } from './system-lines' /** Distance from the bottom (px) that still counts as "reading the tail". */ const STICK_THRESHOLD = 96 /** Distance from the top that triggers a page fetch. */ // Scroll lookahead for the older-messages page: fires early enough that the // next page is usually merged before the reader reaches the top. const LOAD_OLDER_THRESHOLD = 320 export interface MessageInteractions { onReply: (message: ChatMessage) => void onOpenThread: (message: ChatMessage) => void onEdit: (message: ChatMessage) => void onDelete: (message: ChatMessage) => void onTogglePin: (message: ChatMessage) => void onReact: (message: ChatMessage, emoji: string, on: boolean) => void onJump: (messageId: string) => void /** Own post, or a channel manager: pin and delete are allowed. */ canModerate: (message: ChatMessage) => boolean /** False for a read-only room: no reply/thread composer at all. */ canParticipate: boolean threadOf: (messageId: string) => ThreadBrief | undefined nameOf: (userId: number | null | undefined) => string } export interface TranscriptProps { state: TranscriptState viewerId?: number lastReadSeq: number missedCount: number listRef: React.RefObject interactions: MessageInteractions editingId: string | null onSaveEdit: (messageId: string, content: string) => Promise onCancelEdit: () => void onLoadOlder: () => void onRetrySeed: () => void onAtBottom: (atBottom: boolean) => void /** Back to the newest page; replaces the window when the reader jumped away. */ onReturnToLatest: () => void atBottom: boolean retryPending: (localId: string) => void dismissPending: (localId: string) => void typingNames: string[] roomLabel: string customEmojis?: Parameters[0]['custom'] spaceName?: string } export function Transcript({ state, viewerId, lastReadSeq, missedCount, listRef, interactions, editingId, onSaveEdit, onCancelEdit, onLoadOlder, onRetrySeed, onAtBottom, onReturnToLatest, atBottom, retryPending, dismissPending, typingNames, roomLabel, customEmojis, spaceName, }: TranscriptProps) { const { t, tc } = useT() const anchorHeight = useRef(null) const [preview, setPreview] = useState(null) const [flash, setFlash] = useState(null) // Rows are addressed through their `data-message-id` attribute instead of a // ref map: a callback ref would be re-created (and re-attached) on every render // just to look a row up once in a while. const findRow = useCallback( (id: string) => listRef.current?.querySelector(`[data-message-id="${id.replace(/[^\w-]/g, '')}"]`) ?? null, [listRef], ) const handleScroll = useCallback( (event: UIEvent) => { const node = event.currentTarget const distance = node.scrollHeight - node.scrollTop - node.clientHeight onAtBottom(distance <= STICK_THRESHOLD) if (node.scrollTop < LOAD_OLDER_THRESHOLD && state.hasOlder && !state.loadingOlder) { anchorHeight.current = node.scrollHeight onLoadOlder() } }, [onAtBottom, onLoadOlder, state.hasOlder, state.loadingOlder], ) // Keep the reader's position when an older page is inserted above them. useLayoutEffect(() => { const target = anchorHeight.current if (target === null) return anchorHeight.current = null const node = listRef.current if (!node) return const delta = node.scrollHeight - target if (delta > 0) node.scrollTop += delta }, [listRef, state.rows]) // …and bring a jumped-to row into view, flashing it so it is easy to spot. useEffect(() => { const id = state.highlightId if (!id) return const frame = requestAnimationFrame(() => { const node = findRow(id) if (!node) return node.scrollIntoView({ block: 'center', behavior: 'smooth' }) setFlash(id) }) return () => cancelAnimationFrame(frame) }, [findRow, state.highlightId, state.rows]) useEffect(() => { if (!flash) return const timer = setTimeout(() => setFlash(null), 2_400) return () => clearTimeout(timer) }, [flash]) if (state.accessRevoked) { return ( } title={t('chat.transcript.accessDenied')} body={t('chat.transcript.accessDeniedHint')} /> ) } // The divider sits on the first row the viewer has not seen yet. const unreadIndex = state.rows.findIndex((row) => row.seq > lastReadSeq) const atError = state.error !== null && state.rows.length === 0 return (
{state.loadingOlder ? (

{t('chat.transcript.loadingOlder')}

) : state.hasOlder ? null : state.rows.length > 0 ? (

{state.threadId ? t('chat.transcript.threadStart') : t('chat.transcript.start')}

) : null} {state.rows.map((message, index) => { const previous = state.rows[index - 1] const system = isSystemMessage(message) const grouped = !system && !previous?.deleted_at && isGrouped(previous, message) const newDay = !previous || dayKey(previous.created_at) !== dayKey(message.created_at) return (
{newDay ? : null} {index === unreadIndex ? : null} {system ? ( ) : ( )}
) })} {state.pending.map((item) => (
))} {state.loadingSeed && state.rows.length === 0 ? : null} {atError ? ( } title={t('chat.transcript.loadFailed')} body={state.error ?? ''} action={{ label: t('common.action.retry'), onClick: onRetrySeed }} retry /> ) : null} {state.seeded && state.rows.length === 0 && state.pending.length === 0 && !state.loadingSeed && !state.error ? ( } title={ state.threadId ? t('chat.transcript.threadEmptyTitle') : t('chat.transcript.roomEmptyTitle', { room: roomLabel }) } body={state.threadId ? t('chat.transcript.threadEmptyBody') : t('chat.transcript.roomEmptyBody')} /> ) : null} {typingNames.length > 0 ? : null}
{/* Away from the tail for any reason — a jump, a scroll-up, new traffic — gets the same way back, and the pill counts only what really arrived. */} {!atBottom ? ( ) : null} setPreview(null)} />
) } function UnreadMarker({ count }: { count: number }) { const { t } = useT() return (
{count > 0 ? t('chat.unread.count', { count }) : t('chat.unread.label')}
) } function SystemRow({ message, nameOf, }: { message: ChatMessage nameOf: (userId: number | null | undefined) => string }) { return (
{clockStamp(message.created_at)} {systemLine(message, nameOf)}
) } function MessageRow({ message, grouped, viewerId, state, interactions, editing, onSaveEdit, onCancelEdit, onPreview, flashed, customEmojis, spaceName, }: { message: ChatMessage grouped: boolean viewerId?: number state: TranscriptState interactions: MessageInteractions editing: boolean onSaveEdit: (messageId: string, content: string) => Promise onCancelEdit: () => void onPreview: (attachment: Attachment) => void flashed: boolean customEmojis?: Parameters[0]['custom'] spaceName?: string }) { const { t } = useT() const mine = Boolean(viewerId && message.author_id === viewerId) const replyId = message.reply_to_id ?? null const replyRow = replyId ? state.rows.find((row) => row.id === replyId) : undefined const replyRef = replyId ? state.refs[replyId] : undefined const replyMessage = replyRow ?? (replyRef?.state === 'ready' ? replyRef.message : undefined) const thread = interactions.threadOf(message.id) const deleted = Boolean(message.deleted_at) return (
{grouped ? (
) } function ReplyQuote({ message, nameOf, onJump, }: { message: ChatMessage nameOf: (userId: number | null | undefined) => string onJump: () => void }) { const { t } = useT() return ( ) } function MessageToolbar({ message, mine, canModerate, canParticipate, interactions, customEmojis, spaceName, }: { message: ChatMessage mine: boolean canModerate: boolean canParticipate: boolean interactions: MessageInteractions customEmojis?: Parameters[0]['custom'] spaceName?: string }) { const { t } = useT() const [picker, setPicker] = useState(false) const [menu, setMenu] = useState(false) const [copied, setCopied] = useState(false) const reacted = (message.reactions ?? []).filter((reaction) => reaction.me).map((r) => r.emoji) const toggle = (emoji: string) => { const on = !reacted.includes(emoji) interactions.onReact(message, emoji, on) } return (
setPicker((value) => !value)} active={picker}> setPicker(false)} align="end" className="w-80"> { toggle(emoji) setPicker(false) }} />
{canParticipate ? ( <> interactions.onOpenThread(message)}> interactions.onReply(message)}> ) : null} { void navigator.clipboard?.writeText(message.content).then( () => setCopied(true), () => setCopied(false), ) }} > {copied ? : }
setMenu((value) => !value)} active={menu}> setMenu(false)} align="end" className="w-40 py-1"> {mine ? ( } label={t('common.action.edit')} onClick={() => { setMenu(false) interactions.onEdit(message) }} /> ) : null} } label={message.pinned ? t('chat.message.unpin') : t('chat.message.pin')} disabled={!canModerate} onClick={() => { setMenu(false) interactions.onTogglePin(message) }} /> {mine || canModerate ? ( } label={t('common.action.delete')} danger onClick={() => { setMenu(false) interactions.onDelete(message) }} /> ) : null}
) } function MenuItem({ icon, label, onClick, danger = false, disabled = false, }: { icon: ReactNode label: string onClick: () => void danger?: boolean disabled?: boolean }) { return ( ) } function PendingRow({ item, viewerId, onPreview, onRetry, onDismiss, }: { item: PendingMessage viewerId?: number onPreview: (attachment: Attachment) => void onRetry: (localId: string) => void onDismiss: (localId: string) => void }) { const { t } = useT() const failed = item.state === 'failed' return (
{t('chat.transcript.mine')} {clockStamp(item.created_at)} {failed ? t('chat.transcript.unsent') : t('chat.transcript.sending')}

{item.content}

{item.attachments.length > 0 ? : null} {failed ? (
{item.error ?? t('chat.transcript.sendFailed')}
) : null}
) } function InlineEditor({ initial, onSave, onCancel, }: { initial: string onSave: (content: string) => Promise onCancel: () => void }) { const { t } = useT() const [value, setValue] = useState(initial) const [busy, setBusy] = useState(false) const over = textLength(value) > MAX_MESSAGE_LEN const save = () => { if (busy || over || !value.trim()) return setBusy(true) void Promise.resolve(onSave(value)).finally(() => setBusy(false)) } return (