Merge branch 'main' into feature/scheduled-send

# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/layout/sidebar.tsx
#	stores/email-store.ts
#	stores/settings-store.ts
This commit is contained in:
Lucas Gaitzsch
2026-05-22 12:31:06 +02:00
155 changed files with 4702 additions and 869 deletions
+208 -31
View File
@@ -2,19 +2,49 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, Users, Plus, Eraser, Palette } from "lucide-react";
import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette } from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
import type { Calendar } from "@/lib/jmap/types";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useTaskStore } from "@/stores/task-store";
import { useAccountStore } from "@/stores/account-store";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
/**
* Split a per-account calendar list into "owned" (the user's own) and
* "shared" sub-buckets, then group shared by the owning principal so each
* delegator gets its own sub-section.
*/
type AccountCalendarSplit = {
owned: Calendar[];
sharedGroups: { label: string; calendars: Calendar[] }[];
};
function splitAccountCalendars(list: Calendar[]): AccountCalendarSplit {
const owned: Calendar[] = [];
const sharedBuckets = new Map<string, { label: string; calendars: Calendar[] }>();
for (const cal of list) {
if (cal.isShared) {
const key = cal.accountId || cal.accountName || cal.id;
const bucket = sharedBuckets.get(key);
if (bucket) {
bucket.calendars.push(cal);
} else {
sharedBuckets.set(key, { label: cal.accountName || key, calendars: [cal] });
}
} else {
owned.push(cal);
}
}
return { owned, sharedGroups: Array.from(sharedBuckets.values()) };
}
interface CalendarSidebarPanelProps {
calendars: Calendar[];
selectedCalendarIds: string[];
@@ -28,6 +58,12 @@ interface CalendarSidebarPanelProps {
onSubscribe?: () => void;
onEditSubscription?: (subscriptionId: string) => void;
client?: IJMAPClient | null;
/**
* When true, render one collapsible section per connected local account,
* mirroring the mail sidebar's Pro-shell layout. Calendars are bucketed
* by their `localAccountId` and the active account is shown first.
*/
multiAccountMode?: boolean;
}
export function CalendarSidebarPanel({
@@ -43,6 +79,7 @@ export function CalendarSidebarPanel({
onSubscribe,
onEditSubscription,
client,
multiAccountMode,
}: CalendarSidebarPanelProps) {
const t = useTranslations("calendar");
const tSub = useTranslations("calendar.subscription");
@@ -70,6 +107,26 @@ export function CalendarSidebarPanel({
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
// Persisted across mounts so toggle state survives tab switches in the
// Pro shell (same key family as the mail sidebar's account collapse).
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem('calendar-sidebar-collapsed-accounts');
return raw ? new Set(JSON.parse(raw)) : new Set();
} catch { return new Set(); }
});
const toggleAccountGroup = (key: string) => {
setCollapsedAccountGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
try { localStorage.setItem('calendar-sidebar-collapsed-accounts', JSON.stringify(Array.from(next))); } catch { /* */ }
return next;
});
};
const localAccounts = useAccountStore((s) => s.accounts);
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
const personalCalendars = useMemo(() => calendars.filter(c => !c.isShared), [calendars]);
const sharedAccountGroups = useMemo(() => {
const shared = calendars.filter(c => c.isShared);
@@ -84,6 +141,53 @@ export function CalendarSidebarPanel({
return Array.from(groups.values());
}, [calendars]);
/**
* Pro / multi-account grouping: every calendar bucketed by its owning
* local account. Active account comes first, then the rest in their
* account-store order. Calendars without a `localAccountId` (e.g. the
* birthday calendar) fall into a separate "other" bucket so they still
* render.
*/
const localAccountGroups = useMemo(() => {
if (!multiAccountMode) return [];
const byAccount = new Map<string, Calendar[]>();
for (const cal of calendars) {
const key = cal.localAccountId || '__other__';
const list = byAccount.get(key) ?? [];
list.push(cal);
byAccount.set(key, list);
}
const ordered: { key: string; label: string; split: AccountCalendarSplit }[] = [];
// Active account first.
if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) {
const acct = localAccounts.find(a => a.id === activeLocalAccountId);
ordered.push({
key: activeLocalAccountId,
label: acct?.label || acct?.email || acct?.username || activeLocalAccountId,
split: splitAccountCalendars(byAccount.get(activeLocalAccountId)!),
});
byAccount.delete(activeLocalAccountId);
}
// Then the rest in account-store order so the layout matches the mail sidebar.
for (const acct of localAccounts) {
if (!byAccount.has(acct.id)) continue;
ordered.push({
key: acct.id,
label: acct.label || acct.email || acct.username,
split: splitAccountCalendars(byAccount.get(acct.id)!),
});
byAccount.delete(acct.id);
}
// Any leftover buckets (deleted accounts, untagged calendars).
for (const [key, list] of byAccount.entries()) {
const fallbackLabel = key === '__other__'
? t('my_calendars')
: list[0]?.accountName || key;
ordered.push({ key, label: fallbackLabel, split: splitAccountCalendars(list) });
}
return ordered;
}, [multiAccountMode, calendars, localAccounts, activeLocalAccountId, t]);
const getSubscriptionForCalendar = (calendarId: string) => {
return icalSubscriptions.find(s => s.calendarId === calendarId);
};
@@ -268,37 +372,110 @@ export function CalendarSidebarPanel({
)}
</button>
)}
<div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? (
<button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
<div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)}
</div>
{sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.calendars.map(renderCalendarItem)}
{multiAccountMode && localAccountGroups.length > 0 ? (
<>
{localAccountGroups.map((group, idx) => {
const expanded = !collapsedAccountGroups.has(group.key);
const isActive = group.key === activeLocalAccountId;
const { owned, sharedGroups } = group.split;
return (
<div key={group.key} className={cn(idx === 0 ? "" : "mt-3")}>
<button
onClick={() => toggleAccountGroup(group.key)}
className="group w-full flex items-center gap-1.5 px-1 py-1 rounded-sm hover:bg-muted/40 transition-colors"
>
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-semibold text-foreground/90 truncate">
{group.label}
</span>
{isActive && onCreateCalendar && (
<span
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); onCreateCalendar(); }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onCreateCalendar();
}
}}
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={tMgmt('add_calendar')}
>
<Plus className="w-3 h-3" />
</span>
)}
</button>
{expanded && (
<div className="mt-1 pl-3">
{owned.length > 0 && (
<div>
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
{t('my_calendars')}
</div>
<div className="space-y-0.5">
{owned.map(renderCalendarItem)}
</div>
</div>
)}
{sharedGroups.map((sg) => (
<div key={`${group.key}-shared-${sg.label}`} className="mt-2">
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{sg.label}
</div>
<div className="space-y-0.5">
{sg.calendars.map(renderCalendarItem)}
</div>
</div>
))}
</div>
)}
</div>
);
})}
</>
) : (
<>
<div className="flex items-center justify-between mb-2 px-1 group">
{onCreateCalendar ? (
<button
onClick={onCreateCalendar}
className="text-xs font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors flex items-center gap-1.5"
title={tMgmt('add_calendar')}
>
{t('my_calendars')}
<Plus className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
) : (
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('my_calendars')}
</h3>
)}
</div>
</div>
))}
<div className="space-y-0.5">
{personalCalendars.map(renderCalendarItem)}
</div>
{sharedAccountGroups.map((group) => (
<div key={group.accountName} className="mt-4">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1 flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
{group.accountName}
</h3>
<div className="space-y-0.5">
{group.calendars.map(renderCalendarItem)}
</div>
</div>
))}
</>
)}
{renderCalendarMenu()}
</div>
+25 -1
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
@@ -26,6 +26,8 @@ interface CalendarToolbarProps {
selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void;
enableCalendarTasks?: boolean;
/** Show a burger button at the start that opens the (overlay) sidebar. */
onMenuClick?: () => void;
}
export function CalendarToolbar({
@@ -45,6 +47,7 @@ export function CalendarToolbar({
selectedCalendarIds,
onToggleVisibility,
enableCalendarTasks,
onMenuClick,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const formatter = useFormatter();
@@ -115,11 +118,32 @@ export function CalendarToolbar({
return (
<div className={cn("border-b border-border", !isMobile && "flex items-center gap-2 px-4 py-3")}>
{/* Burger menu (rendered in pages that use a narrow overlay sidebar) */}
{onMenuClick && !isMobile && (
<Button
variant="ghost"
size="icon"
onClick={onMenuClick}
className="h-8 w-8 -ml-1 mr-1"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</Button>
)}
{/* ── MOBILE TOOLBAR ── */}
{isMobile && (
<div className="flex flex-col gap-1 px-2 py-2">
{/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1">
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</button>
)}
{onNavigateBack && (
<button
onClick={onNavigateBack}
+22 -5
View File
@@ -6,6 +6,7 @@ import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar } from "@/components/ui/avatar";
import { normalizeContactPhotoUri } from "@/stores/contact-store";
import { cn } from "@/lib/utils";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
@@ -51,6 +52,8 @@ interface ContactFormProps {
addressBooks?: AddressBook[];
allKeywords?: string[];
defaultAddressBookId?: string;
/** Prefills the create form (ignored when `contact` is set). */
prefill?: { email?: string; name?: string };
onSave: (data: Partial<ContactCard>) => Promise<void>;
onCancel: () => void;
}
@@ -144,10 +147,22 @@ function Select({ value, onChange, children, className }: {
);
}
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, onSave, onCancel }: ContactFormProps) {
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel }: ContactFormProps) {
const t = useTranslations("contacts.form");
const isEditing = !!contact;
// Split a free-form display name into given/surname for prefill.
const prefillGivenName = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts[0] || "";
})();
const prefillSurname = (() => {
if (contact || !prefill?.name) return "";
const parts = prefill.name.trim().split(/\s+/);
return parts.slice(1).join(" ");
})();
// Accept JSContact-standard kinds (RFC 9553) and legacy vCard-style aliases.
const findComponent = (...kinds: string[]) =>
contact?.name?.components?.find(c => kinds.includes(c.kind))?.value || "";
@@ -214,9 +229,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
}
const [prefix, setPrefix] = useState(findComponent("title", "prefix"));
const [givenName, setGivenName] = useState(findComponent("given"));
const [givenName, setGivenName] = useState(findComponent("given") || prefillGivenName);
const [additionalName, setAdditionalName] = useState(findComponent("given2", "additional", "middle"));
const [surname, setSurname] = useState(findComponent("surname"));
const [surname, setSurname] = useState(findComponent("surname") || prefillSurname);
const [suffix, setSuffix] = useState(findComponent("generation", "suffix"));
const [nickname, setNickname] = useState(
@@ -230,7 +245,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
}));
}
return [{ address: "", context: "" }];
return [{ address: prefill?.email || "", context: "" }];
});
const [phones, setPhones] = useState<PhoneEntry[]>(() => {
@@ -341,7 +356,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const initialPhotoEntry = useMemo(() => {
if (!contact?.media) return null;
for (const [key, m] of Object.entries(contact.media)) {
if (m.kind === "photo" && m.uri) return { key, uri: m.uri, mediaType: m.mediaType };
if (m.kind === "photo" && m.uri) {
return { key, uri: normalizeContactPhotoUri(m.uri, m.mediaType), mediaType: m.mediaType };
}
}
return null;
}, [contact]);
+14 -1
View File
@@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw } from "lucide-react";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
@@ -112,6 +112,8 @@ interface ContactListProps {
onEditContact: (id: string) => void;
onDeleteContact: (contact: ContactCard) => void;
onAddContactToGroup: (id: string) => void;
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
onMenuClick?: () => void;
}
export function ContactList({
@@ -133,6 +135,7 @@ export function ContactList({
onEditContact,
onDeleteContact,
onAddContactToGroup,
onMenuClick,
}: ContactListProps) {
const t = useTranslations("contacts");
const locale = useLocale();
@@ -267,6 +270,16 @@ export function ContactList({
<div className="border-b border-border bg-background">
<div className="px-3 py-3">
<div className="flex items-center gap-1.5">
{onMenuClick && (
<button
type="button"
onClick={onMenuClick}
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={t("open_categories")}
>
<Menu className="w-4 h-4" />
</button>
)}
<button
type="button"
onClick={() => {
+186 -42
View File
@@ -2,7 +2,7 @@
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { BookUser, User, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2, Settings } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
@@ -10,6 +10,7 @@ import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
import { getContactDisplayName } from "@/stores/contact-store";
import { useAccountStore } from "@/stores/account-store";
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
@@ -32,6 +33,36 @@ interface ContactsSidebarProps {
onDeleteAddressBook?: (addressBook: AddressBook) => void;
onRenameKeyword?: (keyword: string) => void;
className?: string;
/**
* Pro shell: render one collapsible section per connected local account
* (active first), each with "My Address Books" / "Shared from X"
* subsections. Mirrors the calendar sidebar's Pro layout.
*/
multiAccountMode?: boolean;
}
type AddressBookAccountSplit = {
owned: AddressBook[];
sharedGroups: { label: string; books: AddressBook[] }[];
};
function splitAccountBooks(list: AddressBook[]): AddressBookAccountSplit {
const owned: AddressBook[] = [];
const sharedBuckets = new Map<string, { label: string; books: AddressBook[] }>();
for (const book of list) {
if (book.isShared) {
const key = book.accountId || book.accountName || book.id;
const bucket = sharedBuckets.get(key);
if (bucket) {
bucket.books.push(book);
} else {
sharedBuckets.set(key, { label: book.accountName || key, books: [book] });
}
} else {
owned.push(book);
}
}
return { owned, sharedGroups: Array.from(sharedBuckets.values()) };
}
const COLLAPSED_KEY = "contacts-sidebar-collapsed";
@@ -70,6 +101,7 @@ export function ContactsSidebar({
onDeleteAddressBook,
onRenameKeyword,
className,
multiAccountMode,
}: ContactsSidebarProps) {
const t = useTranslations("contacts");
const router = useRouter();
@@ -136,6 +168,47 @@ export function ContactsSidebar({
return Array.from(map.values());
}, [addressBooks]);
// Pro / multi-account grouping: each local account is its own collapsible
// section with owned / shared sub-buckets.
const localAccounts = useAccountStore((s) => s.accounts);
const activeLocalAccountId = useAccountStore((s) => s.activeAccountId);
const localAccountGroups = useMemo(() => {
if (!multiAccountMode) return [];
const byAccount = new Map<string, AddressBook[]>();
for (const book of addressBooks) {
const key = book.localAccountId || '__other__';
const list = byAccount.get(key) ?? [];
list.push(book);
byAccount.set(key, list);
}
const ordered: { key: string; label: string; split: AddressBookAccountSplit }[] = [];
if (activeLocalAccountId && byAccount.has(activeLocalAccountId)) {
const acct = localAccounts.find(a => a.id === activeLocalAccountId);
ordered.push({
key: activeLocalAccountId,
label: acct?.label || acct?.email || acct?.username || activeLocalAccountId,
split: splitAccountBooks(byAccount.get(activeLocalAccountId)!),
});
byAccount.delete(activeLocalAccountId);
}
for (const acct of localAccounts) {
if (!byAccount.has(acct.id)) continue;
ordered.push({
key: acct.id,
label: acct.label || acct.email || acct.username,
split: splitAccountBooks(byAccount.get(acct.id)!),
});
byAccount.delete(acct.id);
}
for (const [key, list] of byAccount.entries()) {
const fallback = key === '__other__'
? t('address_books.title')
: list[0]?.accountName || key;
ordered.push({ key, label: fallback, split: splitAccountBooks(list) });
}
return ordered;
}, [multiAccountMode, addressBooks, localAccounts, activeLocalAccountId, t]);
// Count contacts per address book
const contactCountByBook = useMemo(() => {
const counts: Record<string, number> = {};
@@ -257,47 +330,117 @@ export function ContactsSidebar({
</span>
</button>
{/* My Address Books */}
{personalBooks.length > 0 && (
<div className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
{/* Address Books: per-account groups in multi-account Pro mode, else the
classic "My Address Books" section. */}
{multiAccountMode && localAccountGroups.length > 0 ? (
localAccountGroups.map((group) => {
const sectionKey = `account-${group.key}`;
const expanded = !collapsed[sectionKey];
const { owned, sharedGroups } = group.split;
return (
<div key={group.key} className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(sectionKey)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
>
{expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
<User className="w-3 h-3 text-muted-foreground" />
<span className="text-xs font-semibold text-foreground/90 uppercase tracking-wider truncate">
{group.label}
</span>
</button>
</div>
{expanded && (
<div className="pl-2">
{owned.length > 0 && (
<div className="mt-1">
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
{t("address_books.title")}
</div>
{owned.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
)}
{sharedGroups.map((sg) => (
<div key={`${group.key}-shared-${sg.label}`} className="mt-1">
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider flex items-center gap-1">
<Share2 className="w-3 h-3" />
{sg.label}
</div>
{sg.books.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
))}
</div>
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
);
})
) : (
personalBooks.length > 0 && (
<div className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("address_books.title")}
</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
router.push('/settings');
}}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
title={t("address_books.manage")}
>
<Settings className="w-3 h-3 text-muted-foreground" />
</button>
</div>
{!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
{!collapsed.addressBooks && personalBooks.map((book) => (
<AddressBookItem
key={book.id}
book={book}
isActive={typeof activeCategory === "object" && "addressBookId" in activeCategory && activeCategory.addressBookId === book.id}
contactCount={contactCountByBook[book.id] || 0}
onSelect={() => onSelectCategory({ addressBookId: book.id })}
onDropContacts={onDropContacts}
onContextMenu={(onRenameAddressBook || onShareAddressBook || onCreateContactInBook || onDeleteAddressBook) ? (e) => openBookContextMenu(e, book) : undefined}
/>
))}
</div>
)
)}
{/* Groups section */}
@@ -398,8 +541,9 @@ export function ContactsSidebar({
)}
</div>
{/* Shared accounts with address books */}
{sharedBookGroups.map((group) => (
{/* Shared accounts with address books - only when not already split
into per-account groups above (multi-account Pro mode). */}
{!multiAccountMode && sharedBookGroups.map((group) => (
<div key={group.accountId} className="mt-2">
<div className="flex items-center px-3 py-1 group">
<button
@@ -389,7 +389,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionError(null);
try {
// JMAP strips parameters from Content-Type (RFC 8621), so method=REQUEST
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback in
// is lost. Fetch raw ICS to extract METHOD as a reliable fallback - in
// parallel with parsing to save a roundtrip.
const [events, rawText] = await Promise.all([
client.parseCalendarEvents(client.getCalendarsAccountId(), attachment.blobId),
@@ -420,7 +420,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setState('parsed');
// Hydrate the calendar store with the matching event in the background
// Hydrate the calendar store with the matching event in the background -
// only needed for the "already in calendar" pill, must not block the banner.
// Filter by UID server-side; the previous unfiltered query fetched up to
// 1000 events plus multiple /get batches just to find one match.
+160 -18
View File
@@ -14,6 +14,7 @@ import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store";
import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-store";
@@ -34,6 +35,10 @@ import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
replaceInlineImagePlaceholders,
} from "@/lib/email-composer-utils";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
@@ -75,6 +80,11 @@ interface EmailComposerProps {
fromName?: string;
identityId?: string;
envelopeMailFrom?: string;
/** Local account ID owning the selected identity. Set when the user
* picked an identity from a non-active account in the Pro multi-
* account dropdown; parents should send through that account's
* client instead of the currently-active one. */
localAccountId?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[];
references?: string[];
@@ -195,8 +205,18 @@ export function EmailComposer({
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const signaturePosition = useSettingsStore((state) => state.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled);
const identities = useIdentityStore((s) => s.identities);
const primaryIdentity = identities[0] ?? null;
const activeIdentities = useIdentityStore((s) => s.identities);
// Pro shell: surface identities from every connected account, grouped
// for the From dropdown's <optgroup>s. Outside Pro this collapses to
// the active account's identities only.
const multiAccountIdentities = useProMultiAccountIdentities();
const identities = multiAccountIdentities.enabled
? multiAccountIdentities.allIdentities
: activeIdentities;
const identityGroups = multiAccountIdentities.enabled
? multiAccountIdentities.groups
: [];
const primaryIdentity = activeIdentities[0] ?? null;
// The signature identity used when embedding the signature into the initial
// body for "above quote" mode. Mirrors the signatureIdentity derivation
@@ -300,7 +320,8 @@ export function EmailComposer({
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
const wrap = replyTo.quoteWrapInBlockquote !== false;
const originalHtml = replyTo.htmlBody
?? (replyTo.body
? rewriteCidImagesForEditor(replyTo.htmlBody)
: (replyTo.body
? replyTo.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')
: '');
const bodyHtml = wrap
@@ -314,7 +335,10 @@ export function EmailComposer({
const quoteHeader = mode === 'forward'
? `---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${replyTo.subject || ''}<br><br>`
: `On ${date}, ${fromStr} wrote:<br>`;
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${replyTo.htmlBody}</blockquote>`;
// cid: image refs are rewritten so they render in the editor (browsers
// can't fetch cid: URLs); see useEffect below for the data-URL backfill.
const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody);
return `${prefix}${signatureBlock}<br><div>${quoteHeader}</div><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${quotedHtml}</blockquote>`;
}
if (replyTo.body) {
@@ -410,6 +434,19 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity;
// When the selected identity belongs to a non-active account (Pro
// multi-account dropdown), `currentIdentity.id` carries a "<localId>::"
// namespace and JMAP calls must be routed through that account's
// client with the un-prefixed id. `composerClient` and
// `currentIdentityRawId` are what save/send code should use.
const currentIdentityParts = currentIdentity?.id
? stripCrossAccountIdentityPrefix(currentIdentity.id)
: { localAccountId: null, rawId: undefined };
const composerClient = currentIdentityParts.localAccountId
? (useAuthStore.getState().getClientForAccount(currentIdentityParts.localAccountId) ?? client)
: client;
const currentIdentityRawId = currentIdentityParts.rawId ?? currentIdentity?.id;
// Alias identities often lack a configured signature - fall back to the primary
// identity's signature so replies (which auto-select a matching alias) still
// populate the user's signature.
@@ -543,6 +580,76 @@ export function EmailComposer({
selectedIdentityId,
]);
// Hydrate inline images referenced by the quoted body (issue #163).
// `getInitialBody` rewrites `<img src="cid:xxx">` to placeholder src +
// data-cid; here we (1) register each inline attachment in inlineImagesRef
// so the send path re-attaches the blob with the right cid, and (2) fetch
// each blob as a data URL and swap it into the body so the editor actually
// shows the image instead of a blank placeholder.
useEffect(() => {
if (plainTextMode) return;
if (mode !== 'reply' && mode !== 'replyAll' && mode !== 'forward') return;
if (!composerClient || !replyTo?.attachments?.length) return;
const inlineAtts = replyTo.attachments.filter((att) =>
att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')
);
if (inlineAtts.length === 0) return;
// Seed the ref synchronously so a fast Send still attaches the right blobs
// even if the FileReader work below hasn't resolved yet.
for (const att of inlineAtts) {
if (!att.cid) continue;
if (inlineImagesRef.current.some((e) => e.cid === att.cid)) continue;
inlineImagesRef.current.push({
cid: att.cid,
blobId: att.blobId,
type: att.type,
name: att.name || 'inline',
size: att.size,
dataUrl: '',
});
}
let cancelled = false;
(async () => {
const updates = new Map<string, string>();
for (const att of inlineAtts) {
if (!att.cid) continue;
try {
const buffer = await composerClient.fetchBlobArrayBuffer(
att.blobId,
att.name || 'inline',
att.type,
);
if (cancelled) return;
const blob = new Blob([buffer], { type: att.type });
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
if (cancelled) return;
const entry = inlineImagesRef.current.find((e) => e.cid === att.cid);
if (entry) entry.dataUrl = dataUrl;
updates.set(att.cid, dataUrl);
} catch (err) {
debug.error('Failed to load inline image for compose', err);
}
}
if (cancelled || updates.size === 0) return;
setBody((prev) => replaceInlineImagePlaceholders(prev, updates));
})();
return () => {
cancelled = true;
};
// We deliberately hydrate once per composer open - subsequent replyTo
// object identity churn from parent renders shouldn't refetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [composerClient, plainTextMode, mode]);
const composerSignatureHtml = signatureIdentity?.htmlSignature
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
: signatureIdentity?.textSignature
@@ -944,7 +1051,7 @@ export function EmailComposer({
// Auto-save draft functionality
const saveDraftOnce = async (): Promise<string | null> => {
if (!client) return null;
if (!client || !composerClient) return null;
const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean);
const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
@@ -990,13 +1097,16 @@ export function EmailComposer({
try {
const previousDraftId = draftIdRef.current;
const savedDraftId = await client.createDraft(
// Use the JMAP client and raw identity id for the *owning* account
// - falls back to active client for single-account / same-account
// identities. See `composerClient` derivation above.
const savedDraftId = await composerClient.createDraft(
toAddresses,
subject || t('no_subject'),
plainTextMode ? body : htmlToPlainText(body),
ccAddresses,
bccAddresses,
currentIdentity?.id,
currentIdentityRawId,
fromEmail,
previousDraftId || undefined,
uploadedAttachments,
@@ -1321,6 +1431,13 @@ export function EmailComposer({
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
// S/MIME keys are scoped to one JMAP account's identity - sending
// from a cross-account identity via S/MIME would mix accounts'
// certs/clients. Refuse upfront and tell the user to switch.
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id);
if (crossAccount.localAccountId) {
throw new Error('S/MIME sending from another accounts identity is not supported. Switch to that account first.');
}
// 1. Resolve S/MIME key
if (smimeSign_ && !smimeKeyRecord) {
throw new Error('No S/MIME key bound to this identity');
@@ -1475,6 +1592,15 @@ export function EmailComposer({
};
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
// Strip the cross-account namespace from the identity id before
// handing it to the parent - the JMAP server only knows the raw
// id. The owning local account travels alongside so the parent
// can route the send through the right client.
const rawIdentityId = outgoing.identityId || currentIdentity?.id;
const { localAccountId: identityLocalAccountId, rawId } = rawIdentityId
? stripCrossAccountIdentityPrefix(rawIdentityId)
: { localAccountId: null, rawId: undefined };
await onSend?.({
to: outgoing.to,
cc: outgoing.cc,
@@ -1485,8 +1611,9 @@ export function EmailComposer({
draftId: finalDraftId || undefined,
fromEmail,
fromName,
identityId: outgoing.identityId || currentIdentity?.id,
identityId: rawId,
envelopeMailFrom,
localAccountId: identityLocalAccountId ?? undefined,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references,
@@ -1716,16 +1843,31 @@ export function EmailComposer({
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
>
{identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
{identityGroups.length > 0
? identityGroups.map((group) => (
<optgroup key={group.localAccountId} label={group.accountLabel}>
{group.identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</optgroup>
))
: identities.map((identity) => {
const displayEmail = subAddressTag
? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter)
: identity.email;
return (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${displayEmail}>` : displayEmail}
</option>
);
})}
</select>
) : (
<span className="text-sm text-foreground flex-1 truncate">
+44 -14
View File
@@ -66,6 +66,7 @@ import {
CalendarClock,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
@@ -79,6 +80,7 @@ import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { useTour } from "@/components/tour/tour-provider";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
@@ -954,6 +956,7 @@ export function EmailViewer({
}, [client, t, tComposer]);
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const isEmbedded = useIsEmbedded();
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
@@ -1170,9 +1173,34 @@ export function EmailViewer({
const [contactSidebarEmail, setContactSidebarEmail] = useState<string | null>(null);
const contacts = useContactStore((s) => s.contacts);
const { isMobile: isMobileDevice } = useDeviceDetection();
const router = useRouter();
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
if (isMobileDevice) return; // no sidebar on mobile
if (isMobileDevice) {
// No room for a sidebar on mobile - send the user to the contacts page
// with params describing what to show. The `from=email` flag turns the
// page's mobile back button into a router.back() that returns here.
const allRecipients = [
...(email?.from || []),
...(email?.to || []),
...(email?.cc || []),
...(email?.bcc || []),
...(email?.replyTo || []),
];
const recipientName = allRecipients.find(
(r) => r.email.toLowerCase() === recipientEmail.toLowerCase()
)?.name;
const params = new URLSearchParams();
if (contact) {
params.set('contactId', contact.id);
} else {
params.set('addEmail', recipientEmail);
if (recipientName) params.set('addName', recipientName);
}
params.set('from', 'email');
router.push(`/contacts?${params.toString()}`);
return;
}
setContactSidebarEmail(recipientEmail);
};
@@ -2903,7 +2931,7 @@ export function EmailViewer({
// window between selectedEmail changing and isLoading flipping true, so the
// quick reply / body don't flicker through a partial render.
// An empty bodyValues with no referenced parts means the email has no body
// (e.g. calendar-only invites) not "still loading".
// (e.g. calendar-only invites) - not "still loading".
const hasBodyParts = (email?.textBody?.length ?? 0) > 0 || (email?.htmlBody?.length ?? 0) > 0;
const isBodyLoading = isLoading || (hasBodyParts && (!email?.bodyValues || Object.keys(email.bodyValues).length === 0));
@@ -3260,19 +3288,21 @@ export function EmailViewer({
}
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
{!isEmbedded && (
<div className="text-center p-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-background shadow-lg flex items-center justify-center">
<Mail className="w-10 h-10 text-muted-foreground" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 mr-2" />
{t('compose')}
</Button>
)}
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('no_conversation_selected')}</h3>
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 mr-2" />
{t('compose')}
</Button>
)}
</div>
)}
</div>
);
}
+11 -1
View File
@@ -115,7 +115,17 @@ export const ResizableImage = Node.create({
width: { default: null },
cid: {
default: null,
parseHTML: (el) => el.getAttribute("data-cid"),
parseHTML: (el) => {
const dataCid = el.getAttribute("data-cid");
if (dataCid) return dataCid;
// Fall back to deriving the cid from `src="cid:xxx"` so inline
// image refs survive editor round-trips even when data-cid was
// never set (defensive — the composer normally pre-rewrites
// quoted-body cid: refs into data-cid).
const src = el.getAttribute("src") || "";
if (/^cid:/i.test(src)) return src.slice(4) || null;
return null;
},
renderHTML: (attrs) => (attrs.cid ? { "data-cid": attrs.cid } : {}),
},
};
+216 -89
View File
@@ -11,7 +11,9 @@ import {
AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu,
} from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import { cn, formatFileSize } from "@/lib/utils";
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
@@ -21,6 +23,7 @@ import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Avatar } from "@/components/ui/avatar";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import type { FileResource } from "@/stores/file-store";
@@ -35,6 +38,13 @@ interface ClipboardState {
sourceParentId: string | null;
}
export interface AccountFolderEntry {
accountId: string;
label: string;
email: string;
avatarColor: string;
}
interface FileBrowserProps {
currentPath: string;
resources: FileResource[];
@@ -78,6 +88,13 @@ interface FileBrowserProps {
showDetails: boolean;
onToggleDetails: () => void;
detailResource: FileResource | null;
/** Pro shell only: all connected accounts surfaced as top-level folders at the root. */
accountFolders?: AccountFolderEntry[];
onSelectAccount?: (accountId: string) => void;
/** Pro shell only: when true, the root is a pure account picker - hide the file toolbar and don't render a regular listing. */
accountPickerMode?: boolean;
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
accountLabel?: string | null;
}
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
@@ -321,6 +338,10 @@ export function FileBrowser({
onToggleDetails,
detailResource,
clipboard,
accountFolders,
onSelectAccount,
accountPickerMode,
accountLabel,
}: FileBrowserProps) {
const t = useTranslations("files");
const [showNewFolder, setShowNewFolder] = useState(false);
@@ -352,6 +373,13 @@ export function FileBrowser({
const [isResizing, setIsResizing] = useState(false);
const dragStartWidth = useRef(256);
const [dragTarget, setDragTarget] = useState<string | null>(null);
// Pane-aware: in a Pro split pane (or a narrow window) the folder tree
// sidebar collapses into a burger-toggled overlay so it doesn't crowd the
// file list.
const isDesktopPane = useIsDesktop();
const isNarrow = !isDesktopPane;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// Sync showThumbnails and folderLayout when settings change
useEffect(() => {
@@ -442,8 +470,10 @@ export function FileBrowser({
return sorted;
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
// Build breadcrumb segments
const breadcrumbs = currentPath === '/'
// Build breadcrumb segments. In Pro mode an account is mounted "between"
// Home and the account's filesystem - surfaced as a non-clickable label
// (clicking the actual account again would be a no-op; Home detaches it).
const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = currentPath === '/'
? [{ name: t("breadcrumb_root"), path: '/' }]
: [
{ name: t("breadcrumb_root"), path: '/' },
@@ -452,14 +482,24 @@ export function FileBrowser({
path: '/' + arr.slice(0, i + 1).join('/'),
})),
];
if (accountLabel) {
breadcrumbs.splice(1, 0, { name: accountLabel, path: '', isAccount: true });
}
const handleNavigateUp = useCallback(() => {
if (currentPath === '/') return;
const segments = currentPath.split('/').filter(Boolean);
segments.pop();
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
onNavigate(parentPath, null);
}, [currentPath, onNavigate]);
// Pro shell: going up to root from a subfolder must land on the
// account's filesystem root, not detach back to the account picker.
// Home click (breadcrumb) still detaches.
if (parentPath === '/' && accountLabel) {
onNavigate('/', '__account_root__');
return;
}
onNavigate(parentPath);
}, [currentPath, onNavigate, accountLabel]);
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
if (resource.isDirectory) {
@@ -846,14 +886,27 @@ export function FileBrowser({
>
{/* Toolbar */}
<div role="toolbar" aria-label={t("toolbar")} className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background">
{isNarrow && folderLayout === "sidebar" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -ml-2"
onClick={() => setNarrowSidebarOpen((v) => !v)}
aria-label={t("open_folder_tree")}
>
<Menu className="w-4 h-4" />
</Button>
)}
{/* Breadcrumbs */}
<nav aria-label={t("breadcrumb_root")} className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto">
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-1 shrink-0">
<span key={`${i}:${crumb.path}`} className="flex items-center gap-1 shrink-0">
{i > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />}
<button
onClick={() => onNavigate(crumb.path)}
onContextMenu={(e) => handleBreadcrumbRightClick(e, crumb.path)}
onClick={() => crumb.isAccount
? onNavigate('/', '__account_root__')
: onNavigate(crumb.path)}
onContextMenu={(e) => crumb.isAccount ? undefined : handleBreadcrumbRightClick(e, crumb.path)}
className={cn(
"px-1.5 py-0.5 rounded hover:bg-muted transition-colors",
i === breadcrumbs.length - 1
@@ -902,15 +955,17 @@ export function FileBrowser({
{t("paste")} ({clipboard.names.length})
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowSearch(v => !v)}
title={t("search_placeholder")}
>
<Search className="w-4 h-4" />
</Button>
{!accountPickerMode && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowSearch(v => !v)}
title={t("search_placeholder")}
>
<Search className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -920,62 +975,66 @@ export function FileBrowser({
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <LayoutList className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", showDetails && "bg-muted")}
onClick={onToggleDetails}
title={t("details")}
>
<Info className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
onClick={() => onToggleFavorite(currentPath)}
title={t("toggle_favorite")}
>
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => fileInputRef.current?.click()}
title={t("upload")}
disabled={isUploading}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => folderInputRef.current?.click()}
title={t("upload_folder")}
disabled={isUploading}
>
<FolderUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowNewFolder(true)}
title={t("new_folder")}
>
<FolderPlus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowNewTextFile(true)}
title={t("new_text_file")}
>
<FilePlus className="w-4 h-4" />
</Button>
{!accountPickerMode && (
<>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", showDetails && "bg-muted")}
onClick={onToggleDetails}
title={t("details")}
>
<Info className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
onClick={() => onToggleFavorite(currentPath)}
title={t("toggle_favorite")}
>
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => fileInputRef.current?.click()}
title={t("upload")}
disabled={isUploading}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => folderInputRef.current?.click()}
title={t("upload_folder")}
disabled={isUploading}
>
<FolderUp className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowNewFolder(true)}
title={t("new_folder")}
>
<FolderPlus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setShowNewTextFile(true)}
title={t("new_text_file")}
>
<FilePlus className="w-4 h-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
@@ -1100,26 +1159,59 @@ export function FileBrowser({
{/* File list */}
<div className="flex-1 min-h-0 flex relative">
{/* Narrow-pane backdrop for the overlay folder tree */}
{folderLayout === "sidebar" && isNarrow && narrowSidebarOpen && (
<div
className="absolute inset-0 bg-black/50 z-40"
onClick={() => setNarrowSidebarOpen(false)}
/>
)}
{/* Folder tree sidebar (when layout is sidebar) */}
{folderLayout === "sidebar" && (
<>
<FolderTreeSidebar
currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={sidebarWidth}
isResizing={isResizing}
/>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
isNarrow ? (
<div
className={cn(
"absolute inset-y-0 left-0 z-50",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
)}
onClick={(e) => {
// Auto-close when the user taps a folder name. Chevrons stay
// open so they can expand/collapse without dismissing.
const target = e.target as HTMLElement;
const btn = target.closest('button');
if (btn && !btn.querySelector('svg.lucide-chevron-right, svg.lucide-chevron-down')) {
setNarrowSidebarOpen(false);
}
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }}
/>
</>
>
<FolderTreeSidebar
currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={288}
/>
</div>
) : (
<>
<FolderTreeSidebar
currentPath={currentPath}
onNavigate={onNavigate}
listByParentId={listByParentId}
width={sidebarWidth}
isResizing={isResizing}
/>
<ResizeHandle
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
onResizeEnd={() => {
setIsResizing(false);
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
}}
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }}
/>
</>
)
)}
{/* Favorites & Recent sidebar (when layout is inline) */}
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
@@ -1198,6 +1290,41 @@ export function FileBrowser({
<SkeletonRow />
</tbody>
</table>
) : accountPickerMode && accountFolders && accountFolders.length > 0 && onSelectAccount ? (
/* ======= ACCOUNT PICKER (Pro mode root) ======= */
<div className="p-4">
<div
className="grid gap-3"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(11rem, 1fr))' }}
>
{accountFolders.map((acc) => (
<button
key={`__account__:${acc.accountId}`}
onClick={() => onSelectAccount(acc.accountId)}
title={acc.email}
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-left min-w-0"
>
<Avatar
name={acc.label}
email={acc.email}
size="md"
fallbackColor={acc.avatarColor}
className="shrink-0"
/>
<div className="min-w-0 flex flex-col">
<span className="truncate text-sm font-medium">{acc.label || acc.email}</span>
{acc.label && acc.label !== acc.email && (
<span className="truncate text-xs text-muted-foreground">{acc.email}</span>
)}
</div>
</button>
))}
</div>
</div>
) : accountPickerMode ? (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">{t("no_accounts")}</p>
</div>
) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
<FileUploadArea
onUpload={async (files: File[]) => {
+1 -1
View File
@@ -130,7 +130,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
return (
<div
className={cn(
"border-r border-border bg-secondary overflow-hidden shrink-0 hidden lg:flex flex-col",
"border-r border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${width}px` }}
+4 -1
View File
@@ -78,7 +78,10 @@ export function FilterRuleModal({
const pathMap = new Map<string, string>();
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
for (const node of nodes) {
const fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
// Sieve fileinto expects the IMAP-canonical "INBOX" for the inbox,
// not the localized JMAP display name (e.g. "Entrada" in pt-BR).
const segment = node.role === "inbox" ? "INBOX" : node.name;
const fullPath = parentPath ? `${parentPath}/${segment}` : segment;
pathMap.set(node.id, fullPath);
if (node.children.length > 0) buildPaths(node.children, fullPath);
}
+1 -1
View File
@@ -47,7 +47,7 @@ interface NavigationRailProps {
activeAppId?: string | null;
/**
* If provided, intercepts the rail's built-in route navigation. Return
* `true` to prevent the underlying `<Link>` from navigating used by the
* `true` to prevent the underlying `<Link>` from navigating - used by the
* Pro interface to open the route as a tab instead. The visual rail is
* unchanged.
*/
+203 -76
View File
@@ -52,6 +52,7 @@ import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { AccountSwitcher } from "./account-switcher";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useTour } from "@/components/tour/tour-provider";
interface SidebarProps {
@@ -76,6 +77,20 @@ interface SidebarProps {
scheduledTotal?: number;
showScheduledMailbox?: boolean;
className?: string;
/**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
* sidebar renders a per-connected-account group instead of a single
* folders section - Thunderbird-style. `accountMailboxes` provides the
* mailbox list for non-active accounts (the active account still flows
* through the `mailboxes` prop). `viewingAccountId` highlights which
* account's folder is currently selected (null = active account).
* `onAccountMailboxSelect` fires with the owning accountId when the user
* picks a folder; callers translate that into `selectAccountMailbox`.
*/
multiAccountMode?: boolean;
accountMailboxes?: Record<string, Mailbox[]>;
viewingAccountId?: string | null;
onAccountMailboxSelect?: (accountId: string | null, mailboxId: string) => void;
}
const ROW_PX_BASE = 8;
@@ -664,10 +679,14 @@ export function Sidebar({
scheduledTotal = 0,
showScheduledMailbox = false,
className,
multiAccountMode = false,
accountMailboxes,
viewingAccountId = null,
onAccountMailboxSelect,
}: SidebarProps) {
const router = useRouter();
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const { primaryIdentity: _primaryIdentity } = useAuthStore();
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [foldersExpanded, setFoldersExpanded] = useState(() => {
try {
@@ -699,14 +718,33 @@ export function Sidebar({
return stored !== null ? new Set(JSON.parse(stored) as string[]) : new Set();
} catch { return new Set(); }
});
// Per-connected-account collapse state for Pro / Thunderbird-style mode.
// Stored as the set of accountIds the user has explicitly collapsed -
// anything not in the set is treated as expanded. Inverting the storage
// model lets new accounts default to expanded automatically.
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem('sidebarCollapsedAccountGroups');
if (stored !== null) return new Set(JSON.parse(stored) as string[]);
} catch { /* fall through */ }
return new Set();
});
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher);
const isEmbedded = useIsEmbedded();
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
// own AccountSwitcher would be a redundant second account UI in the same
// pane.
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
const tagCounts = useEmailStore(s => s.tagCounts);
const accounts = useAccountStore(s => s.accounts);
const connectedAccounts = accounts.filter(a => a.isConnected);
const showUnified = enableUnifiedMailbox && connectedAccounts.length > 1;
// Pro shell treats the unified mailbox as a core part of the multi-account
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
// 2+ account requirement still applies - with a single account the
// unified counts would just duplicate that account's inbox.
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
const { unifiedCounts } = useEmailStore();
const t = useTranslations('sidebar');
@@ -754,6 +792,24 @@ export function Sidebar({
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-'));
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
// Multi-account mode (Pro shell): render every connected account as its
// own collapsible group. The active account's tree comes from the
// `mailboxes` prop (which is the live email-store value); other accounts
// come from the per-account cache populated by useProMultiAccountMailboxes.
const useMultiAccount = multiAccountMode && connectedAccounts.length > 1;
const accountGroups = useMultiAccount
? connectedAccounts.map((account) => {
const isActive = account.id === activeAccountId;
const accountMailboxList = isActive
? mailboxes
: (accountMailboxes?.[account.id] ?? []);
const tree = buildMailboxTree(accountMailboxList).filter(
(n) => !n.id.startsWith('shared-account-')
);
return { account, isActive, tree };
})
: [];
const getUnifiedIcon = (role: UnifiedMailboxRole) => {
switch (role) {
case 'inbox': return Inbox;
@@ -833,6 +889,14 @@ export function Sidebar({
return next;
});
};
const toggleAccountGroup = (id: string) => {
setCollapsedAccountGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
try { localStorage.setItem('sidebarCollapsedAccountGroups', JSON.stringify(Array.from(next))); } catch { /* */ }
return next;
});
};
const openFolderSettings = () => {
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
@@ -870,32 +934,35 @@ export function Sidebar({
className
)}
>
{/* Header */}
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
<Button
variant="ghost"
size="icon"
onClick={onSidebarClose}
className="lg:hidden h-9 w-9 flex-shrink-0"
aria-label={t("close")}
>
<X className="w-5 h-5" />
</Button>
{/* Header - hidden in the Pro shell, which owns its own chrome and
would otherwise render an empty strip (no collapse, no switcher). */}
{!isEmbedded && (
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-2" : "gap-1 px-2 py-2")}>
<Button
variant="ghost"
size="icon"
onClick={onSidebarClose}
className="lg:hidden h-9 w-9 flex-shrink-0"
aria-label={t("close")}
>
<X className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={toggleSidebarCollapsed}
className="hidden lg:flex h-8 w-8 flex-shrink-0"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={toggleSidebarCollapsed}
className="hidden lg:flex h-8 w-8 flex-shrink-0"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button>
{!isCollapsed && !hideAccountSwitcher && (
<AccountSwitcher variant="expanded" className="flex-1" />
)}
</div>
{!isCollapsed && !hideAccountSwitcher && (
<AccountSwitcher variant="expanded" className="flex-1" />
)}
</div>
)}
{!isCollapsed && <DemoBanner />}
{!isCollapsed && <VacationBanner />}
@@ -936,56 +1003,116 @@ export function Sidebar({
</div>
)}
<div onContextMenu={handleFoldersHeaderContextMenu}>
<SidebarSectionHeader
label={t("folders")}
expanded={foldersExpanded}
onToggle={toggleFolders}
onSettings={openFolderSettings}
settingsTitle={t('settings')}
isCollapsed={isCollapsed}
first={!showUnified}
/>
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
<>
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
<>
{ownTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))}
{showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</>
)}
</div>
{useMultiAccount ? (
accountGroups.map(({ account, isActive, tree }) => {
const expanded = !collapsedAccountGroups.has(account.id);
const isViewing = isActive ? viewingAccountId === null : viewingAccountId === account.id;
return (
<div key={account.id} onContextMenu={isActive ? handleFoldersHeaderContextMenu : undefined}>
<SidebarSectionHeader
label={account.label || account.email || account.username}
expanded={expanded}
onToggle={() => toggleAccountGroup(account.id)}
onSettings={isActive ? openFolderSettings : undefined}
settingsTitle={isActive ? t('settings') : undefined}
isCollapsed={isCollapsed}
first={!showUnified && account.id === connectedAccounts[0]?.id}
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
/>
{((expanded && !isCollapsed) || isCollapsed) && (
<>
{tree.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
<>
{tree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={(mailboxId) =>
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
colorful={colorfulSidebarIcons}
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
/>
))}
{isActive && showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</>
)}
</div>
);
})
) : (
<div onContextMenu={handleFoldersHeaderContextMenu}>
<SidebarSectionHeader
label={t("folders")}
expanded={foldersExpanded}
onToggle={toggleFolders}
onSettings={openFolderSettings}
settingsTitle={t('settings')}
isCollapsed={isCollapsed}
first={!showUnified}
/>
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
<>
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
{!isCollapsed && t("loading_mailboxes")}
</div>
) : (
<>
{ownTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))}
{showScheduledMailbox && (
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
/>
)}
</>
)}
</>
)}
</div>
)}
{sharedAccounts.length > 0 && (
{!useMultiAccount && sharedAccounts.length > 0 && (
<div>
<SidebarSectionHeader
label={t("shared")}
+2 -2
View File
@@ -1,6 +1,6 @@
'use client';
// Sandboxed slot mount. One iframe per (plugin, slot) created lazily after
// Sandboxed slot mount. One iframe per (plugin, slot) - created lazily after
// the background instance confirms `shouldShow(context)` (if defined). The
// iframe renders the plugin's slot component using the plugin's bundle in a
// null-origin context; its height is pushed back via postMessage and applied
@@ -59,7 +59,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
try { inst.destroy(); } catch { /* ignore */ }
instanceRef.current = null;
};
// We intentionally don't depend on extraProps here propagating prop
// We intentionally don't depend on extraProps here - propagating prop
// changes happens via postMessage below to avoid iframe churn.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [show, pluginId, slot]);
+1 -1
View File
@@ -18,7 +18,7 @@ interface ProComposeTabBodyProps {
/**
* Renders a standalone `<EmailComposer />` inside its own Pro tab. Sending,
* draft autosave, and discard all flow through the shared `email-store`, so
* the result is identical to composing inline in the mail page the
* the result is identical to composing inline in the mail page - the
* composer is just hosted in its own tab instead of in the right pane.
*/
export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
+2 -2
View File
@@ -39,7 +39,7 @@ function buildReplyContext(email: Email): ProReplyContext {
/**
* Renders a single email in its own Pro tab. Fetches the email content on
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient
* mount via `email-store.fetchEmailContent` so the tab is self-sufficient -
* it doesn't depend on what the Mail tab has selected.
*/
export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
@@ -160,7 +160,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
if (!client || !email) return;
try {
await toggleStar(client, email.id);
// Reflect locally the viewer re-reads from email-store's selectedEmail
// Reflect locally - the viewer re-reads from email-store's selectedEmail
// shape only for the mail tab; here we update our local copy too.
setEmail((prev) => prev ? {
...prev,
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "@/i18n/navigation";
import { useSettingsStore } from "@/stores/settings-store";
import { useIsDesktop } from "@/hooks/use-media-query";
import { useProTabStore, type ProTabKind } from "@/stores/pro-tab-store";
const STANDARD_PATH_TO_TAB: Record<string, Exclude<ProTabKind, 'compose' | 'email'>> = {
'/': 'mail',
'/calendar': 'calendar',
'/contacts': 'contacts',
'/files': 'files',
'/settings': 'settings',
};
/**
* When the Pro interface is enabled, the standard mail/calendar/contacts/
* files/settings routes are taken over by the Pro shell - the user shouldn't
* have to click "Open" in settings to land there. Mobile/tablet keeps the
* standard layout because Pro is desktop-only (see pro/page.tsx).
*/
export function ProInterfaceRedirect() {
const router = useRouter();
const pathname = usePathname();
const proInterface = useSettingsStore((s) => s.proInterface);
const isDesktop = useIsDesktop();
useEffect(() => {
if (!proInterface || !isDesktop) return;
const tabKind = STANDARD_PATH_TO_TAB[pathname];
if (!tabKind) return;
useProTabStore.getState().openTab(tabKind);
router.replace('/pro');
}, [proInterface, isDesktop, pathname, router]);
return null;
}
@@ -12,7 +12,7 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode
useEffect(() => {
if (!embeddedMode || !isEmbedded()) return;
// Refuse to attach the listener without a pinned parent origin
// Refuse to attach the listener without a pinned parent origin -
// otherwise any cross-origin frame could forge sso:trigger-logout.
if (!parentOrigin) {
console.error(
+4 -2
View File
@@ -51,7 +51,7 @@ interface IntlProviderProps {
export function IntlProvider({ locale: initialLocale, children }: IntlProviderProps) {
const currentLocale = useLocaleStore((state) => state.locale);
const setLocale = useLocaleStore((state) => state.setLocale);
const [activeLocale, setActiveLocale] = useState(currentLocale || initialLocale);
const [activeLocale, setActiveLocale] = useState(initialLocale);
const [timeZone, setTimeZone] = useState<string>('UTC');
// Detect user's timezone on mount
@@ -66,10 +66,12 @@ export function IntlProvider({ locale: initialLocale, children }: IntlProviderPr
}
}, []);
// Sync initial locale with store on first mount only
// First mount: seed the store from the server-resolved locale if nothing is persisted.
useEffect(() => {
if (!currentLocale) {
setLocale(initialLocale);
} else {
setActiveLocale(currentLocale);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+335 -61
View File
@@ -1,87 +1,361 @@
"use client";
import { useState, useRef, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useAccountStore } from '@/stores/account-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { SettingsSection, SettingItem } from './settings-section';
import { formatFileSize } from '@/lib/utils';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { useRouter } from '@/i18n/navigation';
import { getMaxAccounts } from '@/lib/account-utils';
import { formatFileSize, cn } from '@/lib/utils';
function hostnameOf(serverUrl: string): string {
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
}
export function AccountSettings() {
const t = useTranslations('settings.account');
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, activeAccountId } = useAuthStore();
const router = useRouter();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const { quota } = useEmailStore();
const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
const account = useAccountStore((s) => activeAccountId ? s.getAccountById(activeAccountId) : undefined);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined);
const email = primaryIdentity?.email || account?.email || username;
const max = getMaxAccounts();
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
draggedIndexRef.current = index;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
}, []);
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
}, []);
const handleDrop = useCallback((e: React.DragEvent, dropIndex: number) => {
e.preventDefault();
setDragOverIndex(null);
const fromIndex = draggedIndexRef.current;
if (fromIndex === null || fromIndex === dropIndex) return;
const next = accounts.map((a) => a.id);
const [moved] = next.splice(fromIndex, 1);
next.splice(dropIndex, 0, moved);
reorderAccounts(next);
}, [accounts, reorderAccounts]);
const handleDragEnd = useCallback(() => {
draggedIndexRef.current = null;
setDragOverIndex(null);
}, []);
const moveAccount = useCallback((from: number, to: number) => {
if (to < 0 || to >= accounts.length || from === to) return;
const next = accounts.map((a) => a.id);
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
reorderAccounts(next);
}, [accounts, reorderAccounts]);
const handleSwitch = useCallback((id: string) => {
if (id === activeAccountId) return;
void switchAccount(id);
}, [activeAccountId, switchAccount]);
const handleAddAccount = useCallback(() => {
router.push(`/login?mode=add-account` as never);
}, [router]);
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Display Name */}
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName || t('../../common.unknown')}</span>
</SettingItem>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{email || t('../../common.unknown')}</span>
</SettingItem>
{/* Username / Login (show when it differs from email) */}
{username && username !== email && (
<SettingItem label={t('username_label')}>
<span className="text-sm text-foreground">{username}</span>
<div className="space-y-8">
<SettingsSection title={t('title')} description={t('description')}>
{/* Display Name */}
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName || t('../../common.unknown')}</span>
</SettingItem>
)}
{/* Authentication Method */}
<SettingItem label={t('auth_method_label')}>
<span className="text-sm text-foreground">
{authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')}
</span>
</SettingItem>
{/* Server */}
<SettingItem label={t('server.label')}>
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || t('../../common.unknown')}
</span>
</SettingItem>
{/* Storage */}
{quota && quota.total > 0 && (
<SettingItem
label={t('storage.label')}
description={t('storage.used', {
used: formatFileSize(quota.used),
total: formatFileSize(quota.total),
})}
>
<div className="flex flex-col items-end gap-1">
<span className="text-sm text-foreground">
{t('storage.percentage', { percent: quotaPercentage })}
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${quotaPercentage}%` }}
/>
</div>
</div>
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{email || t('../../common.unknown')}</span>
</SettingItem>
)}
{/* Demo mode indicator */}
{isDemoMode && (
<SettingItem label={t('account_type_label')}>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
{t('demo_account')}
{/* Username / Login (show when it differs from email) */}
{username && username !== email && (
<SettingItem label={t('username_label')}>
<span className="text-sm text-foreground">{username}</span>
</SettingItem>
)}
{/* Authentication Method */}
<SettingItem label={t('auth_method_label')}>
<span className="text-sm text-foreground">
{authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')}
</span>
</SettingItem>
{/* Server */}
<SettingItem label={t('server.label')}>
<span className="text-sm text-foreground truncate max-w-xs">
{serverUrl || t('../../common.unknown')}
</span>
</SettingItem>
{/* Storage */}
{quota && quota.total > 0 && (
<SettingItem
label={t('storage.label')}
description={t('storage.used', {
used: formatFileSize(quota.used),
total: formatFileSize(quota.total),
})}
>
<div className="flex flex-col items-end gap-1">
<span className="text-sm text-foreground">
{t('storage.percentage', { percent: quotaPercentage })}
</span>
<div className="w-32 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${quotaPercentage}%` }}
/>
</div>
</div>
</SettingItem>
)}
{/* Demo mode indicator */}
{isDemoMode && (
<SettingItem label={t('account_type_label')}>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
{t('demo_account')}
</span>
</SettingItem>
)}
</SettingsSection>
{/* Logged-in accounts list */}
{accounts.length > 0 && (
<SettingsSection title={t('accounts.title')} description={t('accounts.description')}>
<div className="space-y-2">
{accounts.map((a, index) => (
<AccountRow
key={a.id}
account={a}
index={index}
isActive={a.id === activeAccountId}
isFirst={index === 0}
isLast={index === accounts.length - 1}
isDragOver={dragOverIndex === index}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onMoveUp={() => moveAccount(index, index - 1)}
onMoveDown={() => moveAccount(index, index + 1)}
onSwitch={() => handleSwitch(a.id)}
onSetDefault={() => setDefaultAccount(a.id)}
labels={{
active: t('accounts.active'),
default: t('accounts.default_badge'),
setDefault: t('accounts.set_default'),
switchTo: t('accounts.switch_to'),
moveUp: t('accounts.move_up'),
moveDown: t('accounts.move_down'),
dragHandle: t('accounts.drag_handle'),
}}
/>
))}
{accounts.length < max && (
<Button
variant="outline"
size="sm"
onClick={handleAddAccount}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
{t('accounts.add')}
</Button>
)}
</div>
</SettingsSection>
)}
</SettingsSection>
</div>
);
}
interface AccountRowProps {
account: AccountEntry;
index: number;
isActive: boolean;
isFirst: boolean;
isLast: boolean;
isDragOver: boolean;
onDragStart: (e: React.DragEvent, index: number) => void;
onDragOver: (e: React.DragEvent, index: number) => void;
onDrop: (e: React.DragEvent, index: number) => void;
onDragEnd: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onSwitch: () => void;
onSetDefault: () => void;
labels: {
active: string;
default: string;
setDefault: string;
switchTo: string;
moveUp: string;
moveDown: string;
dragHandle: string;
};
}
function AccountRow({
account,
index,
isActive,
isFirst,
isLast,
isDragOver,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onMoveUp,
onMoveDown,
onSwitch,
onSetDefault,
labels,
}: AccountRowProps) {
return (
<div
draggable
onDragStart={(e) => onDragStart(e, index)}
onDragOver={(e) => onDragOver(e, index)}
onDrop={(e) => onDrop(e, index)}
onDragEnd={onDragEnd}
className={cn(
'flex items-center gap-3 p-3 border rounded-lg transition-colors',
isDragOver
? 'border-primary bg-primary/5'
: isActive
? 'border-border bg-accent/30'
: 'border-border hover:bg-muted/50'
)}
>
<div
className="cursor-grab active:cursor-grabbing text-muted-foreground/50 hover:text-muted-foreground flex-shrink-0"
title={labels.dragHandle}
>
<GripVertical className="w-4 h-4" />
</div>
<div className="relative flex-shrink-0">
<Avatar
name={account.displayName || account.label}
email={account.email || account.username}
size="sm"
className="w-9 h-9 text-sm"
disableFavicon
fallbackColor={account.avatarColor}
/>
{isActive && (
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
<Check className="w-2.5 h-2.5 text-primary-foreground" />
</div>
)}
</div>
<button
type="button"
onClick={onSwitch}
disabled={isActive}
className={cn(
'min-w-0 flex-1 text-left',
!isActive && 'cursor-pointer'
)}
title={isActive ? labels.active : labels.switchTo}
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
{account.displayName || account.label}
</span>
{account.isDefault && (
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" aria-label={labels.default} />
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{account.email || account.username}
</p>
<div className="flex items-center gap-1 mt-0.5">
{account.hasError ? (
<AlertCircle className="w-3 h-3 text-destructive" />
) : (
<span className={cn(
'w-1.5 h-1.5 rounded-full',
account.isConnected ? 'bg-green-500' : 'bg-muted-foreground/40'
)} />
)}
<span className="text-[10px] text-muted-foreground truncate">
{hostnameOf(account.serverUrl)}
</span>
</div>
</button>
<div className="flex items-center gap-0.5 flex-shrink-0">
{!account.isDefault && (
<button
type="button"
onClick={onSetDefault}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-amber-500 transition-colors"
title={labels.setDefault}
aria-label={labels.setDefault}
>
<Star className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={labels.moveUp}
aria-label={labels.moveUp}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 10l4-4 4 4" />
</svg>
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={labels.moveDown}
aria-label={labels.moveDown}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 6l4 4 4-4" />
</svg>
</button>
</div>
</div>
);
}
+4 -17
View File
@@ -1,13 +1,11 @@
"use client";
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
import { useMediaQuery } from '@/hooks/use-media-query';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -120,7 +118,6 @@ export function LayoutSettings() {
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const isDesktop = useMediaQuery('(min-width: 1024px)');
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -193,20 +190,10 @@ export function LayoutSettings() {
)}
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<div className="flex items-center gap-3">
{proInterface && isDesktop && (
<Link
href="/pro"
className="text-sm font-medium text-primary hover:underline"
>
{t('pro_interface.open_label')}
</Link>
)}
<ToggleSwitch
checked={proInterface}
onChange={(v) => updateSetting('proInterface', v)}
/>
</div>
<ToggleSwitch
checked={proInterface}
onChange={(v) => updateSetting('proInterface', v)}
/>
</SettingItem>
</SettingsSection>
);
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "lucide-react";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, CalendarRights, AddressBookRights } from "@/lib/jmap/types";
@@ -230,6 +231,12 @@ export function ShareCollectionDialog({
: detectAddressBookPreset(rights as AddressBookRights);
return (
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
<Avatar
name={principal?.name}
email={principal?.email ?? undefined}
size="sm"
className="shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{principal?.name || principal?.email || principalId}
@@ -314,6 +321,12 @@ export function ShareCollectionDialog({
className="w-full text-left px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
>
<div className="flex items-center gap-2">
<Avatar
name={p.name}
email={p.email ?? undefined}
size="sm"
className="shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate flex items-center gap-2">
{p.name}