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

+32 -7
View File
@@ -6,21 +6,27 @@ import { blobs, readers } from './sessions';
import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
import { type ChannelWire, toChannelInfo } from './wire';
import { apiFetch, readJson } from './http';
import { applyControl } from './channelThreads';
import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog';
export function mapReceived(item: ReceivedItem): Item {
const timestamp = item.meta?.ts ?? Date.now();
const deviceId = item.meta?.dev ?? '未知设备';
const state = item.complete ? { kind: 'complete' as const } : { kind: 'incomplete' as const };
if (item.kind === 'file' || item.kind === 'raw') {
if (item.kind === 'file') {
const name = item.meta?.name ?? '加密文件';
return {
id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name,
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
...(item.meta?.folderId ? { folderId: item.meta.folderId } : {}),
mime: item.meta?.mime ?? 'application/octet-stream', size: item.meta?.size ?? item.content.byteLength,
remaining: null, preview: previewKindOf(item.meta?.mime ?? '', name),
gated: item.gated,
};
}
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state, text: new TextDecoder().decode(item.content) };
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state,
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
text: new TextDecoder().decode(item.content) };
}
export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise<void> {
@@ -33,7 +39,19 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
for (const event of events) {
switch (event.type) {
case 'item': {
const control = applyControl(event.item);
if (control !== false) {
if (control && control.t === 'file-catalog') {
const key = `${resource.id}:${control.item.id}`;
fileCatalogs.set(key, control.item);
if (visibleFiles.has(key)) publishWorkspace(resource.id, control);
break;
}
if (control) publishWorkspace(resource.id, control);
break;
}
const item = mapReceived(event.item);
if (item.kind === 'file') visibleFiles.add(`${resource.id}:${item.id}`);
if (event.item.snapshot) {
let snapshot: Snapshot = { text: '', updatedBy: item.deviceId, updatedAt: item.ts };
if (item.kind === 'text') {
@@ -52,16 +70,22 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
break;
}
case 'announce': {
const key = `${resource.id}:${event.itemId}`;
visibleFiles.add(key);
const catalog = fileCatalogs.get(key);
if (catalog) { publishWorkspace(resource.id, { t: 'file-catalog', item: catalog }); break; }
const local = (await getResource(resource.id))?.localItems?.find((item) => item.id === event.itemId);
const placeholder: FileItem = local?.kind === 'file' ? local : {
id: event.itemId, kind: 'file', ts: event.createdAt, deviceId: '未知设备', state: { kind: 'incomplete' },
name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none',
gated: true,
gated: true, metadataHidden: true,
};
publishWorkspace(resource.id, { t: 'item', item: placeholder });
break;
}
case 'removed':
visibleFiles.delete(`${resource.id}:${event.itemId}`);
fileCatalogs.delete(`${resource.id}:${event.itemId}`);
blobs.delete(`${resource.id}:${event.itemId}`);
publishWorkspace(resource.id, { t: 'removed', id: event.itemId });
break;
@@ -79,13 +103,15 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise<void> {
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
resource.name = info.settings.name;
resource.membersCanUpload = info.settings.members_can_upload;
resource.expiresAt = info.expires_at;
const latest = await getResource(resource.id);
await saveResource({ ...latest, ...resource, localItems: latest?.localItems });
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
const ownId = toBase64Url(session.endpointHash);
let devices = [deviceInfo(ownId, Date.now(), deviceIdentity().name, ownId)];
const ownName = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
let devices = [deviceInfo(ownId, Date.now(), ownName, ownId)];
if (session.can('owner')) {
const seats = await readJson<Array<{ endpoint_hash: string; joined_at: number; label?: string }>>(
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
@@ -95,16 +121,15 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
try { name = await session.openLabel(seat.label, seat.endpoint_hash); }
catch { name = '设备名校验失败'; }
}
return deviceInfo(seat.endpoint_hash, seat.joined_at, name, ownId);
return deviceInfo(seat.endpoint_hash, seat.joined_at, name ?? (seat.endpoint_hash === ownId ? ownName : undefined), ownId);
}));
const own = devices.find((device) => device.self);
if (own) own.name = deviceIdentity().name;
}
publishWorkspace(resource.id, { t: 'devices', devices });
}
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
publishWorkspace(resource.id, { t: 'reset' });
clearFileCatalog(resource.id);
readers.set(resource.id, session.createItemReader());
let from = 0;
for (;;) {