diff --git a/locales/rn/en.json b/locales/rn/en.json index 4eb02d66..b96147cb 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1,9 +1,12 @@ { "advanced_search": { + "advanced_filters_count": "{name}, {count} active", "after": "After", "before": "Before", + "larger_than_kb": "Larger than {size} KB", "no_attachment": "No attachment", - "not_starred": "Not starred" + "not_starred": "Not starred", + "smaller_than_kb": "Smaller than {size} KB" }, "calendar": { "a11y": { diff --git a/src/components/PushOnboardingPrompt.tsx b/src/components/PushOnboardingPrompt.tsx index f6bbd171..71dd4091 100644 --- a/src/components/PushOnboardingPrompt.tsx +++ b/src/components/PushOnboardingPrompt.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useFloatingCardFrame } from '../lib/use-layout'; +import { useFloatingCardFrame, useLayout } from '../lib/use-layout'; +import { pushPromptBottom } from '../lib/push-prompt-frame'; import { BellRing, X } from './icons'; import Button from './Button'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; @@ -52,6 +53,8 @@ export function PushOnboardingPrompt(): React.ReactElement | null { const insets = useSafeAreaInsets(); // Full width above the tab bar on a phone; bottom right on a tablet. const frame = useFloatingCardFrame(); + // Above the reading pane's bottom bar on a split view. + const { isSplit } = useLayout(); const duration = useAnimDuration(200); const appear = React.useRef(new Animated.Value(0)).current; @@ -118,7 +121,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null { style={[ styles.wrap, { - left: frame.left, right: frame.right, bottom: insets.bottom + frame.bottom, + left: frame.left, right: frame.right, bottom: insets.bottom + pushPromptBottom(frame.bottom, isSplit), opacity: appear, transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }], }, diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 006c4eca..972f9fb0 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -137,6 +137,19 @@ const RichTextEditor = React.forwardRef(function Ri // posts its first message, then replay in order. const readyRef = React.useRef(false); const queuedRef = React.useRef([]); + // Focusing the editor in the page only moves the caret there. Android + // opens the keyboard, and takes key focus off the composer's first button + // (Close: a hardware Space shut the composer), only for a WebView that holds + // native focus. iOS has keyboardDisplayRequiresUserAction for this. + const wantsNativeFocusRef = React.useRef(false); + const requestNativeFocus = React.useCallback(() => { + if (Platform.OS !== 'android') return; + if (!readyRef.current) { + wantsNativeFocusRef.current = true; + return; + } + webViewRef.current?.requestFocus(); + }, []); const call = React.useCallback((script: string) => { if (!readyRef.current) { queuedRef.current.push(script); @@ -203,15 +216,17 @@ const RichTextEditor = React.forwardRef(function Ri call(`window.__rne && window.__rne.getHtml(${id})`); }), focusEnd: () => { + requestNativeFocus(); call(`window.__rne && window.__rne.focusEnd()`); }, focus: () => { + requestNativeFocus(); call(`window.__rne && window.__rne.focus()`); }, blur: () => { call(`document.activeElement && document.activeElement.blur()`); }, - }), [call]); + }), [call, requestNativeFocus]); const onMessage = (event: WebViewMessageEvent) => { let data: { type: string; payload: unknown }; @@ -225,6 +240,12 @@ const RichTextEditor = React.forwardRef(function Ri perfEndAfterPaint('compose-open'); const queued = queuedRef.current; queuedRef.current = []; + // A focus asked for before the page was up (a reply opening) gets the + // view's native focus first, then the caret from the queued script. + if (wantsNativeFocusRef.current) { + wantsNativeFocusRef.current = false; + webViewRef.current?.requestFocus(); + } for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`); } switch (data.type) { diff --git a/src/components/UndoSnackbar.tsx b/src/components/UndoSnackbar.tsx index bffe0447..050729c3 100644 --- a/src/components/UndoSnackbar.tsx +++ b/src/components/UndoSnackbar.tsx @@ -8,7 +8,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens' import { useColors } from '../theme/colors'; import { undoSnackbarColors } from './undo-snackbar-colors'; import { useEmailStore } from '../stores/email-store'; -import { useSendUndoStore, restoreUndoneSend, type PendingUndoSend } from '../stores/send-undo-store'; +import { useSendUndoStore, restoreUndoneSend, undoSendLabel, type PendingUndoSend } from '../stores/send-undo-store'; import { useLocaleStore } from '../stores/locale-store'; import type { RootStackParamList } from '../navigation/types'; import { announce } from '../lib/announce'; @@ -56,7 +56,8 @@ export function UndoSnackbar() { : null; const send: Shown | null = pendingSend ? { - label: t('email_composer.undo_send_label', 'Sending in {seconds}s…', { seconds: pendingSend.delaySeconds }), + // The text at the start; the bar counts down from it (below). + label: undoSendLabel(pendingSend, pendingSend.createdAt), createdAt: pendingSend.createdAt, visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000), send: pendingSend, @@ -74,6 +75,16 @@ export function UndoSnackbar() { if (current) setShown(current); }, [current]); + // A held send counts down: "Sending in 10s…", "Sending in 9s…". + const [now, setNow] = React.useState(() => Date.now()); + const counting = current?.send; + React.useEffect(() => { + if (!counting) return undefined; + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 250); + return () => clearInterval(id); + }, [counting]); + // Read the bar out on iOS, which has no live regions; Android reads the // bar's live region. Once per entry, not on every countdown re-render. const undoLabel = t('common.undo', 'Undo'); @@ -148,7 +159,11 @@ export function UndoSnackbar() { ]} > - {shown.label} + {/* The spoken label stays the first one, so the live region is not + read out again every second of the countdown. */} + + {shown.send ? undoSendLabel(shown.send, now) : shown.label} + {shown.send && ( { + it('renders the pages only once the pager is measured', () => { + expect(src).toMatch(/\s+\{measuredWidth > 0 && \(\s+ { + const pager = src.slice(src.indexOf(' { + expect(src).toMatch(/\(\) => \[cacheVersion, neighboursReady, pageWidth\]/); + }); +}); diff --git a/src/components/__tests__/reader-text-size.test.ts b/src/components/__tests__/reader-text-size.test.ts new file mode 100644 index 00000000..c546962a --- /dev/null +++ b/src/components/__tests__/reader-text-size.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// At 200% text size the reader's header and toolbar stayed at 100% while +// the list and the body grew: their styles were built from the fixed +// tokens. They now take the user's text size from useTypography, as the +// list does. No React Native renderer in the test env, so this checks the +// wiring. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +const FILES = { + 'screens/EmailThreadScreen.tsx': 6, + 'components/email/MessageHeader.tsx': 1, + 'components/email/ThreadMessageCard.tsx': 2, +}; + +describe('reader text size', () => { + for (const [file, uses] of Object.entries(FILES)) { + it(`builds the styles of ${file} at the user's text size`, () => { + const src = read(file); + expect(src).toMatch(/function makeStyles\(c: ThemePalette, typography: ScaledTypography = tokenTypography\)/); + expect(src).not.toMatch(/makeStyles\(c\)/); + expect((src.match(/const scaledType = useTypography\(\);\s+const styles = React\.useMemo\(\(\) => makeStyles\(c, scaledType\), \[c, scaledType\]\);/g) ?? []).length) + .toBe(uses); + }); + } + + it('scales the subject line with the text size', () => { + const src = read('screens/EmailThreadScreen.tsx'); + const subject = src.slice(src.indexOf(' subjectText: {'), src.indexOf(' subjectText: {') + 300); + expect(subject).toMatch(/fontSize: typography\.h3\.fontSize,/); + expect(subject).not.toMatch(/fontSize: 18,/); + }); +}); diff --git a/src/components/__tests__/undo-send-countdown.test.ts b/src/components/__tests__/undo-send-countdown.test.ts new file mode 100644 index 00000000..23d46351 --- /dev/null +++ b/src/components/__tests__/undo-send-countdown.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { accountId: 'primary', connectedAccountId: 'primary', isConnected: true }, + RequestTimeoutError: class RequestTimeoutError extends Error {}, +})); + +import { undoSendLabel, undoSendSecondsLeft } from '../../stores/send-undo-store'; + +// The undo-send bar said "Sending in 10s…" for the whole delay: its text was +// built once from the delay. It counts down now. No React Native renderer in +// the test env, so the bar's wiring is checked in its source. + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); +const HELD = { createdAt: 1_000_000, delaySeconds: 10 }; + +describe('undo-send countdown', () => { + it('counts the seconds left down from the delay', () => { + expect(undoSendSecondsLeft(HELD, HELD.createdAt)).toBe(10); + expect(undoSendSecondsLeft(HELD, HELD.createdAt + 400)).toBe(10); + expect(undoSendSecondsLeft(HELD, HELD.createdAt + 3_500)).toBe(7); + expect(undoSendSecondsLeft(HELD, HELD.createdAt + 9_000)).toBe(1); + // Never 0 or less, never more than the delay (a clock set back). + expect(undoSendSecondsLeft(HELD, HELD.createdAt + 20_000)).toBe(1); + expect(undoSendSecondsLeft(HELD, HELD.createdAt - 5_000)).toBe(10); + }); + + it('builds the bar text for the moment it is drawn', () => { + expect(undoSendLabel(HELD, HELD.createdAt)).toBe('Sending in 10s…'); + expect(undoSendLabel(HELD, HELD.createdAt + 4_200)).toBe('Sending in 6s…'); + }); + + it('redraws the bar text from a ticking clock', () => { + const src = read('components/UndoSnackbar.tsx'); + expect(src).toMatch(/setInterval\(\(\) => setNow\(Date\.now\(\)\), \d+\)/); + expect(src).toMatch(/\{shown\.send \? undoSendLabel\(shown\.send, now\) : shown\.label\}/); + }); +}); diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index 5a07caf9..fa5101af 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -4,7 +4,10 @@ import { Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock, } from '../icons'; import type { Email, EmailAddress, Identity } from '../../api/types'; -import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { spacing, radius, typography, typography as tokenTypography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { useTypography } from '../../theme/dynamic'; +// The user's text size (Settings > Accessibility), as the list has it. +type ScaledTypography = ReturnType; import { useColors } from '../../theme/colors'; import SenderAvatar from '../SenderAvatar'; import { useSettingsStore } from '../../stores/settings-store'; @@ -99,7 +102,8 @@ function DetailRow({ label, value, styles, mono }: { label: string; value?: stri */ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact, jmapAccountId }: Props) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const t = useLocaleStore((s) => s.t); const locale = useLocaleStore((s) => s.locale); const timeFormat = useSettingsStore((s) => s.timeFormat); @@ -372,7 +376,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA ); } -function makeStyles(c: ThemePalette) { +function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) { return StyleSheet.create({ block: { backgroundColor: c.background, @@ -430,7 +434,7 @@ function makeStyles(c: ThemePalette) { detailRow: { flexDirection: 'row', gap: spacing.sm }, detailLabel: { ...typography.caption, color: c.textMuted, width: 92 }, detailValue: { ...typography.caption, color: c.text, flex: 1 }, - detailMono: { fontFamily: 'monospace', fontSize: 11 }, + detailMono: { fontFamily: 'monospace', fontSize: Math.round((11 * typography.caption.fontSize) / 12) }, chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginBottom: spacing.xs }, chip: { flexDirection: 'row', diff --git a/src/components/email/ThreadMessageCard.tsx b/src/components/email/ThreadMessageCard.tsx index 95b5daf4..564dd9d4 100644 --- a/src/components/email/ThreadMessageCard.tsx +++ b/src/components/email/ThreadMessageCard.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import { Paperclip, Reply, ReplyAll, Forward, Star } from '../icons'; import type { Email } from '../../api/types'; -import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { spacing, radius, typography, typography as tokenTypography, componentSizes, type ThemePalette } from '../../theme/tokens'; +import { useTypography } from '../../theme/dynamic'; +// The user's text size (Settings > Accessibility), as the list has it. +type ScaledTypography = ReturnType; import { useColors } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; @@ -13,6 +16,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 +28,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,11 +41,12 @@ 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(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const t = useLocaleStore((s) => s.t); const locale = useLocaleStore((s) => s.locale); const timeFormat = useSettingsStore((s) => s.timeFormat); @@ -71,24 +79,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')} @@ -104,7 +117,8 @@ export function ThreadMessageCard({ */ export function ThreadCardPlaceholder() { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); return ( @@ -116,7 +130,7 @@ export function ThreadCardPlaceholder() { ); } -function makeStyles(c: ThemePalette) { +function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) { return StyleSheet.create({ collapsed: { flexDirection: 'row', diff --git a/src/lib/__tests__/email-detail-cache.test.ts b/src/lib/__tests__/email-detail-cache.test.ts index 5248f445..1ce91d6a 100644 --- a/src/lib/__tests__/email-detail-cache.test.ts +++ b/src/lib/__tests__/email-detail-cache.test.ts @@ -1,4 +1,6 @@ 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: 'own', serverUrl: 'https://mail.example', username: 'me' }, @@ -15,7 +17,7 @@ import { getEmailFlags, getFullEmailsWithState, getThreadsHeaders } from '../../ import { useOfflineCacheStore } from '../../stores/offline-cache-store'; import { dispatchStateChange } from '../state-change-bus'; import { - clearEmailDetailCache, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState, + clearEmailDetailCache, EmailNotFoundError, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState, patchDetail, peekDetail, peekRow, peekThread, prefetchMessage, rememberRows, subscribeEmailCache, } from '../email-detail-cache'; @@ -158,6 +160,31 @@ describe('loading a message', () => { expect(peekDetail('e1')).toBeUndefined(); }); + // A row deleted elsewhere opened "Failed to load email": the viewer could + // not tell a missing message from a failed load. + it('tells a message the server no longer has from a failed load', async () => { + fullGet.mockResolvedValueOnce(fullResponse([], 's1', ['e9'])); + await expect(loadDetail('e9')).rejects.toBeInstanceOf(EmailNotFoundError); + + fullGet.mockResolvedValueOnce(fullResponse([full('e1')], 's1')); + await loadDetail('e1'); + noteEmailState(undefined, 's2'); + flagsGet.mockResolvedValueOnce({ list: [], notFound: ['e1'], state: 's2' }); + await expect(loadDetail('e1')).rejects.toBeInstanceOf(EmailNotFoundError); + + fullGet.mockRejectedValueOnce(new TypeError('Network request failed')); + const failed = await loadDetail('e3').catch((err: unknown) => err); + expect(failed).not.toBeInstanceOf(EmailNotFoundError); + }); + + it('has the viewer say a gone message is no longer available and refresh the list', () => { + const viewer = fs.readFileSync(path.resolve(__dirname, '..', '..', 'screens/EmailThreadScreen.tsx'), 'utf8'); + expect(viewer).toMatch(/if \(err instanceof EmailNotFoundError\) onGone\?\.\(\);/); + expect(viewer).toMatch(/const fetched = await ensureDetail\(activeEmailId, \(\) => \{ gone = true; \}\);/); + expect(viewer).toMatch(/t\('deep_link\.message_not_found', 'This message is no longer available\.'\)/); + expect(viewer).toMatch(/if \(gone\) void useEmailStore\.getState\(\)\.refreshEmails\(\);/); + }); + it('rejects with the fetch error when nothing is held', async () => { fullGet.mockRejectedValue(new Error('boom')); await expect(loadDetail('e1')).rejects.toThrow('boom'); @@ -320,6 +347,49 @@ describe('loading a conversation', () => { expect(threadGet).toHaveBeenCalledWith(['t1'], 'group'); }); + it('keeps a read mark made while the conversation was loading', async () => { + fullGet.mockResolvedValueOnce(fullResponse([full('m1')], 's1')); + await loadDetail('m1'); + let answer!: (v: unknown) => void; + threadGet.mockReturnValueOnce(new Promise((r) => { answer = r; })); + const loading = loadThread('t-m1'); + await flush(); + // Marked read on open, while Thread/get is out. + patchDetail('m1', undefined, { keywords: { $seen: true } }); + answer(threadResponse('t-m1', [{ id: 'm1', threadId: 't-m1', keywords: {}, mailboxIds: { inbox: true } }], 's2')); + await loading; + + expect(peekDetail('m1')?.keywords).toEqual({ $seen: true }); + expect(peekThread('t-m1')?.headers.get('m1')?.keywords).toEqual({ $seen: true }); + }); + + it('keeps a read mark made while the message itself was loading', async () => { + let answer!: (v: unknown) => void; + fullGet.mockReturnValueOnce(new Promise((r) => { answer = r; })); + threadGet.mockResolvedValueOnce(threadResponse('t-m1', [{ id: 'm1', threadId: 't-m1', keywords: {}, mailboxIds: { inbox: true } }])); + await loadThread('t-m1'); + const loading = loadDetail('m1'); + await flush(); + patchDetail('m1', undefined, { keywords: { $seen: true } }); + answer(fullResponse([full('m1')], 's2')); + + expect((await loading).keywords).toEqual({ $seen: true }); + expect(peekDetail('m1')?.keywords).toEqual({ $seen: true }); + }); + + it('takes the server keywords of a conversation read after the local change', async () => { + fullGet.mockResolvedValueOnce(fullResponse([full('m1')], 's1')); + await loadDetail('m1'); + patchDetail('m1', undefined, { keywords: { $seen: true } }); + threadGet.mockResolvedValueOnce( + threadResponse('t-m1', [{ id: 'm1', threadId: 't-m1', keywords: { $seen: true, $flagged: true }, mailboxIds: { inbox: true } }], 's2'), + ); + + await loadThread('t-m1'); + + expect(peekDetail('m1')?.keywords).toEqual({ $seen: true, $flagged: true }); + }); + it('applies local keyword changes to the conversation headers too', async () => { threadGet.mockResolvedValue(threadResponse('t1', headers(['m1']))); await loadThread('t1'); diff --git a/src/lib/__tests__/list-sender.test.ts b/src/lib/__tests__/list-sender.test.ts index 98b785b8..5eebf75f 100644 --- a/src/lib/__tests__/list-sender.test.ts +++ b/src/lib/__tests__/list-sender.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { listAddressLabel } from '../list-sender'; +import { listAddressLabel, rowShowsRecipient } from '../list-sender'; import { toUnicodeEmailOnOwnDomains } from '../idn'; // A sender with no display name on one of the user's own IDN domains showed @@ -42,3 +42,40 @@ describe('list rows', () => { expect(unified).not.toMatch(/\.name \|\| [\w.?[\]]*\.email/); }); }); + +// A search started in Sent spans every folder: Inbox hits showed their +// recipient (the user) instead of their sender. +describe('rowShowsRecipient', () => { + const OWN = [ + { id: 'inb', role: 'inbox' }, + { id: 'snt', role: 'sent' }, + { id: 'drf', role: 'drafts' }, + ]; + const SHARED = [ + { id: 'grp-1:inb', originalId: 'inb', role: 'inbox' }, + { id: 'grp-1:snt', originalId: 'snt', role: 'sent' }, + ]; + + it('follows the open folder for its own list', () => { + expect(rowShowsRecipient({ inb: true }, [], 'sent', 'current')).toBe(true); + expect(rowShowsRecipient({ snt: true }, [], 'inbox', 'current')).toBe(false); + }); + + it("follows each row's own folders in a search over every folder", () => { + expect(rowShowsRecipient({ inb: true }, OWN, 'sent', 'all')).toBe(false); + expect(rowShowsRecipient({ snt: true }, OWN, 'sent', 'all')).toBe(true); + expect(rowShowsRecipient({ drf: true }, OWN, 'inbox', 'all')).toBe(true); + expect(rowShowsRecipient({ inb: true }, OWN, 'sent', 'inb')).toBe(false); + }); + + it("matches a shared account's folders on their raw ids", () => { + expect(rowShowsRecipient({ snt: true }, SHARED, 'sent', 'all')).toBe(true); + expect(rowShowsRecipient({ inb: true }, SHARED, 'sent', 'all')).toBe(false); + }); + + it('is wired into the list rows', () => { + const list = fs.readFileSync(path.resolve(__dirname, '..', '..', 'screens/EmailListScreen.tsx'), 'utf8'); + expect(list).toMatch(/showRecipient=\{showRecipientFor\(item\)\}/); + expect(list).not.toMatch(/const showRecipient = currentRole === 'sent'/); + }); +}); diff --git a/src/lib/__tests__/push-prompt-frame.test.ts b/src/lib/__tests__/push-prompt-frame.test.ts new file mode 100644 index 00000000..88eb37ab --- /dev/null +++ b/src/lib/__tests__/push-prompt-frame.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pushPromptBottom, READER_BAR_CLEARANCE } from '../push-prompt-frame'; + +// On a tablet split view the notification prompt sat in the bottom right +// corner, over the reading pane's Prev / Reply / Forward bar, until it was +// dismissed. + +describe('push prompt placement', () => { + it('sits above the reading pane bar on a split view', () => { + expect(pushPromptBottom(20, true)).toBe(20 + READER_BAR_CLEARANCE); + expect(READER_BAR_CLEARANCE).toBeGreaterThanOrEqual(64); + }); + + it('keeps the toast placement above the tab bar on a phone', () => { + expect(pushPromptBottom(64, false)).toBe(64); + }); + + it('is what the prompt uses', () => { + const src = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components/PushOnboardingPrompt.tsx'), 'utf8'); + expect(src).toMatch(/bottom: insets\.bottom \+ pushPromptBottom\(frame\.bottom, isSplit\)/); + }); +}); diff --git a/src/lib/__tests__/rich-text-editor-focus.test.ts b/src/lib/__tests__/rich-text-editor-focus.test.ts new file mode 100644 index 00000000..c53c49e4 --- /dev/null +++ b/src/lib/__tests__/rich-text-editor-focus.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Reply opened with no keyboard on Android and focus on the composer's +// Close button (a hardware Space shut the composer). 8bb7b607 focused the +// editor inside its page only; an Android WebView takes the keyboard and +// key focus only with native view focus, which nothing gave it. The suite +// has no React Native renderer, so this checks the editor's wiring. + +const src = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components/RichTextEditor.tsx'), 'utf8'); + +describe('rich text editor focus', () => { + it('gives the WebView native focus before focusing the editor in the page', () => { + const handle = src.slice(src.indexOf('focusEnd: () =>'), src.indexOf('blur: () =>')); + expect(handle).toMatch(/focusEnd: \(\) => \{\s+requestNativeFocus\(\);\s+call\(`window\.__rne && window\.__rne\.focusEnd\(\)`\);/); + expect(handle).toMatch(/focus: \(\) => \{\s+requestNativeFocus\(\);\s+call\(`window\.__rne && window\.__rne\.focus\(\)`\);/); + }); + + it('asks for native focus on Android, and holds the request until the page is up', () => { + const fn = src.slice(src.indexOf('const requestNativeFocus'), src.indexOf('const call = ')); + expect(fn).toMatch(/if \(Platform\.OS !== 'android'\) return;/); + expect(fn).toMatch(/if \(!readyRef\.current\) \{\s+wantsNativeFocusRef\.current = true;\s+return;\s+\}\s+webViewRef\.current\?\.requestFocus\(\);/); + const ready = src.slice(src.indexOf('if (!readyRef.current) {\r\n readyRef.current = true;'.replace(/\r\n/g, src.includes('\r\n') ? '\r\n' : '\n'))); + expect(ready).toMatch(/if \(wantsNativeFocusRef\.current\) \{\s+wantsNativeFocusRef\.current = false;\s+webViewRef\.current\?\.requestFocus\(\);\s+\}\s+for \(const script of queued\)/); + }); +}); diff --git a/src/lib/__tests__/search-filter-sheet.test.ts b/src/lib/__tests__/search-filter-sheet.test.ts new file mode 100644 index 00000000..7e0b10c1 --- /dev/null +++ b/src/lib/__tests__/search-filter-sheet.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { filterButtonLabel, filterSheetFrame, keepCustomSize, sizeChipLabel } from '../search-utils'; + +// The search filter sheet (walkthrough on a Pixel 7): it did not scroll and +// its last row was cut off, the size fields sat under the keyboard, the size +// chip read "Larger than (KB): 1 KB", and after Clear the filter button was +// still announced with a count while "Custom size" stayed picked. + +const t = (_key: string, fallback: string, values?: Record) => + fallback.replace(/\{(\w+)\}/g, (_m, k: string) => String(values?.[k] ?? '')); +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +describe('search filter sheet', () => { + it('names a size chip without the field placeholder', () => { + expect(sizeChipLabel('min', '1', t)).toBe('Larger than 1 KB'); + expect(sizeChipLabel('max', '250', t)).toBe('Smaller than 250 KB'); + }); + + it('puts the filter count in the button label, and drops it at zero', () => { + expect(filterButtonLabel(0, t)).toBe('Advanced search filters'); + expect(filterButtonLabel(2, t)).toBe('Advanced search filters, 2 active'); + }); + + it('lets "Custom size" go once the sheet is closed with no size left', () => { + expect(keepCustomSize(true, false, undefined, undefined)).toBe(false); + expect(keepCustomSize(true, false, '', '')).toBe(false); + // Picked in the open sheet, nothing typed yet: it stays. + expect(keepCustomSize(true, true, undefined, undefined)).toBe(true); + expect(keepCustomSize(true, false, '10', undefined)).toBe(true); + }); + + it('keeps the sheet between the system bars and above the keyboard', () => { + // Pixel 7: 915 high, 24 status bar, 48 navigation bar. + expect(filterSheetFrame(915, 24, 48, 0, 16)).toEqual({ paddingTop: 40, paddingBottom: 64, maxHeight: 811 }); + // Keyboard up, window not resized (edge to edge): the sheet ends above it. + expect(filterSheetFrame(915, 24, 48, 300, 16)).toEqual({ paddingTop: 40, paddingBottom: 316, maxHeight: 559 }); + // A window that already shrank for the keyboard does not lose it twice. + expect(filterSheetFrame(615, 24, 48, 300, 16, 300)).toEqual({ paddingTop: 40, paddingBottom: 64, maxHeight: 511 }); + }); + + it('wires the helpers into the list screen', () => { + const list = read('screens/EmailListScreen.tsx'); + expect(list).toMatch(/accessibilityLabel=\{filterButtonLabel\(activeFilterCount, t\)\}/); + expect(list).not.toMatch(/accessibilityValue=\{activeFilterCount/); + expect(list).toMatch(/label=\{sizeChipLabel\('min', filters\.minSizeKb \?\? '', t\)\}/); + expect(list).toMatch(//); + expect(list).not.toMatch(/maxHeight: '85%'/); + expect(list).toMatch(/onPress=\{clearFilters\}/); + expect(list).toMatch(/key=\{filterFormKey\}/); + expect((list.match(/onFocus=\{revealSizeFields\}/g) ?? []).length).toBe(2); + }); +}); diff --git a/src/lib/__tests__/select-all-a11y.test.ts b/src/lib/__tests__/select-all-a11y.test.ts new file mode 100644 index 00000000..3ac56311 --- /dev/null +++ b/src/lib/__tests__/select-all-a11y.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { selectAllA11y } from '../select-all-a11y'; + +// Trash's Select all checkbox was announced "mixed" with nothing selected: +// Android kept the "mixed" a string state had written into the description +// after the state went back to false. The checkbox now never uses 'mixed'. + +const t = (_key: string, fallback: string, values?: Record) => + fallback.replace('{count, plural, one {1 email} other {# emails}}', `${values?.count} emails`); +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +describe('Select all checkbox for screen readers', () => { + it('is plain unchecked with nothing selected', () => { + expect(selectAllA11y(false, 0, t)).toEqual({ label: 'Select all', checked: false }); + }); + + it('names the count in the label while some rows are selected', () => { + expect(selectAllA11y(false, 3, t)).toEqual({ label: 'Select all, 3 emails selected', checked: false }); + }); + + it('is checked with every row selected', () => { + expect(selectAllA11y(true, 5, t)).toEqual({ label: 'Select all', checked: true }); + }); + + it('never hands the list checkbox a mixed state', () => { + const list = read('screens/EmailListScreen.tsx'); + expect(list).not.toMatch(/'mixed'/); + expect(list).toMatch(/accessibilityLabel=\{selectAll\.label\}\s+accessibilityState=\{\{ checked: selectAll\.checked \}\}/); + }); +}); 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/email-detail-cache.ts b/src/lib/email-detail-cache.ts index 4e6f114e..2ac4b900 100644 --- a/src/lib/email-detail-cache.ts +++ b/src/lib/email-detail-cache.ts @@ -163,6 +163,31 @@ function putThread(key: string, entry: ThreadEntry): void { } } +// Local changes (patchDetail) by message key, numbered in the order they +// were made. A response read before one of them must not undo it: the read +// mark set on open was put back by the conversation's headers arriving just +// after it, and the reader's toolbar offered "Mark as read" again. +let localClock = 0; +const localPatches = new Map(); +const MAX_LOCAL_PATCHES = 500; + +/** What was changed here on `key` after a request started at `startedAt` went out. */ +function localSince(key: string, startedAt: number): FlagsHint | undefined { + const local = localPatches.get(key); + return local && local.at > startedAt ? local.patch : undefined; +} + +/** + * The server has no such message (deleted or moved out of reach elsewhere), + * as opposed to a load that failed: the viewer says it is gone. + */ +export class EmailNotFoundError extends Error { + constructor(id: string) { + super(`Email ${id} not found`); + this.name = 'EmailNotFoundError'; + } +} + /** The copy held for a message, if any. Synchronous: for rendering. */ export function peekDetail(id: string, accountId?: string): Email | undefined { return details.get(keyOf(id, accountId))?.email; @@ -185,6 +210,13 @@ export function subscribeEmailCache(listener: () => void): () => void { */ export function patchDetail(id: string, accountId: string | undefined, patch: FlagsHint): void { const key = keyOf(id, accountId); + // Re-inserted, so the oldest change is the first to go. + const earlier = localPatches.get(key)?.patch; + localPatches.delete(key); + localPatches.set(key, { at: ++localClock, patch: { ...earlier, ...patch } }); + while (localPatches.size > MAX_LOCAL_PATCHES) { + localPatches.delete(localPatches.keys().next().value as string); + } let changed = false; const entry = details.get(key); if (entry) { @@ -217,6 +249,7 @@ export function clearEmailDetailCache(): void { pendingThreads.clear(); threadBatches.clear(); latestStates.clear(); + localPatches.clear(); detailChars = 0; } @@ -252,12 +285,15 @@ async function fetchBodies( ): Promise { if (ids.length === 0) return; const gen = generation; + const startedAt = localClock; try { const res = await getFullEmailsWithState(ids, accountId); const keep = gen === generation; if (keep) noteEmailState(accountId, res.state); const found = new Map(); - for (const email of res.list) { + for (const fetched of res.list) { + const local = localSince(keyOf(fetched.id, accountId), startedAt); + const email = local ? { ...fetched, ...local } : fetched; found.set(email.id, email); if (!keep) continue; putDetail(keyOf(email.id, accountId), { email, state: res.state, chars: bodyChars(email) }); @@ -267,7 +303,7 @@ async function fetchBodies( for (const id of ids) { const email = found.get(id); if (email) waiting.get(id)?.resolve(email); - else waiting.get(id)?.reject(new Error(`Email ${id} not found`)); + else waiting.get(id)?.reject(new EmailNotFoundError(id)); } } catch (err) { for (const id of ids) waiting.get(id)?.reject(err); @@ -281,6 +317,7 @@ async function revalidate( ): Promise { if (ids.length === 0) return; const gen = generation; + const startedAt = localClock; let gone = new Set(); try { const res = await getEmailFlags(ids, accountId); @@ -288,10 +325,11 @@ async function revalidate( noteEmailState(accountId, res.state); gone = new Set(res.notFound); let changed = false; - for (const flags of res.list) { - const key = keyOf(flags.id, accountId); + for (const read of res.list) { + const key = keyOf(read.id, accountId); const entry = details.get(key); if (!entry) continue; + const flags = { ...read, ...localSince(key, startedAt) }; const same = sameFlags(flags.keywords, entry.email.keywords) && sameFlags(flags.mailboxIds, entry.email.mailboxIds); // Keep the object when nothing changed, so nothing re-renders. @@ -318,7 +356,7 @@ async function revalidate( for (const id of ids) { const email = peekDetail(id, accountId); if (email) waiting.get(id)?.resolve(email); - else waiting.get(id)?.reject(new Error(`Email ${id} not found`)); + else waiting.get(id)?.reject(gone.has(id) ? new EmailNotFoundError(id) : new Error(`Email ${id} not found`)); } } @@ -471,6 +509,7 @@ async function fetchThreads({ accountId, waiting }: ThreadBatch): Promise const gen = generation; // What was held when the load started: shown again if the refetch fails. const fallback = (threadId: string) => (gen === generation ? threads.get(keyOf(threadId, accountId)) : undefined); + const startedAt = localClock; try { const res = await getThreadsHeaders(Array.from(waiting.keys()), accountId); if (gen !== generation) throw new Error('cleared'); @@ -483,15 +522,20 @@ async function fetchThreads({ accountId, waiting }: ThreadBatch): Promise else d.reject(new Error(`Thread ${threadId} not found`)); continue; } + // A change made here while the headers were on their way stays. + const list = found.list.map((e) => { + const local = localSince(keyOf(e.id, accountId), startedAt); + return local ? { ...e, ...local } : e; + }); const entry: ThreadEntry = { ids: found.emailIds, - headers: new Map(found.list.map((e) => [e.id, e])), + headers: new Map(list.map((e) => [e.id, e])), state: res.state, }; putThread(keyOf(threadId, accountId), entry); // The headers were just read: a member's copy held from an earlier // open takes their keywords and folders, which may have changed since. - for (const header of found.list) { + for (const header of list) { const memberKey = keyOf(header.id, accountId); const held = details.get(memberKey); if (!held) continue; diff --git a/src/lib/list-sender.ts b/src/lib/list-sender.ts index 96582684..543c36ac 100644 --- a/src/lib/list-sender.ts +++ b/src/lib/list-sender.ts @@ -11,3 +11,21 @@ export function listAddressLabel( ): string { return address?.name || (address?.email ? formatAddress(address.email) : '') || unknown; } + +/** + * Whether a list row names its recipient instead of its sender. Sent and + * Drafts name the recipient (webmail 1.4.12). A search started there runs + * over every folder unless it is narrowed (`folderScope` other than + * 'current'), so the open folder says nothing about a row: the row's own + * folders decide, matched on each folder's raw id. `rowMailboxes` are the + * folders of the row's account. + */ +export function rowShowsRecipient( + mailboxIds: Record | undefined, + rowMailboxes: ReadonlyArray<{ id: string; originalId?: string; role?: string | null }>, + listRole: string | null | undefined, + folderScope: string, +): boolean { + if (folderScope === 'current') return listRole === 'sent' || listRole === 'drafts'; + return rowMailboxes.some((m) => (m.role === 'sent' || m.role === 'drafts') && !!mailboxIds?.[m.originalId ?? m.id]); +} diff --git a/src/lib/push-prompt-frame.ts b/src/lib/push-prompt-frame.ts new file mode 100644 index 00000000..ecdc28e4 --- /dev/null +++ b/src/lib/push-prompt-frame.ts @@ -0,0 +1,13 @@ +// Where the push notification prompt floats. It takes the toast placement +// (lib/use-layout floatingCardFrame), but stays until it is answered: on a +// tablet split view the bottom right corner is the reading pane's Prev / +// Reply / Forward bar, which the card covered until it was dismissed. There +// it sits above that bar. + +/** The reader's bottom bar (EmailThreadScreen: 60 + at least 4 for the inset) and a gap. */ +export const READER_BAR_CLEARANCE = 64; + +/** The prompt's distance from the bottom, before the safe-area inset. */ +export function pushPromptBottom(frameBottom: number, split: boolean): number { + return split ? frameBottom + READER_BAR_CLEARANCE : frameBottom; +} diff --git a/src/lib/search-utils.ts b/src/lib/search-utils.ts index a8960622..eb105d10 100644 --- a/src/lib/search-utils.ts +++ b/src/lib/search-utils.ts @@ -29,6 +29,66 @@ export function sizePresetOf(minSizeKb: string | undefined, maxSizeKb: string | } /** Filter properties whose value the server reads as free text. */ +type Translate = (key: string, fallback: string, values?: Record) => string; + +/** + * The chip for a custom size bound: "Larger than 1 KB". It read "Larger than + * (KB): 1 KB", built from the field's placeholder. + */ +export function sizeChipLabel(bound: 'min' | 'max', kb: string, t: Translate): string { + return bound === 'min' + ? t('advanced_search.larger_than_kb', 'Larger than {size} KB', { size: kb }) + : t('advanced_search.smaller_than_kb', 'Smaller than {size} KB', { size: kb }); +} + +/** + * The filter button's name for screen readers, with the number of active + * filters in it. It was given as an accessibility value, which Android left + * in the description ("Advanced search filters, 1") once the value went away + * after Clear; a changed label always replaces the description. + */ +export function filterButtonLabel(count: number, t: Translate): string { + const name = t('advanced_search.advanced_filters_tooltip', 'Advanced search filters'); + return count > 0 ? t('advanced_search.advanced_filters_count', '{name}, {count} active', { name, count }) : name; +} + +/** + * Whether "Custom size" stays picked. With the sheet closed and no bound + * left (the chip removed, the search cleared) it goes back to "Any size": + * it stayed picked, with empty fields, after the size chip was removed. + */ +export function keepCustomSize( + customSize: boolean, + sheetOpen: boolean, + minSizeKb: string | undefined, + maxSizeKb: string | undefined, +): boolean { + if (!customSize) return false; + return sheetOpen || !!minSizeKb || !!maxSizeKb; +} + +/** + * Where the filter sheet sits: clear of the status bar, the navigation bar + * and the keyboard, and never taller than the space between them, so its + * body scrolls instead of running off the bottom (Pixel 7: the last row was + * cut off) and the size fields stay above the keyboard. `height` is the + * sheet's window; `shrunkBy` how much the window already gave way to the + * keyboard (adjustResize, which an edge-to-edge window doesn't get), so the + * keyboard is not taken off twice. + */ +export function filterSheetFrame( + height: number, + insetTop: number, + insetBottom: number, + keyboard: number, + gap: number, + shrunkBy = 0, +): { paddingTop: number; paddingBottom: number; maxHeight: number } { + const paddingTop = insetTop + gap; + const paddingBottom = Math.max(insetBottom, keyboard - Math.max(0, shrunkBy)) + gap; + return { paddingTop, paddingBottom, maxHeight: Math.max(120, height - paddingTop - paddingBottom) }; +} + export type SearchTextProperty = 'text' | 'from' | 'to' | 'subject' | 'body'; // A "quoted phrase" (the closing quote may still be missing while the user diff --git a/src/lib/select-all-a11y.ts b/src/lib/select-all-a11y.ts new file mode 100644 index 00000000..ecaa8d02 --- /dev/null +++ b/src/lib/select-all-a11y.ts @@ -0,0 +1,24 @@ +// What a screen reader hears for the list's Select all checkbox. +// +// It used to say checked: 'mixed' while some rows were selected. Android +// writes "mixed" into the view's content description and, going back to +// checked: false, rebuilds that description only for a string state, so it +// kept saying "mixed" with nothing selected (seen in Trash). The count goes +// into the label instead: a changed label always rebuilds the description. + +type Translate = (key: string, fallback: string, values?: Record) => string; + +export function selectAllA11y( + allSelected: boolean, + selectedCount: number, + t: Translate, +): { label: string; checked: boolean } { + const label = t('email_list.batch_actions.select_all', 'Select all'); + if (allSelected || selectedCount === 0) return { label, checked: allSelected }; + const selected = t( + 'email_list.batch_actions.selected_messages', + '{count, plural, one {1 email} other {# emails}} selected', + { count: selectedCount }, + ); + return { label: `${label}, ${selected}`, checked: false }; +} diff --git a/src/lib/tag-undo-group.ts b/src/lib/tag-undo-group.ts new file mode 100644 index 00000000..cbbe0fcb --- /dev/null +++ b/src/lib/tag-undo-group.ts @@ -0,0 +1,22 @@ +// One Undo for everything changed in a tag sheet. The sheet stays open so +// several tags can be changed in a row (webmail setSelectionTagWithUndo), and +// it covers the undo bar while it is up: the changes join one Undo, and the +// bar shows for its full time once the sheet closes. + +import React from 'react'; +import { useEmailStore } from '../stores/email-store'; + +let seq = 0; + +/** The undo group of a tag sheet while `open`; '' while it is closed. */ +export function useTagUndoGroup(open: boolean): string { + const group = React.useRef(''); + if (open && !group.current) group.current = `tags:${++seq}`; + React.useEffect(() => { + if (open || !group.current) return; + const done = group.current; + group.current = ''; + useEmailStore.getState().releaseUndoGroup(done); + }, [open]); + return open ? group.current : ''; +} 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/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 58605026..ebbc5682 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback, Alert } from 'react-native'; +import { View, Text, StyleSheet, FlatList, Pressable, TextInput, Image, ActivityIndicator, Modal, Platform, ScrollView, TouchableWithoutFeedback, Alert, useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as DocumentPicker from 'expo-document-picker'; @@ -22,6 +22,8 @@ import SenderAvatar from '../components/SenderAvatar'; import { SwipeableRow } from '../components/SwipeableRow'; import { MoveSheet } from '../components/MoveSheet'; import { TagSheet } from '../components/TagSheet'; +import { useTagUndoGroup } from '../lib/tag-undo-group'; +import { selectAllA11y } from '../lib/select-all-a11y'; import { OfflineBanner } from '../components/OfflineBanner'; import { TrashRecoveryLink } from '../components/email/TrashRecoveryLink'; import { @@ -51,14 +53,14 @@ import { useContactsStore } from '../stores/contacts-store'; import { useOutboxStore } from '../stores/outbox-store'; import { toast } from '../stores/toast-store'; import { getContactDisplayName } from '../lib/contact-utils'; -import { listAddressLabel } from '../lib/list-sender'; +import { listAddressLabel, rowShowsRecipient } from '../lib/list-sender'; import { useOwnDomainAddress } from '../lib/use-own-domain-address'; import { formatListDate } from '../lib/date-format'; import { singleLine } from '../lib/single-line'; import { cleanPreview } from '../lib/preview-text'; import { SearchSnippetText } from '../components/email/SearchSnippetText'; import { - findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes, + findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes, mailboxesOfAccount, } from '../lib/mailbox-tree'; import { localizeMailboxName } from '../lib/mailbox-label'; import { @@ -69,7 +71,10 @@ import { confirmMailRemoval } from '../lib/mail-removal-confirm'; import { moveSheetCurrentId } from '../lib/move-targets'; import { listViewKey } from '../lib/list-view-key'; import { searchScopeFolders } from '../lib/search-scope-folders'; -import { SIZE_PRESETS_MB, sizeFilterBytes, sizePresetOf } from '../lib/search-utils'; +import { + SIZE_PRESETS_MB, sizeFilterBytes, sizePresetOf, sizeChipLabel, filterButtonLabel, keepCustomSize, filterSheetFrame, +} from '../lib/search-utils'; +import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction'; import { draftContextFromEmail, isDraftEmail } from '../lib/draft-context'; import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email'; import type { RootStackParamList } from '../navigation/types'; @@ -575,7 +580,18 @@ export default function EmailListScreen({ }, [sharedTags, keywordDefs]); const folderKeywordDefs = (currentOwnerAccountId && keywordDefsByAccount.get(currentOwnerAccountId)) || keywordDefs; const inJunk = currentRole === 'junk' || currentRole === 'spam'; - const showRecipient = currentRole === 'sent' || currentRole === 'drafts'; + // A search started in Sent runs over every folder: each row names its + // recipient only when it is in Sent or Drafts itself. + const rowScope = effectiveFolderScope(storeSearchQuery, filters, currentRole); + const showRecipientFor = React.useCallback( + (item: Email) => rowShowsRecipient( + item.mailboxIds, + rowScope === 'current' ? [] : mailboxesOfAccount(mailboxes, accountIdOfRow(item)), + currentRole, + rowScope, + ), + [rowScope, mailboxes, currentRole], + ); const formatAddress = useOwnDomainAddress(); // When threading is on, collapse same-thread emails so the list shows the @@ -711,6 +727,8 @@ export default function EmailListScreen({ // Batch (multi-select) sheets. const [batchMoveOpen, setBatchMoveOpen] = React.useState(false); const [tagSheetOpen, setTagSheetOpen] = React.useState(false); + // The sheet's changes share one Undo, shown once it closes. + const tagUndoGroup = useTagUndoGroup(tagSheetOpen); // Selection state const [selectedIds, setSelectedIds] = React.useState>(new Set()); @@ -718,6 +736,7 @@ export default function EmailListScreen({ const selectedLabel = t('email_list.batch_actions.selected_messages', '{count, plural, one {1 email} other {# emails}} selected', { count: selectedIds.size }); const allSelected = visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(rowKeyOf(e))); + const selectAll = selectAllA11y(allSelected, selectedIds.size, t); // Refs so row press handlers stay referentially stable across renders. // FlatList rows then skip re-render when the parent re-renders for unrelated @@ -891,7 +910,7 @@ export default function EmailListScreen({ item={item} threadCount={threadCountFor(item)} showPreview={showPreview} - showRecipient={showRecipient} + showRecipient={showRecipientFor(item)} formatAddress={formatAddress} tagIds={rowTagIds.get(key) ?? ''} keywordDefs={(item.jmapAccountId && keywordDefsByAccount.get(item.jmapAccountId)) || folderKeywordDefs} @@ -911,7 +930,7 @@ export default function EmailListScreen({ [ selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor, - showPreview, showRecipient, formatAddress, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk, + showPreview, showRecipientFor, formatAddress, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk, loadAttachments, openAttachment, ], ); @@ -1030,7 +1049,7 @@ export default function EmailListScreen({ }; const handleBatchTagToggle = (token: string, on: boolean) => { - setKeywordForEmails(selectedMessageIds, token, on).catch((err) => + setKeywordForEmails(selectedMessageIds, token, on, undefined, { undoGroup: tagUndoGroup }).catch((err) => reportKeywordFailure(t('notifications.tag_failed', 'Tagging failed'), err)); }; @@ -1179,6 +1198,41 @@ export default function EmailListScreen({ // size" (webmail search-filter-panel). const [customSize, setCustomSize] = React.useState(false); const sizePreset = customSize ? 'custom' : sizePresetOf(filters.minSizeKb, filters.maxSizeKb); + React.useEffect(() => { + if (!keepCustomSize(customSize, filterMenuOpen, filters.minSizeKb, filters.maxSizeKb)) setCustomSize(false); + }, [customSize, filterMenuOpen, filters.minSizeKb, filters.maxSizeKb]); + // Clear empties every field of the sheet, also text not yet committed + // (the fields commit after a pause): a new key remounts them. + const [filterFormKey, setFilterFormKey] = React.useState(0); + const clearFilters = () => { + setCustomSize(false); + setFilterFormKey((k) => k + 1); + setFilters({}); + }; + // The sheet stays between the system bars and above the keyboard. + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const keyboardHeight = useKeyboardObstruction(insets.bottom); + // The sheet's window as laid out, and its tallest height in this + // orientation: a window that resizes for the keyboard is shorter by then. + const [filterWindow, setFilterWindow] = React.useState({ width: 0, full: 0, now: 0 }); + const onFilterBackdropLayout = (height: number) => setFilterWindow((w) => { + const full = w.width === windowWidth ? Math.max(w.full, height) : height; + return w.width === windowWidth && w.full === full && w.now === height ? w : { width: windowWidth, full, now: height }; + }); + const filterWindowKnown = filterWindow.width === windowWidth && filterWindow.now > 0; + const filterFrame = filterSheetFrame( + filterWindowKnown ? filterWindow.now : windowHeight, + insets.top, + insets.bottom, + keyboardHeight, + spacing.lg, + filterWindowKnown ? filterWindow.full - filterWindow.now : 0, + ); + const filterScrollRef = React.useRef(null); + // The size fields are near the end: bring them up once the keyboard is out. + const revealSizeFields = () => { + setTimeout(() => filterScrollRef.current?.scrollToEnd({ animated: true }), 350); + }; const pickSizePreset = (key: string) => { setCustomSize(key === 'custom'); if (key === 'custom') return; @@ -1271,8 +1325,8 @@ export default function EmailListScreen({ onPress={toggleSelectAllVisible} hitSlop={6} accessibilityRole="checkbox" - accessibilityLabel={t('email_list.batch_actions.select_all', 'Select all')} - accessibilityState={{ checked: allSelected ? true : selectionMode ? 'mixed' : false }} + accessibilityLabel={selectAll.label} + accessibilityState={{ checked: selectAll.checked }} > {allSelected ? ( @@ -1336,8 +1390,7 @@ export default function EmailListScreen({ style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]} onPress={() => setFilterMenuOpen(true)} accessibilityRole="button" - accessibilityLabel={t('advanced_search.advanced_filters_tooltip', 'Advanced search filters')} - accessibilityValue={activeFilterCount > 0 ? { text: String(activeFilterCount) } : undefined} + accessibilityLabel={filterButtonLabel(activeFilterCount, t)} > 0 ? c.primary : c.textMuted} /> {activeFilterCount > 0 && ( @@ -1642,13 +1695,13 @@ export default function EmailListScreen({ )} {sizeFilterBytes(filters.minSizeKb) !== null ? ( setFilterField('minSizeKb', undefined)} /> ) : null} {sizeFilterBytes(filters.maxSizeKb) !== null ? ( setFilterField('maxSizeKb', undefined)} /> ) : null} @@ -1656,6 +1709,7 @@ export default function EmailListScreen({ onPress={() => { setSearchInput(''); setCustomSize(false); + setFilterFormKey((k) => k + 1); clearSearchAndFilters(); }} style={styles.clearAllButton} @@ -1813,14 +1867,17 @@ export default function EmailListScreen({ onRequestClose={() => setFilterMenuOpen(false)} > setFilterMenuOpen(false)}> - + onFilterBackdropLayout(e.nativeEvent.layout.height)} + > - + {t('email_list.filter_title', 'Filter emails')} { setCustomSize(false); setFilters({}); }} + onPress={clearFilters} style={styles.filterMenuHeaderBtn} hitSlop={6} accessibilityRole="button" @@ -1840,7 +1897,13 @@ export default function EmailListScreen({ - + {t('advanced_search.from', 'From')} @@ -2010,6 +2073,7 @@ export default function EmailListScreen({ accessibilityLabel={t('advanced_search.size_min', 'Larger than (KB)')} placeholderTextColor={c.textMuted} keyboardType="number-pad" + onFocus={revealSizeFields} style={styles.filterFieldInput} testID="advanced-search-min-size" /> @@ -2022,6 +2086,7 @@ export default function EmailListScreen({ accessibilityLabel={t('advanced_search.size_max', 'Smaller than (KB)')} placeholderTextColor={c.textMuted} keyboardType="number-pad" + onFocus={revealSizeFields} style={styles.filterFieldInput} testID="advanced-search-max-size" /> @@ -2447,7 +2512,6 @@ function makeStyles(c: ThemePalette) { filterMenu: { width: '100%', maxWidth: 380, - maxHeight: '85%', backgroundColor: c.popover, borderRadius: radius.md, borderWidth: 1, @@ -2491,6 +2555,7 @@ function makeStyles(c: ThemePalette) { justifyContent: 'center', borderRadius: radius.xs, }, + filterMenuScroll: { flexGrow: 0, flexShrink: 1 }, filterMenuBody: { padding: spacing.lg, gap: spacing.md, diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 65eb18f5..04707a78 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -15,10 +15,15 @@ import { } from '../components/icons'; 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'; -import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; +import { spacing, radius, typography, typography as tokenTypography, componentSizes, type ThemePalette } from '../theme/tokens'; +import { useTypography } from '../theme/dynamic'; +// The user's text size (Settings > Accessibility), as the list has it. +type ScaledTypography = ReturnType; import { useColors, useResolvedTheme } from '../theme/colors'; import { MoveSheet } from '../components/MoveSheet'; import { ThreadMessageCard, ThreadCardPlaceholder } from '../components/email/ThreadMessageCard'; @@ -35,7 +40,7 @@ import { import { patchKeywordsForEmails } from '../api/email'; import { loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekRow, peekThread, subscribeEmailCache, - type FlagsHint, + EmailNotFoundError, type FlagsHint, } from '../lib/email-detail-cache'; import { shareEmailEml } from '../lib/email-export'; import { useKeywordsStore, useAccountKeywordDefs, keywordToken, type KeywordDef } from '../stores/keywords-store'; @@ -50,7 +55,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'; @@ -95,7 +100,8 @@ export default function EmailThreadScreen(props: Props) { function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Props) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const { t } = useLocaleStore(); // The JMAP account the message lives in, as the screen that opened it named // it (undefined = the user's own). Never the open folder's: that may be @@ -113,8 +119,13 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr const { width: windowWidth } = useWindowDimensions(); // Pages are as wide as the pager itself, which is the window on a phone // but only the detail pane in a split view. Measured, so a rotation or a - // pane resize re-sizes them. - const [pageWidth, setPageWidth] = React.useState(windowWidth); + // pane resize re-sizes them. The pages render only once it is measured: + // laid out at the window's width first, a split view's pager scrolled the + // opened message to the wrong offset, and on a tablet the pane once stayed + // empty (just the toolbar and the bottom bar) until the message was + // reopened. + const [measuredWidth, setMeasuredWidth] = React.useState(0); + const pageWidth = measuredWidth || windowWidth; const markRead = useEmailStore((s) => s.markRead); const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails); const deleteEmail = useEmailStore((s) => s.deleteEmail); @@ -177,6 +188,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr const [moreMenuOpen, setMoreMenuOpen] = React.useState(false); const [moveMenuOpen, setMoveMenuOpen] = React.useState(false); const [tagMenuOpen, setTagMenuOpen] = React.useState(false); + // Tag changes made in the sheet get one Undo, shown once it closes. + const tagUndoGroup = useTagUndoGroup(tagMenuOpen); const [rulesOpen, setRulesOpen] = React.useState(false); const [addressSheet, setAddressSheet] = React.useState(null); // Per-message override of the light/dark rendering (More sheet toggle). @@ -233,9 +246,13 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr // Cache first: a held copy renders at once and the promise resolves with the // checked one (which mark-as-read goes by). Concurrent loads share a request. + // `onGone` hears when the server has no such message any more. const ensureDetail = React.useCallback( - (id: string): Promise => - loadDetail(id, ownerAccountId, hintFor(id)).catch(() => peekDetail(id, ownerAccountId) ?? null), + (id: string, onGone?: () => void): Promise => + loadDetail(id, ownerAccountId, hintFor(id)).catch((err) => { + if (err instanceof EmailNotFoundError) onGone?.(); + return peekDetail(id, ownerAccountId) ?? null; + }), [ownerAccountId, hintFor], ); const ensureDetails = React.useCallback((ids: string[]) => { @@ -324,12 +341,12 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr }, [pageWidth]); const onPagerLayout = React.useCallback((e: LayoutChangeEvent) => { const width = Math.round(e.nativeEvent.layout.width); - if (width > 0) setPageWidth((prev) => (prev === width ? prev : width)); + if (width > 0) setMeasuredWidth((prev) => (prev === width ? prev : width)); }, []); const pagerExtraData = React.useMemo( - () => [cacheVersion, neighboursReady], - [cacheVersion, neighboursReady], + () => [cacheVersion, neighboursReady, pageWidth], + [cacheVersion, neighboursReady, pageWidth], ); // While the body is pinch-zoomed the pager must not treat horizontal @@ -378,8 +395,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr // list row follows when the list holds that account (never a same-id row // of another one). const setKeyword = React.useCallback( - (target: Email, token: string, on: boolean) => - setKeywordForEmails([target.id], token, on, { email: target, accountId: ownerAccountId }), + (target: Email, token: string, on: boolean, undoGroup?: string) => + setKeywordForEmails([target.id], token, on, { email: target, accountId: ownerAccountId }, { undoGroup }), [setKeywordForEmails, ownerAccountId], ); @@ -414,10 +431,18 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr // this lands. Then mark it read per the user's delay. setError(null); void (async () => { - const fetched = await ensureDetail(activeEmailId); + let gone = false; + const fetched = await ensureDetail(activeEmailId, () => { gone = true; }); if (cancelled) return; if (!fetched) { - if (!detailOf(activeEmailId)) setError(t('email_viewer.load_failed', 'Failed to load email')); + if (!detailOf(activeEmailId)) { + // Deleted or moved elsewhere: say so (webmail deep_link + // message_not_found) and let the list drop its row. + setError(gone + ? t('deep_link.message_not_found', 'This message is no longer available.') + : t('email_viewer.load_failed', 'Failed to load email')); + if (gone) void useEmailStore.getState().refreshEmails(); + } return; } cancelRead = scheduleMarkRead(fetched); @@ -473,7 +498,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr if (next[token]) delete next[token]; else next[token] = true; updateLocalKeywords(email.id, next); - setKeyword(email, token, !!next[token]).catch((err) => { + setKeyword(email, token, !!next[token], tagUndoGroup || undefined).catch((err) => { updateLocalKeywords(email.id, email.keywords); toastFailure(t('notifications.tag_failed', 'Tagging failed'), err); }); @@ -795,10 +820,11 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr ) : ( <> + + {measuredWidth > 0 && ( )} /> + )} + {/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */} {kbObstruction === 0 && ( @@ -1257,7 +1286,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)} @@ -1382,7 +1411,8 @@ function MoreMenuSheet({ onForwardAsAttachment, onRules, onViewSource, onExport, }: MoreMenuSheetProps) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(600)).current; @@ -1548,7 +1578,8 @@ interface TagMenuSheetProps { function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); const t = useLocaleStore((s) => s.t); const insets = useSafeAreaInsets(); const slideY = React.useRef(new Animated.Value(500)).current; @@ -1633,7 +1664,8 @@ function MoreMenuItem({ icon, label, onPress, trailing, }: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); return ( void }) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); // Icons only in a short (landscape) window, which leaves room for the message. const labelsSetting = useSettingsStore((s) => s.showToolbarLabels); const { shortHeight } = useLayout(); @@ -1672,7 +1705,8 @@ function BottomBarButton({ icon, label, onPress, disabled, }: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) { const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); + const scaledType = useTypography(); + const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]); // Icons only in a short (landscape) window, which leaves room for the message. const labelsSetting = useSettingsStore((s) => s.showToolbarLabels); const { shortHeight } = useLayout(); @@ -1694,7 +1728,7 @@ function BottomBarButton({ ); } -function makeStyles(c: ThemePalette) { +function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) { return StyleSheet.create({ container: { flex: 1, backgroundColor: c.background }, @@ -1770,9 +1804,10 @@ function makeStyles(c: ThemePalette) { // Webmail viewer on a phone: text-lg font-bold tracking-tight. subjectText: { flex: 1, - fontSize: 18, + // 18 / 26 at the default text size, scaled with it. + fontSize: typography.h3.fontSize, fontWeight: '700', - lineHeight: 26, + lineHeight: Math.round((26 * typography.h3.fontSize) / 18), color: c.text, letterSpacing: -0.2, }, diff --git a/src/stores/__tests__/email-store-keyword-undo.test.ts b/src/stores/__tests__/email-store-keyword-undo.test.ts new file mode 100644 index 00000000..2b891b54 --- /dev/null +++ b/src/stores/__tests__/email-store-keyword-undo.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Actions the viewer runs on the message it shows, which may live in another +// account than the open folder (unified inbox, notification, deep link). +// Stalwart ids are only unique per account, so the fixtures reuse the same +// message id ("e1") and raw folder ids ("a" = Inbox, "t" = Trash) in the +// user's own account and in the group account "grp-1". + +vi.mock('../../api/email', () => ({ + getMailboxes: vi.fn(), + getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })), + getSharedMailboxes: vi.fn(async () => []), + getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })), + getMailboxChanges: vi.fn(async () => null), + queryEmails: vi.fn(), + getEmailQueryChanges: vi.fn(async () => null), + getEmailListDelta: vi.fn(async () => ({ queryChanges: null, changes: null, added: [], addedFetched: false, threads: [] })), + getEmails: vi.fn(), + getEmailsWithState: vi.fn(async () => ({ list: [], state: 'em-state-0' })), + getEmailChanges: vi.fn(async () => null), + getFullEmail: vi.fn(), + importEmailBlob: vi.fn(), + patchKeywordsForEmails: vi.fn(), + patchKeywordsPerEmail: vi.fn(), + moveEmail: vi.fn(), + moveEmails: vi.fn(), + archiveEmails: vi.fn(), + restoreEmailMailboxes: vi.fn(), + setEmailMailboxes: vi.fn(), + destroyEmails: vi.fn(), + deleteEmail: vi.fn(), + deleteEmails: vi.fn(), + searchEmails: vi.fn(), + markAsSpam: vi.fn(), + undoSpam: vi.fn(), + unprefixMailboxId: (id: string, accountId?: string) => + (accountId && id.startsWith(`${accountId}:`) ? id.slice(accountId.length + 1) : id), +})); + +vi.mock('../locale-store', () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + useLocaleStore: { getState: () => ({ locale: 'en', t: (_k: string, f?: string) => f ?? _k }) }, +})); + +// Online with nothing queued: run the online runner straight away. +vi.mock('../outbox-store', () => { + const applyOrQueueBatch = vi.fn(async (_ops: unknown[], onlineRun?: () => Promise) => { + if (onlineRun) await onlineRun(); + return { queued: false }; + }); + return { + applyOrQueueBatch, + applyOrQueue: async (op: unknown, onlineRun?: () => Promise) => applyOrQueueBatch([op], onlineRun), + useOutboxStore: { + getState: () => ({ entries: [], count: () => 0, setAccount: vi.fn(async () => undefined), flush: vi.fn() }), + }, + }; +}); + +vi.mock('../settings-store', () => { + const settings: Record = { archiveMode: 'single', deleteAction: 'trash' }; + return { + useSettingsStore: { + getState: () => ({ + ...settings, + updateSetting: (key: string, value: unknown) => { settings[key] = value; }, + }), + }, + }; +}); + +vi.mock('../offline-cache-store', () => ({ + useOfflineCacheStore: { + getState: () => ({ + has: () => false, + get: vi.fn(async () => null), + put: vi.fn(async () => undefined), + patch: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + }), + }, +})); + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + isConnected: true, + accountId: 'acc-1', + username: 'test@example.com', + serverUrl: 'https://mail.example.com', + getMaxObjectsInGet: () => 500, + getMaxCallsInRequest: () => 16, + getSharedMailAccounts: () => [{ id: 'grp-1', name: 'Support' }], + request: vi.fn(), + }, +})); + +import * as emailApi from '../../api/email'; +import { applyOrQueueBatch } from '../outbox-store'; +import { useEmailStore, type ViewedEmail } from '../email-store'; +import { useSettingsStore } from '../settings-store'; +import type { Email, Mailbox } from '../../api/types'; + +const mockDeleteEmail = emailApi.deleteEmail as ReturnType; +const mockMoveEmail = emailApi.moveEmail as ReturnType; +const mockRestore = emailApi.restoreEmailMailboxes as ReturnType; +const mockMarkAsSpam = emailApi.markAsSpam as ReturnType; +const mockUndoSpam = emailApi.undoSpam as ReturnType; +const mockArchive = emailApi.archiveEmails as ReturnType; +const mockPatchKeywords = emailApi.patchKeywordsForEmails as ReturnType; +const mockApplyOrQueueBatch = applyOrQueueBatch as unknown as ReturnType; + +function own(id: string, role: string): Mailbox { + return { id, name: role, role, accountId: 'acc-1', isShared: false } as Mailbox; +} +function team(rawId: string, role: string): Mailbox { + return { id: `grp-1:${rawId}`, originalId: rawId, name: role, role, accountId: 'grp-1', isShared: true } as Mailbox; +} + +const MAILBOXES = [ + own('a', 'inbox'), own('t', 'trash'), own('x', 'archive'), own('j', 'junk'), + team('a', 'inbox'), team('t', 'trash'), team('x', 'archive'), team('j', 'junk'), +]; + +const mockPatchPerEmail = emailApi.patchKeywordsPerEmail as ReturnType; +const ROW = { id: 'e1', subject: 'Hello', keywords: { $seen: true }, mailboxIds: { a: true } } as unknown as Email; + +beforeEach(() => { + vi.clearAllMocks(); + useEmailStore.getState().reset(); +}); + +describe('tag undo from a tag sheet', () => { + it('gathers the changes made in one sheet into one Undo that puts every tag back', async () => { + const tagged = { ...ROW, keywords: { $seen: true, '$label:blue': true } } as Email; + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [tagged] }); + const opts = { undoGroup: 'tags:1' }; + + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:orange', true, undefined, opts); + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:blue', false, undefined, opts); + // The bar sat under the sheet; closing it shows the bar from now. + useEmailStore.setState({ pendingUndo: { ...useEmailStore.getState().pendingUndo!, createdAt: 1 } }); + useEmailStore.getState().releaseUndoGroup('tags:1'); + const entry = useEmailStore.getState().pendingUndo!; + expect(entry.undoGroup).toBeUndefined(); + expect(entry.createdAt).toBeGreaterThan(1); + + await useEmailStore.getState().undoLast(); + expect(mockPatchPerEmail).toHaveBeenCalledWith( + [{ id: 'e1', patch: { '$label:orange': null, '$label:blue': true } }], + undefined, + ); + expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true, '$label:blue': true }); + }); + + it('offers Undo for a tag set in the viewer\'s tag sheet, and none for its star', async () => { + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [] }); + const viewed = { email: ROW, accountId: undefined }; + + await useEmailStore.getState().setKeywordForEmails(['e1'], '$flagged', true, viewed); + expect(useEmailStore.getState().pendingUndo).toBeNull(); + + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:orange', true, viewed, { undoGroup: 'tags:2' }); + expect(useEmailStore.getState().pendingUndo).toMatchObject({ kind: 'keywords', label: 'Email tagged' }); + + await useEmailStore.getState().undoLast(); + expect(mockPatchPerEmail).toHaveBeenCalledWith([{ id: 'e1', patch: { '$label:orange': null } }], undefined); + }); + + it('starts a new Undo for the next sheet', async () => { + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [ROW] }); + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:orange', true, undefined, { undoGroup: 'tags:3' }); + useEmailStore.getState().releaseUndoGroup('tags:3'); + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:green', true, undefined, { undoGroup: 'tags:4' }); + + expect(useEmailStore.getState().pendingUndo?.keywordPatch).toEqual({ '$label:green': true }); + }); + + it('wires both tag sheets to one Undo per sheet', () => { + const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + const list = read('screens/EmailListScreen.tsx'); + expect(list).toMatch(/const tagUndoGroup = useTagUndoGroup\(tagSheetOpen\);/); + expect(list).toMatch(/setKeywordForEmails\(selectedMessageIds, token, on, undefined, \{ undoGroup: tagUndoGroup \}\)/); + const viewer = read('screens/EmailThreadScreen.tsx'); + expect(viewer).toMatch(/const tagUndoGroup = useTagUndoGroup\(tagMenuOpen\);/); + expect(viewer).toMatch(/setKeyword\(email, token, !!next\[token\], tagUndoGroup \|\| undefined\)/); + }); +}); + +describe('keyword undo', () => { + it('undoes a star made after a tag', async () => { + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [ROW] }); + + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:orange', true); + await useEmailStore.getState().setKeywordForEmails(['e1'], '$flagged', true); + await useEmailStore.getState().undoLast(); + + expect(mockPatchPerEmail).toHaveBeenLastCalledWith([{ id: 'e1', patch: { $flagged: null } }], undefined); + expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true, '$label:orange': true }); + }); + + it("does not offer the tag's Undo while the star after it is on its way", async () => { + useEmailStore.setState({ mailboxes: MAILBOXES, currentMailboxId: 'a', emails: [ROW] }); + await useEmailStore.getState().setKeywordForEmails(['e1'], '$label:orange', true); + expect(useEmailStore.getState().pendingUndo?.keywordPatch).toEqual({ '$label:orange': true }); + + let release!: () => void; + mockPatchKeywords.mockImplementationOnce(() => new Promise((r) => { release = () => r(); })); + const starring = useEmailStore.getState().setKeywordForEmails(['e1'], '$flagged', true); + // Undo tapped on the bar still up from the tag. + await useEmailStore.getState().undoLast(); + expect(mockPatchPerEmail).not.toHaveBeenCalled(); + + release(); + await starring; + await useEmailStore.getState().undoLast(); + expect(mockPatchPerEmail).toHaveBeenCalledTimes(1); + expect(mockPatchPerEmail).toHaveBeenCalledWith([{ id: 'e1', patch: { $flagged: null } }], undefined); + expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true, '$label:orange': true }); + }); +}); diff --git a/src/stores/__tests__/outbox-store.test.ts b/src/stores/__tests__/outbox-store.test.ts index 2c88d927..609fb5f0 100644 --- a/src/stores/__tests__/outbox-store.test.ts +++ b/src/stores/__tests__/outbox-store.test.ts @@ -155,6 +155,25 @@ describe('flush', () => { expect(useOutboxStore.getState().entries).toHaveLength(0); }); + it('sends an edit merged into an entry while that entry was being sent', async () => { + useNetworkStore.setState({ online: false }); + useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $flagged: true } }); + useNetworkStore.setState({ online: true }); + + // The star is out when the Undo comes in and coalesces into its entry. + let release!: () => void; + patchKeywordsForEmails.mockImplementationOnce(() => new Promise((r) => { release = () => r(undefined); })); + const flushing = useOutboxStore.getState().flush(); + await vi.waitFor(() => expect(patchKeywordsForEmails).toHaveBeenCalledTimes(1)); + useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $flagged: null } }); + release(); + await flushing; + + await vi.waitFor(() => expect(patchKeywordsForEmails).toHaveBeenCalledTimes(2)); + expect(patchKeywordsForEmails).toHaveBeenLastCalledWith(['e1'], { $flagged: null }, undefined); + await vi.waitFor(() => expect(useOutboxStore.getState().entries).toHaveLength(0)); + }); + it('does nothing while offline', async () => { useNetworkStore.setState({ online: false }); useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } }); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index fccf662f..a4176993 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -55,6 +55,7 @@ import { useSettingsStore } from './settings-store'; import { useOfflineCacheStore } from './offline-cache-store'; import { useOutboxStore, applyOrQueue, applyOrQueueBatch, type OutboxOp } from './outbox-store'; import { useTagCountsStore } from './tag-counts-store'; +import { patchDetail, peekDetail } from '../lib/email-detail-cache'; // ── Refresh coalescing ───────────────────────────────────────────────── // Push events, mount effects and post-action follow-ups all call @@ -358,6 +359,11 @@ export interface UndoEntry { label: string; /** Time the entry was created - the snackbar uses this to drive its timer. */ createdAt: number; + /** + * The open tag sheet the changes came from: each further change made in it + * joins this entry, and closing it shows the bar afresh (releaseUndoGroup). + */ + undoGroup?: string; /** JMAP account the messages live under; unset for the user's own mail. */ accountId?: string; /** @@ -380,9 +386,46 @@ export interface UndoEntry { * (a list that spans accounts); `accountId` above applies otherwise. */ accountId?: string; + /** The viewer's message, not a row of the list: undo leaves the list alone. */ + unlisted?: boolean; }>; } +/** Options of setKeywordForEmails. */ +export interface KeywordChangeOptions { + /** + * The tag sheet the change is made in. Gives a viewer change an Undo too, + * and gathers every change made before the sheet closes into one Undo. + */ + undoGroup?: string; +} + +/** + * Adds a change made in the same tag sheet to its Undo: each message keeps + * the value a keyword had before the sheet first changed it. + */ +function joinKeywordUndo( + entry: UndoEntry, + items: UndoEntry['items'], + patch: KeywordPatch, +): Pick { + const before = entry.keywordPatch ?? {}; + const itemKey = (it: UndoEntry['items'][number]) => `${it.accountId ?? ''}|${rowKeyOf(it.email)}`; + const earlier = new Map(entry.items.map((it) => [itemKey(it), it])); + const joined = items.map((it) => { + const prev = earlier.get(itemKey(it)); + if (!prev) return it; + earlier.delete(itemKey(it)); + const originalKeywords = { ...it.originalKeywords }; + for (const keyword of Object.keys(before)) { + if (prev.originalKeywords?.[keyword]) originalKeywords[keyword] = true; + else delete originalKeywords[keyword]; + } + return { ...it, originalKeywords }; + }); + return { keywordPatch: { ...before, ...patch }, items: [...earlier.values(), ...joined] }; +} + // Cached emails for one mailbox (the base view: no search query, no filters). // `queryState` is the JMAP queryState for the matching Email/query, used to // drive Email/queryChanges on the next refresh. @@ -502,7 +545,14 @@ export interface EmailState { * `Email/set`. With `viewed`, on the viewer's message in its own account; * the list row follows only when the list holds that account. */ - setKeywordForEmails: (emailIds: string[], token: string, on: boolean, viewed?: ViewedEmail) => Promise; + setKeywordForEmails: ( + emailIds: string[], token: string, on: boolean, viewed?: ViewedEmail, options?: KeywordChangeOptions, + ) => Promise; + /** + * A tag sheet closed: its changes' Undo shows for the bar's full time from + * now, as it sat under the sheet so far. + */ + releaseUndoGroup: (undoGroup: string) => void; undoLast: () => Promise; clearUndo: () => void; searchEmails: (query: string) => Promise; @@ -2156,10 +2206,18 @@ export const useEmailStore = create()( if (refusal) throw refusal; }, - setKeywordForEmails: async (emailIds, token, on, viewed) => { + setKeywordForEmails: async (emailIds, token, on, viewed, options) => { const state = get(); const { targets, listed } = actionTargets(state, emailIds, viewed); if (targets.length === 0) return; + const undoGroup = options?.undoGroup; + // The list's changes get an Undo; the viewer's only from its tag sheet. + const offersUndo = (!viewed || !!undoGroup) && keywordUndoLabel(token, on, targets.length) !== null; + // While this change is on its way the bar must not still offer the + // previous one's Undo: tapping it right after a star undid the tag set + // before, and the star stayed. + const before = get().pendingUndo; + if (offersUndo && before && !(undoGroup && before.undoGroup === undoGroup)) set({ pendingUndo: null }); // One Email/set per account the selection spans (#1082); the viewer's // message goes to the account it names. const groups = viewed ? [{ accountId: viewed.accountId, emails: targets }] : groupByAccount(state, targets); @@ -2210,29 +2268,44 @@ export const useEmailStore = create()( for (const { accountId, emails } of written) { for (const e of emails) patchCache(e.id, { keywords: patch }, accountId); } - // Undo for a read, star or tag change from the list: each message gets - // back the keyword it had (the reader's own toggles get no bar). + // Undo for a read, star or tag change from the list, or a tag change in + // the viewer's tag sheet (webmail lib/email-action-toast.ts + // setSelectionTagWithUndo): each message gets back the keyword it had. + // The reader's own star and read toggles get no bar. const changed = written.flatMap(({ accountId, emails }) => emails.map((email) => ({ email, accountId }))); - const label = !viewed && changed.length > 0 ? keywordUndoLabel(token, on, changed.length) : null; + const label = offersUndo && changed.length > 0 ? keywordUndoLabel(token, on, changed.length) : null; if (label) { + const items: UndoEntry['items'] = changed.map(({ email, accountId }) => ({ + email, + originalMailboxIds: { ...email.mailboxIds }, + originalKeywords: { ...email.keywords }, + accountId, + ...(listed ? {} : { unlisted: true }), + })); + const current = get().pendingUndo; + const joined = undoGroup && current?.kind === 'keywords' && current.undoGroup === undoGroup + ? joinKeywordUndo(current, items, patch) + : { keywordPatch: patch, items }; set({ pendingUndo: { kind: 'keywords', label, createdAt: Date.now(), - keywordPatch: patch, - items: changed.map(({ email, accountId }) => ({ - email, - originalMailboxIds: { ...email.mailboxIds }, - originalKeywords: { ...email.keywords }, - accountId, - })), + ...(undoGroup ? { undoGroup } : {}), + ...joined, }, }); } if (refusal) throw refusal; }, + releaseUndoGroup: (undoGroup) => { + const entry = get().pendingUndo; + if (!entry || entry.undoGroup !== undoGroup) return; + const { undoGroup: _done, ...rest } = entry; + set({ pendingUndo: { ...rest, createdAt: Date.now() } }); + }, + undoLast: async () => { const entry = get().pendingUndo; if (!entry) return; @@ -2246,6 +2319,7 @@ export const useEmailStore = create()( id: it.email.id, key: rowKeyOf(it.email), accountId: it.accountId ?? entry.accountId, + listed: !it.unlisted, patch: revertKeywordPatch(keywordPatch, it.originalKeywords), })); const accounts = new Set(undo.map((u) => u.accountId ?? '')); @@ -2265,14 +2339,19 @@ export const useEmailStore = create()( set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') }); return; } - const byKey = new Map(undo.map((u) => [u.key, u.patch])); + const byKey = new Map(undo.filter((u) => u.listed).map((u) => [u.key, u.patch])); set({ emails: get().emails.map((e) => { const patch = byKey.get(rowKeyOf(e)); return patch ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : e; }), }); - for (const u of undo) patchCache(u.id, { keywords: u.patch }, u.accountId); + for (const u of undo) { + patchCache(u.id, { keywords: u.patch }, u.accountId); + // The open viewer reads the message from the detail cache. + const held = peekDetail(u.id, u.accountId); + if (held) patchDetail(u.id, u.accountId, { keywords: applyKeywordPatch(held.keywords, u.patch) }); + } return; } diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index 2f03d6de..7f78a33b 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -394,6 +394,8 @@ export const useOutboxStore = create((set, get) => ({ set({ flushing: true }); let brokeTransient = false; let dropped = false; + // An entry that took on a later edit while its op was out runs again. + let changedInFlight = false; try { // Process oldest-first so dependent moves replay in the order they were // made. We snapshot the order but re-read the live list each iteration, @@ -413,7 +415,9 @@ export const useOutboxStore = create((set, get) => ({ try { await runOp(entry.op); - removeEntry(accountId, entry.id); + // An edit queued meanwhile was merged into this entry (an Undo of + // the star being sent): dropping the entry would lose it. + if (!removeEntry(accountId, entry.id, entry.op)) changedInFlight = true; retryAttempt = 0; } catch (err) { if (get().activeAccountId !== accountId) break; @@ -446,6 +450,7 @@ export const useOutboxStore = create((set, get) => ({ set({ flushing: false }); } if (brokeTransient && get().entries.length > 0) scheduleRetry(); + else if (changedInFlight) void get().flush(); if (dropped) void refreshAfterDrop(); }, @@ -502,12 +507,20 @@ export const useOutboxStore = create((set, get) => ({ // ── Internal helpers that mutate+persist the live list ────────────────────── -function removeEntry(accountId: string, id: string): void { +/** + * Drops a replayed entry. With `sent`, only while the entry still holds that + * op: one an enqueue coalesced into while it was out stays, and false says + * so. + */ +function removeEntry(accountId: string, id: string, sent?: OutboxOp): boolean { const store = useOutboxStore.getState(); - if (store.activeAccountId !== accountId) return; + if (store.activeAccountId !== accountId) return true; + const current = store.entries.find((e) => e.id === id); + if (current && sent && current.op !== sent) return false; const entries = store.entries.filter((e) => e.id !== id); useOutboxStore.setState({ entries }); persist(accountId, entries); + return true; } function moveToFailed(accountId: string, id: string, err: unknown): void { diff --git a/src/stores/send-undo-store.ts b/src/stores/send-undo-store.ts index 7b3b90ef..b14f5e33 100644 --- a/src/stores/send-undo-store.ts +++ b/src/stores/send-undo-store.ts @@ -77,6 +77,22 @@ function refuseOtherLogin(entry: PendingUndoSend): void { ); } +/** + * Whole seconds until a held send goes out, counted on this device's clock + * from when the delay started: the bar counts down "Sending in 10s…", + * "Sending in 9s…" and so on. Never below 1, since the bar closes a second + * before the release. + */ +export function undoSendSecondsLeft(entry: Pick, now: number): number { + const left = Math.ceil((entry.createdAt + entry.delaySeconds * 1000 - now) / 1000); + return Math.max(1, Math.min(entry.delaySeconds, left)); +} + +/** The undo bar's text for a held send at `now`. */ +export function undoSendLabel(entry: Pick, now: number): string { + return t('email_composer.undo_send_label', 'Sending in {seconds}s…', { seconds: undoSendSecondsLeft(entry, now) }); +} + interface SendUndoState { pending: PendingUndoSend | null; busy: boolean;