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:
@@ -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%' }}>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user