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
@@ -5,7 +5,7 @@ import { setAccountIdentity, requireAccount } from './accountSession';
|
||||
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto';
|
||||
import { mergeVault, validateVault, type VaultProfile } from './accountVault';
|
||||
import { joinChannel } from './channelResources';
|
||||
import { openChannelGrant, sealChannelGrant } from './channelGrant';
|
||||
import { openChannelGrant, peekChannelGrant, sealChannelGrant } from './channelGrant';
|
||||
import { recoverAccountChannels } from './accountChannels';
|
||||
import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence';
|
||||
import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api';
|
||||
@@ -26,7 +26,7 @@ interface AccountSession {
|
||||
controller: AbortController; dirty: boolean; syncing?: Promise<void>; error?: string;
|
||||
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
export type Invitation = InvitationBody;
|
||||
export type Invitation = InvitationBody & { channelName?: string; invitedBy?: string };
|
||||
let account: AccountSession | undefined;
|
||||
export function unlockedAccount() {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
@@ -246,7 +246,13 @@ export async function revokeAccountDevice(id: string): Promise<void> {
|
||||
if ((await localAccountDevice()).id === id) await logoutAccount();
|
||||
}
|
||||
export async function listAccountInvitations(): Promise<Invitation[]> {
|
||||
requireAccount(); return apiFetch('/accounts/invitations').then((r) => r.json() as Promise<Invitation[]>);
|
||||
requireAccount();
|
||||
const rows = await apiFetch('/accounts/invitations').then((r) => r.json() as Promise<InvitationBody[]>);
|
||||
const session = account;
|
||||
return Promise.all(rows.map(async (row): Promise<Invitation> => {
|
||||
const meta = session ? await peekChannelGrant(row.payload, session.username, row.channel_id, session.profile.keys.privateKey) : {};
|
||||
return { ...row, ...(meta.name ? { channelName: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
|
||||
}));
|
||||
}
|
||||
export async function acceptInvitation(channelId: string): Promise<void> {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
@@ -262,7 +268,7 @@ export async function inviteChannelUser(channelId: string, input: string): Promi
|
||||
const plan = await apiFetch(`/channels/${channelId}/invitations/${username}/crypto-plan`, { headers: session.authHeaders('owner') })
|
||||
.then(r => r.json() as Promise<InvitationCryptoPlan>);
|
||||
const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<PublicKeyBody>);
|
||||
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey);
|
||||
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey, { name: resource.name, invitedBy: account?.username });
|
||||
const domainEnvelopes = await packageInvitationDomains(resource, session, plan);
|
||||
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user