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']);