fix: hide reply on drafts and scheduled messages in a conversation and quick reply to the newest real message (bulwarkmail/webmail#1404)

This commit is contained in:
Linus Rath
2026-10-31 03:59:16 +01:00
parent 9794dc03f7
commit 352973793f
5 changed files with 175 additions and 3 deletions
@@ -40,6 +40,7 @@ import {
queryEmailPage,
} from '../email';
import { senderWarningOf } from '../../lib/email-headers';
import { scheduledEmailIdsInThread } from '../thread-scheduled';
import { uploadBytes } from '../blob';
import { getIdentities, updateIdentity } from '../identity';
import { isJMAPSetError } from '../jmap-result';
@@ -447,5 +448,23 @@ live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => {
const [full] = await getFullEmails([id]);
expect(senderWarningOf(full)?.status).toBe('failed');
}, 60_000);
it("finds a conversation's message that waits for a scheduled send (bulwarkmail/webmail#1404)", async () => {
const [identity] = await getIdentities();
const result = await sendEmail(
{ from: [{ email: USER }], to: [{ email: USER }], subject: `scheduled reply ${mailTag}`, textBody: 'live' },
identity.id, sent, 3600, { draftsMailboxId: drafts },
);
try {
const [filed] = await getFullEmails([result.emailId!]);
const ids = await scheduledEmailIdsInThread(filed.threadId);
expect([...ids]).toEqual([result.emailId]);
} finally {
await cancelScheduledSend(result.emailSubmissionId!).catch(() => undefined);
}
// Once cancelled it can be replied to again.
const [filed] = await getFullEmails([result.emailId!]);
expect((await scheduledEmailIdsInThread(filed.threadId)).size).toBe(0);
}, 90_000);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { jmapClient } from './jmap-client';
import { SUBMISSION_ONLY_USING } from './submissions';
/**
* Ids of a conversation's messages that wait for a scheduled send: a pending
* EmailSubmission whose send time is still ahead. The conversation hides
* Reply on them and points the quick reply past them, as it does for drafts
* (webmail 8b29254a6, #1404). Empty when the account can't schedule or the
* server can't answer: the cards then stay as they were.
*/
export async function scheduledEmailIdsInThread(threadId: string, accountId?: string): Promise<Set<string>> {
const account = accountId ?? jmapClient.accountId;
if (!account || !jmapClient.hasDelayedSend(account)) return new Set();
try {
const res = await jmapClient.request([
['EmailSubmission/query', { accountId: account, filter: { threadIds: [threadId], undoStatus: 'pending' } }, '0'],
['EmailSubmission/get', {
accountId: account,
'#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
properties: ['emailId', 'sendAt', 'undoStatus'],
}, '1'],
], SUBMISSION_ONLY_USING);
const [name, body] = res.methodResponses?.[1] ?? [];
if (name !== 'EmailSubmission/get') return new Set();
const now = Date.now();
const ids = new Set<string>();
for (const s of (body.list as Array<{ emailId?: string; sendAt?: string; undoStatus?: string }>) ?? []) {
if (!s.emailId || s.undoStatus !== 'pending') continue;
const at = s.sendAt ? new Date(s.sendAt).getTime() : NaN;
if (Number.isFinite(at) && at > now) ids.add(s.emailId);
}
return ids;
} catch {
return new Set();
}
}
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
vi.mock('../../api/jmap-client', () => ({
jmapClient: { accountId: 'acc-1', request: vi.fn(), hasDelayedSend: vi.fn(() => true) },
}));
import { jmapClient } from '../../api/jmap-client';
import { scheduledEmailIdsInThread } from '../../api/thread-scheduled';
import { isReplyableMember, quickReplyTarget } from '../thread-utils';
// Every card of a conversation offered Reply, and the quick reply answered
// the newest member even when that was the user's own draft or a reply
// waiting for its scheduled send (webmail 8b29254a6, #1404).
const msg = (id: string, keywords: Record<string, boolean> = {}) => ({ id, keywords });
const none = new Set<string>();
describe('replying in a conversation (bulwarkmail/webmail#1404)', () => {
it('does not offer Reply on drafts and scheduled messages', () => {
expect(isReplyableMember(msg('a'), none)).toBe(true);
expect(isReplyableMember(msg('d', { $draft: true }), none)).toBe(false);
expect(isReplyableMember(msg('s'), new Set(['s']))).toBe(false);
});
it('points the quick reply at the newest message that is neither', () => {
const conversation = [msg('1'), msg('2'), msg('3'), msg('draft', { $draft: true })];
expect(quickReplyTarget(conversation, none)?.id).toBe('3');
expect(quickReplyTarget(conversation, new Set(['3']))?.id).toBe('2');
expect(quickReplyTarget([msg('d', { $draft: true })], none)).toBeNull();
});
it('is wired into the conversation cards and the quick reply', () => {
const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailThreadScreen.tsx'), 'utf8');
expect(screen).toMatch(/replyable=\{!!full && isReplyableMember\(m, scheduledIds\)\}/);
expect(screen).toMatch(/const newest = quickReplyTarget\(/);
expect(screen).toMatch(/scheduledEmailIdsInThread\(threadId, jmapAccountId\)/);
});
});
describe('scheduledEmailIdsInThread', () => {
const request = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => request.mockReset());
it('keeps pending submissions whose send time is ahead', async () => {
const future = new Date(Date.now() + 3_600_000).toISOString();
const past = new Date(Date.now() - 60_000).toISOString();
request.mockResolvedValue({
methodResponses: [
['EmailSubmission/query', { ids: ['s1', 's2', 's3'] }, '0'],
['EmailSubmission/get', { list: [
{ emailId: 'e1', sendAt: future, undoStatus: 'pending' },
{ emailId: 'e2', sendAt: past, undoStatus: 'pending' },
{ emailId: 'e3', sendAt: future, undoStatus: 'canceled' },
] }, '1'],
],
});
expect([...await scheduledEmailIdsInThread('t1')]).toEqual(['e1']);
const [[calls]] = request.mock.calls;
expect(calls[0][1].filter).toEqual({ threadIds: ['t1'], undoStatus: 'pending' });
});
it('answers with nothing when the server refuses or the account cannot schedule', async () => {
request.mockResolvedValue({ methodResponses: [['error', { type: 'unsupportedFilter' }, '0'], ['error', {}, '1']] });
expect((await scheduledEmailIdsInThread('t1')).size).toBe(0);
request.mockImplementationOnce(async () => { throw new Error('offline'); });
expect((await scheduledEmailIdsInThread('t1')).size).toBe(0);
(jmapClient.hasDelayedSend as ReturnType<typeof vi.fn>).mockReturnValue(false);
request.mockClear();
expect((await scheduledEmailIdsInThread('t1')).size).toBe(0);
expect(request).not.toHaveBeenCalled();
});
});
+27
View File
@@ -178,3 +178,30 @@ export function expandThreadSelection(
}
return out;
}
/**
* Whether a conversation member can be replied to: not the user's own draft
* and not a message waiting for its scheduled send (webmail 8b29254a6,
* #1404).
*/
export function isReplyableMember(
email: Pick<Email, 'id' | 'keywords'>,
scheduledIds: ReadonlySet<string>,
): boolean {
return !email.keywords?.$draft && !scheduledIds.has(email.id);
}
/**
* The message the quick reply answers: the newest member that can be
* replied to, so it never quotes the user's own draft or scheduled reply.
* Null when there is none.
*/
export function quickReplyTarget<T extends Pick<Email, 'id' | 'keywords'>>(
members: readonly T[],
scheduledIds: ReadonlySet<string>,
): T | null {
for (let i = members.length - 1; i >= 0; i--) {
if (isReplyableMember(members[i], scheduledIds)) return members[i];
}
return null;
}
+19 -3
View File
@@ -49,7 +49,8 @@ import { replyComposeParams } from '../lib/reply-compose';
import { singleLine } from '../lib/single-line';
import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment';
import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer-pages';
import { accountScopedId } from '../lib/thread-utils';
import { accountScopedId, isReplyableMember, quickReplyTarget } from '../lib/thread-utils';
import { scheduledEmailIdsInThread } from '../api/thread-scheduled';
import type { Email, EmailAddress, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
@@ -1013,6 +1014,8 @@ interface EmailPaneProps {
onZoomChange: (zoom: { pinching: boolean; zoomed: boolean }) => void;
}
const NO_IDS: ReadonlySet<string> = new Set();
// One swipeable page: the subject plus either a single message or the whole
// conversation as collapsible cards (newest + unread expanded, mark-read on
// expand). The pager keeps three of these mounted (prev, current, next) so a
@@ -1070,6 +1073,19 @@ function EmailPane({
React.useEffect(() => () => { readTimers.forEach((cancel) => cancel()); readTimers.clear(); }, [readTimers]);
// Members waiting for a scheduled send: no Reply on them, and the quick
// reply answers the newest message that is neither one nor a draft (#1404).
const [scheduledIds, setScheduledIds] = React.useState<ReadonlySet<string>>(NO_IDS);
const isConversation = !!threadIds && threadIds.length > 1;
React.useEffect(() => {
if (!active || !threading || !threadId || !isConversation) return undefined;
let cancelled = false;
void scheduledEmailIdsInThread(threadId, jmapAccountId).then((ids) => {
if (!cancelled) setScheduledIds(ids.size > 0 ? ids : NO_IDS);
});
return () => { cancelled = true; };
}, [active, threading, threadId, isConversation, jmapAccountId]);
// Keeps the quick reply in view while the keyboard opens. Above the
// skeleton's early return: a page opened by id alone (a notification)
// renders the skeleton first, and a hook after it crashed the app.
@@ -1149,7 +1165,7 @@ function EmailPane({
// and WebView stay mounted when a conversation arrives around it.
const members = conversation ?? [shown];
const firstOpenId = conversation?.find((m) => expanded?.has(m.id) ?? m.id === id)?.id;
const newest = conversation ? conversation[conversation.length - 1] : email;
const newest = quickReplyTarget(conversation ?? (email ? [email] : []), scheduledIds);
// The quick reply quotes the message, so it waits for the newest one's body.
const newestLoaded = !!newest && !!peekDetail(newest.id, jmapAccountId);
const onPaneZoom = (z: { pinching: boolean; zoomed: boolean }) => {
@@ -1237,7 +1253,7 @@ function EmailPane({
fill={!asConversation}
email={full ?? m}
expanded={open}
replyable={!!full}
replyable={!!full && isReplyableMember(m, scheduledIds)}
deferBody={!full || !bodyEnabled}
onBodySettled={onBodySettled}
onToggleExpanded={() => toggleCard(m.id)}