fix: show contact photos in the demo's personal account

This commit is contained in:
Linus Rath
2026-10-31 01:40:38 +01:00
parent 5f1d03f19c
commit bd1fa8e08e
5 changed files with 75 additions and 25 deletions
+23 -8
View File
@@ -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);
});
+9 -9
View File
@@ -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<never[]> { return []; }
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
+6 -6
View File
@@ -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: [],
+36 -1
View File
@@ -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;
+1 -1
View File
@@ -1765,7 +1765,7 @@ export const useAuthStore = create<AuthState>()(
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');