feat(web): add account workspaces and signed content flows

This commit is contained in:
justme committed 2026-10-14 04:06:03 +08:00
1 parent 7b8d61d1ba
commit b5cf3d5958
117 files changed
+2789 -1674

No files matched your search

+22 -3
View File
@@ -7,11 +7,15 @@ import { useWorkspace } from './workspace';
import { initialData } from './reduce';
import { useShareSession } from './shareSession';
import { initialShareData } from './shareReduce';
import { loginWithPasskey, passkeyApi } from '@/core-bridge/passkeys';
interface AccountState extends AccountStatus {
busy: boolean;
hydrated: boolean;
login: (username: string, password: string, create: boolean, remember?: boolean) => Promise<void>;
passkeyLogin: (username: string, remember: boolean) => Promise<void>;
unlockPasskey: (password: string, remember: boolean) => Promise<void>;
cancelPasskey: () => Promise<void>;
refresh: () => Promise<void>;
logout: () => Promise<void>;
clearSavedLogin: () => Promise<void>;
@@ -19,7 +23,11 @@ interface AccountState extends AccountStatus {
updatePersonal: (displayName: string, avatar?: string) => Promise<void>;
}
async function refreshResources(): Promise<void> { await Promise.all([useChannels.getState().refresh(), useShares.getState().refresh()]); }
let lastResourceRefresh = 0;
async function refreshResources(): Promise<void> {
await Promise.all([useChannels.getState().refresh(), useShares.getState().refresh()]);
lastResourceRefresh = Date.now();
}
function clearViews(): void { useWorkspace.setState(initialData); useShareSession.setState(initialShareData); useShares.setState({ pendingPayload: null }); }
export const useAccount = create<AccountState>()((set, get) => ({
username: null,
@@ -27,6 +35,17 @@ export const useAccount = create<AccountState>()((set, get) => ({
error: undefined,
busy: false,
hydrated: false,
passkeyLogin: async (username, remember) => {
set({ busy: true, error: undefined });
try { const status = await loginWithPasskey(username, remember); if (status.username) { clearViews(); await refreshResources(); } set({ ...status, busy: false, hydrated: true }); }
catch (error) { set({ busy: false, error: error instanceof Error ? error.message : 'Passkey登录失败' }); throw error; }
},
unlockPasskey: async (password, remember) => {
set({ busy: true, error: undefined });
try { const status = await passkeyApi.unlock(password, remember); clearViews(); await refreshResources(); set({ ...status, unlockUsername: undefined, busy: false }); }
catch (error) { set({ busy: false, unlockUsername: undefined, error: error instanceof Error ? error.message : '账号解锁失败' }); throw error; }
},
cancelPasskey: async () => { await passkeyApi.cancel(); set({ unlockUsername: undefined, error: undefined }); },
login: async (username, password, create, remember = true) => {
set({ busy: true, error: undefined });
try {
@@ -42,8 +61,8 @@ export const useAccount = create<AccountState>()((set, get) => ({
try {
const status = await accountApi.status();
if (status.username !== get().username) clearViews();
if (!get().hydrated || status.username !== get().username || status.revision !== get().revision) await refreshResources();
set({ ...status, personal: status.personal, error: status.error, hydrated: true });
if (!get().hydrated || status.username !== get().username || status.revision !== get().revision || Date.now() - lastResourceRefresh >= 30_000) await refreshResources();
set({ ...status, personal: status.personal, unlockUsername: status.unlockUsername, error: status.error, hydrated: true });
} catch { set({ hydrated: true, error: '账号状态读取失败' }); }
},
logout: async () => {
+1 -6
View File
@@ -4,7 +4,7 @@ import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
import type { ParsedChannelLink } from '@/lib/channelLink';
interface ChannelsState {
/** 我的空间(原型:只在内存里,刷新即清空;真实实现存 IndexedDB,architecture §6) */
/** 服务端空间列表的界面投影,IndexedDB 提供离线缓存。 */
list: ChannelInfo[];
hydrated: boolean;
/** 从链接进来、还没确认加入的空间;含密钥,只放内存 */
@@ -17,7 +17,6 @@ interface ChannelsState {
hydrate: () => Promise<void>;
refresh: () => Promise<void>;
forget: (id: string) => Promise<void>;
loadDemo: () => void;
setPendingLink: (link: ParsedChannelLink | null) => void;
clearJustCreated: () => void;
}
@@ -50,10 +49,6 @@ export const useChannels = create<ChannelsState>()((set) => ({
await api.forget(id);
set((s) => ({ list: s.list.filter((c) => c.id !== id) }));
},
loadDemo: () => {
const demo = api.loadDemo?.() ?? [];
set((s) => ({ list: demo.reduce(upsert, s.list) }));
},
setPendingLink: (link) => set({ pendingLink: link }),
clearJustCreated: () => set({ justCreatedId: null }),
}));
+15 -50
View File
@@ -7,56 +7,21 @@ const text = (id: string, ts: number, body = id): TextItem => ({
});
describe('reduce', () => {
it('preserves sender identity across late content and download updates', () => {
const author = { username: 'alice', displayName: '小白', avatar: 'data:image/png;base64,AA==' };
let state = reduce(initialData, { t: 'item-author', id: 'message-a', author });
state = reduce(state, { t: 'item', item: text('message-a', 1) });
expect(state.byId['message-a']?.author).toEqual(author);
state = reduce(state, { t: 'item', item: { ...text('message-a', 1), state: { kind: 'complete' } } });
expect(state.byId['message-a']?.author).toEqual(author);
state = reduce(state, { t: 'removed', id: 'message-a' });
expect(state.authors['message-a']).toBeUndefined();
});
it('replays groups and channel membership without changing content locations', () => {
const thread = { id: 'channel-a', name: '常规', createdAt: 1, createdBy: 'alice' };
let state = reduce(initialData, { t: 'thread-created', thread });
state = reduce(state, { t: 'item', item: { ...text('file-a', 1), threadId: thread.id } });
const content = state.byId;
state = reduce(state, { t: 'group-created', group: { id: 'group-a', name: '工作', createdAt: 1 } });
state = reduce(state, { t: 'thread-grouped', id: thread.id, groupId: 'group-a' });
state = reduce(state, { t: 'group-renamed', id: 'group-a', name: '项目' });
expect(state.groups[0]?.name).toBe('项目'); expect(state.threads[0]?.groupId).toBe('group-a');
expect(state.byId).toBe(content); expect(state.byId['file-a']?.threadId).toBe(thread.id);
});
it('late channel creation cannot undo a group move or ungrouping', () => {
const thread = { id: 'channel-a', name: '常规', createdAt: 1, createdBy: 'alice', groupId: 'old-group' };
let state = reduce(initialData, { t: 'thread-grouped', id: thread.id, groupId: 'new-group' });
state = reduce(state, { t: 'thread-created', thread });
expect(state.threads[0]?.groupId).toBe('new-group');
state = reduce(state, { t: 'thread-grouped', id: thread.id });
state = reduce(state, { t: 'thread-created', thread });
expect(state.threads[0]?.groupId).toBeUndefined(); expect(state.threads).toHaveLength(1);
});
it('group creation is idempotent and resetting a space clears all groups', () => {
const event = { t: 'group-created' as const, group: { id: 'group-a', name: '工作', createdAt: 1 } };
let state = reduce(reduce(initialData, event), event);
expect(state.groups).toHaveLength(1);
state = reduce(state, { t: 'reset' });
expect(state.groups).toEqual([]); expect(state.threadGroups).toEqual({});
});
it('late item updates preserve the latest folder move', () => {
let state = reduce(initialData, { t: 'item', item: { ...text('a', 1), threadId: 'thread-a' } });
state = reduce(state, { t: 'items-moved', itemIds: ['a'], threadId: 'thread-b', folderId: 'folder-b' });
state = reduce(state, { t: 'item', item: { ...text('a', 1), threadId: 'thread-a' } });
expect(state.byId.a?.threadId).toBe('thread-b'); expect(state.byId.a?.folderId).toBe('folder-b');
});
it('a hidden restricted item reveals its latest location only after metadata is read', () => {
let state = reduce(initialData, { t: 'items-moved', itemIds: ['a'], threadId: 'thread-b', folderId: 'folder-b' });
const file = { id: 'a', kind: 'file' as const, ts: 1, deviceId: '', state: { kind: 'incomplete' as const }, name: '受限下载内容', mime: '', size: 0, remaining: null, preview: 'none' as const, metadataHidden: true };
state = reduce(state, { t: 'item', item: file });
expect(state.byId.a?.folderId).toBeUndefined();
state = reduce(state, { t: 'item', item: { ...file, name: 'real.txt', metadataHidden: false, threadId: 'old' } });
expect(state.byId.a?.folderId).toBe('folder-b');
it('renders server structure and preserves server author and location across late decrypted content', () => {
const stateEvent = { t: 'structure' as const, state: { revision: 1,
channels: [{ id: 'channel-a', name: '常规', groupId: 'group-a', createdAt: 1, createdBy: 'alice', chatEpoch: 1, filesEpoch: 1, chatHistory: true, filesHistory: true }],
groups: [{ id: 'group-a', name: '项目', createdAt: 1 }],
folders: [{ id: 'folder-a', name: '目录', threadId: 'channel-a' }],
locations: [{ itemId: 'a', threadId: 'channel-a', folderId: 'folder-a', authorAccount: 'alice' }] },
people: [{ username: 'alice', displayName: '服务端名称', modifiedAt: 1 }] };
let state = reduce(initialData, stateEvent);
state = reduce(state, { t: 'item', item: { ...text('a', 1), threadId: 'old', author: { username: 'mallory', displayName: '伪造名称' } } });
expect(state.byId.a?.threadId).toBe('channel-a'); expect(state.byId.a?.folderId).toBe('folder-a');
expect(state.byId.a?.author?.username).toBe('alice'); expect(state.groups[0]?.name).toBe('项目');
expect(state.byId.a?.author?.username).toBe('alice');
const ungrouped = { ...stateEvent, state: { ...stateEvent.state, channels: [{ ...stateEvent.state.channels[0]!, groupId: null }] } };
state = reduce(state, ungrouped); expect(state.threads[0]?.groupId).toBeUndefined();
state = reduce(state, { t: 'reset' }); expect(state.groups).toEqual([]);
});
it('新条目按时间倒序,新的在前', () => {
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
+16 -46
View File
@@ -18,9 +18,8 @@ export interface WorkspaceData {
devices: Device[];
threads: ThreadInfo[];
groups: ChannelGroupInfo[];
threadGroups: Record<string, string | null>;
folders: FolderInfo[];
locations: Record<string, { threadId: string; folderId?: string }>;
locations: Record<string, { threadId: string; folderId?: string; authorAccount?: string }>;
byId: Record<string, Item>;
authors: Record<string, SenderInfo>;
/** 新的在前 */
@@ -36,7 +35,6 @@ export const initialData: WorkspaceData = {
devices: [],
threads: [],
groups: [],
threadGroups: {},
folders: [],
locations: {},
byId: {},
@@ -55,6 +53,20 @@ function insertByTime(order: string[], byId: Record<string, Item>, item: Item):
export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
switch (e.t) {
case 'structure': {
const people = new Map(e.people.map(person => [person.username, person]));
const locations = Object.fromEntries(e.state.locations.map(location => [location.itemId, { threadId: location.threadId, folderId: location.folderId ?? undefined, authorAccount: location.authorAccount ?? undefined }]));
const authors = { ...s.authors };
for (const location of e.state.locations) {
const person = location.authorAccount ? people.get(location.authorAccount) : undefined;
if (person) authors[location.itemId] = { username: person.username, displayName: person.displayName || person.username, avatar: person.avatar ?? undefined };
}
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => [id, {
...item, ...(locations[id] ?? {}), ...(authors[id] ? { author: authors[id] } : {}),
}]));
return { ...s, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups,
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
}
case 'reset':
return { ...initialData, connection: s.connection };
case 'error':
@@ -67,56 +79,14 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
return { ...s, channel: e.info };
case 'devices':
return { ...s, devices: e.devices };
case 'thread-created': {
const thread = { ...e.thread };
if (thread.id in s.threadGroups) {
const groupId = s.threadGroups[thread.id];
if (groupId) thread.groupId = groupId;
else delete thread.groupId;
}
return { ...s, threads: [thread, ...s.threads.filter((entry) => entry.id !== thread.id)] };
}
case 'thread-renamed':
return { ...s, threads: s.threads.map((thread) => thread.id === e.id ? { ...thread, name: e.name } : thread) };
case 'group-created':
return { ...s, groups: [...s.groups.filter((group) => group.id !== e.group.id), e.group] };
case 'group-renamed':
return { ...s, groups: s.groups.map((group) => group.id === e.id ? { ...group, name: e.name } : group) };
case 'thread-grouped':
return { ...s, threadGroups: { ...s.threadGroups, [e.id]: e.groupId ?? null },
threads: s.threads.map((thread) => thread.id === e.id ? { ...thread, groupId: e.groupId } : thread) };
case 'folder-created':
return { ...s, folders: [e.folder, ...s.folders.filter((folder) => folder.id !== e.folder.id)] };
case 'folder-renamed':
return { ...s, folders: s.folders.map((folder) => folder.id === e.id ? { ...folder, name: e.name } : folder) };
case 'items-moved': {
const byId = { ...s.byId };
const locations = { ...s.locations };
for (const id of e.itemIds) {
locations[id] = { threadId: e.threadId, ...(e.folderId ? { folderId: e.folderId } : {}) };
const item = byId[id];
if (!item) continue;
if (item.kind === 'file' && item.metadataHidden) continue;
const moved = { ...item, threadId: e.threadId };
if (e.folderId === undefined) delete moved.folderId;
else moved.folderId = e.folderId;
byId[id] = moved;
}
return { ...s, byId, locations };
}
case 'snapshot':
return { ...s, snapshot: e.snapshot };
case 'item-author': {
const item = s.byId[e.id];
return { ...s, authors: { ...s.authors, [e.id]: e.author },
byId: item ? { ...s.byId, [e.id]: { ...item, author: e.author } } : s.byId };
}
case 'file-catalog':
case 'item': {
const known = e.item.id in s.byId;
let item = e.item;
const current = s.byId[item.id];
const author = item.author ?? s.authors[item.id] ?? current?.author;
const author = s.authors[item.id] ?? item.author ?? current?.author;
if (author) item = { ...item, author };
if (e.t === 'item' && item.kind === 'file' && item.metadataHidden && current?.kind === 'file' && !current.metadataHidden) return s;
const location = s.locations[item.id];
+34
View File
@@ -0,0 +1,34 @@
import { create } from 'zustand';
import { useEffect } from 'react';
import { serverApi } from '@/core-bridge/server';
import type { ServerInfoBody } from '@/core-bridge/generated/api';
interface ServerState {
info?: ServerInfoBody;
error?: string;
load: () => Promise<ServerInfoBody>;
}
let loading: Promise<ServerInfoBody> | undefined;
export const useServer = create<ServerState>()((set, get) => ({
load: () => {
const info = get().info;
if (info) return Promise.resolve(info);
loading ??= serverApi.info().then((value) => {
if (!value.defaults || !value.choices) throw new Error('服务端需要升级以提供统一设置');
set({ info: value, error: undefined });
return value;
}).catch((error: unknown) => {
set({ error: error instanceof Error ? error.message : '服务端设置读取失败' });
throw error;
}).finally(() => { loading = undefined; });
return loading;
},
}));
export function useServerSettings() {
const info = useServer(s => s.info);
const error = useServer(s => s.error);
const load = useServer(s => s.load);
useEffect(() => { void load().catch(() => {}); }, [load]);
return { info, error, load };
}
+4 -10
View File
@@ -3,12 +3,10 @@ import { shareApi } from '@/core-bridge/instance';
import type { Share, ShareSettings } from '@/core-bridge/types';
import type { ClipboardPayload } from '@/lib/clipboard';
import type { ParsedShareLink } from '@/lib/shareLink';
/** 便捷分享的默认设置:最多 10 位访客、24 小时、每个文件最多下载 10 次 */
export const SHARE_DEFAULTS: ShareSettings = { ttlSeconds: 86400, maxVisitors: 10, maxDownloads: 10 };
import { useServer } from './server';
interface SharesState {
/** 我的分享:自己创建的(草稿、已封存)和收到的。原型只在内存里 */
/** 我的分享:服务端状态及本机保存的访问凭据。 */
list: Share[];
hydrated: boolean;
/** 从链接进来、还没加入的分享;含密钥,只放内存 */
@@ -26,7 +24,6 @@ interface SharesState {
/** 访客加入:直接进入,不预览 */
join: (link: ParsedShareLink) => Promise<Share>;
forget: (id: string) => Promise<void>;
loadDemo: () => void;
setPendingLink: (link: ParsedShareLink | null) => void;
consumePayload: () => ClipboardPayload | null;
}
@@ -41,7 +38,8 @@ export const useShares = create<SharesState>()((set, get) => ({
pendingPayload: null,
createDraft: async (payload, captchaToken) => {
const s = await shareApi.createDraft(SHARE_DEFAULTS, captchaToken);
const { share } = (await useServer.getState().load()).defaults;
const s = await shareApi.createDraft({ ttlSeconds: share.ttl_seconds, maxVisitors: share.max_visitors, maxDownloads: share.file_max_downloads || null }, captchaToken);
set((st) => ({ list: upsert(st.list, s), pendingPayload: payload ?? null }));
return s;
},
@@ -69,10 +67,6 @@ export const useShares = create<SharesState>()((set, get) => ({
await shareApi.forget(id);
set((st) => ({ list: st.list.filter((x) => x.id !== id) }));
},
loadDemo: () => {
const demo = shareApi.loadDemo?.() ?? [];
set((st) => ({ list: demo.reduce(upsert, st.list) }));
},
setPendingLink: (link) => set({ pendingLink: link }),
consumePayload: () => {
const p = get().pendingPayload;
+3 -3
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { api } from '@/core-bridge/instance';
import type { ChannelSettingsPatch, ConnectionState, FileUploadOptions } from '@/core-bridge/types';
import type { ChannelSettingsPatch, FileUploadOptions } from '@/core-bridge/types';
import { initialData, reduce, type WorkspaceData } from './reduce';
interface Actions {
@@ -19,6 +19,7 @@ interface Actions {
renameGroup: typeof api.renameGroup;
groupThread: typeof api.groupThread;
renameThread: (threadId: string, name: string) => Promise<void>;
setChannelHistory: typeof api.setChannelHistory;
createFolder: (threadId: string, name: string, parentId?: string) => Promise<void>;
renameFolder: (folderId: string, threadId: string, name: string) => Promise<void>;
moveItems: (itemIds: string[], threadId: string, folderId?: string) => Promise<void>;
@@ -29,7 +30,6 @@ interface Actions {
getBlob: (id: string, versionId?: string) => Promise<Blob>;
markSeen: (id: string) => Promise<void>;
listArchive: typeof api.listArchive;
simulateConnection: (state: ConnectionState) => void;
}
export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
@@ -51,6 +51,7 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
renameGroup: (id, name) => api.renameGroup(id, name),
groupThread: (id, groupId) => api.groupThread(id, groupId),
renameThread: (threadId, name) => api.renameThread(threadId, name),
setChannelHistory: (threadId, chatHistory, filesHistory) => api.setChannelHistory(threadId, chatHistory, filesHistory),
createFolder: (threadId, name, parentId) => api.createFolder(threadId, name, parentId),
renameFolder: (folderId, threadId, name) => api.renameFolder(folderId, threadId, name),
moveItems: (itemIds, threadId, folderId) => api.moveItems(itemIds, threadId, folderId),
@@ -61,5 +62,4 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
getBlob: (id, v) => api.getBlob(id, v),
markSeen: (id) => api.markSeen(id),
listArchive: (id) => api.listArchive(id),
simulateConnection: (s) => api.simulateConnection?.(s),
}));