feat(web): connect quick share flow
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
53768a195d
commit
e02e024605
138 files changed
+10614
No files matched your search
@@ -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 }),
|
||||
}));
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
@@ -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),
|
||||
}));
|
||||
Reference in new issue
Block a user