Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130import { 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> = T extends RecentItemData ? Omit<T, "visited"> : never;export type RecentVisit = Unvisited<RecentItemData>;
/** 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<string, RecentItemData>(); 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 meanwhileconst 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<string, Pocket>();
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<RecentItemData[]> => { 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;};