mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
fix: show contact photos in the demo's personal account
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user