feat: route account avatars through shared Avatar component #278

This commit is contained in:
Linus Rath
2026-05-13 00:50:46 +02:00
parent b46a1a69e8
commit aab19379e2
4 changed files with 37 additions and 27 deletions
+10 -11
View File
@@ -6,9 +6,10 @@ import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-reac
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store"; import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { getInitials, getMaxAccounts } from "@/lib/account-utils"; import { getMaxAccounts } from "@/lib/account-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation"; import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
interface AccountSwitcherProps { interface AccountSwitcherProps {
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */ /** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
@@ -17,17 +18,15 @@ interface AccountSwitcherProps {
} }
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) { function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
const initials = getInitials(account.displayName || account.label, account.email || account.username);
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
return ( return (
<div <Avatar
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)} name={account.displayName || account.label}
style={{ backgroundColor: account.avatarColor }} email={account.email || account.username}
title={account.label} size="sm"
> className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
{initials} disableFavicon
</div> fallbackColor={account.avatarColor}
/>
); );
} }
+10 -5
View File
@@ -17,11 +17,12 @@ import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getInitials, getMaxAccounts } from "@/lib/account-utils"; import { getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch } from "@/lib/browser-navigation"; import { apiFetch } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
interface NavItem { interface NavItem {
id: string; id: string;
@@ -610,7 +611,6 @@ export function NavigationRail({
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
{accounts.map((account) => { {accounts.map((account) => {
const isActive = account.id === activeAccountId; const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
return ( return (
<button <button
key={account.id} key={account.id}
@@ -618,15 +618,20 @@ export function NavigationRail({
if (!isActive) switchAccount(account.id); if (!isActive) switchAccount(account.id);
}} }}
className={cn( className={cn(
"relative flex items-center justify-center w-8 h-8 rounded-full text-white text-[11px] font-medium transition-all flex-shrink-0", "relative w-8 h-8 rounded-full transition-all flex-shrink-0",
isActive isActive
? "ring-2 ring-primary ring-offset-2 ring-offset-background" ? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100" : "opacity-70 hover:opacity-100"
)} )}
style={{ backgroundColor: account.avatarColor }}
title={`${account.displayName || account.label} (${account.email || account.username})`} title={`${account.displayName || account.label} (${account.email || account.username})`}
> >
{initials} <Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
disableFavicon
fallbackColor={account.avatarColor}
/>
{isActive && ( {isActive && (
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center"> <span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2 h-2 text-primary-foreground" /> <Check className="w-2 h-2 text-primary-foreground" />
@@ -2,11 +2,11 @@
import { Loader2, X } from "lucide-react"; import { Loader2, X } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getInitials } from "@/lib/account-utils";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
import type { AccountEntry } from "@/stores/account-store"; import type { AccountEntry } from "@/stores/account-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
type ProtocolAccountPickerProps = { type ProtocolAccountPickerProps = {
accounts: AccountEntry[]; accounts: AccountEntry[];
@@ -92,7 +92,6 @@ export function ProtocolAccountPicker({
<div className="max-h-80 overflow-y-auto p-2"> <div className="max-h-80 overflow-y-auto p-2">
{accounts.map((account) => { {accounts.map((account) => {
const isActive = account.id === activeAccountId; const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
let host = account.serverUrl; let host = account.serverUrl;
try { try {
host = new URL(account.serverUrl).hostname; host = new URL(account.serverUrl).hostname;
@@ -112,12 +111,15 @@ export function ProtocolAccountPicker({
isSwitching && "cursor-wait opacity-70" isSwitching && "cursor-wait opacity-70"
)} )}
> >
<div <Avatar
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white" name={account.displayName || account.label}
style={{ backgroundColor: account.avatarColor }} email={account.email || account.username}
> size="md"
{initials} className="shrink-0"
</div> disableFavicon
fallbackColor={account.avatarColor}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground"> <span className="truncate text-sm font-medium text-foreground">
+7 -3
View File
@@ -142,9 +142,13 @@ interface AvatarProps {
className?: string; className?: string;
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */ /** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
disableImages?: boolean; disableImages?: boolean;
/** When true, do not fall through to the sender's domain favicon. Use for the user's own account avatar where the mail-provider logo is not meaningful. */
disableFavicon?: boolean;
/** Background color used when no image source resolves. Overrides the hash-based default. */
fallbackColor?: string;
} }
export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false }: AvatarProps) { export function Avatar({ name, email, contactPhotoUri, size = "md", className, disableImages = false, disableFavicon = false, fallbackColor }: AvatarProps) {
const [imgError, setImgError] = useState(false); const [imgError, setImgError] = useState(false);
const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null); const [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false); const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false);
@@ -226,7 +230,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null; const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
const showFavicon = const showFavicon =
senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > plugin avatar (e.g. Gravatar) > 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;
@@ -257,7 +261,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
sizeClasses[size], sizeClasses[size],
className className
)} )}
style={{ backgroundColor: imgSrc ? "#ffffff" : getBackgroundColor() }} style={{ backgroundColor: imgSrc ? "#ffffff" : (fallbackColor ?? getBackgroundColor()) }}
title={name || email} title={name || email}
> >
{imgSrc ? ( {imgSrc ? (