Merge master into feat/file-api
This commit is contained in:
commit
d3da3597cc
34 files changed
+551
-52
No files matched your search
@@ -1,5 +1,5 @@
|
||||
// Generated from backend DTOs; do not edit.
|
||||
// backend-contract-sha256: 1b3b8d56bf4262d61d7c2814fcd788b09225ccc94120084673ea46de46c09593
|
||||
// backend-contract-sha256: 6e4037d39ac5ae791724731810e3df5b791839e8ab29cf54643ec1a294132964
|
||||
|
||||
export interface AccountChannelBody {
|
||||
channel_id: string;
|
||||
@@ -224,6 +224,7 @@ export interface KeyPackageRequest {
|
||||
export interface MemberBody {
|
||||
devices: number;
|
||||
joined_at: number;
|
||||
last_seen: number;
|
||||
online: boolean;
|
||||
online_devices: number;
|
||||
role: string;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -233,13 +233,21 @@ export async function clearSavedLogin(): Promise<void> {
|
||||
}
|
||||
await forgetAccount(); await forgetLoginFields();
|
||||
}
|
||||
/** 原图超过这个大小(字符数)就分两步传:先传缩略图,头像立刻生效,原图在后台慢慢传(弱网下 MB 级的动图一次传完要等很久) */
|
||||
const SPLIT_UPLOAD_CHARS = 600_000;
|
||||
|
||||
export async function updatePersonal(displayName: string, change?: AvatarChange): Promise<void> {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
const session = account;
|
||||
const send = async (request: AccountProfileRequest) => {
|
||||
const { body, headers: encoding } = await jsonBody(request);
|
||||
return apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise<AccountProfile>);
|
||||
};
|
||||
const split = change && change !== 'keep' && change.avatar.length > SPLIT_UPLOAD_CHARS;
|
||||
const request: AccountProfileRequest = change === 'keep' ? { displayName, keepAvatar: true }
|
||||
: split ? { displayName, keepAvatar: false, avatar: change.thumb, avatarThumb: change.thumb }
|
||||
: change ? { displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb } : { displayName, keepAvatar: false };
|
||||
const { body, headers: encoding } = await jsonBody(request);
|
||||
const value = await apiFetch('/accounts/profile', { method: 'PATCH', headers: { ...headers(session), ...encoding }, body }).then(r => r.json() as Promise<AccountProfile>);
|
||||
const value = await send(request);
|
||||
stillActive(session);
|
||||
const previous = session.personal?.avatar;
|
||||
session.personal = personalProfile(value);
|
||||
@@ -249,6 +257,23 @@ export async function updatePersonal(displayName: string, change?: AvatarChange)
|
||||
primeAvatar(value.username, value.modifiedAt, shown);
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
|
||||
await persistSession(session, { vault: await sealJson(session.profile, session.key), revision: session.revision });
|
||||
if (split) void uploadOriginal(session, displayName, change, send);
|
||||
}
|
||||
|
||||
/** 后台补传原图;失败就保留缩略图当头像(预览时看到的是缩略图),不打扰用户。重试几次,间隔逐步拉长 */
|
||||
async function uploadOriginal(session: NonNullable<typeof account>, displayName: string, change: { avatar: string; thumb: string }, send: (request: AccountProfileRequest) => Promise<AccountProfile>): Promise<void> {
|
||||
for (const wait of [0, 5_000, 20_000, 60_000]) {
|
||||
if (wait) await new Promise((resolve) => setTimeout(resolve, wait));
|
||||
if (account !== session) return;
|
||||
try {
|
||||
const value = await send({ displayName, keepAvatar: false, avatar: change.avatar, avatarThumb: change.thumb });
|
||||
if (account !== session) return;
|
||||
session.personal = { ...personalProfile(value), avatar: change.thumb, hasAvatar: true };
|
||||
primeAvatar(value.username, value.modifiedAt, change.thumb);
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.personal);
|
||||
return;
|
||||
} catch { /* 弱网或暂时失败:稍后重试 */ }
|
||||
}
|
||||
}
|
||||
export async function listAccountDevices(): Promise<AccountDeviceBody[]> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json() as Promise<AccountDeviceBody[]>); }
|
||||
export async function renameAccountDevice(id: string, name: string): Promise<void> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
/** 自己改了头像或名字后立刻重新拉取所有已打开空间的成员资料(其他人靠服务端的设置变更通知) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user