Files
WebDrop/web/src/features/channel/ChatMessage.tsx
T
justme 4b8d0b5a5f
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
- 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
2026-10-14 22:19:25 +08:00

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