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