204 lines
6.7 KiB
TypeScript
204 lines
6.7 KiB
TypeScript
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<void>((r) => setTimeout(r, ms));
|
|
const CREATOR = 'creator';
|
|
export const DEMO_SHARE_ID = 'ShareDemoShareDemoAbcd';
|
|
const DEMO_KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
|
|
|
|
interface Runtime {
|
|
share: Share;
|
|
items: Map<string, Item>;
|
|
blobs: Map<string, Blob | Promise<Blob>>;
|
|
}
|
|
|
|
/** 便捷分享的假实现:不联网,数据都在内存里,刷新即丢。接口见 shareApi.ts */
|
|
export function createMockShareApi(): ShareApi {
|
|
const runtimes = new Map<string, Runtime>();
|
|
let current: Runtime | undefined;
|
|
let emit: (e: ShareEvent) => void = () => {};
|
|
const timers = new Set<number>();
|
|
|
|
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<ShareSettings>) {
|
|
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;
|
|
},
|
|
};
|
|
}
|