List other users' events in Découvrir: own pod events joined with the backend catalog

Découvrir (/events) now shows the union of the signed-in user's own-pod events and the backend catalog's events, de-duplicated by URI, own first. The catalog read lives beside Refine (CRUD-only per the contract) as an unauthenticated, fail-open GET on the backend's /catalog, in connected mode only; meeting points carried by the catalog (as:context) are filtered out. Opening a foreign event is not wired yet.
This commit is contained in:
Sylvain Duchesne
2026-10-12 15:22:30 +02:00
parent 706ee132da
commit e21c408dc1
5 changed files with 184 additions and 2 deletions
+3 -1
View File
@@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
+31
View File
@@ -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<FpEventData[]>([]);
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]);
}
+75
View File
@@ -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<Response>) => { 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();
});
});
+74
View File
@@ -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<FpEventData[]> {
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<string>();
const out: FpEventData[] = [];
for (const event of [...own, ...catalog]) {
if (seen.has(event.id)) continue;
seen.add(event.id);
out.push(event);
}
return out;
}
+1 -1
View File
@@ -219,7 +219,7 @@ function eventValues(data: Partial<FpEventData>): Record<string, unknown> {
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']);