From 60deeb0cba3f503b183254c81476cf77d1a9b287 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Thu, 7 May 2026 21:52:15 +0200 Subject: [PATCH] atmo-rsvp --- package.json | 1 + pnpm-lock.yaml | 3 + src/app.css | 1 + src/lib/events/adapter.ts | 105 +++++++ src/lib/events/atmo-appview.ts | 261 ++++++++++++++++++ .../[[actor=actor]]/event/create/+page.svelte | 55 ++-- .../event/r/[rkey]/+page.server.ts | 79 +++++- .../event/r/[rkey]/+page.svelte | 70 ++--- svelte.config.js | 3 + 9 files changed, 498 insertions(+), 80 deletions(-) create mode 100644 src/lib/events/adapter.ts create mode 100644 src/lib/events/atmo-appview.ts diff --git a/package.json b/package.json index 2768012..3ea2b79 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@atcute/standard-site": "^1.0.1", "@atcute/tid": "^1.1.2", "@atmo-dev/contrail": "^0.5.0", + "@atmo-dev/events-ui": "link:../../atmo-events/packages/ui", "@cloudflare/workers-types": "^4.20260313.1", "@ethercorps/sveltekit-og": "^4.2.1", "@floating-ui/dom": "^1.7.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98bd27d..fea35fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@atmo-dev/contrail': specifier: ^0.5.0 version: 0.5.0(react@19.2.4)(wrangler@4.73.0(@cloudflare/workers-types@4.20260313.1)) + '@atmo-dev/events-ui': + specifier: link:../../atmo-events/packages/ui + version: link:../../atmo-events/packages/ui '@cloudflare/workers-types': specifier: ^4.20260313.1 version: 4.20260313.1 diff --git a/src/app.css b/src/app.css index a06be78..7d7d3e7 100644 --- a/src/app.css +++ b/src/app.css @@ -6,6 +6,7 @@ @plugin 'tailwindcss-animate'; @source '../node_modules/@foxui'; +@source '../../../atmo-events/packages/ui/src'; @import '@foxui/core/theme.css'; @custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *))); diff --git a/src/lib/events/adapter.ts b/src/lib/events/adapter.ts new file mode 100644 index 0000000..6b1974c --- /dev/null +++ b/src/lib/events/adapter.ts @@ -0,0 +1,105 @@ +import { goto } from '$app/navigation'; +import * as TID from '@atcute/tid'; +import { + putRecord, + deleteRecord, + uploadBlob, + getRecord, + resolveHandle +} from '$lib/atproto/methods'; +import { atProtoLoginModalState } from '$lib/atproto/LoginModal.svelte'; +import type { + EditorAdapter, + EditorBlobRef, + EditorViewer +} from '@atmo-dev/events-ui'; + +export type { EditorAdapter, EditorBlobRef, EditorViewer }; + +/** atmo.rsvp's appview — reads + indexing-update notifications go here. */ +const ATMO_APPVIEW = 'https://atmo.rsvp'; + +function handleOrDid(viewer: EditorViewer): string { + if (viewer.handle && viewer.handle !== 'handle.invalid') return viewer.handle; + return viewer.did ?? ''; +} + +export function createBlentoEditorAdapter(opts: { viewer: EditorViewer }): EditorAdapter { + const { viewer } = opts; + return { + features: { delete: true, recurring: true, privateMode: false }, + async putRecord({ collection, rkey, record }) { + const result = await putRecord({ + collection: collection as Parameters[0]['collection'], + rkey, + record + }); + return { uri: result.uri }; + }, + async createRecord({ collection, rkey, record }) { + // Blento's atproto layer only exposes putRecord; both create and update + // flow through it. The PDS would normally assign an rkey when omitted, + // but our path requires one — generate a TID if the caller didn't. + const finalRkey = rkey ?? TID.now(); + const result = await putRecord({ + collection: collection as Parameters[0]['collection'], + rkey: finalRkey, + record + }); + return { uri: result.uri, cid: result.cid }; + }, + async deleteRecord({ collection, rkey }) { + await deleteRecord({ + collection: collection as Parameters[0]['collection'], + rkey + }); + }, + async uploadBlob(blob) { + const result = await uploadBlob({ blob }); + if (!result) throw new Error('uploadBlob failed'); + const { aspectRatio: _ar, ...rest } = result as Record & { + aspectRatio?: unknown; + }; + return rest as unknown as EditorBlobRef; + }, + async getRecord({ did, collection, rkey }) { + const fresh = await getRecord({ + did: did as `did:${string}:${string}`, + collection: collection as Parameters[0]['collection'], + rkey + }); + const value = (fresh as { value?: Record }).value ?? {}; + return { value }; + }, + async resolveHandle(handle: string) { + return resolveHandle({ handle: handle as Parameters[0]['handle'] }); + }, + onSaved({ rkey, isNew }) { + const handle = handleOrDid(viewer); + goto(`/${handle}/event/r/${rkey}${isNew ? '?created=true' : ''}`); + }, + onDeleted() { + const handle = handleOrDid(viewer); + goto(`/${handle}`); + }, + requestLogin() { + atProtoLoginModalState.show(); + }, + async notifyUpdate(uri) { + // Hint atmo's contrail to re-index this record immediately. Without this + // the firehose-based update happens on a ~1min cadence; with it, atmo's + // appview catches the change in well under a second. Fire-and-forget. + try { + await fetch(`${ATMO_APPVIEW}/xrpc/rsvp.atmo.notifyOfUpdate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ uri }) + }); + } catch { + // best-effort; firehose will catch up + } + } + // no createPrivateEvent / putSpaceRecord / deleteSpaceRecord / createSpaceInvite — + // blento doesn't have spaces (privateMode: false in features). + }; +} diff --git a/src/lib/events/atmo-appview.ts b/src/lib/events/atmo-appview.ts new file mode 100644 index 0000000..afd08a5 --- /dev/null +++ b/src/lib/events/atmo-appview.ts @@ -0,0 +1,261 @@ +/** + * Read-only client for atmo.rsvp's event appview. Hits the public xrpc + * endpoints over HTTP — no atmo lexicons needed in blento, response shapes + * are typed structurally to match what ``'s `data` prop expects. + * + * If the response shapes drift from atmo and types get noisy, the right move + * is to either pull `rsvp.atmo.*` lexicons into blento's `lex.config.js` or + * publish them as a shared package. + */ +import type { + FlatEventRecord, + HostProfile, + AttendeeInfo +} from '@atmo-dev/events-ui'; +import { RSVP_GOING, RSVP_INTERESTED, getProfileUrl } from '@atmo-dev/events-ui'; + +const ATMO_APPVIEW = 'https://atmo.rsvp'; +export const RSVP_HYDRATE_LIMIT = 20; + +type AtmoProfileEntry = { + did: string; + handle?: string; + value?: { displayName?: string; avatar?: unknown }; +}; + +type AtmoEventRecord = { + uri: string; + cid?: string | null; + did: string; + rkey: string; + value?: Record; + rsvps?: unknown; + rsvpsCount?: number; + rsvpsGoingCount?: number; + rsvpsInterestedCount?: number; + rsvpsNotgoingCount?: number; + profiles?: AtmoProfileEntry[]; +}; + +type AtmoRsvpRecord = { + uri: string; + did: string; + rkey: string; + value?: { status?: string; subject?: { uri?: string } }; +}; + +type AtmoListRsvpsResponse = { + records?: AtmoRsvpRecord[]; + profiles?: AtmoProfileEntry[]; +}; + +async function appviewGet( + method: string, + params: Record +): Promise { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) qs.set(k, String(v)); + try { + const res = await fetch(`${ATMO_APPVIEW}/xrpc/${method}?${qs}`); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +function getBlobCid(blob: unknown): string | null { + if (!blob || typeof blob !== 'object') return null; + if ('ref' in blob) { + const ref = (blob as { ref?: { $link?: string } }).ref; + if (ref?.$link) return ref.$link; + } + if ('cid' in blob) { + const cid = (blob as { cid?: { $link?: string } | string }).cid; + if (typeof cid === 'string') return cid; + if (cid && typeof cid === 'object' && '$link' in cid && typeof cid.$link === 'string') { + return cid.$link; + } + } + return null; +} + +function getProfileBlobUrl(did: string, blob: unknown): string | undefined { + const cid = getBlobCid(blob); + if (!cid) return undefined; + return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@webp`; +} + +function flattenAtmoEvent(record: AtmoEventRecord): FlatEventRecord | null { + const body = record.value as Record | undefined; + if (!body || typeof body.startsAt !== 'string') return null; + return { + ...(body as unknown as FlatEventRecord), + cid: record.cid ?? null, + did: record.did, + rkey: record.rkey, + uri: record.uri, + ...(record.rsvps !== undefined ? { rsvps: record.rsvps as FlatEventRecord['rsvps'] } : {}), + ...(record.rsvpsCount !== undefined ? { rsvpsCount: record.rsvpsCount } : {}), + ...(record.rsvpsGoingCount !== undefined ? { rsvpsGoingCount: record.rsvpsGoingCount } : {}), + ...(record.rsvpsInterestedCount !== undefined + ? { rsvpsInterestedCount: record.rsvpsInterestedCount } + : {}), + ...(record.rsvpsNotgoingCount !== undefined + ? { rsvpsNotgoingCount: record.rsvpsNotgoingCount } + : {}) + }; +} + +export function getHostProfile( + did: string, + profiles?: AtmoProfileEntry[] +): HostProfile | null { + const profile = profiles?.find((entry) => entry.did === did); + if (!profile) return null; + return { + did, + handle: profile.handle, + displayName: profile.value?.displayName, + avatar: getProfileBlobUrl(did, profile.value?.avatar) + }; +} + +function buildAttendee( + did: string, + status: 'going' | 'interested', + profiles?: AtmoProfileEntry[] +): AttendeeInfo { + const profile = profiles?.find((entry) => entry.did === did); + const handle = profile?.handle; + return { + did, + status, + avatar: getProfileBlobUrl(did, profile?.value?.avatar), + name: profile?.value?.displayName || handle || did, + handle, + url: getProfileUrl(handle || did) + }; +} + +export function getRsvpStatus( + status?: string +): 'going' | 'interested' | 'notgoing' | null { + if (!status) return null; + if (status === RSVP_GOING || status.endsWith('#going')) return 'going'; + if (status === RSVP_INTERESTED || status.endsWith('#interested')) return 'interested'; + if (status.endsWith('#notgoing')) return 'notgoing'; + return null; +} + +export interface AttendeesResult { + going: AttendeeInfo[]; + interested: AttendeeInfo[]; + goingCount: number; + interestedCount: number; +} + +export async function fetchAtmoEvent({ + did, + rkey, + hydrateRsvps = RSVP_HYDRATE_LIMIT, + profiles = true +}: { + did: string; + rkey: string; + hydrateRsvps?: number; + profiles?: boolean; +}): Promise<{ flat: FlatEventRecord; raw: AtmoEventRecord } | null> { + const uri = `at://${did}/community.lexicon.calendar.event/${rkey}`; + const data = await appviewGet('rsvp.atmo.event.getRecord', { + uri, + hydrateRsvps, + profiles + }); + if (!data) return null; + const flat = flattenAtmoEvent(data); + if (!flat) return null; + return { flat, raw: data }; +} + +export async function fetchAtmoEventAttendees(eventUri: string): Promise { + const [going, interested] = await Promise.all([ + appviewGet('rsvp.atmo.rsvp.listRecords', { + subjectUri: eventUri, + status: RSVP_GOING, + profiles: true, + limit: 200 + }), + appviewGet('rsvp.atmo.rsvp.listRecords', { + subjectUri: eventUri, + status: RSVP_INTERESTED, + profiles: true, + limit: 200 + }) + ]); + + const goingRecords = going?.records ?? []; + const interestedRecords = interested?.records ?? []; + const goingProfiles = going?.profiles ?? []; + const interestedProfiles = interested?.profiles ?? []; + + const seenGoing = new Set(); + const uniqueGoing = goingRecords.filter((r) => { + if (seenGoing.has(r.did)) return false; + seenGoing.add(r.did); + return true; + }); + const seenInterested = new Set(); + const uniqueInterested = interestedRecords.filter((r) => { + if (seenInterested.has(r.did)) return false; + seenInterested.add(r.did); + return true; + }); + + return { + going: uniqueGoing.map((r) => buildAttendee(r.did, 'going', goingProfiles)), + interested: uniqueInterested.map((r) => + buildAttendee(r.did, 'interested', interestedProfiles) + ), + goingCount: uniqueGoing.length, + interestedCount: uniqueInterested.length + }; +} + +export async function fetchAtmoViewerRsvp({ + eventUri, + actor +}: { + eventUri: string; + actor: string; +}): Promise { + const data = await appviewGet('rsvp.atmo.rsvp.listRecords', { + actor, + subjectUri: eventUri, + limit: 1 + }); + return data?.records?.[0] ?? null; +} + +export async function fetchAtmoProfile(actor: string): Promise { + const data = await appviewGet<{ profiles?: AtmoProfileEntry[] }>('rsvp.atmo.getProfile', { + actor + }); + return data?.profiles?.[0] ?? null; +} + +export interface VodRecord { + atUri: string; + playlistUrl: string; +} + +const VOD_PLAYBACK_BASE = 'https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist'; + +export function vodFromAtUri(atUri: string): VodRecord { + return { + atUri, + playlistUrl: `${VOD_PLAYBACK_BASE}?uri=${encodeURIComponent(atUri)}` + }; +} + +export { getProfileBlobUrl }; diff --git a/src/routes/[[actor=actor]]/event/create/+page.svelte b/src/routes/[[actor=actor]]/event/create/+page.svelte index 30d96c7..55bc6bc 100644 --- a/src/routes/[[actor=actor]]/event/create/+page.svelte +++ b/src/routes/[[actor=actor]]/event/create/+page.svelte @@ -1,40 +1,31 @@ Create event · Blento -
- -
+ diff --git a/src/routes/[[actor=actor]]/event/r/[rkey]/+page.server.ts b/src/routes/[[actor=actor]]/event/r/[rkey]/+page.server.ts index 58d5ff2..ac980c3 100644 --- a/src/routes/[[actor=actor]]/event/r/[rkey]/+page.server.ts +++ b/src/routes/[[actor=actor]]/event/r/[rkey]/+page.server.ts @@ -1,11 +1,82 @@ import { error } from '@sveltejs/kit'; import { getActor } from '$lib/actor'; +import { + fetchAtmoEvent, + fetchAtmoEventAttendees, + fetchAtmoProfile, + fetchAtmoViewerRsvp, + getHostProfile, + getProfileBlobUrl, + getRsvpStatus, + vodFromAtUri +} from '$lib/events/atmo-appview'; +import type { ActorIdentifier } from '@atcute/lexicons'; -export async function load({ params, request, platform }) { +export async function load({ params, request, platform, locals, url }) { if (!params.rkey) error(404, 'Event URL missing rkey'); - const actor = await getActor({ request, paramActor: params.actor, platform }); - if (!actor) error(404, 'Could not resolve actor'); + const did = await getActor({ + request, + paramActor: params.actor as ActorIdentifier | undefined, + platform + }); + if (!did) error(404, 'Could not resolve actor'); - return { actor, rkey: params.rkey }; + const event = await fetchAtmoEvent({ did, rkey: params.rkey }); + if (!event) error(404, 'Event not found'); + + const additional = event.flat.additionalData as Record | undefined; + const isAtmosphereconf = !!additional?.isAtmosphereconf; + const speakers = (additional?.speakers as Array<{ id: string; name: string }> | undefined) ?? []; + const vodAtUri = additional?.vodAtUri as string | undefined; + const vod = vodAtUri ? vodFromAtUri(vodAtUri) : null; + + const viewerDid = locals.did ?? null; + + const [attendees, viewerRsvpRecord, parentEvent, ...speakerProfiles] = await Promise.all([ + fetchAtmoEventAttendees(event.flat.uri), + viewerDid ? fetchAtmoViewerRsvp({ eventUri: event.flat.uri, actor: viewerDid }) : null, + isAtmosphereconf + ? fetchAtmoEvent({ + did: 'did:plc:lehcqqkwzcwvjvw66uthu5oq', + rkey: '3lte3c7x43l2e' + }) + .then((e) => e?.flat ?? null) + .catch(() => null) + : null, + ...speakers.map(async (s) => { + if (!s.id) return { id: undefined, name: s.name, avatar: undefined, handle: undefined }; + try { + const p = await fetchAtmoProfile(s.id); + return { + id: s.id, + name: s.name, + avatar: p?.value?.avatar ? getProfileBlobUrl(p.did, p.value.avatar) : undefined, + handle: p?.handle || s.id + }; + } catch { + return { id: s.id, name: s.name, avatar: undefined, handle: s.id }; + } + }) + ]); + + return { + ogImage: `https://atmo.rsvp/p/${params.actor || did}/e/${params.rkey}/og.png`, + eventData: event.flat, + actorDid: did, + rkey: params.rkey, + hostProfile: getHostProfile(did, event.raw.profiles) ?? null, + attendees, + viewerRsvpStatus: getRsvpStatus(viewerRsvpRecord?.value?.status), + viewerRsvpRkey: viewerRsvpRecord?.rkey ?? null, + parentEvent, + vod, + speakerProfiles: speakerProfiles as Array<{ + id?: string; + name: string; + avatar?: string; + handle?: string; + }>, + shareUrl: url.href + }; } diff --git a/src/routes/[[actor=actor]]/event/r/[rkey]/+page.svelte b/src/routes/[[actor=actor]]/event/r/[rkey]/+page.svelte index 789c1a1..16085c4 100644 --- a/src/routes/[[actor=actor]]/event/r/[rkey]/+page.svelte +++ b/src/routes/[[actor=actor]]/event/r/[rkey]/+page.svelte @@ -1,30 +1,23 @@ - Event · Blento - + {data.eventData.name} · Blento + - + - + -
- -
+ diff --git a/svelte.config.js b/svelte.config.js index d48382e..14886df 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -9,6 +9,9 @@ const config = { kit: { adapter: adapter(), + alias: { + '@atmo-dev/events-ui': '../../atmo-events/packages/ui/src' + }, paths: { base: '' }, -- 2.51.2