69 lines
3.8 KiB
TypeScript
69 lines
3.8 KiB
TypeScript
import { Fragment, useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
import { Button } from '@/ui/Button';
|
|
import { useWorkspace } from '@/store/workspace';
|
|
import type { Item } from '@/core-bridge/types';
|
|
import { formatDate } from '@/lib/format';
|
|
import { ChatMessage } from './ChatMessage';
|
|
import styles from './ChatPane.module.css';
|
|
|
|
interface Props { threadName: string; ids: string[]; enabled: boolean; onSend: (text: string) => Promise<void>; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void }
|
|
|
|
export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) {
|
|
const [text, setText] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const messages = useRef<HTMLDivElement>(null);
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
const byId = useWorkspace((s) => s.byId);
|
|
const chronological = ids.slice().reverse();
|
|
|
|
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
|
|
|
|
async function submit() {
|
|
const value = text.trim();
|
|
if (!value || busy || !enabled) return;
|
|
setBusy(true);
|
|
setError('');
|
|
try { await onSend(value); setText(''); }
|
|
catch (cause) { setError(cause instanceof Error ? cause.message : '发送失败'); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
|
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); }
|
|
}
|
|
|
|
return (
|
|
<section className={styles.pane} aria-label={`${threadName} 聊天`}>
|
|
<div className={styles.head}><strong>{threadName}</strong><span>频道聊天</span></div>
|
|
<div className={styles.messages} ref={messages} aria-live="polite">
|
|
{chronological.length === 0 ? <p className={styles.empty}>在这里发送消息或文件,成员可以看到你的名称和头像。</p> : null}
|
|
{(() => {
|
|
let previous: Item | undefined;
|
|
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 ?? previous.deviceId) === (item.author?.username ?? item.deviceId);
|
|
const grouped = Boolean(sameSender && !showDate && item.ts - previous!.ts < 5 * 60_000);
|
|
previous = item;
|
|
return <Fragment key={id}>
|
|
{showDate ? <div className={styles.dateDivider} role="separator"><span>{formatDate(item.ts)}</span></div> : null}
|
|
<ChatMessage id={id} compact={grouped} onOpenFile={onOpenFile} onDelete={onDelete} />
|
|
</Fragment>;
|
|
});
|
|
})()}
|
|
</div>
|
|
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
|
{onFiles ? <><input ref={fileInput} type="file" multiple hidden onChange={(event) => { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} />
|
|
<Button variant="ghost" icon="clip" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
|
|
<textarea value={text} disabled={!enabled || busy} rows={2} placeholder={enabled ? '输入消息,Enter 发送,Shift+Enter 换行' : '先创建或加入一个频道'} onChange={(event) => setText(event.target.value)} onKeyDown={keyDown} />
|
|
<Button variant="primary" icon="send" label="发送消息" disabled={!enabled || busy || !text.trim()} onClick={() => void submit()} />
|
|
</form>
|
|
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
|
</section>
|
|
);
|
|
}
|