feat: add onAvatarResolve plugin hook
This commit is contained in:
@@ -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
@@ -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 --------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user