atmo.rsvp
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

small fixes, new db

+206 -90
+2
README.md
··· 12 12 - add your events to any ical compatible calendar 13 13 (go to calendar/ when signed in and click "Add to your calendar") 14 14 - post your events/rsvps to bluesky or anywhere else with nice open-graph images 15 + - display comments 16 + - show what events your bsky follows are going to 15 17 16 18 ## development 17 19
+3 -3
src/lib/components/EventCard.svelte
··· 99 99 {/if} 100 100 </p> 101 101 <h3 102 - class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mt-0.5 line-clamp-2 flex items-center gap-1.5 text-sm leading-snug font-semibold transition-colors sm:text-base" 102 + class="text-base-900 dark:text-base-50 group-hover:text-base-700 dark:group-hover:text-base-200 mt-0.5 flex items-start gap-1.5 text-sm leading-snug font-semibold transition-colors sm:text-base" 103 103 > 104 104 {#if event.space} 105 105 <svg ··· 109 109 stroke-width="2" 110 110 stroke-linecap="round" 111 111 stroke-linejoin="round" 112 - class="text-base-500 dark:text-base-400 mt-0.5 size-3.5 shrink-0" 112 + class="text-base-500 dark:text-base-400 mt-1 size-3.5 shrink-0" 113 113 aria-label="Private event" 114 114 > 115 115 <rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> 116 116 <path d="M7 11V7a5 5 0 0 1 10 0v4" /> 117 117 </svg> 118 118 {/if} 119 - <span>{event.name}</span> 119 + <span class="line-clamp-2">{event.name}</span> 120 120 </h3> 121 121 {#if location || mode} 122 122 <p class="text-base-500 dark:text-base-400 mt-1 text-xs">
+62 -39
src/lib/components/RecentActivity.svelte
··· 71 71 </div> 72 72 <div class="mt-2 flex items-center gap-2"> 73 73 <div class="flex shrink-0 -space-x-1.5"> 74 - {#if shown.length >= 4} 75 - {#each shown.slice(0, 2) as attendee (attendee.did)} 76 - {#if attendee.avatar} 77 - <img 78 - src={attendee.avatar} 79 - alt="" 80 - class="ring-base-50 dark:ring-base-900 h-6 w-6 rounded-full object-cover ring-2" 81 - /> 82 - {:else} 83 - <div 84 - class="bg-base-300 dark:bg-base-700 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium ring-2" 85 - > 86 - {initial(attendee.name)} 87 - </div> 88 - {/if} 89 - {/each} 90 - <span 91 - class="bg-base-200 dark:bg-base-800 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 inline-flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-semibold ring-2" 92 - > 93 - +{shown.length - 2} 94 - </span> 95 - {:else} 96 - {#each shown as attendee (attendee.did)} 97 - {#if attendee.avatar} 98 - <img 99 - src={attendee.avatar} 100 - alt="" 101 - class="ring-base-50 dark:ring-base-900 h-6 w-6 rounded-full object-cover ring-2" 102 - /> 103 - {:else} 104 - <div 105 - class="bg-base-300 dark:bg-base-700 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium ring-2" 106 - > 107 - {initial(attendee.name)} 108 - </div> 109 - {/if} 110 - {/each} 74 + {#if shown.length > 0} 75 + {#if shown.length >= 4} 76 + {#each shown.slice(0, 2) as attendee (attendee.did)} 77 + {#if attendee.avatar} 78 + <img 79 + src={attendee.avatar} 80 + alt="" 81 + class="ring-base-50 dark:ring-base-900 h-6 w-6 rounded-full object-cover ring-2" 82 + /> 83 + {:else} 84 + <div 85 + class="bg-base-300 dark:bg-base-700 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium ring-2" 86 + > 87 + {initial(attendee.name)} 88 + </div> 89 + {/if} 90 + {/each} 91 + <span 92 + class="bg-base-200 dark:bg-base-800 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 inline-flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-semibold ring-2" 93 + > 94 + +{shown.length - 2} 95 + </span> 96 + {:else} 97 + {#each shown as attendee (attendee.did)} 98 + {#if attendee.avatar} 99 + <img 100 + src={attendee.avatar} 101 + alt="" 102 + class="ring-base-50 dark:ring-base-900 h-6 w-6 rounded-full object-cover ring-2" 103 + /> 104 + {:else} 105 + <div 106 + class="bg-base-300 dark:bg-base-700 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium ring-2" 107 + > 108 + {initial(attendee.name)} 109 + </div> 110 + {/if} 111 + {/each} 112 + {/if} 113 + {:else if cluster.host} 114 + {#if cluster.host.avatar} 115 + <img 116 + src={cluster.host.avatar} 117 + alt="" 118 + class="ring-base-50 dark:ring-base-900 h-6 w-6 rounded-full object-cover ring-2" 119 + /> 120 + {:else} 121 + <div 122 + class="bg-base-300 dark:bg-base-700 text-base-700 dark:text-base-200 ring-base-50 dark:ring-base-900 flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium ring-2" 123 + > 124 + {initial(cluster.host.displayName || cluster.host.handle || cluster.host.did)} 125 + </div> 126 + {/if} 111 127 {/if} 112 128 </div> 113 129 <p class="text-base-600 dark:text-base-400 min-w-0 truncate text-sm"> 114 - <span class="text-base-800 dark:text-base-200">{namesSentence(shown)}</span> 115 - {verb(shown.length, status)} 130 + {#if shown.length > 0} 131 + <span class="text-base-800 dark:text-base-200">{namesSentence(shown)}</span> 132 + {verb(shown.length, status)} 133 + {:else if cluster.host} 134 + Hosted by 135 + <span class="text-base-800 dark:text-base-200" 136 + >{cluster.host.displayName || cluster.host.handle || cluster.host.did}</span 137 + > 138 + {/if} 116 139 </p> 117 140 </div> 118 141 </a>
+8 -1
src/lib/contrail.config.ts
··· 79 79 // Exposed as rsvp.atmo.getFeed?feed=network&actor=<did>&collection=<nsid>. 80 80 // Powers the home-page "from people you follow" surface. 81 81 network: { 82 - targets: ['event', 'rsvp'] 82 + // Per-target caps so RSVPs (high-volume) can't squeeze events 83 + // (low-volume) out of the cap. Bumped above the default 200 because 84 + // most RSVPs in feed_items refer to past events, and we want enough 85 + // breathing room to find recent ones after the JS-side filter. 86 + targets: [ 87 + { collection: 'event', maxItems: 200 }, 88 + { collection: 'rsvp', maxItems: 1000 } 89 + ] 83 90 } 84 91 } 85 92 };
+12 -5
src/lib/contrail.ts
··· 69 69 export type ActivityCluster = { 70 70 event: FlatEventRecord; 71 71 attendees: AttendeeInfo[]; 72 - /** ms since epoch of the most recent RSVP in this cluster, taken from the 73 - * RSVP record's `createdAt` (when the user actually RSVP'd) — not from 74 - * contrail's `time_us` (which reflects index time and all bunches up after 75 - * a backfill). */ 72 + /** Set when the cluster's source was the event itself being authored by 73 + * someone in the viewer's follow set (vs. only an RSVP from a follow). 74 + * Used by the UI to render "Hosted by X" when `attendees` is empty. */ 75 + host?: HostProfile; 76 + /** ms since epoch of the most recent activity in this cluster — the latest 77 + * RSVP `createdAt`, or the event's own `createdAt` for event-only clusters. 78 + * Drives display order. */ 76 79 latestCreatedAtMs: number; 77 80 }; 78 81 ··· 83 86 startsAtMax?: string; 84 87 endsAtMin?: string; 85 88 endsAtMax?: string; 89 + rsvpsCountMin?: number; 86 90 rsvpsGoingCountMin?: number; 87 91 hydrateRsvps?: number; 88 92 profiles?: boolean; ··· 163 167 return `/p/${who}/e/${event.rkey}`; 164 168 } 165 169 166 - export function getHostProfile(did: string, profiles?: EventProfiles): HostProfile | null { 170 + export function getHostProfile( 171 + did: string, 172 + profiles?: AttendeeProfileEntry[] 173 + ): HostProfile | null { 167 174 const profile = profiles?.find((entry) => entry.did === did); 168 175 if (!profile) return null; 169 176
+112 -36
src/routes/(app)/+page.server.ts
··· 2 2 buildAttendee, 3 3 flattenEventRecord, 4 4 flattenEventRecords, 5 + getHostProfile, 5 6 getServerClient, 6 7 listDiscoverableEventsFromContrail, 7 8 listEventRecordsFromContrail, 8 - type ActivityCluster 9 + type ActivityCluster, 10 + type HostProfile 9 11 } from '$lib/contrail'; 10 12 import { getSpacesClient } from '$lib/spaces/server/client'; 11 13 import { spacesAvailable } from '$lib/spaces/config'; 12 14 import type { PageServerLoad } from './$types'; 13 15 14 16 const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; 15 - const ACTIVITY_FETCH_LIMIT = 100; 17 + const ACTIVITY_FETCH_LIMIT = 200; 16 18 const ACTIVITY_DISPLAY_LIMIT = 10; 19 + /** Activity feed includes RSVPs to events that ended within this window so 20 + * recently-finished events linger briefly (their RSVPs are still meaningful 21 + * social signal). */ 22 + const ACTIVITY_RECENT_EVENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; 17 23 18 24 export const load: PageServerLoad = async ({ locals, platform }) => { 19 25 const publicClient = getServerClient(platform!.env.DB); ··· 77 83 78 84 const globalPromise = listDiscoverableEventsFromContrail(publicClient, { 79 85 startsAtMin: nowIso, 80 - rsvpsGoingCountMin: 2, 86 + rsvpsCountMin: 2, 81 87 hydrateRsvps: 5, 82 88 sort: 'startsAt', 83 89 order: 'asc', ··· 98 104 ? P 99 105 : never; 100 106 101 - function clusterRsvps( 107 + type ActivityEvent = { 108 + did: string; 109 + uri: string; 110 + value?: { startsAt?: string; endsAt?: string | null; createdAt?: string }; 111 + }; 112 + 113 + function addRsvpsToClusters( 102 114 records: ActivityRsvp[], 103 - profiles: ActivityProfile[] 104 - ): ActivityCluster[] { 115 + profiles: ActivityProfile[], 116 + clusters: Map<string, ActivityCluster> 117 + ) { 105 118 const nowMs = Date.now(); 106 - const clusters = new Map<string, ActivityCluster>(); 107 119 for (const r of records) { 108 120 const status = r.value?.status; 109 121 const isGoing = status?.endsWith('#going'); ··· 113 125 const flatEvent = flattenEventRecord(r.event); 114 126 if (!flatEvent) continue; 115 127 const eventEndMs = new Date(flatEvent.endsAt || flatEvent.startsAt).getTime(); 116 - if (eventEndMs < nowMs) continue; 128 + if (eventEndMs < nowMs - ACTIVITY_RECENT_EVENT_WINDOW_MS) continue; 117 129 118 130 const attendee = buildAttendee(r.did, isGoing ? 'going' : 'interested', profiles); 119 131 const createdAtMs = r.value?.createdAt ? new Date(r.value.createdAt).getTime() : 0; ··· 125 137 cluster.attendees.push(attendee); 126 138 if (createdAtMs > cluster.latestCreatedAtMs) cluster.latestCreatedAtMs = createdAtMs; 127 139 } 128 - return Array.from(clusters.values()) 140 + } 141 + 142 + /** Add events authored by people the viewer follows to existing rsvp-driven 143 + * clusters (or create new clusters for events with no follow-RSVPs). The 144 + * cluster's `host` field flags author-is-a-follow so the UI can render 145 + * "Hosted by X" when there are no attendees. */ 146 + function addFollowEventsToClusters( 147 + records: ActivityEvent[], 148 + profiles: ActivityProfile[], 149 + clusters: Map<string, ActivityCluster> 150 + ) { 151 + const nowMs = Date.now(); 152 + for (const e of records) { 153 + const flatEvent = flattenEventRecord(e as Parameters<typeof flattenEventRecord>[0]); 154 + if (!flatEvent) continue; 155 + const eventEndMs = new Date(flatEvent.endsAt || flatEvent.startsAt).getTime(); 156 + if (eventEndMs < nowMs - ACTIVITY_RECENT_EVENT_WINDOW_MS) continue; 157 + 158 + const host = getHostProfile(flatEvent.did, profiles) ?? undefined; 159 + const eventCreatedAtMs = e.value?.createdAt ? new Date(e.value.createdAt).getTime() : 0; 160 + let cluster = clusters.get(flatEvent.uri); 161 + if (!cluster) { 162 + cluster = { 163 + event: flatEvent, 164 + attendees: [], 165 + host, 166 + latestCreatedAtMs: eventCreatedAtMs 167 + }; 168 + clusters.set(flatEvent.uri, cluster); 169 + } else { 170 + // Existing rsvp-driven cluster gets host attribution layered on. 171 + cluster.host = cluster.host ?? host; 172 + } 173 + } 174 + } 175 + 176 + function finalizeClusters(map: Map<string, ActivityCluster>): ActivityCluster[] { 177 + return Array.from(map.values()) 129 178 .sort((a, b) => b.latestCreatedAtMs - a.latestCreatedAtMs) 130 179 .slice(0, ACTIVITY_DISPLAY_LIMIT); 131 180 } ··· 141 190 } 142 191 }); 143 192 if (!response.ok) return []; 144 - return clusterRsvps( 193 + const clusters = new Map<string, ActivityCluster>(); 194 + addRsvpsToClusters( 145 195 (response.data.records ?? []) as ActivityRsvp[], 146 - (response.data.profiles ?? []) as ActivityProfile[] 196 + (response.data.profiles ?? []) as ActivityProfile[], 197 + clusters 147 198 ); 199 + return finalizeClusters(clusters); 148 200 } 149 201 150 202 const recentActivityPromise = (async (): Promise<{ 151 203 activity: ActivityCluster[]; 152 204 isPersonalized: boolean; 153 205 }> => { 154 - // Logged-in: try the personalized "from people you follow" feed first. 155 - // If empty (cold start, follows haven't RSVP'd to upcoming events), 156 - // fall back to the global recent-RSVP feed so the section isn't dead. 157 - // Anon: skip straight to global. 206 + // Logged-in: pull both RSVPs and events authored by people the viewer 207 + // follows, merge into one cluster set keyed by event URI. RSVP clusters 208 + // + event clusters that point at the same event collapse — the host info 209 + // just gets layered on. If the merged set is empty (cold start), fall 210 + // back to the global recent-RSVP feed. 158 211 if (locals.did) { 159 - const response = await publicClient.get('rsvp.atmo.getFeed', { 160 - params: { 161 - feed: 'network', 162 - actor: locals.did, 163 - // NOTE: contrail's getFeed runtime checks `collection` against 164 - // the SHORT names from `feedConfig.targets` (e.g. 'rsvp'), but 165 - // the regenerated lex schema documents the full NSID as the 166 - // valid enum. Pass the short name; full NSID returns 400. 167 - collection: 'rsvp', 168 - hydrateEvent: true, 169 - profiles: true, 170 - sort: 'createdAt', 171 - order: 'desc', 172 - limit: ACTIVITY_FETCH_LIMIT 173 - } 174 - }); 175 - if (response.ok) { 176 - const personalized = clusterRsvps( 177 - (response.data.records ?? []) as ActivityRsvp[], 178 - (response.data.profiles ?? []) as ActivityProfile[] 212 + const [rsvpResp, eventResp] = await Promise.all([ 213 + publicClient.get('rsvp.atmo.getFeed', { 214 + params: { 215 + feed: 'network', 216 + actor: locals.did, 217 + collection: 'rsvp', 218 + hydrateEvent: true, 219 + profiles: true, 220 + sort: 'createdAt', 221 + order: 'desc', 222 + limit: ACTIVITY_FETCH_LIMIT 223 + } 224 + }), 225 + publicClient.get('rsvp.atmo.getFeed', { 226 + params: { 227 + feed: 'network', 228 + actor: locals.did, 229 + collection: 'event', 230 + profiles: true, 231 + sort: 'startsAt', 232 + order: 'asc', 233 + // Only events still upcoming or recent. Server-side filter is 234 + // possible here because startsAt IS in the event collection's 235 + // queryable (unlike rsvp records, which carry no event date). 236 + startsAtMin: new Date(Date.now() - ACTIVITY_RECENT_EVENT_WINDOW_MS).toISOString(), 237 + limit: ACTIVITY_FETCH_LIMIT 238 + } 239 + }) 240 + ]); 241 + 242 + const clusters = new Map<string, ActivityCluster>(); 243 + if (rsvpResp.ok) { 244 + addRsvpsToClusters( 245 + (rsvpResp.data.records ?? []) as ActivityRsvp[], 246 + (rsvpResp.data.profiles ?? []) as ActivityProfile[], 247 + clusters 179 248 ); 180 - if (personalized.length > 0) return { activity: personalized, isPersonalized: true }; 249 + } 250 + if (eventResp.ok) { 251 + addFollowEventsToClusters( 252 + (eventResp.data.records ?? []) as ActivityEvent[], 253 + (eventResp.data.profiles ?? []) as ActivityProfile[], 254 + clusters 255 + ); 181 256 } 257 + if (clusters.size > 0) return { activity: finalizeClusters(clusters), isPersonalized: true }; 182 258 } 183 259 return { activity: await fetchGlobalActivity(), isPersonalized: false }; 184 260 })();
+2 -2
src/routes/(app)/+page.svelte
··· 78 78 {/if} 79 79 80 80 <div class="mb-8 flex items-baseline justify-between"> 81 - <h2 class="text-base-900 dark:text-base-50 text-xl font-bold">Upcoming Events</h2> 81 + <h2 class="text-base-900 dark:text-base-50 text-xl font-bold">Upcoming Popular Events</h2> 82 82 <a 83 83 href="/events" 84 84 class="text-sm font-medium text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300 transition-colors" ··· 104 104 </h2> 105 105 {#if data.recentActivityIsPersonalized} 106 106 <p class="text-base-500 dark:text-base-400 mb-4 text-sm"> 107 - Events your Bluesky follows are RSVPing to. 107 + Events your Bluesky follows are hosting or going to. 108 108 </p> 109 109 {/if} 110 110 <RecentActivity activities={data.recentActivity} />
+1 -1
src/routes/(app)/events/+page.server.ts
··· 20 20 order: 'asc', 21 21 limit: PAGE_SIZE, 22 22 cursor, 23 - ...(isPopular ? { rsvpsGoingCountMin: 2 } : {}) 23 + ...(isPopular ? { rsvpsCountMin: 2 } : {}) 24 24 }); 25 25 26 26 if (!response) return { events: [], handles: {}, cursor: null };
+1 -1
src/routes/(app)/events/+page.svelte
··· 14 14 sort: 'startsAt', 15 15 order: 'asc', 16 16 limit: '20', 17 - ...(filter === 'popular' ? { rsvpsGoingCountMin: '2' } : {}) 17 + ...(filter === 'popular' ? { rsvpsCountMin: '2' } : {}) 18 18 }); 19 19 20 20 function setFilter(val: string) {
+3 -2
wrangler.jsonc
··· 17 17 "d1_databases": [ 18 18 { 19 19 "binding": "DB", 20 - "database_name": "atmo-events-v2", 21 - "database_id": "7ac1d7f2-afc2-4ac6-8fce-d192c02f63a5" 20 + "database_name": "atmo-events-v4", 21 + "database_id": "3ba9a931-aae7-425b-a68e-d18d9a8ab31b", 22 + "remote": true 22 23 } 23 24 ], 24 25 "triggers": {