diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts index ffb1a563..ea415837 100644 --- a/src/api/__tests__/live-stalwart-mail.test.ts +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -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); }); }); diff --git a/src/api/thread-scheduled.ts b/src/api/thread-scheduled.ts new file mode 100644 index 00000000..5d84762b --- /dev/null +++ b/src/api/thread-scheduled.ts @@ -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> { + 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(); + 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(); + } +} diff --git a/src/lib/__tests__/thread-reply-target.test.ts b/src/lib/__tests__/thread-reply-target.test.ts new file mode 100644 index 00000000..e8e995a1 --- /dev/null +++ b/src/lib/__tests__/thread-reply-target.test.ts @@ -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 = {}) => ({ id, keywords }); +const none = new Set(); + +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; + 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).mockReturnValue(false); + request.mockClear(); + expect((await scheduledEmailIdsInThread('t1')).size).toBe(0); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/thread-utils.ts b/src/lib/thread-utils.ts index 84a4529c..9a0c0981 100644 --- a/src/lib/thread-utils.ts +++ b/src/lib/thread-utils.ts @@ -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, + scheduledIds: ReadonlySet, +): 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>( + members: readonly T[], + scheduledIds: ReadonlySet, +): T | null { + for (let i = members.length - 1; i >= 0; i--) { + if (isReplyableMember(members[i], scheduledIds)) return members[i]; + } + return null; +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 80e77f70..4b0b166b 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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 = 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>(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)}