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";
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<string | null>(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 (
<div
+7 -1
View File
@@ -22,7 +22,7 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks,
sidebarAppHooks, avatarHooks,
} from './plugin-hooks';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -314,6 +314,8 @@ export interface PluginHooksAPI {
onSidebarAppOpen: (handler: (...args: unknown[]) => 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<string, Permission> = {
// 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<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(accountSecurityHooks)),
// Sidebar Apps
...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar
...Object.fromEntries(Object.entries(avatarHooks)),
};
// --- Slot registration bridge --------------------------------
+8
View File
@@ -399,6 +399,13 @@ export const sidebarAppHooks = {
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 ───
const allHookGroups = [
@@ -407,6 +414,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks,
];
export function removeAllPluginHooks(pluginId: string): void {