Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185import { 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<ReturnType<typeof readStored>>["preferences"];
// pocket answers NotFound for an account that never saved anything, which is just an empty listconst 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 listexport const getPreferences = async (ctx: BobbinContext): Promise<Preferences> => 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 readconst editStored = async ( ctx: BobbinContext, replaced: string[], edit: (stored: StoredPreferences) => StoredPreferences | null): Promise<StoredPreferences> => { 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<string[]> => { 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<OnboardingState | null> => 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<OnboardingState | null> => { 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<RecentPref[]> => 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<RecentPref[]> => { 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);};