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
This commit is contained in:
1 parent
17638b6e20
commit
5489407241
260 files changed
+1503
-1246
No files matched your search
@@ -0,0 +1,97 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user