- 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
34 lines
2.4 KiB
TypeScript
34 lines
2.4 KiB
TypeScript
import { ChannelSession } from '@webdrop/core';
|
|
import { getResource, importResources, listResourceDeletes, type SavedResource } from '../database';
|
|
import { openChannelGrant } from './channelGrant';
|
|
import { makeLink } from './links';
|
|
import { apiFetch, readJson } from './http';
|
|
import type { InvitationKeyPair } from './accountCrypto';
|
|
import type { ChannelWire } from './wire';
|
|
import type { AccountChannelBody } from '../generated/api';
|
|
import { channelCache } from './resourceMetadata';
|
|
|
|
export async function recoverAccountChannels(username: string, keys: InvitationKeyPair, signal: AbortSignal): Promise<void> {
|
|
const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise<AccountChannelBody[]>);
|
|
const deletes = new Map((await listResourceDeletes()).map((row) => [row.id, row.modifiedAt]));
|
|
for (const grant of grants) {
|
|
if (grant.role !== 'owner' && grant.role !== 'member') throw new Error('空间成员权限无效');
|
|
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
|
|
if ((deletes.get(grant.channel_id) ?? -1) >= grant.created_at || await getResource(grant.channel_id)) continue;
|
|
const parsed = await openChannelGrant(grant.payload, username, grant.channel_id, keys.privateKey, grant.role, self.location.origin);
|
|
const session = await ChannelSession.open(makeLink(self.location.origin, parsed.id, parsed.keys));
|
|
try {
|
|
const info = await readJson<ChannelWire>(`/channels/${session.id}`, session.authHeaders('read'));
|
|
session.verifyChannelInfo(info);
|
|
if (grant.role === 'owner' && info.creator_account !== username) throw new Error('空间所属账号不匹配');
|
|
const resource: SavedResource = { ...channelCache(info), id: session.id, kind: 'channel', mode: session.mode, role: grant.role,
|
|
creatorAccount: info.creator_account ?? undefined, name: info.settings.name ?? undefined,
|
|
link: session.link(self.location.origin), ...(grant.role === 'owner' ? { ownerLink: session.link(self.location.origin, 'owner') } : {}),
|
|
endpointSecret: new Uint8Array(32), createdAt: info.created_at, modifiedAt: grant.created_at, expiresAt: info.expires_at,
|
|
maxMembers: info.settings.max_members, maxDownloads: info.settings.file_max_downloads };
|
|
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
|
|
await importResources([resource]);
|
|
} finally { session.dispose(); }
|
|
}
|
|
}
|