···1212- add your events to any ical compatible calendar
1313(go to calendar/ when signed in and click "Add to your calendar")
1414- post your events/rsvps to bluesky or anywhere else with nice open-graph images
1515+- display comments
1616+- show what events your bsky follows are going to
15171618## development
1719
···7979 // Exposed as rsvp.atmo.getFeed?feed=network&actor=<did>&collection=<nsid>.
8080 // Powers the home-page "from people you follow" surface.
8181 network: {
8282- targets: ['event', 'rsvp']
8282+ // Per-target caps so RSVPs (high-volume) can't squeeze events
8383+ // (low-volume) out of the cap. Bumped above the default 200 because
8484+ // most RSVPs in feed_items refer to past events, and we want enough
8585+ // breathing room to find recent ones after the JS-side filter.
8686+ targets: [
8787+ { collection: 'event', maxItems: 200 },
8888+ { collection: 'rsvp', maxItems: 1000 }
8989+ ]
8390 }
8491 }
8592};
+12-5
src/lib/contrail.ts
···6969export type ActivityCluster = {
7070 event: FlatEventRecord;
7171 attendees: AttendeeInfo[];
7272- /** ms since epoch of the most recent RSVP in this cluster, taken from the
7373- * RSVP record's `createdAt` (when the user actually RSVP'd) — not from
7474- * contrail's `time_us` (which reflects index time and all bunches up after
7575- * a backfill). */
7272+ /** Set when the cluster's source was the event itself being authored by
7373+ * someone in the viewer's follow set (vs. only an RSVP from a follow).
7474+ * Used by the UI to render "Hosted by X" when `attendees` is empty. */
7575+ host?: HostProfile;
7676+ /** ms since epoch of the most recent activity in this cluster — the latest
7777+ * RSVP `createdAt`, or the event's own `createdAt` for event-only clusters.
7878+ * Drives display order. */
7679 latestCreatedAtMs: number;
7780};
7881···8386 startsAtMax?: string;
8487 endsAtMin?: string;
8588 endsAtMax?: string;
8989+ rsvpsCountMin?: number;
8690 rsvpsGoingCountMin?: number;
8791 hydrateRsvps?: number;
8892 profiles?: boolean;
···163167 return `/p/${who}/e/${event.rkey}`;
164168}
165169166166-export function getHostProfile(did: string, profiles?: EventProfiles): HostProfile | null {
170170+export function getHostProfile(
171171+ did: string,
172172+ profiles?: AttendeeProfileEntry[]
173173+): HostProfile | null {
167174 const profile = profiles?.find((entry) => entry.did === did);
168175 if (!profile) return null;
169176
+112-36
src/routes/(app)/+page.server.ts
···22 buildAttendee,
33 flattenEventRecord,
44 flattenEventRecords,
55+ getHostProfile,
56 getServerClient,
67 listDiscoverableEventsFromContrail,
78 listEventRecordsFromContrail,
88- type ActivityCluster
99+ type ActivityCluster,
1010+ type HostProfile
911} from '$lib/contrail';
1012import { getSpacesClient } from '$lib/spaces/server/client';
1113import { spacesAvailable } from '$lib/spaces/config';
1214import type { PageServerLoad } from './$types';
13151416const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
1515-const ACTIVITY_FETCH_LIMIT = 100;
1717+const ACTIVITY_FETCH_LIMIT = 200;
1618const ACTIVITY_DISPLAY_LIMIT = 10;
1919+/** Activity feed includes RSVPs to events that ended within this window so
2020+ * recently-finished events linger briefly (their RSVPs are still meaningful
2121+ * social signal). */
2222+const ACTIVITY_RECENT_EVENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
17231824export const load: PageServerLoad = async ({ locals, platform }) => {
1925 const publicClient = getServerClient(platform!.env.DB);
···77837884 const globalPromise = listDiscoverableEventsFromContrail(publicClient, {
7985 startsAtMin: nowIso,
8080- rsvpsGoingCountMin: 2,
8686+ rsvpsCountMin: 2,
8187 hydrateRsvps: 5,
8288 sort: 'startsAt',
8389 order: 'asc',
···98104 ? P
99105 : never;
100106101101- function clusterRsvps(
107107+ type ActivityEvent = {
108108+ did: string;
109109+ uri: string;
110110+ value?: { startsAt?: string; endsAt?: string | null; createdAt?: string };
111111+ };
112112+113113+ function addRsvpsToClusters(
102114 records: ActivityRsvp[],
103103- profiles: ActivityProfile[]
104104- ): ActivityCluster[] {
115115+ profiles: ActivityProfile[],
116116+ clusters: Map<string, ActivityCluster>
117117+ ) {
105118 const nowMs = Date.now();
106106- const clusters = new Map<string, ActivityCluster>();
107119 for (const r of records) {
108120 const status = r.value?.status;
109121 const isGoing = status?.endsWith('#going');
···113125 const flatEvent = flattenEventRecord(r.event);
114126 if (!flatEvent) continue;
115127 const eventEndMs = new Date(flatEvent.endsAt || flatEvent.startsAt).getTime();
116116- if (eventEndMs < nowMs) continue;
128128+ if (eventEndMs < nowMs - ACTIVITY_RECENT_EVENT_WINDOW_MS) continue;
117129118130 const attendee = buildAttendee(r.did, isGoing ? 'going' : 'interested', profiles);
119131 const createdAtMs = r.value?.createdAt ? new Date(r.value.createdAt).getTime() : 0;
···125137 cluster.attendees.push(attendee);
126138 if (createdAtMs > cluster.latestCreatedAtMs) cluster.latestCreatedAtMs = createdAtMs;
127139 }
128128- return Array.from(clusters.values())
140140+ }
141141+142142+ /** Add events authored by people the viewer follows to existing rsvp-driven
143143+ * clusters (or create new clusters for events with no follow-RSVPs). The
144144+ * cluster's `host` field flags author-is-a-follow so the UI can render
145145+ * "Hosted by X" when there are no attendees. */
146146+ function addFollowEventsToClusters(
147147+ records: ActivityEvent[],
148148+ profiles: ActivityProfile[],
149149+ clusters: Map<string, ActivityCluster>
150150+ ) {
151151+ const nowMs = Date.now();
152152+ for (const e of records) {
153153+ const flatEvent = flattenEventRecord(e as Parameters<typeof flattenEventRecord>[0]);
154154+ if (!flatEvent) continue;
155155+ const eventEndMs = new Date(flatEvent.endsAt || flatEvent.startsAt).getTime();
156156+ if (eventEndMs < nowMs - ACTIVITY_RECENT_EVENT_WINDOW_MS) continue;
157157+158158+ const host = getHostProfile(flatEvent.did, profiles) ?? undefined;
159159+ const eventCreatedAtMs = e.value?.createdAt ? new Date(e.value.createdAt).getTime() : 0;
160160+ let cluster = clusters.get(flatEvent.uri);
161161+ if (!cluster) {
162162+ cluster = {
163163+ event: flatEvent,
164164+ attendees: [],
165165+ host,
166166+ latestCreatedAtMs: eventCreatedAtMs
167167+ };
168168+ clusters.set(flatEvent.uri, cluster);
169169+ } else {
170170+ // Existing rsvp-driven cluster gets host attribution layered on.
171171+ cluster.host = cluster.host ?? host;
172172+ }
173173+ }
174174+ }
175175+176176+ function finalizeClusters(map: Map<string, ActivityCluster>): ActivityCluster[] {
177177+ return Array.from(map.values())
129178 .sort((a, b) => b.latestCreatedAtMs - a.latestCreatedAtMs)
130179 .slice(0, ACTIVITY_DISPLAY_LIMIT);
131180 }
···141190 }
142191 });
143192 if (!response.ok) return [];
144144- return clusterRsvps(
193193+ const clusters = new Map<string, ActivityCluster>();
194194+ addRsvpsToClusters(
145195 (response.data.records ?? []) as ActivityRsvp[],
146146- (response.data.profiles ?? []) as ActivityProfile[]
196196+ (response.data.profiles ?? []) as ActivityProfile[],
197197+ clusters
147198 );
199199+ return finalizeClusters(clusters);
148200 }
149201150202 const recentActivityPromise = (async (): Promise<{
151203 activity: ActivityCluster[];
152204 isPersonalized: boolean;
153205 }> => {
154154- // Logged-in: try the personalized "from people you follow" feed first.
155155- // If empty (cold start, follows haven't RSVP'd to upcoming events),
156156- // fall back to the global recent-RSVP feed so the section isn't dead.
157157- // Anon: skip straight to global.
206206+ // Logged-in: pull both RSVPs and events authored by people the viewer
207207+ // follows, merge into one cluster set keyed by event URI. RSVP clusters
208208+ // + event clusters that point at the same event collapse — the host info
209209+ // just gets layered on. If the merged set is empty (cold start), fall
210210+ // back to the global recent-RSVP feed.
158211 if (locals.did) {
159159- const response = await publicClient.get('rsvp.atmo.getFeed', {
160160- params: {
161161- feed: 'network',
162162- actor: locals.did,
163163- // NOTE: contrail's getFeed runtime checks `collection` against
164164- // the SHORT names from `feedConfig.targets` (e.g. 'rsvp'), but
165165- // the regenerated lex schema documents the full NSID as the
166166- // valid enum. Pass the short name; full NSID returns 400.
167167- collection: 'rsvp',
168168- hydrateEvent: true,
169169- profiles: true,
170170- sort: 'createdAt',
171171- order: 'desc',
172172- limit: ACTIVITY_FETCH_LIMIT
173173- }
174174- });
175175- if (response.ok) {
176176- const personalized = clusterRsvps(
177177- (response.data.records ?? []) as ActivityRsvp[],
178178- (response.data.profiles ?? []) as ActivityProfile[]
212212+ const [rsvpResp, eventResp] = await Promise.all([
213213+ publicClient.get('rsvp.atmo.getFeed', {
214214+ params: {
215215+ feed: 'network',
216216+ actor: locals.did,
217217+ collection: 'rsvp',
218218+ hydrateEvent: true,
219219+ profiles: true,
220220+ sort: 'createdAt',
221221+ order: 'desc',
222222+ limit: ACTIVITY_FETCH_LIMIT
223223+ }
224224+ }),
225225+ publicClient.get('rsvp.atmo.getFeed', {
226226+ params: {
227227+ feed: 'network',
228228+ actor: locals.did,
229229+ collection: 'event',
230230+ profiles: true,
231231+ sort: 'startsAt',
232232+ order: 'asc',
233233+ // Only events still upcoming or recent. Server-side filter is
234234+ // possible here because startsAt IS in the event collection's
235235+ // queryable (unlike rsvp records, which carry no event date).
236236+ startsAtMin: new Date(Date.now() - ACTIVITY_RECENT_EVENT_WINDOW_MS).toISOString(),
237237+ limit: ACTIVITY_FETCH_LIMIT
238238+ }
239239+ })
240240+ ]);
241241+242242+ const clusters = new Map<string, ActivityCluster>();
243243+ if (rsvpResp.ok) {
244244+ addRsvpsToClusters(
245245+ (rsvpResp.data.records ?? []) as ActivityRsvp[],
246246+ (rsvpResp.data.profiles ?? []) as ActivityProfile[],
247247+ clusters
179248 );
180180- if (personalized.length > 0) return { activity: personalized, isPersonalized: true };
249249+ }
250250+ if (eventResp.ok) {
251251+ addFollowEventsToClusters(
252252+ (eventResp.data.records ?? []) as ActivityEvent[],
253253+ (eventResp.data.profiles ?? []) as ActivityProfile[],
254254+ clusters
255255+ );
181256 }
257257+ if (clusters.size > 0) return { activity: finalizeClusters(clusters), isPersonalized: true };
182258 }
183259 return { activity: await fetchGlobalActivity(), isPersonalized: false };
184260 })();
+2-2
src/routes/(app)/+page.svelte
···7878 {/if}
79798080 <div class="mb-8 flex items-baseline justify-between">
8181- <h2 class="text-base-900 dark:text-base-50 text-xl font-bold">Upcoming Events</h2>
8181+ <h2 class="text-base-900 dark:text-base-50 text-xl font-bold">Upcoming Popular Events</h2>
8282 <a
8383 href="/events"
8484 class="text-sm font-medium text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300 transition-colors"
···104104 </h2>
105105 {#if data.recentActivityIsPersonalized}
106106 <p class="text-base-500 dark:text-base-400 mb-4 text-sm">
107107- Events your Bluesky follows are RSVPing to.
107107+ Events your Bluesky follows are hosting or going to.
108108 </p>
109109 {/if}
110110 <RecentActivity activities={data.recentActivity} />