Skip to content
Clone
main
850 lines 29.3 KB
/**
* 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<HTMLDivElement | null>
interactions: MessageInteractions
editingId: string | null
onSaveEdit: (messageId: string, content: string) => Promise<boolean>
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<typeof EmojiPicker>[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<number | null>(null)
const [preview, setPreview] = useState<Attachment | null>(null)
const [flash, setFlash] = useState<string | null>(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<HTMLElement>(`[data-message-id="${id.replace(/[^\w-]/g, '')}"]`) ?? null,
[listRef],
)
const handleScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
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 (
<TranscriptNotice
icon={<TriangleAlert className="h-5 w-5" />}
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 (
<div className="chat-transcript relative flex min-h-0 flex-1 flex-col">
<div
ref={listRef}
onScroll={handleScroll}
aria-label={t('chat.transcript.aria')}
className="chat-message-list min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-3"
>
{state.loadingOlder ? (
<p className="py-2 text-center text-[11.5px] text-shell-ink-muted">
<Loader2 className="mr-1 inline h-3 w-3 animate-spin" />
{t('chat.transcript.loadingOlder')}
</p>
) : state.hasOlder ? null : state.rows.length > 0 ? (
<p className="chat-history-start">
{state.threadId ? t('chat.transcript.threadStart') : t('chat.transcript.start')}
</p>
) : 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 (
<div key={message.id} data-message-id={message.id}>
{newDay ? <DayDivider value={dayLabel(message.created_at)} /> : null}
{index === unreadIndex ? <UnreadMarker count={state.rows.length - index} /> : null}
{system ? (
<SystemRow message={message} nameOf={interactions.nameOf} />
) : (
<MessageRow
message={message}
grouped={grouped}
viewerId={viewerId}
state={state}
interactions={interactions}
editing={editingId === message.id}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
onPreview={setPreview}
flashed={flash === message.id}
customEmojis={customEmojis}
spaceName={spaceName}
/>
)}
</div>
)
})}
{state.pending.map((item) => (
<div key={item.localId} data-message-id={item.localId}>
<PendingRow
item={item}
viewerId={viewerId}
onPreview={setPreview}
onRetry={retryPending}
onDismiss={dismissPending}
/>
</div>
))}
{state.loadingSeed && state.rows.length === 0 ? <SkeletonRows /> : null}
{atError ? (
<TranscriptNotice
icon={<TriangleAlert className="h-5 w-5" />}
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 ? (
<TranscriptNotice
icon={<MessageSquare className="h-5 w-5" />}
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 ? <TypingRow names={typingNames} /> : null}
</div>
{/* 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 ? (
<button
type="button"
onClick={onReturnToLatest}
className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-full bg-shell-action px-3.5 py-1.5 text-[12.5px] font-semibold text-white shadow-lg transition-[filter] hover:brightness-95"
>
<ArrowDown className="h-3.5 w-3.5" />
{missedCount > 0 ? tc('chat.transcript.newMessages', missedCount) : t('chat.transcript.backToLatest')}
</button>
) : null}
<Lightbox attachment={preview} onClose={() => setPreview(null)} />
</div>
)
}
function UnreadMarker({ count }: { count: number }) {
const { t } = useT()
return (
<div className="chat-unread-marker flex items-center gap-3 px-3 py-1.5">
<div className="h-px flex-1 bg-shell-tile" />
<span className="shrink-0 rounded-full bg-shell-active px-2 py-px text-[11px] font-semibold text-shell-ink-muted">
{count > 0 ? t('chat.unread.count', { count }) : t('chat.unread.label')}
</span>
<div className="h-px flex-1 bg-shell-tile" />
</div>
)
}
function SystemRow({
message,
nameOf,
}: {
message: ChatMessage
nameOf: (userId: number | null | undefined) => string
}) {
return (
<div className="flex items-start gap-2 px-3 py-0.5 text-[12.5px] text-shell-ink-muted">
<span className="w-10 shrink-0 select-none pt-px text-right text-[10px]">{clockStamp(message.created_at)}</span>
<span className="min-w-0 break-words">{systemLine(message, nameOf)}</span>
</div>
)
}
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<boolean>
onCancelEdit: () => void
onPreview: (attachment: Attachment) => void
flashed: boolean
customEmojis?: Parameters<typeof EmojiPicker>[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 (
<div
className={`chat-message-row group relative flex gap-3 rounded-md px-3 transition-colors hover:bg-shell-board ${
grouped ? 'py-px' : 'pb-0.5 pt-2.5'
} ${grouped ? 'chat-message-grouped' : ''} ${flashed ? 'bg-shell-active ring-1 ring-inset ring-shell-tile' : ''}`}
>
{grouped ? (
<span className="w-10 shrink-0" aria-hidden="true" />
) : (
<UserAvatar user={message.author} id={message.author_id} size={40} />
)}
<div className="min-w-0 flex-1">
{!grouped ? (
<div className="flex items-baseline gap-2">
<span className="min-w-0 truncate text-[15px] font-semibold">
{message.author?.display_name || message.author?.username || interactions.nameOf(message.author_id)}
</span>
{message.author_type !== 'User' ? (
<span className="shrink-0 rounded bg-shell-tile px-1 text-[9.5px] font-bold uppercase text-shell-ink">
{message.author_type === 'Webhook' ? 'webhook' : 'bot'}
</span>
) : null}
<span className="shrink-0 text-[11px] text-shell-ink-muted" title={fullStamp(message.created_at)}>
{clockStamp(message.created_at)}
</span>
{message.pinned ? (
<span title={t('chat.transcript.pinned')} className="shrink-0 text-shell-ink-muted">
<Pin className="h-3 w-3" />
</span>
) : null}
</div>
) : null}
{replyMessage ? (
<ReplyQuote
message={replyMessage}
nameOf={interactions.nameOf}
onJump={() => replyId && interactions.onJump(replyId)}
/>
) : replyId && replyRef?.state === 'gone' ? (
<p className="text-[12px] text-shell-ink-muted">{t('chat.transcript.replyDeleted')}</p>
) : null}
{editing ? (
<InlineEditor
initial={message.content}
onCancel={onCancelEdit}
onSave={async (content) => {
const saved = await onSaveEdit(message.id, content)
if (saved) onCancelEdit()
return saved
}}
/>
) : deleted ? (
<p className="text-[14px] italic text-shell-ink-muted">{t('chat.transcript.thisDeleted')}</p>
) : (
<>
<MessageBody
message={message}
viewerId={viewerId}
suffix={
message.edited_at ? (
<span className="text-[10.5px] text-shell-ink-muted" title={fullStamp(message.edited_at)}>
{t('chat.message.edited')}
</span>
) : null
}
/>
{message.attachments.length > 0 ? (
<AttachmentGrid attachments={message.attachments} onOpen={onPreview} />
) : null}
</>
)}
{message.reactions && message.reactions.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1">
{message.reactions.map((reaction) => (
<button
key={reaction.emoji}
type="button"
onClick={() => interactions.onReact(message, reaction.emoji, !reaction.me)}
aria-pressed={reaction.me}
aria-label={
reaction.me
? t('chat.reaction.ariaMine', { emoji: reaction.emoji, count: reaction.count })
: t('chat.reaction.aria', { emoji: reaction.emoji, count: reaction.count })
}
title={t('chat.reaction.title', { count: reaction.count })}
className={`flex items-center gap-1 rounded-full border px-1.5 py-px text-[12px] transition-colors ${
reaction.me
? 'border-shell-tile bg-shell-active text-shell-ink'
: 'border-shell-hairline bg-shell-field text-shell-ink hover:border-shell-ink-muted'
}`}
>
<span className="leading-none">{reaction.emoji}</span>
<span className="font-semibold tabular-nums">{reaction.count}</span>
</button>
))}
</div>
) : null}
{thread && thread.reply_count > 0 ? (
<button
type="button"
onClick={() => interactions.onOpenThread(message)}
className="mt-1 flex max-w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-[12.5px] font-medium text-shell-ink underline-offset-2 transition-colors hover:bg-shell-field hover:underline"
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="shrink-0">{t('chat.thread.replies', { count: thread.reply_count })}</span>
{thread.last_reply_at ? (
<span className="min-w-0 truncate text-shell-ink-muted">
{t('chat.transcript.lastReply', { time: clockStamp(thread.last_reply_at) })}
</span>
) : null}
{thread.locked ? <span className="shrink-0 text-[11px]">· {t('chat.thread.locked')}</span> : null}
</button>
) : null}
</div>
{!editing && !deleted && !message.thread_id ? (
<MessageToolbar
message={message}
mine={mine}
canModerate={interactions.canModerate(message)}
canParticipate={interactions.canParticipate}
interactions={interactions}
customEmojis={customEmojis}
spaceName={spaceName}
/>
) : null}
</div>
)
}
function ReplyQuote({
message,
nameOf,
onJump,
}: {
message: ChatMessage
nameOf: (userId: number | null | undefined) => string
onJump: () => void
}) {
const { t } = useT()
return (
<button
type="button"
onClick={onJump}
title={t('chat.transcript.jumpToOriginal')}
className="mb-0.5 flex max-w-full items-center gap-1.5 text-[12px] text-shell-ink-muted transition-colors hover:text-shell-ink"
>
<CornerUpLeft className="h-3 w-3 shrink-0" />
<span className="shrink-0 font-medium">{nameOf(message.author_id)}</span>
<span className="min-w-0 truncate">
{message.deleted_at
? t('chat.transcript.deletedShort')
: message.content || t('chat.composer.attachmentPlaceholder')}
</span>
</button>
)
}
function MessageToolbar({
message,
mine,
canModerate,
canParticipate,
interactions,
customEmojis,
spaceName,
}: {
message: ChatMessage
mine: boolean
canModerate: boolean
canParticipate: boolean
interactions: MessageInteractions
customEmojis?: Parameters<typeof EmojiPicker>[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 (
<div className="chat-message-toolbar" role="group" aria-label={t('chat.transcript.actions')}>
<div className="relative">
<IconAction label={t('chat.transcript.react')} onClick={() => setPicker((value) => !value)} active={picker}>
<SmilePlus className="h-4 w-4" />
</IconAction>
<Popover open={picker} onClose={() => setPicker(false)} align="end" className="w-80">
<EmojiPicker
custom={customEmojis}
spaceName={spaceName}
onPick={(emoji) => {
toggle(emoji)
setPicker(false)
}}
/>
</Popover>
</div>
{canParticipate ? (
<>
<IconAction label={t('chat.transcript.replyInThread')} onClick={() => interactions.onOpenThread(message)}>
<MessageSquare className="h-4 w-4" />
</IconAction>
<IconAction label={t('chat.transcript.quoteReply')} onClick={() => interactions.onReply(message)}>
<CornerUpLeft className="h-4 w-4" />
</IconAction>
</>
) : null}
<IconAction
label={copied ? t('chat.transcript.copied') : t('chat.transcript.copyText')}
onClick={() => {
void navigator.clipboard?.writeText(message.content).then(
() => setCopied(true),
() => setCopied(false),
)
}}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</IconAction>
<div className="relative">
<IconAction label={t('chat.transcript.more')} onClick={() => setMenu((value) => !value)} active={menu}>
<MoreHorizontal className="h-4 w-4" />
</IconAction>
<Popover open={menu} onClose={() => setMenu(false)} align="end" className="w-40 py-1">
{mine ? (
<MenuItem
icon={<Pencil className="h-3.5 w-3.5" />}
label={t('common.action.edit')}
onClick={() => {
setMenu(false)
interactions.onEdit(message)
}}
/>
) : null}
<MenuItem
icon={<Pin className="h-3.5 w-3.5" />}
label={message.pinned ? t('chat.message.unpin') : t('chat.message.pin')}
disabled={!canModerate}
onClick={() => {
setMenu(false)
interactions.onTogglePin(message)
}}
/>
{mine || canModerate ? (
<MenuItem
icon={<Trash2 className="h-3.5 w-3.5" />}
label={t('common.action.delete')}
danger
onClick={() => {
setMenu(false)
interactions.onDelete(message)
}}
/>
) : null}
</Popover>
</div>
</div>
)
}
function MenuItem({
icon,
label,
onClick,
danger = false,
disabled = false,
}: {
icon: ReactNode
label: string
onClick: () => void
danger?: boolean
disabled?: boolean
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[12.5px] transition-colors hover:bg-shell-board disabled:pointer-events-none disabled:opacity-40 ${
danger ? 'text-[#e5484d]' : ''
}`}
>
{icon}
{label}
</button>
)
}
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 (
<div className="chat-pending-row flex gap-3 px-3 pb-0.5 pt-2.5">
<UserAvatar id={viewerId ?? 0} size={40} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-[15px] font-semibold">{t('chat.transcript.mine')}</span>
<span className="text-[11px] text-shell-ink-muted">{clockStamp(item.created_at)}</span>
<span
className={`shrink-0 rounded px-1 text-[10px] font-semibold ${
failed ? 'bg-[#e5484d]/12 text-[#e5484d]' : 'bg-shell-field text-shell-ink-muted'
}`}
>
{failed ? t('chat.transcript.unsent') : t('chat.transcript.sending')}
</span>
</div>
<p className="whitespace-pre-wrap break-words text-[15px] leading-[1.5]">{item.content}</p>
{item.attachments.length > 0 ? <AttachmentGrid attachments={item.attachments} onOpen={onPreview} /> : null}
{failed ? (
<div className="mt-1 flex flex-wrap items-center gap-2 text-[12px] text-[#e5484d]">
<span className="min-w-0 truncate">{item.error ?? t('chat.transcript.sendFailed')}</span>
<button
type="button"
onClick={() => onRetry(item.localId)}
className="flex shrink-0 items-center gap-1 rounded border border-shell-hairline px-1.5 py-px text-shell-ink hover:bg-shell-field"
>
<RefreshCw className="h-3 w-3" />
{t('common.action.retry')}
</button>
<button
type="button"
onClick={() => onDismiss(item.localId)}
className="flex shrink-0 items-center gap-1 rounded border border-shell-hairline px-1.5 py-px text-shell-ink-muted hover:bg-shell-field"
>
<X className="h-3 w-3" />
{t('common.action.dismiss')}
</button>
</div>
) : null}
</div>
</div>
)
}
function InlineEditor({
initial,
onSave,
onCancel,
}: {
initial: string
onSave: (content: string) => Promise<unknown>
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 (
<div className="mt-1">
<textarea
autoFocus
aria-label={t('chat.message.edit')}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) return
if (event.key === 'Escape') onCancel()
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
save()
}
}}
rows={Math.min(10, value.split('\n').length + 1)}
className="w-full resize-none rounded-md border-2 border-shell-ink bg-shell-surface p-2 text-[14px] outline-none"
/>
<div className="flex items-center gap-2 text-[11.5px] text-shell-ink-muted">
<button
type="button"
disabled={busy || over || !value.trim()}
onClick={save}
className="rounded bg-shell-action px-2.5 py-1 text-[12px] font-semibold text-white disabled:opacity-50"
>
{t('common.action.save')}
</button>
<button type="button" onClick={onCancel} className="rounded px-2 py-1 text-[12px] hover:bg-shell-field">
{t('common.action.cancel')}
</button>
<span>{t('chat.transcript.editHint')}</span>
{over ? (
<span className="text-[#e5484d]">
{t('chat.composer.tooLongBy', { count: textLength(value) - MAX_MESSAGE_LEN })}
</span>
) : null}
</div>
</div>
)
}
function TypingRow({ names }: { names: string[] }) {
const { t } = useT()
const label =
names.length === 1
? t('chat.transcript.typingOne', { name: names[0] })
: names.length === 2
? t('chat.transcript.typingTwo', { first: names[0], second: names[1] })
: t('chat.transcript.typingMany', { names: names.slice(0, 3).join(', ') })
return (
<div className="flex items-center gap-2 px-6 pb-1 pt-1 text-[12px] text-shell-ink-muted">
<span className="flex gap-0.5" aria-hidden>
{[0, 1, 2].map((index) => (
<span
key={index}
className="h-1.5 w-1.5 animate-bounce rounded-full bg-shell-ink-muted"
style={{ animationDelay: `${index * 120}ms` }}
/>
))}
</span>
<span className="truncate">{label}</span>
</div>
)
}
function SkeletonRows() {
return (
<div className="space-y-4 px-3 pt-4">
{[520, 360, 460, 300].map((width) => (
<div key={width} className="flex gap-3">
<span className="h-10 w-10 shrink-0 rounded-full bg-shell-tile" />
<div className="flex-1 space-y-1.5 pt-1">
<span className="block h-3 w-24 rounded-full bg-shell-active" />
<span className="block h-3 max-w-full rounded-full bg-shell-active" style={{ width: width }} />
</div>
</div>
))}
</div>
)
}
function TranscriptNotice({
icon,
title,
body,
action,
retry = false,
}: {
icon: ReactNode
title: string
body: string
action?: { label: string; onClick: () => void }
retry?: boolean
}) {
return (
<div
className="chat-transcript-notice flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-6 py-12 text-center"
role={retry ? 'status' : undefined}
>
<span className="chat-notice-icon">{icon}</span>
<p className="chat-notice-title">{title}</p>
<p className="chat-notice-description">{body}</p>
{action ? (
<button
type="button"
onClick={action.onClick}
className={`mt-1 rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-1.5 text-[12.5px] transition-colors hover:border-shell-ink ${
retry ? 'flex items-center gap-1.5' : ''
}`}
>
{retry ? <RefreshCw className="h-3.5 w-3.5" /> : null}
{action.label}
</button>
) : null}
</div>
)
}