feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

- 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
This commit is contained in:
justme committed 2026-10-14 22:19:25 +08:00
1 parent 5fd4ddd411
commit 4b8d0b5a5f
83 files changed
+1291 -661

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
import { errorMessage } from '@/core-bridge/errors';
import { useChannels } from '@/store/channels';
import { useToast } from '@/store/toast';
import { channelPath, navigate } from './navigation';
const POLL_MS = 8000;
export function invitationLabel(row: AccountInvitation): string {
return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`;
}
async function accept(row: AccountInvitation): Promise<void> {
try {
await accountApi.acceptInvitation(row.channel_id);
await useChannels.getState().refresh();
useToast.getState().push({ kind: 'ok', text: `已加入 ${invitationLabel(row)}` });
navigate(channelPath(row.channel_id));
} catch (cause) {
useToast.getState().push({ kind: 'error', text: `加入失败:${errorMessage(cause)}` });
}
}
/** 登录后轮询账号邀请,出现新邀请就弹通知,可直接接受 */
export function useInvitationNotices(username: string | null): void {
const seen = useRef<Set<string> | null>(null);
useEffect(() => {
if (!username) { seen.current = null; return; }
let stopped = false;
async function poll() {
try {
const rows = await accountApi.invitations();
if (stopped) return;
const known = seen.current;
seen.current = new Set(rows.map((row) => row.channel_id));
const fresh = known ? rows.filter((row) => !known.has(row.channel_id)) : rows;
for (const row of fresh) {
useToast.getState().push({ kind: 'info', text: row.invitedBy ? `${row.invitedBy} 邀请你加入 ${invitationLabel(row)}` : `你收到了加入 ${invitationLabel(row)} 的邀请`,
action: { label: '接受', run: () => { void accept(row); } } });
}
} catch { /* 下一轮再试,不打扰用户 */ }
}
void poll();
const timer = window.setInterval(() => { void poll(); }, POLL_MS);
return () => { stopped = true; window.clearInterval(timer); };
}, [username]);
}