mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user