import { Fragment, useEffect, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react'; import { Button } from '@/ui/Button'; import { useAccount } from '@/store/account/account'; import { useWorkspace } from '@/store/space/workspace'; import type { Item } from '@/domain/types'; import { formatDate } from '@/lib/text/format'; import { ChatMessage } from './ChatMessage'; import styles from './ChatPane.module.css'; interface Props { threadName: string; /** 进入频道时的已读位置:之后别人发的第一条消息前画「上次看到这里」 */ lastSeen?: number; ids: string[]; enabled: boolean; onSend: (text: string) => Promise; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void } const MAX_INPUT_HEIGHT = 160; export function ChatPane({ threadName, lastSeen, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) { const [text, setText] = useState(''); const messages = useRef(null); const input = useRef(null); const fileInput = useRef(null); const byId = useWorkspace((s) => s.byId); const self = useAccount((s) => s.username); const chronological = ids.slice().reverse(); useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]); function fit() { const el = input.current; if (!el) return; el.style.height = 'auto'; el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT)}px`; } // 乐观发送:不等后端,先清空输入框,消息立刻画出来;失败由消息行自己显示重试 function submit() { const value = text.trim(); if (!value || !enabled) return; setText(''); if (input.current) input.current.style.height = 'auto'; void onSend(value); } // 粘贴图片(截图、复制的图片)直接当文件发送;只有文字的粘贴照常进输入框 function paste(event: ClipboardEvent) { if (!onFiles || !enabled) return; const images = [...event.clipboardData.files].filter((file) => file.type.startsWith('image/')); if (!images.length) return; event.preventDefault(); const stamp = new Date().toLocaleString('sv').replace(/[: ]/g, '-'); onFiles(images.map((file, index) => { // 截图的文件名通常就是 image.png,换成带时间的名字,不然连续发几张分不清 const generic = /^image\.\w+$/i.test(file.name) || !file.name; const extension = file.type.split('/')[1]?.replace('jpeg', 'jpg') ?? 'png'; return generic ? new File([file], `粘贴的图片 ${stamp}${images.length > 1 ? `-${index + 1}` : ''}.${extension}`, { type: file.type, lastModified: file.lastModified }) : file; })); } function keyDown(event: KeyboardEvent) { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); } } return (
{threadName}
{chronological.length === 0 ?

这里是 #{threadName} 的开头。发送消息或文件,成员可以看到你的名称和头像。

: null} {(() => { let previous: Item | undefined; let seenShown = false; return chronological.map((id) => { const item = byId[id]; if (!item) return null; const dateKey = new Date(item.ts).toDateString(); const previousDate = previous ? new Date(previous.ts).toDateString() : undefined; const showDate = dateKey !== previousDate; const sameSender = previous && previous.author?.username === item.author?.username; // 分隔线只画在「别人发的第一条新消息」前,而且前面得有已读的消息,否则没有意义 const showSeen = !seenShown && lastSeen !== undefined && item.ts > lastSeen && previous !== undefined && item.localId === undefined && item.author?.username !== self; if (showSeen) seenShown = true; const grouped = Boolean(sameSender && !showDate && !showSeen && item.ts - previous!.ts < 5 * 60_000); previous = item; return {showDate ?
{formatDate(item.ts)}
: null} {showSeen ?
上次看到这里
: null}
; }); })()}
{ event.preventDefault(); submit(); }}> {onFiles ? <> { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} />