- dissolve core-bridge into api (wire contract), domain (models), services (what the UI calls) and worker (everything that runs in the Web Worker, grouped by account, crypto, space, files, shares, runtime); regroup features, lib and store by responsibility and delete unused components - the UI only reads backend capabilities (domain/capabilities); one tokenRole picks the token, hasCode/isRetryable handle errors; pre-checks that decided permissions on the client are gone; tests fail if those rules are reimplemented - files go straight to the file domain (create, upload, download, delete, commit, cancel); the host sees one registration per file; creating a channel allocates a file domain and works even when the file service is down - file panel shows which channel the files belong to and its space usage; unlimited files preview in chat; videos get a stored cover; the file service status is shown - fix: console flooded by Retry-After reads on every upload frame
98 lines
5.7 KiB
TypeScript
98 lines
5.7 KiB
TypeScript
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<void>; 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<HTMLDivElement>(null);
|
|
const input = useRef<HTMLTextAreaElement>(null);
|
|
const fileInput = useRef<HTMLInputElement>(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<HTMLTextAreaElement>) {
|
|
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<HTMLTextAreaElement>) {
|
|
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); }
|
|
}
|
|
|
|
return (
|
|
<section className={styles.pane} aria-label={`${threadName} 聊天`}>
|
|
<div className={styles.head}><span className={styles.hash} aria-hidden="true">#</span><strong>{threadName}</strong></div>
|
|
<div className={styles.messages} ref={messages} aria-live="polite">
|
|
{chronological.length === 0 ? <p className={styles.empty}>这里是 #{threadName} 的开头。发送消息或文件,成员可以看到你的名称和头像。</p> : 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 <Fragment key={id}>
|
|
{showDate ? <div className={styles.dateDivider} role="separator"><span>{formatDate(item.ts)}</span></div> : null}
|
|
{showSeen ? <div className={`${styles.dateDivider} ${styles.seenDivider}`} role="separator"><span>上次看到这里</span></div> : null}
|
|
<ChatMessage id={id} compact={grouped} onOpenFile={onOpenFile} onDelete={onDelete} />
|
|
</Fragment>;
|
|
});
|
|
})()}
|
|
</div>
|
|
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); 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="plus" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
|
|
<textarea ref={input} value={text} disabled={!enabled} rows={1} aria-label="消息内容" placeholder={enabled ? `给 #${threadName} 发消息` : '先创建或加入一个频道'}
|
|
onChange={(event) => { setText(event.target.value); fit(); }} onKeyDown={keyDown} onPaste={paste} />
|
|
<Button type="submit" variant="ghost" icon="send" label="发送消息" disabled={!enabled || !text.trim()} />
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|