~james/core

core/web/src/lib/api/preferences.ts -rw-r--r-- 6.5 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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);
};