From a201c1617b23241d6df32c87adbdc6a68095cc1b Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:59:53 +0200 Subject: [PATCH] feat: add onAvatarResolve plugin hook --- components/ui/avatar.tsx | 37 ++++++++++++++++++++++++++++++------- lib/plugin-api.ts | 8 +++++++- lib/plugin-hooks.ts | 8 ++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index cdc69d18..61961d5f 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { cn } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; import { useContactStore, getContactPhotoUri } from "@/stores/contact-store"; import { useConfig } from "@/hooks/use-config"; +import { avatarHooks } from "@/lib/plugin-hooks"; const IS_DEV = process.env.NODE_ENV !== "production"; @@ -143,10 +144,26 @@ interface AvatarProps { export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) { const [imgError, setImgError] = useState(false); + const [pluginAvatarUrl, setPluginAvatarUrl] = useState(null); + const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false); const senderFavicons = useSettingsStore((s) => s.senderFavicons); const contacts = useContactStore((s) => s.contacts); const { devMode } = useConfig(); + // Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address. + // Runs whenever email or name changes; resets plugin avatar state on each change. + useEffect(() => { + setPluginAvatarUrl(null); + setPluginAvatarFailed(false); + if (!email || avatarHooks.onAvatarResolve.size === 0) return; + let cancelled = false; + avatarHooks.onAvatarResolve + .transform(null as string | null, { email, name }) + .then((url) => { if (!cancelled) setPluginAvatarUrl(url); }) + .catch(() => { if (!cancelled) setPluginAvatarFailed(true); }); + return () => { cancelled = true; }; + }, [email, name]); + // Look up contact photo by email from the contact store const resolvedContactPhoto = useMemo(() => { if (contactPhotoUri) return contactPhotoUri; @@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }: const showFavicon = senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; - // Priority: contact photo > custom avatar > profile picture > company favicon > initials + // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null; + const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl; const imgSrc = !imgError && !domainFailed - ? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) - : (resolvedContactPhoto || customAvatar || profilePic || null); + ? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) + : (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null); const handleImgError = useCallback(() => { + // If the plugin avatar just failed, mark it and fall through to the next source + if (pluginAvatar && imgSrc === pluginAvatar) { + setPluginAvatarFailed(true); + return; + } setImgError(true); - // If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain - if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) { + // If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain + if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) { failedFaviconDomains.add(faviconDomain); } - }, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); + }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); return (
unknown) => Disposable; onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable; + // Avatar + onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable; } // --- Permission mapping for hooks ---------------------------- @@ -417,6 +419,8 @@ const HOOK_PERMISSIONS: Record = { // Sidebar Apps onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe', onSidebarAppChange: 'ui:observe', + // Avatar + onAvatarResolve: 'email:read', }; // Map hook names → actual HookBus instances @@ -463,6 +467,8 @@ const HOOK_BUSES: Record