Implement direct sign-up to an event with registration derived from meeting point memberships
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user