diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index d9d3a77..8eabba1 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -244,12 +244,86 @@ function useNgData(): FestipodDataContextValue { const eventQuery = useShapeQuery(FpEventShapeType, 'public'); const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected'); const partQuery = useShapeQuery(FpParticipationShapeType, 'protected'); - const events = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]); const users = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]); - const participations = React.useMemo( + // The RAW reactive sets straight from `watchShape` (before the optimistic + // overlay). The exposed `events`/`participations` merge these with the pending + // overlay below — see the "OPTIMISTIC OVERLAY" block. + const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]); + const reactiveParticipations = React.useMemo( () => adaptParticipations(partQuery.data), [partQuery.data], ); + + // --- OPTIMISTIC OVERLAY (standard useQuery-mutation pattern; NO polling) ------ + // `watchShape` only surfaces a doc AFTER the broker push (a real latency), so a + // mutation's effect is invisible in the reactive set for a beat. To keep the + // screens' local-first feel, each mutation reflects its effect IMMEDIATELY in a + // pure app-side React overlay laid OVER the SDK surface (never touching the SDK + // internals — see rule_app-uses-sdk-surface-only), then RECONCILED by the push: + // • `pendingAddEvents` / `pendingAddParticipations` — entities created + // optimistically, keyed by their `@id` (= the doc NURI `writeEntity` returns, + // the SAME id `watchShape` surfaces as `s.subject` → they match exactly). + // • `pendingRemoveIds` — participation ids removed optimistically (leave). + // The exposed set = merge(reactive, pendingAdds) minus pendingRemoves, DEDUPED by + // id. Reconciliation (effects below) auto-cleans the overlay the moment the + // reactive read catches up: an add whose id now appears in the reactive set is + // dropped from pendingAdds; a remove whose id is no longer in the reactive set is + // dropped from pendingRemoves. No re-query, no interval — the overlay only reacts + // to `watchShape`'s own pushes. + const [pendingAddEvents, setPendingAddEvents] = useState([]); + const [pendingAddParticipations, setPendingAddParticipations] = useState([]); + const [pendingRemoveIds, setPendingRemoveIds] = useState>(() => new Set()); + + const events = React.useMemo(() => { + if (pendingAddEvents.length === 0) return reactiveEvents; + const seen = new Set(reactiveEvents.map(e => e.id)); + const extra = pendingAddEvents.filter(e => !seen.has(e.id)); + return extra.length ? [...reactiveEvents, ...extra] : reactiveEvents; + }, [reactiveEvents, pendingAddEvents]); + + const participations = React.useMemo(() => { + let base = reactiveParticipations; + if (pendingAddParticipations.length) { + const seen = new Set(base.map(p => p.id)); + const extra = pendingAddParticipations.filter(p => !seen.has(p.id)); + if (extra.length) base = [...base, ...extra]; + } + if (pendingRemoveIds.size) base = base.filter(p => !pendingRemoveIds.has(p.id)); + return base; + }, [reactiveParticipations, pendingAddParticipations, pendingRemoveIds]); + + // RECONCILIATION — drop each optimistic add once the reactive read carries its id + // (the push arrived), and each optimistic remove once the reactive read no longer + // carries its id (the SPARQL delete propagated). Guarded to no-op when there is + // nothing to reconcile, so a stable reactive set does not churn state. + useEffect(() => { + if (pendingAddEvents.length === 0) return; + const live = new Set(reactiveEvents.map(e => e.id)); + setPendingAddEvents(prev => { + const next = prev.filter(e => !live.has(e.id)); + return next.length === prev.length ? prev : next; + }); + }, [reactiveEvents, pendingAddEvents]); + + useEffect(() => { + if (pendingAddParticipations.length === 0) return; + const live = new Set(reactiveParticipations.map(p => p.id)); + setPendingAddParticipations(prev => { + const next = prev.filter(p => !live.has(p.id)); + return next.length === prev.length ? prev : next; + }); + }, [reactiveParticipations, pendingAddParticipations]); + + useEffect(() => { + if (pendingRemoveIds.size === 0) return; + const live = new Set(reactiveParticipations.map(p => p.id)); + setPendingRemoveIds(prev => { + let changed = false; + const next = new Set(prev); + for (const id of prev) if (!live.has(id)) { next.delete(id); changed = true; } + return changed ? next : prev; + }); + }, [reactiveParticipations, pendingRemoveIds]); // The read is "settled" once every scope has reached its sync barrier // (`isSuccess`). A synced-but-empty scope reads `isSuccess` with `data: []` — the // distinction the auto-seed relies on to tell "still syncing" from "truly empty". @@ -281,6 +355,11 @@ function useNgData(): FestipodDataContextValue { // its own (scope re-resolution keyed on `getCurrentUser()`). setOwnedEventIds([]); joinUidsRef.current.clear(); + // Drop the optimistic overlay too: it belongs to the OLD identity's session + // and must not bleed into the new identity's reads (isolation). + setPendingAddEvents([]); + setPendingAddParticipations([]); + setPendingRemoveIds(new Set()); resetCaps(); resetRegistryCache(); }, [username]); @@ -557,6 +636,13 @@ function useNgData(): FestipodDataContextValue { // OPTION B: this event's doc is MINE (I just created it), so track it as owned // → the owner-materializer subscribes to its inbox and maintains its count. setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph])); + // OPTIMISTIC OVERLAY: surface the created event IMMEDIATELY (its id = eventId = + // the doc NURI, the SAME id `watchShape('public')` will surface as `s.subject`, + // so the reconciliation effect drops this add once the push arrives). Without + // it, `getEvent(eventId)` right after create returns undefined until the push. + // participantCount starts at 0 (creator does not auto-participate). + const optimisticEvent: FpEventData = { ...event, id: eventId, participantCount: event.participantCount || 0 }; + setPendingAddEvents(prev => (prev.some(e => e.id === eventId) ? prev : [...prev, optimisticEvent])); // The creator does NOT auto-participate (no host notion — settled product // decision): NO participation is written on create. The creator sees "J'y // serai" and may join/leave their own event like anyone else. @@ -635,6 +721,14 @@ function useNgData(): FestipodDataContextValue { await writeEntity(partGraph, ENTITY_TYPE.participation, { event: iri(eventId), user: iri(uid), isConfirmed: bool(true), }); + // OPTIMISTIC OVERLAY: surface the participation IMMEDIATELY so the joiner shows + // up in the list and `isParticipating` is true before the broker push. Its id = + // partGraph = the doc NURI, the SAME id `watchShape('protected')` will surface + // as `s.subject` (adaptParticipations sets `id: s.subject`) → the reconciliation + // effect drops this add once the push arrives. If a stale pendingRemove targeted + // this exact id (re-join same doc — never happens, ids are fresh), clear it too. + const optimisticPart: FpParticipationData = { id: partGraph, eventId, userId: uid, isConfirmed: true }; + setPendingAddParticipations(prev => (prev.some(p => p.id === partGraph) ? prev : [...prev, optimisticPart])); // OPTION B (brief §B): the joiner does NOT write `participantCount` on the // EVENT doc — that doc belongs to the OWNER, and a non-owner write there is an // isolation violation (NextGraph write is membership-bound; there is no append @@ -706,6 +800,23 @@ function useNgData(): FestipodDataContextValue { console.error(msg); throw new Error(msg); } + // OPTIMISTIC OVERLAY: the delete is broker-confirmed (remaining === 0) but the + // reactive `watchShape('protected')` set only drops the participation on the + // NEXT push (a beat later). Mark its id removed NOW so the leaver disappears + // from the list immediately; the reconciliation effect clears this pendingRemove + // once the reactive set no longer carries the id. Also drop it from + // pendingAddParticipations in case it was still only-optimistic (join+leave in + // the same session before the join's push landed). + setPendingRemoveIds(prev => { + if (prev.has(part.id)) return prev; + const next = new Set(prev); + next.add(part.id); + return next; + }); + setPendingAddParticipations(prev => { + const next = prev.filter(p => p.id !== part.id); + return next.length === prev.length ? prev : next; + }); // OPTION B, symmetric (brief §B "Désinscription"): the leaver does NOT write // `participantCount` on the EVENT doc (owner-owned — same isolation violation // as the join). Instead it DEPOSITS a `leave-participant` marker into the