Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work

Participation privacy (decision_2026-10-29_participation-privacy-mechanism):
a sign-up is a private Join naming the meeting point, addressed to nobody,
handled by the backend on emission. Attendee lists hold member URNs hashed
from (WebID, event, a secret kept in the participant's profile), so counts
stay public and only contacts recognise anyone; the creator has no read
right and is notified anonymously. Each person's participations collection,
created by the backend at the first sign-up, is readable by their /contacts
WAC group only, never world-readable. The member URN is sticky per
(meeting point, participant) and every collection write is verified by
reading back, so a refused write leaves the act unrecorded and retryable.
The EventRef mechanism and its browser-side provisioning are gone.

The backend now runs with WebACL on, as the reference apps do, on its own
secure datasets festipod / settings-festipod instead of the pod provider's
settings dataset; pod-notifications needs it. The compose header and the
deployment leaf say how to reset a stack whose datasets predate this.

The rights matrix covers the new mechanism live (148 hard lines), including
a forged contact Accept between throwaway accounts: the stranger enters the
contacts list (platform defect, reported upstream) but is refused the profile
and the participations. Tests may attack as long as they never break the dev
environment (rule_tests-never-break-the-dev-environment).

Also lands the coherence-pass work that was still uncommitted: contacts as
the Pod's social graph and invite links, image upload to the Pod, the
meeting-point form, profile completion gate, and the doctrine for them.
Superseded briefs and decisions are deleted rather than annotated.
This commit is contained in:
Sylvain Duchesne
2026-10-30 17:06:38 +01:00
parent f90651eb9a
commit 908f7ea973
138 changed files with 8347 additions and 3679 deletions
+44 -4
View File
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { RouterProvider, useNavigate, useRouter } from './router';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { useFestipodData, FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { AuthGate } from './AuthGate';
import { ToastContainer } from '../shared/components/sketchy';
import { ToastContainer, Spinner, Text } from '../shared/components/sketchy';
import { initialBackendName } from '../shared/refine/providers';
// Auth
@@ -31,6 +31,44 @@ import { FriendsListScreen } from '../modules/user/screens/FriendsListScreen';
import { ShareProfileScreen } from '../modules/user/screens/ShareProfileScreen';
import { ConnectScreen } from '../modules/user/screens/ConnectScreen';
/**
* A signed-in person whose Pod has no readable profile, or one without a name,
* cannot be recognised by anyone and cannot edit anything: the app tells them
* so and asks for a name instead of leaving them in screens that silently fail.
* Presence of a name is the only completion flag — nothing is stored locally.
*/
function ProfileCompletionGate({ children }: { children: React.ReactNode }) {
const { currentUser, profileStatus } = useFestipodData();
if (profileStatus === 'loading') {
// A blank screen reads as a crash; the profile read is the app's first
// blocking step, so say that plainly while it settles.
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 24 }}>
<Spinner size={24} />
<Text style={{ color: '#888', fontSize: 14 }}>Chargement de votre profil…</Text>
</div>
);
}
if (profileStatus === 'missing') {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<p style={{ fontWeight: 700 }}>Votre profil est introuvable</p>
<p style={{ color: '#888', fontSize: 14, lineHeight: 1.5 }}>
Votre Pod ne fournit pas de profil lisible : Festipod ne peut pas l'afficher ni le modifier.
Complétez votre profil depuis votre fournisseur de Pod, puis réessayez. Cet écran apparaît aussi quand la lecture a échoué momentanément.
</p>
<button onClick={() => window.location.reload()} style={{ padding: '10px 18px', borderRadius: 10, border: '1.5px solid #ccc', background: '#fff', cursor: 'pointer' }}>
Réessayer
</button>
</div>
);
}
if (profileStatus === 'ready' && (currentUser?.name ?? '').trim() === '') {
return <UpdateProfileScreen onboarding />;
}
return <>{children}</>;
}
function AppContent() {
const { route } = useRouter();
@@ -81,7 +119,9 @@ export function App() {
<AuthGate>
<FestipodDataProvider>
<div className="app-container">
<AppContent />
<ProfileCompletionGate>
<AppContent />
</ProfileCompletionGate>
<ToastContainer />
</div>
</FestipodDataProvider>
+1 -25
View File
@@ -139,19 +139,6 @@ body {
color: var(--app-black);
}
/* Placeholder box */
.app-placeholder {
background: var(--app-light-gray);
border: 2px dashed #ddd;
border-radius: var(--app-radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--app-gray);
font-family: var(--font-app);
font-size: 14px;
}
/* Divider */
.app-divider {
height: 1px;
@@ -263,7 +250,7 @@ body {
position: relative;
}
/* Badge / Tag */
/* Badge */
.app-badge {
display: inline-block;
padding: 3px 10px;
@@ -274,17 +261,6 @@ body {
color: #666;
}
.app-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 500;
color: #5a3e00;
background: #fff3d6;
letter-spacing: 0.1px;
}
/* Section label */
.app-section-label {
font-size: 12px;
+4 -1
View File
@@ -122,7 +122,10 @@ const server = serve({
return new Response("No shared wallet file configured.", { status: 404 });
},
// Serve index.html for all unmatched routes (must be last)
// Serve index.html for all unmatched routes (must be last). The imported
// HTMLBundle is opaque at runtime (`index.index` is its source path, not
// HTML) — only Bun.serve knows how to build & serve it, so it must stay
// the direct route value; wrapping it in Response() would serve garbage.
"/*": index,
},
@@ -59,10 +59,6 @@ export function WelcomeScreen() {
Déjà membre ? <span onClick={() => navigate('/home')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
</Text>
</div>
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
Version beta - 127 membres actifs
</Text>
</div>
);
}
@@ -32,8 +32,11 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
# L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un
# scénario sans navigateur l'endpoint d'enregistrement — voir l'étape
# « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur
# l'outbox de l'utilisateur EST délivrée au backend, qui tient la collection
# `apods:attendees` du point de rencontre. Le point de rencontre est créé dans le
# l'outbox de l'utilisateur, sans destinataire, est traitée par le backend à
# l'émission : il inscrit dans la collection `apods:attendees` du point de
# rencontre l'URN de membre dérivée du secret de participation du profil
# (écrit avant le Join, comme le fait l'app) — jamais le WebID
# (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le
# conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée
# par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.
Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees
@@ -47,7 +50,8 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
# Dépend du scénario précédent pour la mise en place, mais lit la collection
# elle-même : le compteur est le total de la collection (jamais les profils
# résolus) et une référence non résolue reste comptée et rendue anonyme.
# résolus) et une URN de membre, que rien ne résout, reste comptée et rendue
# anonyme.
Scénario: Le compteur et la liste des participants tiennent une référence non résolue
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
@@ -1,26 +1,9 @@
import React, { useState, useMemo } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
import { Header, Text, Input, Button, ImagePicker, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { eventPath, useNavigate } from '../../../app/router';
const importableEvents = [
{
name: 'Festival des Utopies Concrètes',
source: 'Mobilizon',
date: '2026-03-15',
location: 'Paris, Parc de la Villette',
description: 'Festival annuel présentant des alternatives concrètes pour un monde durable.',
},
{
name: "Rencontres de l'Écologie",
source: 'Transiscope',
date: '2026-04-20',
location: 'Lyon, Halle Tony Garnier',
description: 'Deux jours de conférences et ateliers sur la transition écologique.',
},
];
type Step = 1 | 2 | 3;
export function CreateEventScreen() {
@@ -35,7 +18,6 @@ export function CreateEventScreen() {
const [endTime, setEndTime] = useState('');
const [location, setLocation] = useState('');
const [description, setDescription] = useState('');
const [importedFrom, setImportedFrom] = useState<string | null>(null);
const similarExisting = useMemo(() => {
if (name.trim().length < 3) return [];
@@ -43,22 +25,6 @@ export function CreateEventScreen() {
return events.filter(e => e.title.toLowerCase().includes(lower.slice(0, 3)));
}, [name, events]);
const importCandidate = useMemo(() => {
if (importedFrom) return null;
if (name.trim().length < 3) return null;
const lower = name.toLowerCase();
return importableEvents.find(e => e.name.toLowerCase().includes(lower.slice(0, 3))) ?? null;
}, [name, importedFrom]);
const applyImport = (candidate: typeof importableEvents[number]) => {
setName(candidate.name);
setStartDate(candidate.date);
setLocation(candidate.location);
setDescription(candidate.description);
setImportedFrom(candidate.source);
showToast(`Données importées depuis ${candidate.source}`, 'info');
};
const goNext = () => {
if (step === 1) {
if (similarExisting.length > 0) setStep(2);
@@ -74,6 +40,8 @@ export function CreateEventScreen() {
setStep((step - 1) as Step);
};
const [coverImage, setCoverImage] = useState('');
const submit = async () => {
const dateLabel = startDate
? (endDate ? `${startDate} - ${endDate}` : startDate)
@@ -94,6 +62,7 @@ export function CreateEventScreen() {
endTime,
location: location || 'Lieu à définir',
description,
coverImage: coverImage || undefined,
// Option B: the creator does NOT auto-participate (no host notion). The count
// starts at 0 and is DERIVED by the owner-materializer from real inbox
// deposits (|active registrations|) — never a local literal. Passing 1 here was
@@ -139,33 +108,6 @@ export function CreateEventScreen() {
</div>
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{importCandidate && step === 1 && (
<div
style={{
background: '#EFF6FF',
border: '1.5px solid #93C5FD',
borderRadius: 12,
padding: 12,
marginBottom: 16,
}}
>
<Text style={{ margin: 0, fontWeight: 'bold', fontSize: 14, marginBottom: 4 }}>
Données trouvées sur {importCandidate.source}
</Text>
<Text style={{ margin: 0, fontSize: 13, lineHeight: 1.5 }}>
« {importCandidate.name} » — {importCandidate.location}
</Text>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="primary" style={{ padding: '6px 12px', fontSize: 13 }} onClick={() => applyImport(importCandidate)}>
Importer
</Button>
<Button style={{ padding: '6px 12px', fontSize: 13 }} onClick={() => setImportedFrom('dismissed')}>
Ignorer
</Button>
</div>
</div>
)}
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Text style={{ fontSize: 13, color: '#888', margin: 0 }}>
@@ -179,7 +121,6 @@ export function CreateEventScreen() {
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
if (importedFrom === 'dismissed') setImportedFrom(null);
}}
/>
</div>
@@ -254,11 +195,7 @@ export function CreateEventScreen() {
{step === 3 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Placeholder
height={140}
label="+ Ajouter une photo"
style={{ cursor: 'pointer' }}
/>
<ImagePicker value={coverImage} label="+ Ajouter une photo" onChange={setCoverImage} />
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
@@ -10,7 +10,6 @@ export function EventDetailScreen() {
const { eventId } = useParams();
const {
getEvent,
currentUser,
isParticipating,
joinMeetingPoint,
leaveMeetingPoint,
@@ -36,9 +35,8 @@ export function EventDetailScreen() {
const meetingPoints: MeetingPointData[] = meetingPointsRaw.map(mp => ({
id: mp.id,
title: mp.location,
when: mp.time,
duration: '~60 min',
title: mp.title,
when: mp.time || undefined,
lieu: mp.location,
// Derived by the data layer (absent where it cannot be counted per point).
participantCount: mp.participantCount,
@@ -81,11 +79,9 @@ export function EventDetailScreen() {
// the answer is still coming.
const ownership = eventId ? getEventOwnership(eventId) : 'unknown';
const canEdit = ownership === 'mine';
// Preview list shows the OTHER participants (deliberate — the total is in the
// header count; the full list at "Voir tous les participants" shows everyone).
// On this backend `currentUser.id` and `p.id` are BOTH actor URIs (the id the
// attendees collection keys memberships by), so comparing them drops self.
const knownParticipants = participants.filter(p => p.id !== currentUser?.id);
// Preview and full list apply the same rule — everyone, self included — so the
// rows agree with the header count.
const knownParticipants = participants;
// The rest of the total could not be resolved to a profile — a non-contact
// sees only anonymous attendees. The total still counts them, so the
// difference is rendered as anonymous rows (mirrors ParticipantsListScreen),
@@ -140,7 +136,7 @@ export function EventDetailScreen() {
</div>
<div style={{ padding: '0 16px 12px' }}>
<EventCover eventId={event.id} height={100} />
<EventCover url={event.coverImage} height={100} />
</div>
<div style={{ margin: '0 16px 12px', padding: 14, background: '#fafafa', borderRadius: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -217,7 +213,7 @@ export function EventDetailScreen() {
onClick={() => navigate(userPath(p.id))}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
>
<Avatar initials={displayInitials} size={38} color="#2B6CB0" />
<Avatar initials={displayInitials} photo={p.photo} size={38} color="#2B6CB0" />
<div style={{ flex: 1 }}>
<div className="user-content" style={{ fontSize: 14, fontWeight: 600 }}>{displayName}</div>
{displayUsername && (
@@ -238,7 +234,7 @@ export function EventDetailScreen() {
</div>
</div>
))}
{unknownCount > 0 && (
{participants.length > 0 && (
<div
style={{ marginTop: 12, padding: 12, background: '#f9f9f9', borderRadius: 12, textAlign: 'center', cursor: 'pointer' }}
onClick={() => navigate(eventPath(event.id, 'participants'))}
+13 -30
View File
@@ -1,17 +1,11 @@
import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Input, Card, Badge, BottomNav, AvatarStack, getEventPhotoUrl, Text } from '../../../shared/components/sketchy';
import { Header, Input, Card, BottomNav, EventCover, Text } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { eventPath, useNavigate } from '../../../app/router';
import { matchesQuery } from '../../../shared/utils/search';
import { useDiscoverEvents } from '../useDiscoverEvents';
const PEOPLE = [
{ name: 'Marie Leroy', color: '#E8590C' },
{ name: 'Jean Morel', color: '#2B6CB0' },
{ name: 'Alice Duval', color: '#9C4DC7' },
{ name: 'Thomas Bazin', color: '#38A169' },
{ name: 'Camille Noir', color: '#D69E2E' },
];
const EVENT_COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E'];
function EventCard({
@@ -19,23 +13,15 @@ function EventCard({
participantCount,
onClick,
}: {
event: { id: string; title: string; date: string; location: string; distance?: number };
event: { id: string; title: string; date: string; location: string; distance?: number; coverImage?: string };
/** `undefined` when nobody counted it (a catalog entry) — then no count is shown, never a made-up 0. */
participantCount: number | undefined;
onClick: () => void;
}) {
const color = EVENT_COLORS[Number(event.id.replace(/\D/g, '') || 0) % EVENT_COLORS.length] ?? '#E8590C';
const people = PEOPLE.slice(0, Math.min(5, Math.max(1, participantCount ?? 0)));
return (
<Card onClick={onClick} style={{ marginBottom: 12, padding: 0, overflow: 'hidden' }} accentColor={color}>
<div
style={{
height: 100,
backgroundImage: `url(${getEventPhotoUrl(event.id)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<EventCover url={event.coverImage} height={100} borderRadius={0} />
<div style={{ padding: 14 }}>
<div className="user-content" style={{ fontSize: 15.5, fontWeight: 700, lineHeight: 1.3, marginBottom: 6 }}>{event.title}</div>
<div style={{ fontSize: 12.5, color: '#888', marginBottom: 10 }}>
@@ -44,7 +30,6 @@ function EventCard({
{participantCount !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<AvatarStack people={people} size={26} />
<span style={{ fontSize: 12, color: '#666' }}>{participantCount} inscrits</span>
</div>
</div>
@@ -57,7 +42,9 @@ function EventCard({
export function EventsScreen() {
const navigate = useNavigate();
const { events: ownEvents, getEvent } = useFestipodData();
const events = useDiscoverEvents(ownEvents);
const { events: allEvents, loading } = useDiscoverEvents(ownEvents);
const [query, setQuery] = useState('');
const events = allEvents.filter(e => matchesQuery(query, e.title, e.location));
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -67,22 +54,18 @@ export function EventsScreen() {
/>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher un événement..." />
</div>
<div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Badge style={{ background: '#1a1a1a', color: '#fff' }}>Tous</Badge>
<Badge>Cette semaine</Badge>
<Badge>Proches</Badge>
<Badge>Amis</Badge>
<Input placeholder="Rechercher un événement..." value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
</div>
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{events.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32 }}>
Aucun événement à afficher
{loading ? 'Chargement des événements…' : query.trim() ? 'Aucun événement ne correspond à votre recherche.' : 'Aucun événement à afficher pour le moment.'}
</Text>
)}
{events.length > 0 && loading && (
<Text style={{ textAlign: 'center', color: '#888', fontSize: 12, marginBottom: 8 }}>Chargement des événements…</Text>
)}
{events.map(event => {
// An event the data layer READ (own, or another user's opened by URI)
// carries a derived count; a catalog entry nobody opened carries none —
+33 -7
View File
@@ -3,17 +3,20 @@ import { ArrowLeft } from 'lucide-react';
import { Header, Input, Text, Avatar, Checkbox, Button, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { matchesQuery } from '../../../shared/utils/search';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function InviteScreen() {
const navigate = useNavigate();
const { eventId } = useParams();
const { getFriends } = useFestipodData();
const { getFriends, inviteToEvent } = useFestipodData();
const friends = getFriends();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [step, setStep] = useState<'select' | 'message'>('select');
const [message, setMessage] = useState('');
const [query, setQuery] = useState('');
const shownFriends = friends.filter(f => matchesQuery(query, safeUserName(f), safeUserUsername(f)));
const toggleFriend = (id: string) => {
const next = new Set(selected);
@@ -23,9 +26,22 @@ export function InviteScreen() {
const selectedFriends = friends.filter(f => selected.has(f.id));
const [sending, setSending] = useState(false);
const send = () => {
showToast(`${selected.size} invitation${selected.size > 1 ? 's' : ''} envoyée${selected.size > 1 ? 's' : ''}`, 'success');
navigate(eventPath(eventId ?? ''));
if (!eventId || sending) return;
setSending(true);
const count = selected.size;
// The confirmation follows the post: nothing is announced as sent until the Pod accepted it.
void Promise.resolve(inviteToEvent(eventId, [...selected], message))
.then(() => {
showToast(`${count} invitation${count > 1 ? 's' : ''} envoyée${count > 1 ? 's' : ''}`, 'success');
navigate(eventPath(eventId));
})
.catch((err: unknown) => {
console.error('[Invite] invitation failed:', err);
showToast("Les invitations n'ont pas pu être envoyées", 'error');
})
.finally(() => setSending(false));
};
if (step === 'message') {
@@ -126,9 +142,11 @@ export function InviteScreen() {
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
/>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher un ami..." />
</div>
{friends.length > 0 && (
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher un ami..." value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
</div>
)}
{selected.size > 0 && (
<div style={{
@@ -143,7 +161,15 @@ export function InviteScreen() {
)}
<div style={{ flex: 1, overflow: 'auto' }}>
{friends.map((friend) => (
{friends.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', margin: '32px 24px', lineHeight: 1.5 }}>
Vous n'avez pas encore de contact à inviter. Ajoutez des personnes à votre réseau depuis leur profil.
</Text>
)}
{friends.length > 0 && shownFriends.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32 }}>Aucun contact ne correspond à votre recherche.</Text>
)}
{shownFriends.map((friend) => (
<div
key={friend.id}
onClick={() => toggleFriend(friend.id)}
@@ -1,35 +1,39 @@
import React, { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, Input, showToast } from '../../../shared/components/sketchy';
import { Button, Input, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function MeetingPointsScreen() {
const navigate = useNavigate();
const { eventId } = useParams();
const { addMeetingPoint, currentUser, getFriends } = useFestipodData();
const friends = getFriends();
const { addMeetingPoint } = useFestipodData();
const [title, setTitle] = useState('');
const [when, setWhen] = useState('');
const [duration, setDuration] = useState('~30 min');
const [lieu, setLieu] = useState('');
const [invited, setInvited] = useState<string[]>(friends.slice(0, 3).map(f => f.id));
const removeInvited = (id: string) => {
setInvited(invited.filter(i => i !== id));
};
const submit = () => {
if (!eventId) return;
addMeetingPoint({
if (!lieu.trim()) {
showToast('Indiquez le lieu du point de rencontre', 'error');
return;
}
// Only what the backend can store is asked for: the title and the place.
// A free-text "when" / duration has no slot in the pod's vocabulary (no
// ActivityStreams term holds either), so the form no longer offers them.
void Promise.resolve(addMeetingPoint({
eventId,
location: title || lieu || 'Point de rencontre',
time: when || duration,
});
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
navigate(eventPath(eventId ?? ''));
title: title.trim() || undefined,
location: lieu.trim(),
}))
.then(() => {
showToast(title.trim() ? `Point de rencontre créé : ${title.trim()}` : 'Point de rencontre créé', 'success');
navigate(eventPath(eventId));
})
.catch((err: unknown) => {
console.error('[MeetingPoints] creation failed:', err);
showToast("Le point de rencontre n'a pas pu être créé", 'error');
});
};
return (
@@ -52,49 +56,14 @@ export function MeetingPointsScreen() {
style={{ marginBottom: 16 }}
/>
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Quand</label>
<Input
value={when}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setWhen(e.target.value)}
placeholder="Ven. 22 · 9h00"
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Durée</label>
<Input
value={duration}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDuration(e.target.value)}
placeholder="~30 min"
/>
</div>
</div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Lieu</label>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Lieu *</label>
<Input
value={lieu}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLieu(e.target.value)}
placeholder="Café en face du tiers-lieu"
style={{ marginBottom: 16 }}
style={{ marginBottom: 24 }}
/>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 8 }}>Inviter</label>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 20 }}>
{invited.map(id => {
const friend = friends.find(f => f.id === id);
if (!friend) return null;
return (
<div key={id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px 6px 6px', borderRadius: 20, background: '#f5f5f5' }}>
<Avatar initials={safeUserInitials(friend)} color="#2B6CB0" size={22} />
<span style={{ fontSize: 12, fontWeight: 500 }}>{safeUserName(friend).split(' ')[0]}</span>
<span onClick={() => removeInvited(id)} style={{ fontSize: 14, color: '#bbb', cursor: 'pointer' }}>×</span>
</div>
);
})}
<button style={{ padding: '6px 14px', borderRadius: 20, border: '1.5px dashed #ccc', background: 'none', fontSize: 12, cursor: 'pointer', color: '#999', fontFamily: 'var(--font-app)' }}>+ ajouter</button>
</div>
<Button
variant="primary"
style={{ width: '100%', padding: 14, fontSize: 15 }}
@@ -1,7 +1,9 @@
import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { matchesQuery } from '../../../shared/utils/search';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'];
@@ -9,9 +11,10 @@ const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'
export function ParticipantsListScreen() {
const navigate = useNavigate();
const { eventId } = useParams();
const { getEvent, getEventParticipants } = useFestipodData();
const { getEvent, getEventParticipants, getEventReadState } = useFestipodData();
const event = eventId ? getEvent(eventId) : undefined;
const participants = eventId ? getEventParticipants(eventId) : [];
const readState = eventId ? getEventReadState(eventId) : 'unavailable';
// The displayed NUMBER of participants comes ONLY from `event.participantCount`
// (the owner-derived, single source of truth — Option B), never from a local
@@ -21,12 +24,15 @@ export function ParticipantsListScreen() {
const totalCount = event ? event.participantCount : 0;
const unknownCount = Math.max(0, totalCount - participants.length);
const rows = [
const [query, setQuery] = useState('');
const allRows = [
...participants.map((p, i) => ({
key: p.id,
initials: safeUserInitials(p),
name: safeUserName(p),
username: safeUserUsername(p),
photo: p.photo,
color: COLORS[i % COLORS.length] ?? '#888',
known: true,
})),
@@ -35,11 +41,29 @@ export function ParticipantsListScreen() {
initials: '?',
name: '',
username: '',
photo: undefined as string | undefined,
color: '#ccc',
known: false,
})),
];
// An active search narrows to the named people: an anonymous row has nothing to match.
const rows = query.trim() ? allRows.filter(r => r.known && matchesQuery(query, r.name, r.username)) : allRows;
if (!event) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Participants"
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text>{readState === 'loading' ? 'Chargement des participants…' : 'Événement non trouvé'}</Text>
</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
@@ -48,10 +72,15 @@ export function ParticipantsListScreen() {
/>
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher un participant..." />
<Input placeholder="Rechercher un participant..." value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{rows.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32 }}>
{query.trim() ? 'Aucun participant ne correspond à votre recherche.' : 'Aucun participant pour le moment.'}
</Text>
)}
{rows.map((p) => (
<div
key={p.key}
@@ -65,7 +94,7 @@ export function ParticipantsListScreen() {
borderBottom: '1px solid #f5f5f5',
}}
>
<Avatar initials={p.initials} color={p.color} size="sm" />
<Avatar initials={p.initials} photo={p.photo} color={p.color} size="sm" />
<div style={{ flex: 1 }}>
{p.known ? (
<>
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
import { Header, Text, Input, Button, ImagePicker, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { eventPath, useNavigate, useParams } from '../../../app/router';
@@ -23,6 +23,7 @@ export function UpdateEventScreen() {
const [startTime, setStartTime] = useState(event?.startTime ?? '');
const [endTime, setEndTime] = useState(event?.endTime ?? '');
const [location, setLocation] = useState(event?.location ?? '');
const [coverImage, setCoverImage] = useState(event?.coverImage ?? '');
const [description, setDescription] = useState(event?.description ?? '');
const save = () => {
@@ -43,6 +44,7 @@ export function UpdateEventScreen() {
endTime,
location,
description,
coverImage: coverImage || undefined,
}))
.then(() => {
showToast('Événement mis à jour', 'success');
@@ -99,11 +101,9 @@ export function UpdateEventScreen() {
/>
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
<Placeholder
height={140}
label="Photo de couverture"
style={{ marginBottom: 20, cursor: 'pointer' }}
/>
<div style={{ marginBottom: 20 }}>
<ImagePicker value={coverImage} label="Photo de couverture" onChange={setCoverImage} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
@@ -7,7 +7,11 @@ import {
attendeesCollectionUri,
contextLink,
createResource,
ensureParticipationSecret,
linkId,
MEMBER_URN_PREFIX,
memberUrnOf,
participationSecretOf,
podLogin,
postOutboxActivity,
readAttendees,
@@ -267,26 +271,41 @@ Given(
When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
// The app's own path (src/shared/data/outbox.ts): as:Join to the user's own
// outbox, `object` = the meeting point URI and `to` = the HOST (the record's
// dc:creator). Without the addressee the backend silently drops the activity;
// and an activity carrying no routing target never reaches the host.
const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;
await postOutboxActivity(pod, 'Join', mpUri, host);
// The app's own sequence (refineData.tsx, src/shared/data/outbox.ts —
// decision_2026-10-29_participation-privacy-mechanism): the participation
// secret on my profile first (the backend refuses a Join whose author's
// profile carries none), then an as:Join naming the meeting point, posted to
// my OWN outbox with NO addressee — not public, not addressed to the creator.
await ensureParticipationSecret(pod);
await postOutboxActivity(pod, 'Join', mpUri);
});
When('la session quitte le point de rencontre', async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;
await postOutboxActivity(pod, 'Leave', mpUri, host);
await postOutboxActivity(pod, 'Leave', mpUri);
});
/**
* The session's own member URN at the scenario's event: what the attendee list
* records for it — computed from the secret its OWN profile carries (the person
* reads their secret; nobody hands it over), and the anchor event the meeting
* point names.
*/
async function ownMemberUrn(world: FestipodWorld): Promise<string> {
const pod = session(world);
const profileUri = requires(world, linkId((await readResource(pod, pod.webId))['url']), "the session's profile (actor url)");
const secret = requires(world, participationSecretOf(await readResource(pod, profileUri)), "the session's participation secret");
return memberUrnOf(pod.webId, requires(world, world.apods.anchorUri, 'anchor URI'), secret);
}
Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
expect(members.map(linkId)).to.include(pod.webId);
const urn = await ownMemberUrn(this);
const members = (await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn))).map(linkId);
expect(members, 'the member URN derived from my secret is listed').to.include(urn);
expect(members, 'the list never holds a WebID').to.not.include(pod.webId);
});
Then(
@@ -294,8 +313,10 @@ Then(
async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => !found.map(linkId).includes(pod.webId));
expect(members.map(linkId)).to.not.include(pod.webId);
const urn = await ownMemberUrn(this);
const members = (await waitForMembers(pod, collectionUri, found => !found.map(linkId).includes(urn))).map(linkId);
expect(members).to.not.include(urn);
expect(members).to.not.include(pod.webId);
},
);
@@ -304,25 +325,28 @@ Then(
Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
const urn = await ownMemberUrn(this);
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));
const collection = await readResource(pod, collectionUri);
// The count is the collection total — kept as-is, never the number of
// RESOLVED profiles, so it stays total for a reader who resolves nobody.
const raw = collection.totalItems ?? collection['as:totalItems'];
const total = raw === undefined ? members.length : Number(raw);
expect(total, 'the count reflects the joined session').to.be.greaterThan(0);
expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId);
expect(members.map(linkId), 'the counted session is the one listed (its member URN)').to.include(urn);
});
Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
const joined = members.find(member => linkId(member) === pod.webId);
const urn = await ownMemberUrn(this);
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));
const joined = members.find(member => linkId(member) === urn);
expect(joined, 'the joined session is listed').to.not.be.undefined;
// "Anonymous" is the read-side fact: the member travels as a REFERENCE with no
// profile the reader can resolve — the list has nothing but the URI to render,
// which is exactly what the app shows for a participant it cannot dereference.
// "Anonymous" is the read-side fact: the member is a member URN — a value in
// no pod, no WebID, nothing to dereference — so a reader who cannot compute
// it from a contact's secret has nothing but the count to render.
expect(members.map(linkId).every(id => typeof id === 'string' && id.startsWith(MEMBER_URN_PREFIX)), 'every member is a member URN').to.be.true;
const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];
const hasProfile =
typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);
+17 -4
View File
@@ -9,23 +9,36 @@ import { getBackendUrl } from '../../shared/utils/runtimeConfig';
* Connected mode only — without an ActivityPods session (demo) or a configured
* backend the catalog is never called and the list is `own` unchanged.
*/
export function useDiscoverEvents(own: FpEventData[]): FpEventData[] {
export function useDiscoverEvents(own: FpEventData[]): { events: FpEventData[]; loading: boolean } {
const session = useCurrentSession();
const backendUrl = getBackendUrl();
const connected = !!session && !!backendUrl;
const [catalog, setCatalog] = useState<FpEventData[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!connected || !backendUrl) {
setCatalog([]);
setLoading(false);
return;
}
const controller = new AbortController();
void fetchCatalogEvents(backendUrl, controller.signal).then(events => {
if (!controller.signal.aborted) setCatalog(events);
setLoading(true);
void fetchCatalogEvents(backendUrl, { signal: controller.signal }).then(events => {
if (controller.signal.aborted) return;
setCatalog(events);
setLoading(false);
}).catch((err: unknown) => {
// An unreachable/failing catalog must not hold the list in "loading"
// forever: the screen falls back to the user's own events, empty catalog.
if (controller.signal.aborted) return;
console.error('[useDiscoverEvents] catalog fetch failed:', err);
setCatalog([]);
setLoading(false);
});
return () => controller.abort();
}, [connected, backendUrl]);
return useMemo(() => unionEvents(own, catalog), [own, catalog]);
const events = useMemo(() => unionEvents(own, catalog), [own, catalog]);
return { events, loading };
}
+22 -57
View File
@@ -1,16 +1,7 @@
import { useState } from 'react';
import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, Spinner, type MeetingPointData } from '../../../shared/components/sketchy';
import { Title, Text, Card, BottomNav, EventCover, EventMeetingPoints, Spinner, type MeetingPointData } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { usePendingQueries } from '../../../shared/data/pendingQueries';
import { useNavigate } from '../../../app/router';
const PEOPLE = [
{ name: 'Marie Leroy', color: '#E8590C' },
{ name: 'Jean Morel', color: '#2B6CB0' },
{ name: 'Alice Duval', color: '#9C4DC7' },
{ name: 'Thomas Bazin', color: '#38A169' },
{ name: 'Camille Noir', color: '#D69E2E' },
];
import { useNavigate, eventPath } from '../../../app/router';
const EVENT_COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E'];
@@ -19,24 +10,21 @@ function EventCardBody({
joinedIds,
onToggle,
onClick,
isOngoing = false,
}: {
event: { id: string; title: string; date: string; location: string; meetingPoints?: MeetingPointData[] };
event: { id: string; title: string; date: string; location: string; coverImage?: string; meetingPoints?: MeetingPointData[] };
joinedIds: Set<string>;
onToggle: (id: string) => void;
onClick: () => void;
isOngoing?: boolean;
}) {
const color = EVENT_COLORS[Number(event.id.replace(/\D/g, '') || 0) % EVENT_COLORS.length] ?? '#E8590C';
const borderStyle = isOngoing ? '2px solid #c6f6d5' : undefined;
const meetingPoints = event.meetingPoints ?? [];
return (
<Card
onClick={onClick}
style={{ marginBottom: 14, padding: 0, overflow: 'hidden', border: borderStyle }}
style={{ marginBottom: 14, padding: 0, overflow: 'hidden' }}
accentColor={color}
>
<EventCover eventId={event.id} height={110} borderRadius={0} />
<EventCover url={event.coverImage} height={110} borderRadius={0} />
<div style={{ padding: 14 }}>
<div className="user-content" style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>
{event.title}
@@ -44,12 +32,6 @@ function EventCardBody({
<div style={{ fontSize: 12.5, color: '#888', marginBottom: 10 }}>
{event.date} · {event.location}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<AvatarStack people={PEOPLE} size={26} />
<span style={{ fontSize: 12, color: '#666' }}>
{PEOPLE.length} {isOngoing ? 'connexions présentes' : 'connexions'}
</span>
</div>
{meetingPoints.length > 0 && (
<EventMeetingPoints
@@ -65,29 +47,27 @@ function EventCardBody({
export function HomeScreen() {
const navigate = useNavigate();
const { getUserEvents, currentUserId, getEventMeetingPoints } = useFestipodData();
const { getUserEvents, currentUserId, getEventMeetingPoints, isParticipating, joinMeetingPoint, leaveMeetingPoint } = useFestipodData();
const pendingQueries = usePendingQueries();
const [joinedIds, setJoinedIds] = useState<Set<string>>(new Set());
const myEvents = getUserEvents(currentUserId);
const ongoing = myEvents.slice(0, 1);
const upcoming = myEvents.slice(1, 4);
const upcoming = myEvents.slice(0, 4);
const joinedIds = new Set(
myEvents.flatMap(e => getEventMeetingPoints(e.id)).filter(mp => isParticipating(mp.id)).map(mp => String(mp.id)),
);
const toggle = (id: string) => {
setJoinedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const failed = (err: unknown) => console.error('[Home] meeting point participation failed:', err);
const action = joinedIds.has(id) ? leaveMeetingPoint : joinMeetingPoint;
void Promise.resolve(action(id)).catch(failed);
};
const withMeetingPoints = (event: { id: string; title: string; date: string; location: string }) => ({
const withMeetingPoints = (event: { id: string; title: string; date: string; location: string; coverImage?: string }) => ({
...event,
meetingPoints: getEventMeetingPoints(event.id).map(mp => ({
id: mp.id,
title: mp.location,
when: mp.time,
duration: '~60 min',
title: mp.title,
when: mp.time || undefined,
lieu: mp.location,
})),
});
@@ -123,26 +103,11 @@ export function HomeScreen() {
</div>
<div style={{ padding: '0 16px' }}>
{ongoing.length > 0 && (
<>
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, color: '#22543D', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#38A169', display: 'inline-block' }} />
En cours
</div>
{ongoing.map(event => (
<EventCardBody
key={event.id}
event={withMeetingPoints(event)}
joinedIds={joinedIds}
onToggle={toggle}
onClick={() => navigate(`/events/${event.id}`)}
isOngoing
/>
))}
</>
{upcoming.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32, lineHeight: 1.5 }}>
Vous n'avez pas encore d'événement. Relayez-en un, ou découvrez ceux des autres dans l'onglet Découvrir.
</Text>
)}
{upcoming.length > 0 && (
<>
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, color: '#E8590C', margin: '20px 0 10px' }}>
@@ -155,7 +120,7 @@ export function HomeScreen() {
event={withMeetingPoints(event)}
joinedIds={joinedIds}
onToggle={toggle}
onClick={() => navigate(`/events/${event.id}`)}
onClick={() => navigate(eventPath(event.id))}
/>
))}
</>
@@ -21,5 +21,5 @@ Fonctionnalité: US-16 Indiquer un ou plusieurs points de rencontre
Scénario: Renseigner les détails du point de rencontre
Étant donné que je suis sur la page "points de rencontre"
Alors l'écran contient un champ "Quand"
Et l'écran contient un champ "Durée"
Alors l'écran contient un champ "Titre"
Et l'écran contient un champ "Lieu"
@@ -3,7 +3,7 @@
Fonctionnalité: US-10 Visualiser la fiche/le profil d'un participant
En tant qu'utilisateur
Je peux sélectionner un individu dans la liste des inscrits à un événement/atelier
Afin de voir les événements auxquels la personne a participé et voir un formulaire de contact
Afin de voir les événements auxquels la personne a participé et de pouvoir l'ajouter à mon réseau
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
@@ -21,9 +21,9 @@ Fonctionnalité: US-10 Visualiser la fiche/le profil d'un participant
Étant donné que je suis sur la page "profil utilisateur"
Alors les événements affichent leur localisation et distance
Scénario: Voir le formulaire de contact
Scénario: Ajouter l'utilisateur à mon réseau
Étant donné que je suis sur la page "profil utilisateur"
Alors je peux contacter l'utilisateur
Alors je peux ajouter l'utilisateur à mon réseau
Scénario: Vérifier les informations du profil
* Scénario non implémenté
@@ -3,8 +3,8 @@
Fonctionnalité: US-22 Parrainer un nouvel utilisateur
En tant qu'utilisateur
Je peux parrainer un nouvel utilisateur
En lui partageant mon QR code ou lien de contact
Afin de savoir combien de personnes ont rejoint le réseau grâce à moi
En lui partageant mon lien de contact
Afin d'inviter des personnes à rejoindre le réseau
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
@@ -18,17 +18,11 @@ Fonctionnalité: US-22 Parrainer un nouvel utilisateur
Quand je navigue vers "partage de profil"
Alors je vois l'écran "share-profile"
Scénario: Voir le QR code de parrainage
Scénario: Voir l'invitation de parrainage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient un texte "Scannez pour me retrouver sur Festipod"
Alors l'écran contient un texte "Retrouvez-moi sur Festipod"
Scénario: Voir le lien de parrainage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient une section "Mon lien de profil"
Et l'écran contient un bouton "Copier"
Scénario: Voir les statistiques de parrainage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient une section "Statistiques de parrainage"
Et l'écran contient un texte "Personnes parrainées"
Et l'écran contient un texte "Scans du QR code"
Alors l'écran contient une section "Mon lien d'invitation"
Et l'écran contient un bouton "Créer un lien d'invitation"
@@ -3,7 +3,7 @@
Fonctionnalité: US-23 Me connecter avec d'autres utilisateurs
En tant qu'utilisateur
Je peux me connecter avec d'autres utilisateurs
En partageant mon QR code ou mon lien de contact
En partageant mon lien de contact
Afin d'étendre mon réseau
Contexte:
@@ -13,13 +13,13 @@ Fonctionnalité: US-23 Me connecter avec d'autres utilisateurs
Étant donné que je suis sur la page "mon profil"
Alors l'écran contient une section "Partager"
Scénario: Voir le QR code
Scénario: Voir l'invitation à me retrouver
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient un texte "Scannez pour me retrouver sur Festipod"
Alors l'écran contient un texte "Retrouvez-moi sur Festipod"
Scénario: Voir le lien de partage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient une section "Mon lien de profil"
Alors l'écran contient une section "Mon lien d'invitation"
Scénario: Accéder à l'écran de partage dédié
Étant donné que je suis sur la page "mon profil"
@@ -29,4 +29,5 @@ Fonctionnalité: US-9 Visualiser la photo d'un individu
Et l'écran contient un texte "@mariedupont"
Scénario: Ajouter une photo personnelle sur une fiche existante
* Scénario non implémenté
Étant donné que je suis sur la page "modifier le profil"
Alors l'écran contient un bouton "Changer la photo"
+45 -160
View File
@@ -1,181 +1,66 @@
import React, { useEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Text, Button, showToast } from '../../../shared/components/sketchy';
import { Header, Avatar, Text, Button, Card, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase';
import { useNavigate, useGoBack } from '../../../app/router';
type Mode = 'choice' | 'show' | 'scan';
import { copyText } from '../../../shared/utils/inviteLink';
import { useInviteLink } from '../../../shared/hooks/useInviteLink';
import { useGoBack } from '../../../app/router';
export function ConnectScreen() {
const navigate = useNavigate();
const goBack = useGoBack();
const { currentUser } = useFestipodData();
const [mode, setMode] = useState<Mode>('choice');
const [scanProgress, setScanProgress] = useState(0);
const invite = useInviteLink();
useEffect(() => {
if (mode !== 'scan') return;
setScanProgress(0);
const start = Date.now();
const id = setInterval(() => {
const p = Math.min(1, (Date.now() - start) / 2800);
setScanProgress(p);
if (p >= 1) {
clearInterval(id);
showToast('Connexion établie avec Léa Bernard', 'success');
navigate('/profile/friends');
}
}, 80);
return () => clearInterval(id);
}, [mode, navigate]);
const copy = async () => {
if (!invite.link) return;
showToast((await copyText(invite.link)) ? 'Lien copié' : "Le lien n'a pas pu être copié", 'info');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Se connecter"
left={
<ArrowLeft
size={20}
onClick={() => (mode === 'choice' ? goBack() : setMode('choice'))}
style={{ cursor: 'pointer' }}
/>
}
title="Établir une connexion"
left={<ArrowLeft size={20} onClick={goBack} style={{ cursor: 'pointer' }} />}
/>
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
{mode === 'choice' && (
<Card style={{ textAlign: 'center', padding: 24, marginBottom: 16 }}>
<Avatar initials={safeUserInitials(currentUser)} photo={currentUser?.photo} color="#E8590C" size="lg" />
<Text className="user-content" style={{ fontWeight: 'bold', margin: '12px 0 0 0' }}>{safeUserName(currentUser, '')}</Text>
</Card>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 16, lineHeight: 1.5 }}>
Envoyez ce lien à une personne : elle ouvre votre profil et se connecte en un clic — la connexion est
acceptée automatiquement.
</Text>
{invite.link ? (
<Card style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{ margin: 0, flex: 1, fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#888' }}>
{invite.link}
</Text>
<Button variant="primary" style={{ flexShrink: 0, padding: '8px 10px' }} onClick={copy}>Copier</Button>
</Card>
) : invite.creating || invite.discovering ? (
<Text style={{ fontSize: 13, color: '#888' }}>Création du lien…</Text>
) : invite.unavailable === 'session' ? (
<Text style={{ fontSize: 13, color: '#888' }}>
Lien d'invitation indisponible : il nécessite une session connectée à votre Pod.
</Text>
) : invite.error ? (
<>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 16, lineHeight: 1.5 }}>
Pour vous connecter, scannez le QR code de l'autre personne ou affichez le vôtre pour qu'elle le scanne.
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Button variant="primary" style={{ padding: 16, fontSize: 15 }} onClick={() => setMode('show')}>
Afficher mon QR code
</Button>
<Button style={{ padding: 16, fontSize: 15 }} onClick={() => setMode('scan')}>
Scanner un QR code
</Button>
</div>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>{invite.error}</Text>
<Button style={{ padding: '8px 10px' }} onClick={invite.create}>Réessayer</Button>
</>
)}
{mode === 'show' && (
<div style={{ textAlign: 'center', paddingTop: 8 }}>
<div
style={{
width: 220,
height: 220,
margin: '0 auto 16px',
border: '2px solid #e0e0e0',
borderRadius: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
position: 'relative',
}}
>
<div
style={{
width: 190,
height: 190,
background: `
linear-gradient(90deg, #1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%),
linear-gradient(#1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%)
`,
backgroundSize: '17px 17px',
opacity: 0.85,
borderRadius: 8,
}}
/>
<div
style={{
position: 'absolute',
background: '#fff',
padding: 4,
borderRadius: '50%',
}}
>
<Avatar initials={safeUserInitials(currentUser)} color="#E8590C" size="sm" />
</div>
</div>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{safeUserName(currentUser, '')}</Text>
<Text style={{ color: '#888', margin: 0, fontSize: 13 }}>
Montrez ce QR code pour permettre à l'autre personne de vous connecter.
) : invite.unavailable === 'frontend' ? (
<>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>
Lien d'invitation indisponible : le frontend de votre Pod est introuvable.
</Text>
</div>
)}
{mode === 'scan' && (
<div style={{ textAlign: 'center', paddingTop: 8 }}>
<div
style={{
width: 240,
height: 240,
margin: '0 auto 16px',
background: '#111',
borderRadius: 16,
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
inset: 24,
border: '2px solid rgba(255,255,255,0.8)',
borderRadius: 10,
}}
/>
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
const base: React.CSSProperties = {
position: 'absolute',
width: 22,
height: 22,
border: '3px solid #E8590C',
};
const pos: React.CSSProperties =
corner === 'tl' ? { top: 18, left: 18, borderRight: 'none', borderBottom: 'none' }
: corner === 'tr' ? { top: 18, right: 18, borderLeft: 'none', borderBottom: 'none' }
: corner === 'bl' ? { bottom: 18, left: 18, borderRight: 'none', borderTop: 'none' }
: { bottom: 18, right: 18, borderLeft: 'none', borderTop: 'none' };
return <div key={corner} style={{ ...base, ...pos }} />;
})}
<div
style={{
position: 'absolute',
left: 24,
right: 24,
top: `calc(24px + (100% - 48px) * ${(Math.sin(scanProgress * Math.PI * 2) + 1) / 2})`,
height: 2,
background: 'linear-gradient(90deg, transparent, #E8590C, transparent)',
boxShadow: '0 0 12px #E8590C',
}}
/>
</div>
<Text style={{ fontSize: 13, color: '#888', margin: 0 }}>
Alignez le QR code dans le cadre…
</Text>
<div
style={{
marginTop: 16,
height: 4,
background: '#f0f0f0',
borderRadius: 2,
overflow: 'hidden',
}}
>
<div
style={{
width: `${scanProgress * 100}%`,
height: '100%',
background: '#E8590C',
transition: 'width 0.08s linear',
}}
/>
</div>
</div>
<Button style={{ padding: '8px 10px' }} onClick={() => window.location.reload()}>Réessayer</Button>
</>
) : (
<Button variant="primary" onClick={invite.create}>Créer un lien d'invitation</Button>
)}
</div>
</div>
+24 -3
View File
@@ -1,7 +1,9 @@
import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Avatar, Input, Button, BottomNav } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { matchesQuery } from '../../../shared/utils/search';
import { useNavigate, userPath } from '../../../app/router';
const COLORS = ['#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E', '#E8590C'];
@@ -10,6 +12,8 @@ export function FriendsListScreen() {
const navigate = useNavigate();
const { getFriends } = useFestipodData();
const friends = getFriends();
const [query, setQuery] = useState('');
const shown = friends.filter(f => matchesQuery(query, safeUserName(f), safeUserUsername(f)));
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -19,11 +23,28 @@ export function FriendsListScreen() {
/>
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher..." />
<Input placeholder="Rechercher..." value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{friends.map((person, i) => (
{friends.length === 0 && (
<div style={{ textAlign: 'center', padding: '32px 24px' }}>
<Text style={{ color: '#888', lineHeight: 1.5 }}>
Votre réseau est vide. Partagez votre lien de profil, ou ouvrez le profil d'un participant pour lui envoyer une demande.
</Text>
<Button
variant="primary"
style={{ marginTop: 16 }}
onClick={() => navigate('/profile/connect')}
>
Établir une connexion
</Button>
</div>
)}
{friends.length > 0 && shown.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32 }}>Aucun contact ne correspond à votre recherche.</Text>
)}
{shown.map((person, i) => (
<div
key={person.id}
onClick={() => navigate(userPath(person.id))}
@@ -36,7 +57,7 @@ export function FriendsListScreen() {
borderBottom: '1px solid #f5f5f5',
}}
>
<Avatar initials={safeUserInitials(person)} color={COLORS[i % COLORS.length]} size="sm" />
<Avatar initials={safeUserInitials(person)} photo={person.photo} color={COLORS[i % COLORS.length]} size="sm" />
<div style={{ flex: 1 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{safeUserName(person)}</Text>
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>
+11 -29
View File
@@ -1,4 +1,4 @@
import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav, Tag } from '../../../shared/components/sketchy';
import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate } from '../../../app/router';
@@ -19,9 +19,11 @@ export function ProfileScreen() {
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={safeUserInitials(user)} color="#E8590C" size="lg" />
<Avatar initials={safeUserInitials(user)} photo={user?.photo} color="#E8590C" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{safeUserName(user, '')}</Title>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{safeUserUsername(user)}</Text>
{user?.city && <Text className="user-content" style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>📍 {user.city}</Text>}
{user?.bio && <Text className="user-content" style={{ margin: '12px 0 0', fontSize: 14 }}>{user.bio}</Text>}
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginTop: 20 }}>
<div style={{ textAlign: 'center' }}>
@@ -32,10 +34,12 @@ export function ProfileScreen() {
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user?.friendsCount ?? friends.length}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Amis</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user?.participationsCount ?? 0}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
{user?.participationsCount !== undefined && (
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user.participationsCount}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'center' }}>
@@ -46,28 +50,6 @@ export function ProfileScreen() {
<Divider />
<div style={{
margin: '12px 16px',
padding: '14px 16px',
background: 'linear-gradient(135deg, #FFF7ED, #FFFBF5)',
borderRadius: 16,
border: '1px solid #FDDCB5',
}}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, color: '#C05621', marginBottom: 8 }}>
Mes intentions
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
<Tag label="gouvernance coopérative" />
<Tag label="communs numériques" />
<Tag label="habitat participatif" color="#4a3000" bg="#e8f5e9" />
<span style={{ fontSize: 20, cursor: 'pointer', color: '#C05621', lineHeight: 1 }}>
+
</span>
</div>
</div>
<Divider />
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mes événements à venir</Text>
{myEvents.slice(0, 3).map((event) => (
@@ -77,7 +59,7 @@ export function ProfileScreen() {
</Card>
))}
<Button style={{ width: '100%' }} onClick={() => navigate('/events')}>
Voir tous les événements
Découvrir des événements
</Button>
</div>
+66 -69
View File
@@ -1,15 +1,33 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Button, Card, Divider, Avatar } from '../../../shared/components/sketchy';
import { Header, Text, Button, Card, Divider, Avatar, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase';
import { copyText } from '../../../shared/utils/inviteLink';
import { useInviteLink } from '../../../shared/hooks/useInviteLink';
import { useNavigate } from '../../../app/router';
export function ShareProfileScreen() {
const navigate = useNavigate();
const { currentUser } = useFestipodData();
const user = currentUser;
const shareHandle = safeUserUsername(user).replace('@', '');
const profileLink = shareHandle ? `festipod.app/u/${shareHandle}` : '';
const invite = useInviteLink();
const copy = async () => {
if (!invite.link) return;
showToast((await copyText(invite.link)) ? 'Lien copié' : "Le lien n'a pas pu être copié", 'info');
};
const share = async () => {
if (!invite.link) return;
if (typeof navigator.share === 'function') {
try {
await navigator.share({ title: 'Rejoignez mon réseau sur Festipod', url: invite.link });
} catch {
// Dismissed by the person — nothing to report.
}
return;
}
await copy();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -20,79 +38,58 @@ export function ShareProfileScreen() {
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
<Card style={{ textAlign: 'center', padding: 24 }}>
<div style={{
width: 180,
height: 180,
margin: '0 auto 16px',
border: '2px solid #e0e0e0',
borderRadius: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
position: 'relative',
}}>
<div style={{
width: 150,
height: 150,
background: `
linear-gradient(90deg, #1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%),
linear-gradient(#1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%)
`,
backgroundSize: '15px 15px',
opacity: 0.8,
borderRadius: 8,
}} />
<div style={{
position: 'absolute',
background: '#fff',
padding: 4,
borderRadius: '50%',
}}>
<Avatar initials={safeUserInitials(user)} color="#E8590C" size="sm" />
</div>
</div>
<Avatar initials={safeUserInitials(user)} photo={user?.photo} color="#E8590C" size="lg" />
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{safeUserName(user, '')}</Text>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '12px 0 4px 0' }}>{safeUserName(user, '')}</Text>
<Text style={{ color: '#888', margin: 0, fontSize: 14 }}>
Scannez pour me retrouver sur Festipod
Retrouvez-moi sur Festipod
</Text>
</Card>
<Divider />
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mon lien de profil</Text>
<Card style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{
margin: 0,
flex: 1,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#888',
}}>
{profileLink}
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mon lien d'invitation</Text>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 12, lineHeight: 1.5 }}>
La personne ouvre le lien, voit votre profil et se connecte en un clic — la connexion est acceptée
automatiquement.
</Text>
{invite.link ? (
<Card style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{
margin: 0,
flex: 1,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#888',
}}>
{invite.link}
</Text>
<Button style={{ flexShrink: 0, padding: '8px 10px' }} title="Copier le lien" onClick={copy}>📋</Button>
<Button variant="primary" style={{ flexShrink: 0, padding: '8px 10px' }} title="Partager" onClick={share}>↗</Button>
</Card>
) : invite.creating || invite.discovering ? (
<Text style={{ fontSize: 13, color: '#888' }}>Création du lien…</Text>
) : invite.unavailable === 'session' ? (
<Text style={{ fontSize: 13, color: '#888' }}>
Lien d'invitation indisponible : il nécessite une session connectée à votre Pod.
</Text>
<Button style={{ flexShrink: 0, padding: '8px 10px' }} title="Copier le lien">📋</Button>
<Button variant="primary" style={{ flexShrink: 0, padding: '8px 10px' }} title="Partager">↗</Button>
</Card>
<Divider />
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Statistiques de parrainage</Text>
<Card>
<div style={{ display: 'flex', justifyContent: 'space-around', textAlign: 'center' }}>
<div>
<Text style={{ fontWeight: 'bold', fontSize: 24, margin: 0, color: '#E8590C' }}>12</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Personnes parrainées</Text>
</div>
<div>
<Text style={{ fontWeight: 'bold', fontSize: 24, margin: 0, color: '#E8590C' }}>47</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Scans du QR code</Text>
</div>
</div>
</Card>
) : invite.error ? (
<>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>{invite.error}</Text>
<Button style={{ padding: '8px 10px' }} onClick={invite.create}>Réessayer</Button>
</>
) : invite.unavailable === 'frontend' ? (
<>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>
Lien d'invitation indisponible : le frontend de votre Pod est introuvable.
</Text>
<Button style={{ padding: '8px 10px' }} onClick={() => window.location.reload()}>Réessayer</Button>
</>
) : (
<Button variant="primary" onClick={invite.create}>Créer un lien d'invitation</Button>
)}
</div>
</div>
);
@@ -3,8 +3,9 @@ import { Header, Text, Input, Button, Avatar, showToast } from '../../../shared/
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserInitials } from '../../../shared/context/festipodDataBase';
import { useNavigate } from '../../../app/router';
import { useUploadImage } from '../../../shared/hooks/useUploadImage';
export function UpdateProfileScreen() {
export function UpdateProfileScreen({ onboarding = false }: { onboarding?: boolean } = {}) {
const navigate = useNavigate();
const { currentUser, updateProfile } = useFestipodData();
const user = currentUser;
@@ -13,19 +14,36 @@ export function UpdateProfileScreen() {
const [firstName, setFirstName] = useState(nameParts[0] ?? '');
const [lastName, setLastName] = useState(nameParts.slice(1).join(' '));
const [username, setUsername] = useState(user?.username ?? '');
const [city, setCity] = useState(user?.city ?? 'Lyon, France');
const [city, setCity] = useState(user?.city ?? '');
const [bio, setBio] = useState(user?.bio ?? '');
const [photo, setPhoto] = useState(user?.photo ?? '');
const { upload, uploading } = useUploadImage();
const fileInput = React.useRef<HTMLInputElement>(null);
const onPhoto = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
upload(file).then(setPhoto).catch((err: unknown) => {
console.error('[UpdateProfile] photo upload failed:', err);
showToast("La photo n'a pas pu être envoyée", 'error');
});
};
const handleSave = () => {
const fullName = `${firstName} ${lastName}`.trim();
if (!fullName) {
showToast('Indiquez au moins votre prénom ou votre nom', 'error');
return;
}
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
// The edit REJECTS when no profile of mine is resolved — it refuses to write
// into someone else's document. So confirm and leave the screen only once the
// write has settled, never before.
void Promise.resolve(updateProfile({ name: fullName, initials, username, city, bio }))
void Promise.resolve(updateProfile({ name: fullName, initials, username, city, bio, photo }))
.then(() => {
showToast('Profil mis à jour', 'success');
navigate('/profile');
if (!onboarding) navigate('/profile');
})
.catch((err: unknown) => {
console.error('[UpdateProfile] profile write failed:', err);
@@ -36,16 +54,23 @@ export function UpdateProfileScreen() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier le profil"
left={<span onClick={() => navigate('/profile')} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
title={onboarding ? 'Complétez votre profil' : 'Modifier le profil'}
left={onboarding ? undefined : <span onClick={() => navigate('/profile')} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
/>
{onboarding && (
<div style={{ padding: '12px 16px', background: '#FFF7ED', color: '#C05621', fontSize: 13, lineHeight: 1.5 }}>
Votre Pod ne fournit pas encore de nom pour votre profil. Renseignez-le pour que les autres vous reconnaissent.
</div>
)}
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Avatar initials={safeUserInitials(user)} color="#E8590C" size="lg" />
<Button style={{ marginTop: 12 }}>
Changer la photo
<Avatar initials={safeUserInitials(user)} photo={photo || undefined} color="#E8590C" size="lg" />
<Button style={{ marginTop: 12 }} onClick={() => fileInput.current?.click()} disabled={uploading}>
{uploading ? 'Envoi…' : 'Changer la photo'}
</Button>
<input ref={fileInput} type="file" accept="image/*" onChange={onPhoto} style={{ display: 'none' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
+86 -70
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider } from '../../../shared/components/sketchy';
import { Header, Avatar, Title, Text, Button, Card, Divider, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useGoBack, useParams } from '../../../app/router';
@@ -8,19 +9,41 @@ export function UserProfileScreen() {
const navigate = useNavigate();
const goBack = useGoBack();
const { userId } = useParams();
const { users, currentUserId, getUser, getUserEvents, addFriend, getFriends } = useFestipodData();
const { currentUserId, getUser, getUserEvents, openUserEvents, addFriend, getFriends } = useFestipodData();
// Someone else's events are read only when asked for: this screen asks.
useEffect(() => {
if (userId) openUserEvents(userId);
}, [userId, openUserEvents]);
const viewedUser = userId ? getUser(userId) : users.find(u => u.id !== currentUserId);
const viewedUser = userId ? getUser(userId) : undefined;
const friends = getFriends();
const isFriend = viewedUser ? friends.some(f => f.id === viewedUser.id) : false;
const [requested, setRequested] = useState(false);
const [sending, setSending] = useState(false);
const isMe = viewedUser?.id === currentUserId;
// A profile renders only for me and for my contacts; for both the full
// events list is answerable — the catalog declares a contact's public events,
// and their participations collection (read once asked, above) lists where
// they participate. For anyone else the data layer answers [], and this
// screen never renders them anyway.
const userEvents = viewedUser ? getUserEvents(viewedUser.id) : [];
const pastEvents = [
{ title: 'Forum Ouvert Transition', date: '22 jan.', location: "Tiers-lieu L'Hermitage", distance: 89, common: true },
{ title: 'Rencontre des Colibris', date: '12 jan.', location: "La Maison de l'Environnement", distance: 7, common: true },
{ title: 'Formation CNV', date: '8 jan.', location: 'MJC Montplaisir, Lyon', distance: 5, common: false },
{ title: 'Café des possibles', date: '15 déc.', location: 'Café de la Mairie, Lyon 3', distance: 2, common: false },
];
if (!viewedUser) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Profil"
left={<ArrowLeft size={20} onClick={goBack} style={{ cursor: 'pointer' }} />}
/>
<div style={{ flex: 1, padding: 24, textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold' }}>Profil inconnu ou non partagé</Text>
<Text style={{ color: '#888', fontSize: 14, lineHeight: 1.5 }}>
Cette personne n'a pas partagé son profil avec vous : il n'est lisible que par ses contacts.
</Text>
</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -31,40 +54,73 @@ export function UserProfileScreen() {
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={safeUserInitials(viewedUser)} color="#2B6CB0" size="lg" />
<Avatar initials={safeUserInitials(viewedUser)} photo={viewedUser?.photo} color="#2B6CB0" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{safeUserName(viewedUser, viewedUser?.id === currentUserId ? '' : undefined)}</Title>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{safeUserUsername(viewedUser)}</Text>
{viewedUser?.city && <Text className="user-content" style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>📍 {viewedUser.city}</Text>}
{viewedUser?.bio && <Text className="user-content" style={{ margin: '12px 0 0', fontSize: 14 }}>{viewedUser.bio}</Text>}
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginTop: 20 }}>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser?.eventsCount ?? userEvents.length}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Événements</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser?.friendsCount ?? 23}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Contacts</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser?.participationsCount ?? 42}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
{/* The events count shows for me always; for a contact only when it
is a real answer — a bare 0 claims "they do nothing", which a
participations collection not read (yet, or at all) cannot prove. */}
{(isMe || userEvents.length > 0) && (
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{userEvents.length}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Événements</Text>
</div>
)}
{/* Someone else's contact count is not readable (their apods:contacts
is theirs only) — a real count for me, nothing claimed for others. */}
{isMe && (
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{friends.length}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Contacts</Text>
</div>
)}
{isMe && viewedUser?.participationsCount !== undefined && (
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser.participationsCount}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'center' }}>
<Button
variant={isFriend ? 'default' : 'primary'}
onClick={() => viewedUser && addFriend(viewedUser.id)}
>
{isFriend ? '✓ Dans mon réseau' : 'Ajouter au réseau'}
</Button>
<Button>Contacter</Button>
{!isMe && (
<Button
variant={isFriend || requested ? 'default' : 'primary'}
disabled={isFriend || requested || sending}
onClick={() => {
if (!viewedUser || sending) return;
setSending(true);
void Promise.resolve(addFriend(viewedUser.id))
.then(() => {
setRequested(true);
showToast('Demande envoyée', 'success');
})
.catch((err: unknown) => {
console.error('[UserProfile] contact request failed:', err);
showToast("La demande n'a pas pu être envoyée", 'error');
})
.finally(() => setSending(false));
}}
>
{isFriend ? '✓ Dans mon réseau' : requested ? 'Demande envoyée' : 'Ajouter au réseau'}
</Button>
)}
</div>
</div>
<Divider />
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements à venir</Text>
{userEvents.length > 0 && (
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements</Text>
)}
{userEvents.length === 0 && (
<Text style={{ fontSize: 13, color: '#888', margin: 0 }}>Aucun événement visible.</Text>
)}
{userEvents.map((event) => (
<Card key={event.id} onClick={() => navigate(eventPath(event.id))} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
@@ -76,50 +132,10 @@ export function UserProfileScreen() {
{event.distance != null && ` · ${event.distance} km`}
</Text>
</div>
<Badge style={{ background: '#FFF7ED', color: '#E8590C' }}>moi aussi</Badge>
</div>
</Card>
))}
</div>
<Divider />
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements passés</Text>
{pastEvents.map((event, i) => (
<Card key={i} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Text style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
<Text style={{ margin: '4px 0 0 0', fontSize: 13, color: '#888' }}>{event.date}</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 13, color: '#888' }}>
📍 {event.location} · {event.distance} km
</Text>
</div>
{event.common && <Badge style={{ background: '#FFF7ED', color: '#E8590C' }}>moi aussi</Badge>}
</div>
</Card>
))}
</div>
<Divider />
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Envoyer un message</Text>
<div style={{
border: '1.5px solid #e0e0e0',
borderRadius: 12,
padding: 12,
minHeight: 80,
fontSize: 14,
color: '#bbb',
}}>
Écrivez votre message ici...
</div>
<Button variant="primary" style={{ width: '100%', marginTop: 12 }}>
Envoyer
</Button>
</div>
</div>
</div>
);
+12 -26
View File
@@ -41,13 +41,13 @@ Then('l\'écran affiche les informations du profil', async function (this: Festi
expect(avatar, 'Profile screen should render an avatar').to.not.be.null;
});
Then('je peux contacter l\'utilisateur', async function (this: FestipodWorld) {
Then('je peux ajouter l\'utilisateur à mon réseau', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('user-profile');
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');
expect(buttons.some(t => t.includes('Contacter')),
'User profile should expose a "Contacter" button').to.be.true;
expect(buttons.some(t => /Ajouter au réseau|Dans mon réseau|Demande envoyée/.test(t)),
'User profile should expose the "Ajouter au réseau" action (or its resulting state)').to.be.true;
});
Then('je peux voir les événements auxquels l\'utilisateur a participé', async function (this: FestipodWorld) {
@@ -55,10 +55,8 @@ Then('je peux voir les événements auxquels l\'utilisateur a participé', async
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
expect(text.includes('Événements à venir'),
'User profile should have "Événements à venir" section').to.be.true;
expect(text.includes('Événements passés'),
'User profile should have "Événements passés" section').to.be.true;
expect(text.includes('Événements'),
'User profile should have the "Événements" section').to.be.true;
});
Then('les événements affichent leur localisation et distance', async function (this: FestipodWorld) {
@@ -70,30 +68,18 @@ Then('les événements affichent leur localisation et distance', async function
expect(/\d+\s*km/.test(text), 'Events should show a distance in km').to.be.true;
});
Then('je peux voir le QR code', async function (this: FestipodWorld) {
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
if (this.currentScreenId === 'share-profile') {
expect(text.includes('Scannez pour me retrouver'),
'Share profile should invite to scan for follow-up').to.be.true;
} else if (this.currentScreenId === 'meeting-points') {
expect(text.includes('QR') || text.includes('Scannez'),
'Meeting points should reference a QR code').to.be.true;
} else {
expect.fail(`QR code should be on share-profile or meeting-points, not "${this.currentScreenId}"`);
}
});
Then('je peux voir le lien de partage', async function (this: FestipodWorld) {
expect(this.currentScreenId, 'Share link should be on share-profile screen').to.equal('share-profile');
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
expect(text.includes('Mon lien de profil'),
'Share profile should label the profile link section').to.be.true;
expect(/festipod\.app\/u\//.test(text),
'Share profile should display a festipod.app/u/… link').to.be.true;
expect(text.includes("Mon lien d'invitation"),
'Share profile should label the invite link section').to.be.true;
// No connected pod session in the local harness: the invite capability VC
// cannot be issued, so the screen must say it honestly instead of
// displaying a fake address.
expect(text.includes("Lien d'invitation indisponible"),
'Share profile should admit the link is unavailable without a connected pod').to.be.true;
});
function expectProfileDom(
+18 -37
View File
@@ -1,6 +1,10 @@
import { useState } from 'react';
interface AvatarProps {
initials?: string;
name?: string;
/** Picture URL; initials are shown when absent. */
photo?: string;
color?: string;
size?: 'sm' | 'md' | 'lg' | number;
className?: string;
@@ -14,10 +18,15 @@ const sizeMap = {
lg: 56,
};
export function Avatar({ initials, name, color, size = 'md', className = '', online, border }: AvatarProps) {
export function Avatar({ initials, name, photo, color, size = 'md', className = '', online, border }: AvatarProps) {
// A photo URL can be stale or unreadable (best-effort public grant, relative
// URL, pod down). Once a given URL has failed, fall back to initials — keyed
// by the URL itself so a changed photo gets a fresh attempt.
const [failedPhoto, setFailedPhoto] = useState<string | null>(null);
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
const displayInitials = initials || (name ? name.split(' ').map(n => n[0]).join('').slice(0, 2) : '?');
const bg = color || '#999';
const showPhoto = !!photo && /^https?:\/\//i.test(photo) && failedPhoto !== photo;
return (
<div
@@ -30,43 +39,15 @@ export function Avatar({ initials, name, color, size = 'md', className = '', onl
border: border || 'none',
}}
>
{displayInitials}
{showPhoto ? (
<img
src={photo}
alt={name ?? displayInitials}
onError={() => setFailedPhoto(photo)}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
) : displayInitials}
{online && <div className="online-dot" />}
</div>
);
}
interface AvatarStackProps {
people: Array<{ name: string; color: string }>;
size?: number;
}
export function AvatarStack({ people, size = 28 }: AvatarStackProps) {
return (
<div style={{ display: 'flex' }}>
{people.slice(0, 4).map((p, i) => (
<div key={i} style={{ marginLeft: i > 0 ? -8 : 0, zIndex: people.length - i }}>
<Avatar name={p.name} color={p.color} size={size} border="2px solid #fff" />
</div>
))}
{people.length > 4 && (
<div style={{
marginLeft: -8,
width: size,
height: size,
borderRadius: '50%',
background: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 600,
color: '#666',
border: '2px solid #fff',
}}>
+{people.length - 4}
</div>
)}
</div>
);
}
-21
View File
@@ -14,27 +14,6 @@ export function Badge({ children, className = '', style }: BadgeProps) {
);
}
interface TagProps {
label: string;
color?: string;
bg?: string;
className?: string;
}
export function Tag({ label, color, bg, className = '' }: TagProps) {
return (
<span
className={`app-tag ${className}`}
style={{
...(color ? { color } : {}),
...(bg ? { background: bg } : {}),
}}
>
{label}
</span>
);
}
interface RelevanceIconProps {
level?: number;
}
+5 -18
View File
@@ -1,31 +1,22 @@
import React from 'react';
const EVENT_PHOTOS: Record<string, string> = {
'1': 'https://images.unsplash.com/photo-1529119513315-c7c361862fc7?auto=format&fit=crop&w=800&q=70',
'2': 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=70',
'3': 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?auto=format&fit=crop&w=800&q=70',
'4': 'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?auto=format&fit=crop&w=800&q=70',
default: 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?auto=format&fit=crop&w=800&q=70',
};
import { cssUrl } from '../../utils/cssUrl';
interface EventCoverProps {
eventId?: string | number;
/** The event's cover image URL; a neutral placeholder is drawn when absent. */
url?: string;
height?: number;
borderRadius?: number;
style?: React.CSSProperties;
children?: React.ReactNode;
}
export function EventCover({ eventId = 'default', height = 140, borderRadius = 12, style, children }: EventCoverProps) {
const url = EVENT_PHOTOS[String(eventId)] ?? EVENT_PHOTOS.default;
export function EventCover({ url, height = 140, borderRadius = 12, style, children }: EventCoverProps) {
return (
<div
style={{
height,
borderRadius,
backgroundImage: `url(${url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
background: cssUrl(url) ? `center / cover no-repeat ${cssUrl(url)}` : 'linear-gradient(135deg, #f3ece6, #e6edf3)',
position: 'relative',
overflow: 'hidden',
...style,
@@ -35,7 +26,3 @@ export function EventCover({ eventId = 'default', height = 140, borderRadius = 1
</div>
);
}
export function getEventPhotoUrl(eventId: string | number) {
return EVENT_PHOTOS[String(eventId)] ?? EVENT_PHOTOS.default;
}
@@ -3,9 +3,10 @@ import { showToast } from './Toast';
export interface MeetingPointData {
id: string | number;
title: string;
when: string;
duration: string;
/** Optional: a meeting point may be informal and carry no title. */
title?: string;
/** Free-text moment, shown only when the record carries one. */
when?: string;
lieu: string;
/** How many signed up to this point — shown when the caller knows it, omitted otherwise. */
participantCount?: number;
@@ -30,9 +31,9 @@ export function EventMeetingPoints({ points, joinedIds, onToggle, expanded = fal
const handleToggle = (p: MeetingPointData) => {
const willJoin = !joinedIds.has(String(p.id));
onToggle(String(p.id), p.title, willJoin);
onToggle(String(p.id), p.title || p.lieu, willJoin);
showToast(
willJoin ? `Inscription : ${p.title}` : `Désinscription : ${p.title}`,
willJoin ? `Inscription : ${p.title || p.lieu}` : `Désinscription : ${p.title || p.lieu}`,
willJoin ? 'success' : 'info',
);
};
@@ -51,12 +52,18 @@ export function EventMeetingPoints({ points, joinedIds, onToggle, expanded = fal
borderColor: isJoined ? '#c6f6d5' : '#eee',
}}
>
<div style={{ fontSize: 14, fontWeight: 700, color: '#1a1a1a', marginBottom: 4 }}>
{p.title}
</div>
{p.title && (
<div style={{ fontSize: 14, fontWeight: 700, color: '#1a1a1a', marginBottom: 4 }}>
{p.title}
</div>
)}
<div style={{ fontSize: 12, color: '#666', marginBottom: 10 }}>
🕒 {p.when} · {p.duration}
<br />
{p.when && (
<>
🕒 {p.when}
<br />
</>
)}
📍 {p.lieu}
{p.participantCount !== undefined && (
<>
@@ -0,0 +1,51 @@
import React, { useRef } from 'react';
import { showToast } from './Toast';
import { cssUrl } from '../../utils/cssUrl';
import { useUploadImage } from '../../hooks/useUploadImage';
interface ImagePickerProps {
/** Current image URL (preview). */
value?: string;
label: string;
height?: number;
onChange: (url: string) => void;
}
/** A tappable zone that uploads a chosen image to the Pod and reports its URL. */
export function ImagePicker({ value, label, height = 140, onChange }: ImagePickerProps) {
const { upload, uploading } = useUploadImage();
const input = useRef<HTMLInputElement>(null);
const onFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
upload(file)
.then(onChange)
.catch((err: unknown) => {
console.error('[ImagePicker] upload failed:', err);
showToast("L'image n'a pas pu être envoyée", 'error');
});
};
return (
<div
onClick={() => !uploading && input.current?.click()}
style={{
height,
borderRadius: 12,
border: cssUrl(value) ? 'none' : '1.5px dashed #ccc',
background: cssUrl(value) ? `center / cover no-repeat ${cssUrl(value)}` : '#fafafa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontSize: 14,
cursor: 'pointer',
}}
>
{uploading ? 'Envoi…' : cssUrl(value) ? null : label}
<input ref={input} type="file" accept="image/*" onChange={onFile} style={{ display: 'none' }} />
</div>
);
}
@@ -1,26 +0,0 @@
import React from 'react';
interface PlaceholderProps {
width?: string | number;
height?: string | number;
label?: string;
className?: string;
style?: React.CSSProperties;
}
export function Placeholder({
width = '100%',
height = 100,
label = 'Image',
className = '',
style
}: PlaceholderProps) {
return (
<div
className={`app-placeholder ${className}`}
style={{ width, height, ...style }}
>
{label}
</div>
);
}
+4 -4
View File
@@ -2,9 +2,8 @@ export { Button } from './Button';
export { Input } from './Input';
export { Card } from './Card';
export { Title, Subtitle, Text } from './Text';
export { Placeholder } from './Placeholder';
export { Avatar, AvatarStack } from './Avatar';
export { Badge, Tag, RelevanceIcon } from './Badge';
export { Avatar } from './Avatar';
export { Badge, RelevanceIcon } from './Badge';
export { Toggle } from './Toggle';
export { Checkbox } from './Checkbox';
export { ListItem } from './ListItem';
@@ -12,7 +11,8 @@ export { Header } from './Header';
export { NavBar } from './NavBar';
export { BottomNav } from './BottomNav';
export { ToastContainer, showToast } from './Toast';
export { EventCover, getEventPhotoUrl } from './EventCover';
export { EventCover } from './EventCover';
export { ImagePicker } from './ImagePicker';
export { EventMeetingPoints } from './EventMeetingPoints';
export type { MeetingPointData } from './EventMeetingPoints';
export { Divider } from './Divider';
+9 -1
View File
@@ -110,9 +110,17 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
}, [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
// screen announce a creation that did not happen.
return Promise.reject(new Error('addMeetingPoint: nothing is stored in the disconnected demo'));
}, [logPrefix]);
const addFriend = useCallback((_friendId: string) => {
console.log(`${logPrefix} addFriend (local, no-op)`);
return Promise.reject(new Error('addFriend: contacts live on a Pod — nothing is sent in the disconnected demo'));
}, [logPrefix]);
const inviteToEvent = useCallback((_eventId: string, _recipientIds: string[], _message?: string) => {
console.log(`${logPrefix} inviteToEvent (local, no-op)`);
return Promise.reject(new Error('inviteToEvent: invitations are posted to a Pod — nothing is sent in the disconnected demo'));
}, [logPrefix]);
const updateProfile = useCallback((_updates: Partial<FpUserData>) => {
console.log(`${logPrefix} updateProfile (local, no-op)`);
@@ -138,7 +146,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
...queries,
getEventOwnership,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
addMeetingPoint, addFriend, updateProfile, loadTestData,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
+37 -2
View File
@@ -53,6 +53,13 @@ export type EventReadState = 'ready' | 'loading' | 'unavailable';
export interface FestipodDataContextValue {
currentUserId: string;
currentUser: FpUserData | undefined;
/**
* Whether MY profile could be resolved — only the Pod-backed provider knows:
* `'loading'` (still reading), `'ready'` (`currentUser` is defined), `'missing'`
* (the Pod links no readable profile, so nothing of mine can be edited).
* Absent on providers where the question does not arise.
*/
profileStatus?: 'loading' | 'ready' | 'missing';
/**
* WHO THE SESSION SIGNED IN AS — the identity signing in returned, known
* before any document is read. For display (and log attribution) only: it is
@@ -94,6 +101,15 @@ export interface FestipodDataContextValue {
* change: call it from an effect, never during render.
*/
openEvent(eventId: string): void;
/**
* Ask for a person's events — a screen about to show `getUserEvents(userId)`
* for someone else (a profile). A backend that reads a contact's
* participations only on demand reads them from here; `getUserEvents`
* answers with what has landed. Idempotent; a no-op for oneself, for anyone
* who is not a contact, and on backends whose read set is already everything
* they can read. A state change: call it from an effect, never during render.
*/
openUserEvents(userId: string): void;
/** Where `getEvent(eventId)` stands — see {@link EventReadState}. */
getEventReadState(eventId: string): EventReadState;
@@ -115,8 +131,23 @@ 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;
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
addFriend(friendId: string): void;
/**
* Create a meeting point on an event. Same contract as `addFriend`: resolves
* once the meeting point was created and persisted — rejects when nothing
* was created (no backend to write to, or the write was refused).
*/
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void | Promise<void>;
/**
* Ask a person to become a contact (an ActivityPods contact request). Resolves
* once the request was sent — the other side accepts on their own Pod; rejects
* when nothing was sent (no backend to send it, or the Pod refused).
*/
addFriend(friendId: string): void | Promise<void>;
/**
* Invite contacts to an event. Same contract: resolves only once the invitation
* was posted, rejects otherwise.
*/
inviteToEvent(eventId: string, recipientIds: string[], message?: string): Promise<void>;
updateProfile(updates: Partial<FpUserData>): void | Promise<void>;
loadTestData(): Promise<BootstrapResult>;
}
@@ -294,6 +325,7 @@ export function buildQueries(
return {
getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints,
openEvent: openEventInCompleteReadSet,
openUserEvents: openUserEventsInCompleteReadSet,
getEventReadState,
};
}
@@ -305,6 +337,9 @@ export function buildQueries(
*/
function openEventInCompleteReadSet(_eventId: string): void {}
/** `openUserEvents` for the same providers — every participation is already read. */
function openUserEventsInCompleteReadSet(_userId: string): void {}
// ============================================================================
// Hook
// ============================================================================
+11 -10
View File
@@ -993,6 +993,7 @@ function useNgData(): FestipodDataContextValue {
if (updates.endTime !== undefined) persists.push(updateEntityField(graph, id, 'endTime', str(updates.endTime)));
if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location)));
if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance)));
if (updates.coverImage !== undefined) persists.push(updateEntityField(graph, id, 'coverImage', str(updates.coverImage)));
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err));
}, []);
@@ -1190,15 +1191,12 @@ function useNgData(): FestipodDataContextValue {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
}, []);
const addFriend = useCallback((friendId: string) => {
setFriendships(prev => {
if (prev.some(f =>
(f.userId === currentUserId && f.friendId === friendId) ||
(f.userId === friendId && f.friendId === currentUserId)
)) return prev;
return [...prev, { id: `ng-fr-${Date.now()}`, userId: currentUserId, friendId }];
});
}, [currentUserId]);
// The suspended NextGraph path has no contact graph: refuse instead of
// pushing simulated state the screens would take for a real contact.
const addFriend = useCallback((_friendId: string) =>
Promise.reject(new Error('addFriend: not supported on the suspended NextGraph backend')), []);
const inviteToEvent = useCallback((_eventId: string, _recipientIds: string[], _message?: string) =>
Promise.reject(new Error('inviteToEvent: not supported on the suspended NextGraph backend')), []);
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
console.log(`${logPrefix} updateProfile (NG):`, updates);
@@ -1222,6 +1220,9 @@ function useNgData(): FestipodDataContextValue {
if (updates.username !== undefined) persists.push(updateEntityField(graph, graph, 'username', str(updates.username)));
if (updates.role !== undefined) persists.push(updateEntityField(graph, graph, 'role', str(updates.role)));
if (updates.isPublic !== undefined) persists.push(updateEntityField(graph, graph, 'isPublic', bool(updates.isPublic)));
if (updates.bio !== undefined) persists.push(updateEntityField(graph, graph, 'bio', str(updates.bio)));
if (updates.city !== undefined) persists.push(updateEntityField(graph, graph, 'city', str(updates.city)));
if (updates.photo !== undefined) persists.push(updateEntityField(graph, graph, 'photo', str(updates.photo)));
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist profile update failed:`, err));
}, [currentUser, users]);
@@ -1267,7 +1268,7 @@ function useNgData(): FestipodDataContextValue {
...queries,
getEventOwnership,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
addMeetingPoint, addFriend, updateProfile, loadTestData,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
+530 -155
View File
@@ -1,33 +1,39 @@
/**
* Read-your-writes of a join/leave, driven through the REAL data provider
* (`RefineDataProvider` over `useFestipodData`) against a scripted server.
* The participation mechanism of the ActivityPods data path
* (decision_2026-10-29_participation-privacy-mechanism), driven through the
* REAL data provider (`RefineDataProvider` over `useFestipodData`) against a
* scripted server.
*
* THE RACE (rights matrix, line `leave.ui-counts`, 1 run in 5). A join or leave
* is posted to the user's outbox and the backend updates the meeting point's
* attendees collection LATER. When the reads that follow one act all land
* before the backend has processed it, the act is never confirmed; if the user
* then makes the opposite act, the read still on screen (from BEFORE the first
* act) already shows what the second act asserts. Then the next read — the
* first act processed, the second not yet — shows the opposite, and the view
* must not follow it.
* THE TWO CHANNELS OF READ-YOUR-WRITES. A join/leave is confirmed by MY
* PARTICIPATIONS COLLECTION — the backend lists there each meeting point I
* joined, after processing my act; it is linked from my profile once the
* backend processed my first Join. The COUNT is the attendee list's business
* (public, maintained asynchronously by the backend): a stale list read must
* move the number, never the membership.
*
* THE SCRIPT IS ORDERED BY READS, NOT BY CLOCK. The server answers the
* collection from `server.members`, and the test changes it only after counting
* the reads served since an act — so the ordering "act → stale read → backend
* THE SCRIPT IS ORDERED BY READS, NOT BY CLOCK. The server answers from its
* state, and the test changes it (`backendProcesses`) only after counting the
* reads served since an act — so the ordering "act → stale read → backend
* processes → read" is the same on every run. Only the data provider's own
* re-read timers run on the real clock.
*
* What is stubbed: the Refine data provider (Alice's own list: empty, or her
* own meeting point) and `fetch` (catalog, records, collection, actor
* document, outbox). Everything between them — the foreign read set, the
* collection reads, the overlay, the counts — is the production code.
* What is stubbed: the Refine data provider (Alice's own list, and her profile
* — read and merged-on-update like the pod does) and `fetch` (catalog,
* records, collections, actor documents, contacts' profiles, outbox — the
* outbox records what Alice posts). Everything between them — the foreign
* read set, the participations read, the secret step, the recognition by
* hashing, the count overlay, the counts — is the production code. The member
* URNs the lists hold are computed here with node's own sha256, independently
* of the app's Web Crypto path.
*
* THE OVERLAY MUST ALSO GO. A view that only ever shows the overlay would pass
* every count check above; what proves the act was RELEASED (confirmed by a
* read) is that the re-reads stop: they run only while an act is pending.
* every count check; what proves the act was RELEASED (confirmed by a read) is
* that the re-reads stop: the schedule runs only until the act is confirmed
* and the count has moved from its baseline.
*/
import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test';
import { createHash } from 'node:crypto';
import { Window } from 'happy-dom';
import { useLayoutEffect, type ReactNode } from 'react';
import type { DataProvider } from '@refinedev/core';
@@ -36,33 +42,98 @@ import { setCurrentSession } from '../utils/currentSession';
import { setRuntimeConfig } from '../utils/runtimeConfig';
import { useFestipodData, type FestipodDataContextValue } from './festipodDataBase';
const FP_SECRET = 'http://festipod.org/participationSecret';
const FP_PARTICIPATIONS = 'http://festipod.org/participations';
const ALICE = 'http://alice.pod.test/alice';
const ALICE_OUTBOX = 'http://alice.pod.test/alice/outbox';
const ALICE_CONTACTS = 'http://alice.pod.test/alice/contacts';
const ALICE_PROFILE = 'http://alice.pod.test/alice/data/as/profile/alice';
const ALICE_PARTICIPATIONS = 'http://alice.pod.test/alice/data/as/collection/participations';
const BOB = 'http://bob.pod.test/bob';
const BOB_PROFILE = 'http://bob.pod.test/bob/data/as/profile/bob';
const BOB_PARTICIPATIONS = 'http://bob.pod.test/bob/data/as/collection/participations';
const DAVE = 'http://dave.pod.test/dave';
const DAVE_PROFILE = 'http://dave.pod.test/dave/data/as/profile/dave';
/** Carol is nobody's contact here: her profile is never read. */
const CAROL = 'http://carol.pod.test/carol';
const E = 'http://bob.pod.test/bob/data/events/concert';
const M = 'http://bob.pod.test/bob/data/events/concert-bar';
const C = 'http://bob.pod.test/bob/data/events/concert-bar/attendees';
/** M2: the event's SECOND meeting point, with its own attendee list C2. */
const M2 = 'http://bob.pod.test/bob/data/events/concert-terrasse';
const C2 = 'http://bob.pod.test/bob/data/events/concert-terrasse/attendees';
const BACKEND = 'http://backend.test';
const ALICE_SECRET = 'a1'.repeat(32);
const BOB_SECRET = 'b2'.repeat(32);
const CAROL_SECRET = 'c3'.repeat(32);
const DAVE_SECRET = 'd4'.repeat(32);
/** The member URN the backend records: the prefixed sha256 of "<WebID> <event URI> <secret>". */
function urnOf(webId: string, eventUri: string, secret: string): string {
return `urn:festipod:userref:${createHash('sha256').update(`${webId} ${eventUri} ${secret}`).digest('hex')}`;
}
const ALICE_URN_E = urnOf(ALICE, E, ALICE_SECRET);
const BOB_URN_E = urnOf(BOB, E, BOB_SECRET);
const CAROL_URN_E = urnOf(CAROL, E, CAROL_SECRET);
const DAVE_URN_E = urnOf(DAVE, E, DAVE_SECRET);
const EVENT_RECORD = { id: E, type: 'Event', name: 'Concert', startTime: '2026-11-01T20:00:00', 'dc:creator': BOB, to: 'as:Public' };
const MP_RECORD = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C, to: 'as:Public' };
/** M as Bob's pod serves it before the backend attached its collection. */
const MP2_RECORD = { id: M2, type: 'Event', location: 'En terrasse', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C2, to: 'as:Public' };
/** E2: an event Bob DECLARED — known to this session only through the catalog's owner filter. */
const E2 = 'http://bob.pod.test/bob/data/events/festival';
const E2_RECORD = { id: E2, type: 'Event', name: 'Festival', startTime: '2026-12-05T18:00:00', 'dc:creator': BOB, to: 'as:Public' };
/** M as Bob's pod serves it before the backend attached its attendee list. */
const MP_RECORD_UNLINKED = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': BOB, to: 'as:Public' };
/** M as ALICE's own meeting point on Bob's event, as her pod lists it before the backend attached its collection. */
/** M as ALICE's own meeting point on Bob's event, as her pod lists it before the backend attached its list. */
const OWN_MP_UNLINKED = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': ALICE, to: 'as:Public' };
/**
* The scripted server: the collection's members, how many reads of it were
* served, and Alice's own list (`own`, with M's collection link once `ownLinked`).
*/
type JsonRecord = Record<string, unknown>;
/** A profile record as the pod serves it — the Festipod predicates under their full IRI. */
function profileRecord(id: string, name: string, extra: { secret?: string; participations?: string } = {}): JsonRecord {
return {
id, type: 'Profile', name,
...(extra.secret !== undefined ? { [FP_SECRET]: extra.secret } : {}),
...(extra.participations !== undefined ? { [FP_PARTICIPATIONS]: extra.participations } : {}),
};
}
/** The scripted server's state — reset by `render`. */
const server = {
/** Attendee lists of M and M2, and how many reads of either were served. */
members: [] as string[],
members2: [] as string[],
reads: 0,
posted: [] as string[],
own: [] as Record<string, unknown>[],
/** Alice's own list (`own`, with M's list link once `ownLinked`). */
own: [] as JsonRecord[],
ownLinked: false,
/** Whether Bob's pod serves M with its collection link yet. */
/** Whether Bob's pod serves M with its list link yet; `secondMp` puts M2 in the catalog. */
mpLinked: true,
secondMp: false,
/** Alice's profile record, as her pod stores it. */
profile: profileRecord(ALICE_PROFILE, 'Alice'),
profileReads: 0,
profileWrites: [] as JsonRecord[],
profileWriteFails: false,
/** What Alice's participations collection lists, and how many reads of it were served. */
myParticipations: [] as string[],
myParticipationsReads: 0,
/** What Alice's outbox received, in order, and every write in order ('profile-write', 'Join', 'Leave'). */
posted: [] as JsonRecord[],
log: [] as string[],
/** Alice's apods:contacts members. */
contacts: [] as string[],
/** The contacts' profile records, served to anyone who asks (the scripted pod checks no grant). */
contactProfiles: {} as Record<string, JsonRecord>,
/** What Bob's participations collection lists, and how many reads of it were served. */
bobParticipations: [] as string[],
bobParticipationsReads: 0,
/** What the catalog declares for `?owner=BOB`. */
bobDeclared: [] as JsonRecord[],
catalogAsks: 0,
};
function json(body: unknown): Response {
@@ -72,49 +143,112 @@ function json(body: unknown): Response {
async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (init?.signal?.aborted) throw new DOMException('aborted', 'AbortError');
if ((init?.method ?? 'GET') === 'POST') {
if (url !== ALICE_OUTBOX) return new Response(null, { status: 405 });
server.posted.push(String((JSON.parse(String(init?.body)) as { type?: unknown }).type));
return new Response(null, { status: 201 });
const method = init?.method ?? 'GET';
if (method === 'POST') {
if (url === ALICE_OUTBOX) {
const body = JSON.parse(String(init?.body)) as JsonRecord;
server.posted.push(body);
server.log.push(String(body['type']));
return new Response(null, { status: 201 });
}
return new Response(null, { status: 405 });
}
if (url.startsWith(`${BACKEND}/catalog`)) {
server.catalogAsks++;
const owner = new URL(url).searchParams.get('owner');
const items = owner
? (owner === BOB ? server.bobDeclared : [])
: (server.secondMp ? [EVENT_RECORD, MP_RECORD, MP2_RECORD] : [EVENT_RECORD, MP_RECORD]);
return json({ type: 'OrderedCollection', items });
}
switch (url) {
case ALICE: return json({ id: ALICE, type: 'Person', outbox: ALICE_OUTBOX });
case `${BACKEND}/catalog`: return json({ type: 'OrderedCollection', items: [EVENT_RECORD, MP_RECORD] });
case ALICE: return json({ id: ALICE, type: 'Person', outbox: ALICE_OUTBOX, url: ALICE_PROFILE, 'apods:contacts': ALICE_CONTACTS });
case ALICE_CONTACTS: return json({ id: ALICE_CONTACTS, type: 'Collection', items: [...server.contacts] });
case ALICE_PARTICIPATIONS:
server.myParticipationsReads++;
return json({ id: ALICE_PARTICIPATIONS, type: 'Collection', items: [...server.myParticipations] });
case BOB: return json({ id: BOB, type: 'Person', url: BOB_PROFILE });
case DAVE: return json({ id: DAVE, type: 'Person', url: DAVE_PROFILE });
case BOB_PROFILE:
case DAVE_PROFILE: {
const record = server.contactProfiles[url];
return record ? json(record) : new Response('forbidden', { status: 403 });
}
case BOB_PARTICIPATIONS:
server.bobParticipationsReads++;
return json({ id: BOB_PARTICIPATIONS, type: 'Collection', items: [...server.bobParticipations] });
case E: return json(EVENT_RECORD);
case E2: return json(E2_RECORD);
case M: return json(server.mpLinked ? MP_RECORD : MP_RECORD_UNLINKED);
case M2: return json(MP2_RECORD);
case C:
server.reads++;
return json({ id: C, type: 'Collection', items: [...server.members] });
default: return new Response('not found', { status: 404 });
case C2:
server.reads++;
return json({ id: C2, type: 'Collection', items: [...server.members2] });
default:
return new Response('not found', { status: 404 });
}
}
/** Alice's pod: her own list is `server.own` (the attendees link appears once the backend attached it). */
/** Alice's pod: her own list is `server.own`; her profile is read and merged-on-update. */
const alicePod = {
getList: async () => {
const data = server.own.map(record => (server.ownLinked ? { ...record, 'apods:attendees': C } : record));
return { data, total: data.length };
},
getOne: async () => { throw new Error('no record'); },
getOne: async ({ resource, id }: { resource: string; id: string }) => {
if (resource !== 'profiles' || id !== ALICE_PROFILE) throw new Error('no record');
server.profileReads++;
return { data: { ...server.profile } };
},
getMany: async () => ({ data: [] }),
create: async () => { throw new Error('read-only'); },
update: async () => { throw new Error('read-only'); },
update: async ({ resource, id, variables }: { resource: string; id: string; variables: JsonRecord }) => {
if (resource !== 'profiles' || id !== ALICE_PROFILE) throw new Error('read-only');
if (server.profileWriteFails) throw Object.assign(new Error('Forbidden'), { status: 403 });
server.log.push('profile-write');
server.profileWrites.push(variables);
// The data provider's update MERGES the given fields into the stored record.
server.profile = { ...server.profile, ...variables };
return { data: { ...server.profile } };
},
deleteOne: async () => { throw new Error('read-only'); },
getApiUrl: () => 'http://alice.pod.test',
} as unknown as DataProvider;
/**
* The backend processing one of Alice's acts on a meeting point: her member URN
* in (or out of) its list, the meeting point in (or out of) her participations
* collection — created and linked from her profile at her first Join.
*/
function backendProcesses(op: 'join' | 'leave', mp: string): void {
const list = mp === M ? 'members' : 'members2';
if (op === 'join') {
if (!server[list].includes(ALICE_URN_E)) server[list] = [...server[list], ALICE_URN_E];
if (!server.myParticipations.includes(mp)) server.myParticipations = [...server.myParticipations, mp];
server.profile = { ...server.profile, [FP_PARTICIPATIONS]: ALICE_PARTICIPATIONS };
} else {
server[list] = server[list].filter(member => member !== ALICE_URN_E);
server.myParticipations = server.myParticipations.filter(id => id !== mp);
}
}
let latest: FestipodDataContextValue | undefined;
/** Whether the Probe opens E — the reload tests mount a fresh session that never opened anything. */
let probeOpensEvent = true;
function Probe() {
const data = useFestipodData();
latest = data;
// What the event detail screen does: ask for the event it shows.
const { openEvent } = data;
useLayoutEffect(() => { openEvent(E); }, [openEvent]);
useLayoutEffect(() => { if (probeOpensEvent) openEvent(E); }, [openEvent]);
return null;
}
// --- environment ---------------------------------------------------------------
const saved = { fetch: globalThis.fetch, build: activitypods.build, log: console.log, warn: console.warn };
const saved = { fetch: globalThis.fetch, build: activitypods.build, log: console.log, warn: console.warn, error: console.error };
const installed: string[] = [];
let createRoot: (typeof import('react-dom/client'))['createRoot'];
let RefineDataProvider: (props: { children: ReactNode }) => ReactNode;
@@ -132,6 +266,7 @@ beforeAll(async () => {
activitypods.build = () => ({ dataProvider: alicePod });
console.log = () => {};
console.warn = () => {};
console.error = () => {};
createRoot = (await import('react-dom/client')).createRoot;
RefineDataProvider = (await import('./refineData')).RefineDataProvider;
setRuntimeConfig({ backendUrl: BACKEND });
@@ -153,6 +288,7 @@ afterAll(() => {
activitypods.build = saved.build;
console.log = saved.log;
console.warn = saved.warn;
console.error = saved.error;
for (const name of installed) delete (globalThis as Record<string, unknown>)[name];
});
@@ -189,30 +325,69 @@ async function settledView(match: (v: ReturnType<typeof view>) => boolean, timeo
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
function render(members: string[], own: Record<string, unknown>[] = [], mpLinked = true): void {
server.members = members;
interface Scenario {
members?: string[];
members2?: string[];
own?: JsonRecord[];
mpLinked?: boolean;
secondMp?: boolean;
opensEvent?: boolean;
/** Alice's secret on her profile (absent by default — she never joined). */
secret?: string;
/** What Alice's participations collection lists; `undefined` = her profile links none. */
myParticipations?: string[];
contacts?: string[];
contactProfiles?: Record<string, JsonRecord>;
bobParticipations?: string[];
bobDeclared?: JsonRecord[];
}
function render(s: Scenario = {}): void {
server.members = s.members ?? [];
server.members2 = s.members2 ?? [];
server.reads = 0;
server.posted = [];
server.own = own;
server.own = s.own ?? [];
server.ownLinked = false;
server.mpLinked = mpLinked;
server.mpLinked = s.mpLinked ?? true;
server.secondMp = s.secondMp ?? false;
server.profile = profileRecord(ALICE_PROFILE, 'Alice', {
secret: s.secret,
participations: s.myParticipations === undefined ? undefined : ALICE_PARTICIPATIONS,
});
server.profileReads = 0;
server.profileWrites = [];
server.profileWriteFails = false;
server.myParticipations = s.myParticipations ?? [];
server.myParticipationsReads = 0;
server.posted = [];
server.log = [];
server.contacts = s.contacts ?? [];
server.contactProfiles = s.contactProfiles ?? {};
server.bobParticipations = s.bobParticipations ?? [];
server.bobParticipationsReads = 0;
server.bobDeclared = s.bobDeclared ?? [];
server.catalogAsks = 0;
probeOpensEvent = s.opensEvent ?? true;
const host = document.createElement('div');
root = createRoot(host as unknown as Element);
root.render(<RefineDataProvider><Probe /></RefineDataProvider>);
}
async function mount(members: string[]): Promise<void> {
render(members);
await until(() => server.reads >= 1, 'the first read of the attendees collection');
await until(() => view().mp === members.length, `M listed with ${members.length} inscrit`);
/** Mount with E opened and M's list read, my profile read. */
async function mount(s: Scenario = {}): Promise<void> {
render(s);
await until(() => server.reads >= 1, 'the first read of the attendee list');
await until(() => view().mp === (s.members ?? []).length, `M listed with ${(s.members ?? []).length} inscrit`);
await until(() => latest?.currentUser !== undefined, 'my profile read');
}
/**
* How many reads followed the act by `untilMs` after it — a point later than
* the step of the re-read schedule after the confirming read. The schedule runs
* only while an act is pending, so an act released by its confirming read
* leaves exactly that many; one never released (or never re-read) does not.
* Never throws: the count goes into the compared timeline.
* How many LIST reads followed the act by `untilMs` after it — a point later
* than the scheduled steps that should have fired. The schedule stops early
* once the act is confirmed and the count has moved from its baseline, so a
* converged act leaves exactly the reads up to that point; one whose list
* never moves runs the bounded window. Never throws: the count goes into the
* compared timeline.
*/
async function readsBy(servedBefore: number, actAt: number, untilMs: number): Promise<number> {
await sleep(Math.max(0, actAt + untilMs - Date.now()));
@@ -220,137 +395,337 @@ async function readsBy(servedBefore: number, actAt: number, untilMs: number): Pr
}
const NOT_JOINED = { event: 0, mp: 0, joined: false };
const JOINED = { event: 1, mp: 1, joined: true };
const JOINED_M = { event: 1, mp: 1, joined: true };
const isSecret = (value: unknown) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
// --- the race, both directions -------------------------------------------------------
// --- joining: the secret first, then a Join naming the meeting point --------------
test('a leave following a still-unconfirmed join is never undone by a read that predates the leave', async () => {
await mount([]);
test("the first join: a secret is generated and written to my profile BEFORE the Join is posted; the Join names the meeting point with no addressee; my participations collection, linked once the backend processed it, confirms it", async () => {
await mount();
// JOIN — the backend has not processed it when the reads that follow it land.
let served = server.reads;
await latest!.joinMeetingPoint(M);
await until(() => server.reads >= served + 2, 'two reads after the join');
await land();
const afterJoin = view();
// The backend processes the join now — no read has seen it.
server.members = [ALICE];
// LEAVE — the backend processes it only after two more reads.
served = server.reads;
await latest!.leaveMeetingPoint(M);
const leftAt = Date.now();
await until(() => server.reads >= served + 1, 'the first read after the leave');
await land();
const afterLeaveFirstRead = view();
await until(() => server.reads >= served + 2, 'the second read after the leave');
await land();
const afterLeaveSecondRead = view();
// The backend processes the leave.
server.members = [];
const afterBackendProcessedLeave = await settledView(v => v.mp === 0 && v.event === 0 && !v.joined, 8000);
// The third read (the 3 s step) confirms the leave; the 6 s step must find nothing pending.
const readsAfterLeave = await readsBy(served, leftAt, 7000);
expect(server.posted).toEqual(['Join', 'Leave']);
expect({ afterJoin, afterLeaveFirstRead, afterLeaveSecondRead, afterBackendProcessedLeave, readsAfterLeave }).toEqual({
afterJoin: JOINED,
afterLeaveFirstRead: NOT_JOINED,
afterLeaveSecondRead: NOT_JOINED,
afterBackendProcessedLeave: NOT_JOINED,
readsAfterLeave: 3,
});
}, 30000);
test('a join following a still-unconfirmed leave is never undone by a read that predates the join', async () => {
await mount([ALICE]);
expect(view()).toEqual(JOINED);
// LEAVE — the backend has not processed it when the reads that follow it land.
let served = server.reads;
await latest!.leaveMeetingPoint(M);
await until(() => server.reads >= served + 2, 'two reads after the leave');
await land();
const afterLeave = view();
// The backend processes the leave now — no read has seen it.
server.members = [];
// JOIN — the backend processes it only after two more reads.
served = server.reads;
const served = server.reads;
await latest!.joinMeetingPoint(M);
const joinedAt = Date.now();
await until(() => server.reads >= served + 1, 'the first read after the join');
await land();
const afterJoinFirstRead = view();
await until(() => server.reads >= served + 2, 'the second read after the join');
await land();
const afterJoinSecondRead = view();
// The backend processes the join.
server.members = [ALICE];
const afterBackendProcessedJoin = await settledView(v => v.mp === 1 && v.event === 1 && v.joined, 8000);
const readsAfterJoin = await readsBy(served, joinedAt, 7000);
// The order on the wire: the profile write, then the Join.
expect(server.log).toEqual(['profile-write', 'Join']);
expect(server.profileWrites).toHaveLength(1);
expect(Object.keys(server.profileWrites[0]!)).toEqual([FP_SECRET]);
expect(isSecret(server.profileWrites[0]![FP_SECRET])).toBe(true);
// Exactly these keys: no `to`, no `cc`.
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: M }]);
expect(server.posted).toEqual(['Leave', 'Join']);
expect({ afterLeave, afterJoinFirstRead, afterJoinSecondRead, afterBackendProcessedJoin, readsAfterJoin }).toEqual({
afterLeave: NOT_JOINED,
afterJoinFirstRead: JOINED,
afterJoinSecondRead: JOINED,
afterBackendProcessedJoin: JOINED,
readsAfterJoin: 3,
// The membership shows at once (overlay) while the backend has processed
// nothing: my profile links no collection yet, the list is empty.
await until(() => view().joined, 'my membership reflected at once');
await land();
const beforeProcessed = view();
// The backend processes the Join: my URN in M's list, M in my newly created
// participations collection, linked from my profile.
backendProcesses('join', M);
const afterProcessed = await settledView(v => v.mp === 1 && v.event === 1 && v.joined, 8000);
// Confirmed and the total moved → the re-read schedule stops: bounded list reads.
const readsAfterJoin = await readsBy(served, joinedAt, 4000);
expect({ beforeProcessed, afterProcessed, readsAfterJoin }).toEqual({
beforeProcessed: JOINED_M,
afterProcessed: JOINED_M,
readsAfterJoin: 2,
});
// The confirmation came from the collection, found through a re-read of my profile.
expect(server.myParticipationsReads).toBeGreaterThanOrEqual(1);
expect(latest!.getUserEvents(latest!.currentUserId).map(e => e.id)).toEqual([E]);
}, 30000);
test('an own meeting point whose collection link arrives after the act is still re-read until a read confirms the act', async () => {
// Alice's own list shows M before the backend attached its attendees collection.
render([], [OWN_MP_UNLINKED]);
await until(() => view().mp === 0, 'M listed (own, no collection linked yet)');
// The backend attaches the collection now — no list read has seen it.
test('a secret already on my profile is not rewritten: the Join is posted straight away', async () => {
await mount({ secret: ALICE_SECRET, myParticipations: [] });
await latest!.joinMeetingPoint(M);
expect(server.log).toEqual(['Join']);
expect(server.profileWrites).toEqual([]);
expect(server.profile[FP_SECRET]).toBe(ALICE_SECRET);
}, 30000);
test('a malformed secret on my profile reads as absent: a fresh one replaces it before the Join', async () => {
await mount({ secret: 'NOT-A-SECRET' });
await latest!.joinMeetingPoint(M);
expect(server.log).toEqual(['profile-write', 'Join']);
expect(isSecret(server.profile[FP_SECRET])).toBe(true);
}, 30000);
test('the secret write fails: the join rejects and NO Join is posted', async () => {
await mount();
server.profileWriteFails = true;
await expect(latest!.joinMeetingPoint(M)).rejects.toThrow();
await land();
expect(server.posted).toEqual([]);
expect(view().joined).toBe(false);
}, 30000);
test('two joins at once with no secret yet write ONE secret, and both Joins follow it', async () => {
await mount({ secondMp: true });
await until(() => latest!.getEventMeetingPoints(E).some(mp => mp.id === M2), 'M2 listed');
await Promise.all([latest!.joinMeetingPoint(M), latest!.joinMeetingPoint(M2)]);
expect(server.profileWrites).toHaveLength(1);
expect(server.log[0]).toBe('profile-write');
expect(server.posted.map(body => body['object']).sort()).toEqual([M, M2].sort());
}, 30000);
// --- leaving: no secret step, a Leave naming the meeting point -------------------
test("a leave is never undone by a stale read: the membership is my participations collection's word, the count is the attendee list's", async () => {
// Alice joined M long ago: her collection lists M; M's list holds her URN.
render({ members: [ALICE_URN_E], secret: ALICE_SECRET, myParticipations: [M] });
// Two independent chains land here — my participations read (joined) and
// the catalog → meeting point → list reads (the counts). Assert only once
// BOTH have settled.
await settledView(v => v.joined && v.mp === 1 && v.event === 1, 10000);
expect(view()).toEqual(JOINED_M);
const served = server.reads;
await latest!.leaveMeetingPoint(M);
const leftAt = Date.now();
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Leave', actor: ALICE, object: M }]);
expect(server.profileWrites).toEqual([]);
// The leave shows at once (overlay). The re-reads now land on STALE data —
// the list still holds her URN, her collection still lists M: the count
// stays, the membership must NOT flip back.
await until(() => view().joined === false, 'the leave reflects at once');
await until(() => server.reads >= served + 1, 'the first count re-read after the leave');
await land();
const afterStaleRead = view();
backendProcesses('leave', M);
const afterProcessed = await settledView(v => v.mp === 0 && v.event === 0 && !v.joined, 8000);
const readsAfterLeave = await readsBy(served, leftAt, 6000);
expect({ afterStaleRead, afterProcessed, readsAfterLeave }).toEqual({
afterStaleRead: { event: 1, mp: 1, joined: false },
afterProcessed: NOT_JOINED,
readsAfterLeave: 2,
});
expect(latest!.getUserEvents(latest!.currentUserId)).toEqual([]);
}, 30000);
test('an own meeting point whose list link arrives after the join: the membership holds on the overlay; the count follows the late-linked list', async () => {
// Alice's own list shows M before the backend attached its attendee list.
render({ own: [OWN_MP_UNLINKED], secret: ALICE_SECRET, myParticipations: [] });
await until(() => view().mp === 0, 'M listed (own, no list linked yet)');
await until(() => latest?.currentUser !== undefined, 'my profile read');
// The backend attaches the list now — no list read has seen it.
server.ownLinked = true;
const served = server.reads;
await latest!.joinMeetingPoint(M);
const joinedAt = Date.now();
// The list read the act triggers brings the link; the collection's first read predates the backend's processing.
await until(() => server.reads >= served + 1, 'the first read of the newly linked collection');
await until(() => view().joined, 'my membership reflected at once');
// The own-list read the act triggers brings the link; the list's first read
// predates the backend's processing: the baseline is an empty list.
await until(() => server.reads >= served + 1, 'the first read of the newly linked list');
await land();
const afterFirstRead = view();
server.members = [ALICE]; // the backend processes the join
const beforeProcessed = view();
backendProcesses('join', M);
// The next scheduled step (1.5 s) re-reads the collection it did not know at
// act time, that read confirms the join, and the 3 s step finds nothing pending.
const afterProcessed = await settledView(v => v.mp === 1 && v.event === 1 && v.joined, 8000);
const readsAfterJoin = await readsBy(served, joinedAt, 4000);
expect({ afterFirstRead, afterConfirmed: view(), readsAfterJoin }).toEqual({
afterFirstRead: JOINED,
afterConfirmed: JOINED,
expect({ beforeProcessed, afterProcessed, readsAfterJoin }).toEqual({
beforeProcessed: JOINED_M,
afterProcessed: JOINED_M,
readsAfterJoin: 2,
});
}, 30000);
test("another user's meeting point read before its collection was attached is re-read until a read confirms the act", async () => {
// Bob's pod served M before the backend attached its attendees collection.
render([], [], false);
await until(() => view().mp === 0, 'M listed (no collection linked yet)');
// The backend attaches the collection now — no read of M has seen it.
test("another user's meeting point read before its list was attached: the membership holds on the overlay; the count follows the late-linked list", async () => {
// Bob's pod served M before the backend attached its attendee list.
render({ mpLinked: false, secret: ALICE_SECRET, myParticipations: [] });
await until(() => view().mp === 0, 'M listed (no list linked yet)');
await until(() => latest?.currentUser !== undefined, 'my profile read');
server.mpLinked = true;
const served = server.reads;
await latest!.joinMeetingPoint(M);
const joinedAt = Date.now();
// M is read again by URI, which brings the link; the collection's first read predates the backend's processing.
await until(() => server.reads >= served + 1, 'the first read of the newly linked collection');
await until(() => view().joined, 'my membership reflected at once');
// M is read again by URI, which brings the link; the list's first read predates the backend's processing.
await until(() => server.reads >= served + 1, 'the first read of the newly linked list');
await land();
const afterFirstRead = view();
server.members = [ALICE]; // the backend processes the join
const beforeProcessed = view();
backendProcesses('join', M);
// The 1.5 s step re-reads the collection, that read confirms the join, and the 3 s step finds nothing pending.
const afterProcessed = await settledView(v => v.mp === 1 && v.event === 1 && v.joined, 8000);
const readsAfterJoin = await readsBy(served, joinedAt, 4000);
expect({ afterFirstRead, afterConfirmed: view(), readsAfterJoin }).toEqual({
afterFirstRead: JOINED,
afterConfirmed: JOINED,
expect({ beforeProcessed, afterProcessed, readsAfterJoin }).toEqual({
beforeProcessed: JOINED_M,
afterProcessed: JOINED_M,
readsAfterJoin: 2,
});
}, 30000);
// --- my participations collection after a reload --------------------------------
test('present: a meeting point my participations collection lists is read with its event — the event lands on my home without ever opening it', async () => {
render({ members: [ALICE_URN_E], secret: ALICE_SECRET, myParticipations: [M], opensEvent: false });
await until(() => server.myParticipationsReads >= 1, 'my participations read');
await settledView(v => v.joined && v.mp === 1 && v.event === 1, 10000);
await until(() => latest!.getUserEvents(latest!.currentUserId).some(e => e.id === E), 'the joined event on my home');
expect(latest!.getUserEvents(latest!.currentUserId).map(e => e.id)).toEqual([E]);
expect(view()).toEqual(JOINED_M);
// The event came from my collection alone: no event was opened, no catalog asked.
expect(server.catalogAsks).toBe(0);
}, 30000);
test('absent: a profile linking no participations collection — nothing joined, no collection and no list read', async () => {
render({ opensEvent: false });
await until(() => latest?.currentUser !== undefined, 'my profile read');
await land();
await land();
expect(latest!.getUserEvents(latest!.currentUserId)).toEqual([]);
expect(latest!.isParticipating(M)).toBe(false);
expect(server.myParticipationsReads).toBe(0);
expect(server.reads).toBe(0);
}, 30000);
test('after a confirmed leave: a collection that no longer lists M keeps it — and its event — off my home, its list never read', async () => {
// The host's list WOULD still count me if it were stale: nothing may re-join
// the point on its word.
render({ members: [ALICE_URN_E], secret: ALICE_SECRET, myParticipations: [], opensEvent: false });
await until(() => server.myParticipationsReads >= 1, 'my participations read');
await land();
await land();
expect(latest!.getUserEvents(latest!.currentUserId)).toEqual([]);
expect(latest!.isParticipating(M)).toBe(false);
expect(server.reads).toBe(0);
}, 30000);
// --- one person, one event: the public distinct-person count ------------------
test('Alice in both meeting points of the event: each point shows its list count; the event counts her ONCE (the same URN in both)', async () => {
render({ members: [ALICE_URN_E], members2: [ALICE_URN_E], secondMp: true, secret: ALICE_SECRET, myParticipations: [M, M2] });
const counts = () => {
const data = latest;
if (!data) return undefined;
const mps = data.getEventMeetingPoints(E);
const mp = mps.find(p => p.id === M)?.participantCount;
const mp2 = mps.find(p => p.id === M2)?.participantCount;
const event = data.getEvent(E)?.participantCount;
return mp !== undefined && mp2 !== undefined && event !== undefined ? { mp, mp2, event } : undefined;
};
await until(() => counts()?.mp === 1 && counts()?.mp2 === 1 && counts()?.event === 1, 'each point at its list total and the event at the distinct-person count');
expect(counts()).toEqual({ mp: 1, mp2: 1, event: 1 });
await until(() => latest?.getUserEvents(latest!.currentUserId).some(e => e.id === E) === true, 'the joined event on my home');
expect(latest!.getEventParticipants(E).map(u => u.id)).toEqual([ALICE]);
}, 30000);
// --- recognition: a contact's secret, hashed per event, against the lists -------
const bobProfile = (extra: { secret?: string; participations?: string } = { secret: BOB_SECRET }) =>
({ [BOB_PROFILE]: profileRecord(BOB_PROFILE, 'Bob', extra) });
test('a contact in a list is recognised by hashing their secret; a non-contact member stays anonymous; a contact whose profile carries no secret is not recognised', async () => {
// Bob and Dave are Alice's contacts; Carol is not. Dave's profile was read
// before he generated his secret (none on it), while the list holds the URN
// the backend computed from the secret he has since.
render({
members: [BOB_URN_E, CAROL_URN_E, DAVE_URN_E],
contacts: [BOB, DAVE],
contactProfiles: { ...bobProfile(), [DAVE_PROFILE]: profileRecord(DAVE_PROFILE, 'Dave') },
});
await until(() => latest?.getEventParticipants(E).some(u => u.id === BOB) === true, 'Bob recognised at E');
await until(() => latest?.getUser(DAVE) !== undefined, "Dave's profile read");
await land();
const data = latest!;
expect(data.getEventParticipants(E).map(u => u.id)).toEqual([BOB]);
expect(data.isParticipating(M, BOB)).toBe(true);
expect(data.isParticipating(M, DAVE)).toBe(false);
expect(data.isParticipating(M, CAROL)).toBe(false);
// The count stays the total: three inscriptions, one of them recognised.
expect(view()).toEqual({ event: 3, mp: 3, joined: false });
// Recognition read nothing beyond the lists and the contacts' profiles.
expect(server.bobParticipationsReads).toBe(0);
}, 30000);
test('a contact in both meeting points of one event: listed once as a participant, participating in each point, counted once', async () => {
render({ members: [BOB_URN_E], members2: [BOB_URN_E], secondMp: true, contacts: [BOB], contactProfiles: bobProfile() });
await until(() => latest?.isParticipating(M2, BOB) === true, 'Bob recognised in M2');
const data = latest!;
expect(data.getEventParticipants(E).map(u => u.id)).toEqual([BOB]);
expect(data.isParticipating(M, BOB)).toBe(true);
expect(data.getEvent(E)?.participantCount).toBe(1);
}, 30000);
test('a contact absent from the lists is not a participant', async () => {
render({ members: [CAROL_URN_E], contacts: [BOB], contactProfiles: bobProfile() });
await until(() => latest?.getUser(BOB) !== undefined, "Bob's profile read");
await until(() => view().mp === 1, 'M listed with its one member');
await land();
expect(latest!.getEventParticipants(E)).toEqual([]);
expect(latest!.isParticipating(M, BOB)).toBe(false);
}, 30000);
// --- a contact's events: declared (catalog) ∪ participated (their collection, on demand) ---
test("a contact's participations collection is read only once their events are asked for; then declared and participated events list once each, and a stranger's stay empty", async () => {
// Bob declared E2 (catalog) and participates in M and M2, both anchoring E.
render({
opensEvent: false,
contacts: [BOB],
contactProfiles: bobProfile({ secret: BOB_SECRET, participations: BOB_PARTICIPATIONS }),
bobParticipations: [M, M2],
bobDeclared: [E2_RECORD],
});
await until(() => latest?.getUser(BOB) !== undefined, "Bob's profile read");
await until(() => latest?.getUserEvents(BOB).length === 1, "Bob's declared event listed");
await land();
// Nothing asked for Bob's events yet: his collection is not read.
expect(server.bobParticipationsReads).toBe(0);
expect(latest!.getUserEvents(BOB).map(e => e.id)).toEqual([E2]);
latest!.openUserEvents(BOB);
await until(() => latest?.getUserEvents(BOB).length === 2, "Bob's participated event listed");
expect(latest!.getUserEvents(BOB).map(e => e.id).sort()).toEqual([E, E2].sort());
expect(server.bobParticipationsReads).toBe(1);
// A stranger is nobody's contact: asking reads nothing, the answer stays [].
latest!.openUserEvents(CAROL);
await land();
expect(latest!.getUserEvents(CAROL)).toEqual([]);
expect(server.bobParticipationsReads).toBe(1);
// My own view is untouched: Alice joined nothing.
expect(latest!.getUserEvents(ALICE)).toEqual([]);
}, 30000);
test('a contact whose profile links no participations collection: asking for their events lists only what they declared', async () => {
render({ opensEvent: false, contacts: [BOB], contactProfiles: bobProfile() });
await until(() => latest?.getUser(BOB) !== undefined, "Bob's profile read");
latest!.openUserEvents(BOB);
await until(() => server.catalogAsks >= 1, 'the owner-filtered catalog read');
await land();
await land();
expect(latest!.getUserEvents(BOB)).toEqual([]);
expect(server.bobParticipationsReads).toBe(0);
}, 30000);
File diff suppressed because it is too large Load Diff
+68 -22
View File
@@ -84,17 +84,52 @@ test('the count stays the collection total when the members resolve to nobody',
expect(derived.totals.get(MP)).toBe(3);
});
test("an event's count is the sum of its meeting points' collection totals", () => {
// The product rule, from opaque data (decision_2026-10-29_participation-privacy-mechanism):
// the members are member URNs, one per (person, EVENT) — the same person in two
// meeting points of one event carries the SAME URN in both collections and
// counts ONCE at the event level, publicly, with no recognition.
const URN = (hex: string) => `urn:festipod:userref:${hex}`;
test('one person in two meeting points of one event counts once — the same URN in both collections dedupes publicly', () => {
const mp2 = 'http://localhost:3000/alice/data/mp-2';
const aliceUrn = URN('a'.repeat(64));
const derived = deriveContainer(
[
{ id: EVENT, type: 'Event', name: 'Concert' },
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [aliceUrn] } },
{ id: mp2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [aliceUrn] } },
],
bindings,
);
// Each meeting point keeps its own collection total; the event counts the person once.
expect(derived.meetingPoints.map(mp => mp.participantCount)).toEqual([1, 1]);
expect(derived.events[0]?.participantCount).toBe(1);
});
test('two different people in the meeting points of one event count twice — distinct URNs', () => {
const mp2 = 'http://localhost:3000/alice/data/mp-2';
const derived = deriveContainer(
[
{ id: EVENT, type: 'Event', name: 'Concert' },
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 2, items: [URN('b'.repeat(64))] } },
{ id: mp2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [URN('b'.repeat(64)), URN('c'.repeat(64))] } },
],
bindings,
);
expect(derived.events[0]?.participantCount).toBe(2);
});
test('two different people in the meeting points of one event count twice', () => {
const mp2 = 'http://localhost:3000/alice/data/mp-2';
const derived = deriveContainer(
[
{ id: EVENT, type: 'Event', name: 'Concert' },
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 2, items: [BOB] } },
{ id: mp2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [] } },
{ id: mp2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [BOB, ALICE] } },
],
bindings,
);
expect(derived.events[0]?.participantCount).toBe(3);
expect(derived.events[0]?.participantCount).toBe(2);
});
test('an unreadable collection reads as an honest zero, never a guess', () => {
@@ -138,9 +173,12 @@ test('an empty overlay or no session leaves the read untouched', () => {
expect(applyMembershipOverlay(read, new Map([[MP, 'join']]), undefined)).toEqual(read);
});
// ── Counts follow the overlay ──────────────────────────────────────────────
// ── Counts follow the pending acts ──────────────────────────────────────────
// A join is posted beside Refine and lands in the collection asynchronously;
// the number must move with the membership, not wait for the re-read.
// the number must move at once, and the overlay adjustment must live only
// while the collection total is unchanged from the act's baseline (the moment
// it moves, the read is the truth). A LEAVE never adjusts early: the list
// cannot show the reader's own membership, so early removal is undecidable.
const MP2 = 'http://localhost:3000/alice/data/mp-2';
function container(mp1Members: unknown[], mp2Members: unknown[]) {
@@ -154,40 +192,48 @@ function container(mp1Members: unknown[], mp2Members: unknown[]) {
);
}
test("a meeting point carries its own count, and the event's is their sum", () => {
const pending = (mpId: string, op: 'join' | 'leave', baseTotal?: number): PendingActs =>
recordMembershipAct(new Map(), mpId, op, 1, baseTotal);
test("a meeting point carries its own count, and the event's is the distinct people (same member value in two points dedupes)", () => {
const derived = container([BOB], [BOB, ALICE]);
expect(derived.meetingPoints.map(mp => mp.participantCount)).toEqual([1, 2]);
expect(derived.events[0]?.participantCount).toBe(3);
expect(derived.events[0]?.participantCount).toBe(2); // BOB is in both points — ONE person
});
test('a pending leave adjusts nothing early: the count drops only when the read lands it', async () => {
const memberInM = URN('1'.repeat(64));
const memberInM2 = URN('2'.repeat(64));
const derived = container([memberInM], [memberInM2]);
expect(derived.events[0]?.participantCount).toBe(2);
const early = countsWithOverlay(derived, pending(MP, 'leave', 1), ALICE);
expect(early.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(1);
expect(early.events[0]?.participantCount).toBe(2);
});
test('a pending join adds one to its meeting point and to the event', () => {
const derived = container([BOB], []);
const counted = countsWithOverlay(derived, new Map([[MP, 'join']]), ALICE);
const counted = countsWithOverlay(derived, pending(MP, 'join', 1), ALICE);
expect(counted.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(2);
expect(counted.meetingPoints.find(mp => mp.id === MP2)?.participantCount).toBe(0);
expect(counted.events[0]?.participantCount).toBe(2);
});
test('a join the read already carries is not counted twice', () => {
const derived = container([BOB, ALICE], []);
const counted = countsWithOverlay(derived, new Map([[MP, 'join']]), ALICE);
test('the join adjustment lives only while the total is unchanged from the baseline', () => {
// The total MOVED (1 → 2 — the backend landed the act, or another join did;
// indistinguishable, and the read is the truth): no adjustment on top.
const derived = container([BOB, URN('3'.repeat(64))], []);
expect(derived.totals.get(MP)).toBe(2);
const counted = countsWithOverlay(derived, pending(MP, 'join', 1), ALICE);
expect(counted.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(2);
expect(counted.events[0]?.participantCount).toBe(2);
});
test('a pending leave removes one; a leave the read no longer carries removes nothing', () => {
const pending = countsWithOverlay(container([BOB, ALICE], []), new Map([[MP, 'leave']]), ALICE);
expect(pending.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(1);
expect(pending.events[0]?.participantCount).toBe(1);
const landed = countsWithOverlay(container([BOB], []), new Map([[MP, 'leave']]), ALICE);
expect(landed.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(1);
});
test('no overlay, no session, or an overlay on an unknown meeting point leaves the counts as read', () => {
test('no pending act, no session, or an act on an unknown meeting point leaves the counts as read', () => {
const derived = container([BOB], []);
expect(countsWithOverlay(derived, new Map(), ALICE).events).toBe(derived.events);
expect(countsWithOverlay(derived, new Map([[MP, 'join']]), undefined).meetingPoints).toBe(derived.meetingPoints);
const stray = countsWithOverlay(derived, new Map([['http://elsewhere/mp', 'join']]), ALICE);
expect(countsWithOverlay(derived, pending(MP, 'join', 1), undefined).meetingPoints).toBe(derived.meetingPoints);
const stray = countsWithOverlay(derived, pending('http://elsewhere/mp', 'join'), ALICE);
expect(stray.events[0]?.participantCount).toBe(1);
});
+87 -42
View File
@@ -10,13 +10,17 @@
* 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 SUM of its meeting points' collection
* totals — never a stored, incremented counter, and never the number of
* RESOLVED profiles, so it stays total for a reader who may resolve nobody
* (the Alice/Bob rule).
* 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
* rule is publicly computable from opaque data, with no recognition and no
* backend.
*
* 2. `applyMembershipOverlay` reflects a join/leave made beside Refine (through
* the outbox) before the collection read carries it. `countsWithOverlay`
* the outbox) before the backend's update of the collections is read. `countsWithOverlay`
* applies the same overlay to the COUNTS, so a join shows in the number the
* moment it shows in the membership. WHEN the overlay may go is decided by
* `settleMembershipActs` (read-your-writes, below): only a read issued after
@@ -37,8 +41,10 @@ export interface DerivedContainer {
meetingPoints: FpMeetingPointData[];
/**
* Participations keyed BY MEETING POINT: `eventId` carries the meeting point
* id and `userId` the attendee reference — which may be a URI no profile
* resolves to (an anonymous participant).
* id 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. */
@@ -92,25 +98,32 @@ export function deriveContainer(
}
}
for (const event of events) {
event.participantCount = eventParticipantCount(event.id, meetingPoints, totals);
event.participantCount = eventParticipantCount(event.id, meetingPoints, participations);
}
return { events, meetingPoints, participations, totals };
}
/**
* The event's count: the SUM of its meeting points' collection totals —
* derived at read time, never a stored counter. A meeting point whose
* collection could not be read contributes 0, which is the honest "unknown"
* reading of a total nobody could count for us.
* The event's count: the number of DISTINCT member values among the
* participations 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
* opaque data, while each meeting point's own count stays the collection total
* (its card shows per-point inscriptions).
*/
function eventParticipantCount(
eventId: string,
meetingPoints: readonly FpMeetingPointData[],
totals: ReadonlyMap<string, number>,
participations: readonly FpParticipationData[],
): number {
return meetingPoints
.filter(mp => mp.eventId === eventId)
.reduce((sum, mp) => sum + (totals.get(mp.id) ?? 0), 0);
const mpIds = new Set(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);
}
return userIds.size;
}
/** A membership overlay entry: what this session just joined or left. */
@@ -149,35 +162,45 @@ export function applyMembershipOverlay(
}
/**
* The counts this session should see: the read totals, adjusted by the same
* optimistic overlay `applyMembershipOverlay` applies to the memberships — a
* `join` the read does not carry yet adds one to that meeting point, a `leave`
* the read still carries removes one. Event counts are re-summed from the
* adjusted totals, exactly as `deriveContainer` sums them. Once the read
* The counts this session should see: the read totals, adjusted by the pending
* acts — an adjustment that lives ONLY while the collection's item count is
* unchanged from the act's baseline (`PendingAct.baseTotal`): the moment the
* total moves (the backend landed the act, or a concurrent one), the read is
* the truth and the overlay goes, so a confirmed-and-landed join never flickers
* the number back. A LEAVE never adjusts early: the list is not read
* member-wise for the reader's own entry, so "the read still carries me" is
* not decided here — the count drops only when the backend lands it. Meeting point
* counts are the adjusted collection totals; EVENT counts are re-derived from
* the adjusted participations (the same distinct-count rule `deriveContainer`
* applies — distinct member URNs, one per (person, EVENT), so a person in two
* meeting points of the event still counts once at the event level). Once the read
* carries the act, the overlay entry adjusts nothing (and is reconciled away).
* With no overlay, or no `webId` to attribute it to, the derived lists are
* With no pending act, or no `webId` to attribute it to, the derived lists are
* returned as they are (same arrays).
*/
export function countsWithOverlay(
derived: DerivedContainer,
overlay: MembershipOverlay,
pending: PendingActs,
webId: string | undefined,
): { events: FpEventData[]; meetingPoints: FpMeetingPointData[] } {
if (overlay.size === 0 || !webId) return { events: derived.events, meetingPoints: derived.meetingPoints };
if (pending.size === 0 || !webId) return { events: derived.events, meetingPoints: derived.meetingPoints };
const totals = new Map(derived.totals);
for (const [mpId, op] of overlay) {
const effective: MembershipOverlay = new Map();
for (const [mpId, act] of pending) {
if (act.op !== 'join') continue; // a leave adjusts nothing early — see the header
const base = totals.get(mpId);
if (base === undefined) continue;
const readCarries = derived.participations.some(p => p.eventId === mpId && p.userId === webId);
if (op === 'join' && !readCarries) totals.set(mpId, base + 1);
else if (op === 'leave' && readCarries) totals.set(mpId, Math.max(0, base - 1));
if (base === undefined) continue; // no read yet: nothing to adjust against
if (act.baseTotal !== undefined && base !== act.baseTotal) continue; // the total moved: the read is the truth
totals.set(mpId, base + 1);
effective.set(mpId, 'join');
}
const participations = applyMembershipOverlay(derived.participations, effective, webId);
const meetingPoints = derived.meetingPoints.map(mp => {
const total = totals.get(mp.id);
return total === undefined || total === mp.participantCount ? mp : { ...mp, participantCount: total };
});
const events = derived.events.map(event => {
const count = eventParticipantCount(event.id, meetingPoints, totals);
const count = eventParticipantCount(event.id, meetingPoints, participations);
return count === event.participantCount ? event : { ...event, participantCount: count };
});
return { events, meetingPoints };
@@ -186,14 +209,16 @@ export function countsWithOverlay(
/**
* READ-YOUR-WRITES FOR A JOIN/LEAVE — when the overlay may go.
*
* The outbox accepts an act before the backend has updated the attendees
* collection, so a read can land on either side of the backend's processing.
* The outbox accepts an act before the backend has updated the participant's
* participations collection (the membership read) and the attendee list (the
* count read), so a read can land on either side of the backend's processing.
* The overlay entry for a meeting point is therefore dropped only when a read
* CONFIRMS it — never because the read on screen happens to show the asserted
* state:
*
* - Only a read ISSUED AFTER the act can confirm it (`confirmFrom`: the first
* read generation of the collection made once the outbox accepted the act).
* read generation of the participations collection made once the outbox
* accepted the act).
* A read from before says nothing about it.
* - An act made while an earlier act of this session on the same meeting point
* is still unconfirmed is NOT CLEAN: the collection shows the same thing
@@ -212,16 +237,27 @@ export function countsWithOverlay(
* (`MEMBERSHIP_REREAD_DELAYS_MS`) — never an open-ended poll.
*/
/** A join or a leave this session made on a meeting point. */
/** A join or a leave this session made on a meeting point, not yet confirmed by a read. */
export type MembershipOp = 'join' | 'leave';
/** One act of this session on a meeting point, not yet confirmed by a read. */
export interface PendingAct {
op: MembershipOp;
/** Reads of the collection made for this generation or later were issued after the act. */
/** Reads of the participations collection made for this generation or later were issued after the act. */
confirmFrom: number;
/** How many acts of this session on the same meeting point were still unconfirmed when this one was made. */
unconfirmedBefore: number;
/**
* The attendee list's item count when the act was made (undefined when the
* list was never read). The count overlay lives only while the total is
* UNCHANGED from this baseline: the moment it moves, the read is the truth.
* (The list is not read member-wise for MY entry — my membership is the
* participations collection's word — so for the count a total move is the
* convergence signal, and a concurrent act of someone else is
* indistinguishable from mine, which the overlay accepts: it is a count, not
* an identity.)
*/
baseTotal?: number;
/** Once the bounded wait ran out: the generation of the fresh re-read then issued — its result wins. */
settleFrom?: number;
}
@@ -230,19 +266,21 @@ export interface PendingAct {
export type PendingActs = ReadonlyMap<string, PendingAct>;
/**
* What the CURRENT read says about one meeting point: whether it carries the
* session's membership, the generation of the collection read it comes from
* (`undefined` when no collection read has succeeded for it), and the
* generation of the last collection read that came back, failed or not.
* What the CURRENT reads say about one meeting point: whether the membership
* read carries the session's membership, the generation of the read it comes
* from (`undefined` when no read has succeeded for it), the generation of the
* last read that came back, failed or not, and the attendee list's item count
* as read (`undefined` when no list is read yet).
*/
export interface MembershipObservation {
member: boolean;
generation: number | undefined;
attempted?: number;
count?: number;
}
/**
* When the collection is re-read after an act, in ms from the act — the first
* When the collections are re-read after an act, in ms from the act — the first
* re-read is immediate and not listed. The LAST one is the fresh re-read whose
* result wins (`expireMembershipAct`). Finite by construction.
*/
@@ -254,6 +292,7 @@ export function recordMembershipAct(
meetingPointId: string,
op: MembershipOp,
confirmFrom: number,
baseTotal?: number,
): PendingActs {
const previous = pending.get(meetingPointId);
// A repeated act asserts nothing new: it inherits what was still unconfirmed
@@ -261,7 +300,7 @@ export function recordMembershipAct(
const unconfirmedBefore = previous === undefined
? 0
: previous.op === op ? previous.unconfirmedBefore : previous.unconfirmedBefore + 1;
return new Map(pending).set(meetingPointId, { op, confirmFrom, unconfirmedBefore });
return new Map(pending).set(meetingPointId, { op, confirmFrom, unconfirmedBefore, baseTotal });
}
/** The bounded wait ran out: the read of generation `settleFrom` (or later) will win. */
@@ -296,8 +335,14 @@ export function settleMembershipActs(
set(id, undefined);
continue;
}
// The count channel converges on its own signal: an act is released only
// when the attendee list's total has MOVED from the act's baseline (or there
// is nothing to wait for — no baseline, no read count). Until then the
// optimistic count adjustment holds, so a confirmed-but-not-yet-landed
// join never flickers the number back.
const countSettled = seen.count === undefined || act.baseTotal === undefined || seen.count !== act.baseTotal;
const asserted = seen.member === (act.op === 'join');
if (asserted && act.unconfirmedBefore === 0) set(id, undefined);
if (asserted && act.unconfirmedBefore === 0 && countSettled) set(id, undefined);
else if (!asserted && act.unconfirmedBefore === 1) set(id, { ...act, unconfirmedBefore: 0 });
}
return next ?? pending;
+12 -3
View File
@@ -28,16 +28,25 @@ function jsonResponse(body: unknown, status = 200): Response {
const ACTOR = 'http://localhost:3000/alice';
const PROFILE = 'http://localhost:3000/alice/as/profile';
test('fetchActorDocument extracts the outbox and the profile link, compact or prefixed', async () => {
test('fetchActorDocument extracts the outbox, the profile link and the contacts collection, compact or prefixed', async () => {
const double = installFetch((url) => {
if (url === ACTOR) {
return jsonResponse({ id: ACTOR, outbox: 'http://localhost:3000/alice/outbox', url: PROFILE });
return jsonResponse({
id: ACTOR,
outbox: 'http://localhost:3000/alice/outbox',
url: PROFILE,
'apods:contacts': 'http://localhost:3000/alice/contacts',
});
}
return jsonResponse({}, 404);
});
try {
const actor = await fetchActorDocument(ACTOR, 'tok-1');
expect(actor).toEqual({ outbox: 'http://localhost:3000/alice/outbox', profileUri: PROFILE });
expect(actor).toEqual({
outbox: 'http://localhost:3000/alice/outbox',
profileUri: PROFILE,
contactsUri: 'http://localhost:3000/alice/contacts',
});
expect((double.calls[0]!.init.headers as Record<string, string>)['Authorization']).toBe('Bearer tok-1');
} finally {
double.restore();
+4
View File
@@ -26,6 +26,9 @@ export interface ActorDocument {
outbox?: string;
/** URI of the actor's `as:Profile` record (the `url` predicate). */
profileUri?: string;
/** URI of the actor's `apods:contacts` collection — attached to the WebID
* document (attachToTypes: Person), never to the as:Profile record. */
contactsUri?: string;
}
/**
@@ -51,6 +54,7 @@ export async function fetchActorDocument(webId: string, token?: string): Promise
return {
outbox: linkId(record?.['outbox']) ?? linkId(record?.['as:outbox']),
profileUri: linkId(record?.['url']),
contactsUri: linkId(record?.['apods:contacts']) ?? linkId(record?.['http://activitypods.org/ns/core#contacts']),
};
}
+6 -2
View File
@@ -4,8 +4,12 @@
* WHAT THE POD ACTUALLY RETURNS (verified live 2026-10-12, container listing,
* direct GET and catalog item alike): a meeting point record carries its
* `apods:attendees` collection as a BARE URI, never embedded. The collection is
* its own resource — `{ type: Collection, items: [...] }`, members as actor
* URIs, no `totalItems` — readable by anyone (public read, granted at creation).
* its own resource — `{ type: Collection, items: [...] }`, no `totalItems` —
* readable by anyone (public read, granted at creation). Its members are member
* URNs recorded by the backend (decision_2026-10-29_participation-privacy-mechanism):
* one per (person, EVENT), opaque to whoever does not hold the person's
* secret. The read embeds the collection for the COUNT, and for recognising
* the reader's contacts by hashing (`refineData.tsx`).
*
* The participation binding reads members from an EMBEDDED collection object
* (`membersOf`), and an un-embedded link reads as an honest zero. So the
+27
View File
@@ -80,6 +80,33 @@ describe('fetchCatalogEvents fails open', () => {
});
});
describe('fetchCatalogEvents — the owner filter', () => {
const original = globalThis.fetch;
afterEach(() => { globalThis.fetch = original; });
test('asks the catalog narrowed on the owner (URL-encoded) and maps the items', async () => {
const seen: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
seen.push(String(input));
return new Response(JSON.stringify({ items: [body.items[0]] }));
}) as unknown as typeof fetch;
const events = await fetchCatalogEvents('http://b/', { owner: 'http://pod/x/me' });
expect(seen).toEqual(['http://b/catalog?owner=http%3A%2F%2Fpod%2Fx%2Fme']);
expect(events.map(e => e.id)).toEqual(['http://pod/x/e1']);
});
test('no owner: the bare catalog URL, the whole listing, behaviour unchanged', async () => {
const seen: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
seen.push(String(input));
return new Response(JSON.stringify(body));
}) as unknown as typeof fetch;
const events = await fetchCatalogEvents('http://b');
expect(seen).toEqual(['http://b/catalog']);
expect(events.length).toBe(2);
});
});
// PER-ITEM ISOLATION: the catalog aggregates many pods' records, so one bad item
// must cost that item only — never the whole list.
describe('one malformed catalog item is skipped, the valid ones are kept', () => {
+25 -7
View File
@@ -7,6 +7,13 @@
* signed-in user's own pod and has no `custom` method, so this read is a plain
* fetch, like the actor reads in `actor.ts`.
*
* THE OWNER FILTER — the legitimate "what did they declare" channel. A
* personal fact may only be SOURCED from a channel where it is already
* readable by the asker; another user's outbox is not one, even for a contact.
* An event someone declared is public by design, and the declaration itself
* names them publicly (`dc:creator`) — so `?owner=<their WebID>` answers
* exactly the events they declared, nothing more.
*
* FAILS OPEN. Discovery is an enrichment of the list, never a precondition: a
* network error, a non-2xx answer or a malformed body all read back as "no
* catalog records" (with one `console.warn`), so the caller still renders the
@@ -100,8 +107,12 @@ export function catalogMeetingPointIdsOf(body: unknown, eventIds: readonly strin
* Fetch the catalog body. Never throws: every failure is a warned `undefined`,
* which every reader above maps to "nothing".
*/
async function fetchCatalogBody(backendUrl: string, signal?: AbortSignal): Promise<unknown> {
const url = `${backendUrl.replace(/\/+$/, '')}/catalog`;
async function fetchCatalogBody(backendUrl: string, { owner, signal }: CatalogQueryOptions = {}): Promise<unknown> {
// The owner filter is served by the backend (`discover`): it answers only
// the items whose public `dc:creator` attribution names that actor — the
// catalog is the legitimate channel for "what this user DECLARED", public
// by design. No owner → the whole listing, unchanged.
const url = `${backendUrl.replace(/\/+$/, '')}/catalog${owner ? `?owner=${encodeURIComponent(owner)}` : ''}`;
try {
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal });
if (!response.ok) {
@@ -121,12 +132,19 @@ async function fetchCatalogBody(backendUrl: string, signal?: AbortSignal): Promi
}
}
/** Optional refinements of a catalog read. */
export interface CatalogQueryOptions {
/** Narrow the listing to what this actor declared (their public `dc:creator` attribution). */
owner?: string;
signal?: AbortSignal;
}
/**
* Fetch the catalog and return its events. Never throws: every failure is a
* warned empty list.
* Fetch the catalog and return its events, optionally narrowed to what `owner`
* declared. Never throws: every failure is a warned empty list.
*/
export async function fetchCatalogEvents(backendUrl: string, signal?: AbortSignal): Promise<FpEventData[]> {
return catalogEventsFromBody(await fetchCatalogBody(backendUrl, signal));
export async function fetchCatalogEvents(backendUrl: string, options?: CatalogQueryOptions): Promise<FpEventData[]> {
return catalogEventsFromBody(await fetchCatalogBody(backendUrl, options));
}
/**
@@ -142,7 +160,7 @@ export async function fetchCatalogMeetingPointIds(
signal?: AbortSignal,
): Promise<string[] | undefined> {
if (eventIds.length === 0) return [];
const body = await fetchCatalogBody(backendUrl, signal);
const body = await fetchCatalogBody(backendUrl, { signal });
return body === undefined ? undefined : catalogMeetingPointIdsOf(body, eventIds);
}
+101
View File
@@ -0,0 +1,101 @@
import { afterEach, expect, test } from 'bun:test';
import { contactsCollectionOf, fetchCollectionMemberIds, postContactRequest, postEventInvitation } from './contacts';
const realFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = realFetch; });
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/ld+json' } });
test('contactsCollectionOf reads the compact and the full predicate, else undefined', () => {
expect(contactsCollectionOf({ 'apods:contacts': 'https://p/c' })).toBe('https://p/c');
expect(contactsCollectionOf({ 'http://activitypods.org/ns/core#contacts': { id: 'https://p/c2' } })).toBe('https://p/c2');
expect(contactsCollectionOf({ name: 'x' })).toBeUndefined();
expect(contactsCollectionOf(undefined)).toBeUndefined();
});
test('collection members: inline items, followed first page, 404 = empty, other errors throw', async () => {
globalThis.fetch = (async () => json({ type: 'Collection', items: ['https://a', { id: 'https://b' }] })) as unknown as typeof fetch;
expect(await fetchCollectionMemberIds('https://p/c', 't', 'https://p/me')).toEqual(['https://a', 'https://b']);
globalThis.fetch = (async (uri: string) =>
String(uri).endsWith('/c') ? json({ type: 'OrderedCollection', first: 'https://p/c?page=1' }) : json({ orderedItems: ['https://z'] })) as unknown as typeof fetch;
expect(await fetchCollectionMemberIds('https://p/c', 't', 'https://p/me')).toEqual(['https://z']);
globalThis.fetch = (async () => new Response('', { status: 404 })) as unknown as typeof fetch;
expect(await fetchCollectionMemberIds('https://p/c', 't', 'https://p/me')).toEqual([]);
globalThis.fetch = (async () => new Response('', { status: 500 })) as unknown as typeof fetch;
await expect(fetchCollectionMemberIds('https://p/c', 't', 'https://p/me')).rejects.toThrow('HTTP 500');
});
test('postContactRequest posts Offer{Add{my profile}} to my outbox, addressed to the target', async () => {
const calls: { url: string; init?: RequestInit }[] = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url) === 'https://pod/me') return json({ outbox: 'https://pod/me/outbox', url: 'https://pod/me/profile' });
return new Response('', { status: 201 });
}) as unknown as typeof fetch;
await postContactRequest('https://pod/bob', { webId: 'https://pod/me', token: 'tok' });
const post = calls.find(c => c.url === 'https://pod/me/outbox')!;
const body = JSON.parse(String(post.init?.body));
expect(body.type).toBe('Offer');
// The profile type travels embedded — the platform matcher must not
// dereference the (protected) profile document to learn it.
expect(body.object).toEqual({ type: 'Add', object: { id: 'https://pod/me/profile', type: 'Profile' } });
expect(body.to).toBe('https://pod/bob');
expect(body.target).toBe('https://pod/bob');
});
test('postContactRequest rejects when the pod refuses, so no success is announced', async () => {
globalThis.fetch = (async (url: string) =>
String(url) === 'https://pod/me' ? json({ outbox: 'https://pod/me/outbox', url: 'https://pod/me/profile' }) : new Response('', { status: 403 })) as unknown as typeof fetch;
await expect(postContactRequest('https://pod/bob', { webId: 'https://pod/me', token: 't' })).rejects.toThrow('HTTP 403');
});
test('postEventInvitation announces the event to every chosen contact, and refuses an empty list', async () => {
const calls: { url: string; init?: RequestInit }[] = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url) === 'https://pod/me') return json({ outbox: 'https://pod/me/outbox', url: 'https://pod/me/profile' });
return new Response('', { status: 201 });
}) as unknown as typeof fetch;
const session = { webId: 'https://pod/me', token: 't' };
await postEventInvitation('https://pod/ev/1', ['https://pod/a', 'https://pod/b'], session, ' Venez ! ');
const body = JSON.parse(String(calls.find(c => c.url === 'https://pod/me/outbox')!.init?.body));
expect(body).toMatchObject({ type: 'Announce', object: 'https://pod/ev/1', to: ['https://pod/a', 'https://pod/b'], content: 'Venez !' });
await expect(postEventInvitation('https://pod/ev/1', [], session)).rejects.toThrow('no recipient');
});
test('the token never leaves the session origin: a foreign collection page or outbox is refused before any request', async () => {
const seen: string[] = [];
globalThis.fetch = (async (url: string) => {
seen.push(String(url));
if (String(url) === 'https://p/c') return json({ first: 'https://evil.example/p1' });
if (String(url) === 'https://p/me') return json({ outbox: 'https://evil.example/outbox', url: 'https://p/me/profile' });
return json({ orderedItems: [] });
}) as unknown as typeof fetch;
await expect(fetchCollectionMemberIds('https://p/c', 't', 'https://p/me')).rejects.toThrow('own origin');
await expect(postContactRequest('https://p/bob', { webId: 'https://p/me', token: 't' })).rejects.toThrow('own origin');
await expect(postEventInvitation('https://p/ev', ['https://p/a'], { webId: 'https://p/me', token: 't' })).rejects.toThrow('own origin');
expect(seen.some(u => u.startsWith('https://evil.example'))).toBe(false);
});
test('a contact request to oneself is refused', async () => {
await expect(postContactRequest('https://p/me', { webId: 'https://p/me', token: 't' })).rejects.toThrow('oneself');
});
test('a relative first-page address is resolved against the collection, not refused', async () => {
const seen: string[] = [];
globalThis.fetch = (async (url: string) => {
seen.push(String(url));
return String(url) === 'https://p/c' ? json({ first: '?page=1' }) : json({ orderedItems: ['https://p/a'] });
}) as unknown as typeof fetch;
expect(await fetchCollectionMemberIds('https://p/c', 't', 'https://p/me')).toEqual(['https://p/a']);
expect(seen).toEqual(['https://p/c', 'https://p/c?page=1']);
});
test('a non-http session identity can never pass the origin check (opaque origins are all "null")', async () => {
globalThis.fetch = (async () => json({})) as unknown as typeof fetch;
await expect(fetchCollectionMemberIds('data:x', 't', 'urn:x:y')).rejects.toThrow('own origin');
});
+157
View File
@@ -0,0 +1,157 @@
/**
* Contacts — the ActivityPods social graph, used as is.
*
* Festipod keeps no friend model of its own. A user's network is the Pod's
* `apods:contacts` collection (attached to their `as:Profile`, mutual by
* construction), and "adding someone" is a CONTACT REQUEST: an
* `Offer{ object: Add{ object: <my profile> }, to, target }` posted to the
* requester's own outbox. The recipient accepts on their Pod provider's own
* network page — Festipod builds no acceptance screen. Same shape as the
* ActivityPods reference apps.
*
* Direct HTTP beside Refine, like `outbox.ts` and `actor.ts`: the data provider
* implements no `custom` method and a request is not CRUD.
*/
import { asRecord, linkId } from './jsonRecord';
import { fetchActorDocument } from './actor';
import type { CurrentSession } from '../utils/currentSession';
/**
* The session token goes to the user's own Pod and nowhere else: the contacts
* collection URI and the outbox URI come out of documents (a profile record, an
* actor document) that a hostile or compromised host could point anywhere.
*/
export function assertOwnOrigin(uri: string, webId: string): void {
let ok = false;
try {
const target = new URL(uri);
const own = new URL(webId);
// Opaque origins (data:, urn:) all compare equal as "null": only http(s) can be the session's own.
ok = (own.protocol === 'https:' || own.protocol === 'http:') && target.origin === own.origin;
} catch { /* not a URL */ }
if (!ok) throw new Error(`refusing to send the session token to ${uri}: it is not on the session's own origin`);
}
const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams';
const APODS_CONTACTS = ['apods:contacts', 'http://activitypods.org/ns/core#contacts'];
/** The URI of the contacts collection linked from a raw `as:Profile` record, if any. */
export function contactsCollectionOf(profileRecord: unknown): string | undefined {
const r = asRecord(profileRecord);
if (!r) return undefined;
for (const key of APODS_CONTACTS) {
const uri = linkId(r[key]);
if (uri) return uri;
}
return undefined;
}
function itemsOf(page: unknown): unknown[] {
const r = asRecord(page);
if (!r) return [];
const items = r['orderedItems'] ?? r['items'];
return Array.isArray(items) ? items : items === undefined ? [] : [items];
}
/**
* The actor URIs in a collection. An absent collection (404) is an empty one;
* any other failure throws, so a read error never passes for "no contacts".
*/
export async function fetchCollectionMemberIds(collectionUri: string, token: string, webId: string): Promise<string[]> {
const get = async (uri: string): Promise<unknown | undefined> => {
assertOwnOrigin(uri, webId);
const response = await fetch(uri, { headers: { Accept: 'application/ld+json', Authorization: `Bearer ${token}` } });
if (response.status === 404) return undefined;
if (!response.ok) throw new Error(`collection fetch failed: HTTP ${response.status} for ${uri}`);
return response.json();
};
let page = await get(collectionUri);
if (page === undefined) return [];
const first = asRecord(page)?.['first'];
if (first !== undefined) {
const embedded = itemsOf(first);
if (embedded.length > 0 || asRecord(first)?.['orderedItems'] !== undefined || asRecord(first)?.['items'] !== undefined) {
page = first;
} else {
const firstUri = linkId(first);
// A page address may be relative to the collection (`?page=1`): resolve it before judging its origin.
page = firstUri ? await get(new URL(firstUri, collectionUri).toString()) : undefined;
}
}
return itemsOf(page).map(item => linkId(item)).filter((id): id is string => !!id);
}
/**
* Ask `targetActorUri` to become a contact. Resolves once the Pod accepted the
* activity; a refusal or a missing outbox/profile THROWS — the caller must not
* announce a request that was never sent.
*/
export async function postContactRequest(targetActorUri: string, session: CurrentSession): Promise<void> {
if (new URL(targetActorUri, session.webId).href === new URL(session.webId).href) throw new Error('cannot send a contact request to oneself');
const actor = await fetchActorDocument(session.webId, session.token);
if (!actor.outbox) throw new Error(`actor document for ${session.webId} carries no outbox — cannot send a contact request`);
assertOwnOrigin(actor.outbox, session.webId);
if (!actor.profileUri) throw new Error(`actor document for ${session.webId} links no profile — nothing to offer`);
const response = await fetch(actor.outbox, {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json',
Accept: 'application/ld+json',
Authorization: `Bearer ${session.token}`,
},
body: JSON.stringify({
'@context': ACTIVITY_CONTEXT,
type: 'Offer',
actor: session.webId,
// The profile TYPE travels embedded: the platform matcher is
// `object: { type: 'Add', object: { type: 'Profile' } }` and must not
// dereference the profile URI — that document is protected, so a bare
// URI would leave the matcher with nothing to compare and the request
// would be silently ignored. The id stays along, the accept handler
// reads `object.object.object.id`.
object: { type: 'Add', object: { id: actor.profileUri, type: 'Profile' } },
target: targetActorUri,
to: targetActorUri,
}),
});
if (!response.ok) {
throw new Error(`outbox Offer post failed: HTTP ${response.status} for ${actor.outbox}`);
}
}
/**
* Invite contacts to an event: an `Announce{ object: <event> }` posted to the
* inviter's own outbox, addressed to the chosen contacts' actors. Resolves once
* the Pod accepted it; a refusal throws.
*/
export async function postEventInvitation(
eventUri: string,
recipientActorUris: string[],
session: CurrentSession,
message?: string,
): Promise<void> {
if (recipientActorUris.length === 0) throw new Error('no recipient to invite');
const actor = await fetchActorDocument(session.webId, session.token);
if (!actor.outbox) throw new Error(`actor document for ${session.webId} carries no outbox — cannot send an invitation`);
assertOwnOrigin(actor.outbox, session.webId);
const response = await fetch(actor.outbox, {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json',
Accept: 'application/ld+json',
Authorization: `Bearer ${session.token}`,
},
body: JSON.stringify({
'@context': ACTIVITY_CONTEXT,
type: 'Announce',
actor: session.webId,
object: eventUri,
to: recipientActorUris,
...(message?.trim() ? { content: message.trim() } : {}),
}),
});
if (!response.ok) {
throw new Error(`outbox Announce post failed: HTTP ${response.status} for ${actor.outbox}`);
}
}
+38 -43
View File
@@ -91,11 +91,20 @@ export const parsedFeatures: ParsedFeature[] = [
{
"name": "Ajouter une photo personnelle sur une fiche existante",
"tags": [],
"steps": []
"steps": [
{
"keyword": "Étant donné que ",
"text": "je suis sur la page \"modifier le profil\""
},
{
"keyword": "Alors",
"text": "l'écran contient un bouton \"Changer la photo\""
}
]
}
],
"filePath": "src/modules/user/features/us-9-visualiser-photo.feature",
"rawContent": "# language: fr\n@USER @priority-0\nFonctionnalité: US-9 Visualiser la photo d'un individu\n En tant qu'utilisateur\n Je peux visualiser la photo d'un individu ou ajouter une photo personnelle sur une fiche existante\n Et consulter la liste des inscrits à un atelier\n Afin d'identifier les personnes que j'ai rencontrées dont je n'ai pas noté leur nom\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder au profil pour voir la photo\n Étant donné que je suis sur la page \"mon profil\"\n Alors l'écran contient un avatar\n\n Scénario: Naviguer vers le profil depuis la liste des participants\n Étant donné que je suis sur la page \"détail événement\"\n Quand je clique sur un participant\n Alors je suis redirigé vers \"profil utilisateur\"\n Et l'écran affiche les informations du profil\n\n Scénario: Consulter la liste des inscrits à un atelier\n Étant donné que je suis sur la page \"détail événement\"\n Alors je peux voir la liste des participants\n\n Scénario: Vérifier les champs de données du profil\n Étant donné que je suis sur la page \"mon profil\"\n Alors l'écran contient un texte \"Marie Dupont\"\n Et l'écran contient un texte \"@mariedupont\"\n\n Scénario: Ajouter une photo personnelle sur une fiche existante\n * Scénario non implémenté\n",
"rawContent": "# language: fr\n@USER @priority-0\nFonctionnalité: US-9 Visualiser la photo d'un individu\n En tant qu'utilisateur\n Je peux visualiser la photo d'un individu ou ajouter une photo personnelle sur une fiche existante\n Et consulter la liste des inscrits à un atelier\n Afin d'identifier les personnes que j'ai rencontrées dont je n'ai pas noté leur nom\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder au profil pour voir la photo\n Étant donné que je suis sur la page \"mon profil\"\n Alors l'écran contient un avatar\n\n Scénario: Naviguer vers le profil depuis la liste des participants\n Étant donné que je suis sur la page \"détail événement\"\n Quand je clique sur un participant\n Alors je suis redirigé vers \"profil utilisateur\"\n Et l'écran affiche les informations du profil\n\n Scénario: Consulter la liste des inscrits à un atelier\n Étant donné que je suis sur la page \"détail événement\"\n Alors je peux voir la liste des participants\n\n Scénario: Vérifier les champs de données du profil\n Étant donné que je suis sur la page \"mon profil\"\n Alors l'écran contient un texte \"Marie Dupont\"\n Et l'écran contient un texte \"@mariedupont\"\n\n Scénario: Ajouter une photo personnelle sur une fiche existante\n Étant donné que je suis sur la page \"modifier le profil\"\n Alors l'écran contient un bouton \"Changer la photo\"\n",
"screenIds": [
"event-detail",
"profile",
@@ -961,6 +970,10 @@ export const parsedFeatures: ParsedFeature[] = [
"keyword": "Étant donné",
"text": "une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\""
},
{
"keyword": "Et",
"text": "l'application Festipod est installée sur le Pod de la session"
},
{
"keyword": "Et",
"text": "un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees"
@@ -991,6 +1004,10 @@ export const parsedFeatures: ParsedFeature[] = [
"keyword": "Étant donné",
"text": "une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\""
},
{
"keyword": "Et",
"text": "l'application Festipod est installée sur le Pod de la session"
},
{
"keyword": "Et",
"text": "un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees"
@@ -1011,7 +1028,7 @@ export const parsedFeatures: ParsedFeature[] = [
}
],
"filePath": "src/modules/event/features/e2e-activitypods.feature",
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3003, redis remappé :6390). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # SUSPENDU — le flux d'inscription exige que l'app Festipod soit INSTALLÉE sur le\n # Pod (consentement de l'agent d'autorisation + dispatcher d'activités). Sans cette\n # installation, l'activité `as:Join` postée sur l'outbox de l'utilisateur n'est pas\n # délivrée au backend et la collection `apods:attendees` reste inchangée — vérifié\n # sur la pile locale (POST outbox 201, collection inchangée). Le tag `@suspendu`\n # l'exclut de la porte ; les steps restent écrits pour la reprise.\n # Bloqueur de reprise : brancher l'installation de l'app (flux OIDC de T04.d) sur la\n # pile locale, sinon délivrer l'activité à l'inbox de l'app.\n @suspendu\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # SUSPENDU — dépend du scénario précédent : sans inscription sur le Pod, il n'y a ni\n # compteur ni liste à lire. La règle de lecture qu'il vise (compteur = total de la\n # collection, une référence non résolue restant comptée et rendue anonyme) est\n # couverte au niveau unitaire par `src/shared/context/refineDerive.test.ts`.\n @suspendu\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3003, redis remappé :6390). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un\n # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape\n # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur\n # l'outbox de l'utilisateur EST délivrée au backend, qui tient la collection\n # `apods:attendees` du point de rencontre. Le point de rencontre est créé dans le\n # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée\n # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # Dépend du scénario précédent pour la mise en place, mais lit la collection\n # elle-même : le compteur est le total de la collection (jamais les profils\n # résolus) et une référence non résolue reste comptée et rendue anonyme.\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
"screenIds": []
},
{
@@ -1429,17 +1446,17 @@ export const parsedFeatures: ParsedFeature[] = [
},
{
"keyword": "Alors",
"text": "l'écran contient un champ \"Quand\""
"text": "l'écran contient un champ \"Titre\""
},
{
"keyword": "Et",
"text": "l'écran contient un champ \"Durée\""
"text": "l'écran contient un champ \"Lieu\""
}
]
}
],
"filePath": "src/modules/meeting/features/us-16-point-rencontre.feature",
"rawContent": "# language: fr\n@MEETING @priority-1\nFonctionnalité: US-16 Indiquer un ou plusieurs points de rencontre\n En tant qu'utilisateur\n Je peux indiquer un ou plusieurs points de rencontre\n En précisant le lieu et l'heure de cette rencontre\n Afin de croiser et faire connaissance d'autres participants\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder aux points de rencontre\n Étant donné que je suis sur la page \"détail événement\"\n Quand je navigue vers \"points de rencontre\"\n Alors je vois l'écran \"meeting-points\"\n\n Scénario: Voir le formulaire de proposition\n Étant donné que je suis sur la page \"points de rencontre\"\n Alors l'écran contient un bouton \"Créer le point de rencontre\"\n Et l'écran contient un champ \"Lieu\"\n\n Scénario: Renseigner les détails du point de rencontre\n Étant donné que je suis sur la page \"points de rencontre\"\n Alors l'écran contient un champ \"Quand\"\n Et l'écran contient un champ \"Durée\"\n",
"rawContent": "# language: fr\n@MEETING @priority-1\nFonctionnalité: US-16 Indiquer un ou plusieurs points de rencontre\n En tant qu'utilisateur\n Je peux indiquer un ou plusieurs points de rencontre\n En précisant le lieu et l'heure de cette rencontre\n Afin de croiser et faire connaissance d'autres participants\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder aux points de rencontre\n Étant donné que je suis sur la page \"détail événement\"\n Quand je navigue vers \"points de rencontre\"\n Alors je vois l'écran \"meeting-points\"\n\n Scénario: Voir le formulaire de proposition\n Étant donné que je suis sur la page \"points de rencontre\"\n Alors l'écran contient un bouton \"Créer le point de rencontre\"\n Et l'écran contient un champ \"Lieu\"\n\n Scénario: Renseigner les détails du point de rencontre\n Étant donné que je suis sur la page \"points de rencontre\"\n Alors l'écran contient un champ \"Titre\"\n Et l'écran contient un champ \"Lieu\"\n",
"screenIds": [
"event-detail",
"meeting-points"
@@ -1579,7 +1596,7 @@ export const parsedFeatures: ParsedFeature[] = [
{
"id": "us-23",
"name": "US-23 Me connecter avec d'autres utilisateurs",
"description": "En tant qu'utilisateur Je peux me connecter avec d'autres utilisateurs En partageant mon QR code ou mon lien de contact Afin d'étendre mon réseau",
"description": "En tant qu'utilisateur Je peux me connecter avec d'autres utilisateurs En partageant mon lien de contact Afin d'étendre mon réseau",
"tags": [
"@USER",
"@priority-1"
@@ -1608,7 +1625,7 @@ export const parsedFeatures: ParsedFeature[] = [
]
},
{
"name": "Voir le QR code",
"name": "Voir l'invitation à me retrouver",
"tags": [],
"steps": [
{
@@ -1617,7 +1634,7 @@ export const parsedFeatures: ParsedFeature[] = [
},
{
"keyword": "Alors",
"text": "l'écran contient un texte \"Scannez pour me retrouver sur Festipod\""
"text": "l'écran contient un texte \"Retrouvez-moi sur Festipod\""
}
]
},
@@ -1631,7 +1648,7 @@ export const parsedFeatures: ParsedFeature[] = [
},
{
"keyword": "Alors",
"text": "l'écran contient une section \"Mon lien de profil\""
"text": "l'écran contient une section \"Mon lien d'invitation\""
}
]
},
@@ -1669,7 +1686,7 @@ export const parsedFeatures: ParsedFeature[] = [
}
],
"filePath": "src/modules/user/features/us-23-connexion-utilisateurs.feature",
"rawContent": "# language: fr\n@USER @priority-1\nFonctionnalité: US-23 Me connecter avec d'autres utilisateurs\n En tant qu'utilisateur\n Je peux me connecter avec d'autres utilisateurs\n En partageant mon QR code ou mon lien de contact\n Afin d'étendre mon réseau\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder au partage depuis le profil\n Étant donné que je suis sur la page \"mon profil\"\n Alors l'écran contient une section \"Partager\"\n\n Scénario: Voir le QR code\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient un texte \"Scannez pour me retrouver sur Festipod\"\n\n Scénario: Voir le lien de partage\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient une section \"Mon lien de profil\"\n\n Scénario: Accéder à l'écran de partage dédié\n Étant donné que je suis sur la page \"mon profil\"\n Quand je navigue vers \"partage de profil\"\n Alors je vois l'écran \"share-profile\"\n\n Scénario: Vérifier les données du profil\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient un texte \"Marie Dupont\"\n",
"rawContent": "# language: fr\n@USER @priority-1\nFonctionnalité: US-23 Me connecter avec d'autres utilisateurs\n En tant qu'utilisateur\n Je peux me connecter avec d'autres utilisateurs\n En partageant mon lien de contact\n Afin d'étendre mon réseau\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder au partage depuis le profil\n Étant donné que je suis sur la page \"mon profil\"\n Alors l'écran contient une section \"Partager\"\n\n Scénario: Voir l'invitation à me retrouver\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient un texte \"Retrouvez-moi sur Festipod\"\n\n Scénario: Voir le lien de partage\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient une section \"Mon lien d'invitation\"\n\n Scénario: Accéder à l'écran de partage dédié\n Étant donné que je suis sur la page \"mon profil\"\n Quand je navigue vers \"partage de profil\"\n Alors je vois l'écran \"share-profile\"\n\n Scénario: Vérifier les données du profil\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient un texte \"Marie Dupont\"\n",
"screenIds": [
"profile",
"share-profile"
@@ -1759,7 +1776,7 @@ export const parsedFeatures: ParsedFeature[] = [
{
"id": "us-10",
"name": "US-10 Visualiser la fiche/le profil d'un participant",
"description": "En tant qu'utilisateur Je peux sélectionner un individu dans la liste des inscrits à un événement/atelier Afin de voir les événements auxquels la personne a participé et voir un formulaire de contact",
"description": "En tant qu'utilisateur Je peux sélectionner un individu dans la liste des inscrits à un événement/atelier Afin de voir les événements auxquels la personne a participé et de pouvoir l'ajouter à mon réseau",
"tags": [
"@USER",
"@priority-1"
@@ -1820,7 +1837,7 @@ export const parsedFeatures: ParsedFeature[] = [
]
},
{
"name": "Voir le formulaire de contact",
"name": "Ajouter l'utilisateur à mon réseau",
"tags": [],
"steps": [
{
@@ -1829,7 +1846,7 @@ export const parsedFeatures: ParsedFeature[] = [
},
{
"keyword": "Alors",
"text": "je peux contacter l'utilisateur"
"text": "je peux ajouter l'utilisateur à mon réseau"
}
]
},
@@ -1854,7 +1871,7 @@ export const parsedFeatures: ParsedFeature[] = [
}
],
"filePath": "src/modules/user/features/us-10-profil-participant.feature",
"rawContent": "# language: fr\n@USER @priority-1\nFonctionnalité: US-10 Visualiser la fiche/le profil d'un participant\n En tant qu'utilisateur\n Je peux sélectionner un individu dans la liste des inscrits à un événement/atelier\n Afin de voir les événements auxquels la personne a participé et voir un formulaire de contact\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder au profil d'un participant\n Étant donné que je suis sur la page \"détail événement\"\n Quand je clique sur un participant\n Alors je vois l'écran \"user-profile\"\n\n Scénario: Voir les événements du participant\n Étant donné que je suis sur la page \"profil utilisateur\"\n Alors je peux voir les événements auxquels l'utilisateur a participé\n\n Scénario: Voir la localisation des événements\n Étant donné que je suis sur la page \"profil utilisateur\"\n Alors les événements affichent leur localisation et distance\n\n Scénario: Voir le formulaire de contact\n Étant donné que je suis sur la page \"profil utilisateur\"\n Alors je peux contacter l'utilisateur\n\n Scénario: Vérifier les informations du profil\n * Scénario non implémenté\n\n Scénario: Voir les détails du profil utilisateur\n Étant donné que je suis sur la page \"profil utilisateur\"\n Alors l'écran affiche les informations du profil\n",
"rawContent": "# language: fr\n@USER @priority-1\nFonctionnalité: US-10 Visualiser la fiche/le profil d'un participant\n En tant qu'utilisateur\n Je peux sélectionner un individu dans la liste des inscrits à un événement/atelier\n Afin de voir les événements auxquels la personne a participé et de pouvoir l'ajouter à mon réseau\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder au profil d'un participant\n Étant donné que je suis sur la page \"détail événement\"\n Quand je clique sur un participant\n Alors je vois l'écran \"user-profile\"\n\n Scénario: Voir les événements du participant\n Étant donné que je suis sur la page \"profil utilisateur\"\n Alors je peux voir les événements auxquels l'utilisateur a participé\n\n Scénario: Voir la localisation des événements\n Étant donné que je suis sur la page \"profil utilisateur\"\n Alors les événements affichent leur localisation et distance\n\n Scénario: Ajouter l'utilisateur à mon réseau\n Étant donné que je suis sur la page \"profil utilisateur\"\n Alors je peux ajouter l'utilisateur à mon réseau\n\n Scénario: Vérifier les informations du profil\n * Scénario non implémenté\n\n Scénario: Voir les détails du profil utilisateur\n Étant donné que je suis sur la page \"profil utilisateur\"\n Alors l'écran affiche les informations du profil\n",
"screenIds": [
"event-detail",
"user-profile"
@@ -2368,7 +2385,7 @@ export const parsedFeatures: ParsedFeature[] = [
{
"id": "us-22",
"name": "US-22 Parrainer un nouvel utilisateur",
"description": "En tant qu'utilisateur Je peux parrainer un nouvel utilisateur En lui partageant mon QR code ou lien de contact Afin de savoir combien de personnes ont rejoint le réseau grâce à moi",
"description": "En tant qu'utilisateur Je peux parrainer un nouvel utilisateur En lui partageant mon lien de contact Afin d'inviter des personnes à rejoindre le réseau",
"tags": [
"@USER",
"@priority-2"
@@ -2415,7 +2432,7 @@ export const parsedFeatures: ParsedFeature[] = [
]
},
{
"name": "Voir le QR code de parrainage",
"name": "Voir l'invitation de parrainage",
"tags": [],
"steps": [
{
@@ -2424,7 +2441,7 @@ export const parsedFeatures: ParsedFeature[] = [
},
{
"keyword": "Alors",
"text": "l'écran contient un texte \"Scannez pour me retrouver sur Festipod\""
"text": "l'écran contient un texte \"Retrouvez-moi sur Festipod\""
}
]
},
@@ -2438,39 +2455,17 @@ export const parsedFeatures: ParsedFeature[] = [
},
{
"keyword": "Alors",
"text": "l'écran contient une section \"Mon lien de profil\""
"text": "l'écran contient une section \"Mon lien d'invitation\""
},
{
"keyword": "Et",
"text": "l'écran contient un bouton \"Copier\""
}
]
},
{
"name": "Voir les statistiques de parrainage",
"tags": [],
"steps": [
{
"keyword": "Étant donné que ",
"text": "je suis sur la page \"partage profil\""
},
{
"keyword": "Alors",
"text": "l'écran contient une section \"Statistiques de parrainage\""
},
{
"keyword": "Et",
"text": "l'écran contient un texte \"Personnes parrainées\""
},
{
"keyword": "Et",
"text": "l'écran contient un texte \"Scans du QR code\""
"text": "l'écran contient un bouton \"Créer un lien d'invitation\""
}
]
}
],
"filePath": "src/modules/user/features/us-22-parrainer.feature",
"rawContent": "# language: fr\n@USER @priority-2\nFonctionnalité: US-22 Parrainer un nouvel utilisateur\n En tant qu'utilisateur\n Je peux parrainer un nouvel utilisateur\n En lui partageant mon QR code ou lien de contact\n Afin de savoir combien de personnes ont rejoint le réseau grâce à moi\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder au partage de profil\n Étant donné que je suis sur la page \"profil\"\n Alors l'écran contient un bouton \"Partager\"\n\n Scénario: Naviguer vers le partage de profil\n Étant donné que je suis sur la page \"profil\"\n Quand je navigue vers \"partage de profil\"\n Alors je vois l'écran \"share-profile\"\n\n Scénario: Voir le QR code de parrainage\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient un texte \"Scannez pour me retrouver sur Festipod\"\n\n Scénario: Voir le lien de parrainage\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient une section \"Mon lien de profil\"\n Et l'écran contient un bouton \"Copier\"\n\n Scénario: Voir les statistiques de parrainage\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient une section \"Statistiques de parrainage\"\n Et l'écran contient un texte \"Personnes parrainées\"\n Et l'écran contient un texte \"Scans du QR code\"\n",
"rawContent": "# language: fr\n@USER @priority-2\nFonctionnalité: US-22 Parrainer un nouvel utilisateur\n En tant qu'utilisateur\n Je peux parrainer un nouvel utilisateur\n En lui partageant mon lien de contact\n Afin d'inviter des personnes à rejoindre le réseau\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Accéder au partage de profil\n Étant donné que je suis sur la page \"profil\"\n Alors l'écran contient un bouton \"Partager\"\n\n Scénario: Naviguer vers le partage de profil\n Étant donné que je suis sur la page \"profil\"\n Quand je navigue vers \"partage de profil\"\n Alors je vois l'écran \"share-profile\"\n\n Scénario: Voir l'invitation de parrainage\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient un texte \"Retrouvez-moi sur Festipod\"\n\n Scénario: Voir le lien de parrainage\n Étant donné que je suis sur la page \"partage profil\"\n Alors l'écran contient une section \"Mon lien d'invitation\"\n Et l'écran contient un bouton \"Créer un lien d'invitation\"\n",
"screenIds": [
"profile",
"share-profile"
-379
View File
@@ -1,379 +0,0 @@
/**
* Centralized data architecture for Festipod mockups
*
* This module provides:
* - User stories definitions
* - Screen-to-story bidirectional links (computed automatically from feature files)
* - Sample data for mockups
*
* screenIds are extracted automatically from .feature files by parse-features.ts.
* Run `bun run features:parse` to update them.
*/
import { parsedFeatures } from './features';
// ============================================================================
// Types
// ============================================================================
export type StoryCategory = 'WORKSHOP' | 'EVENT' | 'USER' | 'MEETING' | 'NOTIF';
export interface UserStoryDefinition {
id: string;
priority: number;
category: StoryCategory;
title: string;
description: string;
}
export interface UserStory extends UserStoryDefinition {
screenIds: string[];
}
export interface ScreenStories {
screenId: string;
stories: UserStory[];
}
// ============================================================================
// Category metadata
// ============================================================================
export const categoryLabels: Record<StoryCategory, string> = {
WORKSHOP: 'Atelier',
EVENT: 'Événement',
USER: 'Utilisateur',
MEETING: 'Point de rencontre',
NOTIF: 'Notification',
};
export const categoryColors: Record<StoryCategory, string> = {
WORKSHOP: '#9c27b0',
EVENT: '#2196f3',
USER: '#4caf50',
MEETING: '#ff9800',
NOTIF: '#f44336',
};
export const priorityLabels: Record<number, string> = {
0: 'Impossible',
1: 'Haute',
2: 'Moyenne',
3: 'Basse',
};
export const priorityColors: Record<number, string> = {
0: '#999',
1: '#c00',
2: '#e60',
3: '#08a',
};
// ============================================================================
// User Stories Data
// ============================================================================
const userStoriesData: UserStoryDefinition[] = [
// Row 1 - WORKSHOP - Priority 3
{
id: 'us-1',
priority: 3,
category: 'WORKSHOP',
title: 'Visualiser un événement terminé',
description: 'En tant qu\'utilisateur, je peux visualiser un événement terminé et consulter le programme détaillé des ateliers par journée/heure afin de voir les personnes qui ont participé à chaque atelier et consulter les notes/liens (Ressources)/commentaires de cet atelier (Zone de partage Collective).',
},
// Row 2 - WORKSHOP - Priority 3
{
id: 'us-2',
priority: 3,
category: 'WORKSHOP',
title: 'Visualiser un événement terminé (notes)',
description: 'En tant qu\'utilisateur, je peux visualiser un événement terminé et consulter le programme détaillé des ateliers par journée/heure afin de ajouter d\'éventuelles prises de notes/liens (ressources) ou des commentaires associés à l\'atelier (Zone privée de l\'utilisateur et/ou Zone partage publique).',
},
// Row 3 - EVENT - Priority 1
{
id: 'us-3',
priority: 1,
category: 'EVENT',
title: 'Visualiser un événement terminé',
description: 'En tant qu\'utilisateur, je peux visualiser un événement terminé et consulter la description de l\'événement afin de voir les personnes qui ont participé à cet événement.',
},
// Row 4 - WORKSHOP - Priority 3
{
id: 'us-4',
priority: 3,
category: 'WORKSHOP',
title: 'Ajouter/modifier/supprimer un commentaire à un atelier',
description: 'En tant qu\'utilisateur, je peux consulter et ajouter/modifier/supprimer un commentaire à un atelier en sélectionnant l\'icône « ajouter un commentaire » en dessous du titre de l\'atelier afin de voir les commentaires précédents et ajouter mes commentaires, mentionner mes ressentis, faire part de mes annotations.',
},
// Row 5 - EVENT - Priority 3
{
id: 'us-5',
priority: 3,
category: 'EVENT',
title: 'Ajouter/modifier/supprimer un commentaire à un événement',
description: 'En tant qu\'utilisateur, je peux consulter et ajouter/modifier/supprimer un commentaire à un événement en sélectionnant l\'icône « ajouter un commentaire » en dessous du titre de l\'événement (Notes Privées / Personnelles) indiquant les interactions avec les individus rencontrés (Date / Heure / Lieu) afin de voir les commentaires précédents et ajouter mes commentaires, mentionner mes ressentis, faire part de mes annotations.',
},
// Row 6 - WORKSHOP - Priority 3
{
id: 'us-6',
priority: 3,
category: 'WORKSHOP',
title: 'M\'inscrire/me désinscrire à un événement (atelier)',
description: 'En tant qu\'utilisateur, je peux m\'inscrire/me désinscrire à un événement en : 1- regardant si l\'événement public existe déjà, 2- M\'enregistrant sur les différents ateliers afin de m\'inscrire à l\'atelier tout en visualisant les personnes qui sont déjà pré-inscrites.',
},
// Row 7 - EVENT - Priority 1
{
id: 'us-7',
priority: 1,
category: 'EVENT',
title: 'M\'inscrire/me désinscrire à un événement',
description: 'En tant qu\'utilisateur, je peux m\'inscrire/me désinscrire à un événement après avoir consulté la description de l\'événement, les dates et le lieu de tenue de l\'événement s\'il existe déjà dans le système, ou en le retrouvant dans une base existante (Mobilizon, etc.).',
},
// Row 8 - EVENT - Priority 3
{
id: 'us-8',
priority: 3,
category: 'EVENT',
title: 'Consulter et m\'inscrire à un macro-événement',
description: 'En tant qu\'utilisateur, je peux consulter et m\'inscrire à un événement de type « Macro-événement » en créant ou en rattachant des événements existants à ce macro-événement afin de rattacher des événements existants à une thématique particulière ou créer un événement qui est répété est plusieurs périodes dans l\'année (les résidences reconnecté) et voir une consolidation des commentaires / Liens/Ressources /participants de chaque événement rattaché.',
},
// Row 9 - USER - Priority 0
{
id: 'us-9',
priority: 0,
category: 'USER',
title: 'Visualiser la photo d\'un individu',
description: 'En tant qu\'utilisateur, je peux visualiser la photo d\'un individu (ou ajouter une photo personnelle sur une fiche existante) et consulter la liste des inscrits à un atelier afin de identifier les personnes que j\'ai rencontré dont je n\'ai pas noté leur nom.',
},
// Row 10 - USER - Priority 1
{
id: 'us-10',
priority: 1,
category: 'USER',
title: 'Visualiser la fiche/le profil d\'un participant',
description: 'En tant qu\'utilisateur, je peux sélectionner un individu dans la liste des inscrits à un événement/atelier afin de voir les événements auxquels la personne a participé et voir un formulaire de contact pour intéragir avec elle.',
},
// Row 11 - WORKSHOP - Priority 3
{
id: 'us-11',
priority: 3,
category: 'WORKSHOP',
title: 'Visualiser le bilan consolidé de l\'événement',
description: 'En tant qu\'utilisateur, je peux visualiser le bilan consolidé de l\'événement en consultant l\'ensemble des commentaires regroupés par atelier afin de obtenir une synthèse du contenu de chaque atelier et de l\'ensemble des ateliers constituant l\'événement.',
},
// Row 12 - USER - Priority 2
{
id: 'us-12',
priority: 2,
category: 'USER',
title: 'Consulter la carte / tableau des événements',
description: 'En tant qu\'utilisateur, je peux consulter la carte / tableau des événements auxquels j\'ai participé en filtrant les événements auxquels j\'ai participé par dates ou par personne afin de avoir une vue consolidée des événements auxquels j\'ai participé ainsi que le lieu ou j\'ai pu rencontrer une personne ou une vue consolidée des événements auxquels une personne a participé.',
},
// Row 13 - EVENT - Priority 1
{
id: 'us-13',
priority: 1,
category: 'EVENT',
title: 'Créer/Modifier/Supprimer un événement',
description: 'En tant qu\'utilisateur, je peux créer/modifier/supprimer un événement en choisissant les dates et les horaires de début et de fin de l\'événement, retirer une organisation (personne ou structure) et choisir un lieu. [Données obligatoires : Titre, description, image, adresse, + thématique (sera utilisé pour les notifications)] afin de créer/présenter le contenu de cet événement et le catégoriser par type/thématique.',
},
// Row 14 - WORKSHOP - Priority 3
{
id: 'us-14',
priority: 3,
category: 'WORKSHOP',
title: 'Créer/Modifier/Supprimer un atelier',
description: 'En tant qu\'utilisateur, je peux créer/modifier/supprimer un atelier en sélectionnant mon événement et en saisissant les dates et les horaires de début et de fin de l\'atelier afin de définir le programme de mon événement et ajouter description de cet atelier.',
},
// Row 15 - USER - Priority 1
{
id: 'us-15',
priority: 1,
category: 'USER',
title: 'Visualiser les inscrits à un atelier/événement',
description: 'En tant qu\'utilisateur, je peux visualiser les inscrits à un atelier/événement en sélectionnant l\'atelier/l\'événement désiré dans la liste des événements/ateliers de l\'événement afin de consulter la liste des inscrits triée par ordre alphabétique.',
},
// Row 16 - MEETING - Priority 1
{
id: 'us-16',
priority: 1,
category: 'MEETING',
title: 'Indiquer un ou plusieurs points de rencontre',
description: 'En tant qu\'utilisateur, je peux indiquer un ou plusieurs points de rencontre en précisant le lieu (café,...) ainsi que l\'heure de cette rencontre (ou le délai ex : 30min avant l\'événement) afin de croiser et faire connaissance d\'autres participants. Je peux aussi échanger avec les autres participants nos liens de contact (QR code ou lien ou bluetooth ?).',
},
// Row 17 - NOTIF - Priority 2
{
id: 'us-17',
priority: 2,
category: 'NOTIF',
title: 'Informer automatiquement d\'autres utilisateurs',
description: 'En tant qu\'utilisateur, je peux informer automatiquement d\'autres utilisateurs de ma participation à un événement en utilisant un système de notifications (e-mail,...) pour transmettre le lien de l\'événement afin d\'informer les utilisateurs qui résident à proximité de l\'événement (distance à déterminer/configurer). Ou bien informer les utilisateurs ayant manifesté un intérêt pour la thématique de l\'événement. Ou bien informer mes abonnés. Ou bien les trois à la fois.',
},
// Row 18 - NOTIF - Priority 2
{
id: 'us-18',
priority: 2,
category: 'NOTIF',
title: 'Être informé lorsque de nouveaux participants s\'inscrivent',
description: 'En tant qu\'utilisateur, je peux être informé lorsque de nouveaux participants s\'inscrivent à un événement auquel je suis inscrit en utilisant un système de notifications (e-mail,...) afin de savoir qui participe à un événement. Éventuellement être uniquement informé des participants que je connais déjà (paramétrable ex : mon réseau).',
},
// Row 19 - NOTIF - Priority 2
{
id: 'us-19',
priority: 2,
category: 'NOTIF',
title: 'Recevoir un récapitulatif des prochaines rencontres',
description: 'En tant qu\'utilisateur, je peux recevoir un récapitulatif des prochaines rencontres en réceptionnant une liste des événements auxquels je suis inscrit ou qui sont proches de chez moi afin de établir un programme des événements auxquels je participe par période (mois, trimestre, année,...).',
},
// Row 20 - USER - Priority 1
{
id: 'us-20',
priority: 1,
category: 'USER',
title: 'Voir le profil des personnes faisant partie de mon réseau',
description: 'En tant qu\'utilisateur, je peux voir le profil des personnes faisant partie de mon réseau ainsi que le profil des personnes publiques et consulter la description de l\'événement afin de savoir si j\'ai envie de participer à cet événement.',
},
// Row 21 - USER - Priority 2
{
id: 'us-21',
priority: 2,
category: 'USER',
title: 'Décider que tous les utilisateurs puissent suivre mes activités',
description: 'En tant qu\'utilisateur, je peux décider que tous les utilisateurs puissent suivre toutes mes activités en déclarant mon profil public afin de communiquer au sujet de mes déplacements et faire la publicité des événements auxquels je participe.',
},
// Row 22 - USER - Priority 2
{
id: 'us-22',
priority: 2,
category: 'USER',
title: 'Parrainer un nouvel utilisateur',
description: 'En tant qu\'utilisateur, je peux parrainer un nouvel utilisateur en lui partageant mon QR code ou lien de contact afin de savoir combien de personnes ont rejoint le réseau grâce à moi.',
},
// Row 23 - USER - Priority 1
{
id: 'us-23',
priority: 1,
category: 'USER',
title: 'Me connecter avec d\'autres utilisateurs',
description: 'En tant qu\'utilisateur, je peux me connecter avec d\'autres utilisateurs en partageant mon QR code ou mon lien de contact afin de étendre mon réseau.',
},
// Row 24 - USER - Priority 2
{
id: 'us-24',
priority: 2,
category: 'USER',
title: 'Être notifié des activités de mes contacts',
description: 'En tant qu\'utilisateur, je peux être notifié lorsqu\'un contact participe à des événements afin de obtenir une synthèse du contenu de chaque atelier et de l\'ensemble des ateliers constituant l\'événement.',
},
// Row 25 - USER - Priority 2
{
id: 'us-25',
priority: 2,
category: 'USER',
title: 'Être averti des événements susceptibles de m\'intéresser',
description: 'En tant qu\'utilisateur, je peux être notifié lorsqu\'un nouvel événement est ajouté près de chez moi et/ou avec une thématique qui m\'intéresse en configurant mes notifications.',
},
// Row 26 - USER - Priority 2
{
id: 'us-26',
priority: 2,
category: 'USER',
title: 'Définir la portée d\'un événement',
description: 'En tant qu\'utilisateur, je peux créer/présenter le contenu de cet événement et le catégoriser par type/thématique (Liste fixe à déterminer) en indiquant son rayon d\'intérêt en kilomètres afin de m\'assurer que les utilisateurs qui habitent trop loin ne reçoivent pas de notification.',
},
];
// ============================================================================
// Computed data & indexes
// ============================================================================
// Build a map of feature id -> screenIds from parsed features
const featureScreenIds = new Map<string, string[]>();
for (const feature of parsedFeatures) {
featureScreenIds.set(feature.id, feature.screenIds);
}
// Export the stories with screenIds computed from parsed features
export const userStories: UserStory[] = userStoriesData.map(story => ({
...story,
screenIds: featureScreenIds.get(story.id) || [],
}));
// Build reverse index: screenId -> stories
const storiesByScreenIndex = new Map<string, UserStory[]>();
userStories.forEach(story => {
story.screenIds.forEach(screenId => {
if (!storiesByScreenIndex.has(screenId)) {
storiesByScreenIndex.set(screenId, []);
}
storiesByScreenIndex.get(screenId)!.push(story);
});
});
// ============================================================================
// Query functions
// ============================================================================
/**
* Get all stories linked to a specific screen
*/
export function getStoriesForScreen(screenId: string): UserStory[] {
return storiesByScreenIndex.get(screenId) || [];
}
/**
* Get all stories filtered by priority
*/
export function getStoriesByPriority(priority: number): UserStory[] {
return userStories.filter(story => story.priority === priority);
}
/**
* Get all stories filtered by category
*/
export function getStoriesByCategory(category: StoryCategory): UserStory[] {
return userStories.filter(story => story.category === category);
}
/**
* Get a story by its ID
*/
export function getStoryById(id: string): UserStory | undefined {
return userStories.find(story => story.id === id);
}
/**
* Get all screen IDs that have linked stories
*/
export function getScreenIdsWithStories(): string[] {
return Array.from(storiesByScreenIndex.keys());
}
/**
* Get story count for a screen
*/
export function getStoryCountForScreen(screenId: string): number {
return storiesByScreenIndex.get(screenId)?.length || 0;
}
// ============================================================================
// Sample data for mockups
// ============================================================================
export const sampleUsers = [
{ id: '1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' },
{ id: '2', name: 'Jean Durand', initials: 'JD', username: '@jeandurand' },
{ id: '3', name: 'Alice Martin', initials: 'AM', username: '@alice' },
{ id: '4', name: 'Baptiste Morel', initials: 'BM', username: '@baptiste' },
{ id: '5', name: 'Camille Dubois', initials: 'CD', username: '@camille' },
{ id: '6', name: 'David Leroy', initials: 'DL', username: '@david' },
];
export const sampleEvents = [
{ id: '1', title: 'Résidence Reconnexion', date: '16-20 fév.', location: 'Le Revel, Rogues (30)', participants: 24 },
{ id: '2', title: 'Atelier low-tech', date: '8 fév.', location: 'La Maison du Vélo, Lyon', participants: 12 },
{ id: '3', title: 'Forum Ouvert Transition', date: '22 fév.', location: 'Tiers-lieu L\'Hermitage', participants: 45 },
{ id: '4', title: 'Formation CNV', date: '1 mars', location: 'MJC Montplaisir, Lyon', participants: 16 },
];
+22 -39
View File
@@ -1,16 +1,17 @@
import { expect, test, afterEach } from 'bun:test';
import { expect, test } from 'bun:test';
import { postJoinActivity, postLeaveActivity } from './outbox';
import type { OutboxActivityInput } from './outbox';
const ACTOR = 'http://localhost:3000/alice';
const OUTBOX = 'http://localhost:3000/alice/outbox';
const MEETING_POINT = 'http://localhost:3000/alice/data/as/event/mp-1';
// What a Join names (decision_2026-10-29_participation-privacy-mechanism): the
// meeting point itself, with no addressee.
const MEETING_POINT = 'http://localhost:3000/bob/data/events/concert-bar';
const input = (hostUri: string | undefined): OutboxActivityInput => ({
const input: OutboxActivityInput = {
meetingPointUri: MEETING_POINT,
hostUri,
session: { webId: ACTOR, token: 'tok-1' },
});
};
/** Installs a scripted fetch double, returning the calls it saw. */
function installFetch(handler: (url: string, init: RequestInit) => Response) {
@@ -37,65 +38,47 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
afterEach(() => {
// Each test installs its own double; nothing to do here beyond documenting
// that fetch is always restored by the per-test handle.
});
test('postJoinActivity discovers the outbox from the actor document and posts a Join to it', async () => {
const double = installFetch((url, init) => {
test('postJoinActivity discovers the outbox from the actor document and posts a Join naming the meeting point, with no addressee', async () => {
const double = installFetch((url) => {
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX, url: `${ACTOR}/as/profile` });
return jsonResponse({}, 201);
});
try {
await postJoinActivity(input('http://localhost:3000/bob'));
await postJoinActivity(input);
expect(double.calls).toHaveLength(2);
const [actorCall, postCall] = double.calls;
expect(actorCall).toBeDefined();
expect(postCall).toBeDefined();
expect(actorCall!.url).toBe(ACTOR);
expect(postCall!.url).toBe(OUTBOX);
expect(postCall!.init.method).toBe('POST');
const headers = postCall!.init.headers as Record<string, string>;
expect(headers['Authorization']).toBe('Bearer tok-1');
expect(headers['Content-Type']).toBe('application/ld+json');
const body = JSON.parse(String(postCall!.init.body));
expect(body).toEqual({
// Exactly these keys: no `to`, no `cc` — the activity stays readable by its
// author and the app only.
expect(JSON.parse(String(postCall!.init.body))).toEqual({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Join',
actor: ACTOR,
object: MEETING_POINT,
to: 'http://localhost:3000/bob',
});
} finally {
double.restore();
}
});
test('postLeaveActivity posts a Leave addressed to the host', async () => {
test('postLeaveActivity posts a Leave naming the meeting point, with no addressee', async () => {
const double = installFetch((url) => {
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
return jsonResponse({}, 201);
});
try {
await postLeaveActivity(input('http://localhost:3000/bob'));
const postCall = double.calls[1]!;
const body = JSON.parse(String(postCall.init.body));
expect(body.type).toBe('Leave');
expect(body.to).toBe('http://localhost:3000/bob');
} finally {
double.restore();
}
});
// A Join/Leave with no addressee is silently dropped by the backend, so posting
// one is refused loudly BEFORE any network call is made.
test('a missing host refuses the post before any network call', async () => {
const double = installFetch(() => jsonResponse({ id: ACTOR, outbox: OUTBOX }));
try {
await expect(postJoinActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
await expect(postLeaveActivity(input(undefined))).rejects.toThrow(/host is unknown|no addressee/i);
expect(double.calls).toHaveLength(0);
await postLeaveActivity(input);
expect(JSON.parse(String(double.calls[1]!.init.body))).toEqual({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Leave',
actor: ACTOR,
object: MEETING_POINT,
});
} finally {
double.restore();
}
@@ -104,7 +87,7 @@ test('a missing host refuses the post before any network call', async () => {
test('an actor document without an outbox refuses the post, naming the activity', async () => {
const double = installFetch(() => jsonResponse({ id: ACTOR }));
try {
await expect(postJoinActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/carries no outbox.*Join/s);
await expect(postJoinActivity(input)).rejects.toThrow(/carries no outbox.*Join/s);
expect(double.calls).toHaveLength(1);
} finally {
double.restore();
@@ -117,7 +100,7 @@ test('a non-2xx outbox response throws with the status', async () => {
return jsonResponse({ error: 'Forbidden' }, 403);
});
try {
await expect(postLeaveActivity(input('http://localhost:3000/bob'))).rejects.toThrow(/HTTP 403/);
await expect(postLeaveActivity(input)).rejects.toThrow(/HTTP 403/);
} finally {
double.restore();
}
+17 -28
View File
@@ -3,24 +3,24 @@
*
* WHY THIS LIVES BESIDE REFINE. The refine-providers package implements no
* `custom` method: there is no escape hatch through the data provider, and a
* participation is not a CRUD write anyway. The platform's production
* precedent (recorded in the data-layer brief, reconnaissance of 2026-10-09)
* is: a join is an `as:Join` activity posted to the USER'S OWN outbox with the
* user's token; the on-behalf backend watches the flow and maintains the
* meeting point's `apods:attendees` collection; membership IS the attendance —
* a leave is `as:Leave` on the same path, and an absence from the collection
* reads as not attending.
* participation is not a CRUD write anyway. A join is an `as:Join` activity
* 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 it carries no
* `to`/`cc`: not public, not addressed to the meeting point's creator. A
* non-public activity is readable only by its author and the app (the app reads
* the outbox through `apods:ReadOutbox`), so the target stays hidden from
* everyone else. The Festipod backend handles it on emission — 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 FLOW. The outbox URI is read off the user's own actor (WebID) document
* (`outbox` predicate) at post time — the reference integration resolves it
* the same way, and posting before the actor document has been read is what it
* guards against. The activity's `object` is the meeting point's URI and its
* `to` is the meeting point's host (the record's Pod-stamped `dc:creator`), so
* the host's Pod receives it. THE HOST IS REQUIRED: a Join/Leave with no
* addressee is silently dropped by the backend, so posting without a host URI
* throws rather than constructing an activity nothing will ever receive.
* the same way. The Join presupposes the author's participation secret on
* their profile; ensuring it is the caller's step, taken before the post.
*
* ASYNCHRONY IS THE PLATFORM'S. The collection update lands when the backend
* ASYNCHRONY IS THE PLATFORM'S. The collections are updated when the backend
* has processed the activity — the caller must not treat the 2xx of the post
* as the membership. The data layer carries an optimistic overlay for exactly
* that gap.
@@ -33,35 +33,24 @@ import { fetchActorDocument } from './actor';
const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams';
export interface OutboxActivityInput {
/** URI of the meeting point the activity targets. */
/** URI of the meeting point the activity names. */
meetingPointUri: string;
/** The meeting point's host (the record's `dc:creator`) — where the activity is addressed. */
hostUri: string | undefined;
/** The authenticated session — whose outbox receives the activity. */
session: CurrentSession;
}
async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise<void> {
const { meetingPointUri, hostUri, session } = input;
// A Join/Leave is addressed to the host: without a `to` the backend never
// routes it, so the attendee would silently never be recorded. Fail loudly
// before posting rather than construct an activity nothing receives.
if (!hostUri) {
throw new Error(
`cannot post ${type} for ${meetingPointUri}: the meeting point's host is unknown ` +
'(no addressee) — the activity would never be delivered.',
);
}
const { meetingPointUri, session } = input;
const actor = await fetchActorDocument(session.webId, session.token);
if (!actor.outbox) {
throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`);
}
// No addressee: the activity stays readable by its author and the app only.
const activity: Record<string, unknown> = {
'@context': ACTIVITY_CONTEXT,
type,
actor: session.webId,
object: meetingPointUri,
to: hostUri,
};
const response = await fetch(actor.outbox, {
@@ -0,0 +1,89 @@
import { expect, test } from 'bun:test';
import { createHash } from 'node:crypto';
import {
MEMBER_URN_PREFIX,
PARTICIPATION_SECRET,
PARTICIPATIONS,
generateParticipationSecret,
memberUrn,
participationItemsOf,
participationSecretOf,
participationSecretValues,
participationsCollectionOf,
} from './participationPrivacy';
const SECRET = 'ab'.repeat(32);
const ALICE = 'http://localhost:3000/alice';
const EVENT = 'http://localhost:3000/bob/data/events/concert';
const OTHER_EVENT = 'http://localhost:3000/bob/data/events/festival';
// --- the secret -------------------------------------------------------------------
test('the secret is read under the full IRI key and under the compact key', () => {
expect(participationSecretOf({ [PARTICIPATION_SECRET]: SECRET })).toBe(SECRET);
expect(participationSecretOf({ 'fp:participationSecret': SECRET })).toBe(SECRET);
});
test('the secret is read from a JSON-LD value object and from a single-entry array', () => {
expect(participationSecretOf({ [PARTICIPATION_SECRET]: { '@value': SECRET } })).toBe(SECRET);
expect(participationSecretOf({ [PARTICIPATION_SECRET]: [SECRET] })).toBe(SECRET);
});
test('a malformed secret reads as absent: wrong length, uppercase, non-hex, non-string', () => {
expect(participationSecretOf({ [PARTICIPATION_SECRET]: 'ab'.repeat(31) })).toBeUndefined();
expect(participationSecretOf({ [PARTICIPATION_SECRET]: 'AB'.repeat(32) })).toBeUndefined();
expect(participationSecretOf({ [PARTICIPATION_SECRET]: 'zz'.repeat(32) })).toBeUndefined();
expect(participationSecretOf({ [PARTICIPATION_SECRET]: 42 })).toBeUndefined();
});
test('a profile with no secret, or no profile at all, reads as absent', () => {
expect(participationSecretOf({ id: `${ALICE}/profile`, name: 'Alice' })).toBeUndefined();
expect(participationSecretOf(undefined)).toBeUndefined();
expect(participationSecretOf('not a record')).toBeUndefined();
});
test('a generated secret is 64 lowercase hex characters, reads back as itself, and differs from the next one', () => {
const a = generateParticipationSecret();
const b = generateParticipationSecret();
expect(a).toMatch(/^[0-9a-f]{64}$/);
expect(participationSecretOf(participationSecretValues(a))).toBe(a);
expect(a).not.toBe(b);
});
test('the secret is written under the full IRI — the platform context defines no fp: prefix', () => {
expect(participationSecretValues(SECRET)).toEqual({ 'http://festipod.org/participationSecret': SECRET });
});
// --- the member URN -------------------------------------------------------------
test('the member URN is the prefixed lowercase-hex sha256 of "<WebID> <event URI> <secret>"', async () => {
const expected = createHash('sha256').update(`${ALICE} ${EVENT} ${SECRET}`).digest('hex');
expect(await memberUrn(ALICE, EVENT, SECRET)).toBe(`${MEMBER_URN_PREFIX}${expected}`);
expect(MEMBER_URN_PREFIX).toBe('urn:festipod:userref:');
});
test('the member URN is the same for one person at one event, and differs per event and per secret', async () => {
const atEvent = await memberUrn(ALICE, EVENT, SECRET);
expect(await memberUrn(ALICE, EVENT, SECRET)).toBe(atEvent);
expect(await memberUrn(ALICE, OTHER_EVENT, SECRET)).not.toBe(atEvent);
expect(await memberUrn(ALICE, EVENT, 'cd'.repeat(32))).not.toBe(atEvent);
});
// --- the participations collection ------------------------------------------------
test('the participations link is read under either key, bare or as an object', () => {
const uri = `${ALICE}/data/as/collection/participations`;
expect(participationsCollectionOf({ [PARTICIPATIONS]: uri })).toBe(uri);
expect(participationsCollectionOf({ 'fp:participations': { id: uri } })).toBe(uri);
expect(participationsCollectionOf({ name: 'Alice' })).toBeUndefined();
});
test('the collection items are the meeting point URIs, whatever list key and entry form', () => {
const m1 = 'http://localhost:3000/bob/data/events/m1';
const m2 = 'http://localhost:3000/bob/data/events/m2';
expect(participationItemsOf({ type: 'Collection', items: [m1, { id: m2 }] })).toEqual([m1, m2]);
expect(participationItemsOf({ orderedItems: m1 })).toEqual([m1]);
expect(participationItemsOf({ 'as:items': [m2] })).toEqual([m2]);
expect(participationItemsOf({ type: 'Collection' })).toEqual([]);
expect(participationItemsOf(undefined)).toEqual([]);
});
+98
View File
@@ -0,0 +1,98 @@
/**
* The profile-side pieces of the participation privacy mechanism
* (decision_2026-10-29_participation-privacy-mechanism): the participation
* secret, the member URN it derives, and the link to a person's participations
* collection. Pure and vocabulary-only — no fetch, no React.
*
* THE SECRET lives on the person's `as:Profile` under
* `http://festipod.org/participationSecret`: 32 random bytes as 64 lowercase
* hex characters. The profile is readable by the person's contacts and the app
* only, so the secret has exactly the audience recognition needs. The browser
* generates it at the first sign-up when the own profile carries none, and
* writes it through the data provider's profile update before posting the
* Join — the backend refuses a Join whose author's profile carries no usable
* secret. Anything that is not 64 lowercase hex characters reads as absent.
*
* THE MEMBER URN is what an attendee list holds:
*
* urn:festipod:userref:<lowercase-hex sha256("<WebID> <event URI> <secret>")>
*
* the event URI being the meeting point's anchor (`as:context`). The backend
* computes and records it; this app computes it only to RECOGNISE a contact
* whose secret it read with their profile. The same for one person across the
* meeting points of one event, unrelated from one event to another.
*
* THE PARTICIPATIONS COLLECTION is linked from the profile under
* `http://festipod.org/participations`: an `as:Collection` of meeting point
* URIs, created by the backend at the person's first Join and readable by the
* owner, the app and the owner's contacts. A profile linking none has no
* participation to list.
*
* The platform context defines no `fp:` prefix, so a profile read back may carry
* either the compact key or the full IRI — both are read. Writes use the full
* IRI, which needs no prefix.
*/
import { asRecord, asString, firstEntry, linkId } from './jsonRecord';
const FP = 'http://festipod.org/';
/** The profile predicate holding the participation secret. */
export const PARTICIPATION_SECRET = `${FP}participationSecret`;
/** The profile predicate linking the participations collection. */
export const PARTICIPATIONS = `${FP}participations`;
/** The prefix of every member URN an attendee list holds. */
export const MEMBER_URN_PREFIX = 'urn:festipod:userref:';
const SECRET_SHAPE = /^[0-9a-f]{64}$/;
/** A predicate's value under its compact (`fp:`) or full IRI key. */
function fpValue(record: unknown, iri: string): unknown {
const r = asRecord(record);
if (!r) return undefined;
return r[iri] ?? r[`fp:${iri.slice(FP.length)}`];
}
/** A literal as JSON-LD may carry it: a bare string, or a value object (`@value`). */
function literalOf(value: unknown): string | undefined {
const entry = firstEntry(value);
return asString(entry) ?? asString(asRecord(entry)?.['@value']);
}
/** The participation secret a raw profile record carries — `undefined` when absent or malformed. */
export function participationSecretOf(profileRecord: unknown): string | undefined {
const value = literalOf(fpValue(profileRecord, PARTICIPATION_SECRET));
return value !== undefined && SECRET_SHAPE.test(value) ? value : undefined;
}
/** A fresh participation secret: 32 random bytes as lowercase hex (Web Crypto). */
export function generateParticipationSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
}
/** The values a profile update sends to store `secret` — the full IRI key, no prefix needed. */
export function participationSecretValues(secret: string): Record<string, string> {
return { [PARTICIPATION_SECRET]: secret };
}
/** The member URN of `webId` at `eventUri` under `secret` — what the attendee lists of that event hold for this person. */
export async function memberUrn(webId: string, eventUri: string, secret: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${webId} ${eventUri} ${secret}`));
const hex = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');
return `${MEMBER_URN_PREFIX}${hex}`;
}
/** The URI of the participations collection a raw profile record links, if any. */
export function participationsCollectionOf(profileRecord: unknown): string | undefined {
return linkId(firstEntry(fpValue(profileRecord, PARTICIPATIONS)));
}
/** The meeting point URIs a participations collection lists (`items`, `orderedItems` or `as:items`). */
export function participationItemsOf(collection: unknown): string[] {
const r = asRecord(collection);
if (!r) return [];
const items = r['items'] ?? r['orderedItems'] ?? r['as:items'];
const list = Array.isArray(items) ? items : items === undefined || items === null ? [] : [items];
return list.map(item => linkId(item)).filter((id): id is string => id !== undefined);
}
+3
View File
@@ -62,6 +62,9 @@ export function adaptUser(s: UnionSubject): FpUserData {
username: one(s, 'username'),
role: one(s, 'role') || undefined,
isPublic: s.props[`${FP}isPublic`] ? boolOf(s, 'isPublic') : undefined,
bio: one(s, 'bio') || undefined,
city: one(s, 'city') || undefined,
photo: one(s, 'photo') || undefined,
};
}
+57 -43
View File
@@ -435,56 +435,77 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then(\"le pod provider local répond sur son endpoint OpenID\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);\n expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);\n});",
"lineNumber": 40
"lineNumber": 95
},
{
"pattern": "le backend Festipod sert la description de son application",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then(\"le backend Festipod sert la description de son application\", async function (this: FestipodWorld) {\n const { backendUrl } = apodsStackConfig();\n const description = await readResource(undefined, `${backendUrl}/app`);\n // The description IS the OIDC client the app registers with: it must carry\n // the interop access-need group the Pod grants, not merely answer 200.\n expect(JSON.stringify(description.type)).to.contain('Application');\n expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');\n});",
"lineNumber": 46
"lineNumber": 101
},
{
"pattern": "l'application Festipod est chargée depuis la pile locale",
"keyword": "When",
"file": "activitypods.steps.ts",
"sourceCode": "When(\"l'application Festipod est chargée depuis la pile locale\", async function (this: FestipodWorld) {\n const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');\n expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;\n await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });",
"lineNumber": 57
"lineNumber": 112
},
{
"pattern": "l'entrée de connexion au Pod est proposée",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then(\"l'entrée de connexion au Pod est proposée\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const host = new URL(podProviderUrl).host;\n await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });",
"lineNumber": 65
"lineNumber": 120
},
{
"pattern": "aucune erreur runtime n'a été émise au chargement",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then(\"aucune erreur runtime n'a été émise au chargement\", function (this: FestipodWorld) {\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises au chargement de l'app :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
"lineNumber": 71
"lineNumber": 126
},
{
"pattern": "l'application Festipod est installée sur le Pod de la session",
"keyword": "Given",
"file": "activitypods.steps.ts",
"sourceCode": "Given(\"l'application Festipod est installée sur le Pod de la session\", async function (this: FestipodWorld) {\n const pod = session(this);\n const config = apodsStackConfig();\n // The registered agent IS the Solid-OIDC client_id: the app actor URI the\n // backend serves as both the actor and its Client ID Document. Registering\n // against `/app` (the alias) is refused — the agent must be the actor URI.\n const description = await readResource(undefined, `${config.backendUrl}/app`);\n const appUri = requires(this, recordUri(description), 'app actor URI (the id of GET /app)');\n const groupUri = requires(this, linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');\n const group = await readResource(undefined, groupUri);\n const acceptedAccessNeeds = arrayOf(group['interop:hasAccessNeed']);\n const acceptedSpecialRights = arrayOf(group['apods:hasSpecialRights']);\n\n const response = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/register`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pod.token}` },\n body: JSON.stringify({ appUri, acceptedAccessNeeds, acceptedSpecialRights }),\n });",
"lineNumber": 143
},
{
"pattern": "la session rejoint le point de rencontre",
"keyword": "When",
"file": "activitypods.steps.ts",
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own path (data/outbox.ts): as:Join to the user's outbox, carrying\n // the meeting point record so the backend finds its apods:attendees collection.\n await postOutboxActivity(pod, 'Join', await readResource(pod, mpUri), pod.webId);\n});",
"lineNumber": 158
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own path (src/shared/data/outbox.ts): as:Join to the user's own\n // outbox, `object` = the meeting point URI and `to` = the HOST (the record's\n // dc:creator). Without the addressee the backend silently drops the activity;\n // and an activity carrying no routing target never reaches the host.\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Join', mpUri, host);\n});",
"lineNumber": 267
},
{
"pattern": "la session quitte le point de rencontre",
"keyword": "When",
"file": "activitypods.steps.ts",
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n await postOutboxActivity(pod, 'Leave', await readResource(pod, mpUri), pod.webId);\n});",
"lineNumber": 166
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Leave', mpUri, host);\n});",
"lineNumber": 278
},
{
"pattern": "la collection apods:attendees contient la session",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await readAttendees(pod, collectionUri);\n expect(members.map(linkId)).to.include(pod.webId);\n});",
"lineNumber": 172
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n expect(members.map(linkId)).to.include(pod.webId);\n});",
"lineNumber": 285
},
{
"pattern": "le compteur du point de rencontre compte cette inscription",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const collection = await readResource(pod, collectionUri);\n // The count is the collection total — kept as-is, never the number of\n // RESOLVED profiles, so it stays total for a reader who resolves nobody.\n const raw = collection.totalItems ?? collection['as:totalItems'];\n const total = raw === undefined ? members.length : Number(raw);\n expect(total, 'the count reflects the joined session').to.be.greaterThan(0);\n expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId);\n});",
"lineNumber": 304
},
{
"pattern": "la liste des participants rend ce participant comme anonyme",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const joined = members.find(member => linkId(member) === pod.webId);\n expect(joined, 'the joined session is listed').to.not.be.undefined;\n // \"Anonymous\" is the read-side fact: the member travels as a REFERENCE with no\n // profile the reader can resolve — the list has nothing but the URI to render,\n // which is exactly what the app shows for a participant it cannot dereference.\n const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];\n const hasProfile =\n typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);\n expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;\n});",
"lineNumber": 317
},
{
"pattern": "le portefeuille contient des données de test",
@@ -844,17 +865,17 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"lineNumber": 27
},
{
"pattern": "je peux contacter l'utilisateur",
"pattern": "je peux ajouter l'utilisateur à mon réseau",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux contacter l\\'utilisateur', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n expect(buttons.some(t => t.includes('Contacter')),\n 'User profile should expose a \"Contacter\" button').to.be.true;\n});",
"sourceCode": "Then('je peux ajouter l\\'utilisateur à mon réseau', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');\n expect(buttons.some(t => /Ajouter au réseau|Dans mon réseau|Demande envoyée/.test(t)),\n 'User profile should expose the \"Ajouter au réseau\" action (or its resulting state)').to.be.true;\n});",
"lineNumber": 44
},
{
"pattern": "je peux voir les événements auxquels l'utilisateur a participé",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux voir les événements auxquels l\\'utilisateur a participé', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const text = doc!.body.textContent ?? '';\n expect(text.includes('Événements à venir'),\n 'User profile should have \"Événements à venir\" section').to.be.true;\n expect(text.includes('Événements passés'),\n 'User profile should have \"Événements passés\" section').to.be.true;\n});",
"sourceCode": "Then('je peux voir les événements auxquels l\\'utilisateur a participé', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const text = doc!.body.textContent ?? '';\n expect(text.includes('Événements'),\n 'User profile should have the \"Événements\" section').to.be.true;\n});",
"lineNumber": 53
},
{
@@ -862,21 +883,14 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('les événements affichent leur localisation et distance', async function (this: FestipodWorld) {\n expect(this.currentScreenId).to.equal('user-profile');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const text = doc!.body.textContent ?? '';\n expect(text.includes('📍'), 'Events should show a 📍 location icon').to.be.true;\n expect(/\\d+\\s*km/.test(text), 'Events should show a distance in km').to.be.true;\n});",
"lineNumber": 64
},
{
"pattern": "je peux voir le QR code",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux voir le QR code', async function (this: FestipodWorld) {\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const text = doc!.body.textContent ?? '';\n if (this.currentScreenId === 'share-profile') {\n expect(text.includes('Scannez pour me retrouver'),\n 'Share profile should invite to scan for follow-up').to.be.true;\n } else if (this.currentScreenId === 'meeting-points') {\n expect(text.includes('QR') || text.includes('Scannez'),\n 'Meeting points should reference a QR code').to.be.true;\n } else {\n expect.fail(`QR code should be on share-profile or meeting-points, not \"${this.currentScreenId}\"`);\n }\n});",
"lineNumber": 73
"lineNumber": 62
},
{
"pattern": "je peux voir le lien de partage",
"keyword": "Then",
"file": "user.steps.ts",
"sourceCode": "Then('je peux voir le lien de partage', async function (this: FestipodWorld) {\n expect(this.currentScreenId, 'Share link should be on share-profile screen').to.equal('share-profile');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const text = doc!.body.textContent ?? '';\n expect(text.includes('Mon lien de profil'),\n 'Share profile should label the profile link section').to.be.true;\n expect(/festipod\\.app\\/u\\//.test(text),\n 'Share profile should display a festipod.app/u/… link').to.be.true;\n});",
"lineNumber": 88
"sourceCode": "Then('je peux voir le lien de partage', async function (this: FestipodWorld) {\n expect(this.currentScreenId, 'Share link should be on share-profile screen').to.equal('share-profile');\n const doc = this.renderedDoc;\n expect(doc, 'Screen should be rendered').to.not.be.null;\n const text = doc!.body.textContent ?? '';\n expect(text.includes(\"Mon lien d'invitation\"),\n 'Share profile should label the invite link section').to.be.true;\n // No connected pod session in the local harness: the invite capability VC\n // cannot be issued, so the screen must say it honestly instead of\n // displaying a fake address.\n expect(text.includes(\"Lien d'invitation indisponible\"),\n 'Share profile should admit the link is unavailable without a connected pod').to.be.true;\n});",
"lineNumber": 71
},
{
"pattern": "un navigateur {string} avec le wallet partagé",
@@ -939,133 +953,133 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Given",
"file": "navigation.steps.ts",
"sourceCode": "Given('je suis sur la page {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n await this.navigateTo(`#/demo/${screenId}`);\n});",
"lineNumber": 37
"lineNumber": 38
},
{
"pattern": "je suis connecté en tant qu'utilisateur",
"keyword": "Given",
"file": "navigation.steps.ts",
"sourceCode": "Given('je suis connecté en tant qu\\'utilisateur', async function (this: FestipodWorld) {\n this.isAuthenticated = true;\n});",
"lineNumber": 42
"lineNumber": 43
},
{
"pattern": "je suis connecté",
"keyword": "Given",
"file": "navigation.steps.ts",
"sourceCode": "Given('je suis connecté', async function (this: FestipodWorld) {\n this.isAuthenticated = true;\n});",
"lineNumber": 46
"lineNumber": 47
},
{
"pattern": "je ne suis pas connecté",
"keyword": "Given",
"file": "navigation.steps.ts",
"sourceCode": "Given('je ne suis pas connecté', async function (this: FestipodWorld) {\n this.isAuthenticated = false;\n});",
"lineNumber": 50
"lineNumber": 51
},
{
"pattern": "je navigue vers {string}",
"keyword": "When",
"file": "navigation.steps.ts",
"sourceCode": "When('je navigue vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n await this.navigateTo(`#/demo/${screenId}`);\n});",
"lineNumber": 54
"lineNumber": 55
},
{
"pattern": "je clique sur {string}",
"keyword": "When",
"file": "navigation.steps.ts",
"sourceCode": "When('je clique sur {string}', async function (this: FestipodWorld, elementName: string) {\n const source = this.getRenderedText();\n const escapedName = elementName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');\n expect(pattern.test(source), `Clickable element \"${elementName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 59
"lineNumber": 60
},
{
"pattern": "je sélectionne {string}",
"keyword": "When",
"file": "navigation.steps.ts",
"sourceCode": "When('je sélectionne {string}', async function (this: FestipodWorld, elementName: string) {\n const source = this.getRenderedText();\n const escapedName = elementName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`onClick[^>]*>[^<]*${escapedName}`, 'i');\n expect(pattern.test(source), `Selectable element \"${elementName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 66
"lineNumber": 67
},
{
"pattern": "je clique sur le bouton {string}",
"keyword": "When",
"file": "navigation.steps.ts",
"sourceCode": "When('je clique sur le bouton {string}', async function (this: FestipodWorld, buttonName: string) {\n const source = this.getRenderedText();\n const escapedName = buttonName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`<Button[^>]*>[^<]*${escapedName}[^<]*</Button>`, 'i');\n expect(pattern.test(source), `Button \"${buttonName}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 73
"lineNumber": 74
},
{
"pattern": "je suis redirigé vers {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je suis redirigé vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
"lineNumber": 80
"lineNumber": 81
},
{
"pattern": "je vois l'écran {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je vois l\\'écran {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
"lineNumber": 85
"lineNumber": 86
},
{
"pattern": "je reste sur la page {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je reste sur la page {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n expect(this.currentScreenId).to.equal(screenId);\n});",
"lineNumber": 90
"lineNumber": 91
},
{
"pattern": "l'écran contient une section {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient une section {string}', async function (this: FestipodWorld, sectionName: string) {\n expect(this.hasText(sectionName), `Section \"${sectionName}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 95
"lineNumber": 96
},
{
"pattern": "je peux naviguer vers {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je peux naviguer vers {string}', async function (this: FestipodWorld, pageName: string) {\n const screenId = resolveScreenId(pageName);\n const source = this.getRenderedText();\n const pattern = new RegExp(`navigate\\\\s*\\\\(\\\\s*['\"]${screenId}['\"]\\\\s*\\\\)`);\n expect(pattern.test(source), `Navigation to \"${screenId}\" should exist in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 99
"lineNumber": 100
},
{
"pattern": "la navigation affiche {string} comme actif",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('la navigation affiche {string} comme actif', async function (this: FestipodWorld, menuItem: string) {\n const source = this.getRenderedText();\n const escapedItem = menuItem.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const pattern = new RegExp(`label:\\\\s*['\"]${escapedItem}['\"][^}]*active:\\\\s*true`, 'i');\n expect(pattern.test(source), `Menu item \"${menuItem}\" should be active in NavBar of screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 106
"lineNumber": 107
},
{
"pattern": "l'écran contient un bouton {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient un bouton {string}', async function (this: FestipodWorld, buttonText: string) {\n expect(this.hasText(buttonText), `Button \"${buttonText}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 113
"lineNumber": 114
},
{
"pattern": "l'écran contient un champ {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient un champ {string}', async function (this: FestipodWorld, fieldLabel: string) {\n expect(this.hasText(fieldLabel), `Field \"${fieldLabel}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 117
"lineNumber": 118
},
{
"pattern": "l'écran contient un texte {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient un texte {string}', async function (this: FestipodWorld, text: string) {\n expect(this.hasText(text), `Text \"${text}\" should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 121
"lineNumber": 122
},
{
"pattern": "l'écran contient un avatar",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('l\\'écran contient un avatar', async function (this: FestipodWorld) {\n const source = this.getRenderedText();\n const hasAvatar = /<Avatar/.test(source);\n expect(hasAvatar, `Avatar should be present in screen \"${this.currentScreenId}\"`).to.be.true;\n});",
"lineNumber": 125
"lineNumber": 126
},
{
"pattern": "je peux voir la section {string}",
"keyword": "Then",
"file": "navigation.steps.ts",
"sourceCode": "Then('je peux voir la section {string}', async function (this: FestipodWorld, sectionName: string) {\n const source = this.getRenderedText();\n const found = source.includes(sectionName);\n if (!found) {\n this.attach(`Looking for section: \"${sectionName}\"`, 'text/plain');\n this.attach(`Rendered text: ${source.substring(0, 500)}...`, 'text/plain');\n }\n expect(found, `Section \"${sectionName}\" should be visible on screen`).to.be.true;\n});",
"lineNumber": 131
"lineNumber": 132
}
];
+8 -1
View File
@@ -33,6 +33,8 @@ export interface FpUserData {
isPublic?: boolean;
bio?: string;
city?: string;
/** URL of the profile picture (absent: the avatar falls back to initials). */
photo?: string;
eventsCount?: number;
friendsCount?: number;
participationsCount?: number;
@@ -55,7 +57,12 @@ export interface FpMeetingPointData {
/** Where participants gather (SHEX MeetingPoint.place). */
place?: string;
location: string;
time: string;
/**
* Free-text moment ("30 min avant"). No active backend has a slot for it —
* the form stopped offering it for that reason — so it is absent on read
* and the displays mask it when missing.
*/
time?: string;
/** NURI of the meeting point's inbox (wired in T02.b/c). */
inbox?: string;
/**
+59
View File
@@ -0,0 +1,59 @@
/**
* useInviteLink — the invitation flow shared by ConnectScreen and
* ShareProfileScreen, so the two screens carry no duplicated logic.
*
* THE FLOW. The pod frontend URL is discovered through nodeinfo (T08.a
* discovery, now serving the invite page instead of the /network page); on
* demand, the session's Pod issues an invite capability VC
* (`utils/inviteLink.ts`) and the two are combined into the page link the
* invitee opens. Nothing is created before the person asks for it: no
* session (offline demo) or no discoverable frontend is reported upfront,
* and a failed creation is reported honestly — both screens only render
* what this hook returns.
*/
import { useCallback, useState } from 'react';
import { createInviteCapability, invitePageLink } from '../utils/inviteLink';
import { usePodFrontendUrl } from '../utils/nodeinfo';
import { useCurrentSession } from '../utils/currentSession';
export interface InviteLink {
/** `<frontendUrl>/invite/<capabilityUri>` once created, '' until then. */
link: string;
/** True while the pod frontend URL is still being discovered. */
discovering: boolean;
/** True while the Pod is issuing the capability VC. */
creating: boolean;
/** The creation failure, to be displayed as-is. */
error: string | undefined;
/** Why no link can be created here, when knowable upfront. */
unavailable: 'session' | 'frontend' | undefined;
/** Issue the capability and build the page link. Ignored while creating. */
create: () => void;
}
export function useInviteLink(): InviteLink {
const session = useCurrentSession();
const { loading: discovering, frontendUrl } = usePodFrontendUrl(session?.webId);
const [link, setLink] = useState('');
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const create = useCallback(() => {
if (!session || !frontendUrl || creating) return;
setCreating(true);
setError(undefined);
createInviteCapability(session)
.then((capabilityUri) => setLink(invitePageLink(frontendUrl, capabilityUri)))
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => setCreating(false));
}, [session, frontendUrl, creating]);
const unavailable: InviteLink['unavailable'] = !session
? 'session'
: !discovering && !frontendUrl
? 'frontend'
: undefined;
return { link, discovering, creating, error, unavailable, create };
}
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useState } from 'react';
import { fetchJson, resolveContainerUri } from '@activitypods/refine-providers/utils';
import { useCurrentSession } from '../utils/currentSession';
import { grantPublicRead } from '../utils/grantPublicRead';
/**
* Upload an image into the signed-in user's Pod (the `file` container) and return
* its URL, readable by anyone. Same flow as the ActivityPods reference apps: a
* binary POST straight from the browser, the URL read from the `Location` header,
* then public read granted. Needs an ActivityPods session — without one (the
* disconnected demo) the upload rejects.
*/
/** Largest image accepted, in bytes. */
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export function useUploadImage(): { upload: (file: File) => Promise<string>; uploading: boolean; error?: Error } {
const session = useCurrentSession();
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<Error | undefined>();
const upload = useCallback(async (file: File): Promise<string> => {
setUploading(true);
setError(undefined);
try {
if (!file.type.startsWith('image/')) throw new Error('Only image files can be uploaded');
if (file.size > MAX_IMAGE_BYTES) throw new Error('The image is too large (10 MB at most)');
if (!session) throw new Error('Not authenticated: an image can only be uploaded to a Pod');
const containerUri = await resolveContainerUri(
'file',
{ types: ['http://semapps.org/ns/core#File'] },
session.webId,
session.token,
['https://www.w3.org/ns/activitystreams'],
);
const { headers } = await fetchJson(
containerUri,
{ method: 'POST', body: file, headers: { 'Content-Type': file.type } },
session.token,
);
const location = headers.get('Location');
if (!location) throw new Error('The Pod did not return a Location header when uploading the file');
await grantPublicRead(location, session.token);
return location;
} catch (err) {
const e = err instanceof Error ? err : new Error(String(err));
setError(e);
throw e;
} finally {
setUploading(false);
}
}, [session]);
return { upload, uploading, error };
}
@@ -161,6 +161,20 @@ test('event toPatch sends only what changed', () => {
expect(event.toPatch({ title: 'Nouveau titre' })).toEqual({ name: 'Nouveau titre' });
});
test('event toValues ignores an empty cover image', () => {
const { event } = getBackend('activitypods').bindings;
// An emptied picker must not wipe a stored cover by writing `image: ''` — the
// attribute is omitted (the forms send `coverImage || undefined`).
expect(
event.toValues({ title: 'T', description: '', date: '', location: '', participantCount: 0, coverImage: '' }),
).toEqual({
name: 'T',
content: '',
location: { name: '' },
to: 'https://www.w3.org/ns/activitystreams#Public',
});
});
test('meetingPoint toDomain reads the context link and the host from dc:creator', () => {
const { meetingPoint } = getBackend('activitypods').bindings;
const objectForm = meetingPoint.toDomain({
@@ -180,7 +194,6 @@ test('meetingPoint toDomain reads the context link and the host from dc:creator'
title: 'Rdv entrée',
description: 'On se retrouve là-bas',
location: 'Café de la Place',
time: '',
});
// The full IRI form (a context that does not compact `dc`) and the legacy
// `organizer` fallback both still resolve the host.
@@ -234,6 +247,9 @@ test('profile toDomain maps the AS2 profile fields and derives the initials', ()
preferredUsername: 'mariedupont',
summary: 'Organisatrice d’ateliers',
location: { name: 'Lyon' },
// The avatar photo round-trips under `vcard:photo`, the term the reference
// apps store it under (NOT the AS2 `image` — that is the event cover's slot).
'vcard:photo': 'https://example.org/marie.jpg',
});
expect(domain).toEqual({
id: 'http://localhost:3000/alice',
@@ -242,6 +258,7 @@ test('profile toDomain maps the AS2 profile fields and derives the initials', ()
username: 'mariedupont',
bio: 'Organisatrice d’ateliers',
city: 'Lyon',
photo: 'https://example.org/marie.jpg',
});
expect(profile.toDomain({ id: 'http://localhost:3000/bob', name: 'Bob' })).toMatchObject({
initials: 'BO',
@@ -249,6 +266,29 @@ test('profile toDomain maps the AS2 profile fields and derives the initials', ()
});
});
// The pod seeds its own profile document from the signup name, under the vcard
// terms (`vcard:fn` / `vcard:given-name`) — never the AS2 `name` the app writes.
// Reading them on sign-in is what keeps a fresh pod out of the profile-completion
// gate (regression 2026-10-26: the gate claimed the pod provided no name).
test('profile toDomain reads the pod-seeded name from the vcard terms', () => {
const { profile } = getBackend('activitypods').bindings;
expect(profile.toDomain({
id: 'http://localhost:3000/probe3/data/1ed05d08',
type: ['vcard:Individual', 'Profile'],
'vcard:fn': 'Probe 3',
})).toMatchObject({ name: 'Probe 3', initials: 'P3' });
expect(profile.toDomain({
id: 'http://localhost:3000/probe4/data/9f2c',
'vcard:given-name': 'Given Only',
})).toMatchObject({ name: 'Given Only' });
// The AS2 `name` still wins when both are present (the app's own writes).
expect(profile.toDomain({
id: 'http://localhost:3000/alice',
name: 'AS2 Name',
'vcard:fn': 'Vcard Name',
})).toMatchObject({ name: 'AS2 Name' });
});
test('profile toValues writes the mapped fields and none of the derived ones', () => {
const { profile } = getBackend('activitypods').bindings;
const values = profile.toValues({
@@ -259,6 +299,7 @@ test('profile toValues writes the mapped fields and none of the derived ones', (
isPublic: true,
bio: 'Bio',
city: 'Lyon',
photo: 'https://example.org/marie.jpg',
eventsCount: 23,
friendsCount: 7,
participationsCount: 4,
@@ -269,6 +310,8 @@ test('profile toValues writes the mapped fields and none of the derived ones', (
summary: 'Bio',
// `location` is `@type: @id`: the city is a named node, not a bare text value.
location: { name: 'Lyon' },
// The avatar photo goes to `vcard:photo` — the reference apps' term for it.
'vcard:photo': 'https://example.org/marie.jpg',
});
});
@@ -325,8 +368,10 @@ test('backend satisfies the RefineBackend contract shape', () => {
eventId: 'event-1',
title: undefined,
location: 'Café de la Place',
time: '',
});
// `time` has no slot on this backend: a written point reads back without the
// property at all.
expect(backend.bindings.meetingPoint.toDomain(stored)).not.toHaveProperty('time');
});
// Events and meeting points share the as/Event container: the presence of
+34 -9
View File
@@ -27,9 +27,13 @@
* `as/Event` shape is open, so the extra predicate breaks nothing); a profile is an
* `as/Profile`. Fields the app's domain type carries but ActivityStreams has no slot
* for are left unmapped on purpose, one comment per field — recording them would need
* a Festipod-owned predicate, and a custom prefix must be registered at prefix.cc
* before the Pod provider accepts it. App fields with no slot read back as their
* type's empty value (`''` / `undefined`) and are re-derived by the reader.
* a Festipod-owned predicate. Such a predicate is not refused for want of a prefix.cc
* registration: the pod stores a `http://festipod.org/` predicate written on a profile
* with the app's token — the browser's own path (the participation secret,
* `data/participationPrivacy.ts`) — and returns it under its full IRI, which the rights
* e2e verifies on every run (`join.secret-app-write`, `join.secret-app-readback`). App
* fields with no slot read back as their type's empty value (`''` / `undefined`) and
* are re-derived by the reader.
*
* EVENTS AND MEETING POINTS SHARE ONE CONTAINER. Container discovery is per type
* (TypeIndex `solid:forClass`), and both resources resolve to `as:Event`, so both
@@ -37,9 +41,9 @@
* presence of `as:context` is what distinguishes a meeting point, so the reader must
* filter on it — that is where the distinction lives, since the container does not.
*
* PARTICIPATIONS ARE A COLLECTION, READ EMBEDDED. The production precedent
* (hand-off, reconnaissance of 2026-10-09): a join adds the attendee to the event's
* `apods:attendees` collection and a leave removes it, so membership IS the
* PARTICIPATIONS ARE A COLLECTION, READ EMBEDDED. A join adds the participant's member
* URN to the meeting point's `apods:attendees` collection and a leave removes it
* (decision_2026-10-29_participation-privacy-mechanism), so membership IS the
* attendance. That collection is per-event and is NOT a TypeIndex container, so it is
* not a listed resource here: it arrives inside the event record — the collection URI
* under `apods:attendees`, its members dereferenced when the reader may read them and
@@ -62,6 +66,9 @@
* `apods:` prefixes and its round-trip on a real pod is verified (concept
* `data-layer`, `cookbook_local-activitypods-platform`); the package default, the
* bare ActivityStreams context, does not define the `as:` prefix a CURIE needs.
* The platform context ALSO defines `vcard:` (verified 2026-10-22 against the
* live context) — the prefix the profile photo mapping relies on; no local
* prefix supplement is needed.
*
* THE AUTH PROVIDER COMES WITH THE SET. The contract ships all three providers
* together, and the data/live providers take the auth provider as a parameter. Its
@@ -214,7 +221,10 @@ function eventValues(data: Partial<FpEventData>): Record<string, unknown> {
const endTime = composeDateTime(data.endDate, data.endTime);
if (endTime !== undefined) out['endTime'] = endTime;
if (data.location !== undefined) out['location'] = namedTextValue(data.location);
if (data.coverImage !== undefined) out['image'] = data.coverImage;
// An empty picker means "no cover chosen": omit the attribute entirely rather
// than write an empty `image` over a previously stored one. The forms send
// `coverImage || undefined`; a bare `''` slipping through is ignored here.
if (data.coverImage) out['image'] = data.coverImage;
return out;
}
@@ -283,7 +293,6 @@ function meetingPointFromRecord(record: unknown): FpMeetingPointData {
title: firstString(r?.['name']),
description: firstString(r?.['content']),
location: namedValue(r?.['location']) ?? '',
time: '',
};
}
@@ -299,12 +308,27 @@ function profileValues(data: Partial<FpUserData>): Record<string, unknown> {
if (data.username !== undefined) out['preferredUsername'] = data.username;
if (data.bio !== undefined) out['summary'] = data.bio;
if (data.city !== undefined) out['location'] = namedTextValue(data.city);
// The avatar photo maps to `vcard:photo`, the term the reference apps
// (welcometomyplace, mutual-aid) store it under — NOT the plain AS2 `image`,
// which stays the event cover's slot. The platform context defines the
// `vcard:` prefix (verified 2026-10-22 against the live context), so the
// CURIE expands on write and compacts back on read.
if (data.photo !== undefined) out['vcard:photo'] = data.photo;
return out;
}
function profileFromRecord(record: unknown): FpUserData {
const r = asRecord(record);
const name = firstString(r?.['name']) ?? '';
// The pod seeds its own vcard profile from the signup name (`vcard:fn`, with
// `vcard:given-name` as a fallback seen on fresh pods) — NOT the AS2 `name`
// this app writes. Reading both keeps the first sign-in out of the
// profile-completion gate ("Votre Pod ne fournit pas encore de nom" was
// factually wrong for such pods). Same precedent as `vcard:photo` below.
const name =
firstString(r?.['name']) ??
firstString(r?.['vcard:fn']) ??
firstString(r?.['vcard:given-name']) ??
'';
return {
id: recordId(record),
name,
@@ -312,6 +336,7 @@ function profileFromRecord(record: unknown): FpUserData {
username: firstString(r?.['preferredUsername']) ?? '',
bio: firstString(r?.['summary']),
city: namedValue(r?.['location']),
photo: urlValue(r?.['vcard:photo']),
};
}
@@ -225,6 +225,39 @@ export const festipodShapesSchema = {
iri: "http://festipod.org/isPublic",
readablePredicate: "isPublic",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/bio",
readablePredicate: "bio",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/city",
readablePredicate: "city",
},
{
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/photo",
readablePredicate: "photo",
},
],
},
"http://festipod.org/Participation": {
@@ -142,6 +142,24 @@ export interface UserProfile {
* Original IRI: http://festipod.org/isPublic
*/
isPublic?: boolean;
/**
* Free-text presentation of the user
*
* Original IRI: http://festipod.org/bio
*/
bio?: string;
/**
* City the user lives in
*
* Original IRI: http://festipod.org/city
*/
city?: string;
/**
* URL of the profile picture
*
* Original IRI: http://festipod.org/photo
*/
photo?: string;
}
/**
@@ -42,6 +42,12 @@ fp:UserProfile {
// rdfs:comment "Role or title of the user" ;
fp:isPublic xsd:boolean ?
// rdfs:comment "Whether the profile is public" ;
fp:bio xsd:string ?
// rdfs:comment "Free-text presentation of the user" ;
fp:city xsd:string ?
// rdfs:comment "City the user lives in" ;
fp:photo xsd:string ?
// rdfs:comment "URL of the profile picture" ;
}
fp:Participation {
+1
View File
@@ -19,6 +19,7 @@ const screenNameMap: Record<string, string> = {
'inviter des amis': 'invite',
'invitation': 'invite',
'mon profil': 'profile',
'modifier le profil': 'edit-profile',
'profil': 'profile',
'profil utilisateur': 'user-profile',
'profil d\'un utilisateur': 'user-profile',
+98 -12
View File
@@ -1,3 +1,5 @@
import { createHash, randomBytes } from 'node:crypto';
/**
* The local ActivityPods stack, driven over real HTTP — the instrument the
* `@e2e-activitypods` smoke layer uses.
@@ -58,7 +60,7 @@ export interface ApodsSession {
const JSON_CONTEXT = 'https://activitypods.org/context.json';
const AS_CONTEXT = 'https://www.w3.org/ns/activitystreams';
function asRecord(value: unknown): JsonRecord | undefined {
export function asRecord(value: unknown): JsonRecord | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as JsonRecord) : undefined;
}
@@ -142,9 +144,19 @@ export async function podLogin(
}
const parsed = JSON.parse(login.text) as { token: string; webId: string };
const partial: ApodsSession = { webId: parsed.webId, token: parsed.token, outbox: '', storage: '' };
const actor = await readResource(partial, parsed.webId);
const outbox = linkId(actor['outbox']);
const storage = linkId(actor['pim:storage']);
// The pod patches `outbox`/`pim:storage` into the WebId document asynchronously after
// the account exists (secure dataset + storage provisioning); on a loaded dev stack
// that lags the login by many seconds. Poll instead of a single read.
let outbox: string | undefined;
let storage: string | undefined;
const deadline = Date.now() + 90000;
for (;;) {
const actor = await readResource(partial, parsed.webId);
outbox = linkId(actor['outbox']);
storage = linkId(actor['pim:storage']);
if ((outbox && storage) || Date.now() >= deadline) break;
await new Promise(r => setTimeout(r, 3000));
}
if (!outbox || !storage) {
throw new Error(`Actor ${parsed.webId} carries no outbox or storage`);
}
@@ -181,10 +193,21 @@ export async function createResource(
return location;
}
/** Post an activity to the session's own outbox (the app's join/leave path). */
export async function postOutboxActivity(session: ApodsSession, type: string, object: unknown, to?: string): Promise<void> {
/** The member URN prefix every attendee list entry carries (decision_2026-10-29_participation-privacy-mechanism). */
export const MEMBER_URN_PREFIX = 'urn:festipod:userref:';
/** The profile predicate holding a person's participation secret (64 lowercase hex). */
export const PARTICIPATION_SECRET = 'http://festipod.org/participationSecret';
/** The profile predicate linking a person's participations collection. */
export const PARTICIPATIONS = 'http://festipod.org/participations';
/**
* Post a participation activity (`Join` / `Leave`) to the session's own outbox,
* the shape the app posts (`src/shared/data/outbox.ts`): `object` = the meeting
* point, NO `to`/`cc` — not public, addressed to no one, so only its author and
* the app read it. Returns the stored activity's URI (`Location`).
*/
export async function postOutboxActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string): Promise<string | undefined> {
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object };
if (to) activity['to'] = to;
const response = await request(session.outbox, {
method: 'POST',
headers: { 'Content-Type': 'application/ld+json' },
@@ -194,15 +217,17 @@ export async function postOutboxActivity(session: ApodsSession, type: string, ob
if (response.status !== 200 && response.status !== 201) {
throw new Error(`Outbox ${type} post failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
}
return response.headers.get('Location') ?? undefined;
}
/**
* The members of an `apods:attendees` collection, read by `getOne` on the
* collection URI. Returns the raw member entries (bare URIs when the reader
* may not dereference them) — a member the reader cannot resolve is a state
* the app model needs, so it is reported, never dropped.
* The members of an `apods:attendees` collection, read by a GET on the
* collection URI. Returns the raw member entries. Since
* decision_2026-10-29_participation-privacy-mechanism the members are MEMBER
* URNs (`urn:festipod:userref:…`), never WebIDs: values in no pod, computable
* only by whoever reads the participant's secret.
*/
export async function readAttendees(session: ApodsSession, collectionUri: string): Promise<unknown[]> {
export async function readAttendees(session: ApodsSession | undefined, collectionUri: string): Promise<unknown[]> {
const response = await request(collectionUri, { session });
if (response.status !== 200) {
throw new Error(`Read collection ${collectionUri} failed: HTTP ${response.status} ${response.text.slice(0, 200)}`);
@@ -212,3 +237,64 @@ export async function readAttendees(session: ApodsSession, collectionUri: string
if (Array.isArray(raw)) return raw;
return raw === undefined || raw === null ? [] : [raw];
}
/** A predicate's single literal under its full IRI or its `fp:` CURIE (a string or a value object). */
function fpLiteral(record: JsonRecord | undefined, iri: string): string | undefined {
if (!record) return undefined;
const raw = firstEntry(record[iri] ?? record[`fp:${iri.slice('http://festipod.org/'.length)}`]);
if (typeof raw === 'string') return raw;
const value = asRecord(raw)?.['@value'];
return typeof value === 'string' ? value : undefined;
}
/** The participation secret a profile record carries — `undefined` when absent or not 64 lowercase hex. */
export function participationSecretOf(profile: JsonRecord | undefined): string | undefined {
const value = fpLiteral(profile, PARTICIPATION_SECRET);
return value && /^[0-9a-f]{64}$/.test(value) ? value : undefined;
}
/** The participations collection a profile record links, if any. */
export function participationsCollectionOf(profile: JsonRecord | undefined): string | undefined {
if (!profile) return undefined;
return linkId(firstEntry(profile[PARTICIPATIONS] ?? profile['fp:participations']));
}
/** The member URN of `webId` at `eventUri` under `secret`: `urn:festipod:userref:<sha256 hex>`. */
export function memberUrnOf(webId: string, eventUri: string, secret: string): string {
return `${MEMBER_URN_PREFIX}${createHash('sha256').update(`${webId} ${eventUri} ${secret}`).digest('hex')}`;
}
/**
* The session's participation secret, ensured the way the app's data provider
* writes a profile field (`refineData.tsx` `ensureParticipationSecret` →
* `update`, contract `contract_activitypods-providers`: read the current record,
* drop its `id` and `@context`, PUT `{ "@context": jsonContext, ...current,
* ...values }`, the platform context being Festipod's `jsonContext`). Present → returned untouched; absent →
* 32 random bytes as hex, written, then read back — the write must come back
* or this throws. NOTE: a Node-side session holds the OWNER's token, not the
* app's; the app-token write path is proven by the rights matrix, whose joins
* go through the app itself.
*/
export async function ensureParticipationSecret(session: ApodsSession): Promise<{ profileUri: string; secret: string }> {
const actor = await readResource(session, session.webId);
const profileUri = linkId(actor['url']);
if (!profileUri) throw new Error(`actor ${session.webId} links no profile (url)`);
const current = await readResource(session, profileUri);
const present = participationSecretOf(current);
if (present) return { profileUri, secret: present };
const secret = randomBytes(32).toString('hex');
// The provider's update: the current record minus its `id` and `@context`, the given field merged over it.
const { '@context': _context, id: _id, ...fields } = current;
const written = await request(profileUri, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({ '@context': JSON_CONTEXT, ...fields, [PARTICIPATION_SECRET]: secret }),
session,
});
if (written.status < 200 || written.status >= 300) {
throw new Error(`Write the participation secret on ${profileUri} failed: HTTP ${written.status} ${written.text.slice(0, 200)}`);
}
const back = participationSecretOf(await readResource(session, profileUri));
if (back !== secret) throw new Error(`${profileUri} came back without the participation secret just written`);
return { profileUri, secret };
}
+3 -4
View File
@@ -78,6 +78,7 @@ const screenFileMap: Record<string, string> = {
'home': 'src/modules/home/screens/HomeScreen.tsx',
'profile': 'src/modules/user/screens/ProfileScreen.tsx',
'update-profile': 'src/modules/user/screens/UpdateProfileScreen.tsx',
'edit-profile': 'src/modules/user/screens/UpdateProfileScreen.tsx',
'user-profile': 'src/modules/user/screens/UserProfileScreen.tsx',
'settings': 'src/modules/home/screens/SettingsScreen.tsx',
'events': 'src/modules/event/screens/EventsScreen.tsx',
@@ -185,7 +186,7 @@ export const screenExpectedContent: Record<string, string[]> = {
],
'share-profile': [
'Partager',
'QR',
"Mon lien d'invitation",
],
'friends-list': [
'Mon réseau',
@@ -240,11 +241,9 @@ export const screenRequiredFields: Record<string, string[]> = {
],
'meeting-points': [
'Lieu de rencontre',
'Heure',
],
'share-profile': [
'QR Code',
'Lien de partage',
"Mon lien d'invitation",
],
};
@@ -72,18 +72,20 @@ describe('own profile screens', () => {
expect(await textOf('profile', '/profile')).not.toContain('Participant');
});
test('share-profile: no identifier in the name, initials or festipod.app/u/ link', async () => {
test('share-profile: no identifier in the name, initials or profile link area', async () => {
patch('user-1', { name: ID, username: ID, initials: 'HT' });
const text = await textOf('share-profile', '/profile/share');
expect(text).not.toContain('localhost');
expect(text).not.toContain('HT');
expect(text).not.toContain('festipod.app/u/');
});
test('share-profile: a human username still builds the link; empty name is not « Participant »', async () => {
test('share-profile: without a connected pod session the link is honestly unavailable', async () => {
// No connected pod session in the local harness: the invite capability
// cannot be issued — the screen says so instead of displaying a fake address.
patch('user-1', { name: '' });
const text = await textOf('share-profile', '/profile/share');
expect(text).toContain('festipod.app/u/mariedupont');
expect(text).toContain("Lien d'invitation indisponible");
expect(text).not.toContain('/invite/');
expect(text).not.toContain('Participant');
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from 'bun:test';
import { cssUrl } from './cssUrl';
test('cssUrl keeps a plain http(s) address and neutralises anything that could end the string', () => {
expect(cssUrl('https://pod.example/file/a.png')).toBe('url("https://pod.example/file/a.png")');
// Inside a quoted CSS string parentheses are inert; what must not survive is a quote or a space.
const hostile = cssUrl('https://x/y), url(https://evil/p.png')!;
expect(hostile).toBe('url("https://x/y),%20url(https://evil/p.png")');
expect(hostile.match(/"/g)?.length).toBe(2);
expect(cssUrl('https://x/"a\\b "c')!.match(/"/g)?.length).toBe(2);
});
test('cssUrl refuses anything that is not an http(s) address', () => {
expect(cssUrl(undefined)).toBeUndefined();
expect(cssUrl('')).toBeUndefined();
expect(cssUrl('javascript:alert(1)')).toBeUndefined();
expect(cssUrl('data:image/png;base64,AAAA')).toBeUndefined();
});
+10
View File
@@ -0,0 +1,10 @@
/**
* A CSS `url("…")` for an image address that may have been written by another
* user. Only http(s) addresses are accepted, and the characters that could end
* the quoted string are percent-encoded, so the value cannot add a second
* background or break the declaration. `undefined` means "draw no image".
*/
export function cssUrl(address: string | undefined): string | undefined {
if (!address || !/^https?:\/\//i.test(address)) return undefined;
return `url("${address.replace(/["\\\n\r()\s]/g, c => encodeURIComponent(c))}")`;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Grant public read on an uploaded Pod resource. An uploaded file is a plain LDP
* resource, not an ActivityPub activity, so nothing addresses it and the Pod does
* not open it up by itself: a cover or an avatar nobody else can read is a dead
* feature. Uses the uploader's own Solid session (the standard WAC ACL PATCH),
* exactly as the ActivityPods reference apps do.
*
* Best-effort: the upload already succeeded either way, so a failure here is
* swallowed rather than blocking the caller.
*/
export async function grantPublicRead(resourceUri: string, token: string): Promise<void> {
try {
const head = await fetch(resourceUri, { method: 'HEAD', headers: { Authorization: `Bearer ${token}` } });
const aclMatch = head.headers.get('Link')
?.split(',')
.map(part => part.trim())
.find(part => /rel="?acl"?/.test(part))
?.match(/<([^>]+)>/);
if (!aclMatch) return;
const aclUri = new URL(aclMatch[1]!, resourceUri).toString();
await fetch(aclUri, {
method: 'PATCH',
headers: { 'Content-Type': 'application/ld+json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
'@context': {
'@base': aclUri,
acl: 'http://www.w3.org/ns/auth/acl#',
foaf: 'http://xmlns.com/foaf/0.1/',
'acl:agentClass': { '@type': '@id' },
'acl:mode': { '@type': '@id' },
'acl:accessTo': { '@type': '@id' },
},
'@graph': [
{
'@id': '#Read',
'@type': 'acl:Authorization',
'acl:agentClass': 'foaf:Agent',
'acl:accessTo': resourceUri,
'acl:mode': 'acl:Read',
},
],
}),
});
} catch {
// Best-effort — see above.
}
}
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, expect, test } from 'bun:test';
import { createInviteCapability, invitePageLink } from './inviteLink';
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/ld+json' } });
const actorThen = (handler: (url: string, init?: RequestInit) => Response) => {
globalThis.fetch = (async (url: string, init?: RequestInit) => {
if (String(url) === 'https://pod.org/alice') {
return json({ outbox: 'https://pod.org/alice/outbox', url: 'https://pod.org/alice/profile' });
}
return handler(String(url), init);
}) as unknown as typeof fetch;
};
test('createInviteCapability posts the reference VC payload to {webId}/vc/v0.3 and returns the capability id', async () => {
const posts: { url: string; init?: RequestInit }[] = [];
actorThen((url, init) => {
posts.push({ url, init });
return json({ id: 'https://pod.org/alice/vc/cap1' }, 201);
});
const capability = await createInviteCapability({ webId: 'https://pod.org/alice', token: 'tok' });
expect(capability).toBe('https://pod.org/alice/vc/cap1');
expect(posts).toHaveLength(1);
expect(posts[0]!.url).toBe('https://pod.org/alice/vc/v0.3/credentials/issue');
expect(posts[0]!.init?.method).toBe('POST');
const headers = posts[0]!.init?.headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer tok');
// Structure identical to the reference createContactCapability: the grant
// offers to add the bearer's profile, the authorization reads the profile
// DOCUMENT (the actor `url` predicate).
expect(JSON.parse(String(posts[0]!.init?.body))).toEqual({
credential: {
'@context': [
'https://www.w3.org/ns/credentials/v2',
{
as: 'https://www.w3.org/ns/activitystreams#',
apods: 'http://activitypods.org/ns/core#',
acl: 'http://www.w3.org/ns/auth/acl#',
},
],
type: 'VerifiableCredential',
name: 'Invite Link',
credentialSubject: {
'apods:hasActivityGrant': {
type: 'as:Offer',
'as:to': { '@id': 'https://pod.org/alice' },
'as:target': { '@id': 'https://pod.org/alice' },
'as:object': {
type: 'as:Add',
'as:object': { type: 'as:Profile' },
},
},
'apods:hasAuthorization': {
type: 'acl:Authorization',
'acl:mode': 'acl:Read',
'acl:accessTo': { '@id': 'https://pod.org/alice/profile' },
},
},
},
});
});
test('createInviteCapability drops a hash fragment from the webId before joining the VC path', async () => {
const posts: string[] = [];
globalThis.fetch = (async (url: string, init?: RequestInit) => {
const target = String(url);
if (target === 'https://pod.org/alice#i') return json({ url: 'https://pod.org/alice/profile' });
posts.push(target);
void init;
return json({ id: 'https://pod.org/alice/vc/cap2' }, 201);
}) as unknown as typeof fetch;
await createInviteCapability({ webId: 'https://pod.org/alice#i', token: 'tok' });
expect(posts).toEqual(['https://pod.org/alice/vc/v0.3/credentials/issue']);
});
test('createInviteCapability throws when the endpoint fails or answers no credential id', async () => {
actorThen(() => new Response('', { status: 500 }));
await expect(createInviteCapability({ webId: 'https://pod.org/alice', token: 'tok' })).rejects.toThrow('HTTP 500');
actorThen(() => json({ name: 'Invite Link' }, 201));
await expect(createInviteCapability({ webId: 'https://pod.org/alice', token: 'tok' })).rejects.toThrow('no credential id');
globalThis.fetch = (async () => new Response('', { status: 404 })) as unknown as typeof fetch;
await expect(createInviteCapability({ webId: 'https://pod.org/alice', token: 'tok' })).rejects.toThrow('actor document');
});
test('invitePageLink points at the pod frontend /invite route, encoding the capability URI', () => {
expect(invitePageLink('https://app.pod.org', 'https://pod.org/alice/vc/cap 1?x=1&y=2'))
.toBe('https://app.pod.org/invite/https%3A%2F%2Fpod.org%2Falice%2Fvc%2Fcap%201%3Fx%3D1%26y%3D2');
// Tolerates a trailing slash on the discovered frontend URL.
expect(invitePageLink('https://app.pod.org/', 'https://pod.org/alice/vc/cap1'))
.toBe('https://app.pod.org/invite/https%3A%2F%2Fpod.org%2Falice%2Fvc%2Fcap1');
});
+130
View File
@@ -0,0 +1,130 @@
/**
* Invite link — the native ActivityPods invitation mechanism, used as is.
*
* WHAT IT IS. A shareable link that lets someone join my network in one click,
* with NO manual acceptance on my side: the link carries a Verifiable
* Credential capability issued by my own Pod (`POST {webId}/vc/v0.3/
* credentials/issue`). The VC grants two things — an activity grant
* (`apods:hasActivityGrant`: I offer to add the bearer's profile to my
* network) and an authorization (`apods:hasAuthorization`: Read on my profile
* document, so the invitee can actually see it). The pod provider's own
* frontend hosts the `/invite/:capability` page that understands this; that is
* why the page link is built on the DISCOVERED pod frontend URL (nodeinfo,
* see `nodeinfo.ts`), not on this app's origin. Same structure as the
* reference `createContactCapability` in the pod provider frontend.
*
* CREATION ON DEMAND. The reference tries to reuse an existing "Invite Link"
* VC listed from the pod; the pinned contract exposes no such list endpoint,
* so every tap creates a fresh VC. Several valid invite VCs at once are
* tolerated by the platform.
*
* FAILURE IS HONEST. Any failure — the actor read, the VC endpoint, a
* response without a credential id — THROWS. The screens catch and say so;
* a link that was never created must never be announced.
*/
import { fetchActorDocument } from '../data/actor';
import type { CurrentSession } from './currentSession';
const VC_API_PATH = '/vc/v0.3';
const credentialContext = [
'https://www.w3.org/ns/credentials/v2',
{
as: 'https://www.w3.org/ns/activitystreams#',
apods: 'http://activitypods.org/ns/core#',
acl: 'http://www.w3.org/ns/auth/acl#',
},
];
/**
* The session's VC issue endpoint. Derived from the session's own webId, so
* the bearer token goes to the session's own origin by construction — the
* endpoint cannot be pointed elsewhere the way an outbox read from a document
* could. Any hash fragment is dropped first: a webId like `…/alice#i` must
* not glue the path onto the fragment.
*/
function vcIssueEndpoint(webId: string): string {
const id = new URL(webId);
id.hash = '';
id.search = '';
const base = id.toString().replace(/\/+$/, '');
return `${base}/${VC_API_PATH.replace(/^\/+/, '')}/credentials/issue`;
}
/**
* Ask the session's own Pod to issue an invite-link capability. Resolves with
* the CAPABILITY URI (not a page link — see `invitePageLink`); any failure
* throws.
*/
export async function createInviteCapability(session: CurrentSession): Promise<string> {
// The authorization must name the profile DOCUMENT (what `acl:accessTo`
// checks); the actor's `url` predicate is that document's URI.
const actor = await fetchActorDocument(session.webId, session.token);
if (!actor.profileUri) throw new Error(`actor document for ${session.webId} links no profile — nothing to invite to`);
const endpoint = vcIssueEndpoint(session.webId);
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json',
Accept: 'application/ld+json',
Authorization: `Bearer ${session.token}`,
},
body: JSON.stringify({
credential: {
'@context': credentialContext,
type: 'VerifiableCredential',
name: 'Invite Link',
credentialSubject: {
'apods:hasActivityGrant': {
type: 'as:Offer',
'as:to': { '@id': session.webId },
'as:target': { '@id': session.webId },
'as:object': {
type: 'as:Add',
'as:object': {
type: 'as:Profile',
},
},
},
'apods:hasAuthorization': {
type: 'acl:Authorization',
'acl:mode': 'acl:Read',
'acl:accessTo': { '@id': actor.profileUri },
},
},
},
}),
}).catch((err: unknown) => {
throw new Error('creating the invite link capability failed: the VC endpoint could not be reached', { cause: err });
});
const verifiableCredential = (await response.json().catch(() => undefined)) as { id?: unknown } | undefined;
const capabilityUri = typeof verifiableCredential?.id === 'string' ? verifiableCredential.id : undefined;
if (!response.ok || !capabilityUri) {
throw new Error(
`creating the invite link capability failed: HTTP ${response.status} for ${endpoint} returned no credential id`,
);
}
return capabilityUri;
}
/**
* The page an invitee opens: the pod provider frontend's `/invite/:capability`
* route, with the capability URI percent-encoded (it carries query-looking
* characters of its own). `frontendUrl` comes from nodeinfo discovery.
*/
export function invitePageLink(frontendUrl: string, capabilityUri: string): string {
return `${frontendUrl.replace(/\/+$/, '')}/invite/${encodeURIComponent(capabilityUri)}`;
}
/** Copy `text` to the clipboard; resolves false when the browser refuses or has no Clipboard API. */
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, expect, test } from 'bun:test';
import { fetchPodFrontendUrl } from './nodeinfo';
const NODEINFO_REL = 'http://nodeinfo.diaspora.software/ns/schema/2.1';
const ORIGINAL_FETCH = globalThis.fetch;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function mockFetch(handler: (url: string) => Response | Promise<Response>): void {
globalThis.fetch = ((input: unknown) =>
handler(String(input instanceof Request ? input.url : input))) as typeof fetch;
}
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
});
test('fetchPodFrontendUrl follows the well-known link and reads metadata.frontend_url', async () => {
mockFetch((url) => {
if (url === 'https://pod.org/.well-known/nodeinfo') {
return jsonResponse({ links: [{ rel: NODEINFO_REL, href: 'https://pod.org/nodeinfo/2.1' }] });
}
if (url === 'https://pod.org/nodeinfo/2.1') {
return jsonResponse({ metadata: { frontend_url: 'https://app.pod.org' } });
}
throw new Error(`unexpected fetch: ${url}`);
});
expect(await fetchPodFrontendUrl('pod.org')).toBe('https://app.pod.org');
});
test('fetchPodFrontendUrl uses http for a host with a port (local dev pod)', async () => {
const requested: string[] = [];
mockFetch((url) => {
requested.push(url);
if (url === 'http://localhost:4005/.well-known/nodeinfo') {
return jsonResponse({ links: [{ rel: NODEINFO_REL, href: 'http://localhost:4005/nodeinfo/2.1' }] });
}
return jsonResponse({ metadata: { frontend_url: 'http://localhost:3000' } });
});
expect(await fetchPodFrontendUrl('localhost:4005')).toBe('http://localhost:3000');
expect(requested[0]).toBe('http://localhost:4005/.well-known/nodeinfo');
});
test('fetchPodFrontendUrl resolves undefined on any failure — never throws', async () => {
// Network failure.
mockFetch(() => {
throw new Error('offline');
});
expect(await fetchPodFrontendUrl('pod.org')).toBeUndefined();
// Well-known answers 404.
mockFetch(() => jsonResponse({}, 404));
expect(await fetchPodFrontendUrl('pod.org')).toBeUndefined();
// No link with the preferred rel.
mockFetch(() => jsonResponse({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', href: 'x' }] }));
expect(await fetchPodFrontendUrl('pod.org')).toBeUndefined();
// Nodeinfo document has no usable frontend_url.
mockFetch((url) =>
url.endsWith('/.well-known/nodeinfo')
? jsonResponse({ links: [{ rel: NODEINFO_REL, href: 'https://pod.org/nodeinfo/2.1' }] })
: jsonResponse({ metadata: {} }),
);
expect(await fetchPodFrontendUrl('pod.org')).toBeUndefined();
// Blank or malformed host.
expect(await fetchPodFrontendUrl('')).toBeUndefined();
});
+95
View File
@@ -0,0 +1,95 @@
/**
* nodeinfo — discover a Pod provider's own frontend URL through the standard
* nodeinfo protocol: `/.well-known/nodeinfo` links to the actual versioned
* document, whose `metadata.frontend_url` names the provider's web app.
*
* WHY: a Festipod profile is protected, so the shared invitation link does
* NOT point at this app: the invite page (`<frontend_url>/invite/<capability>`
* — see `inviteLink.ts`) lives on the Pod provider's own frontend, the only
* place that understands the invite capability flow. That page lives on the
* VIEWER'S OWN pod (the one hosting their actor URI), discovered from their
* identity, not the target's — same pattern as the reference ActivityPods
* frontends.
*
* Best-effort by design: DNS, TLS, the well-known or the document itself can
* all fail; every failure resolves `undefined` and never throws, so callers
* degrade to "link unavailable" instead of crashing the screen.
*/
import { useEffect, useState } from 'react';
type NodeInfoLinks = { links: { rel: string; href: string }[] };
type NodeInfo = { metadata?: { frontend_url?: string } };
const NODEINFO_REL = 'http://nodeinfo.diaspora.software/ns/schema/2.1';
const NODEINFO_TIMEOUT_MS = 8000;
/** The provider's `frontend_url`, or `undefined` when undiscoverable. */
export async function fetchPodFrontendUrl(podHost: string): Promise<string | undefined> {
if (!podHost) return undefined;
// A host with a port is almost certainly a local dev instance, not a real HTTPS deployment.
const protocol = podHost.includes(':') ? 'http' : 'https';
try {
const linksResponse = await fetch(`${protocol}://${podHost}/.well-known/nodeinfo`, {
signal: AbortSignal.timeout(NODEINFO_TIMEOUT_MS),
});
if (!linksResponse.ok) return undefined;
const links = (await linksResponse.json()) as NodeInfoLinks;
const link = links.links?.find((l) => l.rel === NODEINFO_REL);
if (!link) return undefined;
const nodeinfoResponse = await fetch(link.href, {
signal: AbortSignal.timeout(NODEINFO_TIMEOUT_MS),
});
if (!nodeinfoResponse.ok) return undefined;
const nodeinfo = (await nodeinfoResponse.json()) as NodeInfo;
const frontendUrl = nodeinfo.metadata?.frontend_url;
return typeof frontendUrl === 'string' && frontendUrl ? frontendUrl : undefined;
} catch {
return undefined;
}
}
/**
* Resolve the viewer's own pod frontend URL from their actor URI's host.
* `loading` covers the nodeinfo round-trip; `frontendUrl` stays `undefined`
* when there is no actor URI (offline demo) or the provider answers nothing
* usable. Never throws.
*/
export function usePodFrontendUrl(
actorUri: string | undefined,
): { loading: boolean; frontendUrl: string | undefined } {
const host = hostOf(actorUri);
const [state, setState] = useState<{ loading: boolean; frontendUrl: string | undefined }>({
loading: !!host,
frontendUrl: undefined,
});
useEffect(() => {
let cancelled = false;
if (!host) {
setState({ loading: false, frontendUrl: undefined });
return;
}
setState({ loading: true, frontendUrl: undefined });
fetchPodFrontendUrl(host).then((frontendUrl) => {
if (!cancelled) setState({ loading: false, frontendUrl });
});
return () => {
cancelled = true;
};
}, [host]);
return state;
}
/** Host part of an http(s) actor URI; anything else (demo ids, blanks) → undefined. */
function hostOf(actorUri: string | undefined): string | undefined {
if (!actorUri || !/^https?:\/\//.test(actorUri)) return undefined;
try {
return new URL(actorUri).host;
} catch {
return undefined;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from 'bun:test';
import { matchesQuery } from './search';
test('matchesQuery ignores case and accents, and an empty query matches all', () => {
expect(matchesQuery('', 'x')).toBe(true);
expect(matchesQuery(' ', undefined)).toBe(true);
expect(matchesQuery('ete', 'Fête de l\'Été')).toBe(true);
expect(matchesQuery('LYON', 'Café', 'lyon 3')).toBe(true);
expect(matchesQuery('paris', 'Lyon', undefined)).toBe(false);
});
+7
View File
@@ -0,0 +1,7 @@
/** Case- and accent-insensitive "does any field contain the query" — an empty query matches everything. */
export function matchesQuery(query: string, ...fields: Array<string | undefined>): boolean {
const fold = (s: string) => s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
const q = fold(query.trim());
if (!q) return true;
return fields.some(f => f !== undefined && fold(f).includes(q));
}