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

+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand';
import { accountApi } from '@/core-bridge/account';
import type { AccountStatus } from '@/core-bridge/account';
import { useChannels } from './channels';
import { useShares } from './shares';
import { useWorkspace } from './workspace';
import { initialData } from './reduce';
import { useShareSession } from './shareSession';
import { initialShareData } from './shareReduce';
interface AccountState extends AccountStatus {
busy: boolean;
hydrated: boolean;
login: (username: string, password: string, create: boolean) => Promise<void>;
refresh: () => Promise<void>;
logout: () => Promise<void>;
sync: () => Promise<void>;
updatePersonal: (displayName: string, avatar?: string) => Promise<void>;
}
async function refreshResources(): Promise<void> { await Promise.all([useChannels.getState().refresh(), useShares.getState().refresh()]); }
function clearViews(): void { useWorkspace.setState(initialData); useShareSession.setState(initialShareData); useShares.setState({ pendingPayload: null }); }
export const useAccount = create<AccountState>()((set, get) => ({
username: null,
revision: 0,
error: undefined,
busy: false,
hydrated: false,
login: async (username, password, create) => {
set({ busy: true, error: undefined });
try {
const status = create ? await accountApi.register(username, password) : await accountApi.login(username, password);
clearViews(); await refreshResources();
set({ ...status, busy: false, hydrated: true });
} catch (error) {
set({ error: error instanceof Error ? error.message : '登录失败', busy: false, hydrated: true });
throw error;
}
},
refresh: async () => {
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 });
} catch { set({ hydrated: true, error: '账号状态读取失败' }); }
},
logout: async () => {
set({ busy: true });
try { await accountApi.logout(); clearViews(); await refreshResources(); }
finally { set({ username: null, personal: undefined, revision: 0, busy: false, error: undefined }); }
},
sync: async () => {
set({ busy: true, error: undefined });
try { const status = await accountApi.sync(); await refreshResources(); set({ ...status, busy: false }); }
catch (error) { set({ error: error instanceof Error ? error.message : '同步失败', busy: false }); throw error; }
},
updatePersonal: async (displayName, avatar) => {
set({ busy: true, error: undefined });
try { const status = await accountApi.updatePersonal(displayName, avatar); set({ ...status, busy: false }); }
catch (error) { set({ error: error instanceof Error ? error.message : '资料保存失败', busy: false }); throw error; }
},
}));
+8 -6
View File
@@ -4,17 +4,18 @@ import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
import type { ParsedChannelLink } from '@/lib/channelLink';
interface ChannelsState {
/** 我的频道(原型:只在内存里,刷新即清空;真实实现存 IndexedDB,architecture §6) */
/** 我的空间(原型:只在内存里,刷新即清空;真实实现存 IndexedDB,architecture §6) */
list: ChannelInfo[];
hydrated: boolean;
/** 从链接进来、还没确认加入的频道;含密钥,只放内存 */
/** 从链接进来、还没确认加入的空间;含密钥,只放内存 */
pendingLink: ParsedChannelLink | null;
/** 刚创建的频道:进入时直接展示分享面板与二维码 */
/** 刚创建的空间:进入时直接展示分享面板与二维码 */
justCreatedId: string | null;
create: (opts: CreateOptions, captchaToken?: string) => Promise<ChannelInfo>;
join: (link: ParsedChannelLink) => Promise<ChannelInfo>;
join: (link: ParsedChannelLink, password?: string) => Promise<ChannelInfo>;
hydrate: () => Promise<void>;
refresh: () => Promise<void>;
forget: (id: string) => Promise<void>;
loadDemo: () => void;
setPendingLink: (link: ParsedChannelLink | null) => void;
@@ -35,8 +36,8 @@ export const useChannels = create<ChannelsState>()((set) => ({
set((s) => ({ list: upsert(s.list, c), justCreatedId: c.id }));
return c;
},
join: async (link) => {
const c = await api.joinChannel(link);
join: async (link, password) => {
const c = await api.joinChannel(link, password);
set((s) => ({ list: upsert(s.list, c), pendingLink: null }));
return c;
},
@@ -44,6 +45,7 @@ export const useChannels = create<ChannelsState>()((set) => ({
hydration ??= api.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
return hydration;
},
refresh: async () => set({ list: await api.listSaved(), hydrated: true }),
forget: async (id) => {
await api.forget(id);
set((s) => ({ list: s.list.filter((c) => c.id !== id) }));
+51
View File
@@ -7,6 +7,57 @@ 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('新条目按时间倒序,新的在前', () => {
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
s = reduce(s, { t: 'item', item: text('c', 3) });
+70 -3
View File
@@ -1,9 +1,13 @@
import type {
ChannelInfo,
ChannelGroupInfo,
ConnectionState,
Device,
FolderInfo,
Item,
SenderInfo,
Snapshot,
ThreadInfo,
WorkspaceEvent,
} from '@/core-bridge/types';
@@ -12,7 +16,13 @@ export interface WorkspaceData {
connection: ConnectionState;
channel: ChannelInfo | null;
devices: Device[];
threads: ThreadInfo[];
groups: ChannelGroupInfo[];
threadGroups: Record<string, string | null>;
folders: FolderInfo[];
locations: Record<string, { threadId: string; folderId?: string }>;
byId: Record<string, Item>;
authors: Record<string, SenderInfo>;
/** 新的在前 */
order: string[];
snapshot: Snapshot;
@@ -24,7 +34,13 @@ export const initialData: WorkspaceData = {
connection: 'offline',
channel: null,
devices: [],
threads: [],
groups: [],
threadGroups: {},
folders: [],
locations: {},
byId: {},
authors: {},
order: [],
snapshot: { text: '', updatedBy: '', updatedAt: 0 },
error: null,
@@ -51,18 +67,69 @@ 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;
const byId = { ...s.byId, [e.item.id]: e.item };
return { ...s, byId, order: known ? s.order : insertByTime(s.order, byId, e.item) };
let item = e.item;
const current = s.byId[item.id];
const author = item.author ?? s.authors[item.id] ?? 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];
if (location && !(item.kind === 'file' && item.metadataHidden)) item = { ...item, threadId: location.threadId, folderId: location.folderId };
const byId = { ...s.byId, [item.id]: item };
return { ...s, byId, ...(author ? { authors: { ...s.authors, [item.id]: author } } : {}), order: known ? s.order : insertByTime(s.order, byId, item) };
}
case 'removed': {
if (!(e.id in s.byId)) return s;
const byId = { ...s.byId };
delete byId[e.id];
return { ...s, byId, order: s.order.filter((id) => id !== e.id) };
const authors = { ...s.authors }; delete authors[e.id];
return { ...s, byId, authors, order: s.order.filter((id) => id !== e.id) };
}
default: {
const exhaustive: never = e;
+1 -1
View File
@@ -13,7 +13,7 @@ interface Actions {
listArchive: typeof shareApi.listArchive;
}
/** 正在打开的那个便捷分享(草稿或已封存)。和频道的 useWorkspace 互不相干 */
/** 正在打开的那个便捷分享(草稿或已封存)。和空间的 useWorkspace 互不相干 */
export const useShareSession = create<ShareData & Actions>()(() => ({
...initialShareData,
open: (shareId) => {
+2
View File
@@ -19,6 +19,7 @@ interface SharesState {
/** 新建草稿(可带上要放进去的内容) */
createDraft: (payload?: ClipboardPayload, captchaToken?: string) => Promise<Share>;
hydrate: () => Promise<void>;
refresh: () => Promise<void>;
updateSettings: (id: string, patch: Partial<ShareSettings>) => Promise<Share>;
/** 完成分享:此后无人可改 */
seal: (id: string) => Promise<Share>;
@@ -48,6 +49,7 @@ export const useShares = create<SharesState>()((set, get) => ({
hydration ??= shareApi.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
return hydration;
},
refresh: async () => set({ list: await shareApi.listSaved(), hydrated: true }),
updateSettings: async (id, patch) => {
const s = await shareApi.updateSettings(id, patch);
set((st) => ({ list: upsert(st.list, s) }));
+35 -7
View File
@@ -1,13 +1,27 @@
import { create } from 'zustand';
import { api } from '@/core-bridge/instance';
import type { ConnectionState } from '@/core-bridge/types';
import type { ChannelSettingsPatch, ConnectionState, FileUploadOptions } from '@/core-bridge/types';
import { initialData, reduce, type WorkspaceData } from './reduce';
interface Actions {
/** 打开(订阅)一个频道:先清空上一个频道的数据,返回取消订阅函数 */
inviteUser: typeof api.inviteUser;
listInvitations: typeof api.listInvitations;
revokeInvitation: typeof api.revokeInvitation;
/** 打开(订阅)一个空间:先清空上一个空间的数据,返回取消订阅函数 */
open: (channelId: string) => () => void;
sendText: (text: string) => Promise<void>;
sendFiles: (files: File[]) => Promise<void>;
sendText: (text: string, threadId?: string) => Promise<void>;
updateChannelSettings: (patch: ChannelSettingsPatch) => Promise<void>;
renameDevice: (endpointHash: string, name: string) => Promise<void>;
releaseDevice: (endpointHash: string) => Promise<void>;
sendFiles: (files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string) => Promise<void>;
createThread: (name: string, groupId?: string) => Promise<string>;
createGroup: typeof api.createGroup;
renameGroup: typeof api.renameGroup;
groupThread: typeof api.groupThread;
renameThread: (threadId: string, name: string) => Promise<void>;
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>;
editText: (id: string, text: string) => Promise<void>;
resolveConflict: (id: string, choice: 'mine' | 'theirs') => Promise<void>;
remove: (id: string) => Promise<void>;
@@ -20,13 +34,27 @@ interface Actions {
export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
...initialData,
inviteUser: (username) => api.inviteUser(username),
listInvitations: () => api.listInvitations(),
revokeInvitation: (username) => api.revokeInvitation(username),
open: (channelId) => {
useWorkspace.setState(initialData);
return api.connect(channelId, (e) => useWorkspace.setState((s) => reduce(s, e)));
},
sendText: (t) => api.sendText(t),
sendFiles: (f) => api.sendFiles(f),
editText: (id, t) => api.editText(id, t),
sendText: (t, threadId) => api.sendText(t, threadId),
updateChannelSettings: (patch) => api.updateChannelSettings(patch),
renameDevice: (endpointHash, name) => api.renameDevice(endpointHash, name),
releaseDevice: (endpointHash) => api.releaseDevice(endpointHash),
sendFiles: (f, options, threadId, folderId) => api.sendFiles(f, options, threadId, folderId),
createThread: (name, groupId) => api.createThread(name, groupId),
createGroup: (name) => api.createGroup(name),
renameGroup: (id, name) => api.renameGroup(id, name),
groupThread: (id, groupId) => api.groupThread(id, groupId),
renameThread: (threadId, name) => api.renameThread(threadId, name),
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),
editText: (id, t): Promise<void> => api.editText(id, t, useWorkspace.getState().byId[id]?.threadId),
resolveConflict: (id, c) => api.resolveConflict(id, c),
remove: (id) => api.remove(id),
setSnapshot: (t) => api.setSnapshot(t),