your personal website on atproto - mirror blento.app

Merge pull request #224 from flo-bit/events-subscribe-calendar

add ical calendars

authored by

Florian and committed by
GitHub
7ecddd19 ae294471

+610 -89
+1
src/lib/cache.ts
··· 12 12 lastfm: 60 * 60, // 1 hour (default, overridable per-put) 13 13 npmx: 60 * 60 * 12, // 12 hours 14 14 profile: 60 * 60 * 24, // 24 hours 15 + ical: 60 * 60 * 2, // 2 hours 15 16 meta: 0 // no auto-expiry 16 17 } as const; 17 18
+108
src/lib/events/fetch-attendees.ts
··· 1 + import { getBlentoOrBskyProfile, parseUri } from '$lib/atproto/methods'; 2 + import type { CacheService, CachedProfile } from '$lib/cache'; 3 + import type { Did } from '@atcute/lexicons'; 4 + 5 + export type RsvpStatus = 'going' | 'interested'; 6 + 7 + /** 8 + * Fetch raw RSVP data for an event from Microcosm Constellation backlinks. 9 + * Returns a map of DID -> status (going/interested). 10 + */ 11 + export async function fetchEventRsvps(eventUri: string): Promise<Map<string, RsvpStatus>> { 12 + const allRecords: Record<string, unknown> = {}; 13 + let cursor: string | undefined; 14 + 15 + do { 16 + const params: Record<string, unknown> = { 17 + subject: eventUri, 18 + source: 'community.lexicon.calendar.rsvp:subject.uri' 19 + }; 20 + if (cursor) params.cursor = cursor; 21 + 22 + const res = await fetch( 23 + 'https://slingshot.microcosm.blue/xrpc/com.bad-example.proxy.hydrateQueryResponse', 24 + { 25 + method: 'POST', 26 + headers: { 'Content-Type': 'application/json' }, 27 + body: JSON.stringify({ 28 + atproto_proxy: 'did:web:constellation.microcosm.blue#constellation', 29 + hydration_sources: [{ path: 'records[]', shape: 'at-uri-parts' }], 30 + params, 31 + xrpc: 'blue.microcosm.links.getBacklinks' 32 + }) 33 + } 34 + ); 35 + 36 + if (!res.ok) break; 37 + 38 + const data = await res.json(); 39 + for (const [key, value] of Object.entries(data.records ?? {})) { 40 + allRecords[key] = value; 41 + } 42 + cursor = data.output?.cursor || undefined; 43 + } while (cursor); 44 + 45 + const rsvpMap = new Map<string, RsvpStatus>(); 46 + 47 + for (const [uri, raw] of Object.entries(allRecords)) { 48 + const record = raw as { value?: { status?: string } }; 49 + const parts = parseUri(uri); 50 + const repo = parts?.repo; 51 + if (!repo) continue; 52 + 53 + const status = record.value?.status || ''; 54 + if (status.includes('#going')) { 55 + rsvpMap.set(repo, 'going'); 56 + } else if (status.includes('#interested')) { 57 + rsvpMap.set(repo, 'interested'); 58 + } 59 + } 60 + 61 + return rsvpMap; 62 + } 63 + 64 + /** 65 + * Resolve a DID to a profile using cache or getBlentoOrBskyProfile as fallback. 66 + */ 67 + export async function resolveProfile( 68 + did: string, 69 + cache?: CacheService | null 70 + ): Promise<CachedProfile | null> { 71 + if (cache) { 72 + const profile = await cache.getProfile(did as Did).catch(() => null); 73 + if (profile) return profile; 74 + } 75 + const p = await getBlentoOrBskyProfile({ did: did as Did }).catch(() => null); 76 + if (!p) return null; 77 + return { 78 + did: p.did as string, 79 + handle: p.handle as string, 80 + displayName: p.displayName as string | undefined, 81 + avatar: p.avatar as string | undefined, 82 + hasBlento: p.hasBlento, 83 + url: p.url 84 + }; 85 + } 86 + 87 + /** 88 + * Resolve a DID to a handle using cache or getBlentoOrBskyProfile as fallback. 89 + */ 90 + export async function resolveHandleForDid( 91 + did: string, 92 + cache?: CacheService | null 93 + ): Promise<string | null> { 94 + const profile = await resolveProfile(did, cache); 95 + return profile?.handle && profile.handle !== 'handle.invalid' ? profile.handle : null; 96 + } 97 + 98 + /** 99 + * Get a profile URL for a user. Uses their Blento URL if they have one, 100 + * otherwise falls back to their Bluesky profile. 101 + */ 102 + export function getProfileUrl(did: string, profile?: CachedProfile | null): string { 103 + if (profile?.hasBlento) { 104 + return profile.url || `https://blento.app/${profile.handle || did}`; 105 + } 106 + const handle = profile?.handle; 107 + return handle ? `https://bsky.app/profile/${handle}` : `https://bsky.app/profile/${did}`; 108 + }
+215
src/lib/ical.ts
··· 1 + import type { EventData } from '$lib/cards/social/EventCard'; 2 + 3 + /** 4 + * Escape text for iCal fields (RFC 5545 Section 3.3.11). 5 + * Backslashes, semicolons, commas, and newlines must be escaped. 6 + */ 7 + function escapeText(text: string): string { 8 + return text 9 + .replace(/\\/g, '\\\\') 10 + .replace(/;/g, '\\;') 11 + .replace(/,/g, '\\,') 12 + .replace(/\n/g, '\\n'); 13 + } 14 + 15 + /** 16 + * Fold long lines per RFC 5545 (max 75 octets per line). 17 + * Continuation lines start with a single space. 18 + */ 19 + function foldLine(line: string): string { 20 + const maxLen = 75; 21 + if (line.length <= maxLen) return line; 22 + 23 + const parts: string[] = []; 24 + parts.push(line.slice(0, maxLen)); 25 + let i = maxLen; 26 + while (i < line.length) { 27 + parts.push(' ' + line.slice(i, i + maxLen - 1)); 28 + i += maxLen - 1; 29 + } 30 + return parts.join('\r\n'); 31 + } 32 + 33 + /** 34 + * Convert an ISO 8601 date string to iCal DATETIME format (UTC). 35 + * e.g. "2026-02-22T15:00:00Z" -> "20260222T150000Z" 36 + */ 37 + function toICalDate(isoString: string): string { 38 + const d = new Date(isoString); 39 + const pad = (n: number) => n.toString().padStart(2, '0'); 40 + return ( 41 + d.getUTCFullYear().toString() + 42 + pad(d.getUTCMonth() + 1) + 43 + pad(d.getUTCDate()) + 44 + 'T' + 45 + pad(d.getUTCHours()) + 46 + pad(d.getUTCMinutes()) + 47 + pad(d.getUTCSeconds()) + 48 + 'Z' 49 + ); 50 + } 51 + 52 + /** 53 + * Extract a location string from event locations array. 54 + */ 55 + function getLocationString(locations: EventData['locations']): string | undefined { 56 + if (!locations || locations.length === 0) return undefined; 57 + 58 + const loc = locations.find((v) => v.$type === 'community.lexicon.location.address'); 59 + if (!loc) return undefined; 60 + 61 + const flat = loc as Record<string, unknown>; 62 + const nested = loc.address; 63 + 64 + const street = (flat.street as string) || undefined; 65 + const locality = (flat.locality as string) || nested?.locality; 66 + const region = (flat.region as string) || nested?.region; 67 + 68 + const parts = [street, locality, region].filter(Boolean); 69 + return parts.length > 0 ? parts.join(', ') : undefined; 70 + } 71 + 72 + function getModeLabel(mode: string): string { 73 + if (mode.includes('virtual')) return 'Virtual'; 74 + if (mode.includes('hybrid')) return 'Hybrid'; 75 + if (mode.includes('inperson')) return 'In-Person'; 76 + return 'Event'; 77 + } 78 + 79 + export interface ICalAttendee { 80 + name: string; 81 + status: 'going' | 'interested'; 82 + url?: string; 83 + } 84 + 85 + export interface ICalEvent { 86 + eventData: EventData; 87 + uid: string; 88 + url?: string; 89 + organizer?: string; 90 + imageUrl?: string; 91 + attendees?: ICalAttendee[]; 92 + } 93 + 94 + /** 95 + * Generate a single VEVENT block. 96 + */ 97 + function generateVEvent(event: ICalEvent): string | null { 98 + const { eventData, uid, url, organizer, imageUrl } = event; 99 + 100 + // Skip events with invalid or missing start dates 101 + const startTime = new Date(eventData.startsAt); 102 + if (isNaN(startTime.getTime())) return null; 103 + 104 + const lines: string[] = []; 105 + 106 + lines.push('BEGIN:VEVENT'); 107 + lines.push(`UID:${escapeText(uid)}`); 108 + lines.push(`DTSTART:${toICalDate(eventData.startsAt)}`); 109 + 110 + if (eventData.endsAt) { 111 + lines.push(`DTEND:${toICalDate(eventData.endsAt)}`); 112 + } else { 113 + // Default to 1 hour duration when no end time is specified 114 + const defaultEnd = new Date(startTime.getTime() + 60 * 60 * 1000); 115 + lines.push(`DTEND:${toICalDate(defaultEnd.toISOString())}`); 116 + } 117 + 118 + lines.push(`SUMMARY:${escapeText(eventData.name)}`); 119 + 120 + // Description: text + links 121 + const descParts: string[] = []; 122 + if (eventData.description) { 123 + descParts.push(eventData.description); 124 + } 125 + if (eventData.uris && eventData.uris.length > 0) { 126 + descParts.push(''); 127 + descParts.push('Links:'); 128 + for (const link of eventData.uris) { 129 + descParts.push(link.name ? `${link.name}: ${link.uri}` : link.uri); 130 + } 131 + } 132 + if (url) { 133 + descParts.push(''); 134 + descParts.push(`Event page: ${url}`); 135 + } 136 + if (descParts.length > 0) { 137 + lines.push(`DESCRIPTION:${escapeText(descParts.join('\n'))}`); 138 + } 139 + 140 + const location = getLocationString(eventData.locations); 141 + if (location) { 142 + lines.push(`LOCATION:${escapeText(location)}`); 143 + } 144 + 145 + if (url) { 146 + lines.push(`URL:${url}`); 147 + } 148 + 149 + // Categories from event mode 150 + if (eventData.mode) { 151 + lines.push(`CATEGORIES:${escapeText(getModeLabel(eventData.mode))}`); 152 + } 153 + 154 + // Organizer 155 + if (organizer) { 156 + lines.push( 157 + `ORGANIZER;CN=${escapeText(organizer)}:https://bsky.app/profile/${encodeURIComponent(organizer)}` 158 + ); 159 + } 160 + 161 + // Attendees 162 + if (event.attendees) { 163 + for (const attendee of event.attendees) { 164 + const partstat = attendee.status === 'going' ? 'ACCEPTED' : 'TENTATIVE'; 165 + lines.push( 166 + `ATTENDEE;CN=${escapeText(attendee.name)};PARTSTAT=${partstat}:${attendee.url || `https://bsky.app/profile/${encodeURIComponent(attendee.name)}`}` 167 + ); 168 + } 169 + } 170 + 171 + // Image (supported by Apple Calendar, Google Calendar) 172 + if (imageUrl) { 173 + lines.push(`IMAGE;VALUE=URI;DISPLAY=BADGE:${imageUrl}`); 174 + } 175 + 176 + lines.push(`DTSTAMP:${toICalDate(new Date().toISOString())}`); 177 + 178 + // Reminder 15 minutes before 179 + lines.push('BEGIN:VALARM'); 180 + lines.push('TRIGGER:-PT15M'); 181 + lines.push('ACTION:DISPLAY'); 182 + lines.push(`DESCRIPTION:${escapeText(eventData.name)}`); 183 + lines.push('END:VALARM'); 184 + 185 + lines.push('END:VEVENT'); 186 + 187 + return lines.map(foldLine).join('\r\n'); 188 + } 189 + 190 + /** 191 + * Generate a complete iCal feed from multiple events. 192 + */ 193 + export function generateICalFeed(events: ICalEvent[], calendarName: string): string { 194 + const lines: string[] = []; 195 + 196 + lines.push('BEGIN:VCALENDAR'); 197 + lines.push('VERSION:2.0'); 198 + lines.push('PRODID:-//Blento//Events//EN'); 199 + lines.push(`X-WR-CALNAME:${escapeText(calendarName)}`); 200 + lines.push('CALSCALE:GREGORIAN'); 201 + lines.push('METHOD:PUBLISH'); 202 + 203 + const vevents = events.map(generateVEvent).filter((v): v is string => v !== null); 204 + 205 + const result = 206 + lines.map(foldLine).join('\r\n') + '\r\n' + vevents.join('\r\n') + '\r\nEND:VCALENDAR\r\n'; 207 + return result; 208 + } 209 + 210 + /** 211 + * Generate iCal content for a single event (for client-side download). 212 + */ 213 + export function generateICalEvent(eventData: EventData, atUri: string, eventUrl?: string): string { 214 + return generateICalFeed([{ eventData, uid: atUri, url: eventUrl }], eventData.name); 215 + }
+37 -1
src/routes/[[actor=actor]]/events/[rkey]/+page.svelte
··· 9 9 import { page } from '$app/state'; 10 10 import { segmentize, type Facet } from '@atcute/bluesky-richtext-segmenter'; 11 11 import { sanitize } from '$lib/sanitize'; 12 + import { generateICalEvent } from '$lib/ical'; 12 13 13 14 let { data } = $props(); 14 15 ··· 178 179 if (!user.did) return; 179 180 attendeesRef?.removeAttendee(user.did); 180 181 } 182 + 183 + function downloadIcs() { 184 + const ical = generateICalEvent(eventData, eventUri, page.url.href); 185 + const blob = new Blob([ical], { type: 'text/calendar;charset=utf-8' }); 186 + const url = URL.createObjectURL(blob); 187 + const a = document.createElement('a'); 188 + a.href = url; 189 + a.download = `${eventData.name.replace(/[^a-zA-Z0-9]/g, '-')}.ics`; 190 + a.click(); 191 + URL.revokeObjectURL(url); 192 + } 181 193 </script> 182 194 183 195 <svelte:head> ··· 284 296 {#if location} 285 297 <div class="mb-6 flex items-center gap-4"> 286 298 <div 287 - class="border-base-200 dark:border-base-700 flex size-12 shrink-0 items-center justify-center rounded-xl border" 299 + class="border-base-200 dark:border-base-700 bg-base-100 dark:bg-base-950/30 flex size-12 shrink-0 items-center justify-center rounded-xl border" 288 300 > 289 301 <svg 290 302 xmlns="http://www.w3.org/2000/svg" ··· 392 404 </div> 393 405 </div> 394 406 {/if} 407 + 408 + <!-- Add to Calendar --> 409 + <div class="order-5 md:order-0 md:col-start-1"> 410 + <button 411 + onclick={downloadIcs} 412 + class="text-base-700 dark:text-base-300 hover:text-base-900 dark:hover:text-base-100 flex cursor-pointer items-center gap-2 text-sm font-medium transition-colors" 413 + > 414 + <svg 415 + xmlns="http://www.w3.org/2000/svg" 416 + fill="none" 417 + viewBox="0 0 24 24" 418 + stroke-width="1.5" 419 + stroke="currentColor" 420 + class="size-4" 421 + > 422 + <path 423 + stroke-linecap="round" 424 + stroke-linejoin="round" 425 + d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" 426 + /> 427 + </svg> 428 + Add to Calendar 429 + </button> 430 + </div> 395 431 396 432 <!-- Attendees --> 397 433 <div class="order-5 md:order-0 md:col-start-1">
+17 -88
src/routes/[[actor=actor]]/events/[rkey]/api.remote.ts
··· 1 1 import { query, getRequestEvent } from '$app/server'; 2 - import { createCache, type CachedProfile } from '$lib/cache'; 3 - import { getBlentoOrBskyProfile, parseUri } from '$lib/atproto/methods'; 4 - import type { Did } from '@atcute/lexicons'; 2 + import { createCache } from '$lib/cache'; 3 + import { fetchEventRsvps, resolveProfile } from '$lib/events/fetch-attendees'; 5 4 6 5 export type AttendeeInfo = { 7 6 did: string; ··· 22 21 export const fetchEventAttendees = query( 23 22 'unchecked', 24 23 async (eventUri: string): Promise<EventAttendeesResult> => { 25 - // 1. Fetch backlinks (RSVPs) 26 - const allRecords: Record<string, unknown> = {}; 27 - let cursor: string | undefined; 24 + const rsvpMap = await fetchEventRsvps(eventUri); 28 25 29 - do { 30 - const params: Record<string, unknown> = { 31 - subject: eventUri, 32 - source: 'community.lexicon.calendar.rsvp:subject.uri' 33 - }; 34 - if (cursor) params.cursor = cursor; 35 - 36 - const res = await fetch( 37 - 'https://slingshot.microcosm.blue/xrpc/com.bad-example.proxy.hydrateQueryResponse', 38 - { 39 - method: 'POST', 40 - headers: { 'Content-Type': 'application/json' }, 41 - body: JSON.stringify({ 42 - atproto_proxy: 'did:web:constellation.microcosm.blue#constellation', 43 - hydration_sources: [ 44 - { 45 - path: 'records[]', 46 - shape: 'at-uri-parts' 47 - } 48 - ], 49 - params, 50 - xrpc: 'blue.microcosm.links.getBacklinks' 51 - }) 52 - } 53 - ); 54 - 55 - if (!res.ok) break; 56 - 57 - const data = await res.json(); 58 - const output = data.output; 59 - 60 - for (const [key, value] of Object.entries(data.records ?? {})) { 61 - allRecords[key] = value; 62 - } 63 - 64 - cursor = output.cursor || undefined; 65 - } while (cursor); 66 - 67 - // 2. Parse RSVPs and collect unique DIDs 68 26 const going: string[] = []; 69 27 const interested: string[] = []; 70 - 71 - for (const [uri, raw] of Object.entries(allRecords)) { 72 - console.log(uri, raw); 73 - const record = raw as { did?: string; value?: { status?: string } }; 74 - // DID can be on the record directly or extracted from the AT URI key 75 - const parts = parseUri(uri); 76 - const repo = parts?.repo; 77 - if (!repo) continue; 78 - 79 - const status = record.value?.status || ''; 80 - if (status.includes('#going')) { 81 - going.push(repo); 82 - } else if (status.includes('#interested')) { 83 - interested.push(repo); 84 - } 28 + for (const [did, status] of rsvpMap) { 29 + if (status === 'going') going.push(did); 30 + else interested.push(did); 85 31 } 86 32 87 - // 3. Fetch profiles for attendees (with caching) 33 + // Fetch profiles for attendees (with caching) 88 34 const uniqueDids = [...new Set([...going, ...interested])]; 89 35 const { platform } = getRequestEvent(); 90 36 const cache = createCache(platform); 91 37 92 - const profileMap = new Map<string, CachedProfile>(); 38 + const profileMap = new Map< 39 + string, 40 + { handle?: string; displayName?: string; avatar?: string; hasBlento?: boolean; url?: string } 41 + >(); 93 42 94 43 await Promise.all( 95 44 uniqueDids.map(async (did) => { 96 - try { 97 - let profile: CachedProfile; 98 - if (cache) { 99 - profile = await cache.getProfile(did as Did); 100 - } else { 101 - const p = await getBlentoOrBskyProfile({ did: did as Did }); 102 - profile = { 103 - did: p.did as string, 104 - handle: p.handle as string, 105 - displayName: p.displayName as string | undefined, 106 - avatar: p.avatar as string | undefined, 107 - hasBlento: p.hasBlento, 108 - url: p.url 109 - }; 110 - } 111 - profileMap.set(did, profile); 112 - } catch { 113 - // skip failed profile fetches 114 - } 45 + const profile = await resolveProfile(did, cache).catch(() => null); 46 + if (profile) profileMap.set(did, profile); 115 47 }) 116 48 ); 117 49 ··· 133 65 }; 134 66 } 135 67 136 - const uniqueGoing = [...new Set(going)]; 137 - const uniqueInterested = [...new Set(interested)]; 138 - 139 68 return { 140 - going: uniqueGoing.map((did) => toAttendeeInfo(did, 'going')), 141 - interested: uniqueInterested.map((did) => toAttendeeInfo(did, 'interested')), 142 - goingCount: uniqueGoing.length, 143 - interestedCount: uniqueInterested.length 69 + going: going.map((did) => toAttendeeInfo(did, 'going')), 70 + interested: interested.map((did) => toAttendeeInfo(did, 'interested')), 71 + goingCount: going.length, 72 + interestedCount: interested.length 144 73 }; 145 74 } 146 75 );
+97
src/routes/[[actor=actor]]/events/calendar/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { getCDNImageBlobUrl, listRecords } from '$lib/atproto/methods.js'; 4 + import { createCache } from '$lib/cache'; 5 + import type { Did } from '@atcute/lexicons'; 6 + import { getActor } from '$lib/actor'; 7 + import { generateICalFeed, type ICalEvent } from '$lib/ical'; 8 + import { fetchEventRsvps, getProfileUrl, resolveProfile } from '$lib/events/fetch-attendees'; 9 + 10 + export async function GET({ params, platform, request }) { 11 + const cache = createCache(platform); 12 + 13 + const did = await getActor({ request, paramActor: params.actor, platform }); 14 + 15 + if (!did) { 16 + throw error(404, 'Not found'); 17 + } 18 + 19 + try { 20 + // Check cache first 21 + const cacheKey = `${did}:calendar`; 22 + if (cache) { 23 + const cached = await cache.get('ical', cacheKey); 24 + if (cached) { 25 + return new Response(cached, { 26 + headers: { 27 + 'Content-Type': 'text/calendar; charset=utf-8', 28 + 'Cache-Control': 'public, max-age=3600' 29 + } 30 + }); 31 + } 32 + } 33 + 34 + const [records, hostProfile] = await Promise.all([ 35 + listRecords({ 36 + did: did as Did, 37 + collection: 'community.lexicon.calendar.event', 38 + limit: 100 39 + }), 40 + resolveProfile(did, cache) 41 + ]); 42 + 43 + const actor = hostProfile?.handle || did; 44 + 45 + // Fetch attendees for all events in parallel 46 + const events: ICalEvent[] = await Promise.all( 47 + records.map(async (r) => { 48 + const eventData = r.value as EventData; 49 + const thumbnail = eventData.media?.find((m) => m.role === 'thumbnail'); 50 + const imageUrl = thumbnail?.content 51 + ? getCDNImageBlobUrl({ did, blob: thumbnail.content, type: 'jpeg' }) 52 + : undefined; 53 + 54 + // Fetch RSVPs and resolve handles 55 + const rsvpMap = await fetchEventRsvps(r.uri).catch(() => new Map()); 56 + const attendees: ICalAttendee[] = []; 57 + await Promise.all( 58 + Array.from(rsvpMap.entries()).map(async ([attendeeDid, status]) => { 59 + const profile = await resolveProfile(attendeeDid, cache).catch(() => null); 60 + attendees.push({ 61 + name: profile?.handle || attendeeDid, 62 + status, 63 + url: getProfileUrl(attendeeDid, profile) 64 + }); 65 + }) 66 + ); 67 + 68 + return { 69 + eventData, 70 + uid: r.uri, 71 + url: `https://blento.app/${actor}/events/${r.uri.split('/').pop()}`, 72 + organizer: actor, 73 + imageUrl, 74 + attendees 75 + }; 76 + }) 77 + ); 78 + 79 + const calendarName = `${hostProfile?.displayName || actor}'s Events`; 80 + const ical = generateICalFeed(events, calendarName); 81 + 82 + // Store in cache 83 + if (cache) { 84 + await cache.put('ical', cacheKey, ical).catch(() => {}); 85 + } 86 + 87 + return new Response(ical, { 88 + headers: { 89 + 'Content-Type': 'text/calendar; charset=utf-8', 90 + 'Cache-Control': 'public, max-age=3600' 91 + } 92 + }); 93 + } catch (e) { 94 + if (e && typeof e === 'object' && 'status' in e) throw e; 95 + throw error(500, 'Failed to generate calendar'); 96 + } 97 + }
+135
src/routes/[[actor=actor]]/events/rsvp-calendar/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { EventData } from '$lib/cards/social/EventCard'; 3 + import { getCDNImageBlobUrl, getRecord, listRecords, parseUri } from '$lib/atproto/methods.js'; 4 + import { createCache } from '$lib/cache'; 5 + import type { Did } from '@atcute/lexicons'; 6 + import { getActor } from '$lib/actor'; 7 + import { generateICalFeed, type ICalAttendee, type ICalEvent } from '$lib/ical'; 8 + import { fetchEventRsvps, getProfileUrl, resolveProfile } from '$lib/events/fetch-attendees'; 9 + 10 + interface RsvpRecord { 11 + $type: string; 12 + status: string; 13 + subject: { uri: string; cid?: string }; 14 + createdAt: string; 15 + } 16 + 17 + export async function GET({ params, platform, request }) { 18 + const cache = createCache(platform); 19 + 20 + const did = await getActor({ request, paramActor: params.actor, platform }); 21 + 22 + if (!did) { 23 + throw error(404, 'Not found'); 24 + } 25 + 26 + try { 27 + // Check cache first 28 + const cacheKey = `${did}:rsvp-calendar`; 29 + if (cache) { 30 + const cached = await cache.get('ical', cacheKey); 31 + if (cached) { 32 + return new Response(cached, { 33 + headers: { 34 + 'Content-Type': 'text/calendar; charset=utf-8', 35 + 'Cache-Control': 'public, max-age=3600' 36 + } 37 + }); 38 + } 39 + } 40 + 41 + const [rsvpRecords, hostProfile] = await Promise.all([ 42 + listRecords({ 43 + did: did as Did, 44 + collection: 'community.lexicon.calendar.rsvp', 45 + limit: 100 46 + }), 47 + resolveProfile(did, cache) 48 + ]); 49 + 50 + // Filter to only going and interested RSVPs 51 + const activeRsvps = rsvpRecords.filter((r) => { 52 + const rsvp = r.value as unknown as RsvpRecord; 53 + return rsvp.status?.endsWith('#going') || rsvp.status?.endsWith('#interested'); 54 + }); 55 + 56 + // Fetch each referenced event in parallel 57 + const eventResults = await Promise.all( 58 + activeRsvps.map(async (r) => { 59 + const rsvp = r.value as unknown as RsvpRecord; 60 + const parsed = parseUri(rsvp.subject.uri); 61 + if (!parsed?.rkey || !parsed?.repo) return null; 62 + 63 + try { 64 + const [record, organizerProfile] = await Promise.all([ 65 + getRecord({ 66 + did: parsed.repo as Did, 67 + collection: 'community.lexicon.calendar.event', 68 + rkey: parsed.rkey 69 + }), 70 + resolveProfile(parsed.repo, cache).catch(() => null) 71 + ]); 72 + if (!record?.value) return null; 73 + const eventData = record.value as EventData; 74 + const actor = organizerProfile?.handle || parsed.repo; 75 + const thumbnail = eventData.media?.find((m) => m.role === 'thumbnail'); 76 + const imageUrl = thumbnail?.content 77 + ? getCDNImageBlobUrl({ 78 + did: parsed.repo, 79 + blob: thumbnail.content, 80 + type: 'jpeg' 81 + }) 82 + : undefined; 83 + 84 + // Fetch RSVPs and resolve handles 85 + const rsvpMap = await fetchEventRsvps(rsvp.subject.uri).catch( 86 + () => new Map<string, 'going' | 'interested'>() 87 + ); 88 + const attendees: ICalAttendee[] = []; 89 + await Promise.all( 90 + Array.from(rsvpMap.entries()).map(async ([attendeeDid, status]) => { 91 + const profile = await resolveProfile(attendeeDid, cache).catch(() => null); 92 + attendees.push({ 93 + name: profile?.handle || attendeeDid, 94 + status, 95 + url: getProfileUrl(attendeeDid, profile) 96 + }); 97 + }) 98 + ); 99 + 100 + return { 101 + eventData, 102 + uid: rsvp.subject.uri, 103 + url: `https://blento.app/${actor}/events/${parsed.rkey}`, 104 + organizer: actor, 105 + imageUrl, 106 + attendees 107 + } satisfies ICalEvent; 108 + } catch { 109 + return null; 110 + } 111 + }) 112 + ); 113 + 114 + const events: ICalEvent[] = eventResults.filter((e) => e !== null); 115 + 116 + const actor = hostProfile?.handle || did; 117 + const calendarName = `${hostProfile?.displayName || actor}'s RSVP Events`; 118 + const ical = generateICalFeed(events, calendarName); 119 + 120 + // Store in cache 121 + if (cache) { 122 + await cache.put('ical', cacheKey, ical).catch(() => {}); 123 + } 124 + 125 + return new Response(ical, { 126 + headers: { 127 + 'Content-Type': 'text/calendar; charset=utf-8', 128 + 'Cache-Control': 'public, max-age=3600' 129 + } 130 + }); 131 + } catch (e) { 132 + if (e && typeof e === 'object' && 'status' in e) throw e; 133 + throw error(500, 'Failed to generate calendar'); 134 + } 135 + }