From 949c050eb033f60e7fb5e3f1a99387225800e4ce Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:13:49 +0100 Subject: [PATCH] fix: give the phone conversation view Archive, Delete, Report spam and Mark as unread --- .../thread-conversation-actions.test.tsx | 38 +++++++++++++ components/email/thread-conversation-view.tsx | 54 +++++++++++++++---- components/mail/mail-app.tsx | 27 ++++++++++ 3 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 components/email/__tests__/thread-conversation-actions.test.tsx diff --git a/components/email/__tests__/thread-conversation-actions.test.tsx b/components/email/__tests__/thread-conversation-actions.test.tsx new file mode 100644 index 000000000..9e69ba2dc --- /dev/null +++ b/components/email/__tests__/thread-conversation-actions.test.tsx @@ -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( + {}} + 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); + }); +}); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 4cde880b6..ff7dbcc6b 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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({
{/* Header */}
- + + +
-

+

{thread.latestEmail.subject || t("email_viewer.no_subject")}

{t("threads.messages_other", { count: emails.length })}

+
+ {onArchive && ( + + + + )} + {onDelete && ( + + + + )} + {onToggleSpam && ( + + {mailboxRole === 'junk' ? : } + + )} + {onMarkUnread && ( + + + + )} +
{/* Email Cards */} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index d4fca7719..fb7966083 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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} /> ) : ( <>