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
This commit is contained in:
1 parent
5fd4ddd411
commit
4b8d0b5a5f
83 files changed
+1291
-661
No files matched your search
@@ -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]);
|
||||
}
|
||||
Reference in new issue
Block a user