your personal website on atproto - mirror
blento.app
1import { getBlentoOrBskyProfile, getRecord, listRecords, parseUri } from '$lib/atproto/methods';
2import type { CacheService, CachedProfile } from '$lib/cache';
3import type { EventData } from '$lib/cards/social/EventCard';
4import type { Did } from '@atcute/lexicons';
5
6export type RsvpStatus = 'going' | 'interested';
7
8export interface ResolvedRsvp {
9 event: EventData;
10 rkey: string;
11 hostDid: string;
12 hostProfile: CachedProfile | null;
13 status: 'going' | 'interested';
14 eventUri: string;
15}
16
17/**
18 * Fetch raw RSVP data for an event from Microcosm Constellation backlinks.
19 * Returns a map of DID -> status (going/interested).
20 */
21export async function fetchEventRsvps(eventUri: string): Promise<Map<string, RsvpStatus>> {
22 const allRecords: Record<string, unknown> = {};
23 let cursor: string | undefined;
24
25 do {
26 const params: Record<string, unknown> = {
27 subject: eventUri,
28 source: 'community.lexicon.calendar.rsvp:subject.uri'
29 };
30 if (cursor) params.cursor = cursor;
31
32 const res = await fetch(
33 'https://slingshot.microcosm.blue/xrpc/com.bad-example.proxy.hydrateQueryResponse',
34 {
35 method: 'POST',
36 headers: { 'Content-Type': 'application/json' },
37 body: JSON.stringify({
38 atproto_proxy: 'did:web:constellation.microcosm.blue#constellation',
39 hydration_sources: [{ path: 'records[]', shape: 'at-uri-parts' }],
40 params,
41 xrpc: 'blue.microcosm.links.getBacklinks'
42 })
43 }
44 );
45
46 if (!res.ok) break;
47
48 const data = await res.json();
49 for (const [key, value] of Object.entries(data.records ?? {})) {
50 allRecords[key] = value;
51 }
52 cursor = data.output?.cursor || undefined;
53 } while (cursor);
54
55 const rsvpMap = new Map<string, RsvpStatus>();
56
57 for (const [uri, raw] of Object.entries(allRecords)) {
58 const record = raw as { value?: { status?: string } };
59 const parts = parseUri(uri);
60 const repo = parts?.repo;
61 if (!repo) continue;
62
63 const status = record.value?.status || '';
64 if (status.includes('#going')) {
65 rsvpMap.set(repo, 'going');
66 } else if (status.includes('#interested')) {
67 rsvpMap.set(repo, 'interested');
68 }
69 }
70
71 return rsvpMap;
72}
73
74/**
75 * Resolve a DID to a profile using cache or getBlentoOrBskyProfile as fallback.
76 */
77export async function resolveProfile(
78 did: string,
79 cache?: CacheService | null
80): Promise<CachedProfile | null> {
81 if (cache) {
82 const profile = await cache.getProfile(did as Did).catch(() => null);
83 if (profile) return profile;
84 }
85 const p = await getBlentoOrBskyProfile({ did: did as Did }).catch(() => null);
86 if (!p) return null;
87 return {
88 did: p.did as string,
89 handle: p.handle as string,
90 displayName: p.displayName as string | undefined,
91 avatar: p.avatar as string | undefined,
92 hasBlento: p.hasBlento,
93 url: p.url
94 };
95}
96
97/**
98 * Resolve a DID to a handle using cache or getBlentoOrBskyProfile as fallback.
99 */
100export async function resolveHandleForDid(
101 did: string,
102 cache?: CacheService | null
103): Promise<string | null> {
104 const profile = await resolveProfile(did, cache);
105 return profile?.handle && profile.handle !== 'handle.invalid' ? profile.handle : null;
106}
107
108/**
109 * Get a profile URL for a user. Uses their Blento URL if they have one,
110 * otherwise falls back to their Bluesky profile.
111 */
112export function getProfileUrl(did: string, profile?: CachedProfile | null): string {
113 if (profile?.hasBlento) {
114 return profile.url || `https://blento.app/${profile.handle || did}`;
115 }
116 const handle = profile?.handle;
117 return handle ? `https://bsky.app/profile/${handle}` : `https://bsky.app/profile/${did}`;
118}
119
120interface RsvpRecord {
121 $type: string;
122 status: string;
123 subject: { uri: string; cid?: string };
124 createdAt: string;
125}
126
127/**
128 * Fetch a user's RSVPs (going/interested) and resolve each referenced event + host profile.
129 */
130export async function fetchUserRsvps(
131 did: string,
132 cache?: CacheService | null
133): Promise<ResolvedRsvp[]> {
134 const rsvpRecords = await listRecords({
135 did: did as Did,
136 collection: 'community.lexicon.calendar.rsvp',
137 limit: 100
138 });
139
140 const activeRsvps = rsvpRecords.filter((r) => {
141 const rsvp = r.value as unknown as RsvpRecord;
142 return rsvp.status?.endsWith('#going') || rsvp.status?.endsWith('#interested');
143 });
144
145 const results = await Promise.all(
146 activeRsvps.map(async (r) => {
147 const rsvp = r.value as unknown as RsvpRecord;
148 const parsed = parseUri(rsvp.subject.uri);
149 if (!parsed?.rkey || !parsed?.repo) return null;
150
151 try {
152 const [record, hostProfile] = await Promise.all([
153 getRecord({
154 did: parsed.repo as Did,
155 collection: 'community.lexicon.calendar.event',
156 rkey: parsed.rkey
157 }),
158 resolveProfile(parsed.repo, cache).catch(() => null)
159 ]);
160
161 if (!record?.value) return null;
162
163 return {
164 event: record.value as EventData,
165 rkey: parsed.rkey,
166 hostDid: parsed.repo,
167 hostProfile,
168 status: (rsvp.status?.endsWith('#going') ? 'going' : 'interested') as
169 | 'going'
170 | 'interested',
171 eventUri: rsvp.subject.uri
172 };
173 } catch {
174 return null;
175 }
176 })
177 );
178
179 return results.filter((r) => r !== null);
180}