diff --git a/src/data/events-account.ts b/src/data/events-account.ts index 554820a..9a99068 100644 --- a/src/data/events-account.ts +++ b/src/data/events-account.ts @@ -6,3 +6,21 @@ export const EVENTS_ACCOUNTS = [ { did: 'did:plc:rldh4m4qtswi7tq4rp23r27q', handle: 'atproto.nl' }, { did: 'did:plc:bs2glhnim6sqt3f4ccllwxej', handle: 'atproto.amsterdam' }, ]; + +// Individual events we have chosen to carry, from accounts we do NOT auto-publish. +// +// Adding an account to EVENTS_ACCOUNTS above pulls in everything it ever posts, which is right +// for sibling chapters and wrong for an organisation whose calendar is mostly about something +// else. 40verse is a cultural agenda platform: most of its events are not ours to list, but its +// pre-launch is squarely on topic. Curating one record keeps the atproto record, the RSVP link +// and the thumbnail, none of which a plain link card would have. +// +// `handle` is used for the atmo.rsvp URL and the source badge, so it must be the handle the +// record's DID actually resolves to. +export const CURATED_EVENTS = [ + { + did: 'did:plc:or4aocitcm5ssxwece57m7lu', + handle: '40verse.social', + rkey: '3muenpsewo25x', + }, +]; diff --git a/src/lib/atproto/client.ts b/src/lib/atproto/client.ts index d9db558..feb1a1f 100644 --- a/src/lib/atproto/client.ts +++ b/src/lib/atproto/client.ts @@ -176,6 +176,28 @@ export async function listRecords( } } +/** One record by DID + collection + rkey. Null on failure, so a single unreachable record + * never fails a build. */ +export async function getRecord( + did: string, + collection: string, + rkey: string, +): Promise<{ uri: string; cid: string; value: Record } | null> { + const pds = await resolvePds(did); + if (!pds) return null; + try { + const params = new URLSearchParams({ repo: did, collection, rkey }); + const data = (await getJson2(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`)) as { + uri?: string; + cid?: string; + value?: Record; + }; + return data.uri && data.value ? { uri: data.uri, cid: data.cid ?? '', value: data.value } : null; + } catch { + return null; + } +} + /** Recent top-level posts (no replies, no reposts) for one actor. Empty on failure. */ export async function getAuthorFeed(actor: string, limit = 10): Promise { const params = new URLSearchParams({ diff --git a/src/lib/atproto/events.ts b/src/lib/atproto/events.ts index 321ea8b..53bd5af 100644 --- a/src/lib/atproto/events.ts +++ b/src/lib/atproto/events.ts @@ -1,5 +1,5 @@ -import { listRecords, resolvePds } from './client'; -import { EVENTS_ACCOUNTS } from '../../data/events-account'; +import { listRecords, resolvePds, getRecord } from './client'; +import { EVENTS_ACCOUNTS, CURATED_EVENTS } from '../../data/events-account'; const EVENT_COLLECTION = 'community.lexicon.calendar.event'; @@ -35,49 +35,74 @@ function formatLocation(locs: unknown): string | undefined { return l.uri ? 'Online' : undefined; } +function toEvent( + { uri, cid, value: v }: { uri: string; cid: string; value: Record }, + did: string, + handle: string, + pds: string | null, + now: number, +): CalendarEvent { + const rkey = uri.split('/').pop() as string; + const startsAt = v.startsAt as string; + const endsAt = v.endsAt as string | undefined; + const endMs = Date.parse(endsAt ?? startsAt); + const ref = v.media?.[0]?.content?.ref?.$link as string | undefined; + const thumb = + ref && pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${ref}` : undefined; + return { + rkey, + uri, + cid, + sourceHandle: handle, + name: (v.name ?? 'Untitled event') as string, + description: v.description as string | undefined, + startsAt, + endsAt, + timezone: v.timezone as string | undefined, + location: formatLocation(v.locations), + mode: (v.mode as string | undefined)?.split('#').pop(), + status: (v.status as string | undefined)?.split('#').pop(), + url: `https://atmo.rsvp/p/${handle}/e/${rkey}`, + thumb, + upcoming: Number.isFinite(endMs) ? endMs >= now : true, + }; +} + async function accountEvents(did: string, handle: string, now: number): Promise { const [records, pds] = await Promise.all([ listRecords(did, EVENT_COLLECTION, 100), resolvePds(did), ]); - return records - .map(({ uri, cid, value: v }) => { - const rkey = uri.split('/').pop() as string; - const startsAt = v.startsAt as string; - const endsAt = v.endsAt as string | undefined; - const endMs = Date.parse(endsAt ?? startsAt); - const ref = v.media?.[0]?.content?.ref?.$link as string | undefined; - const thumb = - ref && pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${ref}` : undefined; - return { - rkey, - uri, - cid, - sourceHandle: handle, - name: (v.name ?? 'Untitled event') as string, - description: v.description as string | undefined, - startsAt, - endsAt, - timezone: v.timezone as string | undefined, - location: formatLocation(v.locations), - mode: (v.mode as string | undefined)?.split('#').pop(), - status: (v.status as string | undefined)?.split('#').pop(), - url: `https://atmo.rsvp/p/${handle}/e/${rkey}`, - thumb, - upcoming: Number.isFinite(endMs) ? endMs >= now : true, - }; - }) - .filter((e) => Boolean(e.startsAt)); + return records.map((r) => toEvent(r, did, handle, pds, now)).filter((e) => Boolean(e.startsAt)); +} + +/** Individually curated events from accounts we do not auto-publish (see CURATED_EVENTS). + * A record that has gone missing is skipped rather than failing the build. */ +async function curatedEvents(now: number): Promise { + const out = await Promise.all( + CURATED_EVENTS.map(async ({ did, handle, rkey }) => { + const [record, pds] = await Promise.all([ + getRecord(did, EVENT_COLLECTION, rkey), + resolvePds(did), + ]); + return record ? toEvent(record, did, handle, pds, now) : null; + }), + ); + return out.filter((e): e is CalendarEvent => Boolean(e) && Boolean(e!.startsAt)); } /** All calendar events from atproto.nl + sibling chapters, upcoming first (soonest first), * then past (most recent first). Public, no auth — fetched at build time. */ export async function getCalendarEvents(): Promise { const now = Date.now(); - const perAccount = await Promise.all( - EVENTS_ACCOUNTS.map((a) => accountEvents(a.did, a.handle, now)), + const [perAccount, curated] = await Promise.all([ + Promise.all(EVENTS_ACCOUNTS.map((a) => accountEvents(a.did, a.handle, now))), + curatedEvents(now), + ]); + // Dedupe by URI: a curated event whose account is later auto-published must not appear twice. + const events = [...perAccount.flat(), ...curated].filter( + (e, i, all) => all.findIndex((x) => x.uri === e.uri) === i, ); - const events = perAccount.flat(); return events.sort((a, b) => { if (a.upcoming !== b.upcoming) return a.upcoming ? -1 : 1;