import { previewKindOf } from '@/lib/fileKind'; import type { ShareApi } from './shareApi'; import { b64url } from './mockLinks'; import { PHONE, archiveEntries, makeSvgBlob, seedItems } from './mockSeed'; import type { FileItem, Item, Share, ShareEvent, ShareSettings } from './types'; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); const CREATOR = 'creator'; export const DEMO_SHARE_ID = 'ShareDemoShareDemoAbcd'; const DEMO_KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF'; interface Runtime { share: Share; items: Map; blobs: Map>; } /** 便捷分享的假实现:不联网,数据都在内存里,刷新即丢。接口见 shareApi.ts */ export function createMockShareApi(): ShareApi { const runtimes = new Map(); let current: Runtime | undefined; let emit: (e: ShareEvent) => void = () => {}; const timers = new Set(); const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`; const put = (item: Item) => { current?.items.set(item.id, item); emit({ t: 'item', item }); }; const draftOnly = () => { if (!current || current.share.state !== 'draft') throw new Error('forbidden'); return current; }; function seedDemo(): Runtime { const share: Share = { id: DEMO_SHARE_ID, state: 'sealed', mine: false, link: `${location.origin}/s/${DEMO_SHARE_ID}#k=${DEMO_KEY}`, createdAt: Date.now() - 2 * 3600_000, expiresAt: Date.now() + 22 * 3600_000, ttlSeconds: 86400, maxVisitors: 10, maxDownloads: 5, }; const rt: Runtime = { share, items: new Map(), blobs: new Map() }; const keep = new Set(['it-zip', 'it-image', 'it-json', 'it-short', 'it-pdf']); for (const item of seedItems().items) { if (!keep.has(item.id)) continue; rt.items.set(item.id, item.kind === 'file' ? { ...item, deviceId: CREATOR, versions: undefined, unseen: false, remaining: share.maxDownloads } : { ...item, deviceId: CREATOR }); } rt.blobs.set('it-image', makeSvgBlob()); rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' })); rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' })); runtimes.set(share.id, rt); return rt; } return { async listSaved() { return []; }, async forget() {}, async createDraft(settings) { await wait(300); const id = b64url(16); const share: Share = { id, state: 'draft', mine: true, link: `${location.origin}/s/${id}#k=${b64url(32)}`, createdAt: Date.now(), expiresAt: Date.now() + 86400_000, ...settings, }; runtimes.set(id, { share, items: new Map(), blobs: new Map() }); return share; }, async updateSettings(id, patch: Partial) { const rt = runtimes.get(id); if (!rt || rt.share.state !== 'draft') throw new Error('forbidden'); rt.share = { ...rt.share, ...patch }; emit({ t: 'share', share: rt.share }); return rt.share; }, async seal(id) { await wait(300); const rt = runtimes.get(id); if (!rt || rt.share.state !== 'draft') throw new Error('forbidden'); // 丢弃所有者密钥:此后没有人能改,创建者也只是一个读者 rt.share = { ...rt.share, state: 'sealed' }; for (const item of rt.items.values()) { if (item.kind === 'file') rt.items.set(item.id, { ...item, remaining: rt.share.maxDownloads }); } emit({ t: 'share', share: rt.share }); for (const item of rt.items.values()) emit({ t: 'item', item }); return rt.share; }, async join(link) { await wait(300); const known = runtimes.get(link.id); if (known) return known.share; // 原型里不认识的链接:当作一个已封存、没有内容的分享 const share: Share = { id: link.id, state: 'sealed', mine: false, link: `${location.origin}/s/${link.id}#k=${link.key}`, createdAt: Date.now(), expiresAt: Date.now() + 86400_000, ttlSeconds: 86400, maxVisitors: 10, maxDownloads: null, }; runtimes.set(link.id, { share, items: new Map(), blobs: new Map() }); return share; }, loadDemo() { return [(runtimes.get(DEMO_SHARE_ID) ?? seedDemo()).share]; }, connect(shareId, onEvent) { emit = onEvent; current = runtimes.get(shareId); const rt = current; if (!rt) return () => {}; const id = window.setTimeout(() => { timers.delete(id); emit({ t: 'share', share: rt.share }); for (const item of rt.items.values()) emit({ t: 'item', item }); emit({ t: 'ready' }); }, 400); timers.add(id); return () => { for (const t of timers) clearTimeout(t); timers.clear(); emit = () => {}; }; }, async addText(text) { draftOnly(); put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text }); }, async editText(id, text) { const rt = draftOnly(); const item = rt.items.get(id); if (!item || item.kind !== 'text') throw new Error('not_found'); put({ ...item, text, ts: Date.now() }); }, async addFiles(files) { const rt = draftOnly(); for (const file of files) { const id = uid(); const base: FileItem = { id, kind: 'file', ts: Date.now(), deviceId: PHONE, state: { kind: 'receiving', progress: 0 }, name: file.name, mime: file.type, size: file.size, remaining: null, preview: previewKindOf(file.type, file.name), }; rt.blobs.set(id, file); put(base); for (let p = 0.25; p < 1; p += 0.25) { await wait(200); put({ ...base, state: { kind: 'receiving', progress: p } }); } await wait(200); put({ ...base, state: { kind: 'complete' } }); } }, async removeItem(id) { const rt = draftOnly(); rt.items.delete(id); rt.blobs.delete(id); emit({ t: 'removed', id }); }, async getBlob(id) { const item = current?.items.get(id); // 封存后每次拉取消耗一次下载(GATED,api.md);用完即 410 if (item?.kind === 'file' && current?.share.state === 'sealed' && item.remaining !== null) { if (item.remaining <= 0) throw new Error('gone'); put({ ...item, remaining: item.remaining - 1 }); } const b = current?.blobs.get(id); if (b) return b; if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' }); throw new Error('not found'); }, async listArchive() { await wait(250); return archiveEntries; }, }; }