feat(web): add spaces, grouped channels and file chat
This commit is contained in:
1 parent
c7d8ef15bc
commit
f6cfb12716
111 files changed
+4371
-506
No files matched your search
@@ -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; }
|
||||
},
|
||||
}));
|
||||
@@ -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) }));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -13,7 +13,7 @@ interface Actions {
|
||||
listArchive: typeof shareApi.listArchive;
|
||||
}
|
||||
|
||||
/** 正在打开的那个便捷分享(草稿或已封存)。和频道的 useWorkspace 互不相干 */
|
||||
/** 正在打开的那个便捷分享(草稿或已封存)。和空间的 useWorkspace 互不相干 */
|
||||
export const useShareSession = create<ShareData & Actions>()(() => ({
|
||||
...initialShareData,
|
||||
open: (shareId) => {
|
||||
|
||||
@@ -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) }));
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user