mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: show the sender's BIMI logo from the webmail when the message passes DMARC
This commit is contained in:
@@ -12,10 +12,12 @@ import {
|
|||||||
hasFaviconFailed,
|
hasFaviconFailed,
|
||||||
markFaviconFailed,
|
markFaviconFailed,
|
||||||
} from '../lib/avatar-utils';
|
} from '../lib/avatar-utils';
|
||||||
|
import { bimiLogoUrl, isNoLogoImage, markBrandLogoFailed, showBrandLogo } from '../lib/avatar-bimi';
|
||||||
import type { ContactPhotoRef } from '../lib/contact-utils';
|
import type { ContactPhotoRef } from '../lib/contact-utils';
|
||||||
import { useContactPhotoSource } from '../lib/use-contact-photo';
|
import { useContactPhotoSource } from '../lib/use-contact-photo';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useContactsStore } from '../stores/contacts-store';
|
import { useContactsStore } from '../stores/contacts-store';
|
||||||
|
import { useAccountStore } from '../stores/account-store';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { ShieldAlert } from './icons';
|
import { ShieldAlert } from './icons';
|
||||||
|
|
||||||
@@ -43,12 +45,19 @@ interface SenderAvatarProps {
|
|||||||
* or photo (webmail #1439).
|
* or photo (webmail #1439).
|
||||||
*/
|
*/
|
||||||
senderWarning?: string | null;
|
senderWarning?: string | null;
|
||||||
|
/**
|
||||||
|
* The From domain of a message that passed DMARC for it (see
|
||||||
|
* brandIndicatorDomain in lib/avatar-bimi): its BIMI logo, from the
|
||||||
|
* account's webmail, comes before the favicon, under the same switch.
|
||||||
|
*/
|
||||||
|
brandDomain?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors the webmail `Avatar` priority for the subset available on native:
|
// Mirrors the webmail `Avatar` priority for the subset available on native:
|
||||||
// sender warning → address-book photo → company favicon (if sender domain is
|
// sender warning → address-book photo → BIMI logo (from the account's
|
||||||
|
// webmail, `senderFavicons` on) → company favicon (if sender domain is
|
||||||
// non-personal and `senderFavicons` is on) → initials over HSL-hashed color.
|
// non-personal and `senderFavicons` is on) → initials over HSL-hashed color.
|
||||||
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) {
|
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning, brandDomain }: SenderAvatarProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const warned = !!senderWarning;
|
const warned = !!senderWarning;
|
||||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
|
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
|
||||||
@@ -63,9 +72,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
|||||||
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
|
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
|
||||||
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
|
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
|
||||||
const [imgError, setImgError] = React.useState(false);
|
const [imgError, setImgError] = React.useState(false);
|
||||||
|
// BIMI logos come from the webmail the active account signed in through.
|
||||||
|
const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null);
|
||||||
|
const [failedBrand, setFailedBrand] = React.useState<string | null>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
|
setFailedBrand(null);
|
||||||
}, [email]);
|
}, [email]);
|
||||||
|
|
||||||
const primaryName = (name ?? '').split(',')[0].trim();
|
const primaryName = (name ?? '').split(',')[0].trim();
|
||||||
@@ -74,9 +87,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
|||||||
|
|
||||||
const faviconDomain = getFaviconDomain(email ?? undefined);
|
const faviconDomain = getFaviconDomain(email ?? undefined);
|
||||||
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
|
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
|
||||||
|
const brandUrl = brandDomain ? bimiLogoUrl(webmailUrl, brandDomain) : null;
|
||||||
|
const showBrand = !!brandUrl && brandUrl !== failedBrand
|
||||||
|
&& showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl });
|
||||||
const content = avatarContent({
|
const content = avatarContent({
|
||||||
senderWarning,
|
senderWarning,
|
||||||
hasPhoto: !disableImages && !!photoSource,
|
hasPhoto: !disableImages && !!photoSource,
|
||||||
|
showBrand,
|
||||||
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
|
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +101,12 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
|||||||
setImgError(true);
|
setImgError(true);
|
||||||
if (faviconDomain) markFaviconFailed(faviconDomain);
|
if (faviconDomain) markFaviconFailed(faviconDomain);
|
||||||
}, [faviconDomain]);
|
}, [faviconDomain]);
|
||||||
|
// No logo (or it didn't load): the favicon or initials take over.
|
||||||
|
const handleBrandFailed = React.useCallback(() => {
|
||||||
|
if (!brandUrl) return;
|
||||||
|
markBrandLogoFailed(brandUrl);
|
||||||
|
setFailedBrand(brandUrl);
|
||||||
|
}, [brandUrl]);
|
||||||
|
|
||||||
const containerStyle = [
|
const containerStyle = [
|
||||||
styles.container,
|
styles.container,
|
||||||
@@ -93,7 +116,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
|||||||
borderRadius: size / 2,
|
borderRadius: size / 2,
|
||||||
backgroundColor: content === 'warning'
|
backgroundColor: content === 'warning'
|
||||||
? c.errorBg
|
? c.errorBg
|
||||||
: content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor,
|
: content === 'photo' ? 'transparent' : content === 'favicon' || content === 'brand' ? '#ffffff' : bgColor,
|
||||||
},
|
},
|
||||||
style,
|
style,
|
||||||
];
|
];
|
||||||
@@ -127,6 +150,22 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (content === 'brand' && brandUrl) {
|
||||||
|
return (
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: brandUrl }}
|
||||||
|
style={{ width: size, height: size, borderRadius: size / 2 }}
|
||||||
|
resizeMode="cover"
|
||||||
|
// The webmail answers a domain without a logo with a 1x1 PNG.
|
||||||
|
onLoad={(e) => { if (isNoLogoImage(e.nativeEvent?.source)) handleBrandFailed(); }}
|
||||||
|
onError={handleBrandFailed}
|
||||||
|
testID="sender-brand-avatar"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (content === 'favicon' && faviconDomain) {
|
if (content === 'favicon' && faviconDomain) {
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle}>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
bimiLogoUrl,
|
||||||
|
brandIndicatorDomain,
|
||||||
|
isNoLogoImage,
|
||||||
|
markBrandLogoFailed,
|
||||||
|
resetBrandLogosForTests,
|
||||||
|
showBrandLogo,
|
||||||
|
} from '../avatar-bimi';
|
||||||
|
import { avatarContent } from '../avatar-utils';
|
||||||
|
import { LIST_AUTH_RESULTS_PROPERTY } from '../email-headers';
|
||||||
|
import type { Email } from '../../api/types';
|
||||||
|
|
||||||
|
// BIMI logos as the webmail shows them (components/ui/avatar.tsx,
|
||||||
|
// lib/email-headers.ts brandIndicatorDomain, app/api/bimi).
|
||||||
|
|
||||||
|
const PASS = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example';
|
||||||
|
const FAIL = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example';
|
||||||
|
const OTHER_DOMAIN = 'mx.example.org; spf=pass smtp.mailfrom=other.example; dkim=pass header.d=other.example; dmarc=pass header.from=other.example';
|
||||||
|
const NO_DMARC = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example';
|
||||||
|
const SPOOFED_PASS = 'mx.example.org; spf=fail smtp.mailfrom=bank.example; dkim=none; dmarc=pass header.from=bank.example';
|
||||||
|
|
||||||
|
function row(auth?: string[], from = 'billing@bank.example'): Email {
|
||||||
|
return {
|
||||||
|
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z',
|
||||||
|
hasAttachment: false, from: [{ name: 'Bank', email: from }],
|
||||||
|
...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}),
|
||||||
|
} as Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => resetBrandLogosForTests());
|
||||||
|
|
||||||
|
describe('brandIndicatorDomain', () => {
|
||||||
|
it('names the From domain of a message that passed DMARC for it, from the list property or the headers', () => {
|
||||||
|
expect(brandIndicatorDomain(row([PASS]))).toBe('bank.example');
|
||||||
|
const full = { ...row(), headers: [{ name: 'Authentication-Results', value: ` ${PASS}` }] };
|
||||||
|
expect(brandIndicatorDomain(full)).toBe('bank.example');
|
||||||
|
expect(brandIndicatorDomain(row([PASS], 'Billing@BANK.example'))).toBe('bank.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names none for a failed, missing or foreign DMARC result, or a spoofed message', () => {
|
||||||
|
expect(brandIndicatorDomain(row([FAIL]))).toBeUndefined();
|
||||||
|
expect(brandIndicatorDomain(row([NO_DMARC]))).toBeUndefined();
|
||||||
|
expect(brandIndicatorDomain(row([OTHER_DOMAIN]))).toBeUndefined();
|
||||||
|
expect(brandIndicatorDomain(row([SPOOFED_PASS]))).toBeUndefined();
|
||||||
|
expect(brandIndicatorDomain(row())).toBeUndefined();
|
||||||
|
expect(brandIndicatorDomain(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bimiLogoUrl', () => {
|
||||||
|
it('asks the account webmail for the domain', () => {
|
||||||
|
expect(bimiLogoUrl('https://mail.example.org/', 'bank.example')).toBe('https://mail.example.org/api/bimi?domain=bank.example');
|
||||||
|
expect(bimiLogoUrl('https://mail.example.org/webmail', 'a&b.example')).toBe('https://mail.example.org/webmail/api/bimi?domain=a%26b.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has none without a webmail', () => {
|
||||||
|
expect(bimiLogoUrl(null, 'bank.example')).toBeNull();
|
||||||
|
expect(bimiLogoUrl('', 'bank.example')).toBeNull();
|
||||||
|
expect(bimiLogoUrl('javascript:alert(1)', 'bank.example')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showBrandLogo', () => {
|
||||||
|
const base = { senderFavicons: true, email: 'billing@bank.example', brandDomain: 'bank.example', webmailUrl: 'https://mail.example.org' };
|
||||||
|
|
||||||
|
it('shows the logo for the address whose message passed', () => {
|
||||||
|
expect(showBrandLogo(base)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the sender favicon switch, which is off by default', () => {
|
||||||
|
expect(showBrandLogo({ ...base, senderFavicons: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows none for another address than the one the check was for', () => {
|
||||||
|
expect(showBrandLogo({ ...base, email: 'someone@elsewhere.example' })).toBe(false);
|
||||||
|
expect(showBrandLogo({ ...base, email: 'news@mail.bank.example' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows none without a webmail or a passing domain', () => {
|
||||||
|
expect(showBrandLogo({ ...base, webmailUrl: undefined })).toBe(false);
|
||||||
|
expect(showBrandLogo({ ...base, brandDomain: undefined })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops asking for a logo that failed this session', () => {
|
||||||
|
markBrandLogoFailed('https://mail.example.org/api/bimi?domain=bank.example');
|
||||||
|
expect(showBrandLogo(base)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the 1x1 answer as no logo', () => {
|
||||||
|
expect(isNoLogoImage({ width: 1, height: 1 })).toBe(true);
|
||||||
|
expect(isNoLogoImage({ width: 128, height: 128 })).toBe(false);
|
||||||
|
expect(isNoLogoImage(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('avatar order with a BIMI logo', () => {
|
||||||
|
it('keeps the warning shield and the contact photo ahead of the logo, and the logo ahead of the favicon', () => {
|
||||||
|
expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showBrand: true, showFavicon: true })).toBe('warning');
|
||||||
|
expect(avatarContent({ hasPhoto: true, showBrand: true, showFavicon: true })).toBe('photo');
|
||||||
|
expect(avatarContent({ hasPhoto: false, showBrand: true, showFavicon: true })).toBe('brand');
|
||||||
|
expect(avatarContent({ hasPhoto: false, showBrand: false, showFavicon: true })).toBe('favicon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the logo in SenderAvatar under the favicon switch, never for a warned sender', () => {
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components', 'SenderAvatar.tsx'), 'utf8');
|
||||||
|
// senderFavicons there is the setting, off for disabled images and a warned sender.
|
||||||
|
expect(source).toContain("const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;");
|
||||||
|
expect(source).toContain('showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl })');
|
||||||
|
expect(source).toContain('const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null);');
|
||||||
|
expect(source).toMatch(/if \(content === 'brand' && brandUrl\) \{[\s\S]*?source=\{\{ uri: brandUrl \}\}[\s\S]*?onError=\{handleBrandFailed\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// BIMI sender logos (webmail components/ui/avatar.tsx, lib/email-headers.ts
|
||||||
|
// brandIndicatorDomain, app/api/bimi/route.ts). The logo comes from the
|
||||||
|
// webmail the account signed in through: GET <webmail>/api/bimi?domain=<From
|
||||||
|
// domain> answers it as a PNG, or a 1x1 transparent PNG when the domain has
|
||||||
|
// none. The webmail caches its answers (6 h for a logo, 1 h for none); a
|
||||||
|
// domain without a logo is also remembered here for the session.
|
||||||
|
|
||||||
|
import type { Email } from '../api/types';
|
||||||
|
import {
|
||||||
|
LIST_AUTH_RESULTS_PROPERTY,
|
||||||
|
headerValues,
|
||||||
|
isAuthenticationSpoofed,
|
||||||
|
parseAuthenticationResults,
|
||||||
|
type AuthenticationResults,
|
||||||
|
} from './email-headers';
|
||||||
|
|
||||||
|
function domainOf(address: string): string | undefined {
|
||||||
|
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
|
||||||
|
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Authentication-Results of a message as the list or the reader holds it. */
|
||||||
|
function authenticationResultsOf(email: Pick<Email, 'headers' | 'from'>): AuthenticationResults | undefined {
|
||||||
|
let values: string[];
|
||||||
|
if (email.headers) {
|
||||||
|
values = headerValues(email.headers, 'Authentication-Results');
|
||||||
|
} else {
|
||||||
|
const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY];
|
||||||
|
values = Array.isArray(raw) ? raw.filter((value): value is string => typeof value === 'string') : [];
|
||||||
|
}
|
||||||
|
return values.length > 0 ? parseAuthenticationResults(values) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The From domain of a message that passed DMARC for that domain, whose
|
||||||
|
* BIMI logo may stand for the sender (webmail brandIndicatorDomain). The
|
||||||
|
* logo says who the domain is, not that a message came from it, so a message
|
||||||
|
* without a DMARC pass for its own From domain gets none. Undefined for no
|
||||||
|
* message (rows that show the recipient).
|
||||||
|
*/
|
||||||
|
export function brandIndicatorDomain(email: Pick<Email, 'headers' | 'from'> | undefined): string | undefined {
|
||||||
|
if (!email) return undefined;
|
||||||
|
const fromEmail = email.from?.[0]?.email ?? undefined;
|
||||||
|
const auth = authenticationResultsOf(email);
|
||||||
|
if (!auth?.dmarc || auth.dmarc.result !== 'pass' || !fromEmail) return undefined;
|
||||||
|
const domain = domainOf(fromEmail);
|
||||||
|
if (!domain || isAuthenticationSpoofed(auth)) return undefined;
|
||||||
|
const checked = auth.dmarc.domain ? domainOf(`@${auth.dmarc.domain}`) : domain;
|
||||||
|
return checked === domain ? domain : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The logo URL on the account's webmail; null without a usable webmail address. */
|
||||||
|
export function bimiLogoUrl(webmailUrl: string | null | undefined, domain: string): string | null {
|
||||||
|
const base = webmailUrl?.trim().replace(/\/+$/, '');
|
||||||
|
if (!base || !/^https?:\/\/[^/]/i.test(base)) return null;
|
||||||
|
return `${base}/api/bimi?domain=${encodeURIComponent(domain)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the avatar of `email` may show the BIMI logo of `brandDomain`
|
||||||
|
* (webmail Avatar showBrand): only under the sender favicon switch, only for
|
||||||
|
* the address shown (a thread row may show another participant than the
|
||||||
|
* message whose DMARC pass named the domain), and only from a webmail.
|
||||||
|
*/
|
||||||
|
export function showBrandLogo(options: {
|
||||||
|
senderFavicons: boolean;
|
||||||
|
email: string | null | undefined;
|
||||||
|
brandDomain: string | null | undefined;
|
||||||
|
webmailUrl: string | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
const { senderFavicons, email, brandDomain, webmailUrl } = options;
|
||||||
|
if (!senderFavicons || !brandDomain || !email) return false;
|
||||||
|
if (domainOf(email) !== brandDomain) return false;
|
||||||
|
const url = bimiLogoUrl(webmailUrl, brandDomain);
|
||||||
|
return url !== null && !failedBrandLogos.has(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logos that didn't load or answered "no logo", for the session.
|
||||||
|
const failedBrandLogos = new Set<string>();
|
||||||
|
|
||||||
|
export function markBrandLogoFailed(url: string): void {
|
||||||
|
failedBrandLogos.add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBrandLogoFailed(url: string): boolean {
|
||||||
|
return failedBrandLogos.has(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The webmail's "no logo" answer is a 1x1 transparent PNG. */
|
||||||
|
export function isNoLogoImage(size: { width?: number; height?: number } | null | undefined): boolean {
|
||||||
|
return !!size && typeof size.width === 'number' && size.width <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetBrandLogosForTests(): void {
|
||||||
|
failedBrandLogos.clear();
|
||||||
|
}
|
||||||
@@ -238,21 +238,24 @@ export function getContactPhotoForEmail(
|
|||||||
|
|
||||||
// --- What the avatar shows --------------------------------------------------
|
// --- What the avatar shows --------------------------------------------------
|
||||||
|
|
||||||
export type AvatarContent = 'warning' | 'photo' | 'favicon' | 'initials';
|
export type AvatarContent = 'warning' | 'photo' | 'brand' | 'favicon' | 'initials';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a sender avatar shows, in the webmail `Avatar`'s order. A failed or
|
* What a sender avatar shows, in the webmail `Avatar`'s order. A failed or
|
||||||
* unverified sender check comes first: a warning shield instead of any photo,
|
* unverified sender check comes first: a warning shield instead of any photo,
|
||||||
* favicon or initials, so a forged From doesn't get the real sender's logo or
|
* logo, favicon or initials, so a forged From doesn't get the real sender's
|
||||||
* contact photo (webmail 605a3983b, #1439).
|
* logo or contact photo (webmail 605a3983b, #1439). Then the contact photo,
|
||||||
|
* the domain's BIMI logo (see avatar-bimi.ts), the favicon.
|
||||||
*/
|
*/
|
||||||
export function avatarContent(options: {
|
export function avatarContent(options: {
|
||||||
senderWarning?: string | null;
|
senderWarning?: string | null;
|
||||||
hasPhoto: boolean;
|
hasPhoto: boolean;
|
||||||
|
showBrand?: boolean;
|
||||||
showFavicon: boolean;
|
showFavicon: boolean;
|
||||||
}): AvatarContent {
|
}): AvatarContent {
|
||||||
if (options.senderWarning) return 'warning';
|
if (options.senderWarning) return 'warning';
|
||||||
if (options.hasPhoto) return 'photo';
|
if (options.hasPhoto) return 'photo';
|
||||||
|
if (options.showBrand) return 'brand';
|
||||||
if (options.showFavicon) return 'favicon';
|
if (options.showFavicon) return 'favicon';
|
||||||
return 'initials';
|
return 'initials';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user