feat(web): add spaces, grouped channels and file chat

This commit is contained in:
justme committed 2026-10-13 01:39:23 +08:00
1 parent c7d8ef15bc
commit f6cfb12716
111 files changed
+4371 -506

No files matched your search

@@ -0,0 +1,172 @@
import type { ChannelSession, ReceivedItem } from '@webdrop/core';
import type { SavedResource } from '../database';
import type { FileItem, FolderInfo, SenderInfo, ThreadInfo, WorkspaceEvent } from '../types';
import { readSender } from '@/lib/sender';
import { previewKindOf } from '@/lib/fileKind';
import { publishWorkspace } from './presentation';
import { getSession } from './sessions';
import { deviceIdentity } from './presentation';
import { flushOutbox, persistPlan } from './transfer';
const CONTROL_APP = 'webdrop.workspace';
const encoder = new TextEncoder();
type Control =
| { op: 'item.author'; itemId: string; author: SenderInfo }
| { op: 'file.published'; item: FileItem }
| { op: 'thread.create'; id: string; name: string; groupId?: string }
| { op: 'group.create'; id: string; name: string }
| { op: 'group.rename'; id: string; name: string }
| { op: 'thread.group'; id: string; groupId: string | null }
| { op: 'thread.rename'; id: string; name: string }
| { op: 'folder.create'; id: string; threadId: string; parentId?: string; name: string }
| { op: 'folder.rename'; id: string; name: string }
| { op: 'items.move'; itemIds: string[]; threadId: string; folderId: string | null };
function cleanName(input: string): string {
const name = input.trim();
if (!name || name.length > 80 || /[\u0000-\u001f]/.test(name)) throw new Error('名称需为 1–80 个字符,不能包含控制字符');
return name;
}
async function sendControl(resourceId: string, control: Control, threadId?: string): Promise<void> {
const { session, resource } = await getSession(resourceId);
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
if (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload)) throw new Error('此链接没有上传权限');
const device = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })), {
kind: 'other', threadId, dev: device,
}, { ts: Date.now(), dev: device, control: true });
await persistPlan(resource, plan);
const event = controlEvent(control, Date.now(), device);
if (event) publishWorkspace(resourceId, event);
await flushOutbox(resource, session);
}
export async function createThread(resourceId: string, input: string, groupId?: string): Promise<string> {
if (groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效');
const id = crypto.randomUUID();
await sendControl(resourceId, { op: 'thread.create', id, name: cleanName(input), ...(groupId ? { groupId } : {}) });
return id;
}
export async function createGroup(resourceId: string, input: string): Promise<string> {
const id = crypto.randomUUID(); await sendControl(resourceId, { op: 'group.create', id, name: cleanName(input) }); return id;
}
export async function renameGroup(resourceId: string, id: string, input: string): Promise<void> {
if (!isId(id)) throw new Error('频道分组格式无效');
await sendControl(resourceId, { op: 'group.rename', id, name: cleanName(input) });
}
export async function groupThread(resourceId: string, id: string, groupId?: string): Promise<void> {
if (!isId(id) || groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效');
await sendControl(resourceId, { op: 'thread.group', id, groupId: groupId ?? null }, id);
}
export async function renameThread(resourceId: string, id: string, input: string): Promise<void> {
await sendControl(resourceId, { op: 'thread.rename', id, name: cleanName(input) }, id);
}
export async function createFolder(resourceId: string, threadId: string, input: string, parentId?: string): Promise<void> {
await sendControl(resourceId, { op: 'folder.create', id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: cleanName(input) }, threadId);
}
export async function renameFolder(resourceId: string, id: string, input: string, threadId: string): Promise<void> {
await sendControl(resourceId, { op: 'folder.rename', id, name: cleanName(input) }, threadId);
}
export async function moveItems(resourceId: string, itemIds: string[], threadId: string, folderId?: string): Promise<void> {
if (itemIds.length < 1 || itemIds.length > 100) throw new Error('一次最多移动 100 个文件');
await sendControl(resourceId, { op: 'items.move', itemIds, threadId, folderId: folderId ?? null }, threadId);
}
export async function queueFileCatalog(resource: SavedResource, session: ChannelSession, item: FileItem): Promise<void> {
const control: Control = { op: 'file.published', item: { ...item, state: { kind: 'complete' } } };
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })),
{ kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true });
await persistPlan(resource, plan);
}
export async function queueItemAuthor(resource: SavedResource, session: ChannelSession, itemId: string, author: SenderInfo, threadId: string, ts: number): Promise<void> {
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, op: 'item.author', itemId, author })),
{ kind: 'other', threadId }, { ts, control: true });
await persistPlan(resource, plan);
}
function isId(value: unknown): value is string {
return typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value);
}
function isItemId(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value); }
function validName(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0 && value.length <= 80 && !/[\u0000-\u001f]/.test(value);
}
function controlEvent(control: Control, ts: number, author: string): WorkspaceEvent | null {
switch (control.op) {
case 'item.author': return { t: 'item-author', id: control.itemId, author: control.author };
case 'file.published': return { t: 'file-catalog', item: control.item };
case 'thread.create': return { t: 'thread-created', thread: { id: control.id, name: control.name, createdAt: ts, createdBy: author, ...(control.groupId ? { groupId: control.groupId } : {}) } };
case 'group.create': return { t: 'group-created', group: { id: control.id, name: control.name, createdAt: ts } };
case 'group.rename': return { t: 'group-renamed', id: control.id, name: control.name };
case 'thread.group': return { t: 'thread-grouped', id: control.id, ...(control.groupId ? { groupId: control.groupId } : {}) };
case 'thread.rename': return { t: 'thread-renamed', id: control.id, name: control.name };
case 'folder.create': return { t: 'folder-created', folder: { id: control.id, threadId: control.threadId, ...(control.parentId ? { parentId: control.parentId } : {}), name: control.name } };
case 'folder.rename': return { t: 'folder-renamed', id: control.id, name: control.name };
case 'items.move': return { t: 'items-moved', itemIds: control.itemIds, threadId: control.threadId, ...(control.folderId ? { folderId: control.folderId } : {}) };
}
}
export function applyControl(item: ReceivedItem): WorkspaceEvent | false {
if (item.meta?.kind !== 'other') return false;
let value: unknown;
try { value = JSON.parse(new TextDecoder().decode(item.content)); } catch { return false; }
if (!value || typeof value !== 'object' || (value as { app?: unknown }).app !== CONTROL_APP) return false;
const row = value as Record<string, unknown>;
if (row.v !== 1 || typeof row.op !== 'string') return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
const author = item.meta.dev ?? '未知设备';
switch (row.op) {
case 'item.author': {
const sender = readSender(row.author);
return isItemId(row.itemId) && sender ? { t: 'item-author', id: row.itemId, author: sender } : { t: 'error', message: '发送者资料格式无效,已拒绝应用。' };
}
case 'file.published': {
const file = row.item as FileItem | undefined;
const sender = readSender(file?.author);
if (!file || !isItemId(file.id) || !isId(file.threadId) || (file.folderId !== undefined && !isId(file.folderId))
|| file.kind !== 'file' || typeof file.name !== 'string' || file.name.length > 1024 || typeof file.mime !== 'string' || file.mime.length > 256
|| !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean' || file.author !== undefined && !sender) return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' };
return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, ...(file.folderId ? { folderId: file.folderId } : {}),
name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备',
...(sender ? { author: sender } : {}),
gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } };
}
case 'thread.create':
return isId(row.id) && validName(row.name) && (row.groupId === undefined || isId(row.groupId))
? { t: 'thread-created', thread: { id: row.id, name: row.name, createdAt: item.meta.ts, createdBy: author,
...(row.groupId ? { groupId: row.groupId as string } : {}) } satisfies ThreadInfo } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
case 'group.create':
return isId(row.id) && validName(row.name) ? { t: 'group-created', group: { id: row.id, name: row.name, createdAt: item.meta.ts } }
: { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' };
case 'group.rename':
return isId(row.id) && validName(row.name) ? { t: 'group-renamed', id: row.id, name: row.name }
: { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' };
case 'thread.group':
return isId(row.id) && (row.groupId === null || isId(row.groupId)) ? { t: 'thread-grouped', id: row.id, ...(row.groupId ? { groupId: row.groupId as string } : {}) }
: { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' };
case 'thread.rename':
return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80
? { t: 'thread-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
case 'folder.create':
if (!isId(row.id) || !isId(row.threadId) || (row.parentId !== undefined && !isId(row.parentId))
|| typeof row.name !== 'string' || row.name.length < 1 || row.name.length > 80) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
return { t: 'folder-created', folder: { id: row.id, threadId: row.threadId, ...(row.parentId ? { parentId: row.parentId as string } : {}), name: row.name } satisfies FolderInfo };
case 'folder.rename':
return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80
? { t: 'folder-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
case 'items.move':
if (!Array.isArray(row.itemIds) || row.itemIds.length > 100 || !row.itemIds.every(isItemId) || !isId(row.threadId)
|| (row.folderId !== null && !isId(row.folderId))) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
return { t: 'items-moved', itemIds: row.itemIds, threadId: row.threadId, ...(row.folderId ? { folderId: row.folderId as string } : {}) };
default:
return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
}
}