From bd1fa8e08ea652a38e5d64623f502f44044bb5e5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 01:40:38 +0100 Subject: [PATCH] fix: show contact photos in the demo's personal account --- lib/demo/__tests__/fixtures.test.ts | 31 +++++++++++++++++------- lib/demo/demo-client.ts | 18 +++++++------- lib/demo/demo-data.ts | 12 +++++----- lib/demo/fixtures/contacts.ts | 37 ++++++++++++++++++++++++++++- stores/auth-store.ts | 2 +- 5 files changed, 75 insertions(+), 25 deletions(-) diff --git a/lib/demo/__tests__/fixtures.test.ts b/lib/demo/__tests__/fixtures.test.ts index 2e85fa1f5..38e487e2d 100644 --- a/lib/demo/__tests__/fixtures.test.ts +++ b/lib/demo/__tests__/fixtures.test.ts @@ -12,7 +12,7 @@ import path from 'node:path'; import { getDemoData } from '../demo-data'; import { DemoJMAPClient } from '../demo-client'; import { DEMO_ASSETS } from '../fixtures/assets'; -import { PORTRAITS, NORA, ALICE, BOB } from '../fixtures/people'; +import { PORTRAITS, NORA, NORA_PERSONAL, ALICE, BOB } from '../fixtures/people'; import { GONOGO_INVITE_BLOB, GONOGO_UID } from '../fixtures/emails'; import { GONOGO_EVENT_ID, createGoNoGoInvitationIcs } from '../fixtures/calendars'; import { SUPPORT_ACCOUNT_ID, SMB, MB } from '../fixtures/mailboxes'; @@ -107,12 +107,13 @@ describe('demo fixtures: structure', () => { } }); - it('contact groups only list contacts that exist', () => { - const ids = new Set(work.contacts.map((c) => c.id)); - for (const group of work.contacts.filter((c) => c.kind === 'group')) { + it.each([['work', work], ['personal', personal]] as const)('%s contact groups only list contacts that exist', (_name, data) => { + const ids = new Set(data.contacts.map((c) => c.id)); + expect(ids.size, 'duplicate contact ids').toBe(data.contacts.length); + for (const group of data.contacts.filter((c) => c.kind === 'group')) { for (const member of Object.keys(group.members ?? {})) expect(ids.has(member), `${group.id} -> ${member}`).toBe(true); } - expect(work.contacts.filter((c) => c.kind === 'group').length).toBeGreaterThanOrEqual(3); + expect(data.contacts.filter((c) => c.kind === 'group').length).toBeGreaterThanOrEqual(2); }); it('people who write to Nora from the cast are in her address books', () => { @@ -122,6 +123,19 @@ describe('demo fixtures: structure', () => { expect(contactEmails.has(p.email.toLowerCase()), `${p.name} has a portrait but no contact card`).toBe(true); } }); + + it('the personal login has the photo of everyone who writes to it, and of Nora on both addresses', () => { + // Avatars only look at the open account's cards. + const photoOf = (address: string) => personal.contacts + .find((c) => Object.values(c.emails ?? {}).some((e) => e.address === address)) + ?.media?.photo1?.uri; + const senders = new Set(personal.emails.flatMap((e) => e.from ?? []).map((a) => a.email)); + const cast = PORTRAITS.filter((p) => senders.has(p.email)); + expect(cast.length).toBeGreaterThanOrEqual(5); + for (const p of cast) expect(photoOf(p.email), p.name).toBe(`/demo/portraits/${p.portrait}.jpg`); + expect(photoOf(NORA_PERSONAL.email)).toBe('/demo/portraits/nora-lindqvist.jpg'); + expect(photoOf(NORA.email)).toBe('/demo/portraits/nora-lindqvist.jpg'); + }); }); describe('demo fixtures: files behind the data', () => { @@ -151,7 +165,7 @@ describe('demo fixtures: files behind the data', () => { }); it('contact photos are served locally, not from a third party', () => { - for (const c of work.contacts) { + for (const c of [...work.contacts, ...personal.contacts]) { for (const m of Object.values(c.media ?? {})) expect(m.uri).toMatch(/^\/demo\/portraits\//); } }); @@ -253,12 +267,13 @@ describe('demo fixtures: features the screenshots show', () => { expect(mailboxes.find((m) => m.id === MB.archive2025)?.parentId).toBe(MB.archive); }); - it('the personal account is a separate mail-only login', async () => { + it('the personal account is a separate login with mail and contacts', async () => { const second = new DemoJMAPClient('personal'); expect(second.getAccountId()).not.toBe(client.getAccountId()); expect(second.getUsername()).not.toBe(client.getUsername()); expect(second.supportsCalendars()).toBe(false); - expect(second.supportsContacts()).toBe(false); + expect(second.supportsContacts()).toBe(true); + expect(second.supportsFiles()).toBe(false); expect((await second.getMailboxes()).find((m) => m.role === 'inbox')?.unreadEmails).toBeGreaterThan(0); }); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index c00a06b6f..2d098ed69 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -37,7 +37,7 @@ export const DEMO_SIMULATE_KEY = 'bulwark-demo-simulate'; * * One instance per demo login: `new DemoJMAPClient('work')` is the primary * account (with the shared Support mailbox delegated to it), and - * `new DemoJMAPClient('personal')` the mail-only second account. + * `new DemoJMAPClient('personal')` the second account, with mail and contacts. */ export class DemoJMAPClient implements IJMAPClient { private data: DemoData; @@ -117,8 +117,8 @@ export class DemoJMAPClient implements IJMAPClient { // ── Capabilities ────────────────────────────────────────────── - /** The personal login is mail only; everything else lives in the work account. */ - private get mailOnly(): boolean { return this.profile === 'personal'; } + /** The personal login has mail and contacts; everything else lives in the work account. */ + private get isPersonal(): boolean { return this.profile === 'personal'; } hasAccountCapability(capability: string, _accountId?: string): boolean { return capability === 'urn:ietf:params:jmap:submission'; @@ -141,8 +141,8 @@ export class DemoJMAPClient implements IJMAPClient { 'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } }, 'urn:ietf:params:jmap:vacationresponse': {}, 'urn:ietf:params:jmap:quota': {}, - ...(this.mailOnly ? {} : { - 'urn:ietf:params:jmap:contacts': {}, + 'urn:ietf:params:jmap:contacts': {}, + ...(this.isPersonal ? {} : { 'urn:ietf:params:jmap:calendars': {}, 'urn:ietf:params:jmap:sieve': {}, 'urn:ietf:params:jmap:files': {}, @@ -161,10 +161,10 @@ export class DemoJMAPClient implements IJMAPClient { supportsQuota(): boolean { return true; } supportsVacationResponse(): boolean { return true; } accountSupports(): boolean { return true; } - supportsContacts(): boolean { return !this.mailOnly; } - supportsCalendars(): boolean { return !this.mailOnly; } - supportsSieve(): boolean { return !this.mailOnly; } - supportsFiles(): boolean { return !this.mailOnly; } + supportsContacts(): boolean { return true; } + supportsCalendars(): boolean { return !this.isPersonal; } + supportsSieve(): boolean { return !this.isPersonal; } + supportsFiles(): boolean { return !this.isPersonal; } supportsPrincipals(): boolean { return false; } async getPrincipals(): Promise { return []; } async setCalendarShare(): Promise { /* demo: no-op */ } diff --git a/lib/demo/demo-data.ts b/lib/demo/demo-data.ts index 876d7062f..051c858a0 100644 --- a/lib/demo/demo-data.ts +++ b/lib/demo/demo-data.ts @@ -3,7 +3,7 @@ import { createDemoMailboxes, createDemoSupportMailboxes, createDemoPersonalMail import { createDemoEmails } from './fixtures/emails'; import { createDemoSupportEmails } from './fixtures/support'; import { createDemoPersonalEmails, createDemoPersonalIdentities } from './fixtures/personal'; -import { createDemoContacts, createDemoAddressBooks } from './fixtures/contacts'; +import { createDemoContacts, createDemoAddressBooks, createDemoPersonalContacts, createDemoPersonalAddressBooks } from './fixtures/contacts'; import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars'; import { createDemoCalendarTasks } from './fixtures/tasks'; import { createDemoIdentities } from './fixtures/identities'; @@ -18,8 +18,8 @@ import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types'; /** * The demo signs in to two accounts. "work" is the primary login with mail, * contacts, calendars, files and filters, plus the shared Support mailbox - * delegated to it. "personal" is a mail-only second login, so the account - * switcher and the merged inbox have more than one source. + * delegated to it. "personal" is a second login with mail and contacts, so + * the account switcher and the merged inbox have more than one source. */ export type DemoProfile = 'work' | 'personal'; @@ -56,7 +56,7 @@ export const DEMO_PERSONAL_ACCOUNT_ID = 'demo-personal'; /** What `getSharedAccounts()` reports for a profile. */ export function demoSharedAccounts(profile: DemoProfile): SharedAccount[] { if (profile === 'personal') { - return [{ id: DEMO_PERSONAL_ACCOUNT_ID, name: 'Personal', isPrimary: true, capabilities: { mail: true, sieve: false, calendars: false, contacts: false, filenode: false } }]; + return [{ id: DEMO_PERSONAL_ACCOUNT_ID, name: 'Personal', isPrimary: true, capabilities: { mail: true, sieve: false, calendars: false, contacts: true, filenode: false } }]; } return [ { id: DEMO_WORK_ACCOUNT_ID, name: 'Meridian Labs', isPrimary: true, capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: true } }, @@ -75,8 +75,8 @@ export function getDemoData(profile: DemoProfile = 'work'): DemoData { mailboxes: createDemoPersonalMailboxes(), sharedMailboxes: {}, emails: createDemoPersonalEmails(), - contacts: [], - addressBooks: [], + contacts: createDemoPersonalContacts(), + addressBooks: createDemoPersonalAddressBooks(), calendars: [], calendarEvents: [], calendarTasks: [], diff --git a/lib/demo/fixtures/contacts.ts b/lib/demo/fixtures/contacts.ts index 805ae724f..d3688054f 100644 --- a/lib/demo/fixtures/contacts.ts +++ b/lib/demo/fixtures/contacts.ts @@ -1,7 +1,7 @@ import type { ContactCard, AddressBook } from '@/lib/jmap/types'; import { type Person, portraitUri, - NORA, ALICE, BOB, PRIYA, AHMED, JAMES, MICHAEL, RACHEL, WEI, MARIA, LISA, + NORA, NORA_PERSONAL, ALICE, BOB, PRIYA, AHMED, JAMES, MICHAEL, RACHEL, WEI, MARIA, LISA, SARAH, OLIVIA, JENNIFER, KENJI, SOFIA, ANNA, ELIAS, CARLOS, MARCUS, EMMA, DAVID, DANIEL, TOMAS, LENA, YUSUF, } from './people'; @@ -9,6 +9,8 @@ import { export const AB = { personal: 'demo-addressbook-personal', work: 'demo-addressbook-work', + /** The one address book of the personal login. */ + home: 'demo-addressbook-home', } as const; export function createDemoAddressBooks(): AddressBook[] { @@ -209,5 +211,38 @@ export function createDemoContacts(): ContactCard[] { ]; } +export function createDemoPersonalAddressBooks(): AddressBook[] { + return [{ + id: AB.home, + name: 'Contacts', + isDefault: true, + isSubscribed: true, + sortOrder: 1, + myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: false }, + }]; +} + +/** + * The personal login's contacts: Nora's own card and copies of the work + * account's Personal book. Avatars only look at the open account's cards, + * so without these every sender of the personal mail, and Nora herself, + * fell back to initials there. + */ +export function createDemoPersonalContacts(): ContactCard[] { + const homeId = (id: string) => id.replace(/^demo-/, 'demo-home-'); + const copies = createDemoContacts() + .filter((card) => card.addressBookIds[AB.personal]) + .map((card) => ({ + ...card, + id: homeId(card.id), + addressBookIds: { [AB.home]: true }, + ...(card.members ? { members: Object.fromEntries(Object.keys(card.members).map((id) => [homeId(id), true])) } : {}), + })); + return [ + person('demo-home-contact-nora', AB.home, NORA_PERSONAL, 'private', { moreEmails: [[NORA.email, 'work']] }), + ...copies, + ]; +} + /** The persona as a contact card, for the hover card on her own @mention. */ export const SELF_CONTACT_EMAIL = NORA.email; diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 1cd5f21f2..310883be6 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1765,7 +1765,7 @@ export const useAuthStore = create()( const { DemoJMAPClient } = await import('@/lib/demo/demo-client'); const { seedDemoPreferences } = await import('@/lib/demo/demo-preferences'); // Two logins: the work account (primary, with the shared Support - // mailbox) and a mail-only personal account, so the account + // mailbox) and a personal account with mail and contacts, so the account // switcher and the merged inbox have more than one source. const client = new DemoJMAPClient('work'); const personal = new DemoJMAPClient('personal');