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
This commit is contained in:
@@ -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 }) => <span data-testid="avatar">{props.name}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SelectableAvatar', () => {
|
||||||
|
it('renders the wrapped avatar', () => {
|
||||||
|
render(<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} 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(
|
||||||
|
<div onClick={onRowClick}>
|
||||||
|
<SelectableAvatar name="Marta" checked={false} onToggle={onToggle} selectLabel="Select" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
|
||||||
|
rerender(<SelectableAvatar name="Marta" checked onToggle={() => {}} selectLabel="Select" />);
|
||||||
|
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { useCallback } from "react";
|
|||||||
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
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 { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-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) {
|
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
@@ -180,12 +181,15 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
{density !== 'extra-compact' && (
|
{density !== 'extra-compact' && (
|
||||||
<Avatar
|
<SelectableAvatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size={isFocusedMailLayout ? "sm" : "md"}
|
size={isFocusedMailLayout ? "sm" : "md"}
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
disableImages={hideJunkAvatarImages}
|
disableImages={hideJunkAvatarImages}
|
||||||
|
checked={isChecked}
|
||||||
|
onToggle={() => toggleEmailSelection(email.id)}
|
||||||
|
selectLabel={tBatch('select')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof Avatar> & {
|
||||||
|
/** 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={selectLabel}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"group/select relative shrink-0 rounded-full outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-primary/60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar {...avatarProps} />
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex items-center justify-center rounded-full",
|
||||||
|
"bg-primary text-primary-foreground transition-opacity duration-150",
|
||||||
|
checked ? "opacity-100" : "opacity-0 group-hover/select:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import React, { useCallback } from "react";
|
|||||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
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 { 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 { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
@@ -75,6 +75,7 @@ interface SingleEmailItemProps {
|
|||||||
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||||
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
@@ -221,12 +222,15 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{density !== 'extra-compact' && (
|
||||||
<Avatar
|
<SelectableAvatar
|
||||||
name={sender?.name}
|
name={sender?.name}
|
||||||
email={sender?.email}
|
email={sender?.email}
|
||||||
size={isFocusedMailLayout ? "sm" : "md"}
|
size={isFocusedMailLayout ? "sm" : "md"}
|
||||||
className="flex-shrink-0 shadow-sm"
|
className="flex-shrink-0 shadow-sm"
|
||||||
disableImages={hideJunkAvatarImages}
|
disableImages={hideJunkAvatarImages}
|
||||||
|
checked={isChecked}
|
||||||
|
onToggle={() => toggleEmailSelection(email.id)}
|
||||||
|
selectLabel={tBatch('select')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -431,6 +435,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
}, ref) {
|
}, ref) {
|
||||||
const t = useTranslations('threads');
|
const t = useTranslations('threads');
|
||||||
const tEmailViewer = useTranslations('email_viewer');
|
const tEmailViewer = useTranslations('email_viewer');
|
||||||
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const density = useSettingsStore((state) => state.density);
|
const density = useSettingsStore((state) => state.density);
|
||||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||||
@@ -515,13 +520,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
const emailsToShow = expandedEmails || thread.emails;
|
const emailsToShow = expandedEmails || thread.emails;
|
||||||
|
|
||||||
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
|
// Toggle selection for all emails in this thread.
|
||||||
e.stopPropagation();
|
const toggleThreadSelection = () => {
|
||||||
if (e.shiftKey) {
|
|
||||||
selectRangeEmails(latestEmail.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Toggle selection for all emails in this thread
|
|
||||||
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
|
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
|
||||||
const newSelection = new Set(selectedEmailIds);
|
const newSelection = new Set(selectedEmailIds);
|
||||||
thread.emails.forEach(em => {
|
thread.emails.forEach(em => {
|
||||||
@@ -534,6 +534,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
|
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
selectRangeEmails(latestEmail.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toggleThreadSelection();
|
||||||
|
};
|
||||||
|
|
||||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
const handleHeaderClick = (e: React.MouseEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -633,12 +642,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
|
|
||||||
{density !== 'extra-compact' && (
|
{density !== 'extra-compact' && (
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<Avatar
|
<SelectableAvatar
|
||||||
name={avatarPerson?.name}
|
name={avatarPerson?.name}
|
||||||
email={avatarPerson?.email}
|
email={avatarPerson?.email}
|
||||||
size={isFocusedMailLayout ? "sm" : "md"}
|
size={isFocusedMailLayout ? "sm" : "md"}
|
||||||
className="shadow-sm"
|
className="shadow-sm"
|
||||||
disableImages={hideJunkAvatarImages}
|
disableImages={hideJunkAvatarImages}
|
||||||
|
checked={isChecked}
|
||||||
|
onToggle={toggleThreadSelection}
|
||||||
|
selectLabel={tBatch('select')}
|
||||||
/>
|
/>
|
||||||
{!isMobile && !isFocusedMailLayout && (
|
{!isMobile && !isFocusedMailLayout && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user