feat: route account avatars through shared Avatar component #278
This commit is contained in:
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user