- chat: left-aligned discord-style messages with grouped senders, hover actions, an auto-growing composer and file cards - send: render messages and files locally first and replace the placeholder when the worker publishes the real item; failures keep the text with a retry action - navigation: space rail plus a flatter channel list with the space name header and collapsible groups - members: right pane tabs (files / members) grouped by online, offline and pending users, with a prominent invite action and a top bar shortcut - notifications: toast host for invite results; invitees get a toast with an accept action, and the invitation payload now carries the space name and inviter inside the encrypted grant - users over devices: drop device names, the device list and device rename/release; senders show the account (guests show as guest) and the share panel only keeps links - follow contract 7.0.0 (max_members, members_used, members_full, /join) - docs: ui-design and frontend updated for the new layout and concepts
57 lines
3.9 KiB
TypeScript
57 lines
3.9 KiB
TypeScript
import { useState } from 'react';
|
|
import { isLocalId, useWorkspace } from '@/store/workspace';
|
|
import { useAccount } from '@/store/account';
|
|
import { useDownload } from '@/features/item/download';
|
|
import { iconOf } from '@/features/item/summary';
|
|
import { senderLabel } from '@/lib/sender';
|
|
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
|
import { Icon } from '@/ui/Icon';
|
|
import { Button } from '@/ui/Button';
|
|
import styles from './ChatPane.module.css';
|
|
|
|
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
|
|
|
|
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props) {
|
|
const item = useWorkspace((s) => s.byId[id]);
|
|
const owner = useWorkspace((s) => s.channel?.role === 'owner');
|
|
const retry = useWorkspace((s) => s.retrySend);
|
|
const username = useAccount((s) => s.username);
|
|
const download = useDownload();
|
|
const [downloading, setDownloading] = useState(false);
|
|
if (!item) return null;
|
|
const sender = item.author;
|
|
const name = senderLabel(item);
|
|
const self = sender?.username !== undefined && sender.username === username;
|
|
const local = isLocalId(id);
|
|
const sending = local && item.state.kind === 'receiving';
|
|
const failed = local && item.state.kind === 'incomplete';
|
|
const ready = item.state.kind === 'complete';
|
|
const deleteLabel = item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息';
|
|
async function save() {
|
|
if (!item || downloading) return;
|
|
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
|
|
}
|
|
return <article className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
|
|
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
|
|
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
|
|
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
|
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
|
|
<div className={styles.body}>
|
|
{!compact ? <header><strong className={styles.senderName} title={sender?.username}>{name}</strong><time>{formatTime(item.ts)}</time></header> : null}
|
|
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
|
: <div className={styles.fileCard}>
|
|
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
|
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
|
|
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
|
|
</button>
|
|
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
|
|
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
|
|
</div>}
|
|
{item.state.kind === 'receiving' && item.kind === 'file' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
|
|
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
|
|
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
|
</div>
|
|
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
|
|
</article>;
|
|
}
|