mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: show the whole conversation in a message window and answer the reader's keys there
This commit is contained in:
@@ -94,6 +94,13 @@ describe('a message opened from a bulwark:// link', () => {
|
||||
});
|
||||
|
||||
describe('PopoutMessageApp', () => {
|
||||
// pass-2 mail-p2-5: the window showed one message of a conversation.
|
||||
it('reads the message in its conversation, as the reading pane does', async () => {
|
||||
render(<PopoutMessageApp id="m/1" />);
|
||||
await settle();
|
||||
expect((view as unknown as { conversation?: boolean }).conversation).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the message of the address, through its login and account', async () => {
|
||||
render(<PopoutMessageApp id="rendered-for-another-id" />);
|
||||
await settle();
|
||||
|
||||
@@ -195,6 +195,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email
|
||||
liveState={liveState}
|
||||
onMailChanged={handleMailChanged}
|
||||
noAutomaticActions={fromLink}
|
||||
conversation
|
||||
className="min-h-0 w-full flex-1"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { act, fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* A message window (desktop app, installed app) showed only the message it was
|
||||
* opened with, and r, e, # and ? did nothing there: its shortcuts waited for a
|
||||
* Pro tab to be in front, and a window has no tabs (pass-2 mail-p2-5).
|
||||
*/
|
||||
|
||||
const viewerProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }));
|
||||
|
||||
vi.mock('@/components/email/email-viewer', () => ({
|
||||
EmailViewer: (props: Record<string, unknown>) => {
|
||||
viewerProps.current = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/error', () => ({
|
||||
ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
EmailViewerErrorFallback: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
||||
|
||||
vi.mock('@/lib/popout/window-role', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('@/lib/popout/window-role')>(),
|
||||
isPopoutWindow: () => true,
|
||||
}));
|
||||
vi.mock('@/components/email/move-to-dialog', () => ({ MoveToDialog: () => null }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
|
||||
const help = vi.hoisted(() => ({ open: false }));
|
||||
vi.mock('@/components/keyboard-shortcuts-modal', () => ({
|
||||
KeyboardShortcutsModal: ({ isOpen }: { isOpen: boolean }) => {
|
||||
help.open = isOpen;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function selectorStore<T extends object>(state: T) {
|
||||
const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
|
||||
hook.getState = () => state;
|
||||
return hook;
|
||||
}
|
||||
|
||||
vi.mock('@/stores/auth-store', () => ({
|
||||
useAuthStore: selectorStore({ client: null, activeAccountId: 'dso', getClientForAccount: () => undefined }),
|
||||
}));
|
||||
vi.mock('@/stores/email-store', () => ({
|
||||
resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client }),
|
||||
useEmailStore: selectorStore({
|
||||
fetchEmailContent: async () => null,
|
||||
deleteEmail: async () => {},
|
||||
markAsRead: async () => {},
|
||||
toggleStar: async () => {},
|
||||
moveToMailbox: async () => {},
|
||||
setEmailKeywordsLocal: () => {},
|
||||
mailboxes: [],
|
||||
emails: [],
|
||||
selectedEmail: null,
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/stores/identity-store', () => ({
|
||||
useIdentityStore: selectorStore({ identities: [{ id: 'b', email: 'dso@example.org', name: 'Dso' }] }),
|
||||
}));
|
||||
const settings = vi.hoisted(() => ({ state: { singleKeyShortcuts: true, disableThreading: false } }));
|
||||
vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore(settings.state) }));
|
||||
vi.mock('@/stores/pro-tab-store', () => ({
|
||||
useProTabStore: selectorStore({ openComposeTab: () => {}, closeTab: () => {}, updateTabTitle: () => {} }),
|
||||
}));
|
||||
|
||||
import { ProEmailView } from '../pro-email-tab-body';
|
||||
|
||||
const OLDER = {
|
||||
id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ email: 'aylin@example.org', name: 'Aylin' }], subject: 'Question about hiring plan',
|
||||
receivedAt: '2026-10-20T08:00:00Z', preview: 'First',
|
||||
};
|
||||
const NEWER = {
|
||||
...OLDER, id: 'm2', from: [{ email: 'robin@example.org', name: 'Robin' }], subject: 'Re: Question about hiring plan',
|
||||
receivedAt: '2026-10-21T08:00:00Z', preview: 'Second', keywords: {},
|
||||
};
|
||||
const BODY = {
|
||||
bodyValues: { '1': { value: 'Hi' } },
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '1', type: 'text/plain' }],
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
getEmail: vi.fn(async (id: string) => (id === 'm1' ? { ...OLDER, ...BODY } : { ...NEWER, ...BODY })),
|
||||
getThread: vi.fn(async () => ({ id: 't1', emailIds: ['m2', 'm1'] })),
|
||||
// Not in date order: the window sorts.
|
||||
getSomeEmails: vi.fn(async () => [NEWER, OLDER]),
|
||||
markAsRead: vi.fn(async () => {}),
|
||||
getUsername: () => 'dso@example.org',
|
||||
getAccountId: () => 'a',
|
||||
};
|
||||
}
|
||||
|
||||
describe('a message window', () => {
|
||||
beforeEach(() => {
|
||||
viewerProps.current = null;
|
||||
help.open = false;
|
||||
settings.state.disableThreading = false;
|
||||
});
|
||||
|
||||
async function open() {
|
||||
const client = makeClient();
|
||||
const onOpenCompose = vi.fn(async (_data: Record<string, unknown>) => true);
|
||||
render(<ProEmailView emailId="m1" client={client as never} clientAccountId="dso" onOpenCompose={onOpenCompose} onClose={vi.fn()} conversation />);
|
||||
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
|
||||
return { client, onOpenCompose };
|
||||
}
|
||||
|
||||
it('shows the whole conversation, oldest first', async () => {
|
||||
const { client } = await open();
|
||||
await waitFor(() => expect((viewerProps.current?.mvConversation as Array<{ id: string }> | undefined)?.map((e) => e.id)).toEqual(['m1', 'm2']));
|
||||
expect(viewerProps.current?.conversationPane).toBe(true);
|
||||
expect(client.getThread).toHaveBeenCalledWith('t1', 'a');
|
||||
});
|
||||
|
||||
it('keeps one message with "Show emails as individual messages"', async () => {
|
||||
settings.state.disableThreading = true;
|
||||
const { client } = await open();
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 5)); });
|
||||
expect(viewerProps.current?.mvConversation).toBeUndefined();
|
||||
expect(client.getThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replies to the message of the conversation whose button was pressed', async () => {
|
||||
const { onOpenCompose } = await open();
|
||||
await waitFor(() => expect(viewerProps.current?.mvConversation).toBeTruthy());
|
||||
await act(async () => {
|
||||
await (viewerProps.current!.onReplyConversationEmail as (e: unknown) => Promise<void>)(NEWER);
|
||||
});
|
||||
await waitFor(() => expect(onOpenCompose).toHaveBeenCalled());
|
||||
expect(onOpenCompose.mock.calls[0][0]).toMatchObject({ mode: 'reply', sourceEmailId: 'm2' });
|
||||
});
|
||||
|
||||
it('marks an unfolded unread message read on the server', async () => {
|
||||
const { client } = await open();
|
||||
await waitFor(() => expect(viewerProps.current?.mvConversation).toBeTruthy());
|
||||
await act(async () => {
|
||||
(viewerProps.current!.onMarkConversationEmailRead as (e: unknown) => void)(NEWER);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
});
|
||||
expect(client.markAsRead).toHaveBeenCalledWith('m2', true, 'a');
|
||||
});
|
||||
|
||||
it('answers r and ? with no Pro tab around it', async () => {
|
||||
const { onOpenCompose } = await open();
|
||||
fireEvent.keyDown(document.body, { key: 'r', code: 'KeyR' });
|
||||
await waitFor(() => expect(onOpenCompose).toHaveBeenCalledTimes(1));
|
||||
expect(onOpenCompose.mock.calls[0][0]).toMatchObject({ mode: 'reply', sourceEmailId: 'm1' });
|
||||
fireEvent.keyDown(document.body, { key: '?', code: 'Slash', shiftKey: true });
|
||||
await waitFor(() => expect(help.open).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -41,10 +41,13 @@ import { isFilePreviewable } from '@/lib/file-preview';
|
||||
import { useDesktopCommands } from '@/lib/desktop/commands';
|
||||
import { isPopoutWindow } from '@/lib/popout/window-role';
|
||||
import { offerMoveUndo } from '@/lib/popout/moved-undo';
|
||||
import { useViewConversation } from './use-view-conversation';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Only needed when the device policy turns downloads off.
|
||||
const FilePreviewModal = dynamic(() => import('@/components/files/file-preview-modal').then((m) => m.FilePreviewModal), { ssr: false });
|
||||
// Only a message window opens the shortcuts help from here.
|
||||
const KeyboardShortcutsModal = dynamic(() => import('@/components/keyboard-shortcuts-modal').then((m) => m.KeyboardShortcutsModal), { ssr: false });
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -86,6 +89,11 @@ interface ProEmailViewProps {
|
||||
onMailChanged?: () => void;
|
||||
/** See EmailViewer. */
|
||||
noAutomaticActions?: boolean;
|
||||
/**
|
||||
* Show the message inside its conversation, as the mail page's reading
|
||||
* pane does (a message window). "Show emails as individual messages" keeps one.
|
||||
*/
|
||||
conversation?: boolean;
|
||||
/**
|
||||
* The message left this view: deleted, archived, moved, or reopened in the
|
||||
* composer. The host decides what that means (close the tab / clear the
|
||||
@@ -152,7 +160,7 @@ function buildReplyContext(email: Email, source: EmailWriteSource): ProReplyCont
|
||||
* Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's
|
||||
* reading pane.
|
||||
*/
|
||||
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, onClose, className }: ProEmailViewProps) {
|
||||
export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, conversation, onClose, className }: ProEmailViewProps) {
|
||||
const t = useTranslations();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tRemove = useTranslations('email_list.confirm_remove');
|
||||
@@ -295,17 +303,21 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
return mb?.role;
|
||||
}, [email, mailboxes]);
|
||||
|
||||
const handleReply = useCallback((draftText?: string) => {
|
||||
if (!email) return;
|
||||
const replyToMessage = useCallback((target: Email, draftText?: string) => {
|
||||
openComposeTab({
|
||||
mode: 'reply',
|
||||
replyTo: buildReplyContext(email, writeSource),
|
||||
sourceEmailId: email.id,
|
||||
replyTo: buildReplyContext(target, writeSource),
|
||||
sourceEmailId: target.id,
|
||||
sourceEmailSource: writeSource,
|
||||
initialDraftText: draftText,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
}, { source: email, body: draftText ?? '' });
|
||||
}, [email, openComposeTab, t, writeSource]);
|
||||
title: buildReplySubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
}, { source: target, body: draftText ?? '' });
|
||||
}, [openComposeTab, t, writeSource]);
|
||||
|
||||
const handleReply = useCallback((draftText?: string) => {
|
||||
if (!email) return;
|
||||
replyToMessage(email, draftText);
|
||||
}, [email, replyToMessage]);
|
||||
|
||||
// A notification's Reply, once this tab has the message (mail-app.tsx).
|
||||
const pendingTabReply = useNotificationTabReply((s) => s.pending);
|
||||
@@ -315,16 +327,25 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
if (text !== null) handleReply(text);
|
||||
}, [email, pendingTabReply, handleReply]);
|
||||
|
||||
const handleReplyAll = useCallback(() => {
|
||||
if (!email) return;
|
||||
const replyAllToMessage = useCallback((target: Email) => {
|
||||
openComposeTab({
|
||||
mode: 'replyAll',
|
||||
replyTo: buildReplyContext(email, writeSource),
|
||||
sourceEmailId: email.id,
|
||||
replyTo: buildReplyContext(target, writeSource),
|
||||
sourceEmailId: target.id,
|
||||
sourceEmailSource: writeSource,
|
||||
title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
}, { source: email });
|
||||
}, [email, openComposeTab, t, writeSource]);
|
||||
title: buildReplySubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
|
||||
}, { source: target });
|
||||
}, [openComposeTab, t, writeSource]);
|
||||
|
||||
const handleReplyAll = useCallback(() => {
|
||||
if (!email) return;
|
||||
replyAllToMessage(email);
|
||||
}, [email, replyAllToMessage]);
|
||||
|
||||
// A message window reads the message in its conversation (mail-p2-5).
|
||||
const disableThreading = useSettingsStore((s) => s.disableThreading);
|
||||
const conversationView = !!conversation && !disableThreading;
|
||||
const viewConversation = useViewConversation({ enabled: conversationView, email, source: blobSource, onChanged: onMailChanged });
|
||||
|
||||
const handleForward = useCallback(() => {
|
||||
if (!email) return;
|
||||
@@ -521,6 +542,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
// This view's message answers the same keys as a message in the mail tab,
|
||||
// while its tab is the one in front. c comes from the mail page, as on any tab.
|
||||
const isFocusedTab = useIsFocusedProTab();
|
||||
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
|
||||
const shortcutEmails = useMemo(() => (email ? [email] : []), [email]);
|
||||
const shortcutHandlers = useMemo(() => ({
|
||||
@@ -532,9 +554,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
onDelete: () => void handleDelete(),
|
||||
onMarkAsUnread: email ? () => void handleMarkAsRead(email.id, false) : undefined,
|
||||
onMarkAsRead: email ? () => void handleMarkAsRead(email.id, true) : undefined,
|
||||
}), [email, handleReply, handleReplyAll, handleForward, handleToggleStar, archiveActionEnabled, handleArchive, handleDelete, handleMarkAsRead]);
|
||||
// A message window has no mail page behind it to answer ?.
|
||||
onShowHelp: inOwnWindow ? () => setShowShortcuts(true) : undefined,
|
||||
}), [email, handleReply, handleReplyAll, handleForward, handleToggleStar, archiveActionEnabled, handleArchive, handleDelete, handleMarkAsRead, inOwnWindow]);
|
||||
// A Pro tab answers while it is the one in front; a message window is
|
||||
// always in front of itself (it has no tabs).
|
||||
useKeyboardShortcuts({
|
||||
enabled: isFocusedTab && !!email,
|
||||
enabled: (isFocusedTab || inOwnWindow) && !!email && !showShortcuts,
|
||||
singleKeyShortcuts,
|
||||
emails: shortcutEmails,
|
||||
selectedEmailId: email?.id,
|
||||
@@ -633,6 +659,20 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
<EmailViewer
|
||||
noAutomaticActions={noAutomaticActions}
|
||||
email={email}
|
||||
mvConversation={conversationView ? viewConversation.emails : undefined}
|
||||
conversationPane={conversationView}
|
||||
mvConversationPending={conversationView && viewConversation.pending}
|
||||
onLoadConversationEmail={conversationView ? (e) => void viewConversation.loadBody(e) : undefined}
|
||||
onMarkConversationEmailRead={conversationView ? (e) => void viewConversation.markRead(e) : undefined}
|
||||
onReplyConversationEmail={conversationView ? async (e) => {
|
||||
// The reply quotes the message, so it needs the body.
|
||||
const full = e.bodyValues ? e : await viewConversation.loadBody(e);
|
||||
replyToMessage(full ?? e);
|
||||
} : undefined}
|
||||
onReplyAllConversationEmail={conversationView ? async (e) => {
|
||||
const full = e.bodyValues ? e : await viewConversation.loadBody(e);
|
||||
replyAllToMessage(full ?? e);
|
||||
} : undefined}
|
||||
blobSource={blobSource}
|
||||
isLoading={isLoading}
|
||||
onReply={handleReply}
|
||||
@@ -659,6 +699,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
{inOwnWindow && <KeyboardShortcutsModal isOpen={showShortcuts} onClose={() => setShowShortcuts(false)} />}
|
||||
{inOwnWindow && (
|
||||
<MoveToDialog
|
||||
emails={moveDialogOpen && email ? [email] : []}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import type { EmailBlobContext } from "@/stores/email-store";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
|
||||
export interface ViewConversation {
|
||||
/** Every message of the open message's conversation, oldest first; undefined while there is none. */
|
||||
emails: Email[] | undefined;
|
||||
/** The conversation is on its way. */
|
||||
pending: boolean;
|
||||
/** Fetches a folded message's body when it unfolds. */
|
||||
loadBody: (target: Email) => Promise<Email | null>;
|
||||
/** An unfolded message was read. */
|
||||
markRead: (target: Email) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation around a message read outside the mail page (a message
|
||||
* window), fetched the way the mail page's reading pane fetches it: the
|
||||
* thread's ids, then their list properties, drafts left out except the open
|
||||
* one, oldest first. A conversation of one message is none.
|
||||
*/
|
||||
export function useViewConversation({ enabled, email, source, onChanged }: {
|
||||
enabled: boolean;
|
||||
email: Email | null;
|
||||
source: EmailBlobContext;
|
||||
onChanged?: () => void;
|
||||
}): ViewConversation {
|
||||
const threadId = enabled ? email?.threadId ?? null : null;
|
||||
const openId = email?.id ?? null;
|
||||
const [result, setResult] = useState<{ threadId: string; emails: Email[] } | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const seqRef = useRef(0);
|
||||
const onChangedRef = useRef(onChanged);
|
||||
onChangedRef.current = onChanged;
|
||||
|
||||
useEffect(() => {
|
||||
const seq = ++seqRef.current;
|
||||
const client = source.client;
|
||||
if (!threadId || !client) {
|
||||
setResult(null);
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
void (async () => {
|
||||
const thread = await client.getThread(threadId, source.accountId);
|
||||
const ids = thread?.emailIds ?? [];
|
||||
if (ids.length < 2) return null;
|
||||
const emails = await client.getSomeEmails(ids, source.accountId);
|
||||
return emails
|
||||
.filter((e) => e.id === openId || !e.keywords?.$draft)
|
||||
.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
|
||||
})().catch(() => null).then((emails) => {
|
||||
if (seq !== seqRef.current) return;
|
||||
setResult(emails && emails.length > 1 ? { threadId, emails } : null);
|
||||
setPending(false);
|
||||
});
|
||||
}, [threadId, openId, source.client, source.accountId]);
|
||||
|
||||
const patch = useCallback((id: string, change: (e: Email) => Email) => {
|
||||
setResult((prev) => (prev ? { ...prev, emails: prev.emails.map((e) => (e.id === id ? change(e) : e)) } : prev));
|
||||
}, []);
|
||||
|
||||
const loadBody = useCallback(async (target: Email) => {
|
||||
const client = source.client;
|
||||
if (!client) return null;
|
||||
const seq = seqRef.current;
|
||||
const full = await client.getEmail(target.id, source.accountId).catch(() => null);
|
||||
if (!full || seq !== seqRef.current) return null;
|
||||
patch(target.id, (e) => ({ ...full, keywords: e.keywords }));
|
||||
return full;
|
||||
}, [source.client, source.accountId, patch]);
|
||||
|
||||
const markRead = useCallback(async (target: Email) => {
|
||||
const client = source.client;
|
||||
if (!client || target.keywords?.$seen) return;
|
||||
try {
|
||||
if (emailHooks.onBeforeEmailReadStateChange.size > 0
|
||||
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [target.id], read: true }))) {
|
||||
return;
|
||||
}
|
||||
await client.markAsRead(target.id, true, source.accountId);
|
||||
void emailHooks.onEmailReadStateChange.emit({ emailIds: [target.id], read: true });
|
||||
patch(target.id, (e) => ({ ...e, keywords: { ...e.keywords, $seen: true } }));
|
||||
onChangedRef.current?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to mark conversation message read:", error);
|
||||
}
|
||||
}, [source.client, source.accountId, patch]);
|
||||
|
||||
return {
|
||||
emails: result && result.threadId === threadId ? result.emails : undefined,
|
||||
pending: pending && !!threadId,
|
||||
loadBody,
|
||||
markRead,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user