import { browser } from "$app/env"; import * as v from "@atcute/lexicons/validations"; import type { BobbinContext } from "#lib/api/client.ts"; import { recentIssueSchema, recentPullSchema, recentRepoSchema } from "#lib/api/lexicons/types/org/tangled/temp/actor/defs.ts"; import { editRecents, getRecents, samePrefs, type RecentPref } from "#lib/api/preferences.ts"; import type { RecentItemData } from "#lib/components/timeline/types.ts"; const KEY_PREFIX = "tangled.recents."; const LIMIT = 5; const TITLE_LIMIT = 100; export const PUSH_DELAY_MS = 5_000; const TYPES = { repo: "org.tangled.temp.actor.defs#recentRepo", issue: "org.tangled.temp.actor.defs#recentIssue", pull: "org.tangled.temp.actor.defs#recentPull" } as const; const KIND_OF = new Map(Object.entries(TYPES).map(([kind, type]) => [type as string, kind])); const PREF_SCHEMA = v.variant([recentRepoSchema, recentIssueSchema, recentPullSchema], true); type Unvisited = T extends RecentItemData ? Omit : never; export type RecentVisit = Unvisited; /** the signed-in account's pocket, or null once someone else is signed in */ export type Pocket = (did: string) => BobbinContext | null; const keyOf = (did: string) => `${KEY_PREFIX}${did}`; /** the unresolved app path a recent row points at */ export const recentPath = (item: RecentVisit): string => { switch (item.kind) { case "repo": return `/${item.ownerHandle}/${item.name}`; case "issue": return `/${item.ownerHandle}/${item.repoName}/issues/${item.rkey}`; case "pull": return `/${item.ownerHandle}/${item.repoName}/pulls/${item.rkey}`; } }; const visitedAt = (item: RecentItemData) => Date.parse(item.visited); const toPref = ({ kind, ...fields }: RecentItemData) => ({ $type: TYPES[kind], ...fields }); const isRecent = (value: unknown): value is RecentItemData => typeof value === "object" && value !== null && v.safeParse(PREF_SCHEMA, toPref(value as RecentItemData)).ok; /** one list per path, the latest visit winning, newest first, without rows pocket would refuse */ export const mergeRecents = (...lists: unknown[][]): RecentItemData[] => { const byPath = new Map(); for (const item of lists.flat().filter(isRecent)) { const seen = byPath.get(recentPath(item)); if (!seen || visitedAt(item) > visitedAt(seen)) byPath.set(recentPath(item), item); } return [...byPath.values()].sort((a, b) => visitedAt(b) - visitedAt(a)).slice(0, LIMIT); }; const fromPrefs = (prefs: RecentPref[]) => prefs.map(({ $type, ...fields }) => ({ kind: KIND_OF.get($type ?? ""), ...fields })); const toPrefs = (items: RecentItemData[]) => items.map(toPref) as RecentPref[]; /** the account's last few visited repos, issues and pulls in this browser, newest first */ export const listRecents = (did: string): RecentItemData[] => { if (!browser) return []; try { const parsed: unknown = JSON.parse(localStorage.getItem(keyOf(did)) ?? "[]"); return Array.isArray(parsed) ? parsed.filter(isRecent).slice(0, LIMIT) : []; } catch { return []; } }; const storeRecents = (did: string, items: RecentItemData[]) => { try { localStorage.setItem(keyOf(did), JSON.stringify(items)); } catch { // private browsing can refuse storage, recents are only a convenience } }; // the edit reruns against pocket's current rows if another device wrote meanwhile const push = async (ctx: BobbinContext, did: string) => { const saved = await editRecents(ctx, (remote) => toPrefs(mergeRecents(listRecents(did), fromPrefs(remote))) ); storeRecents(did, mergeRecents(listRecents(did), fromPrefs(saved))); }; const pending = new Map(); const schedulePush = (pocket: Pocket, did: string) => { const waiting = pending.has(did); pending.set(did, pocket); if (waiting) return; setTimeout(() => { const ctx = pending.get(did)?.(did); pending.delete(did); if (ctx) push(ctx, did).catch(() => {}); }, PUSH_DELAY_MS); }; export const rememberVisit = (did: string, visit: RecentVisit, pocket: Pocket): void => { if (!browser) return; const titled = "title" in visit ? { ...visit, title: [...visit.title].slice(0, TITLE_LIMIT).join("") } : visit; const item = { ...titled, visited: new Date().toISOString() } as RecentItemData; storeRecents(did, mergeRecents([item], listRecents(did))); schedulePush(pocket, did); }; /** merges the account's pocket list into this browser's and answers with the result */ export const syncRecents = async (pocket: Pocket, did: string): Promise => { const ctx = pocket(did); if (!ctx) return listRecents(did); const remote = await getRecents(ctx); const merged = mergeRecents(listRecents(did), fromPrefs(remote)); storeRecents(did, merged); if (!samePrefs(toPrefs(merged), remote)) schedulePush(pocket, did); return merged; };