Skip to content
Clone
eeff292
712 lines 24.1 KB
/**
* Render primitives shared by the transcript, the thread panel and the side
* panels. Nothing here talks to the API: the content renderer is the one piece
* with real logic, and it is deliberately React-node based — user text is never
* injected as HTML, so a crafted message cannot execute anything.
*/
import {
type Attachment,
attachmentKind,
attachmentUrl,
type ChatMessage,
clockStamp,
EVERYONE_TOKENS,
formatSize,
initialsOf,
type UserInfo,
} from '@app/domains/chat/model'
import { useT } from '@app/i18n'
import { useSpaLinkClick } from '@app/lib'
import {
IconDownload as Download,
IconFileText as FileText,
IconMaximize as Maximize2,
IconX as X,
} from '@tabler/icons-react'
import DOMPurify from 'dompurify'
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
const avatarPalette = ['#b77945', '#448675', '#b6638d', '#6871c9', '#b96369', '#4f8a90', '#9272bb']
/** Deterministic tile colour, so a person looks the same in every row. */
function colorFor(id: string): string {
let hash = 0
for (let i = 0; i < id.length; i += 1) hash = (hash * 31 + id.charCodeAt(i)) | 0
return avatarPalette[Math.abs(hash) % avatarPalette.length]
}
/**
* Avatar with no layout surprises: images keep their aspect ratio, and a missing
* or broken image degrades to the coloured initial instead of an empty box.
*/
export function UserAvatar({
user,
id,
size = 40,
className = '',
}: {
user?: Pick<UserInfo, 'avatar_url' | 'display_name' | 'username'> | null
id: number
size?: number
className?: string
}) {
// "this exact URL failed to load" is the state, so a new avatar needs no
// effect to clear a previous failure.
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const label = user?.display_name || user?.username || String(id)
const src = user?.avatar_url && failedUrl !== user.avatar_url ? user.avatar_url : null
if (src) {
return (
<img
src={src}
alt={label}
title={label}
loading="lazy"
onError={() => setFailedUrl(src)}
className={`shrink-0 rounded-full bg-shell-tile object-cover ${className}`}
/* Passing `className` hands the size to CSS (a resizable window cannot
use a fixed pixel avatar); `size` still covers every other caller. */
style={className ? undefined : { width: size, height: size }}
/>
)
}
return (
<span
title={label}
className={`grid shrink-0 place-content-center rounded-full font-semibold text-white ${className}`}
style={
className
? { backgroundColor: colorFor(String(id || 0)) }
: { width: size, height: size, backgroundColor: colorFor(String(id || 0)), fontSize: size * 0.4 }
}
>
{initialsOf(user)}
</span>
)
}
export function IconAction({
label,
onClick,
children,
active = false,
disabled = false,
className = '',
}: {
label: string
onClick?: () => void
children: ReactNode
active?: boolean
disabled?: boolean
className?: string
}) {
return (
<button
type="button"
title={label}
aria-label={label}
aria-pressed={active || undefined}
disabled={disabled}
onClick={onClick}
className={`chat-icon-action grid h-7 w-7 place-content-center rounded-md text-shell-ink-muted transition-colors hover:bg-shell-active hover:text-shell-ink disabled:opacity-40 ${
active ? 'bg-shell-active text-shell-ink' : ''
} ${className}`}
>
{children}
</button>
)
}
/**
* Small click-outside popover used by the reaction picker, the message menu and
* the mention list. Escape and any outside pointer press close it; it renders
* inline with viewport positioning so menus remain visible near screen edges.
*/
export function Popover({
open,
onClose,
align = 'start',
side = 'top',
className = '',
children,
}: {
open: boolean
onClose: () => void
align?: 'start' | 'end'
side?: 'top' | 'bottom'
className?: string
children: ReactNode
}) {
const host = useRef<HTMLDivElement | null>(null)
const [position, setPosition] = useState({ left: 0, top: 0 })
useLayoutEffect(() => {
if (!open) return
const place = () => {
const node = host.current
const anchor = node?.parentElement
if (!node || !anchor) return
const rect = anchor.getBoundingClientRect()
const width = node.offsetWidth
const height = node.offsetHeight
const below = rect.bottom + 8
const above = rect.top - height - 8
const preferred = side === 'bottom' ? below : above
const alternate = side === 'bottom' ? above : below
const top = preferred >= 12 && preferred + height <= window.innerHeight - 12 ? preferred : alternate
setPosition({
left: Math.max(12, Math.min(align === 'end' ? rect.right - width : rect.left, window.innerWidth - width - 12)),
top: Math.max(12, Math.min(top, window.innerHeight - height - 12)),
})
}
place()
window.addEventListener('resize', place)
window.addEventListener('scroll', place, true)
return () => {
window.removeEventListener('resize', place)
window.removeEventListener('scroll', place, true)
}
}, [open, align, side, children])
useEffect(() => {
if (!open) return
const dismiss = (event: MouseEvent | TouchEvent) => {
if (!host.current?.parentElement?.contains(event.target as Node)) onClose()
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
document.addEventListener('mousedown', dismiss)
document.addEventListener('touchstart', dismiss)
window.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', dismiss)
document.removeEventListener('touchstart', dismiss)
window.removeEventListener('keydown', onKey)
}
}, [open, onClose])
if (!open) return null
return (
<div
ref={host}
style={position}
className={`chat-popover fixed z-30 rounded-lg border border-shell-hairline bg-shell-surface p-1.5 shadow-lg ${className}`}
>
{children}
</div>
)
}
export function DayDivider({ value }: { value: string }) {
return (
<div className="chat-day-divider flex items-center gap-3 px-3 pb-1 pt-5">
<div className="h-px flex-1 bg-shell-hairline" />
<span className="shrink-0 text-[11px] font-bold text-shell-ink-muted">{value}</span>
<div className="h-px flex-1 bg-shell-hairline" />
</div>
)
}
/** The「未读」rule: everything under it is what the viewer has not seen yet. */
export function UnreadDivider({ count }: { count: number }) {
const { t } = useT()
return (
<div className="flex items-center gap-3 px-3 py-2">
<div className="h-px flex-1 bg-[#e5484d]/45" />
<span className="shrink-0 rounded-full bg-[#e5484d] px-2 py-px text-[10.5px] font-bold text-white">
{count > 0 ? t('chat.unread.count', { count }) : t('chat.unread.label')}
</span>
<div className="h-px flex-1 bg-[#e5484d]/45" />
</div>
)
}
/* ------------------------------------------------------------ rich content */
interface Token {
type: 'code' | 'text'
value: string
lang?: string
}
/** Split a message into fenced blocks and text; unterminated fences are treated
* as code, which is what a reader expects from a half-pasted snippet. */
function splitFences(content: string): Token[] {
const tokens: Token[] = []
const lines = content.split('\n')
let buffer: string[] = []
let code: string[] | null = null
let lang: string | undefined
const flushText = () => {
if (buffer.length > 0) tokens.push({ type: 'text', value: buffer.join('\n') })
buffer = []
}
for (const line of lines) {
const fence = /^```(.*)$/.exec(line.trim())
if (fence && code === null) {
flushText()
code = []
lang = fence[1]?.trim() || undefined
continue
}
if (fence && code !== null) {
tokens.push({ type: 'code', value: code.join('\n'), lang })
code = null
lang = undefined
continue
}
if (code !== null) code.push(line)
else buffer.push(line)
}
if (code !== null) tokens.push({ type: 'code', value: code.join('\n'), lang })
flushText()
return tokens
}
// 链接里允许成对括号(`.../Rust_(programming_language)`),所以右括号不直接排除,
// 由 trimUrlTail 按配对情况决定是否截断。
const INLINE = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(~~[^~\n]+~~)|(\bhttps?:\/\/[^\s<>'"]+)/g
/**
* 裁掉链接末尾不属于 URL 的标点。右括号只有在比左括号多的时候才算句尾
* (`(见 https://a/b)` 的 `)` 要去掉,`.../Rust_(x)` 的要留着)。
*/
function trimUrlTail(value: string): string {
let end = value.length
const counts = (text: string, open: string, close: string) => {
let depth = 0
for (const ch of text) {
if (ch === open) depth += 1
else if (ch === close) depth -= 1
}
return depth
}
while (end > 0) {
const ch = value[end - 1]
const head = value.slice(0, end)
const drop =
ch === ')' || ch === '\uff09'
? counts(head, '(', ')') + counts(head, '\uff08', '\uff09') < 0
: ch === ']' || ch === '\uff3d'
? counts(head, '[', ']') + counts(head, '\uff3b', '\uff3d') < 0
: ch === '}' || ch === '\uff5d'
? counts(head, '{', '}') + counts(head, '\uff5b', '\uff5d') < 0
: '.,;!?>\u3002\u3001\uff0c\uff01\uff1f\u300b\u300d\u300f\u3011\u3017'.includes(ch)
if (!drop) break
end -= 1
}
return value.slice(0, end)
}
function linkTarget(value: string): string {
return value.startsWith('http') ? value : `https://${value}`
}
/** Inline markdown + @mention chips. Returns plain strings where nothing matched. */
function renderInline(
text: string,
mentionIds: Set<number>,
isSelf: boolean,
embeds: Map<string, MessageEmbed> = new Map(),
): ReactNode[] {
const nodes: ReactNode[] = []
let last = 0
let key = 0
for (const match of text.matchAll(INLINE)) {
const index = match.index ?? 0
if (index > last) nodes.push(text.slice(last, index))
const value = match[0]
if (value.startsWith('`')) {
nodes.push(
<code
key={`i${(key += 1)}`}
className="rounded bg-shell-field px-1 py-px font-mono text-[0.9em] text-shell-ink"
>
{value.slice(1, -1)}
</code>,
)
} else if (value.startsWith('**')) {
nodes.push(
<strong key={`i${(key += 1)}`} className="font-semibold">
{value.slice(2, -2)}
</strong>,
)
} else if (value.startsWith('~~')) {
nodes.push(
<s key={`i${(key += 1)}`} className="text-shell-ink-muted">
{value.slice(2, -2)}
</s>,
)
} else if (value.startsWith('*')) {
nodes.push(<em key={`i${(key += 1)}`}>{value.slice(1, -1)}</em>)
} else if (/^https?:\/\//.test(value)) {
// 句尾标点不属于链接:留着会让 embed 匹配不上,也会让点开的地址 404。
const url = trimUrlTail(value)
const tail = value.slice(url.length)
const embed = embeds.get(url) ?? embeds.get(embedKey(url))
if (embed) nodes.push(<EmbedView key={`i${(key += 1)}`} embed={embed} />)
else nodes.push(<InlineLink key={`i${(key += 1)}`} url={url} />)
if (tail) nodes.push(tail)
} else {
nodes.push(value)
}
last = index + value.length
}
if (last < text.length) nodes.push(text.slice(last))
return mentionChips(nodes, mentionIds, isSelf)
}
/** Opening a link must not touch the SPA router: `window.open` in a new tab. */
function InlineLink({ url, children }: { url: string; children?: ReactNode }) {
return (
<button
type="button"
title={url}
onClick={() => window.open(linkTarget(url), '_blank', 'noopener,noreferrer')}
className="max-w-full truncate text-shell-ink underline decoration-shell-hairline underline-offset-2 hover:decoration-shell-ink-muted"
>
{children ?? url}
</button>
)
}
/* --------------------------------------------------------------- embeds */
/**
* 服务端 `lib/social` 生成的一条 embed。平台命中时带 `html`(iframe/video/
* audio/img/link card),未命中平台的普通链接只有 OpenGraph 字段。
*/
export type MessageEmbed = import('@app/services/api').MessageEmbed
const EMBED_TAGS = ['iframe', 'video', 'audio', 'source', 'img', 'figure', 'figcaption']
const EMBED_ATTRS = [
'allow',
'allowfullscreen',
'frameborder',
'referrerpolicy',
'sandbox',
'loading',
'controls',
'playsinline',
'preload',
'poster',
'autoplay',
'loop',
'muted',
'data-platform',
'data-category',
]
/** 服务端 HTML 同样属于不可信输入,注入前一律清洗。 */
function sanitizeEmbed(html: string): string {
return DOMPurify.sanitize(html, {
ADD_TAGS: EMBED_TAGS,
ADD_ATTR: EMBED_ATTRS,
FORBID_TAGS: ['script', 'style', 'object', 'embed', 'form', 'input', 'textarea', 'button', 'base', 'link'],
})
}
function embedHost(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return url
}
}
/** 句尾标点/尾斜杠不参与匹配:`...see https://youtu.be/x.` 也要能命中。 */
function embedKey(url: string): string {
return url.replace(/[.,;:!?]+$/, '').replace(/\/+$/, '')
}
/** 平台 embed:直接展示服务端生成的 HTML;普通链接退化为 OpenGraph 卡片。 */
export function EmbedView({ embed }: { embed: MessageEmbed }) {
const html = useMemo(() => (embed.html ? sanitizeEmbed(embed.html) : ''), [embed.html])
// embed HTML 里的链接同样不允许直接跳转:站内走 react-router,站外开新标签页。
const onClick = useSpaLinkClick()
if (html) {
return (
<span
className="chat-embed my-1.5 block min-w-0 max-w-full select-text"
data-embed-platform={embed.platform ?? ''}
data-embed-kind={embed.kind ?? ''}
onClick={onClick}
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
// 无 HTML 且 OpenGraph 字段全空时不能吞掉链接,退回普通链接展示。
if (!embed.title && !embed.description && !embed.image_url && !embed.site_name) {
return <InlineLink url={embed.url ?? ''} />
}
return (
<button
type="button"
onClick={() => window.open(linkTarget(embed.url ?? ''), '_blank', 'noopener,noreferrer')}
className="chat-embed-card my-1.5 flex w-full max-w-[480px] gap-2.5 overflow-hidden rounded-lg border border-shell-hairline bg-shell-surface text-left hover:border-shell-ink-muted"
>
{embed.image_url ? (
<img src={embed.image_url} alt="" loading="lazy" className="h-20 w-20 shrink-0 object-cover" />
) : null}
<span className="min-w-0 flex-1 p-2 pr-3">
<span className="block truncate text-[11px] font-semibold uppercase tracking-wide text-shell-ink-muted">
{embed.site_name || embedHost(embed.url ?? '')}
</span>
{embed.title ? (
<span className="mt-0.5 line-clamp-2 block text-[13px] font-semibold text-shell-ink">{embed.title}</span>
) : null}
{embed.description ? (
<span className="mt-0.5 line-clamp-2 block text-[12px] leading-[1.4] text-shell-ink-muted">
{embed.description}
</span>
) : null}
</span>
</button>
)
}
const MENTION = /@[\w.\-\u4e00-\u9fa5]+/g
/** Replace bare @tokens inside text nodes with highlighted chips. */
function mentionChips(nodes: ReactNode[], mentionIds: Set<number>, isSelf: boolean): ReactNode[] {
const out: ReactNode[] = []
let key = 0
for (const node of nodes) {
if (typeof node !== 'string') {
out.push(node)
continue
}
let last = 0
for (const match of node.matchAll(MENTION)) {
const index = match.index ?? 0
if (index > last) out.push(node.slice(last, index))
const token = match[0]
const everyone = EVERYONE_TOKENS.some((name) => token.toLowerCase() === name)
const hit = everyone ? isSelf : mentionIds.size > 0
out.push(
<span
key={`m${(key += 1)}`}
className={hit ? 'rounded bg-shell-active px-0.5 font-semibold text-shell-ink' : 'font-medium text-shell-ink'}
>
{token}
</span>,
)
last = index + token.length
}
if (last === 0) out.push(node)
else if (last < node.length) out.push(node.slice(last))
}
return out
}
/**
* A message body: fenced code, inline markdown, links and @mentions, with the
* author's own mention set deciding whether a chip is highlighted.
*/
export function MessageBody({
message,
viewerId,
compact = false,
suffix = null,
}: {
message: ChatMessage
viewerId?: number
compact?: boolean
/** 追加在正文最后一段文本末尾的行内内容(“已编辑”等标记)。 */
suffix?: ReactNode
}) {
const mentionIds = useMemo(() => new Set(message.mentions ?? []), [message.mentions])
const isSelf = Boolean(viewerId && mentionIds.has(viewerId)) || message.mention_everyone
const tokens = useMemo(() => splitFences(message.content), [message.content])
const embeds = useMemo(() => {
const list: MessageEmbed[] = message.embeds ?? []
const map = new Map<string, MessageEmbed>()
for (const item of list) {
if (!item || typeof item.url !== 'string') continue
map.set(item.url, item)
map.set(embedKey(item.url), item)
}
return map
}, [message])
const lastTextIndex = tokens.reduce((acc, token, index) => (token.type === 'text' ? index : acc), -1)
return (
<div className={`chat-message-body min-w-0 break-words ${compact ? 'text-[13px]' : 'text-[15px]'} leading-[1.5]`}>
{tokens.map((token, index) =>
token.type === 'code' ? (
<pre
key={`c${index}`}
className="my-1.5 max-h-80 overflow-auto rounded-md border border-shell-hairline bg-shell-field p-2.5 font-mono text-[12.5px] leading-[1.45]"
>
{token.lang ? (
<span className="mb-1 block text-[10px] uppercase text-shell-ink-muted">{token.lang}</span>
) : null}
<code>{token.value}</code>
</pre>
) : (
<div key={`t${index}`} className="whitespace-pre-wrap break-words">
{renderInline(token.value, mentionIds, isSelf, embeds)}
{suffix && index === lastTextIndex ? <span className="ml-1.5">{suffix}</span> : null}
</div>
),
)}
{suffix && lastTextIndex < 0 ? <span className="ml-1.5">{suffix}</span> : null}
</div>
)
}
/* ------------------------------------------------------------ attachments */
export function AttachmentGrid({
attachments,
onOpen,
}: {
attachments: Attachment[]
onOpen: (attachment: Attachment) => void
}) {
if (attachments.length === 0) return null
const images = attachments.filter((item) => attachmentKind(item) === 'image')
const rest = attachments.filter((item) => attachmentKind(item) !== 'image')
return (
<div className="mt-1.5 space-y-1.5">
{images.length > 0 ? (
<div className={`flex flex-wrap gap-2 ${images.length > 1 ? 'max-w-[420px]' : ''}`}>
{images.map((image) => (
<button
key={image.id}
type="button"
onClick={() => onOpen(image)}
title={image.filename}
className="group relative overflow-hidden rounded-lg border border-shell-hairline bg-shell-field"
>
{/* The grid loads the compressed derivative; clicking opens the
lightbox, which streams the original. */}
<img
src={attachmentUrl(image.id, 'preview')}
alt={image.filename}
loading="lazy"
className="max-h-64 max-w-full object-contain transition-transform group-hover:scale-[1.01] sm:max-w-[320px]"
/>
<span className="absolute right-1.5 top-1.5 hidden rounded bg-black/55 p-1 text-white group-hover:block">
<Maximize2 className="h-3.5 w-3.5" />
</span>
</button>
))}
</div>
) : null}
{rest.map((item) => (
<AttachmentCard key={item.id} attachment={item} onOpen={onOpen} />
))}
</div>
)
}
function AttachmentCard({ attachment, onOpen }: { attachment: Attachment; onOpen: (attachment: Attachment) => void }) {
const { t } = useT()
const kind = attachmentKind(attachment)
if (kind === 'video' || kind === 'audio') {
return (
<video
controls
preload="none"
src={attachmentUrl(attachment.id)}
className="max-h-72 w-full max-w-[420px] rounded-lg border border-shell-hairline bg-black"
/>
)
}
return (
<div className="flex max-w-[420px] items-center gap-2.5 rounded-lg border border-shell-hairline bg-shell-field p-2.5">
<FileText className="h-5 w-5 shrink-0 text-shell-ink-muted" />
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium">{attachment.filename}</p>
<p className="text-[11px] text-shell-ink-muted">
{formatSize(attachment.size)} · {attachment.content_type || t('chat.attachment.unknownType')}
</p>
</div>
<IconAction label={t('chat.attachment.download')} onClick={() => downloadAttachment(attachment)}>
<Download className="h-4 w-4" />
</IconAction>
<IconAction label={t('chat.attachment.preview')} onClick={() => onOpen(attachment)}>
<Maximize2 className="h-4 w-4" />
</IconAction>
</div>
)
}
/** Streaming endpoint is cookie-authed, so a new tab is a real download. */
function downloadAttachment(attachment: Attachment): void {
window.open(attachmentUrl(attachment.id), '_blank', 'noopener,noreferrer')
}
export function Lightbox({ attachment, onClose }: { attachment: Attachment | null; onClose: () => void }) {
const { t } = useT()
useEffect(() => {
if (!attachment) return
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [attachment, onClose])
if (!attachment) return null
const kind = attachmentKind(attachment)
return (
<div
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-black/80 p-6"
onClick={onClose}
>
<div className="flex w-full max-w-4xl items-center gap-3 text-white/85">
<span className="min-w-0 flex-1 truncate text-[13px]">{attachment.filename}</span>
<span className="shrink-0 text-[12px] text-white/60">{formatSize(attachment.size)}</span>
<button
type="button"
aria-label={t('chat.attachment.closePreview')}
onClick={onClose}
className="shrink-0 rounded p-1 hover:bg-white/15"
>
<X className="h-5 w-5" />
</button>
</div>
<div
className="flex max-h-[80vh] w-full max-w-4xl items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
{kind === 'image' ? (
<img
src={attachmentUrl(attachment.id)}
alt={attachment.filename}
className="max-h-[74vh] max-w-full rounded-lg object-contain"
/>
) : kind === 'video' ? (
<video src={attachmentUrl(attachment.id)} controls autoPlay className="max-h-[74vh] w-full rounded-lg" />
) : kind === 'audio' ? (
<audio src={attachmentUrl(attachment.id)} controls className="w-full" />
) : (
<iframe
title={attachment.filename}
src={attachmentUrl(attachment.id)}
sandbox=""
className="h-[74vh] w-full rounded-lg bg-white"
/>
)}
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
downloadAttachment(attachment)
}}
className="rounded-md bg-white/15 px-3 py-1.5 text-[13px] text-white hover:bg-white/25"
>
{t('common.action.download')}
</button>
</div>
)
}
/** Compact timestamp gutter used by grouped rows. */
export function GutterTime({ value }: { value: string }) {
return (
<span className="w-10 shrink-0 select-none pt-1 text-right text-[10px] text-shell-ink-muted">
{clockStamp(value)}
</span>
)
}