import { ClientResponseError, ok } from "@atcute/client"; import type { Datetime } from "@atcute/lexicons/syntax"; import type { BobbinContext } from "#lib/api/client.ts"; import { mainSchema as getPreferencesSchema } from "#lib/api/lexicons/types/com/bad-example/pocket/getPreferences.ts"; import { mainSchema as putPreferencesSchema } from "#lib/api/lexicons/types/com/bad-example/pocket/putPreferences.ts"; import type { RecentsPref } from "#lib/api/lexicons/types/org/tangled/temp/actor/defs.ts"; const KNOT_PREF = "org.tangled.temp.actor.defs#savedKnotPref"; const SPINDLE_PREF = "org.tangled.temp.actor.defs#savedSpindlePref"; export type ServiceKind = "knots" | "spindles"; export interface Preferences { knots: string[]; spindles: string[]; } export const hostOf = (input: string): string => input .trim() .replace(/^[a-z]+:\/\//i, "") .replace(/\/.*$/, "") .toLowerCase(); type StoredPreferences = Awaited>["preferences"]; // pocket answers NotFound for an account that never saved anything, which is just an empty list const readStored = async (ctx: BobbinContext) => { try { return await ok(ctx.xrpc.call(getPreferencesSchema, { params: {} })); } catch (cause) { if (cause instanceof ClientResponseError && cause.error === "NotFound") return { preferences: [], rev: null }; throw cause; } }; const itemsOf = (hosts: string[]) => hosts.map((host) => ({ id: host, value: host })); const valuesOf = ( stored: StoredPreferences, type: typeof KNOT_PREF | typeof SPINDLE_PREF ): string[] => stored.flatMap((pref) => pref.$type === type && (pref.$type === KNOT_PREF || pref.$type === SPINDLE_PREF) ? pref.items.map((item) => item.value) : [] ); const servicesOf = (stored: StoredPreferences): Preferences => ({ knots: valuesOf(stored, KNOT_PREF), spindles: valuesOf(stored, SPINDLE_PREF) }); // throws on failure so callers never mistake a failed read for an empty list export const getPreferences = async (ctx: BobbinContext): Promise => servicesOf((await readStored(ctx)).preferences); const WRITE_TRIES = 3; const canonical = (value: unknown) => JSON.stringify(value, (_, inner: unknown) => inner && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => (a < b ? -1 : 1))) : inner ); /** whether two lists hold the same entries, in whatever key order pocket hands them back */ export const samePrefs = (a: unknown[], b: unknown[]): boolean => canonical(a) === canonical(b); // put replaces the whole list, so entries of other types are sent back as read const editStored = async ( ctx: BobbinContext, replaced: string[], edit: (stored: StoredPreferences) => StoredPreferences | null ): Promise => { for (let tries = 0; tries < WRITE_TRIES; tries++) { const { preferences: stored, rev } = await readStored(ctx); const entries = edit(stored); if (!entries) return stored; const preferences = [ ...stored.filter((pref) => !replaced.includes(pref.$type)), ...entries ]; try { await ok(ctx.xrpc.call(putPreferencesSchema, { input: { preferences, swapRev: rev } })); return preferences; } catch (cause) { if (!(cause instanceof ClientResponseError && cause.error === "InvalidSwap")) throw cause; } } throw new Error("Your preferences kept changing somewhere else while saving. Try again."); }; /** rewrites one of the two lists and answers with what it saved */ export const editSavedServices = async ( ctx: BobbinContext, kind: ServiceKind, edit: (hosts: string[]) => string[] ): Promise => { const saved = await editStored(ctx, [KNOT_PREF, SPINDLE_PREF], (stored) => { const targets = servicesOf(stored); const next = { ...targets, [kind]: edit(targets[kind]) }; return [ { $type: KNOT_PREF, items: itemsOf(next.knots) }, { $type: SPINDLE_PREF, items: itemsOf(next.spindles) } ]; }); return servicesOf(saved)[kind]; }; const ONBOARDING_PREF = "org.tangled.temp.actor.defs#onboardingPref"; export interface OnboardingState { startedAt: Datetime; completedAt?: Datetime; nudgeDismissedAt?: Datetime; } const onboardingOf = (stored: StoredPreferences): OnboardingState | null => { const pref = stored.find((entry) => entry.$type === ONBOARDING_PREF); if (!pref || !("startedAt" in pref)) return null; return { startedAt: pref.startedAt, ...(pref.completedAt ? { completedAt: pref.completedAt } : {}), ...(pref.nudgeDismissedAt ? { nudgeDismissedAt: pref.nudgeDismissedAt } : {}) }; }; export const getOnboarding = async (ctx: BobbinContext): Promise => onboardingOf((await readStored(ctx)).preferences); export const showsOnboardingNudge = (state: OnboardingState | null): boolean => Boolean(state && !state.completedAt && !state.nudgeDismissedAt); const editOnboarding = async ( ctx: BobbinContext, edit: (current: OnboardingState | null, now: Datetime) => OnboardingState | null ): Promise => { const saved = await editStored(ctx, [ONBOARDING_PREF], (stored) => { const current = onboardingOf(stored); const next = edit(current, new Date().toISOString() as Datetime); if (next === current) return null; return next ? [{ $type: ONBOARDING_PREF, ...next }] : []; }); return onboardingOf(saved); }; export const startOnboarding = (ctx: BobbinContext) => editOnboarding(ctx, (current, now) => current ?? { startedAt: now }); export const completeOnboarding = (ctx: BobbinContext) => editOnboarding(ctx, (current, now) => current && !current.completedAt ? { ...current, completedAt: now } : current ); export const dismissOnboardingNudge = (ctx: BobbinContext) => editOnboarding(ctx, (current, now) => current && !current.nudgeDismissedAt ? { ...current, nudgeDismissedAt: now } : current ); const RECENTS_PREF = "org.tangled.temp.actor.defs#recentsPref"; export type RecentPref = RecentsPref["items"][number]; const recentsOf = (stored: StoredPreferences): RecentPref[] => stored.flatMap((pref) => (pref.$type === RECENTS_PREF ? pref.items : [])); export const getRecents = async (ctx: BobbinContext): Promise => recentsOf((await readStored(ctx)).preferences); /** saves what edit makes of the stored recents, unless it hands back the same rows */ export const editRecents = async ( ctx: BobbinContext, edit: (current: RecentPref[]) => RecentPref[] ): Promise => { const saved = await editStored(ctx, [RECENTS_PREF], (stored) => { const current = recentsOf(stored); const next = edit(current); return samePrefs(next, current) ? null : [{ $type: RECENTS_PREF, items: next }]; }); return recentsOf(saved); };