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<ReturnType<typeof readStored>>["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<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 read
const 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);
};