fix: hide reply and forward on drafts in a conversation

This commit is contained in:
Linus Rath
2026-10-31 13:52:38 +01:00
parent 9763036c70
commit 9f64f8abb3
5 changed files with 70 additions and 10 deletions
+16 -7
View File
@@ -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 (
<View style={bare ? (content.fill ? styles.bareFill : undefined) : styles.expanded}>
{!bare && (
<Pressable onPress={onToggleExpanded} style={styles.collapseHandle} hitSlop={6} accessibilityLabel={t('threads.collapse', 'Collapse conversation')} />
)}
<MessageContent {...content} compact={false} onToggleStar={bare ? undefined : onToggleStar} />
{!bare && (
{!bare && replyActions !== 'hidden' && (
<View style={[styles.actions, !replyable && styles.actionsDisabled]}>
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={6}>
<Pressable style={styles.actionBtn} onPress={reply('reply')} disabled={!replyable} hitSlop={6} {...actionA11y}>
<Reply size={16} color={c.text} />
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={6}>
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} disabled={!replyable} hitSlop={6} {...actionA11y}>
<ReplyAll size={16} color={c.text} />
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
</Pressable>
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={6}>
<Pressable style={styles.actionBtn} onPress={reply('forward')} disabled={!replyable} hitSlop={6} {...actionA11y}>
<Forward size={16} color={c.text} />
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
</Pressable>
@@ -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<string>();
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\)\}/);
});
});
@@ -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\)/);
});
+19
View File
@@ -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<Email, 'id' | 'keywords'>,
loaded: boolean,
scheduledIds: ReadonlySet<string>,
): CardReplyActions {
if (!isReplyableMember(email, scheduledIds)) return 'hidden';
return loaded ? 'enabled' : 'disabled';
}
+3 -2
View File
@@ -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)}