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 { useAccountStore, type AccountEntry } from "@/stores/account-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 { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
interface AccountSwitcherProps {
/** "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" }) {
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 (
<div
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
style={{ backgroundColor: account.avatarColor }}
title={account.label}
>
{initials}
</div>
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
className={cn("flex-shrink-0", size === "md" && "w-9 h-9 text-sm")}
disableFavicon
fallbackColor={account.avatarColor}
/>
);
}
+10 -5
View File
@@ -17,11 +17,12 @@ import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useUpdateStore, selectHasUpdate } from "@/stores/update-store";
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 { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
interface NavItem {
id: string;
@@ -610,7 +611,6 @@ export function NavigationRail({
<div className="flex flex-col items-center gap-3">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
return (
<button
key={account.id}
@@ -618,15 +618,20 @@ export function NavigationRail({
if (!isActive) switchAccount(account.id);
}}
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
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
: "opacity-70 hover:opacity-100"
)}
style={{ backgroundColor: account.avatarColor }}
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 && (
<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" />
@@ -2,11 +2,11 @@
import { Loader2, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { getInitials } from "@/lib/account-utils";
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
import type { AccountEntry } from "@/stores/account-store";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
type ProtocolAccountPickerProps = {
accounts: AccountEntry[];
@@ -92,7 +92,6 @@ export function ProtocolAccountPicker({
<div className="max-h-80 overflow-y-auto p-2">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
let host = account.serverUrl;
try {
host = new URL(account.serverUrl).hostname;
@@ -112,12 +111,15 @@ export function ProtocolAccountPicker({
isSwitching && "cursor-wait opacity-70"
)}
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
style={{ backgroundColor: account.avatarColor }}
>
{initials}
</div>
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="md"
className="shrink-0"
disableFavicon
fallbackColor={account.avatarColor}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
+7 -3
View File
@@ -142,9 +142,13 @@ interface AvatarProps {
className?: string;
/** When true, suppress all image sources (favicons, plugin avatars, profile pics, contact photos) and render initials only. */
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 [pluginAvatarUrl, setPluginAvatarUrl] = useState<string | null>(null);
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 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
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],
className
)}
style={{ backgroundColor: imgSrc ? "#ffffff" : getBackgroundColor() }}
style={{ backgroundColor: imgSrc ? "#ffffff" : (fallbackColor ?? getBackgroundColor()) }}
title={name || email}
>
{imgSrc ? (