import { jsonGet } from "#lib/api/_request.ts"; import type { BobbinContext } from "#lib/api/client.ts"; import type * as OrgTangledFeedGetTimeline from "#lib/api/lexicons/types/org/tangled/feed/getTimeline.ts"; import { avatarUrl, avatarWidthFor } from "#lib/avatar.ts"; import type { Notification, Party } from "#lib/components/landing/hero/notifications.ts"; /* * The hero stack, live: what's happening on Tangled right now, from the configured bobbin's * timeline (org.tangled.feed.getTimeline, as /timeline reads it). Follows, stars and new repos become notifications, newest first. */ type Feed = OrgTangledFeedGetTimeline.$output["feed"]; type Event = Feed[number]["event"]; /** Below this many cards the stack can't loop, so the source counts as quiet. */ const ENOUGH = 6; const COUNT = 24; const AVATAR_PX = 34; const face = (did: string) => avatarUrl(did, avatarWidthFor(AVATAR_PX)); /** The handle's first letter or digit, for someone with no face to show. */ export const initialOf = (handle: string) => (/[a-z0-9]/i.exec(handle)?.[0] ?? "?").toUpperCase(); const person = (handle: string, did: string): Party => ({ name: handle, href: `/${handle}`, avatar: face(did), initial: initialOf(handle) }); const repo = (r: { slug: string; owner: { handle: string } }): Party => ({ name: r.slug, href: `/${r.owner.handle}/${r.slug}` }); const valid = (handle: string | undefined) => !!handle && handle !== "handle.invalid"; /** One timeline event as a notification, or nothing for one the stack can't show. */ function fromEvent(event: Event): Notification | undefined { switch (event.$type) { case "org.tangled.feed.getTimeline#followEvent": if (!valid(event.actor.handle) || !valid(event.subject.handle)) return undefined; return { actor: person(event.actor.handle, event.actor.did), verb: "followed", subject: person(event.subject.handle, event.subject.did) }; case "org.tangled.feed.getTimeline#starEvent": if (!valid(event.actor.handle) || !valid(event.repo.owner.handle)) return undefined; return { actor: person(event.actor.handle, event.actor.did), verb: "starred", // The repo wears its owner's face. subject: { ...repo(event.repo), avatar: face(event.repo.owner.did), initial: initialOf(event.repo.owner.handle) } }; case "org.tangled.feed.getTimeline#repoEvent": { const owner = event.repo.owner; if (!valid(owner.handle)) return undefined; return { actor: person(owner.handle, owner.did), verb: event.source ? "forked" : "created", subject: repo(event.source ?? event.repo) }; } default: return undefined; } } /** fromEvent, with an event that doesn't have the shape its type promises skipped rather than * taking the whole stack (and the landing's ssr) down with it. */ function safeFromEvent(event: Event): Notification | undefined { try { return fromEvent(event); } catch { return undefined; } } /** * The feed as notifications. Activity comes in bursts — one person starring five repos — so * a card by the same person as the one before it is skipped, to keep the stack varied. */ export function fromTimeline(feed: Feed): Notification[] { const out: Notification[] = []; for (const { event, eventAt } of feed) { const n = safeFromEvent(event); if (n && n.actor.href !== out.at(-1)?.actor.href) out.push({ ...n, at: eventAt }); } return out; } async function timeline(ctx: BobbinContext, signal: AbortSignal) { const { feed } = await jsonGet( ctx, "org.tangled.feed.getTimeline", { limit: COUNT }, { signal } ); if (feed.length < ENOUGH) throw new Error("timeline: too quiet"); return feed; } /** The live notifications, or null when the timeline couldn't be reached in time. */ export async function loadLiveActivity( ctx: BobbinContext, timeout = 2500 ): Promise { const signal = AbortSignal.timeout(timeout); let feed: Feed; try { feed = await timeline(ctx, signal); } catch { return null; } const items = fromTimeline(feed); return items.length >= ENOUGH ? items : null; }