diff --git a/src/modules/event/screens/EventsScreen.tsx b/src/modules/event/screens/EventsScreen.tsx index 4cccfbf..d32cc18 100644 --- a/src/modules/event/screens/EventsScreen.tsx +++ b/src/modules/event/screens/EventsScreen.tsx @@ -2,6 +2,7 @@ import { ArrowLeft } from 'lucide-react'; import { Header, Input, Card, Badge, BottomNav, AvatarStack, getEventPhotoUrl, Text } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; import { eventPath, useNavigate } from '../../../app/router'; +import { useDiscoverEvents } from '../useDiscoverEvents'; const PEOPLE = [ { name: 'Marie Leroy', color: '#E8590C' }, @@ -50,7 +51,8 @@ function EventCard({ export function EventsScreen() { const navigate = useNavigate(); - const { events } = useFestipodData(); + const { events: ownEvents } = useFestipodData(); + const events = useDiscoverEvents(ownEvents); return (
diff --git a/src/modules/event/useDiscoverEvents.ts b/src/modules/event/useDiscoverEvents.ts new file mode 100644 index 0000000..a5f784a --- /dev/null +++ b/src/modules/event/useDiscoverEvents.ts @@ -0,0 +1,31 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { FpEventData } from '../../shared/data/types'; +import { fetchCatalogEvents, unionEvents } from '../../shared/data/catalog'; +import { useCurrentSession } from '../../shared/utils/currentSession'; +import { getBackendUrl } from '../../shared/utils/runtimeConfig'; + +/** + * The Découvrir list: the user's own events plus the catalog's, de-duplicated. + * Connected mode only — without an ActivityPods session (demo) or a configured + * backend the catalog is never called and the list is `own` unchanged. + */ +export function useDiscoverEvents(own: FpEventData[]): FpEventData[] { + const session = useCurrentSession(); + const backendUrl = getBackendUrl(); + const connected = !!session && !!backendUrl; + const [catalog, setCatalog] = useState([]); + + useEffect(() => { + if (!connected || !backendUrl) { + setCatalog([]); + return; + } + const controller = new AbortController(); + void fetchCatalogEvents(backendUrl, controller.signal).then(events => { + if (!controller.signal.aborted) setCatalog(events); + }); + return () => controller.abort(); + }, [connected, backendUrl]); + + return useMemo(() => unionEvents(own, catalog), [own, catalog]); +} diff --git a/src/shared/data/catalog.test.ts b/src/shared/data/catalog.test.ts new file mode 100644 index 0000000..d848cc4 --- /dev/null +++ b/src/shared/data/catalog.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import { catalogEventsFromBody, fetchCatalogEvents, unionEvents } from './catalog'; +import type { FpEventData } from './types'; + +const ev = (id: string, title = id): FpEventData => ({ + id, title, description: '', date: '', location: '', participantCount: 0, +}); + +const body = { + type: 'Collection', + totalItems: 3, + items: [ + { id: 'http://pod/x/e1', type: 'Event', name: 'Concert', location: { name: 'Paris' }, startTime: '2026-10-20T19:00:00.000Z' }, + { id: 'http://pod/x/m1', type: 'Event', name: 'Rdv', 'as:context': 'http://pod/x/e1' }, + { id: 'http://pod/y/e2', type: 'Event', name: 'Expo' }, + ], +}; + +describe('catalogEventsFromBody', () => { + test('keeps events, drops meeting points, maps id/title/location/date', () => { + const events = catalogEventsFromBody(body); + expect(events.map(e => e.id)).toEqual(['http://pod/x/e1', 'http://pod/y/e2']); + expect(events[0]?.title).toBe('Concert'); + expect(events[0]?.location).toBe('Paris'); + expect(events[0]?.startDate).toBe('2026-10-20'); + }); + test('malformed bodies map to nothing', () => { + expect(catalogEventsFromBody(null)).toEqual([]); + expect(catalogEventsFromBody({ items: 'nope' })).toEqual([]); + expect(catalogEventsFromBody({ items: [1, null, 'x'] })).toEqual([]); + }); +}); + +describe('unionEvents', () => { + test('de-duplicates by URI, own first', () => { + const out = unionEvents([ev('a', 'own-a')], [ev('a', 'cat-a'), ev('b')]); + expect(out.map(e => `${e.id}:${e.title}`)).toEqual(['a:own-a', 'b:b']); + }); + test('empty catalog leaves own untouched', () => { + expect(unionEvents([ev('a')], []).map(e => e.id)).toEqual(['a']); + }); +}); + +describe('fetchCatalogEvents fails open', () => { + const original = globalThis.fetch; + afterEach(() => { globalThis.fetch = original; }); + const stub = (impl: () => Promise) => { globalThis.fetch = (async () => impl()) as unknown as typeof fetch; }; + + test('success', async () => { + stub(async () => new Response(JSON.stringify(body))); + expect((await fetchCatalogEvents('http://b/')).length).toBe(2); + }); + test('network error', async () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + stub(async () => { throw new Error('down'); }); + expect(await fetchCatalogEvents('http://b')).toEqual([]); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + test('non-2xx', async () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + stub(async () => new Response('x', { status: 500 })); + expect(await fetchCatalogEvents('http://b')).toEqual([]); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + test('malformed body', async () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + stub(async () => new Response('{"items":"x"}')); + expect(await fetchCatalogEvents('http://b')).toEqual([]); + stub(async () => new Response('not json')); + expect(await fetchCatalogEvents('http://b')).toEqual([]); + warn.mockRestore(); + }); +}); diff --git a/src/shared/data/catalog.ts b/src/shared/data/catalog.ts new file mode 100644 index 0000000..69f9dd5 --- /dev/null +++ b/src/shared/data/catalog.ts @@ -0,0 +1,74 @@ +/** + * Catalog read — cross-user event discovery, living BESIDE Refine. + * + * The Festipod backend keeps a public index of every event and meeting point + * created through the app (`GET {backend}/catalog`, an unauthenticated + * ActivityStreams collection). The Refine data provider is CRUD over the + * signed-in user's own pod and has no `custom` method, so this read is a plain + * fetch, like the actor reads in `actor.ts`. + * + * FAILS OPEN. Discovery is an enrichment of the list, never a precondition: a + * network error, a non-2xx answer or a malformed body all read back as "no + * catalog events" (with one `console.warn`), so the caller still renders the + * user's own events. + * + * MEETING POINTS ARE IN THE CATALOG TOO (both are `as:Event`; a meeting point + * carries `as:context` → its anchor event). They are filtered out here: the + * Découvrir list shows events. The mapping is the app's existing event reader. + */ + +import type { FpEventData } from './types'; +import { asRecord, linkId } from './jsonRecord'; +import { eventFromRecord } from '../refine/providers/activitypods'; + +/** The records of a catalog body that are events (not meeting points), mapped to the app's event type. */ +export function catalogEventsFromBody(body: unknown): FpEventData[] { + const items = asRecord(body)?.['items']; + if (!Array.isArray(items)) return []; + const events: FpEventData[] = []; + for (const item of items) { + const record = asRecord(item); + if (!record) continue; + if (linkId(record['as:context']) !== undefined) continue; + const event = eventFromRecord(record); + if (event.id) events.push(event); + } + return events; +} + +/** + * Fetch the catalog and return its events. Never throws: every failure is a + * warned empty list. + */ +export async function fetchCatalogEvents(backendUrl: string, signal?: AbortSignal): Promise { + const url = `${backendUrl.replace(/\/+$/, '')}/catalog`; + try { + const response = await fetch(url, { headers: { Accept: 'application/json' }, signal }); + if (!response.ok) { + console.warn(`[catalog] ${url} answered HTTP ${response.status} — listing own events only`); + return []; + } + const body: unknown = await response.json(); + if (asRecord(body)?.['items'] === undefined || !Array.isArray(asRecord(body)?.['items'])) { + console.warn(`[catalog] ${url} returned a malformed body — listing own events only`); + return []; + } + return catalogEventsFromBody(body); + } catch (error) { + if (signal?.aborted) return []; + console.warn(`[catalog] ${url} unreachable — listing own events only`, error); + return []; + } +} + +/** Own events first, then catalog events not already listed, de-duplicated by event URI. */ +export function unionEvents(own: FpEventData[], catalog: FpEventData[]): FpEventData[] { + const seen = new Set(); + const out: FpEventData[] = []; + for (const event of [...own, ...catalog]) { + if (seen.has(event.id)) continue; + seen.add(event.id); + out.push(event); + } + return out; +} diff --git a/src/shared/refine/providers/activitypods.ts b/src/shared/refine/providers/activitypods.ts index 218b711..cc69bef 100644 --- a/src/shared/refine/providers/activitypods.ts +++ b/src/shared/refine/providers/activitypods.ts @@ -219,7 +219,7 @@ function eventValues(data: Partial): Record { return out; } -function eventFromRecord(record: unknown): FpEventData { +export function eventFromRecord(record: unknown): FpEventData { const r = asRecord(record); const start = splitDateTime(r?.['startTime']); const end = splitDateTime(r?.['endTime']);