feat(web): connect quick share flow

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:37 +08:00
1 parent 53768a195d
commit e02e024605
138 files changed
+10614

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
import { create } from 'zustand';
import { api } from '@/core-bridge/instance';
import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
import type { ParsedChannelLink } from '@/lib/channelLink';
interface ChannelsState {
/** 我的频道(原型:只在内存里,刷新即清空;真实实现存 IndexedDB,architecture §6) */
list: ChannelInfo[];
/** 从链接进来、还没确认加入的频道;含密钥,只放内存 */
pendingLink: ParsedChannelLink | null;
/** 刚创建的频道:进入时直接展示分享面板与二维码 */
justCreatedId: string | null;
create: (opts: CreateOptions) => Promise<ChannelInfo>;
join: (link: ParsedChannelLink) => Promise<ChannelInfo>;
forget: (id: string) => void;
loadDemo: () => void;
setPendingLink: (link: ParsedChannelLink | null) => void;
clearJustCreated: () => void;
}
const upsert = (list: ChannelInfo[], c: ChannelInfo) => [c, ...list.filter((x) => x.id !== c.id)];
export const useChannels = create<ChannelsState>()((set) => ({
list: [],
pendingLink: null,
justCreatedId: null,
create: async (opts) => {
const c = await api.createChannel(opts);
set((s) => ({ list: upsert(s.list, c), justCreatedId: c.id }));
return c;
},
join: async (link) => {
const c = await api.joinChannel(link);
set((s) => ({ list: upsert(s.list, c), pendingLink: null }));
return c;
},
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 }),
}));
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import type { TextItem } from '@/core-bridge/types';
import { initialData, reduce } from './reduce';
const text = (id: string, ts: number, body = id): TextItem => ({
id, kind: 'text', ts, deviceId: 'self', state: { kind: 'complete' }, text: body,
});
describe('reduce', () => {
it('新条目按时间倒序,新的在前', () => {
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
s = reduce(s, { t: 'item', item: text('c', 3) });
s = reduce(s, { t: 'item', item: text('b', 2) });
expect(s.order).toEqual(['c', 'b', 'a']);
});
it('同 id 再次到达是更新,不改变顺序也不重复', () => {
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
s = reduce(s, { t: 'item', item: text('b', 2) });
s = reduce(s, { t: 'item', item: text('a', 1, '改过') });
expect(s.order).toEqual(['b', 'a']);
expect((s.byId.a as TextItem).text).toBe('改过');
});
it('移除条目,未知 id 不改变状态', () => {
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
const same = reduce(s, { t: 'removed', id: 'zzz' });
expect(same).toBe(s);
s = reduce(s, { t: 'removed', id: 'a' });
expect(s.order).toEqual([]);
expect(s.byId).toEqual({});
});
});
+66
View File
@@ -0,0 +1,66 @@
import type {
ChannelInfo,
ConnectionState,
Device,
Item,
Snapshot,
WorkspaceEvent,
} from '@/core-bridge/types';
export interface WorkspaceData {
ready: boolean;
connection: ConnectionState;
channel: ChannelInfo | null;
devices: Device[];
byId: Record<string, Item>;
/** 新的在前 */
order: string[];
snapshot: Snapshot;
}
export const initialData: WorkspaceData = {
ready: false,
connection: 'offline',
channel: null,
devices: [],
byId: {},
order: [],
snapshot: { text: '', updatedBy: '', updatedAt: 0 },
};
function insertByTime(order: string[], byId: Record<string, Item>, item: Item): string[] {
const at = order.findIndex((id) => (byId[id]?.ts ?? 0) < item.ts);
const next = order.slice();
next.splice(at === -1 ? next.length : at, 0, item.id);
return next;
}
export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
switch (e.t) {
case 'ready':
return { ...s, ready: true };
case 'connection':
return { ...s, connection: e.state };
case 'channel':
return { ...s, channel: e.info };
case 'devices':
return { ...s, devices: e.devices };
case 'snapshot':
return { ...s, snapshot: e.snapshot };
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) };
}
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) };
}
default: {
const exhaustive: never = e;
return exhaustive;
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import type { Share, TextItem } from '@/core-bridge/types';
import { initialShareData, reduceShare } from './shareReduce';
const text = (id: string, ts: number): TextItem => ({ id, kind: 'text', ts, deviceId: 'x', state: { kind: 'complete' }, text: id });
const share: Share = { id: 's', state: 'draft', mine: true, link: 'l', createdAt: 0, ttlSeconds: 1, seats: 2, maxDownloads: null };
describe('reduceShare', () => {
it('收到分享信息与就绪', () => {
let s = reduceShare(initialShareData, { t: 'share', share });
s = reduceShare(s, { t: 'ready' });
expect(s.share).toEqual(share);
expect(s.ready).toBe(true);
});
it('条目按时间倒序,重复到达是更新', () => {
let s = reduceShare(initialShareData, { t: 'item', item: text('a', 1) });
s = reduceShare(s, { t: 'item', item: text('b', 2) });
s = reduceShare(s, { t: 'item', item: text('a', 1) });
expect(s.order).toEqual(['b', 'a']);
});
it('草稿里移除条目', () => {
let s = reduceShare(initialShareData, { t: 'item', item: text('a', 1) });
s = reduceShare(s, { t: 'removed', id: 'a' });
expect(s.order).toEqual([]);
expect(s.byId).toEqual({});
});
});
+42
View File
@@ -0,0 +1,42 @@
import type { Item, Share, ShareEvent } from '@/core-bridge/types';
export interface ShareData {
ready: boolean;
share: Share | null;
byId: Record<string, Item>;
/** 新的在前 */
order: string[];
}
export const initialShareData: ShareData = { ready: false, share: null, byId: {}, order: [] };
function insertByTime(order: string[], byId: Record<string, Item>, item: Item): string[] {
const at = order.findIndex((id) => (byId[id]?.ts ?? 0) < item.ts);
const next = order.slice();
next.splice(at === -1 ? next.length : at, 0, item.id);
return next;
}
export function reduceShare(s: ShareData, e: ShareEvent): ShareData {
switch (e.t) {
case 'ready':
return { ...s, ready: true };
case 'share':
return { ...s, share: e.share };
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) };
}
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) };
}
default: {
const exhaustive: never = e;
return exhaustive;
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import { create } from 'zustand';
import { shareApi } from '@/core-bridge/instance';
import { initialShareData, reduceShare, type ShareData } from './shareReduce';
interface Actions {
/** 打开(订阅)一个分享:先清空上一个的数据,返回取消订阅函数 */
open: (shareId: string) => () => void;
addText: (text: string) => Promise<void>;
addFiles: (files: File[]) => Promise<void>;
removeItem: (id: string) => Promise<void>;
getBlob: (id: string) => Promise<Blob>;
listArchive: typeof shareApi.listArchive;
}
/** 正在打开的那个便捷分享(草稿或已封存)。和频道的 useWorkspace 互不相干 */
export const useShareSession = create<ShareData & Actions>()(() => ({
...initialShareData,
open: (shareId) => {
useShareSession.setState(initialShareData);
return shareApi.connect(shareId, (e) => useShareSession.setState((s) => reduceShare(s, e)));
},
addText: (t) => shareApi.addText(t),
addFiles: (f) => shareApi.addFiles(f),
removeItem: (id) => shareApi.removeItem(id),
getBlob: (id) => shareApi.getBlob(id),
listArchive: (id) => shareApi.listArchive(id),
}));
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand';
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, seats: 10, maxDownloads: 10 };
interface SharesState {
/** 我的分享:自己创建的(草稿、已封存)和收到的。原型只在内存里 */
list: Share[];
/** 从链接进来、还没加入的分享;含密钥,只放内存 */
pendingLink: ParsedShareLink | null;
/** 便捷分享:新建草稿时带来的内容,进入草稿页后放进去 */
pendingPayload: ClipboardPayload | null;
/** 新建草稿(可带上要放进去的内容) */
createDraft: (payload?: ClipboardPayload) => Promise<Share>;
updateSettings: (id: string, patch: Partial<ShareSettings>) => Promise<Share>;
/** 完成分享:此后无人可改 */
seal: (id: string) => Promise<Share>;
/** 访客加入:直接进入,不预览 */
join: (link: ParsedShareLink) => Promise<Share>;
forget: (id: string) => void;
loadDemo: () => void;
setPendingLink: (link: ParsedShareLink | null) => void;
consumePayload: () => ClipboardPayload | null;
}
const upsert = (list: Share[], s: Share) => [s, ...list.filter((x) => x.id !== s.id)];
export const useShares = create<SharesState>()((set, get) => ({
list: [],
pendingLink: null,
pendingPayload: null,
createDraft: async (payload) => {
const s = await shareApi.createDraft(SHARE_DEFAULTS);
set((st) => ({ list: upsert(st.list, s), pendingPayload: payload ?? null }));
return s;
},
updateSettings: async (id, patch) => {
const s = await shareApi.updateSettings(id, patch);
set((st) => ({ list: upsert(st.list, s) }));
return s;
},
seal: async (id) => {
const s = await shareApi.seal(id);
set((st) => ({ list: upsert(st.list, s) }));
return s;
},
join: async (link) => {
const s = await shareApi.join(link);
set((st) => ({ list: upsert(st.list, s), pendingLink: null }));
return s;
},
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;
if (p) set({ pendingPayload: null });
return p;
},
}));
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand';
import { api } from '@/core-bridge/instance';
import type { ConnectionState } from '@/core-bridge/types';
import { initialData, reduce, type WorkspaceData } from './reduce';
interface Actions {
/** 打开(订阅)一个频道:先清空上一个频道的数据,返回取消订阅函数 */
open: (channelId: string) => () => void;
sendText: (text: string) => Promise<void>;
sendFiles: (files: File[]) => Promise<void>;
editText: (id: string, text: string) => Promise<void>;
resolveConflict: (id: string, choice: 'mine' | 'theirs') => Promise<void>;
remove: (id: string) => Promise<void>;
setSnapshot: (text: string) => Promise<void>;
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>()(() => ({
...initialData,
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),
resolveConflict: (id, c) => api.resolveConflict(id, c),
remove: (id) => api.remove(id),
setSnapshot: (t) => api.setSnapshot(t),
getBlob: (id, v) => api.getBlob(id, v),
markSeen: (id) => api.markSeen(id),
listArchive: (id) => api.listArchive(id),
simulateConnection: (s) => api.simulateConnection?.(s),
}));