From 902774eae1d8100de1957988d983485fce74c71c Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Sun, 28 Jun 2026 19:23:14 +0300 Subject: [PATCH] feat(list): click sender avatar to select message/thread (Thunderbird-style) Wrap the message-list avatar in a SelectableAvatar control: clicking the avatar toggles the row into the current selection instead of opening it, matching Thunderbird's correspondent-avatar selection affordance. A check overlay appears on hover (hinting it is clickable) and stays while selected. - email-list-item + thread single-email: toggle that message's id - thread header: toggle the whole thread (reuses existing thread-select logic) - focused-mail and extra-compact layouts render no avatar, so unaffected --- .../__tests__/selectable-avatar.test.tsx | 39 ++++++++++++ components/email/email-list-item.tsx | 8 ++- components/email/selectable-avatar.tsx | 59 +++++++++++++++++++ components/email/thread-list-item.tsx | 32 ++++++---- 4 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 components/email/__tests__/selectable-avatar.test.tsx create mode 100644 components/email/selectable-avatar.tsx diff --git a/components/email/__tests__/selectable-avatar.test.tsx b/components/email/__tests__/selectable-avatar.test.tsx new file mode 100644 index 00000000..51ac9269 --- /dev/null +++ b/components/email/__tests__/selectable-avatar.test.tsx @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SelectableAvatar } from '../selectable-avatar'; + +// Isolate from the real Avatar (image fetching, libravatar hashing) — we only +// care about the selection wrapper behaviour here. +vi.mock('@/components/ui/avatar', () => ({ + Avatar: (props: { name?: string }) => {props.name}, +})); + +describe('SelectableAvatar', () => { + it('renders the wrapped avatar', () => { + render( {}} selectLabel="Select" />); + expect(screen.getByTestId('avatar')).toHaveTextContent('Marta'); + }); + + it('fires onToggle and stops propagation when the avatar is clicked', () => { + const onToggle = vi.fn(); + const onRowClick = vi.fn(); + render( +
+ +
, + ); + fireEvent.click(screen.getByRole('checkbox')); + expect(onToggle).toHaveBeenCalledTimes(1); + // Clicking the avatar must not bubble up to open/select the row. + expect(onRowClick).not.toHaveBeenCalled(); + }); + + it('reflects the checked state via aria-checked', () => { + const { rerender } = render( + {}} selectLabel="Select" />, + ); + expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false'); + rerender( {}} selectLabel="Select" />); + expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 2ee2ca84..27fb5c64 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -5,7 +5,7 @@ import { useCallback } from "react"; import { formatDate, stripInvisibleLeading } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; -import { Avatar } from "@/components/ui/avatar"; +import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; @@ -34,6 +34,7 @@ interface EmailListItemProps { export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) { const t = useTranslations('email_viewer'); + const tBatch = useTranslations('email_list.batch_actions'); const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); const showPreview = useSettingsStore((state) => state.showPreview); const density = useSettingsStore((state) => state.density); @@ -180,12 +181,15 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte {/* Avatar */} {density !== 'extra-compact' && ( - toggleEmailSelection(email.id)} + selectLabel={tBatch('select')} /> )} diff --git a/components/email/selectable-avatar.tsx b/components/email/selectable-avatar.tsx new file mode 100644 index 00000000..02076a96 --- /dev/null +++ b/components/email/selectable-avatar.tsx @@ -0,0 +1,59 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Check } from "lucide-react"; +import { Avatar } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; + +type SelectableAvatarProps = ComponentProps & { + /** Whether the underlying message/thread is currently selected. */ + checked: boolean; + /** Toggle selection. The wrapper stops propagation so the row is not opened. */ + onToggle: () => void; + /** Accessible label for the selection control. */ + selectLabel?: string; +}; + +/** + * Avatar that doubles as a selection control, Thunderbird-style: clicking the + * avatar toggles the message/thread into the current selection instead of + * opening it. A check overlay appears on hover (hinting it is clickable) and + * stays visible while the row is selected. + */ +export function SelectableAvatar({ + checked, + onToggle, + selectLabel, + className, + ...avatarProps +}: SelectableAvatarProps) { + return ( + + ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 2e9caac6..b6cf70bc 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -4,7 +4,7 @@ import React, { useCallback } from "react"; import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils"; import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; -import { Avatar } from "@/components/ui/avatar"; +import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; @@ -75,6 +75,7 @@ interface SingleEmailItemProps { const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) { const t = useTranslations('email_viewer'); + const tBatch = useTranslations('email_list.batch_actions'); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isAnswered = email.keywords?.$answered; @@ -221,12 +222,15 @@ const SingleEmailItem = React.forwardRef( )} {density !== 'extra-compact' && ( - toggleEmailSelection(email.id)} + selectLabel={tBatch('select')} /> )} @@ -431,6 +435,7 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); @@ -515,13 +520,8 @@ export const ThreadListItem = React.forwardRef { - e.stopPropagation(); - if (e.shiftKey) { - selectRangeEmails(latestEmail.id); - return; - } - // Toggle selection for all emails in this thread + // Toggle selection for all emails in this thread. + const toggleThreadSelection = () => { const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id)); const newSelection = new Set(selectedEmailIds); thread.emails.forEach(em => { @@ -534,6 +534,15 @@ export const ThreadListItem = React.forwardRef { + e.stopPropagation(); + if (e.shiftKey) { + selectRangeEmails(latestEmail.id); + return; + } + toggleThreadSelection(); + }; + const handleHeaderClick = (e: React.MouseEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); @@ -633,12 +642,15 @@ export const ThreadListItem = React.forwardRef - {!isMobile && !isFocusedMailLayout && (