diff --git a/src/lib/events.ts b/src/lib/events.ts index f2f3c9c..23a153f 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -33,7 +33,7 @@ interface RawUri { source?: string; } -interface RawEvent { +export interface RawEvent { $type: string; name: string; startsAt: string; @@ -154,7 +154,7 @@ function simplifyAddress(raw: string): string { /** * Parse a raw event record into our clean format. */ -function parseEvent(v: RawEvent, source: string, uri: string): CommunityEvent { +export function parseEvent(v: RawEvent, source: string, uri: string): CommunityEvent { // Parse mode let mode: CommunityEvent['mode'] = 'in_person'; if (v.mode?.includes('#virtual')) mode = 'online'; diff --git a/src/lib/shared-content.ts b/src/lib/shared-content.ts index f57ce79..92f526a 100644 --- a/src/lib/shared-content.ts +++ b/src/lib/shared-content.ts @@ -5,6 +5,7 @@ */ import { getProfile, type AtProfile } from './atproto'; +import { parseEvent, type CommunityEvent, type RawEvent } from './events'; const COLLECTION = 'community.opensocial.sharedContent'; @@ -28,6 +29,11 @@ interface RawSharedContent { sharedBy: string; documentUri: string; documentCid: string; + // Event-specific fields (present when type='event') + startsAt?: string; + endsAt?: string; + location?: string; + mode?: string; } interface RawDocument { @@ -141,7 +147,7 @@ async function fetchSharedFromAccount(handleOrDid: string): Promise) { - if (r.value.type === 'document' && r.value.documentUri) { + if (r.value.documentUri) { records.push(r.value); } } @@ -174,9 +180,10 @@ export async function fetchSharedContent( } } - // Deduplicate by documentUri + // Deduplicate by documentUri, filter to documents only const seen = new Set(); const unique = allRecords.filter(r => { + if (r.type !== 'document') return false; if (seen.has(r.documentUri)) return false; seen.add(r.documentUri); return true; @@ -251,3 +258,84 @@ export async function fetchSharedContent( return db - da; }); } + +/** + * Fetch shared events from multiple accounts. + * Resolves the full event record from each documentUri, then parses it + * into CommunityEvent objects using the same logic as direct event fetching. + */ +export async function fetchSharedEvents( + accounts: string[] +): Promise { + const results = await Promise.allSettled( + accounts.map(a => fetchSharedFromAccount(a)) + ); + + const allRecords: Array = []; + for (let i = 0; i < results.length; i++) { + if (results[i].status === 'fulfilled') { + const recs = (results[i] as PromiseFulfilledResult).value; + allRecords.push(...recs.map(r => ({ ...r, _community: accounts[i] }))); + } else { + console.warn(`Failed to fetch shared events from ${accounts[i]}:`, + (results[i] as PromiseRejectedResult).reason); + } + } + + // Deduplicate by documentUri, filter to events only + const seen = new Set(); + const unique = allRecords.filter(r => { + if (r.type !== 'event') return false; + if (seen.has(r.documentUri)) return false; + seen.add(r.documentUri); + return true; + }); + + const events: CommunityEvent[] = []; + const BATCH_SIZE = 10; + + for (let i = 0; i < unique.length; i += BATCH_SIZE) { + const batch = unique.slice(i, i + BATCH_SIZE); + const resolved = await Promise.allSettled( + batch.map(async (record) => { + // Fetch the full event record to get uris, description, etc. + const fullEvent = await fetchRecord(record.documentUri) as RawEvent | null; + + if (fullEvent) { + return parseEvent(fullEvent, record._community, record.documentUri); + } + + // Fallback: build a CommunityEvent from the shared content metadata + let mode: CommunityEvent['mode'] = 'in_person'; + if (record.mode === 'virtual') mode = 'online'; + else if (record.mode === 'hybrid') mode = 'hybrid'; + + // Build a fallback href from the documentUri + let href = ''; + const match = record.documentUri.match(/at:\/\/([^/]+)\/[^/]+\/(.+)/); + if (match) { + href = `https://smokesignal.events/${match[1]}/${match[2]}`; + } + + return { + name: record.title, + date: record.startsAt ?? record.sharedAt, + endDate: record.endsAt, + location: record.location, + mode, + description: undefined, + href, + source: record._community, + } satisfies CommunityEvent; + }) + ); + + for (const result of resolved) { + if (result.status === 'fulfilled' && result.value) { + events.push(result.value); + } + } + } + + return events; +} diff --git a/src/pages/events.astro b/src/pages/events.astro index b1acff4..d4c50c3 100644 --- a/src/pages/events.astro +++ b/src/pages/events.astro @@ -4,6 +4,7 @@ import Header from '../components/Header.astro'; import Footer from '../components/Footer.astro'; import EventCard from '../components/EventCard.astro'; import { fetchEvents } from '../lib/events'; +import { fetchSharedEvents } from '../lib/shared-content'; import { preflight } from '../lib/preflight'; import yaml from 'js-yaml'; import communitiesRaw from '../data/communities.yml?raw'; @@ -27,7 +28,19 @@ await preflight(); // Fetch events from ATProto (community.lexicon.calendar.event) // Individual community failures are tolerated; only total service outage fails the build. -const upcomingEvents = await fetchEvents(eventAccounts); +const [directEvents, sharedEvents] = await Promise.all([ + fetchEvents(eventAccounts), + fetchSharedEvents(eventAccounts), +]); + +// Merge and deduplicate by name + start time +const seen = new Set(); +const upcomingEvents = [...directEvents, ...sharedEvents].filter(e => { + const key = `${e.name.toLowerCase().trim()}|${e.date}`; + if (seen.has(key)) return false; + seen.add(key); + return true; +}).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); --- diff --git a/src/pages/index.astro b/src/pages/index.astro index 9c5b13e..812274b 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -11,7 +11,7 @@ import communitiesRaw from '../data/communities.yml?raw'; import { getProfile, excerpt } from '../lib/atproto'; import { fetchBlogPosts } from '../lib/blog'; import { fetchEvents } from '../lib/events'; -import { fetchSharedContent } from '../lib/shared-content'; +import { fetchSharedContent, fetchSharedEvents } from '../lib/shared-content'; import { preflight } from '../lib/preflight'; interface Community { @@ -108,7 +108,19 @@ const eventAccounts = [ // Fetch events from ATProto (community.lexicon.calendar.event) // Individual community failures are tolerated; only total failure crashes the build. -const liveEvents = await fetchEvents(eventAccounts); +const [directEvents, sharedEventsList] = await Promise.all([ + fetchEvents(eventAccounts), + fetchSharedEvents(eventAccounts), +]); + +// Merge and deduplicate by name + start time +const seenEvents = new Set(); +const liveEvents = [...directEvents, ...sharedEventsList].filter(e => { + const key = `${e.name.toLowerCase().trim()}|${e.date}`; + if (seenEvents.has(key)) return false; + seenEvents.add(key); + return true; +}).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); const events = liveEvents.slice(0, 4); // Community highlights (static)