From 785e1cf9c50908dda6f2dfd0a51d10020e3dc836 Mon Sep 17 00:00:00 2001 From: Andrew Radulescu Date: Fri, 13 Nov 2026 08:40:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(eveniment):=20cine=20particip=C4=83=20?= =?UTF-8?q?=E2=80=94=20ini=C8=9Bialele=20primilor=20voluntari=20pe=20pagin?= =?UTF-8?q?a=20evenimentului?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: GET /mobile/event/:id trimite participants { total, preview[] } cu inițialele primilor 5 voluntari aprobați (cei mai vechi întâi), fără nume, id-uri sau e-mailuri, doar celor care pot vedea evenimentul. Inițialele păstrează diacriticele (Ș, Ț, Ă, Î, Â). numberOfPersonsGoingToEvent rămâne neschimbat. Mobil: rândul cu locuri arată fețele suprapuse ca .avs din prototip (32pt, inel alb, -8pt, navy cu inițiale galbene), bula „+N” și eticheta „18 persoane participă”; la text mare fețele trec sub descriere. --- .../presenters/mobile-event.presenter.ts | 59 ++++++ .../modules/event/event-participants.spec.ts | 140 ++++++++++++ .../src/modules/event/event-participants.ts | 77 +++++++ .../src/modules/event/models/event.model.ts | 3 + ...one-event-with-volunteer-status.usecase.ts | 10 +- backend/test/event-participants.e2e-spec.ts | 199 ++++++++++++++++++ mobile/src/assets/locales/en/translation.json | 3 + mobile/src/assets/locales/ro/translation.json | 3 + .../src/common/interfaces/event.interface.ts | 13 ++ .../events/ParticipantAvatars.test.tsx | 95 +++++++++ .../components/events/ParticipantAvatars.tsx | 111 ++++++++++ mobile/src/screens/Event.tsx | 30 +++ .../src/screens/event-participants.test.tsx | 133 ++++++++++++ 13 files changed, 872 insertions(+), 4 deletions(-) create mode 100644 backend/src/modules/event/event-participants.spec.ts create mode 100644 backend/src/modules/event/event-participants.ts create mode 100644 backend/test/event-participants.e2e-spec.ts create mode 100644 mobile/src/components/events/ParticipantAvatars.test.tsx create mode 100644 mobile/src/components/events/ParticipantAvatars.tsx create mode 100644 mobile/src/screens/event-participants.test.tsx diff --git a/backend/src/api/_mobile/event/presenters/mobile-event.presenter.ts b/backend/src/api/_mobile/event/presenters/mobile-event.presenter.ts index 9a86e0a67..db880f019 100644 --- a/backend/src/api/_mobile/event/presenters/mobile-event.presenter.ts +++ b/backend/src/api/_mobile/event/presenters/mobile-event.presenter.ts @@ -3,6 +3,10 @@ import { Expose } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { IEventWithVolunteerStatus } from 'src/modules/event/models/event.model'; import { OrganizationStructureListItemPresenter } from 'src/api/organization/presenters/organization-structure-list-item.presenter'; +import { + IEventParticipants, + PARTICIPANTS_PREVIEW_SIZE, +} from 'src/modules/event/event-participants'; import { formatEventDate } from '../helpers/event.helper'; import { EventVolunteerStatus } from 'src/modules/event/enums/event-volunteer-status.enum'; import { @@ -10,6 +14,47 @@ import { getEventDisplayStatus, } from 'src/modules/event/enums/event-status.enum'; +export class EventParticipantPreviewPresenter { + @Expose() + @ApiProperty({ + description: 'Initials of a volunteer going to the event (no name)', + example: 'ȘP', + }) + initials: string; + + @Expose() + @ApiProperty({ + description: + 'Picture of the volunteer; not sent today (pictures are not shared between volunteers)', + required: false, + }) + avatarUrl?: string; +} + +export class EventParticipantsPresenter { + constructor(participants: IEventParticipants) { + this.total = participants.total; + this.preview = participants.preview.map((person) => { + const item = new EventParticipantPreviewPresenter(); + item.initials = person.initials; + if (person.avatarUrl) item.avatarUrl = person.avatarUrl; + return item; + }); + } + + @Expose() + @ApiProperty({ description: 'Volunteers going (approved registrations)' }) + total: number; + + @Expose() + @ApiProperty({ + type: EventParticipantPreviewPresenter, + isArray: true, + description: `The first ${PARTICIPANTS_PREVIEW_SIZE} to register, oldest first`, + }) + preview: EventParticipantPreviewPresenter[]; +} + export class MobileEventPresenter { constructor(event: IEventWithVolunteerStatus) { this.id = event.id; @@ -35,6 +80,12 @@ export class MobileEventPresenter { this.volunteerStatus = event.volunteerStatus; this.numberOfPersonsGoingToEvent = event.numberOfPersonsGoingToEvent; + this.participants = new EventParticipantsPresenter( + event.participants ?? { + total: event.numberOfPersonsGoingToEvent ?? 0, + preview: [], + }, + ); this.registrationStatus = event.registrationStatus ?? null; this.rejectionReason = event.rejectionReason ?? null; @@ -144,6 +195,14 @@ export class MobileEventPresenter { }) numberOfPersonsGoingToEvent: number; + @Expose() + @ApiProperty({ + type: EventParticipantsPresenter, + description: + 'Who is coming, privacy-safe: the count and the initials of the first ones', + }) + participants: EventParticipantsPresenter; + @Expose() @ApiProperty({ description: 'Wether the event is public or not' }) isPublic: boolean; diff --git a/backend/src/modules/event/event-participants.spec.ts b/backend/src/modules/event/event-participants.spec.ts new file mode 100644 index 000000000..64cdbead9 --- /dev/null +++ b/backend/src/modules/event/event-participants.spec.ts @@ -0,0 +1,140 @@ +import { EventRegistrationStatus } from './enums/event-registration-status.enum'; +import { + buildEventParticipants, + participantInitials, + ParticipantRsvp, + PARTICIPANTS_PREVIEW_SIZE, +} from './event-participants'; + +const rsvp = ( + id: string, + createdOn: string, + user: { firstName?: string; lastName?: string; name?: string }, + going = true, + status: EventRegistrationStatus | null = EventRegistrationStatus.APPROVED, +): ParticipantRsvp => ({ + id, + createdOn: new Date(createdOn), + going, + status, + user, +}); + +describe('participantInitials', () => { + it('takes the first letter of the first and last name', () => { + expect(participantInitials({ firstName: 'Ana', lastName: 'Popescu' })).toBe( + 'AP', + ); + }); + + it('keeps Romanian diacritics and upper-cases them', () => { + expect( + participantInitials({ firstName: 'ștefan', lastName: 'țurcanu' }), + ).toBe('ȘȚ'); + expect( + participantInitials({ firstName: 'Ălina', lastName: 'Îonescu' }), + ).toBe('ĂÎ'); + expect(participantInitials({ firstName: 'âna', lastName: 'Bălan' })).toBe( + 'ÂB', + ); + }); + + it('joins decomposed letters (S + comma below) into one', () => { + expect( + participantInitials({ firstName: 'Ștefan', lastName: 'Țepes' }), + ).toBe('ȘȚ'); + }); + + it('falls back to the full name when first/last are missing', () => { + expect(participantInitials({ name: 'maria ioana Șerban' })).toBe('MȘ'); + expect(participantInitials({ name: 'Cristi' })).toBe('C'); + expect(participantInitials({ firstName: '', lastName: '', name: '' })).toBe( + '?', + ); + expect(participantInitials(undefined)).toBe('?'); + }); + + it('never returns more than two letters', () => { + expect( + participantInitials({ firstName: 'Ana Maria', lastName: 'Pop Ionescu' }), + ).toBe('AP'); + }); +}); + +describe('buildEventParticipants', () => { + it('counts only approved, going registrations', () => { + const result = buildEventParticipants([ + rsvp('1', '2026-01-01', { firstName: 'Ana', lastName: 'Pop' }), + rsvp( + '2', + '2026-01-02', + { firstName: 'Bia', lastName: 'Ene' }, + true, + EventRegistrationStatus.PENDING, + ), + rsvp( + '3', + '2026-01-03', + { firstName: 'Cor', lastName: 'Dan' }, + true, + EventRegistrationStatus.REJECTED, + ), + rsvp('4', '2026-01-04', { firstName: 'Dan', lastName: 'Lup' }, false), + ]); + expect(result).toEqual({ total: 1, preview: [{ initials: 'AP' }] }); + }); + + it('orders by registration time, then id, whatever the input order', () => { + const rows = [ + rsvp('b', '2026-01-02', { firstName: 'Bia', lastName: 'B' }), + rsvp('c', '2026-01-01', { firstName: 'Cor', lastName: 'C' }), + rsvp('a', '2026-01-02', { firstName: 'Ana', lastName: 'A' }), + ]; + const expected = ['CC', 'AA', 'BB']; + expect(buildEventParticipants(rows).preview.map((p) => p.initials)).toEqual( + expected, + ); + expect( + buildEventParticipants([...rows].reverse()).preview.map( + (p) => p.initials, + ), + ).toEqual(expected); + }); + + it('shows at most the preview size, and the full total', () => { + const rows = Array.from({ length: 18 }, (_, i) => + rsvp(`id-${String(i).padStart(2, '0')}`, `2026-01-${i + 1}`, { + firstName: 'Ana', + lastName: String.fromCharCode(65 + i), + }), + ); + const result = buildEventParticipants(rows); + expect(result.total).toBe(18); + expect(result.preview).toHaveLength(PARTICIPANTS_PREVIEW_SIZE); + expect(result.preview[0]).toEqual({ initials: 'AA' }); + expect(buildEventParticipants(rows, 3).preview).toHaveLength(3); + }); + + it('exposes initials only, never names, ids or emails', () => { + const result = buildEventParticipants([ + { + ...rsvp('secret-id', '2026-01-01', { + firstName: 'Ana', + lastName: 'Pop', + }), + user: { + firstName: 'Ana', + lastName: 'Pop', + name: 'Ana Pop', + email: 'ana@example.ro', + } as never, + }, + ]); + expect(Object.keys(result.preview[0])).toEqual(['initials']); + expect(JSON.stringify(result)).not.toMatch(/Ana|Pop|secret|example/); + }); + + it('is empty without registrations', () => { + expect(buildEventParticipants()).toEqual({ total: 0, preview: [] }); + }); +}); diff --git a/backend/src/modules/event/event-participants.ts b/backend/src/modules/event/event-participants.ts new file mode 100644 index 000000000..2c4aca76d --- /dev/null +++ b/backend/src/modules/event/event-participants.ts @@ -0,0 +1,77 @@ +import { EventRegistrationStatus } from './enums/event-registration-status.enum'; + +// How many faces the event screen shows before the „+N” bubble. +export const PARTICIPANTS_PREVIEW_SIZE = 5; + +export interface IEventParticipantPreview { + initials: string; + // Only set if volunteers' pictures are ever shared with other volunteers; today they are not + // (the NGO and the person themselves are the only ones who see a profile picture). + avatarUrl?: string; +} + +export interface IEventParticipants { + total: number; + preview: IEventParticipantPreview[]; +} + +export interface ParticipantRsvp { + id?: string; + going: boolean; + status?: EventRegistrationStatus | null; + createdOn?: Date | string; + user?: { firstName?: string; lastName?: string; name?: string } | null; +} + +// The first letter of a word, as one character even when typed decomposed (S + comma below → +// „Ș”), upper-cased the Romanian way. +const firstLetter = (word?: string | null): string => { + const letter = Array.from((word ?? '').normalize('NFC').trim())[0] ?? ''; + return letter.toLocaleUpperCase('ro-RO'); +}; + +// „Ștefan Țurcanu” → „ȘȚ”. First and last name when the profile has them, otherwise the first and +// last word of the full name; never more than two letters, „?” when there is nothing to show. +export function participantInitials(user?: { + firstName?: string; + lastName?: string; + name?: string; +}): string { + const first = firstLetter(user?.firstName); + const last = firstLetter(user?.lastName); + if (first || last) return `${first}${last}` || '?'; + + const words = (user?.name ?? '').normalize('NFC').trim().split(/\s+/); + const initials = + words.length > 1 + ? `${firstLetter(words[0])}${firstLetter(words[words.length - 1])}` + : firstLetter(words[0]); + return initials || '?'; +} + +const time = (date?: Date | string): number => + date ? new Date(date).getTime() : 0; + +// Who is coming, without saying who: the number of approved registrations and the initials of the +// first ones to register (oldest first, then by id, so the row does not reshuffle between visits). +export function buildEventParticipants( + rsvps: ParticipantRsvp[] = [], + size = PARTICIPANTS_PREVIEW_SIZE, +): IEventParticipants { + const going = rsvps + .filter( + (rsvp) => rsvp.going && rsvp.status === EventRegistrationStatus.APPROVED, + ) + .sort( + (a, b) => + time(a.createdOn) - time(b.createdOn) || + (a.id ?? '').localeCompare(b.id ?? ''), + ); + + return { + total: going.length, + preview: going + .slice(0, Math.max(0, size)) + .map((rsvp) => ({ initials: participantInitials(rsvp.user ?? {}) })), + }; +} diff --git a/backend/src/modules/event/models/event.model.ts b/backend/src/modules/event/models/event.model.ts index 53e499398..871799add 100644 --- a/backend/src/modules/event/models/event.model.ts +++ b/backend/src/modules/event/models/event.model.ts @@ -1,3 +1,4 @@ +import { IEventParticipants } from '../event-participants'; import { EventRegistrationStatus } from '../enums/event-registration-status.enum'; import { IBaseModel } from 'src/common/interfaces/base.model'; import { IActivityTypeModel } from 'src/modules/activity-type/models/activity-type.model'; @@ -44,6 +45,8 @@ export interface IEventWithVolunteerStatus // #501: the NGO's decision on the volunteer's registration (null when not going) registrationStatus?: EventRegistrationStatus; rejectionReason?: string; + // Who is coming, privacy-safe: count + initials of the first ones (see event-participants.ts) + participants?: IEventParticipants; } export interface IEventsListItemModel extends Pick< diff --git a/backend/src/usecases/event/get-one-event-with-volunteer-status.usecase.ts b/backend/src/usecases/event/get-one-event-with-volunteer-status.usecase.ts index 69647969c..11e22017c 100644 --- a/backend/src/usecases/event/get-one-event-with-volunteer-status.usecase.ts +++ b/backend/src/usecases/event/get-one-event-with-volunteer-status.usecase.ts @@ -7,6 +7,7 @@ import { FindOneEventOptions, IEventWithVolunteerStatus, } from 'src/modules/event/models/event.model'; +import { buildEventParticipants } from 'src/modules/event/event-participants'; import { EventStatus } from 'src/modules/event/enums/event-status.enum'; import { EventExceptionMessages } from 'src/modules/event/exceptions/event.exceptions'; import { IEventModel } from 'src/modules/event/models/event.model'; @@ -38,10 +39,10 @@ export class GetOneEventWithVolunteerStatusUsecase // 2. count the number of rsvps going for this event const { eventRsvps, ...rest } = event; - // #501: a registration counts once the NGO approved it - const numberOfPersonsGoingToEvent = eventRsvps.filter( - (rsvp) => rsvp.going && rsvp.status === EventRegistrationStatus.APPROVED, - ).length; + // #501: a registration counts once the NGO approved it; the event screen shows the count and + // the initials of the first ones (no names: the people are other volunteers) + const participants = buildEventParticipants(eventRsvps); + const numberOfPersonsGoingToEvent = participants.total; // 3.1 check if the user has responded to this event const rsvp = eventRsvps.find((rsvp) => rsvp.user.id === userId); @@ -56,6 +57,7 @@ export class GetOneEventWithVolunteerStatusUsecase ...rest, volunteerStatus, numberOfPersonsGoingToEvent, + participants, registrationStatus: rsvp?.going ? rsvp.status : null, rejectionReason: rsvp?.going && rsvp.status === EventRegistrationStatus.REJECTED diff --git a/backend/test/event-participants.e2e-spec.ts b/backend/test/event-participants.e2e-spec.ts new file mode 100644 index 000000000..2cd182fd2 --- /dev/null +++ b/backend/test/event-participants.e2e-spec.ts @@ -0,0 +1,199 @@ +import { + AuthIdentityStatus, + AuthRealm, +} from 'src/modules/auth/enums/auth.enums'; +import { IAuthIdentityModel } from 'src/modules/auth/models/auth-identity.model'; +import { AuthIdentityFacade } from 'src/modules/auth/services/auth-identity.facade'; +import * as request from 'supertest'; +import { createTestApp, TestApp } from './utils/test-app'; +import { truncate } from './utils/test-data-source'; + +// The event screen shows who is coming as overlapping initials: the approved count and the +// initials of the first ones to register, never names, ids or emails, and only to someone who may +// see the event at all. +describe('Mobile event participants (e2e)', () => { + let testApp: TestApp; + let organizationId: string; + let eventId: string; + let member: IAuthIdentityModel; + let outsider: IAuthIdentityModel; + const userIds: Record = {}; + + const detail = async ( + by: IAuthIdentityModel, + expected: number, + ): Promise => + request(testApp.app.getHttpServer()) + .get(`/mobile/event/${eventId}`) + .set( + 'Authorization', + `Bearer ${await testApp.mintAccessToken(by, AuthRealm.MOBILE)}`, + ) + .expect(expected); + + const person = async ( + key: string, + firstName: string, + lastName: string, + member = true, + ): Promise => { + const email = `${key}@example.ro`; + const [user] = await testApp.dataSource.query( + `INSERT INTO "user" (type, name, first_name, last_name, email, phone) + VALUES ('regular', $1, $2, $3, $4, '+40722000000') RETURNING id`, + [`${firstName} ${lastName}`, firstName, lastName, email], + ); + userIds[key] = user.id; + if (member) { + const [profile] = await testApp.dataSource.query( + `INSERT INTO volunteer_profile (email) VALUES ($1) RETURNING id`, + [email], + ); + await testApp.dataSource.query( + `INSERT INTO volunteer (user_id, organization_id, status, volunteer_profile_id) VALUES ($1, $2, 'active', $3)`, + [user.id, organizationId, profile.id], + ); + } + return testApp.app.get(AuthIdentityFacade).createIdentity({ + realm: AuthRealm.MOBILE, + email, + status: AuthIdentityStatus.ACTIVE, + userId: user.id, + }); + }; + + const register = ( + key: string, + minutesAgo: number, + status: string | null = 'approved', + going = true, + ): Promise => + testApp.dataSource.query( + `INSERT INTO event_rsvp (going, status, event_id, user_id, created_on) + VALUES ($1, $2, $3, $4, now() - $5::interval)`, + [going, status, eventId, userIds[key], `${minutesAgo} minutes`], + ); + + beforeAll(async () => { + testApp = await createTestApp(); + }); + + afterAll(async () => { + await testApp?.close(); + }); + + beforeEach(async () => { + await truncate(testApp.dataSource, [ + 'auth_identity', + 'event_rsvp', + 'event_targets_organization_structure', + 'event', + 'volunteer', + 'volunteer_profile', + 'organization_structure', + 'user', + 'organization', + ]); + const [organization] = await testApp.dataSource.query( + `INSERT INTO organization (name, email, phone, status, validated_at) VALUES ('Org', 'org@example.ro', '+40700000000', 'active', now()) RETURNING id`, + ); + organizationId = organization.id; + + // a private event: only the NGO's active volunteers may see it + const [event] = await testApp.dataSource.query( + `INSERT INTO event (name, description, start_date, end_date, organization_id, status, is_public) + VALUES ('Maraton', 'd', now() + interval '1 day', now() + interval '2 days', $1, 'published', false) RETURNING id`, + [organizationId], + ); + eventId = event.id; + + member = await person('member', 'Ana', 'Popescu'); + outsider = await person('outsider', 'Ion', 'Străin', false); + }); + + it('sends the approved count and the initials of the first five, oldest first', async () => { + const people: [string, string, string][] = [ + ['p1', 'ștefan', 'Țurcanu'], + ['p2', 'Ăurelia', 'Îonescu'], + ['p3', 'Mihai', 'Șerban'], + ['p4', 'Ioana', 'Dobre'], + ['p5', 'Radu', 'Lupu'], + ['p6', 'Elena', 'Matei'], + ['p7', 'Vlad', 'Nistor'], + ]; + for (const [key, first, last] of people) await person(key, first, last); + // registration order: p1 (oldest) … p7, inserted out of order on purpose + const minutesAgo = { + p1: 70, + p2: 60, + p3: 50, + p4: 40, + p5: 30, + p6: 20, + p7: 10, + }; + for (const key of ['p4', 'p7', 'p1', 'p6', 'p3', 'p5', 'p2']) + await register(key, minutesAgo[key as keyof typeof minutesAgo]); + // not counted: pending, rejected, not going + await person('pending', 'Paul', 'Pending'); + await register('pending', 80, 'pending'); + await person('rejected', 'Rita', 'Rejected'); + await register('rejected', 80, 'rejected'); + await person('declined', 'Dana', 'Declined'); + await register('declined', 80, null, false); + + const { body } = await detail(member, 200); + expect(body.participants).toEqual({ + total: 7, + preview: [ + { initials: 'ȘȚ' }, + { initials: 'ĂÎ' }, + { initials: 'MȘ' }, + { initials: 'ID' }, + { initials: 'RL' }, + ], + }); + // the existing count is unchanged + expect(body.numberOfPersonsGoingToEvent).toBe(7); + + // nothing that identifies anyone + const sent = JSON.stringify(body.participants); + expect(sent).not.toMatch(/example\.ro|Țurcanu|ștefan|Popescu/); + for (const id of Object.values(userIds)) expect(sent).not.toContain(id); + expect( + body.participants.preview.every((p: object) => !('avatarUrl' in p)), + ).toBe(true); + + // stable between requests + const again = await detail(member, 200); + expect(again.body.participants).toEqual(body.participants); + }); + + it('an event with nobody going sends an empty preview', async () => { + const { body } = await detail(member, 200); + expect(body.participants).toEqual({ total: 0, preview: [] }); + expect(body.numberOfPersonsGoingToEvent).toBe(0); + }); + + it('someone who may not see the event gets 404 and no participants', async () => { + await person('p1', 'Ana', 'Pop'); + await register('p1', 10); + const { body } = await detail(outsider, 404); + expect(body.participants).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain('AP'); + }); + + it('once the event is public, an outsider sees the same initials', async () => { + await testApp.dataSource.query( + `UPDATE event SET is_public = true WHERE id = $1`, + [eventId], + ); + await person('p1', 'Ana', 'Pop'); + await register('p1', 10); + const { body } = await detail(outsider, 200); + expect(body.participants).toEqual({ + total: 1, + preview: [{ initials: 'AP' }], + }); + }); +}); diff --git a/mobile/src/assets/locales/en/translation.json b/mobile/src/assets/locales/en/translation.json index 02061b610..9b0ad4f07 100644 --- a/mobile/src/assets/locales/en/translation.json +++ b/mobile/src/assets/locales/en/translation.json @@ -531,6 +531,9 @@ "attending": "{{numberOfVolunteer}} people participate!", "attending_of": "{{numberOfVolunteer}} of people participate!", "attending_one": "One person participates!", + "participants_label": "{{n}} people participate", + "participants_label_of": "{{n}} people participate", + "participants_label_one": "One person participates", "attending_other": "", "type": { "public": "Public event", diff --git a/mobile/src/assets/locales/ro/translation.json b/mobile/src/assets/locales/ro/translation.json index 3a93efb64..b66805617 100644 --- a/mobile/src/assets/locales/ro/translation.json +++ b/mobile/src/assets/locales/ro/translation.json @@ -547,6 +547,9 @@ "attending": "{{numberOfVolunteer}} persoane participă!", "attending_of": "{{numberOfVolunteer}} de persoane participă!", "attending_one": "O persoană participă!", + "participants_label": "{{n}} persoane participă", + "participants_label_of": "{{n}} de persoane participă", + "participants_label_one": "O persoană participă", "attending_few": "", "attending_other": "", "type": { diff --git a/mobile/src/common/interfaces/event.interface.ts b/mobile/src/common/interfaces/event.interface.ts index 81662033d..f2c963e30 100644 --- a/mobile/src/common/interfaces/event.interface.ts +++ b/mobile/src/common/interfaces/event.interface.ts @@ -2,6 +2,17 @@ import { EventRegistrationStatus } from '../enums/event-registration-status.enum import { EventVolunteerStatus } from '../enums/event-volunteer-status.enum'; import { IEventListItem } from './event-list-item.interface'; +// one of the first volunteers going: initials only (a picture only if the API ever sends one) +export interface ParticipantPreview { + initials: string; + avatarUrl?: string; +} + +export interface EventParticipants { + total: number; + preview: ParticipantPreview[]; +} + export interface IEvent extends IEventListItem { organizationName: string; description: string; @@ -10,4 +21,6 @@ export interface IEvent extends IEventListItem { // #501: null when not going; only approved registrations count as going registrationStatus?: EventRegistrationStatus | null; rejectionReason?: string | null; + // who is coming, privacy-safe: the count and the initials of the first ones (no names) + participants?: EventParticipants | null; } diff --git a/mobile/src/components/events/ParticipantAvatars.test.tsx b/mobile/src/components/events/ParticipantAvatars.test.tsx new file mode 100644 index 000000000..cfb4005bb --- /dev/null +++ b/mobile/src/components/events/ParticipantAvatars.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import ParticipantAvatars from './ParticipantAvatars'; + +const preview = ['ȘT', 'AM', 'ID', 'RL', 'EM'].map((initials) => ({ initials })); + +describe('ParticipantAvatars', () => { + it('shows three faces and a „+N” bubble for the rest, as one labelled image', async () => { + await render( + , + ); + const row = screen.getByLabelText('18 persoane participă'); + expect(row.props.accessibilityRole).toBe('image'); + expect(screen.getAllByTestId('event-participants-face')).toHaveLength(3); + expect(screen.getByText('ȘT')).toBeTruthy(); + expect(screen.getByText('ID')).toBeTruthy(); + expect(screen.queryByText('RL')).toBeNull(); + expect(screen.getByText('+15')).toBeTruthy(); + }); + + it('shows up to five faces when asked', async () => { + await render( + , + ); + expect(screen.getAllByTestId('event-participants-face')).toHaveLength(5); + expect(screen.getByText('+2')).toBeTruthy(); + }); + + it('has no bubble when everyone is shown', async () => { + await render( + , + ); + expect(screen.getAllByTestId('event-participants-face')).toHaveLength(2); + expect(screen.queryByTestId('event-participants-more')).toBeNull(); + }); + + it('caps the bubble at +99', async () => { + await render( + , + ); + expect(screen.getByText('+99')).toBeTruthy(); + }); + + it('shows the picture instead of the initials when there is one', async () => { + await render( + , + ); + expect(screen.queryByText('AP')).toBeNull(); + expect(screen.getByTestId('event-participants-photo').props.source).toEqual({ + uri: 'https://example.ro/a.jpg', + }); + }); + + it('renders nothing when nobody is going or there is no preview', async () => { + const { toJSON } = await render( + , + ); + expect(toJSON()).toBeNull(); + const second = await render( + , + ); + expect(second.toJSON()).toBeNull(); + const third = await render(); + expect(third.toJSON()).toBeNull(); + }); + + it('keeps the initials fixed-size (the circle does not grow with Dynamic Type)', async () => { + await render( + , + ); + expect(screen.getByText('ȘT').props.allowFontScaling).toBe(false); + }); +}); diff --git a/mobile/src/components/events/ParticipantAvatars.tsx b/mobile/src/components/events/ParticipantAvatars.tsx new file mode 100644 index 000000000..b67d48789 --- /dev/null +++ b/mobile/src/components/events/ParticipantAvatars.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Image, Text, View } from 'react-native'; +import { StyleSheet } from 'react-native-unistyles'; +import { EventParticipants } from '../../common/interfaces/event.interface'; + +/** at most this many faces by default, then the „+N” bubble */ +const MAX_FACES = 3; + +/** + * Who is coming, like the handbook's `.avs`: overlapping 32pt circles with a 2pt white ring + * (−8pt overlap), navy with yellow Manrope 800 initials (the picture when there is one), then a + * „+N” bubble for the rest. The circles are fixed-size graphics, so their letters do not grow + * with Dynamic Type; the row reads as one element: „18 persoane participă”. + */ +const ParticipantAvatars = ({ + participants, + accessibilityLabel, + max = MAX_FACES, + testID = 'event-participants', +}: { + participants?: EventParticipants | null; + /** how many faces before „+N” (the API sends up to 5) */ + max?: number; + accessibilityLabel: string; + testID?: string; +}) => { + const faces = (participants?.preview ?? []).slice(0, Math.max(1, max)); + if (!participants || participants.total <= 0 || faces.length === 0) { + return null; + } + const more = Math.max(0, participants.total - faces.length); + + return ( + + {faces.map((face, index) => ( + 0 ? styles.overlap : null]} + testID={`${testID}-face`} + > + {face.avatarUrl ? ( + + ) : ( + + {face.initials} + + )} + + ))} + {more > 0 && ( + + + {`+${more > 99 ? 99 : more}`} + + + )} + + ); +}; + +export default ParticipantAvatars; + +const SIZE = 32; + +const styles = StyleSheet.create((theme) => ({ + row: { flexDirection: 'row', alignItems: 'center' }, + face: { + width: SIZE, + height: SIZE, + borderRadius: SIZE / 2, + borderWidth: 2, + borderColor: theme.colors.white, + backgroundColor: theme.colors.navy, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + overlap: { marginLeft: -8 }, + photo: { width: '100%', height: '100%' }, + initials: { + fontFamily: theme.fontFamily.textHeavy, + fontSize: 12, + lineHeight: 15, + color: theme.colors.yellow, + includeFontPadding: false, + }, + // the rest: a light bubble in the same ring, so it reads as "and more", not as a person + more: { + width: undefined, + minWidth: SIZE, + paddingHorizontal: 5, + backgroundColor: theme.colors.paper, + }, + moreText: { + fontFamily: theme.fontFamily.textHeavy, + fontSize: 11.5, + lineHeight: 15, + color: theme.colors.navy, + includeFontPadding: false, + }, +})); diff --git a/mobile/src/screens/Event.tsx b/mobile/src/screens/Event.tsx index 1cc051367..65d1724e3 100644 --- a/mobile/src/screens/Event.tsx +++ b/mobile/src/screens/Event.tsx @@ -33,6 +33,7 @@ import { TAB_ROUTES } from '../routes/tab-routes'; import { Sheet, StatusBarScrim } from '../components/ui'; import { GlassSurface } from '../components/navigation/GlassTabBar'; import { DateBlock } from '../components/events/EventCard'; +import ParticipantAvatars from '../components/events/ParticipantAvatars'; import { AppText, Button, HexAvatar, StatusChip, statusKind } from '../design/components'; import { CalendarIcon, @@ -72,6 +73,8 @@ const InfoRow = ({ captionUnderline, captionFirst, chevron, + trailing, + below, onPress, accessibilityLabel, testID, @@ -87,6 +90,10 @@ const InfoRow = ({ captionFirst?: boolean; /** a chevron at the end: the row opens a page */ chevron?: boolean; + /** at the end of the row (the participants' faces) */ + trailing?: ReactNode; + /** under the caption, inside the text column (the faces at large text sizes) */ + below?: ReactNode; onPress?: () => void; accessibilityLabel?: string; testID?: string; @@ -112,7 +119,9 @@ const InfoRow = ({ {caption} )} + {below ? {below} : null} + {trailing} {chevron && } ); @@ -263,6 +272,14 @@ const Event = ({ navigation, route }: any) => { ); }; + // the faces' spoken label: „18 persoane participă” (no exclamation, no names) + const participantsLabel = (n: number) => { + if (n === 1) { + return `${t('participants_label_one')}`; + } + return `${t(romanianNeedsDe(n) ? 'participants_label_of' : 'participants_label', { n })}`; + }; + const attendees = () => { const n = event?.numberOfPersonsGoingToEvent; if (n === undefined || n === null) { @@ -307,6 +324,15 @@ const Event = ({ navigation, route }: any) => { ); } + // beside the facts there is room for three faces; under them (large text), for all five + const participantFaces = event?.participants?.total ? ( + + ) : null; + const barStatus = !responded ? event?.isPublic ? t('bar.open') @@ -445,6 +471,9 @@ const Event = ({ navigation, route }: any) => { event.capacity ? `${capacityLabel(event.capacity)}` : `${t('capacity_unlimited')}` } caption={attendees() || `${t('capacity')}`} + // large text: the faces go under the caption so the title and caption keep the width + trailing={stackedBar ? undefined : participantFaces} + below={stackedBar ? participantFaces : undefined} testID="event-capacity" /> {!event.isPublic && ( @@ -616,6 +645,7 @@ const styles = StyleSheet.create((theme) => ({ }, infoText: { flex: 1, minWidth: 0, gap: 1 }, infoTextReversed: { flexDirection: 'column-reverse' }, + infoBelow: { marginTop: 8, alignSelf: 'flex-start' }, infoTitle: { fontFamily: theme.fontFamily.textHeavy, fontSize: 15.5, lineHeight: 21 }, infoCaption: { fontFamily: theme.fontFamily.text }, underline: { textDecorationLine: 'underline', fontFamily: theme.fontFamily.textBold }, diff --git a/mobile/src/screens/event-participants.test.tsx b/mobile/src/screens/event-participants.test.tsx new file mode 100644 index 000000000..ad6dda215 --- /dev/null +++ b/mobile/src/screens/event-participants.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { render, screen, within } from '@testing-library/react-native'; +import '../common/config/i18n'; +import Event from './Event'; +import useStore from '../store/store'; +import { EventVolunteerStatus } from '../common/enums/event-volunteer-status.enum'; + +// The event page shows who is coming as overlapping initials in the places row (handbook `.avs`). + +let mockFontScale = 1; +jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + __esModule: true, + default: () => ({ width: 390, height: 844, scale: 3, fontScale: mockFontScale }), +})); +jest.mock('../services/organization/organization.service', () => ({ + useActivateOrganization: () => ({ activate: jest.fn() }), +})); +jest.mock('@gorhom/bottom-sheet', () => require('@gorhom/bottom-sheet/mock')); +jest.mock('expo-linking', () => ({ createURL: (p: string) => p, openURL: jest.fn() })); +jest.mock('../services/event/event.service', () => ({ + useEventQuery: () => ({ isFetching: false, error: null, refetch: jest.fn() }), + useSetRsvpEventMutation: () => ({ mutate: jest.fn(), isLoading: false }), + useCancelRsvpMutation: () => ({ mutate: jest.fn(), isLoading: false }), +})); + +const { Navigator, Screen } = createNativeStackNavigator(); + +const renderEvent = () => + render( + + + + + + + , + ); + +const setEvent = (extra: Record = {}) => + useStore.setState({ + userProfile: { firstName: 'Ana', myOrganizations: [] } as any, + event: { + id: '9', + name: 'Eveniment 9', + location: 'Pădurea Băneasa', + isPublic: true, + startDate: '2026-11-22T07:00:00.000Z', + endDate: '2026-11-22T12:00:00.000Z', + eventInterval: '', + capacity: 50, + displayStatus: 'published', + organizationName: 'Asociația Exemplu', + description: 'd', + volunteerStatus: EventVolunteerStatus.NO_RESPONSE, + numberOfPersonsGoingToEvent: 18, + participants: { + total: 18, + preview: ['ȘT', 'AM', 'ID', 'RL', 'EM'].map((initials) => ({ initials })), + }, + ...extra, + } as any, + }); + +beforeEach(() => { + mockFontScale = 1; +}); + +describe('Eveniment: cine participă', () => { + it('shows three faces and „+15” beside the places, read as „18 persoane participă”', async () => { + setEvent(); + await renderEvent(); + const row = await screen.findByTestId('event-capacity'); + const faces = within(row).getByLabelText('18 persoane participă'); + expect(within(faces).getAllByTestId('event-participants-face')).toHaveLength(3); + expect(within(faces).getByText('ȘT')).toBeTruthy(); + expect(within(faces).getByText('+15')).toBeTruthy(); + // the caption and the places are still there + expect(within(row).getByText('50 de locuri')).toBeTruthy(); + expect(within(row).getByText(/18 persoane participă!/)).toBeTruthy(); + }); + + it('uses „de” from 20', async () => { + setEvent({ + numberOfPersonsGoingToEvent: 20, + participants: { total: 20, preview: [{ initials: 'AP' }] }, + }); + await renderEvent(); + expect(await screen.findByLabelText('20 de persoane participă')).toBeTruthy(); + expect(screen.getByText('+19')).toBeTruthy(); + }); + + it('one person: no bubble, singular label', async () => { + setEvent({ + numberOfPersonsGoingToEvent: 1, + participants: { total: 1, preview: [{ initials: 'AP' }] }, + }); + await renderEvent(); + expect(await screen.findByLabelText('O persoană participă')).toBeTruthy(); + expect(screen.queryByTestId('event-participants-more')).toBeNull(); + }); + + it('no faces when nobody is going', async () => { + setEvent({ numberOfPersonsGoingToEvent: 0, participants: { total: 0, preview: [] } }); + await renderEvent(); + await screen.findByTestId('event-capacity'); + expect(screen.queryByTestId('event-participants')).toBeNull(); + }); + + it('no faces with an older API that sends no participants', async () => { + setEvent({ participants: undefined }); + await renderEvent(); + await screen.findByTestId('event-capacity'); + expect(screen.queryByTestId('event-participants')).toBeNull(); + expect(screen.getByText(/18 persoane participă!/)).toBeTruthy(); + }); + + it('at large text sizes the faces move under the caption and show all five', async () => { + mockFontScale = 1.6; + setEvent(); + await renderEvent(); + const faces = await screen.findByLabelText('18 persoane participă'); + expect(within(faces).getAllByTestId('event-participants-face')).toHaveLength(5); + expect(within(faces).getByText('+13')).toBeTruthy(); + }); +});