mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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.
This commit is contained in:
@@ -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<ViewStyle>;
|
||||
/**
|
||||
* 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 (
|
||||
<View style={containerStyle}>
|
||||
<Image
|
||||
source={{ uri: contactPhoto }}
|
||||
style={{ width: size, height: size, borderRadius: size / 2 }}
|
||||
resizeMode="cover"
|
||||
onError={() => setPhotoError(true)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (showFavicon && faviconDomain) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<readonly ContactCard[], Map<string, string>>();
|
||||
|
||||
export function getContactPhotoIndex(contacts: readonly ContactCard[]): Map<string, string> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user