From eca38715453c8b3e94d4697ac943f552f3e12c81 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:40:59 +0200 Subject: [PATCH] feat: show the sender's contact photo as their avatar #54 Sender avatars ignored address-book photos, so a contact whose photo shows in Contacts still got initials or a favicon in the mail list, the viewer and the thread cards. Like the webmail's `Avatar`, the contact photo now wins over the favicon and the initials; `disableImages` (Junk) still shows initials only. The lookup goes through a lowercased email -> photo index that is built once per contacts array, and each avatar selects just its own photo string, so a list of avatars neither rescans the address book per row nor re-renders when an unrelated contact changes. Stalwart returns photos as inline data URIs, which the index normalizes the same way Contacts does, so no authenticated download is involved. --- src/components/SenderAvatar.tsx | 38 ++++++++++++++---- src/lib/__tests__/avatar-utils.test.ts | 54 ++++++++++++++++++++++++++ src/lib/avatar-utils.ts | 39 +++++++++++++++++++ 3 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 src/lib/__tests__/avatar-utils.test.ts diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx index a6efa775..79309e21 100644 --- a/src/components/SenderAvatar.tsx +++ b/src/components/SenderAvatar.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; import { generateEmailAvatarColor, + getContactPhotoForEmail, getEmailInitials, getFaviconDomain, getFaviconUrl, @@ -9,6 +10,7 @@ import { markFaviconFailed, } from '../lib/avatar-utils'; import { useSettingsStore } from '../stores/settings-store'; +import { useContactsStore } from '../stores/contacts-store'; interface SenderAvatarProps { name?: string | null; @@ -16,31 +18,40 @@ interface SenderAvatarProps { size?: number; style?: StyleProp; /** - * Never load a remote image (favicon) — initials only. The list passes this - * inside Junk unless `showAvatarsInJunk` is on (webmail 1.5.1), so a spam - * sender's domain is not contacted by merely listing the folder. + * Never show an image (contact photo or favicon) — initials only. The list + * passes this inside Junk unless `showAvatarsInJunk` is on (webmail 1.5.1), + * so a spam sender's domain is not contacted by merely listing the folder. */ disableImages?: boolean; } // Mirrors the webmail `Avatar` priority for the subset available on native: -// company favicon (if sender domain is non-personal and `senderFavicons` is on) -// → initials over HSL-hashed color. +// address-book photo → company favicon (if sender domain is non-personal and +// `senderFavicons` is on) → initials over HSL-hashed color. export default function SenderAvatar({ name, email, size = 40, style, disableImages }: SenderAvatarProps) { const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages; + // Selects a string, so only avatars whose photo changed re-render when the + // contacts change; the index behind it is built once per contacts array. + const contactPhoto = useContactsStore((s) => getContactPhotoForEmail(s.contacts, email)); const [imgError, setImgError] = React.useState(false); + const [photoError, setPhotoError] = React.useState(false); React.useEffect(() => { setImgError(false); }, [email]); + React.useEffect(() => { + setPhotoError(false); + }, [contactPhoto]); + const primaryName = (name ?? '').split(',')[0].trim(); const initials = getEmailInitials(primaryName, email ?? undefined); const bgColor = generateEmailAvatarColor(primaryName, email ?? undefined); const faviconDomain = getFaviconDomain(email ?? undefined); const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false; - const showFavicon = senderFavicons && !!faviconDomain && !imgError && !domainFailed; + const showPhoto = !disableImages && !!contactPhoto && !photoError; + const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed; const handleError = React.useCallback(() => { setImgError(true); @@ -53,13 +64,26 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma width: size, height: size, borderRadius: size / 2, - backgroundColor: showFavicon ? '#ffffff' : bgColor, + backgroundColor: showPhoto ? 'transparent' : showFavicon ? '#ffffff' : bgColor, }, style, ]; const fontSize = Math.round(size * 0.4); + if (showPhoto && contactPhoto) { + return ( + + setPhotoError(true)} + /> + + ); + } + if (showFavicon && faviconDomain) { return ( diff --git a/src/lib/__tests__/avatar-utils.test.ts b/src/lib/__tests__/avatar-utils.test.ts new file mode 100644 index 00000000..58072373 --- /dev/null +++ b/src/lib/__tests__/avatar-utils.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { getContactPhotoForEmail, getContactPhotoIndex } from '../avatar-utils'; +import type { ContactCard } from '../../api/types'; + +function card(id: string, emails: string[], photo?: string, mediaType?: string): ContactCard { + return { + id, + addressBookIds: { ab1: true }, + emails: Object.fromEntries(emails.map((address, i) => [`e${i}`, { address }])), + ...(photo ? { media: { m0: { kind: 'photo', uri: photo, ...(mediaType ? { mediaType } : {}) } } } : {}), + }; +} + +describe('contact photo avatars (#54)', () => { + it('finds the photo of the contact that holds the sender address, case-insensitively', () => { + const contacts = [ + card('bob', ['bob@example.org']), + card('alice', ['Alice.Nguyen@Example.com', 'alice@home.example'], 'data:image/png;base64,AAAA'), + ]; + expect(getContactPhotoForEmail(contacts, 'alice.nguyen@example.com')).toBe('data:image/png;base64,AAAA'); + expect(getContactPhotoForEmail(contacts, ' ALICE@home.example ')).toBe('data:image/png;base64,AAAA'); + expect(getContactPhotoForEmail(contacts, 'bob@example.org')).toBeUndefined(); + expect(getContactPhotoForEmail(contacts, 'nobody@example.org')).toBeUndefined(); + expect(getContactPhotoForEmail(contacts, undefined)).toBeUndefined(); + expect(getContactPhotoForEmail(contacts, '')).toBeUndefined(); + }); + + it('normalizes the malformed data URIs Stalwart emits (#307)', () => { + const contacts = [card('a', ['a@x.org'], 'data:base64,AAAA', 'image/png')]; + expect(getContactPhotoForEmail(contacts, 'a@x.org')).toBe('data:image/png;base64,AAAA'); + }); + + it('lets the first contact with a photo win when an address is on several cards', () => { + const contacts = [ + card('no-photo', ['dup@x.org']), + card('first', ['dup@x.org'], 'https://x.org/first.jpg'), + card('second', ['dup@x.org'], 'https://x.org/second.jpg'), + ]; + expect(getContactPhotoForEmail(contacts, 'dup@x.org')).toBe('https://x.org/first.jpg'); + }); + + it('builds the index once per contacts array', () => { + const contacts = [card('a', ['a@x.org'], 'https://x.org/a.jpg')]; + const index = getContactPhotoIndex(contacts); + expect(getContactPhotoIndex(contacts)).toBe(index); + + // A store update hands out a new array, which gets a fresh index. + const updated = [...contacts, card('b', ['b@x.org'], 'https://x.org/b.jpg')]; + const next = getContactPhotoIndex(updated); + expect(next).not.toBe(index); + expect(next.get('b@x.org')).toBe('https://x.org/b.jpg'); + expect(index.has('b@x.org')).toBe(false); + }); +}); diff --git a/src/lib/avatar-utils.ts b/src/lib/avatar-utils.ts index 723b6a56..469ffc5d 100644 --- a/src/lib/avatar-utils.ts +++ b/src/lib/avatar-utils.ts @@ -10,6 +10,9 @@ // Both variants also define `getInitials` with slightly different rules, so we // export them separately to keep the match 1:1. +import type { ContactCard } from '../api/types'; +import { getContactPhotoUri } from './contact-utils'; + // --- Account avatar ------------------------------------------------------- const ACCOUNT_AVATAR_PALETTE = [ @@ -150,3 +153,39 @@ export function hasFaviconFailed(domain: string): boolean { export function markFaviconFailed(domain: string): void { failedFaviconDomains.add(domain); } + +// --- Contact photos ------------------------------------------------------- +// +// Webmail `components/ui/avatar.tsx` looks the sender up in the address book +// and shows their contact photo ahead of favicons and initials. The webmail +// scans every contact per avatar; the mail list renders dozens of avatars, so +// build one lowercased email → photo index per contacts array instead and +// reuse it until the store hands out a new array. + +const contactPhotoIndexCache = new WeakMap>(); + +export function getContactPhotoIndex(contacts: readonly ContactCard[]): Map { + let index = contactPhotoIndexCache.get(contacts); + if (index) return index; + index = new Map(); + for (const contact of contacts) { + if (!contact.emails) continue; + const photo = getContactPhotoUri(contact); + if (!photo) continue; + for (const entry of Object.values(contact.emails)) { + const key = entry?.address?.trim().toLowerCase(); + // First card with a photo wins, so the pick is stable across renders. + if (key && !index.has(key)) index.set(key, photo); + } + } + contactPhotoIndexCache.set(contacts, index); + return index; +} + +export function getContactPhotoForEmail( + contacts: readonly ContactCard[], + email: string | null | undefined, +): string | undefined { + const key = email?.trim().toLowerCase(); + return key ? getContactPhotoIndex(contacts).get(key) : undefined; +}