feat(web): role groups, last seen and unread markers in members, channels and spaces; lock channels without keys

This commit is contained in:
justme committed 2026-10-17 21:31:15 +08:00
1 parent 5043c798ff
commit d1ff8bab7d
23 files changed
+381 -34

No files matched your search

+7
View File
@@ -14,6 +14,8 @@ export interface ChannelInfo {
name?: string;
/** 空间头像(data URL,可以是 GIF 动图) */
avatar?: string;
/** 空间日志的最新序号,用来判断别的空间有没有新动静 */
lastSeq?: number;
mode: ChannelMode;
role: 'owner' | 'member';
maxMembers: number;
@@ -170,6 +172,8 @@ export type FolderInfo = Omit<WorkspaceFolder, 'parentId'> & { parentId?: string
export type WorkspaceEvent =
| { t: 'structure'; state: WorkspaceState; people: AccountProfile[] }
/** 加密空间里这个账号没有密钥的频道(能看到名字,不能读也不能写) */
| { t: 'access'; locked: string[] }
| { t: 'reset' }
| { t: 'error'; message: string }
| { t: 'ready' }
@@ -180,6 +184,9 @@ export type WorkspaceEvent =
| { t: 'removed'; id: string }
| { t: 'snapshot'; snapshot: Snapshot };
/** 账号所在空间的日志序号前进了(空间列表的未读提示用) */
export type SpaceEvent = { t: 'space-seq'; id: string; lastSeq: number };
export type ShareEvent =
| { t: 'ready' }
| { t: 'share'; share: Share }
+3
View File
@@ -12,6 +12,7 @@ import { connectShare, getShareBlob, stopShare } from './worker/shareRead';
import { createShare, joinShare, listSavedShares, removeShare, updateShareSettings } from './worker/shareResources';
import { sealShare } from './worker/shareSeal';
import { setEventSink } from './worker/port';
import { stopWatchingSpaces, watchSpaces } from './worker/spaceWatch';
import { serial } from './worker/serial';
import { errorMessage } from './errors';
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
@@ -88,6 +89,8 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
case 'forgetChannel': return forgetChannel(id);
case 'connectChannel': void connectChannelResource(id); return true;
case 'stopChannel': stopChannel(id); return true;
case 'watchSpaces': watchSpaces(); return true;
case 'stopWatchingSpaces': stopWatchingSpaces(); return true;
case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined, undefined, typeof payload.localId === 'string' ? payload.localId : undefined);
case 'cancelUpload': return cancelFileUpload(id, String(payload.localId ?? ''));
case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters<typeof sendFiles>[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined, Array.isArray(payload.localIds) ? payload.localIds as string[] : undefined);
@@ -154,6 +154,14 @@ export async function prepareObject(resource: SavedResource, session: ChannelSes
const state = states.get(resource.id); if (state) state.objects.push(object);
return { itemIdBytes: idBytes, itemId, channelId, domain, epoch };
}
/** 加密空间里没拿到聊天数据域密钥的频道:成员只能看到频道名,读写都会被拒绝。空间主的新频道首次使用时才初始化密钥,不算锁定 */
export async function lockedThreads(resource: SavedResource, session: ChannelSession, channels: readonly { id: string; chatEpoch: number }[]): Promise<string[]> {
if (resource.mode !== 'enc') return [];
const owner = session.can('owner');
const missing = (keys: Record<string, string>) => channels.filter(c => !(owner && c.chatEpoch === 0) && !keys[domainId(c.id, 'chat', c.chatEpoch)]).map(c => c.id);
const { keys } = await cachedKeys(resource, session, data => missing(data.keys).length === 0);
return missing(keys);
}
export async function canDecryptRecord(resource: SavedResource, session: ChannelSession, itemId: string): Promise<boolean> {
if (resource.mode !== 'enc') return true;
let state = states.get(resource.id); let object = state?.objects.find(o => o.itemId === itemId);
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ShareEvent, WorkspaceEvent } from '../types';
import type { ShareEvent, SpaceEvent, WorkspaceEvent } from '../types';
type BridgeEvent = WorkspaceEvent | ShareEvent;
type BridgeEvent = WorkspaceEvent | ShareEvent | SpaceEvent;
type EventSink = (resourceId: string, event: BridgeEvent, blob?: Blob) => void;
let sink: EventSink = () => {};
+64
View File
@@ -0,0 +1,64 @@
import { apiFetch } from './http';
import { publish } from './port';
import { delay } from './transports';
/** 账号所在空间的实时进展(只有序号):空间列表据此提示「有新动静」,不必逐个空间订阅 */
export const SPACES_RESOURCE = '__spaces';
const IDLE_MS = 45_000;
let running: AbortController | undefined;
async function readOnce(signal: AbortSignal): Promise<void> {
const controller = new AbortController();
const abort = () => controller.abort();
signal.addEventListener('abort', abort, { once: true });
let timer = setTimeout(abort, IDLE_MS);
try {
const response = await apiFetch('/accounts/spaces/events', { signal: controller.signal });
if (!response.body) throw new Error('连接失败');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (!signal.aborted) {
const chunk = await reader.read();
if (chunk.done) break;
clearTimeout(timer);
timer = setTimeout(abort, IDLE_MS);
buffer += decoder.decode(chunk.value, { stream: true }).replace(/\r/g, '');
let boundary: number;
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const lines = buffer.slice(0, boundary).split('\n');
buffer = buffer.slice(boundary + 2);
if (lines.find((line) => line.startsWith('event:'))?.slice(6).trim() !== 'space') continue;
const data = lines.filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
const parsed = JSON.parse(data) as { id?: string; last_seq?: number };
if (parsed.id && typeof parsed.last_seq === 'number') publish(SPACES_RESOURCE, { t: 'space-seq', id: parsed.id, lastSeq: parsed.last_seq });
}
}
} finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); }
} finally {
clearTimeout(timer);
signal.removeEventListener('abort', abort);
controller.abort();
}
}
export function watchSpaces(): void {
if (running) return;
const controller = running = new AbortController();
void (async () => {
let wait = 1000;
while (!controller.signal.aborted) {
const started = Date.now();
try { await readOnce(controller.signal); } catch { /* 断线或未登录:退避后重连 */ }
wait = Date.now() - started > 30_000 ? 1000 : Math.min(wait * 2, 30_000);
await delay(wait, controller.signal);
}
})();
}
export function stopWatchingSpaces(): void {
running?.abort();
running = undefined;
}
+1
View File
@@ -11,6 +11,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
joinPolicy, passwordRequired: raw.password_required,
name: raw.settings.name ?? undefined,
avatar: raw.avatar ?? undefined,
lastSeq: raw.last_seq,
mode: raw.mode,
role: raw.can_manage_structure ? 'owner' : 'member',
canUpload: raw.can_upload,
@@ -1,6 +1,6 @@
import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest, WorkspaceRoleCreatedBody, WorkspaceRoleRequest } from '../generated/api';
import type { FileItem } from '../types';
import { getResource, cacheResource } from '../database';
import { getResource, cacheResource, currentResourceScope } from '../database';
import { getSession } from './sessions';
import { apiFetch } from './http';
import { publishWorkspace } from './presentation';
@@ -34,6 +34,17 @@ async function emit(id: string, body: WorkspaceBody): Promise<void> {
}));
if (current.get(id) !== body) return;
publishWorkspace(id, { t: 'structure', state: body.structure, people });
void publishAccess(id, body);
}
async function publishAccess(id: string, body: WorkspaceBody): Promise<void> {
try {
const resource = await getResource(id); if (!resource) return;
const { session } = await getSession(id);
const { lockedThreads } = await import('./channelDataKeys');
const locked = await lockedThreads(resource, session, body.structure.channels);
if (current.get(id) === body) publishWorkspace(id, { t: 'access', locked });
} catch { /* 账号没解锁或离线:不改变现有状态 */ }
}
const republishing = new Set<string>();
async function loadAvatars(id: string, body: WorkspaceBody): Promise<void> {
@@ -54,8 +65,11 @@ export function cachedPerson(id: string, username: string): WorkspaceBody['peopl
return current.get(id)?.people.find(person => person.username === username);
}
export async function loadWorkspace(id: string): Promise<WorkspaceBody> {
const scope = currentResourceScope();
const { session } = await getSession(id);
const body = await apiFetch(`/spaces/${id}/structure`, { headers: session.authHeaders('read') }).then(r => r.json() as Promise<WorkspaceBody>);
// 请求期间换了账号:这份结构是上一个账号的视角,不能再发给界面或写进缓存
if (currentResourceScope() !== scope) throw new Error('账号已切换');
return publish(id, body);
}
/** 自己改了头像或名字后立刻重新拉取所有已打开空间的成员资料(其他人靠服务端的设置变更通知) */
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ShareEvent, WorkspaceEvent } from './types';
import type { ShareEvent, SpaceEvent, WorkspaceEvent } from './types';
type BridgeEvent = WorkspaceEvent | ShareEvent;
type BridgeEvent = WorkspaceEvent | ShareEvent | SpaceEvent;
type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void };
let worker: Worker | undefined;