Files
WebDrop/web/src/features/channel/chat/ChatPane.tsx
T
justme 5489407241 refactor(web): restructure into api/domain/services/worker; capabilities, file domains, video covers
- 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
2026-10-19 13:38:51 +08:00

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>
);
}