feat(web): add account workspaces and signed content flows
This commit is contained in:
1 parent
7b8d61d1ba
commit
b5cf3d5958
117 files changed
+2789
-1674
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
Reference in new issue
Block a user