feat: integrate client and server sharing workflows

This commit is contained in:
justme committed 2026-10-02 10:49:45 +08:00
1 parent 42b93ae2d8
commit 7406e2fe37
101 files changed
+3153 -253

No files matched your search

+16 -5
View File
@@ -6,28 +6,32 @@ import type { ParsedChannelLink } from '@/lib/channelLink';
interface ChannelsState {
/** 我的频道(原型:只在内存里,刷新即清空;真实实现存 IndexedDB,architecture §6) */
list: ChannelInfo[];
hydrated: boolean;
/** 从链接进来、还没确认加入的频道;含密钥,只放内存 */
pendingLink: ParsedChannelLink | null;
/** 刚创建的频道:进入时直接展示分享面板与二维码 */
justCreatedId: string | null;
create: (opts: CreateOptions) => Promise<ChannelInfo>;
create: (opts: CreateOptions, captchaToken?: string) => Promise<ChannelInfo>;
join: (link: ParsedChannelLink) => Promise<ChannelInfo>;
forget: (id: string) => void;
hydrate: () => Promise<void>;
forget: (id: string) => Promise<void>;
loadDemo: () => void;
setPendingLink: (link: ParsedChannelLink | null) => void;
clearJustCreated: () => void;
}
const upsert = (list: ChannelInfo[], c: ChannelInfo) => [c, ...list.filter((x) => x.id !== c.id)];
let hydration: Promise<void> | undefined;
export const useChannels = create<ChannelsState>()((set) => ({
list: [],
hydrated: false,
pendingLink: null,
justCreatedId: null,
create: async (opts) => {
const c = await api.createChannel(opts);
create: async (opts, captchaToken) => {
const c = await api.createChannel(opts, captchaToken);
set((s) => ({ list: upsert(s.list, c), justCreatedId: c.id }));
return c;
},
@@ -36,7 +40,14 @@ export const useChannels = create<ChannelsState>()((set) => ({
set((s) => ({ list: upsert(s.list, c), pendingLink: null }));
return c;
},
forget: (id) => set((s) => ({ list: s.list.filter((c) => c.id !== id) })),
hydrate: () => {
hydration ??= api.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
return hydration;
},
forget: async (id) => {
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) }));
+6
View File
@@ -16,6 +16,7 @@ export interface WorkspaceData {
/** 新的在前 */
order: string[];
snapshot: Snapshot;
error: string | null;
}
export const initialData: WorkspaceData = {
@@ -26,6 +27,7 @@ export const initialData: WorkspaceData = {
byId: {},
order: [],
snapshot: { text: '', updatedBy: '', updatedAt: 0 },
error: null,
};
function insertByTime(order: string[], byId: Record<string, Item>, item: Item): string[] {
@@ -37,6 +39,10 @@ function insertByTime(order: string[], byId: Record<string, Item>, item: Item):
export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
switch (e.t) {
case 'reset':
return { ...initialData, connection: s.connection };
case 'error':
return { ...s, error: e.message };
case 'ready':
return { ...s, ready: true };
case 'connection':
+1 -1
View File
@@ -3,7 +3,7 @@ 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 };
const share: Share = { id: 's', state: 'draft', mine: true, link: 'l', createdAt: 0, expiresAt: 86400000, ttlSeconds: 1, maxVisitors: 2, maxDownloads: null };
describe('reduceShare', () => {
it('收到分享信息与就绪', () => {
+7 -1
View File
@@ -6,9 +6,11 @@ export interface ShareData {
byId: Record<string, Item>;
/** 新的在前 */
order: string[];
integrityError: string | null;
error: string | null;
}
export const initialShareData: ShareData = { ready: false, share: null, byId: {}, order: [] };
export const initialShareData: ShareData = { ready: false, share: null, byId: {}, order: [], integrityError: null, error: null };
function insertByTime(order: string[], byId: Record<string, Item>, item: Item): string[] {
const at = order.findIndex((id) => (byId[id]?.ts ?? 0) < item.ts);
@@ -34,6 +36,10 @@ export function reduceShare(s: ShareData, e: ShareEvent): ShareData {
delete byId[e.id];
return { ...s, byId, order: s.order.filter((id) => id !== e.id) };
}
case 'integrity':
return { ...s, byId: {}, order: [], integrityError: e.message };
case 'error':
return { ...s, error: e.message };
default: {
const exhaustive: never = e;
return exhaustive;
+2
View File
@@ -6,6 +6,7 @@ interface Actions {
/** 打开(订阅)一个分享:先清空上一个的数据,返回取消订阅函数 */
open: (shareId: string) => () => void;
addText: (text: string) => Promise<void>;
editText: (id: string, text: string) => Promise<void>;
addFiles: (files: File[]) => Promise<void>;
removeItem: (id: string) => Promise<void>;
getBlob: (id: string) => Promise<Blob>;
@@ -20,6 +21,7 @@ export const useShareSession = create<ShareData & Actions>()(() => ({
return shareApi.connect(shareId, (e) => useShareSession.setState((s) => reduceShare(s, e)));
},
addText: (t) => shareApi.addText(t),
editText: (id, text) => shareApi.editText(id, text),
addFiles: (f) => shareApi.addFiles(f),
removeItem: (id) => shareApi.removeItem(id),
getBlob: (id) => shareApi.getBlob(id),
+17 -6
View File
@@ -5,41 +5,49 @@ 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 };
export const SHARE_DEFAULTS: ShareSettings = { ttlSeconds: 86400, maxVisitors: 10, maxDownloads: 10 };
interface SharesState {
/** 我的分享:自己创建的(草稿、已封存)和收到的。原型只在内存里 */
list: Share[];
hydrated: boolean;
/** 从链接进来、还没加入的分享;含密钥,只放内存 */
pendingLink: ParsedShareLink | null;
/** 便捷分享:新建草稿时带来的内容,进入草稿页后放进去 */
pendingPayload: ClipboardPayload | null;
/** 新建草稿(可带上要放进去的内容) */
createDraft: (payload?: ClipboardPayload) => Promise<Share>;
createDraft: (payload?: ClipboardPayload, captchaToken?: string) => Promise<Share>;
hydrate: () => Promise<void>;
updateSettings: (id: string, patch: Partial<ShareSettings>) => Promise<Share>;
/** 完成分享:此后无人可改 */
seal: (id: string) => Promise<Share>;
/** 访客加入:直接进入,不预览 */
join: (link: ParsedShareLink) => Promise<Share>;
forget: (id: string) => void;
forget: (id: string) => Promise<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)];
let hydration: Promise<void> | undefined;
export const useShares = create<SharesState>()((set, get) => ({
list: [],
hydrated: false,
pendingLink: null,
pendingPayload: null,
createDraft: async (payload) => {
const s = await shareApi.createDraft(SHARE_DEFAULTS);
createDraft: async (payload, captchaToken) => {
const s = await shareApi.createDraft(SHARE_DEFAULTS, captchaToken);
set((st) => ({ list: upsert(st.list, s), pendingPayload: payload ?? null }));
return s;
},
hydrate: () => {
hydration ??= shareApi.listSaved().then((list) => set({ list, hydrated: true }), () => set({ hydrated: true }));
return hydration;
},
updateSettings: async (id, patch) => {
const s = await shareApi.updateSettings(id, patch);
set((st) => ({ list: upsert(st.list, s) }));
@@ -55,7 +63,10 @@ export const useShares = create<SharesState>()((set, get) => ({
set((st) => ({ list: upsert(st.list, s), pendingLink: null }));
return s;
},
forget: (id) => set((st) => ({ list: st.list.filter((x) => x.id !== id) })),
forget: async (id) => {
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) }));