/** * 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 { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode, type UIEvent, } from 'react' import { ArrowDown, Check, Copy, CornerUpLeft, Loader2, MessageSquare, MoreHorizontal, Pencil, Pin, RefreshCw, SmilePlus, Trash2, TriangleAlert, X, } from 'lucide-react' import { clockStamp, dayKey, dayLabel, fullStamp, isGrouped, isSystemMessage, MAX_MESSAGE_LEN, textLength, type Attachment, type ChatMessage, type PendingMessage, type ThreadBrief, } from '@/entities/chat' import type { TranscriptState } from '../model/transcript' import { AttachmentGrid, DayDivider, IconAction, Lightbox, MessageBody, Popover, UserAvatar, } from './parts' import { EmojiPicker } from './emoji-picker' import { systemLine } from './system-lines' import { useT } from '@/shared/i18n' /** 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 (