From 9f64f8abb344632805f6567b874f8702ee98ce96 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:31:58 +0100 Subject: [PATCH] fix: hide reply and forward on drafts in a conversation --- src/components/email/ThreadMessageCard.tsx | 23 +++++++++----- src/lib/__tests__/thread-card-actions.test.ts | 31 +++++++++++++++++++ src/lib/__tests__/thread-reply-target.test.ts | 2 +- src/lib/thread-card-actions.ts | 19 ++++++++++++ src/screens/EmailThreadScreen.tsx | 5 +-- 5 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 src/lib/__tests__/thread-card-actions.test.ts create mode 100644 src/lib/thread-card-actions.ts diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index 95b5daf4..e0f220f1 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -13,6 +13,7 @@ import { singleLine } from '../../lib/single-line'; import { cleanPreview } from '../../lib/preview-text'; import { useSenderWarning } from './use-sender-warning'; import { useOwnDomainAddress } from '../../lib/use-own-domain-address'; +import type { CardReplyActions } from '../../lib/thread-card-actions'; interface Props extends MessageContentProps { expanded: boolean; @@ -24,8 +25,11 @@ interface Props extends MessageContentProps { * the card so the content survives a conversation arriving around it. */ bare?: boolean; - /** The full message is here (not only its header), so it can be replied to. */ - replyable?: boolean; + /** + * The reply row: hidden on a draft or scheduled message, disabled until + * the full message is here (lib/thread-card-actions). + */ + replyActions?: CardReplyActions; } /** @@ -34,7 +38,7 @@ interface Props extends MessageContentProps { * actions - the webmail's thread-conversation-view cards. */ export function ThreadMessageCard({ - expanded, onToggleExpanded, onReply, bare, replyable = true, ...content + expanded, onToggleExpanded, onReply, bare, replyActions = 'enabled', ...content }: Props) { const { email, onToggleStar } = content; const c = useColors(); @@ -71,24 +75,29 @@ export function ThreadMessageCard({ // The content keeps its place among the children whether bare or not, so // it (and its WebView) is not remounted when a conversation arrives. + const replyable = replyActions === 'enabled'; const reply = (mode: 'reply' | 'replyAll' | 'forward') => (replyable ? () => onReply(mode, email) : undefined); + const actionA11y = { + accessibilityRole: 'button' as const, + accessibilityState: { disabled: !replyable }, + }; return ( {!bare && ( )} - {!bare && ( + {!bare && replyActions !== 'hidden' && ( - + {t('email_viewer.reply', 'Reply')} - + {t('email_viewer.reply_all', 'Reply All')} - + {t('email_viewer.forward', 'Forward')} diff --git a/src/lib/__tests__/thread-card-actions.test.ts b/src/lib/__tests__/thread-card-actions.test.ts new file mode 100644 index 00000000..33c16db5 --- /dev/null +++ b/src/lib/__tests__/thread-card-actions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { cardReplyActions } from '../thread-card-actions'; + +// A draft in a conversation showed Reply / Reply all / Forward greyed out, +// announced to screen readers as enabled buttons. The webmail renders no +// reply buttons on a draft or a scheduled message; neither does the card. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); +const NONE = new Set(); + +describe('reply row of a conversation card', () => { + it('hides the row on a draft and on a scheduled message', () => { + expect(cardReplyActions({ id: 'd1', keywords: { $draft: true } }, true, NONE)).toBe('hidden'); + expect(cardReplyActions({ id: 'd1', keywords: { $draft: true } }, false, NONE)).toBe('hidden'); + expect(cardReplyActions({ id: 's1', keywords: {} }, true, new Set(['s1']))).toBe('hidden'); + }); + + it('shows it disabled while the body loads, then enabled', () => { + expect(cardReplyActions({ id: 'm1', keywords: { $seen: true } }, false, NONE)).toBe('disabled'); + expect(cardReplyActions({ id: 'm1', keywords: { $seen: true } }, true, NONE)).toBe('enabled'); + }); + + it('renders no row for a hidden state and marks disabled buttons for screen readers', () => { + const card = read('components/email/ThreadMessageCard.tsx'); + expect(card).toMatch(/\{!bare && replyActions !== 'hidden' && \(/); + expect(card).toMatch(/accessibilityState: \{ disabled: !replyable \}/); + expect(read('screens/EmailThreadScreen.tsx')).toMatch(/replyActions=\{cardReplyActions\(m, !!full, scheduledIds\)\}/); + }); +}); diff --git a/src/lib/__tests__/thread-reply-target.test.ts b/src/lib/__tests__/thread-reply-target.test.ts index e8e995a1..3dbdb231 100644 --- a/src/lib/__tests__/thread-reply-target.test.ts +++ b/src/lib/__tests__/thread-reply-target.test.ts @@ -33,7 +33,7 @@ describe('replying in a conversation (bulwarkmail/webmail#1404)', () => { 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(/replyActions=\{cardReplyActions\(m, !!full, scheduledIds\)\}/); expect(screen).toMatch(/const newest = quickReplyTarget\(/); expect(screen).toMatch(/scheduledEmailIdsInThread\(threadId, jmapAccountId\)/); }); diff --git a/src/lib/thread-card-actions.ts b/src/lib/thread-card-actions.ts new file mode 100644 index 00000000..7563ab98 --- /dev/null +++ b/src/lib/thread-card-actions.ts @@ -0,0 +1,19 @@ +// The Reply / Reply all / Forward row under an open message of a +// conversation. A draft or a scheduled message gets none (webmail +// email-viewer.tsx: the reply buttons render only `!isDraft && !isScheduled`); +// greyed-out buttons that a screen reader announced as enabled were left on +// drafts. A message whose body is still loading shows them disabled. + +import type { Email } from '../api/types'; +import { isReplyableMember } from './thread-utils'; + +export type CardReplyActions = 'hidden' | 'disabled' | 'enabled'; + +export function cardReplyActions( + email: Pick, + loaded: boolean, + scheduledIds: ReadonlySet, +): CardReplyActions { + if (!isReplyableMember(email, scheduledIds)) return 'hidden'; + return loaded ? 'enabled' : 'disabled'; +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 5eee6048..0771d07c 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -16,6 +16,7 @@ import { import { pickEmailBody } from '../lib/email-body'; import { confirmMailRemoval } from '../lib/mail-removal-confirm'; import { useTagUndoGroup } from '../lib/tag-undo-group'; +import { cardReplyActions } from '../lib/thread-card-actions'; import { NewTagRow } from '../components/NewTagRow'; import { QuickRuleSheet } from '../components/filters/QuickRuleSheet'; import { isSieveSupported } from '../api/sieve'; @@ -51,7 +52,7 @@ 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, isReplyableMember, quickReplyTarget } from '../lib/thread-utils'; +import { accountScopedId, 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'; @@ -1260,7 +1261,7 @@ function EmailPane({ fill={!asConversation} email={full ?? m} expanded={open} - replyable={!!full && isReplyableMember(m, scheduledIds)} + replyActions={cardReplyActions(m, !!full, scheduledIds)} deferBody={!full || !bodyEnabled} onBodySettled={onBodySettled} onToggleExpanded={() => toggleCard(m.id)}