fix: give the phone conversation view Archive, Delete, Report spam and Mark as unread

This commit is contained in:
Linus Rath
2026-10-28 05:16:08 +01:00
parent 6b3eda6d79
commit 949c050eb0
3 changed files with 110 additions and 9 deletions
@@ -0,0 +1,38 @@
// The phone conversation view had Back and per-message Reply only: deleting or
// archiving a conversation meant going back and long-pressing its row. Its
// header now has the row's actions.
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { ThreadConversationView } = await import('@/components/email/thread-conversation-view');
const email = {
id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true },
from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }],
subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hi', hasAttachment: false,
htmlBody: [], textBody: [], attachments: [], bodyValues: {},
};
describe('phone conversation header', () => {
it('archives, deletes, reports spam and marks unread', () => {
const handlers = { onArchive: vi.fn(), onDelete: vi.fn(), onToggleSpam: vi.fn(), onMarkUnread: vi.fn() };
render(
<ThreadConversationView
thread={{ threadId: 't1', emails: [email], latestEmail: email } as never}
emails={[email] as never}
onBack={() => {}}
mailboxRole="inbox"
{...handlers}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'context_menu.archive' }));
fireEvent.click(screen.getByRole('button', { name: 'context_menu.delete' }));
fireEvent.click(screen.getByRole('button', { name: 'context_menu.mark_as_spam' }));
fireEvent.click(screen.getByRole('button', { name: 'context_menu.mark_unread' }));
for (const handler of Object.values(handlers)) expect(handler).toHaveBeenCalledTimes(1);
});
});
+45 -9
View File
@@ -20,7 +20,13 @@ import { useShallow } from "zustand/react/shallow";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils";
import { IconButton } from "@/components/ui/icon-button";
import {
Archive,
Mail,
ShieldAlert,
ShieldCheck,
Trash2,
ArrowLeft,
ChevronDown,
ChevronUp,
@@ -75,6 +81,15 @@ interface ThreadConversationViewProps {
onMarkAsRead?: (emailId: string, read: boolean) => void;
/** Role of the folder the conversation was opened from, for plugin actions limited to some folders. */
mailboxRole?: string | null;
/**
* The conversation's actions in the header, as the list row offers them
* (on the row's newest message): Archive, Delete, Report spam / Not spam,
* Mark as unread.
*/
onArchive?: () => void;
onDelete?: () => void;
onToggleSpam?: () => void;
onMarkUnread?: () => void;
}
// Helper function to get file icon based on mime type or extension
@@ -191,6 +206,10 @@ export function ThreadConversationView({
blobSource,
onMarkAsRead,
mailboxRole,
onArchive,
onDelete,
onToggleSpam,
onMarkUnread,
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContent = useConversationExternalContent();
@@ -252,22 +271,39 @@ export function ThreadConversationView({
<div className="flex flex-col h-full bg-background">
{/* Header */}
<div className="flex items-center px-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-header-py)' }}>
<button
onClick={onBack}
className="p-2 -ms-2 rounded-full hover:bg-muted transition-colors"
aria-label={t('email_viewer.back_to_list')}
title={t('email_viewer.back_to_list')}
>
<ArrowLeft className="w-5 h-5" />
</button>
<IconButton size="icon-lg" className="-ms-2 flex-shrink-0" onClick={onBack} label={t('email_viewer.back_to_list')}>
<ArrowLeft />
</IconButton>
<div className="flex-1 min-w-0">
<h1 className="font-semibold text-foreground break-words">
<h1 className="font-semibold text-foreground break-words" dir="auto">
{thread.latestEmail.subject || t("email_viewer.no_subject")}
</h1>
<p className="text-sm text-muted-foreground">
{t("threads.messages_other", { count: emails.length })}
</p>
</div>
<div className="flex items-center flex-shrink-0 -me-2" data-testid="conversation-actions">
{onArchive && (
<IconButton size="icon-lg" onClick={onArchive} label={t('context_menu.archive')}>
<Archive />
</IconButton>
)}
{onDelete && (
<IconButton size="icon-lg" onClick={onDelete} label={t('context_menu.delete')}>
<Trash2 />
</IconButton>
)}
{onToggleSpam && (
<IconButton size="icon-lg" onClick={onToggleSpam} label={mailboxRole === 'junk' ? t('context_menu.not_spam') : t('context_menu.mark_as_spam')}>
{mailboxRole === 'junk' ? <ShieldCheck /> : <ShieldAlert />}
</IconButton>
)}
{onMarkUnread && (
<IconButton size="icon-lg" onClick={onMarkUnread} label={t('context_menu.mark_unread')}>
<Mail />
</IconButton>
)}
</div>
</div>
{/* Email Cards */}
+27
View File
@@ -1905,6 +1905,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// back once there is nothing left to read, so tablet never ends up on an
// empty reading pane with only the bottom bar.
const readingPaneEmpty = !showComposer && !conversationThread && !selectedEmail && !isLoadingEmail;
// The phone conversation's Report spam / Not spam, as in the list: not on
// your own outgoing mail.
const conversationRole = mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const conversationInJunk = conversationRole === 'junk';
const conversationSpamApplicable = !['sent', 'drafts', 'scheduled'].includes(conversationRole || '');
useEffect(() => {
if (readingPaneEmpty && !tabletListVisible) setTabletListVisible(true);
}, [readingPaneEmpty, tabletListVisible, setTabletListVisible]);
@@ -5398,6 +5403,28 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}}
mailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)}
// What the list row does with the conversation, then back to
// the list, which now shows what is left.
onArchive={archiveActionEnabled ? () => {
const target = conversationThread.latestEmail;
handleMobileBack();
void handleArchive(target);
} : undefined}
onDelete={() => {
const target = conversationThread.latestEmail;
handleMobileBack();
void handleDelete(target);
}}
onToggleSpam={conversationSpamApplicable ? () => {
const target = conversationThread.latestEmail;
handleMobileBack();
void (conversationInJunk ? handleUndoSpam(target) : handleMarkAsSpam(target));
} : undefined}
onMarkUnread={client ? () => {
const target = conversationThread.latestEmail;
handleMobileBack();
void markAsRead(client, target.id, false, emailKeyFor(target)).catch(toastUpdateFailure);
} : undefined}
/>
) : (
<>