759ae5e
/**
* 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<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>
)
}