Files
webmail/components/email/thread-list-item.tsx
T

1646 lines
78 KiB
TypeScript

"use client";
import React, { useCallback } from "react";
import { cleanPreview, formatDate } from "@/lib/utils";
import { emailDisplayDate } from "@/lib/email-date";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { ListAttachmentChips, useListHasAttachment } from "./attachment-chips";
import { VerificationCodeChip, useListVerificationCode } from "./verification-code-chip";
import type { Attachment } from "@/lib/jmap/types";
import type { LoadListAttachments } from "@/lib/list-attachments";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "@/components/icons";
import type { AppIcon } from "@/components/icons";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
import { SizeCell } from "./list-columns";
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore, isUnscopedSearchActive } from "@/stores/email-store";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { emailKeyFor, getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { useTagDisplay } from "@/hooks/use-tag-display";
import { TagBadge, TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { useLongPress } from "@/hooks/use-long-press";
import { useSwipeActions } from "@/hooks/use-swipe-actions";
import type { SwipeAction } from "@/stores/settings-store";
import { ThreadEmailItem } from "./thread-email-item";
import { EmailHoverActions } from "./email-hover-actions";
import { SearchSnippetText } from "./search-snippet-text";
import { PluginRowBadges } from "./plugin-row-badges";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
import { useTranslations } from "next-intl";
/**
* Unread bullet in the row gutter.
*
* It is absolutely positioned so it never widens the row. With an avatar it
* sits in the avatar's wrapper and centres on the avatar: the avatar centres
* itself against the row, so a dot anchored on the first line floats above
* it once the row grows a chip line. Extra-compact rows have no avatar and
* anchor it on their single line instead (top padding + half a line).
*/
/**
* A mouse press on the row itself, i.e. one about to become a click that opens
* it: a touch may be a scroll starting, and a press on a control inside the
* row (checkbox, hover action, link) does something else.
*/
function isMousePressOnRow(e: React.PointerEvent): boolean {
if (e.pointerType !== 'mouse' || e.button !== 0) return false;
return !(e.target as Element).closest('button, input, a');
}
/**
* Whether a row has been pointed at yet. The hover actions stay display:none
* until the pointer is over the row, so mounting them for every row up front
* only added a handful of icon buttons per row to every list render. They
* mount on the first pointer-over and stay from then on, so the hover
* behaviour itself is unchanged. Pointer-over rather than pointer-enter: when
* a row slides under a resting pointer (the row above was just deleted from
* its hover actions), the browser's pointerover comes from the list around
* it, and React builds no onPointerEnter from that.
*/
/**
* Role of the folder the list shows (sender vs recipient, junk actions). In
* aggregate role views the selected mailbox is virtual, so the unified role
* stands in for it.
*/
export function listMailboxRole(state: {
mailboxes: { id: string; role?: string | null }[];
selectedMailbox: string;
isUnifiedView: boolean;
unifiedRole: string | null;
}): string | undefined {
return state.mailboxes.find((mb) => mb.id === state.selectedMailbox)?.role
?? (state.isUnifiedView ? (state.unifiedRole ?? undefined) : undefined);
}
function useArmOnPointerOver(): [boolean, () => void] {
const [armed, setArmed] = React.useState(false);
const arm = React.useCallback(() => setArmed(true), []);
return [armed, arm];
}
function UnreadDot({ besideAvatar }: { besideAvatar: boolean }) {
const t = useTranslations('email_viewer');
return (
<div
className={cn('absolute -translate-y-1/2', besideAvatar ? '-start-2.5 top-1/2' : 'start-0.5')}
style={besideAvatar ? undefined : { top: 'calc(var(--density-item-py) + 0.625rem)' }}
>
<Circle className="w-2 h-2 fill-unread text-unread" />
<span className="sr-only">{t('unread')}</span>
</div>
);
}
/** The chip row under a row's preview: the verification code, then the attachments. */
function RowChips({ email, loadAttachments, onOpenAttachment, mountainView, mvRow }: {
email: Email;
loadAttachments?: LoadListAttachments;
onOpenAttachment?: (attachment: Attachment) => void;
/** Gmail's pill chips. */
mountainView?: boolean;
/** A one-line Mountain View row: the chips start under the subject, as in Gmail. */
mvRow?: boolean;
}) {
const variant = mountainView || mvRow ? "mountain-view" : "default";
const code = useListVerificationCode(email);
const leading = code ? <VerificationCodeChip code={code} variant={variant} /> : undefined;
// The sender column is w-32 (lg:w-40) and the row line has gap-3 after it.
const className = cn(variant === "mountain-view" ? "mt-2" : "mt-1.5", mvRow && "mt-1 pb-2.5 ps-[8.75rem] lg:ps-[10.75rem]");
// Most rows have no attachment: no loader for them, nor an empty chip row.
const mayHaveAttachments = email.hasAttachment || (email.attachments?.length ?? 0) > 0;
if (onOpenAttachment && mayHaveAttachments) {
return <ListAttachmentChips email={email} load={loadAttachments} onOpen={onOpenAttachment} leading={leading} variant={variant} max={variant === "mountain-view" ? 3 : 2} className={className} />;
}
return leading ? <div className={cn("flex", className)}>{leading}</div> : null;
}
/**
* The fill behind Mountain View's hover actions, which has to match the row
* they sit on (see the [data-mv-row] rules in globals.css).
*/
function mvHoverBackground(checked: boolean, unread: boolean, selected: boolean): string {
if (selected) return "bg-accent";
if (checked) return "bg-[var(--mv-nav-selected)]";
return unread ? "bg-background" : "bg-[var(--mv-row-read)]";
}
/**
* Mountain View's leading controls: a checkbox and a star, both always
* visible and both acting on the row without opening it. They take the slot
* the default layout gives to the sender avatar.
*/
function MvRowLead({
checked,
onToggleChecked,
starred,
onToggleStar,
selectLabel,
starLabel,
}: {
checked: boolean;
onToggleChecked: (e: React.MouseEvent) => void;
starred: boolean;
onToggleStar?: () => void;
selectLabel: string;
starLabel: string;
}) {
return (
<div className="flex items-center gap-0.5 flex-shrink-0 self-center">
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={selectLabel}
onClick={onToggleChecked}
className={cn(
"p-1 rounded-full transition-colors hover:bg-foreground/10",
checked && "text-primary"
)}
>
{checked ? (
<CheckSquare className="w-[18px] h-[18px]" />
) : (
<Square className="w-[18px] h-[18px] text-muted-foreground" />
)}
</button>
{onToggleStar && (
<button
type="button"
aria-label={starLabel}
aria-pressed={starred}
title={starLabel}
onClick={(e) => {
e.stopPropagation();
onToggleStar();
}}
className="p-1 rounded-full transition-colors hover:bg-foreground/10"
>
<Star
className={cn(
"w-[18px] h-[18px]",
starred ? "fill-amber-400 text-amber-400" : "text-muted-foreground/60"
)}
/>
</button>
)}
</div>
);
}
function MvMobileStar({
starred,
onToggle,
label,
}: {
starred: boolean;
onToggle?: () => void;
label: string;
}) {
return (
<button
type="button"
aria-label={label}
aria-pressed={starred}
title={label}
disabled={!onToggle}
onClick={(event) => {
event.stopPropagation();
onToggle?.();
}}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 disabled:pointer-events-none"
>
<Star
className={cn(
"h-[18px] w-[18px]",
starred ? "fill-amber-400 text-amber-400" : "text-muted-foreground/70"
)}
/>
</button>
);
}
function StatusIcon({ icon: Icon, label, className }: { icon: AppIcon; label: string; className: string }) {
return (
<>
<Icon className={className} />
<span className="sr-only">{label}</span>
</>
);
}
function describeAccount(label: string | undefined, account: AccountEntry | undefined): string | undefined {
const address = account?.email || account?.username;
if (!label) return address;
if (!address || address.toLowerCase() === label.toLowerCase()) return label;
return `${label}, ${address}`;
}
/**
* Small chip showing the originating folder of a message, rendered in the
* aggregate "All …" views (All Mail / unified / cross-account) where rows come
* from different folders. `email.sourceFolder` is stamped at fetch time.
*/
function SourceFolderTag({ name }: { name: string }) {
return (
<span
className="inline-flex max-w-[8rem] shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[11px] text-muted-foreground"
title={name}
>
<Folder className="h-3 w-3 shrink-0" />
<span className="truncate">{name}</span>
</span>
);
}
/**
* Whether rows show their folder chip: in the aggregate "All …" views, and in
* search results that span every folder rather than the one picked in the
* search panel (#1172). Rows of a single-folder list carry no sourceFolder.
*
* Such a row never subscribes to the view state: starting a search flips it
* while the old list is still on screen, and every row re-rendered for a chip
* it has nothing to put in.
*/
function useShowSourceFolder(sourceFolder: string | undefined): boolean {
return useEmailStore((state) => !!sourceFolder && (state.isUnifiedView || isUnscopedSearchActive(state)));
}
/**
* How many messages a collapsed thread stands for.
*
* Built from the tag lozenge so it lines up with the tags it sits next to: the
* same shape, and the same group spacing.
*/
function ThreadCountPill({ count, hasUnread, title }: { count: number; hasUnread: boolean; title: string }) {
return (
<span
className={cn(
TAG_LOZENGE_CLASS,
"gap-0.5",
hasUnread ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
title={title}
>
<MessageSquare className="w-3 h-3" />
<span aria-hidden="true">{count}</span>
<span className="sr-only">{title}</span>
</span>
);
}
interface ThreadListItemProps {
thread: ThreadGroup;
isExpanded: boolean;
/** emailKeyFor of the open message: ids repeat across accounts. */
selectedEmailKey?: string;
isLoading?: boolean;
expandedEmails?: Email[];
onToggleExpand: () => void;
onCollapseAllThreads?: () => void;
onEmailSelect: (email: Email) => void;
onEmailDoubleClick?: (email: Email) => void;
/** A mouse was pressed on the row: the click that follows will open it. */
onPrefetch?: (email: Email) => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
onOpenConversation?: (thread: ThreadGroup) => void;
onToggleStar?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetTag?: (emailId: string, tagId: string | null, emailKey?: string) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
onOpenAttachment?: (email: Email, attachment: Attachment) => void;
loadAttachments?: LoadListAttachments;
}
interface SingleEmailItemProps {
email: Email;
selected: boolean;
onClick: () => void;
onDoubleClick?: () => void;
onPrefetch?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
showPreview: boolean;
rowTint: string | null;
onToggleStar?: () => void;
onMarkAsRead?: (read: boolean) => void;
onDelete?: () => void;
onArchive?: () => void;
onSetTag?: (tagId: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onOpenAttachment?: (attachment: Attachment) => void;
loadAttachments?: LoadListAttachments;
}
// Visual + behaviour metadata for each mobile swipe action. Keyed by the
// SwipeAction values that map to a row callback ('none' is handled inline).
const SWIPE_ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: AppIcon; bg: string }> = {
archive: { icon: Archive, bg: 'bg-emerald-600' },
delete: { icon: Trash2, bg: 'bg-red-600' },
markRead: { icon: MailOpen, bg: 'bg-sky-600' },
star: { icon: Star, bg: 'bg-amber-500' },
spam: { icon: ShieldAlert, bg: 'bg-orange-600' },
};
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onPrefetch, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const tHover = useTranslations('settings.email_behavior.hover_actions');
const tStatus = useTranslations('email_list');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const showPaperclip = useListHasAttachment(email, loadAttachments);
const { selectedMailbox, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, isUnifiedView } = useEmailStore(useShallow((state) => ({
selectedMailbox: state.selectedMailbox,
// The role, not the folder list: the list is a new array on every
// count update, and subscribing to it re-rendered every row for each.
currentMailboxRole: listMailboxRole(state),
selectedEmailKeys: state.selectedEmailKeys,
toggleEmailSelection: state.toggleEmailSelection,
selectRangeEmails: state.selectRangeEmails,
isUnifiedView: state.isUnifiedView,
})));
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). In aggregate role-views the selected mailbox is virtual → fall back
// to the unified role so junk-contextual UI and avatar hiding work.
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const formatAddress = useOwnDomainAddress();
const { sortTagIds, tagColor } = useKeywordFormat();
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
// The tint replaces the dot, and the dot can be switched off on its own (#912).
const showAccountDot = useSettingsStore((state) => state.showAccountDot) && !tintListRowsByAccount;
const density = useSettingsStore((state) => state.density);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
// Show the originating folder in the aggregate "All …" views.
const showSourceFolder = useShowSourceFolder(email.sourceFolder);
const getAccountById = useAccountStore((state) => state.getAccountById);
const account = email.accountId ? getAccountById(email.accountId) : undefined;
const accountColor = account?.avatarColor;
const accountDescription = describeAccount(email.accountLabel, account);
const isChecked = selectedEmailKeys.has(emailKeyFor(email));
const isMobile = useUIStore((state) => state.isMobile);
// One line per row where the list has the full width; stacked on phones.
const { oneLine: oneLineRow, columns } = useListRowShape();
// Mountain View leads its rows with a checkbox and a clickable star
// instead of an avatar, and marks unread with weight alone. Only the
// one-line layout is shaped that way, so the two conditions travel
// together.
const mvRow = interfaceLayout === 'mountain-view' && oneLineRow;
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
const trimmedPreview = cleanPreview(email.preview);
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// Search hits carry server snippets with the matched terms marked; they
// replace the plain subject / preview so the user sees why a mail matched.
const subjectSnippet = email.searchSnippet?.subject ?? null;
const previewSnippet = showPreview ? (email.searchSnippet?.preview ?? null) : null;
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
? formatScheduledSendAt(email.scheduledSendAt, timeFormat)
: null;
const tagIds = sortTagIds(getEmailTagIds(email.keywords));
// The account tint stands in for the per-account dot, so it outranks the
// tag tint - the tag still shows its own colour on its chip.
//
// `accountId` only resolves to a local account for personal entries; shared
// ones carry the JMAP owner id, which no AccountEntry matches. Falling back
// to the reaching client and then to a colour derived from the label means
// every row that knows which account it came from gets tinted, instead of
// silently staying blank.
const accountTintSource = accountColor
?? (email.sourceClientAccountId ? getAccountById(email.sourceClientAccountId)?.avatarColor : undefined)
?? (email.accountLabel ? generateAvatarColor(email.accountLabel) : undefined);
const accountRowTint = (tintListRowsByAccount && isUnifiedView && accountTintSource)
? (KEYWORD_PALETTE[accountTintKey(accountTintSource)]?.rowTint ?? null)
: null;
const resolvedRowTint = accountRowTint
?? (!tintListRowsByTag ? null : (rowTint ?? (tagIds[0] ? tagColor(tagIds[0]).rowTint : null)));
const { dragHandlers, isDragging } = useEmailDrag({
email,
sourceMailboxId: selectedMailbox,
});
const [hoverActionsArmed, armHoverActions] = useArmOnPointerOver();
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
email
);
}, [onContextMenu, email]),
isMobile
);
const longPressHandlers = { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel };
// Mobile swipe actions (Gmail-style): drag a row left/right to fire the
// configured action. Desktop keeps hover actions + context menu.
const swipeRightAction = useSettingsStore((state) => state.swipeRightAction);
const swipeLeftAction = useSettingsStore((state) => state.swipeLeftAction);
const runSwipeAction = useCallback((action: SwipeAction) => {
switch (action) {
case 'archive': onArchive?.(); break;
case 'delete': onDelete?.(); break;
case 'markRead': onMarkAsRead?.(isUnread); break; // unread -> read, read -> unread
case 'star': onToggleStar?.(); break;
case 'spam': onMarkAsSpam?.(); break;
default: break;
}
}, [onArchive, onDelete, onMarkAsRead, onToggleStar, onMarkAsSpam, isUnread]);
const isActionable = (action: SwipeAction): boolean => {
switch (action) {
case 'archive': return !!onArchive;
case 'delete': return !!onDelete;
case 'markRead': return !!onMarkAsRead;
case 'star': return !!onToggleStar;
case 'spam': return !!onMarkAsSpam;
default: return false;
}
};
const hasRight = isActionable(swipeRightAction);
const hasLeft = isActionable(swipeLeftAction);
const swipeEnabled = isMobile && (hasLeft || hasRight);
const { swipeHandlers, offsetX, side: swipeSide, willCommit, consumeTap } = useSwipeActions({
enabled: swipeEnabled,
hasLeft,
hasRight,
onCommit: (s) => runSwipeAction(s === 'right' ? swipeRightAction : swipeLeftAction),
});
const swipeActiveAction = swipeSide === 'right' ? swipeRightAction : swipeSide === 'left' ? swipeLeftAction : 'none';
const swipeMeta = swipeActiveAction !== 'none' ? SWIPE_ACTION_META[swipeActiveAction] : null;
const touchHandlers = swipeEnabled
? {
onTouchStart: (e: React.TouchEvent) => { longPressHandlers.onTouchStart(e); swipeHandlers.onTouchStart(e); },
onTouchMove: (e: React.TouchEvent) => { longPressHandlers.onTouchMove(e); swipeHandlers.onTouchMove(e); },
onTouchEnd: (e: React.TouchEvent) => { longPressHandlers.onTouchEnd(e); swipeHandlers.onTouchEnd(); },
onTouchCancel: () => { longPressHandlers.onTouchCancel(); swipeHandlers.onTouchCancel(); },
}
: longPressHandlers;
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
selectRangeEmails(email);
} else {
toggleEmailSelection(email);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
// Once something is ticked, the whole row is the checkbox, as in phone
// mail apps (#1014). Mountain View on the desktop opens the message and
// keeps the ticks instead, as Gmail does (#1231).
const togglesOnClick = selectedEmailKeys.size > 0 && !mvRow;
const handleClick = (e: React.MouseEvent) => {
if (swipeEnabled && consumeTap()) { return; }
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email);
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email);
} else if (togglesOnClick) {
toggleEmailSelection(email);
} else {
// Mountain View keeps the ticked rows while a message is read, as
// Gmail does, so selecting can carry on afterwards. (#1231)
onClick();
}
};
return (
<div
ref={ref}
{...dragHandlers}
{...touchHandlers}
data-testid="email-list-item"
onPointerOver={armHoverActions}
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(); }}
data-email-id={email.id}
data-subject={email.subject || ''}
data-unread={isUnread ? 'true' : 'false'}
data-starred={email.keywords?.$flagged ? 'true' : 'false'}
data-mv-mobile-row={mvMobileRow ? "" : undefined}
data-mv-row={mvRow && !resolvedRowTint ? "" : undefined}
data-checked={isChecked ? "" : undefined}
aria-current={selected ? 'true' : undefined}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
mvMobileRow && "border-b-transparent",
resolvedRowTint ? resolvedRowTint : (
selected
? "bg-accent"
: mvRow && !isUnread
? "bg-muted/60"
: "bg-background"
),
selected && !resolvedRowTint && "shadow-sm",
!resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm",
!resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm",
resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !resolvedRowTint && !mvRow && !mvMobileRow && "bg-accent/30",
isChecked && "ring-2 ring-primary/20",
isChecked && !resolvedRowTint && "bg-accent/40",
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30",
isPressed && "scale-[0.98] ring-2 ring-primary/30",
isPressed && !resolvedRowTint && "bg-muted"
)}
onClick={handleClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onDoubleClick || togglesOnClick) return;
e.preventDefault();
onDoubleClick();
}}
onContextMenu={handleContextMenu}
style={{ minHeight: oneLineRow ? undefined : 'var(--list-item-height)', touchAction: swipeEnabled ? 'pan-y' : undefined }}
>
{swipeEnabled && swipeMeta && (
// Decorative reveal on the physically exposed edge. That edge
// follows the (physical) translate direction, not text direction,
// so force LTR to keep justify-start/-end as physical left/right
// (otherwise the icon flips to the wrong side under RTL).
<div
aria-hidden
dir="ltr"
className={cn('absolute inset-0 z-0 flex items-center px-5 text-white', swipeMeta.bg, swipeSide === 'right' ? 'justify-start' : 'justify-end')}
>
<swipeMeta.icon className={cn('h-5 w-5 transition-transform', willCommit ? 'scale-110' : 'scale-90 opacity-80')} />
</div>
)}
<div
className={cn('px-3', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10', swipeEnabled && 'relative z-10 bg-inherit')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: mvRow ? undefined : 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }}
>
{/* Checkbox - only for extra-compact density (no avatar) while in selection mode */}
{!mvRow && density === 'extra-compact' && selectedEmailKeys.size > 0 && (
<button
onClick={handleCheckboxClick}
role="checkbox"
aria-checked={isChecked}
aria-label={tBatch('select')}
className={cn(
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!oneLineRow && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{mvRow && (
<MvRowLead
checked={isChecked}
onToggleChecked={handleCheckboxClick}
starred={!!isStarred}
onToggleStar={onToggleStar}
selectLabel={tBatch('select')}
starLabel={tHover('star')}
/>
)}
{!mvRow && density !== 'extra-compact' && (
<div className="relative flex flex-shrink-0 self-center">
<SelectableAvatar
name={sender?.name}
email={sender?.email}
size={oneLineRow ? "sm" : "md"}
className="shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={handleCheckboxClick}
selectLabel={tBatch('select')}
/>
{isUnread && <UnreadDot besideAvatar />}
</div>
)}
{isUnread && !mvRow && density === 'extra-compact' && (
<UnreadDot besideAvatar={false} />
)}
<div className="flex-1 min-w-0">
{oneLineRow ? (
<>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
{/* In columns the dot sits inside the From cell, so a row
with it starts its columns where the header does. */}
{!columns && isUnifiedView && email.accountId && accountColor && showAccountDot && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: accountColor }}
title={accountDescription}
>
<span className="sr-only">{accountDescription}</span>
</span>
)}
<span className={cn(
columns ? cn(LIST_COLUMN_CLASS.from, 'flex items-center gap-2') : 'w-32 shrink-0 truncate lg:w-40',
'text-sm',
isUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'font-medium text-foreground/80'
)}>
{columns ? (
<>
{/* Where a conversation row has its chevron. */}
<span aria-hidden="true" className="w-3.5 shrink-0" />
{isUnifiedView && email.accountId && accountColor && showAccountDot && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: accountColor }}
title={accountDescription}
>
<span className="sr-only">{accountDescription}</span>
</span>
)}
<span className="min-w-0 truncate">
{sender?.name || (sender?.email && formatAddress(sender.email)) || 'Unknown'}
</span>
</>
) : (sender?.name || (sender?.email && formatAddress(sender.email)) || 'Unknown')}
</span>
<div className={cn("flex min-w-0 flex-1 items-center text-sm", mvRow ? "gap-0" : "gap-2")}>
{tagIds.length > 0 && (
<span className={cn(TAG_GROUP_CLASS, mvRow && "me-2")}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
'min-w-0 truncate',
isUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'text-foreground/90'
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || '(no subject)')}
</span>
<PluginRowBadges email={email} className={mvRow ? "ms-2" : undefined} />
{(previewSnippet || inlinePreview) && (
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
{/* Gmail runs the snippet on from the subject after a dash. */}
{mvRow && '\u00a0-\u00a0'}
{previewSnippet ? <> <SearchSnippetText snippet={previewSnippet} /></> : (mvRow ? trimmedPreview : inlinePreview)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />}
{isStarred && !mvRow && <StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isAnswered && !isForwarded && !mvRow && <StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && !mvRow && <StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />}
{isAnswered && isForwarded && !mvRow && (
<>
<StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{showPaperclip && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
'text-xs tabular-nums',
columns && LIST_COLUMN_CLASS.date,
isUnread ? (mvRow ? 'text-foreground font-bold' : 'text-foreground font-semibold') : 'text-muted-foreground'
)}>
{formatDate(emailDisplayDate(email), mvRow ? { listStyle: 'mountain-view' } : undefined)}
</span>
)}
{columns && <SizeCell bytes={email.size} />}
</div>
</div>
{mvRow && (
<RowChips email={email} loadAttachments={loadAttachments} onOpenAttachment={onOpenAttachment} mvRow />
)}
</>
) : (
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isUnifiedView && email.accountId && accountColor && showAccountDot && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: accountColor }}
title={accountDescription}
>
<span className="sr-only">{accountDescription}</span>
</span>
)}
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"}
</span>
{tagPlacement === 'sender' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<div className="flex items-center gap-1.5">
{isPinned && (
<StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />
)}
{isStarred && !mvMobileRow && (
<StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isAnswered && !isForwarded && (
<StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isForwarded && !isAnswered && (
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
)}
{isAnswered && isForwarded && (
<>
<StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{showPaperclip && (
<StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
"text-xs tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(emailDisplayDate(email))}
</span>
)}
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
<MvMobileStar
starred={!!isStarred}
onToggle={onToggleStar}
label={tHover('star')}
/>
)}
</div>
</div>
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
"min-w-0 flex-1 truncate text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || "(no subject)")}
</span>
<PluginRowBadges email={email} />
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
<p className={cn(
"text-sm leading-relaxed",
mvMobileRow ? "line-clamp-1 min-w-0 flex-1" : "line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || t('no_preview_available'))}
</p>
{mvMobileRow && (
<MvMobileStar
starred={!!isStarred}
onToggle={onToggleStar}
label={tHover('star')}
/>
)}
</div>
)}
<RowChips email={email} loadAttachments={loadAttachments} onOpenAttachment={onOpenAttachment} mountainView={mvMobileRow} />
</>
)}
</div>
</div>
{/* Hover Quick Actions */}
{hoverActionsArmed && !email.isScheduled && (
<EmailHoverActions
email={email}
backgroundClassName={resolvedRowTint ? resolvedRowTint : mvRow ? mvHoverBackground(isChecked, isUnread, selected) : ((selected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar}
onMarkAsRead={onMarkAsRead}
onDelete={onDelete}
onArchive={onArchive}
onSetTag={onSetTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
mailboxRole={currentMailboxRole ?? null}
/>
)}
</div>
);
}
);
export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemProps>(
function ThreadListItem({
onOpenAttachment,
loadAttachments,
thread,
isExpanded,
selectedEmailKey,
isLoading = false,
expandedEmails,
onToggleExpand,
onCollapseAllThreads,
onEmailSelect,
onEmailDoubleClick,
onPrefetch,
onContextMenu,
onOpenConversation,
onToggleStar,
onMarkAsRead,
onDelete,
onArchive,
onSetTag,
onMarkAsSpam,
onUndoSpam,
}, ref) {
const t = useTranslations('threads');
const tEmailViewer = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const tHover = useTranslations('settings.email_behavior.hover_actions');
const tStatus = useTranslations('email_list');
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAnswered, hasForwarded, emailCount } = thread;
// Only the latest email's parts are loaded; an older one with the flag keeps the paperclip.
const latestHasAttachment = useListHasAttachment(latestEmail, loadAttachments);
const hasAttachment = latestHasAttachment || thread.emails.some((e) => e !== latestEmail && e.hasAttachment);
// One line per row where the list has the full width; stacked on phones.
const { oneLine: oneLineRow, columns } = useListRowShape();
// Mountain View leads its rows with a checkbox and a clickable star
// instead of an avatar, and marks unread with weight alone. Only the
// one-line layout is shaped that way, so the two conditions travel
// together.
const mvRow = interfaceLayout === 'mountain-view' && oneLineRow;
const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile;
const trimmedPreview = cleanPreview(latestEmail.preview);
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
// In a search the matched mail need not be the thread's latest one: show
// the snippet of whichever email in the thread carries one.
const threadSnippet = thread.emails.find((e) => e.searchSnippet)?.searchSnippet ?? latestEmail.searchSnippet;
const subjectSnippet = threadSnippet?.subject ?? null;
const previewSnippet = showPreview ? (threadSnippet?.preview ?? null) : null;
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
? formatScheduledSendAt(latestEmail.scheduledSendAt, timeFormat)
: null;
const { selectedMailbox, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, isUnifiedView } = useEmailStore(useShallow((state) => ({
selectedMailbox: state.selectedMailbox,
// The role, not the folder list: the list is a new array on every
// count update, and subscribing to it re-rendered every row for each.
currentMailboxRole: listMailboxRole(state),
selectedEmailKeys: state.selectedEmailKeys,
toggleEmailSelection: state.toggleEmailSelection,
selectRangeEmails: state.selectRangeEmails,
isUnifiedView: state.isUnifiedView,
})));
const showSourceFolder = useShowSourceFolder(latestEmail.sourceFolder);
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined;
const threadAccountColor = threadAccount?.avatarColor;
const threadAccountDescription = describeAccount(latestEmail.accountLabel, threadAccount);
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). Aggregate role-views use a virtual selected mailbox → fall back to
// the unified role so junk-contextual UI and avatar hiding work.
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const displayNames = showRecipient
? Array.from(new Set(
thread.emails.flatMap(e => (e.to ?? []).map(r => r.name || r.email.split('@')[0]))
)).slice(0, 4)
: participantNames;
const avatarPerson = showRecipient ? latestEmail.to?.[0] : latestEmail.from?.[0];
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
email: latestEmail,
sourceMailboxId: selectedMailbox,
threadEmails: thread.emails,
});
const [hoverActionsArmed, armHoverActions] = useArmOnPointerOver();
const { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel, isPressed: isThreadPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
{ preventDefault: () => {}, stopPropagation: () => {}, clientX: pos.clientX, clientY: pos.clientY } as React.MouseEvent,
latestEmail
);
}, [onContextMenu, latestEmail]),
isMobile
);
const threadLongPressHandlers = { onTouchStart: threadOnTouchStart, onTouchEnd: threadOnTouchEnd, onTouchMove: threadOnTouchMove, onTouchCancel: threadOnTouchCancel };
const { sortTagIds, tagColor } = useKeywordFormat();
const { variant: tagVariant, placement: tagPlacement } = useTagDisplay();
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount);
// The tint replaces the dot, and the dot can be switched off on its own (#912).
const showAccountDot = useSettingsStore((state) => state.showAccountDot) && !tintListRowsByAccount;
// A collapsed row speaks for every message under it, so it carries their tags too.
const tagIds = sortTagIds(getThreadTagIds(thread.emails));
const threadTintSource = threadAccountColor
?? (latestEmail.sourceClientAccountId ? getAccountById(latestEmail.sourceClientAccountId)?.avatarColor : undefined)
?? (latestEmail.accountLabel ? generateAvatarColor(latestEmail.accountLabel) : undefined);
const accountRowTint = (tintListRowsByAccount && isUnifiedView && threadTintSource)
? (KEYWORD_PALETTE[accountTintKey(threadTintSource)]?.rowTint ?? null)
: null;
const rowTint = accountRowTint
?? ((tintListRowsByTag && tagIds[0]) ? tagColor(tagIds[0]).rowTint : null);
const isSelected = selectedEmailKey === emailKeyFor(latestEmail) ||
thread.emails.some(e => emailKeyFor(e) === selectedEmailKey);
const isChecked = thread.emails.some(e => selectedEmailKeys.has(emailKeyFor(e)));
if (emailCount === 1) {
return (
<SingleEmailItem
ref={ref}
email={latestEmail}
selected={selectedEmailKey === emailKeyFor(latestEmail)}
onClick={() => onEmailSelect(latestEmail)}
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined}
onPrefetch={onPrefetch ? () => onPrefetch(latestEmail) : undefined}
onContextMenu={onContextMenu}
showPreview={showPreview}
rowTint={rowTint}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color, emailKeyFor(latestEmail)) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
onOpenAttachment={onOpenAttachment ? (a) => onOpenAttachment(latestEmail, a) : undefined}
loadAttachments={loadAttachments}
/>
);
}
const emailsToShow = expandedEmails || thread.emails;
// Toggle selection for all emails in this thread.
const toggleThreadSelection = () => {
const allSelected = thread.emails.every(em => selectedEmailKeys.has(emailKeyFor(em)));
const newSelection = new Set(selectedEmailKeys);
thread.emails.forEach(em => {
if (allSelected) {
newSelection.delete(emailKeyFor(em));
} else {
newSelection.add(emailKeyFor(em));
}
});
useEmailStore.setState({ selectedEmailKeys: newSelection, lastSelectedEmailKey: emailKeyFor(latestEmail) });
};
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
selectRangeEmails(latestEmail);
return;
}
toggleThreadSelection();
};
// See SingleEmailItem: with rows ticked, a click ticks instead of opening.
const threadTogglesOnClick = selectedEmailKeys.size > 0 && !mvRow;
const handleHeaderClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
// Ctrl+Click: toggle selection for all thread emails
thread.emails.forEach(em => toggleEmailSelection(em));
return;
}
if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(latestEmail);
return;
}
// In selection mode the row ticks the whole thread (#1014); the
// chevron still unfolds it.
const target = e.target as HTMLElement;
if (threadTogglesOnClick && !target.closest('[data-expand-toggle]')) {
toggleThreadSelection();
return;
}
// Mountain View opens the conversation in the reading pane, as Gmail
// does, rather than unfolding it inside the list. On the phone too: the
// separate phone thread view would list it newest first. (#1224)
if (isMobile && onOpenConversation && !mvMobileRow) {
onOpenConversation(thread);
return;
}
if (mvRow || mvMobileRow) {
// Desktop keeps the ticked rows while the conversation is read (#1231).
onEmailSelect(latestEmail);
return;
}
if (target.closest('[data-expand-toggle]')) {
onToggleExpand();
} else {
if (!isExpanded) {
onCollapseAllThreads?.();
onToggleExpand();
}
onEmailSelect(latestEmail);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, latestEmail);
};
return (
<div ref={ref} className={cn(
"border-b border-border",
mvMobileRow && "border-b-transparent",
isThreadDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
)}>
<div
{...dragHandlers}
{...threadLongPressHandlers}
onPointerOver={armHoverActions}
onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }}
aria-current={isSelected ? 'true' : undefined}
data-unread={hasUnread ? 'true' : 'false'}
data-mv-mobile-row={mvMobileRow ? "" : undefined}
data-mv-row={mvRow && !rowTint ? "" : undefined}
data-checked={isChecked ? "" : undefined}
className={cn(
"relative group cursor-pointer select-none transition-shadow duration-200 overflow-hidden",
rowTint ? rowTint : (
isSelected
? "bg-accent"
: mvRow && !hasUnread
? "bg-muted/60"
: "bg-background"
),
isSelected && !rowTint && "shadow-sm",
!rowTint && !isSelected && !isChecked && "hover:bg-muted hover:shadow-sm",
!rowTint && (isSelected || isChecked) && "hover:bg-accent hover:shadow-sm",
rowTint && "hover:brightness-95 dark:hover:brightness-110",
hasUnread && !rowTint && !isSelected && !mvRow && !mvMobileRow && "bg-accent/30",
isExpanded && "border-b border-border/50",
isChecked && "ring-2 ring-primary/20",
isChecked && !rowTint && "bg-accent/40",
isThreadPressed && "scale-[0.98] ring-2 ring-primary/30",
isThreadPressed && !rowTint && "bg-muted"
)}
onClick={handleHeaderClick}
onDoubleClick={(e) => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!onEmailDoubleClick || threadTogglesOnClick) return;
e.preventDefault();
onEmailDoubleClick(latestEmail);
}}
onContextMenu={handleContextMenu}
style={{ minHeight: oneLineRow ? undefined : 'var(--list-item-height)' }}
>
<div
className={cn('px-3', oneLineRow ? 'flex items-center' : 'flex items-start', mvRow && 'min-h-10')}
style={{ gap: 'var(--density-item-gap)', paddingBlock: mvRow ? undefined : 'var(--density-item-py)' }}
>
{/* Checkbox for thread selection - only for extra-compact density (no avatar) while in selection mode */}
{!mvRow && density === 'extra-compact' && selectedEmailKeys.size > 0 && (
<button
onClick={handleThreadCheckboxClick}
role="checkbox"
aria-checked={isChecked}
aria-label={tBatch('select')}
className={cn(
"p-3 lg:p-1 rounded flex-shrink-0 transition-all duration-200",
!oneLineRow && 'mt-2',
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"animate-in fade-in zoom-in-95 duration-150",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
)}
{mvRow && (
<MvRowLead
checked={isChecked}
onToggleChecked={handleThreadCheckboxClick}
starred={!!hasStarred}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
selectLabel={tBatch('select')}
starLabel={tHover('star')}
/>
)}
{!mvRow && density !== 'extra-compact' && (
<div className="relative flex-shrink-0 self-center">
<SelectableAvatar
name={avatarPerson?.name}
email={avatarPerson?.email}
size={oneLineRow ? "sm" : "md"}
className="shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={handleThreadCheckboxClick}
selectLabel={tBatch('select')}
/>
{hasUnread && <UnreadDot besideAvatar />}
{!isMobile && !columns && (
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className={cn(
"absolute -bottom-2.5 left-1/2 -translate-x-1/2 p-0.5 rounded-full",
"transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
"text-muted-foreground hover:text-foreground",
"bg-background border border-border"
)}
aria-expanded={isExpanded}
aria-label={t('toggle_thread')}
>
{isLoading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
</div>
)}
{hasUnread && !mvRow && density === 'extra-compact' && (
<UnreadDot besideAvatar={false} />
)}
<div className="flex-1 min-w-0">
{oneLineRow ? (
<>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
{!columns && isUnifiedView && latestEmail.accountId && threadAccountColor && showAccountDot && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: threadAccountColor }}
title={threadAccountDescription}
>
<span className="sr-only">{threadAccountDescription}</span>
</span>
)}
{columns ? (
// Zimbra-style: the chevron leads the From cell and the
// count follows the names, so a folded conversation is
// one line like any message. (#262)
<span data-list-from="" className={cn(LIST_COLUMN_CLASS.from, 'flex items-center gap-2 text-sm')}>
<button
data-expand-toggle
onClick={(e) => {
e.stopPropagation();
onToggleExpand();
}}
className="-m-0.5 shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
aria-expanded={isExpanded}
aria-label={t('toggle_thread')}
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</button>
{isUnifiedView && latestEmail.accountId && threadAccountColor && showAccountDot && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: threadAccountColor }}
title={threadAccountDescription}
>
<span className="sr-only">{threadAccountDescription}</span>
</span>
)}
<span className={cn('min-w-0 truncate', hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80')}>
{displayNames.join(', ')}
</span>
<span className="shrink-0 text-xs text-muted-foreground" title={t('messages_tooltip', { count: emailCount })}>
({emailCount})
</span>
</span>
) : mvRow ? (
// Gmail puts the message count after the names, in grey.
<span data-mv-names="" className="flex w-32 shrink-0 items-baseline gap-1 text-sm lg:w-40">
<span className={cn('min-w-0 truncate', hasUnread ? 'font-bold text-foreground' : 'font-medium text-foreground/80')}>
{displayNames.join(', ')}
</span>
<span className="shrink-0 text-xs text-muted-foreground" title={t('messages_tooltip', { count: emailCount })}>
{emailCount}
</span>
</span>
) : (
<span className={cn(
// Matches SingleEmailItem: the sender column sets where
// every row's tags and subject begin, so the two have to
// agree or thread rows sit 1rem further right.
'w-32 shrink-0 truncate text-sm lg:w-40',
hasUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground/80'
)}>
{displayNames.join(', ')}
</span>
)}
<div className={cn("flex min-w-0 flex-1 items-center text-sm", mvRow ? "gap-0" : "gap-2")}>
{((!mvRow && !columns) || tagIds.length > 0) && (
<span className={cn(TAG_GROUP_CLASS, mvRow && "me-2")}>
{!mvRow && !columns && (
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
)}
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
'min-w-0 truncate',
hasUnread ? (mvRow ? 'font-bold text-foreground' : 'font-semibold text-foreground') : 'text-foreground/90'
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || '(no subject)')}
</span>
<PluginRowBadges email={latestEmail} className={mvRow ? "ms-2" : undefined} />
{(previewSnippet || inlinePreview) && (
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
{mvRow && '\u00a0-\u00a0'}
{previewSnippet ? <> <SearchSnippetText snippet={previewSnippet} /></> : (mvRow ? trimmedPreview : inlinePreview)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{hasPinned && <StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />}
{hasStarred && !mvRow && <StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{hasAnswered && !hasForwarded && !mvRow && <StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />}
{hasForwarded && !hasAnswered && !mvRow && <StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />}
{hasAnswered && hasForwarded && !mvRow && (
<>
<StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{hasAttachment && <StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className={cn("inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300", columns && "max-w-28")}
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
'text-xs tabular-nums',
columns && LIST_COLUMN_CLASS.date,
hasUnread ? (mvRow ? 'text-foreground font-bold' : 'text-foreground font-semibold') : 'text-muted-foreground'
)}>
{formatDate(emailDisplayDate(latestEmail), mvRow ? { listStyle: 'mountain-view' } : undefined)}
</span>
)}
{columns && <SizeCell bytes={thread.emails.reduce((sum, e) => sum + (e.size ?? 0), 0)} />}
</div>
</div>
{mvRow && (
<RowChips
email={latestEmail}
loadAttachments={loadAttachments}
onOpenAttachment={onOpenAttachment ? (a) => onOpenAttachment(latestEmail, a) : undefined}
mvRow
/>
)}
</>
) : (
<>
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
{isUnifiedView && latestEmail.accountId && threadAccountColor && showAccountDot && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: threadAccountColor }}
title={threadAccountDescription}
>
<span className="sr-only">{threadAccountDescription}</span>
</span>
)}
<span className={cn(
"truncate text-sm",
hasUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{displayNames.join(", ")}
</span>
<span className={TAG_GROUP_CLASS}>
<ThreadCountPill
count={emailCount}
hasUnread={hasUnread}
title={t('messages_tooltip', { count: emailCount })}
/>
{tagPlacement === 'sender' && tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
<div className="flex items-center gap-1.5">
{hasPinned && (
<StatusIcon icon={Pin} label={tStatus('pinned')} className="w-3.5 h-3.5 text-primary" />
)}
{hasStarred && !mvMobileRow && (
<StatusIcon icon={Star} label={tStatus('starred')} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{hasAnswered && !hasForwarded && (
<StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasForwarded && !hasAnswered && (
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
)}
{hasAnswered && hasForwarded && (
<>
<StatusIcon icon={Reply} label={tStatus('replied')} className="w-3.5 h-3.5 text-muted-foreground" />
<StatusIcon icon={Forward} label={tStatus('forwarded')} className="w-3.5 h-3.5 text-muted-foreground" />
</>
)}
{hasAttachment && (
<StatusIcon icon={Paperclip} label={tStatus('has_attachment')} className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
title={scheduledSendLabel}
>
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="truncate">{scheduledSendLabel}</span>
</span>
) : (
<span className={cn(
"text-xs tabular-nums",
hasUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(emailDisplayDate(latestEmail))}
</span>
)}
{mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && (
<MvMobileStar
starred={!!hasStarred}
onToggle={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
label={tHover('star')}
/>
)}
</div>
</div>
<div className="mb-1 flex min-w-0 items-center gap-1.5">
{tagPlacement === 'subject' && tagIds.length > 0 && (
<span className={TAG_GROUP_CLASS}>
{tagIds.map((id) => (
<TagBadge key={id} tagId={id} variant={tagVariant} />
))}
</span>
)}
<span className={cn(
"min-w-0 flex-1 truncate text-sm",
hasUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || "(no subject)")}
</span>
<PluginRowBadges email={latestEmail} />
</div>
{showPreview && density !== 'extra-compact' && density !== 'compact' && (
<div className={cn("flex min-w-0 items-center gap-1", !mvMobileRow && "contents")}>
<p className={cn(
"text-sm leading-relaxed",
mvMobileRow ? "line-clamp-1 min-w-0 flex-1" : "line-clamp-2",
hasUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || tEmailViewer('no_preview_available'))}
</p>
{mvMobileRow && (
<MvMobileStar
starred={!!hasStarred}
onToggle={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
label={tHover('star')}
/>
)}
</div>
)}
<RowChips
email={latestEmail}
loadAttachments={loadAttachments}
onOpenAttachment={onOpenAttachment ? (a) => onOpenAttachment(latestEmail, a) : undefined}
mountainView={mvMobileRow}
/>
</>
)}
</div>
</div>
{/* Hover Quick Actions for thread header */}
{hoverActionsArmed && !latestEmail.isScheduled && (
<EmailHoverActions
email={latestEmail}
backgroundClassName={rowTint ? rowTint : mvRow ? mvHoverBackground(isChecked, hasUnread, isSelected) : ((isSelected || isChecked) ? "bg-accent" : "bg-muted")}
onToggleStar={onToggleStar ? () => onToggleStar(latestEmail) : undefined}
onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined}
onDelete={onDelete ? () => onDelete(latestEmail) : undefined}
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color, emailKeyFor(latestEmail)) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
mailboxRole={currentMailboxRole ?? null}
/>
)}
</div>
{isExpanded && !isMobile && (
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin me-2" />
{t('loading')}
</div>
) : (
emailsToShow.map((email, index) => (
<ThreadEmailItem
key={email.id}
email={email}
selected={emailKeyFor(email) === selectedEmailKey}
isLast={index === emailsToShow.length - 1}
onClick={() => onEmailSelect(email)}
onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(email) : undefined}
onContextMenu={onContextMenu}
/>
))
)}
</div>
)}
</div>
);
}
);
ThreadListItem.displayName = "ThreadListItem";
/**
* Whether two groupings describe the same thread row. The grouping is rebuilt
* whenever the list or the thread counts change, which hands every row a new
* `thread` object even when its messages are the very same objects.
*/
function isSameThread(a: ThreadGroup, b: ThreadGroup): boolean {
if (a === b) return true;
return a.threadKey === b.threadKey
&& a.threadId === b.threadId
&& a.emailCount === b.emailCount
&& a.latestEmail === b.latestEmail
&& a.hasUnread === b.hasUnread
&& a.hasStarred === b.hasStarred
&& a.hasPinned === b.hasPinned
&& a.hasAttachment === b.hasAttachment
&& a.hasAnswered === b.hasAnswered
&& a.hasForwarded === b.hasForwarded
&& a.emails.length === b.emails.length
&& a.emails.every((email, i) => email === b.emails[i])
&& a.participantNames.length === b.participantNames.length
&& a.participantNames.every((name, i) => name === b.participantNames[i]);
}
/** The open message, if this row draws it; otherwise none of the row's business. */
function openMessageWithin(props: ThreadListItemProps): string | undefined {
const key = props.selectedEmailKey;
if (!key) return undefined;
const drawn = props.thread.emails.some((email) => emailKeyFor(email) === key)
|| (props.expandedEmails?.some((email) => emailKeyFor(email) === key) ?? false);
return drawn ? key : undefined;
}
/** Props equality for MemoizedThreadListItem. */
export function areThreadRowPropsEqual(prev: ThreadListItemProps, next: ThreadListItemProps): boolean {
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]) as Set<keyof ThreadListItemProps>;
for (const key of keys) {
if (key === 'thread') {
if (!isSameThread(prev.thread, next.thread)) return false;
} else if (key === 'selectedEmailKey') {
if (openMessageWithin(prev) !== openMessageWithin(next)) return false;
} else if (!Object.is(prev[key], next[key])) {
return false;
}
}
return true;
}
/**
* ThreadListItem for the message list. Opening a message, switching folder or
* a late thread count re-rendered every row on screen; this one re-renders
* only when something it draws has changed. Handlers are compared by
* identity, so the list passes stable ones (see useStableHandlers).
*/
export const MemoizedThreadListItem = React.memo(ThreadListItem, areThreadRowPropsEqual);