feat: add onAvatarResolve plugin hook

This commit is contained in:
Linus Rath
2026-04-12 01:59:53 +02:00
parent d24f402b0c
commit a201c1617b
3 changed files with 45 additions and 8 deletions
+30 -7
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store"; import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store"; import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config"; import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks";
const IS_DEV = process.env.NODE_ENV !== "production"; const IS_DEV = process.env.NODE_ENV !== "production";
@@ -143,10 +144,26 @@ interface AvatarProps {
export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) { export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
const senderFavicons = useSettingsStore((s) => s.senderFavicons); const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const contacts = useContactStore((s) => s.contacts); const contacts = useContactStore((s) => s.contacts);
const { devMode } = useConfig(); 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 // Look up contact photo by email from the contact store
const resolvedContactPhoto = useMemo(() => { const resolvedContactPhoto = useMemo(() => {
if (contactPhotoUri) return contactPhotoUri; if (contactPhotoUri) return contactPhotoUri;
@@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }:
const showFavicon = const showFavicon =
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; 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 customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const imgSrc = !imgError && !domainFailed const imgSrc = !imgError && !domainFailed
? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) ? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null)
: (resolvedContactPhoto || customAvatar || profilePic || null); : (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null);
const handleImgError = useCallback(() => { 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); setImgError(true);
// If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain // If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain
if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) { if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) {
failedFaviconDomains.add(faviconDomain); failedFaviconDomains.add(faviconDomain);
} }
}, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]);
return ( return (
<div <div
+7 -1
View File
@@ -22,7 +22,7 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, sidebarAppHooks, avatarHooks,
} from './plugin-hooks'; } from './plugin-hooks';
import { toast as appToast } from '@/stores/toast-store'; import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
@@ -314,6 +314,8 @@ export interface PluginHooksAPI {
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Avatar
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
} }
// --- Permission mapping for hooks ---------------------------- // --- Permission mapping for hooks ----------------------------
@@ -417,6 +419,8 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
// Sidebar Apps // Sidebar Apps
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe', onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
onSidebarAppChange: 'ui:observe', onSidebarAppChange: 'ui:observe',
// Avatar
onAvatarResolve: 'email:read',
}; };
// Map hook names → actual HookBus instances // Map hook names → actual HookBus instances
@@ -463,6 +467,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(accountSecurityHooks)), ...Object.fromEntries(Object.entries(accountSecurityHooks)),
// Sidebar Apps // Sidebar Apps
...Object.fromEntries(Object.entries(sidebarAppHooks)), ...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar
...Object.fromEntries(Object.entries(avatarHooks)),
}; };
// --- Slot registration bridge -------------------------------- // --- Slot registration bridge --------------------------------
+8
View File
@@ -399,6 +399,13 @@ export const sidebarAppHooks = {
onSidebarAppChange: new HookBus(), onSidebarAppChange: new HookBus(),
}; };
// §7.21 Avatar Hooks
// Transform hook: handlers receive (currentUrl: string | null, context: { email: string; name?: string })
// and return a URL string to use as the avatar, or undefined/null to pass through to the next handler.
export const avatarHooks = {
onAvatarResolve: new HookBus(),
};
// ─── Aggregate: remove all handlers for a plugin across all buses ─── // ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [ const allHookGroups = [
@@ -407,6 +414,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks,
]; ];
export function removeAllPluginHooks(pluginId: string): void { export function removeAllPluginHooks(pluginId: string): void {