Implement direct sign-up to an event with registration derived from meeting point memberships

This commit is contained in:
Sylvain Duchesne
2026-11-06 23:16:29 +01:00
parent af0cba6920
commit 5540d361c7
13 changed files with 385 additions and 147 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ Tous les utilisateurs sont authentifiés — il n'y a pas d'accès anonyme à l'
### Concepts métier
- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. C'est ce à quoi on s'inscrit (on ne s'inscrit pas à un événement). Sans points de rencontre, un événement Festipod n'a pas d'intérêt.
- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. On s'inscrit à un point de rencontre ou directement à un événement ; être inscrit à un de ses points de rencontre inscrit à l'événement. Sans points de rencontre, un événement Festipod n'a pas d'intérêt.
- **Événement** — l'ancrage. Un événement public réel (festival, conférence, salon, exposition…) référencé dans Festipod pour servir de support à des points de rencontre. C'est simplement un *prétexte* (titre, dates, lieu, thèmes) ; le déclarant n'est pas l'organisateur officiel de l'événement, juste celui qui l'a inscrit dans Festipod.
- **Communauté d'intérêt** — un groupement thématique d'utilisateurs. Sert principalement à découvrir des événements (via abonnement) et à délimiter les périmètres de référencement.
- **Liste curated** — une liste d'événements éditorialisée (par un utilisateur ou une communauté), distincte de « les événements que j'ai déclarés » ou « les événements de la communauté ». Permet d'organiser/recommander.
+26 -28
View File
@@ -35,17 +35,16 @@ const firstStringOf = value =>
.find(v => typeof v === 'string' && v.trim().length > 0);
/**
* What a Join/Leave targets: the stored object must be a meeting point — an `as:Event` (checked by
* `verifiedDelivery`, and by `meetingPointOf` on the creator's copy) anchored to an event by
* `as:context` — whose creator is its `dc:creator`, required to own the pod the meeting point lies
* in. Only on the copy the creator's pod serves (`meetingPointOf`) is that creator believed (the pod
* What a Join/Leave targets: the stored object must be an `as:Event` (checked by `verifiedDelivery`,
* and by `meetingPointOf` on the creator's copy) — a meeting point when anchored to an event by
* `as:context`, otherwise an event signed up to directly, which is its own anchor — whose creator is
* its `dc:creator`, required to own the pod it lies in. Only on the copy the creator's pod serves (`meetingPointOf`) is that creator believed (the pod
* stamps the creator, and nobody can store a resource in someone else's pod); on the copy read
* through the participant's pod, it only names where to read that one. Resolves
* `{ ok, eventUri, creatorUri }` or a refusal.
*/
function meetingPointTarget(meetingPoint, meetingPointUri) {
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`);
if (!eventUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single as:context (its event)` };
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`) ?? meetingPointUri;
const creatorUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR);
if (!creatorUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single dc:creator` };
if (!isInActorPod(meetingPointUri, creatorUri)) {
@@ -55,9 +54,9 @@ function meetingPointTarget(meetingPoint, meetingPointUri) {
}
/**
* The notification template for the creator of a meeting point (`pod-notifications.send`, rendered in
* the creator's language): always ANONYMOUS, the meeting point named through `meetingPointName`, the
* link to its event's meeting points (`eventUri`). Every value travels as a template PARAMETER, never
* The notification template for the creator of a target (`pod-notifications.send`, rendered in the
* creator's language): always ANONYMOUS, the target named through `meetingPointName` or `eventName`, the link to the
* event (`eventUri`), or to its meeting points when the target is one. Every value travels as a template PARAMETER, never
* inside the template, so no user text is compiled.
*
* Never names the participant: naming would need a trustworthy signal that the creator is one of the
@@ -66,21 +65,18 @@ function meetingPointTarget(meetingPoint, meetingPointUri) {
* request checked; platform defect reported upstream). Until that list can be trusted, every
* notification is anonymous.
*/
function notificationTemplate(change) {
const title =
change === 'join'
? {
en: 'A participant joined your meeting point "{{{meetingPointName}}}"',
fr: 'Un participant a rejoint votre point de rencontre « {{{meetingPointName}}} »'
}
: {
en: 'A participant left your meeting point "{{{meetingPointName}}}"',
fr: 'Un participant a quitté votre point de rencontre « {{{meetingPointName}}} »'
};
return {
title,
actions: [{ caption: { en: 'View', fr: 'Voir' }, link: '/events/{{encodeUri eventUri}}/meeting-points' }]
function notificationTemplate(change, isMeetingPoint) {
const joined = change === 'join';
const nameParam = isMeetingPoint ? 'meetingPointName' : 'eventName';
const subject = isMeetingPoint
? { en: 'your meeting point', fr: 'votre point de rencontre' }
: { en: 'your event', fr: 'votre événement' };
const title = {
en: `A participant ${joined ? 'joined' : 'left'} ${subject.en} "{{{${nameParam}}}}"`,
fr: `Un participant a ${joined ? 'rejoint' : 'quitté'} ${subject.fr} « {{{${nameParam}}}} »`
};
const link = isMeetingPoint ? '/events/{{encodeUri eventUri}}/meeting-points' : '/events/{{encodeUri eventUri}}';
return { title, actions: [{ caption: { en: 'View', fr: 'Voir' }, link }] };
}
// Participation (decision_2026-10-29_participation-privacy-mechanism; its delivery:
@@ -366,25 +362,27 @@ module.exports = {
await expectItem(ctx, { collectionUri, itemUri: meetingPointUri, actorUri: participantUri, present: joining });
},
/**
* Tells the creator of the meeting point that a participant joined or left it — ALWAYS anonymously.
* Tells the creator of the target (a meeting point, or an event signed up to directly) that a
* participant joined or left it — ALWAYS anonymously.
* The participant is never named: the only membership signal the app can read (the participant's
* `apods:contacts` list) is forgeable, so naming cannot be trusted (platform defect reported
* upstream, see `notificationTemplate`). The notification carries no `activity`: given one,
* `pod-notifications.send` fetches the emitter's profile. A creator acting on their own meeting
* point is not notified. A failure is logged, never thrown: the act stands.
* point or event is not notified. A failure is logged, never thrown: the act stands.
*/
async notifyCreator(ctx, { change, meetingPointUri, meetingPoint, eventUri, creatorUri, participantUri }) {
const isMeetingPoint = eventUri !== meetingPointUri;
try {
if (creatorUri === participantUri) {
this.logger.info(`${participantUri} acted on their own meeting point ${meetingPointUri}: no notification`);
this.logger.info(`${participantUri} acted on their own ${meetingPointUri}: no notification`);
return;
}
this.logger.info(`Notifying ${creatorUri} of a ${change} on ${meetingPointUri}, anonymously`);
await ctx.call('pod-notifications.send', {
template: notificationTemplate(change),
template: notificationTemplate(change, isMeetingPoint),
recipientUri: creatorUri,
context: meetingPointUri,
meetingPointName: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '',
[isMeetingPoint ? 'meetingPointName' : 'eventName']: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '',
eventUri
});
} catch (e) {
@@ -955,6 +955,17 @@ describe('registration — a direct sign-up: a Join naming an event itself', ()
}
});
test('a declarer signing up to, then leaving, their own event is not notified', async () => {
const j = join(PLAIN, T0, ALICE);
const l = leave(PLAIN, T1, ALICE);
const w = plainWorld([j, l]);
await emitJoin(w, j);
await emitLeave(w, l);
expect(attendees(w, PLAIN)).toEqual([]);
expect(ledgerOf(w, PLAIN, ALICE).change).toBe('leave');
expect(w.state.notifications).toEqual([]);
});
test('the same person carries a different URN on another event (R7e)', async () => {
const OTHER = `${ALICE}/data/events/other`;
const a = join(PLAIN, T0, BOB, 1);
+76 -20
View File
@@ -1,6 +1,6 @@
import { useLayoutEffect } from 'react';
import { useLayoutEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
import { Button, Avatar, ConfirmDialog, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
@@ -11,8 +11,11 @@ export function EventDetailScreen() {
const {
getEvent,
isParticipating,
getEventRegistration,
joinMeetingPoint,
leaveMeetingPoint,
joinEvent,
leaveEvent,
getEventParticipants,
getEventMeetingPoints,
getEventOwnership,
@@ -42,15 +45,16 @@ export function EventDetailScreen() {
participantCount: mp.participantCount,
}));
// SIGNING UP IS PER MEETING POINT — the product model (on s'inscrit à un
// point de rencontre, pas à un événement). There is no event-level join on
// this screen anymore: "joined" means a member of at least one of this
// event's meeting points, and it gates the Invite affordance the way the
// old event-level membership did.
// Signing up is per meeting point, or directly to the event: the registration
// to the event is derived by the data layer from both, and it gates the
// Invite affordance.
const joinedMeetingPointIds = new Set(
meetingPointsRaw.filter(mp => isParticipating(mp.id)).map(mp => String(mp.id)),
);
const joined = joinedMeetingPointIds.size > 0;
const registration = eventId ? getEventRegistration(eventId) : undefined;
const registered = registration?.registered ?? false;
const [confirmingLeave, setConfirmingLeave] = useState(false);
const [leaving, setLeaving] = useState(false);
const handleToggleMeetingPoint = (id: string, _title: string, willJoin: boolean) => {
const failed = (message: string) => (err: unknown) => {
@@ -66,6 +70,39 @@ export function EventDetailScreen() {
}
};
const handleJoinEvent = () => {
if (!eventId) return;
void Promise.resolve(joinEvent(eventId)).catch((err: unknown) => {
console.error('[EventDetail] event sign-up failed:', err);
showToast("L'inscription à l'événement n'a pas pu être enregistrée", 'error');
});
};
const withdrawFromEvent = () => {
if (!eventId) return;
setLeaving(true);
Promise.resolve(leaveEvent(eventId))
.catch((err: unknown) => {
console.error('[EventDetail] event withdrawal failed:', err);
showToast("La désinscription de l'événement n'a pas pu être enregistrée", 'error');
})
.finally(() => {
setLeaving(false);
setConfirmingLeave(false);
});
};
// Withdrawing from an event also leaves its meeting points: ask first when
// there are any, otherwise withdraw at once.
const handleLeaveEvent = () => {
if (registration && registration.meetingPointIds.length > 0) setConfirmingLeave(true);
else withdrawFromEvent();
};
const leftMeetingPointTitles = meetingPointsRaw
.filter(mp => registration?.meetingPointIds.includes(mp.id))
.map(mp => mp.title || mp.location);
// EDITING IS OWNING. Only a document's owner writes to it, and nothing delegates
// a write, so "may I edit this event" is answered entirely by whether this
// session owns the event's document — no permission call, no extra read.
@@ -165,19 +202,26 @@ export function EventDetailScreen() {
)}
</div>
{/* SIGNING UP IS PER MEETING POINT: no event-level join button here. The
join affordances live on the meeting points below; Invite follows
membership, the way the old event-level "Je participe" did. */}
{joined && (
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
<Button
onClick={() => navigate(eventPath(event.id, 'invite'))}
style={{ flex: 1, padding: '12px 0' }}
>
Inviter
<div style={{ margin: '4px 16px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{registered ? (
<>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--app-accent-dark)' }}>✓ Inscrit à l'événement</div>
<Button onClick={handleLeaveEvent} disabled={leaving} style={{ padding: '12px 0' }}>
Se désinscrire de l'événement
</Button>
<Button
onClick={() => navigate(eventPath(event.id, 'invite'))}
style={{ padding: '12px 0' }}
>
Inviter
</Button>
</>
) : (
<Button variant="primary" onClick={handleJoinEvent} style={{ padding: '12px 0' }}>
S'inscrire à l'événement
</Button>
</div>
)}
)}
</div>
{meetingPoints.length > 0 && (
<div style={{ padding: '0 16px 8px' }}>
@@ -244,6 +288,18 @@ export function EventDetailScreen() {
)}
</div>
</div>
{confirmingLeave && (
<ConfirmDialog
title="Se désinscrire de l'événement ?"
confirmLabel="Confirmer la désinscription"
cancelLabel="Annuler"
onConfirm={withdrawFromEvent}
onCancel={() => setConfirmingLeave(false)}
busy={leaving}
>
Vous êtes inscrit aux points de rencontre suivants : {leftMeetingPointTitles.join(', ')}. En confirmant, vous en serez aussi désinscrit.
</ConfirmDialog>
)}
</div>
);
}
+2 -3
View File
@@ -77,9 +77,8 @@ Then('les événements affichent leur lieu', async function (this: FestipodWorld
'Event cards should display a location (text or 📍 icon)').to.be.true;
});
// The product model signs up to a MEETING POINT, not to the event (arbitration
// of the event-join removal, T04.e): the signup affordance on this screen is the
// per-meeting-point "S'inscrire" toggle, not an event-level join button.
// Signing up is per meeting point or directly to the event: the detail screen
// exposes the event-level button and the per-meeting-point toggles.
Then('je peux m\'inscrire à l\'événement', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('event-detail');
const doc = this.renderedDoc;
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { Button } from './Button';
interface ConfirmDialogProps {
title: string;
children: ReactNode;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
busy?: boolean;
}
export function ConfirmDialog({ title, children, confirmLabel, cancelLabel, onConfirm, onCancel, busy = false }: ConfirmDialogProps) {
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
zIndex: 900,
}}
>
<div style={{ background: '#fff', borderRadius: 14, padding: 20, maxWidth: 360, width: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 17, fontWeight: 700 }}>{title}</div>
<div style={{ fontSize: 14, color: '#555', lineHeight: 1.5 }}>{children}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Button variant="primary" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
</div>
</div>
</div>
);
}
+1
View File
@@ -15,5 +15,6 @@ export { EventCover } from './EventCover';
export { ImagePicker } from './ImagePicker';
export { EventMeetingPoints } from './EventMeetingPoints';
export type { MeetingPointData } from './EventMeetingPoints';
export { ConfirmDialog } from './ConfirmDialog';
export { Divider } from './Divider';
export { Spinner } from './Spinner';
+7 -1
View File
@@ -108,6 +108,12 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
const leaveMeetingPoint = useCallback((_meetingPointId: string) => {
console.log(`${logPrefix} leaveMeetingPoint (local, no-op)`);
}, [logPrefix]);
const joinEvent = useCallback(async (_eventId: string) => {
console.log(`${logPrefix} joinEvent (local, no-op)`);
}, [logPrefix]);
const leaveEvent = useCallback(async (_eventId: string) => {
console.log(`${logPrefix} leaveEvent (local, no-op)`);
}, [logPrefix]);
const addMeetingPoint = useCallback((_mp: Omit<FpMeetingPointData, 'id'>) => {
console.log(`${logPrefix} addMeetingPoint (local, no-op)`);
// Nothing is stored in the disconnected demo: say so instead of letting the
@@ -145,7 +151,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, joinEvent, leaveEvent,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
+59 -13
View File
@@ -2,10 +2,13 @@
* FestipodDataContext — the screen-facing data contract, and the shared pieces
* every provider implementation builds on.
*
* The app signs up to a MEETING POINT, not to an event (product model): an
* event is only the public anchor meeting points graft onto. So the
* participation surface is `joinMeetingPoint` / `leaveMeetingPoint`; there is
* no event-level join. Provider implementations:
* The app signs up to a MEETING POINT, or directly to an event (product
* model): an event is the public anchor meeting points graft onto, and being in
* one of its meeting points registers you to it. So the participation surface
* is `joinMeetingPoint` / `leaveMeetingPoint` for a meeting point and
* `joinEvent` / `leaveEvent` for the event itself; the registration to an
* event (`getEventRegistration`) is derived from both, never stored.
* Provider implementations:
*
* - `LocalDataProvider` (`FestipodDataContext.tsx`) — the disconnected demo,
* seed fixtures, mutations as no-ops. What the `@ui` layer renders against.
@@ -32,6 +35,17 @@ import { normalizeIdentifier } from '../utils/identifier';
// Context interface
// ============================================================================
/**
* How the current user is registered to an event: `direct` (signed up to the
* event itself) and `meetingPointIds` (the event's meeting points they belong
* to). `registered` is derived — one of the two — never stored.
*/
export interface EventRegistration {
direct: boolean;
meetingPointIds: string[];
registered: boolean;
}
/**
* Whether this session may WRITE an event's document — asked the same way on
* every backend: only a record's owner writes it. `'unknown'` is a REAL third
@@ -82,6 +96,8 @@ export interface FestipodDataContextValue {
getUserEvents(userId: string): FpEventData[];
/** Membership test against a MEETING POINT id (defaults to the current user). */
isParticipating(meetingPointId: string, userId?: string): boolean;
/** How the current user is registered to an event — see {@link EventRegistration}. */
getEventRegistration(eventId: string): EventRegistration;
getFriends(userId?: string): FpUserData[];
getEventMeetingPoints(eventId: string): FpMeetingPointData[];
/**
@@ -131,6 +147,18 @@ export interface FestipodDataContextValue {
joinMeetingPoint(meetingPointId: string, userId?: string): Promise<void> | void;
/** Withdraw from a meeting point — the symmetric fact to `joinMeetingPoint`. */
leaveMeetingPoint(meetingPointId: string, userId?: string): Promise<void> | void;
/**
* Sign up directly to an event, whether or not it has meeting points.
* Idempotent; rejects when the act was refused or could not be sent.
*/
joinEvent(eventId: string): Promise<void>;
/**
* Withdraw from an event: the direct sign-up AND every membership of the
* event's meeting points. The confirmation the user owes before losing those
* memberships belongs to the screen. Resolves once every act was accepted;
* rejects naming the targets that failed (the status then follows the data).
*/
leaveEvent(eventId: string): Promise<void>;
/**
* Create a meeting point on an event. Same contract as `addFriend`: resolves
* once the meeting point was created and persisted — rejects when nothing
@@ -262,12 +290,11 @@ export function safeUserInitials(user: FpUserData | undefined): string {
* that provider's arrays. Cross-entity derivations only: nothing here touches
* a backend.
*
* THE `participations` ARRAY'S `eventId` IS THE JOINED TARGET'S ID. On the
* local/NextGraph providers that is an event; on the ActivityPods provider it
* is a meeting point. `isParticipating` and `getUserEvents` filter on it
* as-is, so a provider whose participations are keyed differently (the
* ActivityPods reader derives event-level participants from the meeting
* points) overrides those two queries and reuses the rest.
* THE `participations` ARRAY'S `eventId` IS THE JOINED TARGET'S ID: an event
* (a direct sign-up) or a meeting point. `getUserEvents`, `isParticipating`
* and `getEventRegistration` read it as such; a provider whose participations
* are not the whole truth about the current user (the ActivityPods reader
* overlays pending acts) overrides those queries and reuses the rest.
*/
export function buildQueries(
events: FpEventData[],
@@ -296,8 +323,9 @@ export function buildQueries(
};
const getUserEvents = (userId: string) => {
const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId);
return events.filter(e => partEventIds.includes(e.id));
const joined = new Set(participations.filter(p => p.userId === userId).map(p => p.eventId));
const viaMeetingPoints = new Set(meetingPoints.filter(mp => joined.has(mp.id)).map(mp => mp.eventId));
return events.filter(e => joined.has(e.id) || viaMeetingPoints.has(e.id));
};
const isParticipating = (meetingPointId: string, userId?: string) => {
@@ -305,6 +333,10 @@ export function buildQueries(
return participations.some(p => p.eventId === meetingPointId && p.userId === uid);
};
const getEventRegistration = (eventId: string): EventRegistration =>
registrationOf(eventId, meetingPoints, target =>
participations.some(p => p.eventId === target && p.userId === currentUserId));
const getFriends = (userId?: string) => {
const uid = userId || currentUserId;
const friendIds = friendships
@@ -323,13 +355,27 @@ export function buildQueries(
events.some(e => e.id === id) ? 'ready' : 'unavailable';
return {
getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints,
getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getEventRegistration, getFriends, getEventMeetingPoints,
openEvent: openEventInCompleteReadSet,
openUserEvents: openUserEventsInCompleteReadSet,
getEventReadState,
};
}
/**
* The registration to an event, given who holds which target: registered iff
* signed up to the event itself or a member of one of its meeting points.
*/
export function registrationOf(
eventId: string,
meetingPoints: readonly FpMeetingPointData[],
holds: (targetId: string) => boolean,
): EventRegistration {
const direct = holds(eventId);
const meetingPointIds = meetingPoints.filter(mp => mp.eventId === eventId && holds(mp.id)).map(mp => mp.id);
return { direct, meetingPointIds, registered: direct || meetingPointIds.length > 0 };
}
/**
* `openEvent` for a provider whose read set is already everything it can read
* (the local fixtures, the suspended NextGraph path): nothing to bring in. One
+10 -1
View File
@@ -997,6 +997,15 @@ function useNgData(): FestipodDataContextValue {
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err));
}, []);
const joinEvent = useCallback(
() => Promise.reject(new Error('joinEvent: not supported on the suspended NextGraph path')),
[],
);
const leaveEvent = useCallback(
() => Promise.reject(new Error('leaveEvent: not supported on the suspended NextGraph path')),
[],
);
const joinMeetingPoint = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log(`${logPrefix} joinMeetingPoint (NG):`, eventId, 'user:', uid);
@@ -1267,7 +1276,7 @@ function useNgData(): FestipodDataContextValue {
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, joinEvent, leaveEvent,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
+96 -35
View File
@@ -112,6 +112,7 @@ import {
FestipodDataContext,
buildQueries,
nextId,
registrationOf,
type FestipodDataContextValue,
type EventOwnership,
type EventReadState,
@@ -162,11 +163,14 @@ function inertDataValue(): FestipodDataContextValue {
selectedEventId: '', selectedEvent: undefined, selectedUserId: '', selectedUser: undefined,
getEvent: () => undefined, getUser: () => undefined,
getEventParticipants: () => [], getUserEvents: () => [], isParticipating: () => false,
getEventRegistration: () => ({ direct: false, meetingPointIds: [], registered: false }),
getFriends: () => [], getEventMeetingPoints: () => [], getEventOwnership: (): EventOwnership => 'unknown',
openEvent: () => {}, openUserEvents: () => {}, getEventReadState: (): EventReadState => 'unavailable',
setSelectedEventId: () => {}, setSelectedUserId: () => {},
createEvent: async (event: Omit<FpEventData, 'id'>) => ({ ...event, id: nextId('event') }),
updateEvent: () => {}, joinMeetingPoint: () => {}, leaveMeetingPoint: () => {},
joinEvent: () => Promise.reject(new Error('joinEvent: no data provider')),
leaveEvent: () => Promise.reject(new Error('leaveEvent: no data provider')),
addMeetingPoint: () => Promise.reject(new Error('addMeetingPoint: no data provider')), addFriend: () => Promise.reject(new Error('addFriend: no data provider')), inviteToEvent: () => Promise.reject(new Error('inviteToEvent: no data provider')), updateProfile: () => {}, loadTestData: async () => emptyBootstrap(),
};
return none;
@@ -251,6 +255,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
);
const ownMeetingPointIdsRef = useRef(ownMeetingPointIds);
ownMeetingPointIdsRef.current = ownMeetingPointIds;
const ownEventIdsRef = useRef(ownEventIds);
ownEventIdsRef.current = ownEventIds;
// --- MY PROFILE (the app's own entity id space) ------------------------------
// The actor (WebID) document links the `as:Profile` record via `url`; the
@@ -359,7 +365,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
useEffect(() => {
if (myProfileRead) readMyParticipations();
}, [myProfileRead, myParticipationsUri, readMyParticipations]);
const joinedMeetingPointIds = myParticipations.ids;
const joinedTargetIds = myParticipations.ids;
// The events each CONTACT DECLARES, keyed by their actor URI — read from the
// catalog with `?owner=<their WebID>` (the header's first contact channel):
// the declaration is public by design, and the catalog item IS the event
@@ -429,8 +435,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// to every derivation. An own point (on an own event, or one I proposed on
// another's) is already in the own list and never re-read by URI.
const joinedForeignMeetingPointIds = useMemo(
() => joinedMeetingPointIds.filter(id => !ownMeetingPointIds.has(id)),
[joinedMeetingPointIds, ownMeetingPointIds],
() => joinedTargetIds.filter(id => !ownMeetingPointIds.has(id) && !ownEventIds.has(id)),
[joinedTargetIds, ownMeetingPointIds, ownEventIds],
);
const foreignMeetingPointIds = useMemo(() => {
const ids = new Set<string>();
@@ -457,7 +463,12 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const ids = new Set<string>();
for (const mpId of joinedForeignMeetingPointIds) {
const record = foreignMeetingPointsRead.records.get(mpId);
if (record === undefined || !(bindings.meetingPoint.isInstance?.(record) ?? false)) continue;
if (record === undefined) continue;
// A joined target that reads as an event is a direct sign-up: its own anchor.
if (!(bindings.meetingPoint.isInstance?.(record) ?? false)) {
if (bindings.event.isInstance?.(record) ?? true) ids.add(mpId);
continue;
}
try {
const eventId = bindings.meetingPoint.toDomain(record).eventId;
if (eventId) ids.add(eventId);
@@ -499,7 +510,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
foreignMeetingPointsRead.records, bindings]);
// --- ATTENDEE LISTS, read by URI and embedded --------------------------------
// Every meeting point's linked collection, own and foreign alike — public by
// Every meeting point's and event's linked collection, own and foreign alike — public by
// design, read by URI with the same token policy, keyed by the URI the record
// links (never by what the fetched document claims). The members are member
// URNs: the read embeds them for the COUNT derivation and for recognising
@@ -509,11 +520,11 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const collectionOf = useMemo(() => {
const links = new Map<string, string>(); // meeting point id → collection URI
for (const record of readRecords) {
if (!(bindings.meetingPoint.isInstance?.(record) ?? false)) continue;
const link = attendeesCollectionLink(record);
if (!link) continue;
const kind = (bindings.meetingPoint.isInstance?.(record) ?? false) ? bindings.meetingPoint : bindings.event;
try {
links.set(bindings.meetingPoint.domainId(record), link);
links.set(kind.domainId(record), link);
} catch {
// No identifier: the derive skips this record too.
}
@@ -580,28 +591,28 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
useEffect(() => {
if (pendingActs.size === 0 || !webId) return;
setPendingActs(prev => settleMembershipActs(prev, mpId => {
if (!derived.meetingPoints.some(mp => mp.id === mpId)) return undefined;
if (!derived.meetingPoints.some(mp => mp.id === mpId) && !derived.events.some(e => e.id === mpId)) return undefined;
return {
member: joinedMeetingPointIds.includes(mpId),
member: joinedTargetIds.includes(mpId),
generation: myParticipations.generation,
attempted: myParticipations.attempted,
count: derived.totals.get(mpId),
};
}));
}, [pendingActs, derived, joinedMeetingPointIds, myParticipations, webId]);
}, [pendingActs, derived, joinedTargetIds, myParticipations, webId]);
// MY meeting points as this session sees them: the collection's, with the
// pending acts on top (an act asserts the state between the POST and the read
// that confirms it; while it is pending it wins over the last read, in either
// direction).
const myMeetingPointIds = useMemo(() => {
const ids = new Set(joinedMeetingPointIds);
const myTargetIds = useMemo(() => {
const ids = new Set(joinedTargetIds);
for (const [mpId, op] of overlay) {
if (op === 'join') ids.add(mpId);
else ids.delete(mpId);
}
return ids;
}, [joinedMeetingPointIds, overlay]);
}, [joinedTargetIds, overlay]);
// --- PARTICIPANT RESOLUTION (the Alice/Bob rule) ------------------------------
// WHO gets resolved: CONTACTS plus self only — never anyone else. A contact
@@ -736,7 +747,10 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// with the meeting points whose list holds it (one person carries the same
// URN in every list of one event).
const listedMembersByEvent = useMemo(() => {
const eventOf = new Map(derived.meetingPoints.map(mp => [mp.id, mp.eventId]));
const eventOf = new Map([
...derived.events.map(e => [e.id, e.id] as const),
...derived.meetingPoints.map(mp => [mp.id, mp.eventId] as const),
]);
const byEvent = new Map<string, Map<string, string[]>>(); // event → URN → meeting points
for (const p of derived.participations) {
const eventId = eventOf.get(p.eventId);
@@ -781,7 +795,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// Per contact, the meeting points whose list holds their URN — a set lookup
// per (contact, event): O(contacts + participations) per event. A contact
// whose profile carries no usable secret is never recognised.
const recognisedMeetingPoints = useMemo(() => {
const recognisedTargets = useMemo(() => {
const byContact = new Map<string, Set<string>>();
for (const [eventId, members] of listedMembersByEvent) {
for (const [contactId, keys] of contactKeys) {
@@ -834,10 +848,15 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const contactMpUris = useMemo(() => [...new Set([...contactListedMps.values()].flat())], [contactListedMps]);
const contactMpsRead = usePublicRecords(contactMpUris, { retry: userEventsAsks, tokenOf });
const contactMpEventIds = useMemo(() => {
const ids = new Map<string, string>(); // meeting point URI → anchor event URI
const ids = new Map<string, string>(); // listed target URI → anchor event URI
for (const uri of contactMpUris) {
const record = contactMpsRead.records.get(uri);
if (record === undefined || !(bindings.meetingPoint.isInstance?.(record) ?? false)) continue;
if (record === undefined) continue;
// A listed target that reads as an event is a direct sign-up: its own anchor.
if (!(bindings.meetingPoint.isInstance?.(record) ?? false)) {
if (bindings.event.isInstance?.(record) ?? true) ids.set(uri, uri);
continue;
}
try {
const eventId = bindings.meetingPoint.toDomain(record).eventId;
if (eventId) ids.set(uri, eventId);
@@ -917,23 +936,23 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// WHO is at an event: myself per my own meeting points, a contact per the
// recognition above (their URN found in one of the event's lists). Nobody
// else is ever identified — the count on the event stays the total.
const mpIds = new Set(mpIdsOf(eventId));
const targetIds = [eventId, ...mpIdsOf(eventId)];
const ids = new Set<string>();
for (const u of users) {
const theirs = u.id === webId ? myMeetingPointIds : recognisedMeetingPoints.get(u.id);
if (theirs && [...mpIds].some(id => theirs.has(id))) ids.add(u.id);
const theirs = u.id === webId ? myTargetIds : recognisedTargets.get(u.id);
if (theirs && targetIds.some(id => theirs.has(id))) ids.add(u.id);
}
return users.filter(u => ids.has(u.id));
}, [allMeetingPoints, users, webId, myMeetingPointIds, recognisedMeetingPoints]);
}, [allMeetingPoints, users, webId, myTargetIds, recognisedTargets]);
const getUserEvents = useCallback((userId: string) => {
if (!webId) return [];
if (userId === currentUserId) {
// Mine: my meeting points (my participations collection, with the pending
// acts on top), mapped to the events through the meeting points the read
// set carries.
const joinedEventIds = new Set<string>();
for (const mpId of myMeetingPointIds) {
// Mine: my direct sign-ups and my meeting points (my participations
// collection, with the pending acts on top), the latter mapped to their
// events through the meeting points the read set carries.
const joinedEventIds = new Set<string>(myTargetIds);
for (const mpId of myTargetIds) {
const mp = allMeetingPoints.find(m => m.id === mpId);
if (mp) joinedEventIds.add(mp.eventId);
}
@@ -950,7 +969,12 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
for (const event of declaredEventsByContact.get(userId) ?? []) byId.set(event.id, event);
for (const event of participatedEventsByContact.get(userId) ?? []) byId.set(event.id, event);
return [...byId.values()];
}, [currentUserId, webId, contactIdSet, declaredEventsByContact, participatedEventsByContact, myMeetingPointIds, allMeetingPoints, allEvents]);
}, [currentUserId, webId, contactIdSet, declaredEventsByContact, participatedEventsByContact, myTargetIds, allMeetingPoints, allEvents]);
const getEventRegistration = useCallback(
(eventId: string) => registrationOf(eventId, allMeetingPoints, id => myTargetIds.has(id)),
[allMeetingPoints, myTargetIds],
);
// Own participation — my meeting points, pending acts included. Another user:
// a contact participates iff recognised in this meeting point's list; anyone
@@ -958,10 +982,10 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const isParticipating = useCallback((meetingPointId: string, userId?: string) => {
if (userId !== undefined && userId !== currentUserId && userId !== webId) {
if (!contactIdSet.has(userId)) return false;
return recognisedMeetingPoints.get(userId)?.has(meetingPointId) ?? false;
return recognisedTargets.get(userId)?.has(meetingPointId) ?? false;
}
return myMeetingPointIds.has(meetingPointId);
}, [currentUserId, webId, contactIdSet, recognisedMeetingPoints, myMeetingPointIds]);
return myTargetIds.has(meetingPointId);
}, [currentUserId, webId, contactIdSet, recognisedTargets, myTargetIds]);
// --- Ownership: editing is owning ------------------------------------------------
// The list is own-pod scoped, so membership in it IS ownership. A failed
@@ -1025,6 +1049,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
actTimers.current.clear();
actBaseTotals.current.clear();
}, []);
const isOwnTarget = (id: string) => ownMeetingPointIdsRef.current.has(id) || ownEventIdsRef.current.has(id);
const afterMembershipAct = useCallback((mpId: string, op: MembershipOp) => {
const invalidateOwnList = () => void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
// The count: re-read the list the backend updates. With no list linked
@@ -1032,7 +1057,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const readCountAgain = (): void => {
const collection = collectionOfRef.current.get(mpId);
if (collection !== undefined) { readCollectionAgain(collection); return; }
if (ownMeetingPointIdsRef.current.has(mpId)) invalidateOwnList();
if (isOwnTarget(mpId)) invalidateOwnList();
else readMeetingPointAgain(mpId);
};
readCountAgain();
@@ -1043,7 +1068,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const confirmFrom = readMyParticipations(true);
actBaseTotals.current.set(mpId, totalsRef.current.get(mpId));
setPendingActs(prev => recordMembershipAct(prev, mpId, op, confirmFrom, totalsRef.current.get(mpId)));
if (ownMeetingPointIdsRef.current.has(mpId)) invalidateOwnList();
if (isOwnTarget(mpId)) invalidateOwnList();
for (const timer of actTimers.current.get(mpId) ?? []) clearTimeout(timer);
const last = MEMBERSHIP_REREAD_DELAYS_MS.length - 1;
actTimers.current.set(mpId, MEMBERSHIP_REREAD_DELAYS_MS.map((delay, step) => setTimeout(() => {
@@ -1200,7 +1225,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// The membership check is MY meeting points' word — my participations
// collection with the pending acts on top, so a join still unconfirmed can
// be withdrawn.
if (!myMeetingPointIds.has(mp.id)) {
if (!myTargetIds.has(mp.id)) {
const msg = `leaveMeetingPoint refused: no membership of mine on ${mp.id} — nothing was withdrawn.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
@@ -1208,7 +1233,42 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId);
await postLeaveActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() });
afterMembershipAct(mp.id, 'leave');
}, [logPrefix, allMeetingPoints, myMeetingPointIds, session, webId, afterMembershipAct]);
}, [logPrefix, allMeetingPoints, myTargetIds, session, webId, afterMembershipAct]);
const joinEvent = useCallback(async (eventId: string) => {
const event = allEvents.find(e => e.id === eventId);
if (!event || !session) {
throw new Error(`joinEvent refused: ${eventId} is not in the read set ` +
'(or there is no session) — the write would target a reference this session cannot see.');
}
if (myTargetIds.has(event.id)) return;
console.log(`${logPrefix} joinEvent (refine):`, eventId);
await ensureParticipationSecret();
await postJoinActivity({ meetingPointUri: event.id, session, appActorUri: appActorUri() });
afterMembershipAct(event.id, 'join');
}, [logPrefix, allEvents, session, myTargetIds, ensureParticipationSecret, afterMembershipAct]);
// One Leave per held target, in parallel; each accepted one is followed, the
// refused ones are named — the status keeps following the data.
const leaveEvent = useCallback(async (eventId: string) => {
if (!allEvents.some(e => e.id === eventId) || !session) {
throw new Error(`leaveEvent refused: ${eventId} is not in the read set (or there is no session).`);
}
const { direct, meetingPointIds } = getEventRegistration(eventId);
const targets = direct ? [eventId, ...meetingPointIds] : meetingPointIds;
if (targets.length === 0) {
throw new Error(`leaveEvent refused: no registration of mine on ${eventId} — nothing was withdrawn.`);
}
console.log(`${logPrefix} leaveEvent (refine):`, eventId, targets.length);
const outcomes = await Promise.allSettled(targets.map(async target => {
await postLeaveActivity({ meetingPointUri: target, session, appActorUri: appActorUri() });
afterMembershipAct(target, 'leave');
}));
const failed = targets.filter((_, i) => outcomes[i]?.status === 'rejected');
if (failed.length > 0) {
throw new Error(`leaveEvent: the withdrawal was refused for ${failed.join(', ')}`);
}
}, [logPrefix, allEvents, session, getEventRegistration, afterMembershipAct]);
// --- The rest of the contract -------------------------------------------------------
// A contact request, not a friendship: the Pod's social graph is mutual and
@@ -1257,13 +1317,14 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
getEventParticipants,
getUserEvents,
isParticipating,
getEventRegistration,
getFriends: shared.getFriends,
getEventMeetingPoints: shared.getEventMeetingPoints,
getEventOwnership,
openEvent,
openUserEvents,
getEventReadState,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, joinEvent, leaveEvent,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
+44 -34
View File
@@ -7,17 +7,20 @@
* here, and they are the two places the ActivityPods model meets the app's:
*
* 1. `deriveContainer` SPLITS one `as/Event` container into events and meeting
* points and reads each meeting point's `apods:attendees` collection. Events
* and meeting points share the container (the bindings' `isInstance` is the
* only distinction), so the split has to happen exactly once, at read time.
* The event's `participantCount` is the number of DISTINCT member values
* across its meeting points' memberships — never a stored, incremented
* counter, and never the number of RECOGNISED people: the members are the
* member URNs of decision_2026-10-29_participation-privacy-mechanism, one
* per (person, EVENT), so the same person in two meeting points of one event
* carries the SAME URN in both collections and counts ONCE — the product
* points and reads the `apods:attendees` collection of each — a meeting
* point's members are its memberships, an event's own members are its direct
* sign-ups. Events and meeting points share the container (the bindings'
* `isInstance` is the only distinction), so the split has to happen exactly
* once, at read time. The event's `participantCount` is the number of
* DISTINCT member values across its own list and its meeting points' lists —
* never a stored, incremented counter, and never the number of RECOGNISED
* people: the members are the member URNs of
* decision_2026-10-29_participation-privacy-mechanism, one per (person,
* EVENT), so the same person directly signed up and in meeting points of one
* event carries the SAME URN in every list and counts ONCE — the product
* rule is publicly computable from opaque data, with no recognition and no
* backend.
* backend. Being registered to an event is never stored: it is a direct
* sign-up or a membership of one of its meeting points.
*
* 2. `applyMembershipOverlay` reflects a join/leave made beside Refine (through
* the outbox) before the backend's update of the collections is read. `countsWithOverlay`
@@ -40,14 +43,15 @@ export interface DerivedContainer {
events: FpEventData[];
meetingPoints: FpMeetingPointData[];
/**
* Participations keyed BY MEETING POINT: `eventId` carries the meeting point
* id and `userId` the attendee-list member — the member URN of
* Participations keyed BY TARGET: `eventId` carries the id of the meeting
* point — or of the event itself for a direct sign-up — whose list holds the
* member, and `userId` the attendee-list member — the member URN of
* decision_2026-10-29_participation-privacy-mechanism (one per (person,
* EVENT)), which only a holder of the person's secret recognises (see
* `refineData.tsx`).
*/
participations: FpParticipationData[];
/** Per-meeting-point collection totals, keyed by meeting point id. */
/** Per-list collection totals, keyed by meeting point or event id. */
totals: Map<string, number>;
}
@@ -55,10 +59,10 @@ export interface DerivedContainer {
* Split a container's raw records with the backend's own bindings and derive
* the participations and the participant counts.
*
* A meeting point's attendees collection travels inside its record; each member
* is mapped to a participation stamped with the HOST meeting point's id (a
* member carries no link back to what it attends), and the collection's total is
* kept as-is — a member the reader cannot resolve still counts.
* An attendees collection travels inside its record; each member is mapped to a
* participation stamped with the HOST record's id (a member carries no link back
* to what it attends), and the collection's total is kept as-is — a member the
* reader cannot resolve still counts.
*/
export function deriveContainer(
records: readonly unknown[],
@@ -72,26 +76,32 @@ export function deriveContainer(
// pods too (events opened by URI, their meeting points, the collections those
// link to): one that the bindings reject is skipped with a warning — it never
// takes the whole read down with it.
const readMembers = (record: unknown, targetId: string): number | undefined => {
const attendees = bindings.participation.membersOf?.(record);
if (!attendees) return undefined;
for (const member of attendees.members) {
try {
const mapped = bindings.participation.toDomain(member);
participations.push({ ...mapped, id: `${targetId}|${mapped.userId}`, eventId: targetId });
} catch (error) {
console.warn(`[refine] skipped an unreadable attendee of ${targetId}`, error);
}
}
return attendees.total ?? attendees.members.length;
};
for (const record of records) {
try {
if (bindings.meetingPoint.isInstance?.(record) ?? false) {
const mp = bindings.meetingPoint.toDomain(record);
meetingPoints.push(mp);
const attendees = bindings.participation.membersOf?.(record);
const members = attendees?.members ?? [];
for (const member of members) {
try {
const mapped = bindings.participation.toDomain(member);
participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id });
} catch (error) {
console.warn(`[refine] skipped an unreadable attendee of ${mp.id}`, error);
}
}
const total = attendees?.total ?? members.length;
const total = readMembers(record, mp.id) ?? 0;
totals.set(mp.id, total);
mp.participantCount = total;
} else if (bindings.event.isInstance?.(record) ?? true) {
events.push(bindings.event.toDomain(record));
const event = bindings.event.toDomain(record);
events.push(event);
const total = readMembers(record, event.id);
if (total !== undefined) totals.set(event.id, total);
}
} catch (error) {
console.warn('[refine] skipped a record the bindings cannot read', error);
@@ -105,11 +115,11 @@ export function deriveContainer(
/**
* The event's count: the number of DISTINCT member values among the
* participations of its meeting points — derived at read time, never a stored
* participations of its own list and of its meeting points — derived at read time, never a stored
* counter. The members are the member URNs of
* decision_2026-10-29_participation-privacy-mechanism: one URN per (person, EVENT),
* so a person in two of the event's meeting points carries the same URN in both
* collections and counts ONCE — distinct PERSONS, publicly computable from
* so a person in several of the event's lists carries the same URN in each and
* counts ONCE — distinct PERSONS, publicly computable from
* opaque data, while each meeting point's own count stays the collection total
* (its card shows per-point inscriptions).
*/
@@ -118,10 +128,10 @@ function eventParticipantCount(
meetingPoints: readonly FpMeetingPointData[],
participations: readonly FpParticipationData[],
): number {
const mpIds = new Set(meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id));
const targetIds = new Set([eventId, ...meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id)]);
const userIds = new Set<string>();
for (const p of participations) {
if (mpIds.has(p.eventId)) userIds.add(p.userId);
if (targetIds.has(p.eventId)) userIds.add(p.userId);
}
return userIds.size;
}
+11 -11
View File
@@ -1,5 +1,5 @@
/**
* Outbox posting — how a user joins or leaves a meeting point.
* Outbox posting — how a user joins or leaves a meeting point or an event.
*
* WHY THIS LIVES BESIDE REFINE. The refine-providers package implements no
* `custom` method: there is no escape hatch through the data provider, and a
@@ -7,13 +7,13 @@
* posted to the USER'S OWN outbox with the user's token, a withdrawal an
* `as:Leave` on the same path (decision_2026-10-29_participation-privacy-mechanism).
*
* THE ACTIVITY. Its `object` is the meeting point itself, and its only
* addressee is the Festipod app actor (`to`): not public, not addressed to the
* meeting point's creator. A non-public activity is readable only by its author
* and its addressees, so the target stays hidden from everyone else. The user's
* pod delivers it to the app's inbox, where the Festipod backend handles it —
* it records the author's member URN in the meeting point's attendee list and
* the meeting point in the author's participations collection
* THE ACTIVITY. Its `object` is the meeting point — or the event, for a direct
* sign-up — itself, and its only addressee is the Festipod app actor (`to`):
* not public, not addressed to the target's creator. A non-public activity is
* readable only by its author and its addressees, so the target stays hidden
* from everyone else. The user's pod delivers it to the app's inbox, where the
* Festipod backend handles it — it records the author's member URN in the
* target's attendee list and the target in the author's participations collection
* (decision_2026-11-03_participation-delivered-to-the-app).
*
* THE FLOW. The outbox URI is read off the user's own actor (WebID) document
@@ -34,7 +34,7 @@ import { fetchActorDocument } from './actor';
const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams';
export interface OutboxActivityInput {
/** URI of the meeting point the activity names. */
/** URI of the target the activity names: a meeting point, or an event for a direct sign-up. */
meetingPointUri: string;
/** The authenticated session — whose outbox receives the activity. */
session: CurrentSession;
@@ -71,12 +71,12 @@ async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput):
}
}
/** Post an `as:Join` activity to the user's outbox — signing up to a meeting point. */
/** Post an `as:Join` activity to the user's outbox — signing up to a meeting point or an event. */
export function postJoinActivity(input: OutboxActivityInput): Promise<void> {
return postActivity('Join', input);
}
/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point. */
/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point or an event. */
export function postLeaveActivity(input: OutboxActivityInput): Promise<void> {
return postActivity('Leave', input);
}