mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
fix: give the phone conversation view Archive, Delete, Report spam and Mark as unread
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user