- 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
47 lines
2.6 KiB
TypeScript
47 lines
2.6 KiB
TypeScript
import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink';
|
|
import { makeLink } from './links';
|
|
import { decryptInvitation, encryptInvitation } from './accountCrypto';
|
|
|
|
interface ChannelGrant {
|
|
v: 1;
|
|
channelId: string;
|
|
mode: 'enc' | 'raw';
|
|
role: 'owner' | 'member';
|
|
keys: ParsedChannelLink['keys'];
|
|
/** 仅供被邀请方在收件箱显示,在加密 payload 内,服务端不可见 */
|
|
name?: string;
|
|
invitedBy?: string;
|
|
}
|
|
|
|
export interface InvitationMeta { name?: string; invitedBy?: string }
|
|
|
|
export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey, meta: InvitationMeta = {}): Promise<string> {
|
|
const parsed = parseChannelLink(link);
|
|
if (!parsed.ok) throw new Error('空间凭据格式无效');
|
|
const grant: ChannelGrant = { v: 1, channelId: parsed.link.id, mode: parsed.link.mode,
|
|
role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys,
|
|
...(meta.name ? { name: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
|
|
return encryptInvitation(JSON.stringify(grant), recipient, grant.channelId, publicKey);
|
|
}
|
|
|
|
/** 只读取显示用的元信息;解不开或没有这些字段时返回空,调用方回退到空间 ID */
|
|
export async function peekChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey): Promise<InvitationMeta> {
|
|
try {
|
|
const grant = JSON.parse(await decryptInvitation(payload, recipient, channelId, privateKey)) as Partial<ChannelGrant>;
|
|
return { ...(typeof grant.name === 'string' ? { name: grant.name } : {}), ...(typeof grant.invitedBy === 'string' ? { invitedBy: grant.invitedBy } : {}) };
|
|
} catch { return {}; }
|
|
}
|
|
|
|
export async function openChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey,
|
|
role: 'owner' | 'member', origin: string): Promise<ParsedChannelLink> {
|
|
const plain = await decryptInvitation(payload, recipient, channelId, privateKey);
|
|
let grant: ChannelGrant;
|
|
try { grant = JSON.parse(plain) as ChannelGrant; } catch { throw new Error('空间授权包格式无效'); }
|
|
if (!grant || grant.v !== 1 || grant.channelId !== channelId || grant.role !== role || !['enc', 'raw'].includes(grant.mode)
|
|
|| !grant.keys || typeof grant.keys !== 'object') throw new Error('空间授权包校验失败');
|
|
const parsed = parseChannelLink(makeLink(origin, channelId, grant.keys));
|
|
if (!parsed.ok || parsed.link.mode !== grant.mode) throw new Error('空间授权包模式不匹配');
|
|
if (!parsed.ok || parsed.link.id !== channelId || parsed.link.isOwner !== (role === 'owner')) throw new Error('空间授权包权限不匹配');
|
|
return parsed.link;
|
|
}
|