Files
WebDrop/web/src/core-bridge/worker/channelResources.ts
T
justme 4b8d0b5a5f
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
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
2026-10-14 22:19:25 +08:00

177 lines
9.4 KiB
TypeScript

import { ChannelSession } from '@webdrop/core';
import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
import type { ParsedChannelLink } from '@/lib/channelLink';
import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types';
import { apiFetch, readJson, getServerInfo } from './http';
import { channelCache, isOfflineFailure } from './resourceMetadata';
import { accountToken } from './accountSession';
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api';
import { getSession, sessions } from './sessions';
import { makeLink } from './links';
import { toChannelInfo, type ChannelWire } from './wire';
import { stopChannel } from './channelTransport';
import { publishWorkspace } from './presentation';
import { requireAccount, accountPublicKey } from './accountSession';
import { sealChannelGrant } from './channelGrant';
export async function createChannel(options: CreateOptions, captchaToken?: string): Promise<ChannelInfo> {
const username = requireAccount();
const defaults = (await getServerInfo()).defaults?.channel;
if (!defaults) throw new Error('服务端需要升级以提供统一设置');
if (defaults.join_policy !== 'open' && defaults.join_policy !== 'invite') throw new Error('空间加入规则无效');
const endpointSecret = crypto.getRandomValues(new Uint8Array(32));
const { session, request } = await ChannelSession.create({
mode: options.mode,
endpointSecret,
settings: {
...defaults,
name: options.name?.trim() || undefined,
max_members: options.maxMembers,
ttl_seconds: options.ttlSeconds,
members_can_upload: options.membersCanUpload,
file_max_downloads: options.fileMaxDownloads,
join_policy: options.joinPolicy ?? defaults.join_policy, allow_guests: options.allowGuests ?? defaults.allow_guests,
},
});
try {
const keyPackage = await sealChannelGrant(session.link(self.location.origin, 'owner'), username, accountPublicKey());
const info = await apiFetch('/channels', {
method: 'POST', headers: { ...request.headers, 'Content-Type': 'application/json', ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) }, body: JSON.stringify({ ...request.body, key_package: keyPackage, join_password: options.joinPassword || undefined }),
}).then((response) => response.json() as Promise<ChannelWire>);
session.verifyChannelInfo(info);
const resource: SavedResource = {
...channelCache(info),
id: session.id,
creatorAccount: info.creator_account ?? undefined,
kind: 'channel', mode: session.mode, role: 'owner',
name: info.settings.name ?? undefined,
link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'),
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
maxMembers: info.settings.max_members,
maxDownloads: info.settings.file_max_downloads, localItems: [],
};
await saveResource(resource);
sessions.set(session.id, session);
attachDataKeys(session, resource);
const workspace = await import('./workspaceStructure').then(module => module.loadWorkspace(session.id));
if (workspace.structure.defaultChannelId) await initializeChannelKeys(resource, session, workspace.structure.defaultChannelId);
return toChannelInfo(info, resource);
} catch (error) {
session.dispose();
throw error;
}
}
export async function joinChannel(input: { link: ParsedChannelLink; password?: string }): Promise<ChannelInfo> {
const { id, keys } = input.link;
const existing = await getResource(id);
const endpointSecret = existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32));
const session = await ChannelSession.open(makeLink(self.location.origin, id, keys), { endpointSecret });
try {
const info = await readJson<ChannelWire>(`/channels/${id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
const joined = await apiFetch(`/channels/${id}/join`, {
method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }),
}).then((response) => response.json() as Promise<JoinResult>);
info.members_used = joined.members_used;
const resource: SavedResource = {
...channelCache(info),
id, kind: 'channel', mode: session.mode, role: session.role,
creatorAccount: info.creator_account ?? undefined,
name: info.settings.name ?? undefined,
link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined,
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
maxMembers: info.settings.max_members,
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
};
await saveResource(resource);
sessions.set(id, session);
return toChannelInfo(info, resource);
} catch (error) {
session.dispose();
throw error;
}
}
export async function previewChannel(link: ParsedChannelLink): Promise<ChannelInfo> {
const session = await ChannelSession.open(makeLink(self.location.origin, link.id, link.keys));
try {
const info = await readJson<ChannelWire>(`/channels/${link.id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
return toChannelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role,
endpointSecret: session.endpointSecret, link: session.link(self.location.origin), createdAt: info.created_at,
expiresAt: info.expires_at, maxMembers: info.settings.max_members });
} finally { session.dispose(); }
}
export async function channelMembers(id: string): Promise<MemberBody[]> {
const { session } = await getSession(id);
return readJson(`/channels/${id}/members`, session.authHeaders(session.can('owner') ? 'owner' : 'read'));
}
export async function listChannels(): Promise<ChannelInfo[]> {
const saved = await listResources('channel');
let catalog: Map<string, ChannelWire> | undefined;
if (accountToken()) {
try {
const rows = await apiFetch('/accounts/spaces').then(r => r.json() as Promise<ChannelWire[]>);
catalog = new Map(rows.map(row => [row.id, row]));
} catch (error) { if (!isOfflineFailure(error)) throw error; }
}
const rows = await Promise.all(saved.map(async (resource): Promise<ChannelInfo | null> => {
try {
if (catalog && !catalog.has(resource.id)) return null;
const { session } = await getSession(resource.id);
const raw = catalog?.get(resource.id) ?? await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'));
session.verifyChannelInfo(raw);
const info = toChannelInfo(raw, resource);
await cacheResource(resource, channelCache(raw));
return { ...info, dataSource: 'server' };
} catch (error) {
if (error instanceof Error && 'status' in error && (error.status === 404 || error.status === 410)) return null;
if (!isOfflineFailure(error)) throw error;
if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
return {
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined,
creatorAccount: resource.creatorAccount,
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
dataSource: 'cache',
};
}
}));
return rows.filter((row): row is ChannelInfo => row !== null);
}
export async function updateChannelSettings(resourceId: string, patch: ChannelSettingsPatch): Promise<void> {
const { session, resource } = await getSession(resourceId);
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
const body: SettingsRequest = {};
if (patch.name !== undefined) body.name = patch.name.trim();
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy;
if (patch.allowGuests !== undefined) body.allow_guests = patch.allowGuests;
if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword;
const info = await apiFetch(`/channels/${resourceId}/settings`, {
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((response) => response.json() as Promise<ChannelWire>);
session.verifyChannelInfo(info);
const updated = await cacheResource(resource, channelCache(info));
publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) });
}
export async function forgetChannel(id: string): Promise<void> {
stopChannel(id);
sessions.get(id)?.dispose();
sessions.delete(id);
await removeResource(id);
}