- 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
130 lines
6.1 KiB
TypeScript
130 lines
6.1 KiB
TypeScript
import { ChannelSession, IntegrityError } from '@webdrop/core';
|
|
import { getDraftFile, saveResource, type SavedResource } from '../database';
|
|
import type { FileItem, TextItem } from '../types';
|
|
import { makeFileItem } from './presentation';
|
|
import { active, blobs, getSession } from './sessions';
|
|
import { fail, readJson } from './http';
|
|
import { toShareInfo, type ShareWire } from './wire';
|
|
|
|
import { flushOutbox, downloadDecrypted } from './transfer';
|
|
import { publishShare } from './presentation';
|
|
import { fetchManifest, type ManifestFile } from './shareManifest';
|
|
import { readShareInfo } from './shareState';
|
|
import { errorMessage } from '../errors';
|
|
|
|
export async function connectShare(id: string): Promise<void> {
|
|
active.get(id)?.abort();
|
|
const controller = new AbortController();
|
|
active.set(id, controller);
|
|
try {
|
|
const opened = await getSession(id);
|
|
let { session } = opened;
|
|
const { resource } = opened;
|
|
if (controller.signal.aborted) return;
|
|
if (resource.kind !== 'share') throw new Error('资源类型不匹配');
|
|
const raw = await readShareInfo(session, resource);
|
|
session = (await getSession(id)).session;
|
|
if (controller.signal.aborted) return;
|
|
const share = toShareInfo(raw, resource);
|
|
resource.state = share.state;
|
|
resource.expiresAt = raw.expires_at;
|
|
await saveResource(resource);
|
|
publishShare(id, { t: 'share', share });
|
|
|
|
if (raw.state === 'draft' && resource.mine) {
|
|
await flushOutbox(resource, session);
|
|
const current = await readShareInfo(session, resource);
|
|
resource.draftItems = resource.draftItems?.map((item) => item.kind === 'file'
|
|
? { ...item, state: { kind: current.items.some((row) => row.item_id === item.id && row.complete) ? 'complete' : 'incomplete' } } : item);
|
|
await saveResource(resource);
|
|
if (controller.signal.aborted) return;
|
|
for (const item of resource.draftItems ?? []) publishShare(id, { t: 'item', item });
|
|
publishShare(id, { t: 'ready' });
|
|
return;
|
|
}
|
|
|
|
await loadSealedManifest(resource, session, raw, controller.signal);
|
|
} catch (error) {
|
|
if (controller.signal.aborted) return;
|
|
publishShare(id, error instanceof IntegrityError
|
|
? { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' }
|
|
: { t: 'error', message: errorMessage(error) });
|
|
publishShare(id, { t: 'ready' });
|
|
} finally {
|
|
if (active.get(id) === controller) active.delete(id);
|
|
}
|
|
}
|
|
|
|
export function stopShare(id: string): void {
|
|
active.get(id)?.abort();
|
|
active.delete(id);
|
|
}
|
|
|
|
async function loadSealedManifest(resource: SavedResource, session: ChannelSession, raw: ShareWire, signal: AbortSignal): Promise<void> {
|
|
try {
|
|
const manifest = await fetchManifest(session, resource.id, raw, signal);
|
|
const byId = new Map(raw.items.map((item) => [item.item_id, item]));
|
|
for (let index = 0; index < manifest.items.length; index++) {
|
|
const entry = manifest.items[index];
|
|
if (!entry) throw new Error('invalid_manifest_item');
|
|
if (entry.t === 'text' && typeof entry.text === 'string' && Number.isSafeInteger(entry.ts)) {
|
|
const item: TextItem = { id: `manifest-text-${index}`, kind: 'text', ts: entry.ts, state: { kind: 'complete' }, text: entry.text };
|
|
publishShare(resource.id, { t: 'item', item });
|
|
} else if (entry.t === 'file' && typeof entry.id === 'string' && typeof entry.size === 'number' && Number.isSafeInteger(entry.size)) {
|
|
const detail = byId.get(entry.id);
|
|
const item = makeFileItem(entry.id, entry.name ?? '加密文件', entry.mime ?? 'application/octet-stream', entry.size,
|
|
entry.ts, { kind: 'complete' }, detail?.downloads_left ?? (detail ? null : 0));
|
|
item.gated = true;
|
|
publishShare(resource.id, { t: 'item', item });
|
|
} else throw new Error('invalid_manifest_item');
|
|
}
|
|
} catch {
|
|
publishShare(resource.id, { t: 'integrity', message: '分享清单格式不正确,内容已拒绝显示。' });
|
|
}
|
|
publishShare(resource.id, { t: 'ready' });
|
|
}
|
|
|
|
export async function getShareBlob(id: string, itemId: string): Promise<Blob> {
|
|
const cached = blobs.get(`${id}:${itemId}`);
|
|
if (cached) return cached;
|
|
const { session, resource } = await getSession(id);
|
|
if (resource.kind !== 'share') throw new Error('资源类型不匹配');
|
|
if (resource.state === 'draft' && resource.mine) {
|
|
const draftFile = await getDraftFile(id, itemId);
|
|
if (draftFile) {
|
|
blobs.set(`${id}:${itemId}`, draftFile);
|
|
return draftFile;
|
|
}
|
|
const entry = resource.manifestEntries?.find((row) => row.t === 'file' && row.id === itemId);
|
|
if (!entry || entry.t !== 'file') throw fail(404, 'not_found');
|
|
const result = await downloadDecrypted(session, resource, itemId, entry.size);
|
|
blobs.set(`${id}:${itemId}`, result.blob);
|
|
return result.blob;
|
|
}
|
|
const raw = await readJson<ShareWire>(`/shares/${id}`, session.authHeaders('read'));
|
|
const manifest = await fetchManifest(session, id, raw);
|
|
if (itemId.startsWith('manifest-text-')) {
|
|
const entry = manifest.items[Number(itemId.slice(14))];
|
|
if (entry?.t !== 'text') throw fail(404, 'not_found');
|
|
return new Blob([entry.text], { type: 'text/plain' });
|
|
}
|
|
const entry = manifest.items.find((candidate): candidate is ManifestFile => candidate.t === 'file' && candidate.id === itemId);
|
|
if (!entry) throw fail(404, 'not_found');
|
|
let result: Awaited<ReturnType<typeof downloadDecrypted>>;
|
|
try { result = await downloadDecrypted(session, resource, itemId, entry.size); }
|
|
catch (error) {
|
|
if (error instanceof IntegrityError) publishShare(id, { t: 'integrity', message: '文件校验失败,内容已丢弃。' });
|
|
throw error;
|
|
}
|
|
blobs.set(`${id}:${itemId}`, result.blob);
|
|
const fileInfo = raw.items.find((value) => value.item_id === itemId);
|
|
const remaining = fileInfo?.downloads_left === null || fileInfo?.downloads_left === undefined
|
|
? null
|
|
: Math.max(0, fileInfo.downloads_left - 1);
|
|
const item: FileItem = makeFileItem(itemId, entry.name ?? '加密文件', entry.mime ?? 'application/octet-stream', entry.size, entry.ts,
|
|
{ kind: 'complete' }, remaining);
|
|
publishShare(id, { t: 'item', item });
|
|
return result.blob;
|
|
}
|
|
|