Merge branch 'feat/do-ux-p2-compose-drafts' into feat/do-ux-p2-compose-fix

This commit is contained in:
Linus Rath
2026-11-04 12:40:56 +01:00
8 changed files with 622 additions and 10 deletions
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest';
import { answersMessage, findReplyDraft, threadDrafts } from '@/lib/reply-drafts';
import type { Email } from '@/lib/jmap/types';
const message = { id: 'm1', messageId: ['abc@example.org'] as unknown as string };
const draft = (id: string, extra: Partial<Email> = {}) => ({
id, threadId: 't1', keywords: { $draft: true }, receivedAt: '2026-11-02T10:00:00Z', ...extra,
}) as Email;
describe('answersMessage', () => {
it('matches a draft whose In-Reply-To names the message, brackets or not', () => {
expect(answersMessage(draft('d1', { inReplyTo: ['<abc@example.org>'] }), message)).toBe(true);
expect(answersMessage(draft('d1', { inReplyTo: ['abc@example.org'] }), { messageId: 'abc@example.org' })).toBe(true);
});
it('leaves out a reply to another message, and a sent message', () => {
expect(answersMessage(draft('d1', { inReplyTo: ['other@example.org'] }), message)).toBe(false);
expect(answersMessage({ inReplyTo: ['abc@example.org'], keywords: {} }, message)).toBe(false);
});
});
describe('findReplyDraft', () => {
it('reads list rows without In-Reply-To in full and picks the newest that answers', async () => {
const full: Record<string, Email> = {
d1: draft('d1', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-01T10:00:00Z' }),
d2: draft('d2', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-03T10:00:00Z' }),
d3: draft('d3', { inReplyTo: ['other@example.org'], receivedAt: '2026-11-04T10:00:00Z' }),
};
const getEmail = vi.fn(async (id: string) => full[id] ?? null);
const rows = Object.values(full).map(({ inReplyTo: _drop, ...row }) => row as Email);
const found = await findReplyDraft(rows, message, getEmail);
expect(found?.id).toBe('d2');
expect(getEmail).toHaveBeenCalledWith('d3');
expect(getEmail).not.toHaveBeenCalledWith('d1');
});
it('finds nothing for a message without a Message-ID', async () => {
const getEmail = vi.fn();
expect(await findReplyDraft([draft('d1')], { id: 'm1' }, getEmail)).toBeNull();
expect(getEmail).not.toHaveBeenCalled();
});
});
describe('threadDrafts', () => {
it('returns the drafts of a conversation, and asks nothing more of a single message', async () => {
const client = {
getThread: vi.fn(async (id: string) => (id === 't1' ? { emailIds: ['m1', 'd1'] } : { emailIds: ['x'] })),
getSomeEmails: vi.fn(async () => [{ id: 'm1', keywords: {} }, draft('d1')] as Email[]),
};
expect((await threadDrafts(client, 't1', 'acc')).map((e) => e.id)).toEqual(['d1']);
expect(await threadDrafts(client, 't2', 'acc')).toEqual([]);
expect(client.getSomeEmails).toHaveBeenCalledTimes(1);
});
});
+58
View File
@@ -0,0 +1,58 @@
import type { Email } from '@/lib/jmap/types';
import { stripMessageIdBrackets } from '@/lib/email-threading';
/**
* A reply saved as a draft, found from the message it answers (compose-p2-6).
* The reader marks that message with it, and Reply opens it again instead of
* starting a second draft, as Gmail and Outlook do.
*/
/** The message's own Message-IDs, bare. JMAP sends a list; older code a string. */
function messageIdsOf(email: Pick<Email, 'messageId'>): string[] {
const raw = email.messageId as unknown;
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
return list.filter((id): id is string => typeof id === 'string').map(stripMessageIdBrackets).filter(Boolean);
}
/** Whether `draft` is a reply to `email` (its In-Reply-To names it). */
export function answersMessage(draft: Pick<Email, 'inReplyTo' | 'keywords'>, email: Pick<Email, 'messageId'>): boolean {
if (!draft.keywords?.$draft) return false;
const ids = messageIdsOf(email);
if (ids.length === 0) return false;
return (draft.inReplyTo ?? []).some((id) => ids.includes(stripMessageIdBrackets(id)));
}
const newestFirst = (a: Pick<Email, 'receivedAt'>, b: Pick<Email, 'receivedAt'>) =>
(b.receivedAt ?? '').localeCompare(a.receivedAt ?? '');
/**
* The newest of a conversation's drafts that answers `email`, in full. List
* rows carry no In-Reply-To, so a draft without it is read once more through
* `getEmail`; one that has it (a full fetch) is taken as it is.
*/
export async function findReplyDraft(
drafts: readonly Email[],
email: Pick<Email, 'id' | 'messageId'>,
getEmail: (id: string) => Promise<Email | null>,
): Promise<Email | null> {
if (messageIdsOf(email).length === 0) return null;
const candidates = drafts.filter((d) => d.id !== email.id && d.keywords?.$draft).sort(newestFirst);
for (const candidate of candidates) {
const full = candidate.inReplyTo !== undefined ? candidate : await getEmail(candidate.id).catch(() => null);
if (full && answersMessage(full, email)) return full;
}
return null;
}
/** A conversation's drafts, read fresh (after a composer closed or a draft went). */
export async function threadDrafts(
client: { getThread(threadId: string, accountId?: string): Promise<{ emailIds: string[] } | null>; getSomeEmails(ids: string[], accountId?: string): Promise<Email[]> },
threadId: string,
accountId: string | undefined,
): Promise<Email[]> {
const thread = await client.getThread(threadId, accountId);
const ids = thread?.emailIds ?? [];
if (ids.length < 2) return [];
const members = await client.getSomeEmails(ids, accountId);
return members.filter((e) => e.keywords?.$draft);
}