mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
Merge branch 'cu-mail' into catch-up-2026-11
This commit is contained in:
+4
-1
@@ -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": {
|
||||
|
||||
@@ -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] }) }],
|
||||
},
|
||||
|
||||
@@ -137,6 +137,19 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
// posts its first message, then replay in order.
|
||||
const readyRef = React.useRef(false);
|
||||
const queuedRef = React.useRef<string[]>([]);
|
||||
// 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<RichTextEditorHandle, Props>(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<RichTextEditorHandle, Props>(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) {
|
||||
|
||||
@@ -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() {
|
||||
]}
|
||||
>
|
||||
<View style={styles.bar} accessibilityLiveRegion="polite">
|
||||
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
|
||||
{/* The spoken label stays the first one, so the live region is not
|
||||
read out again every second of the countdown. */}
|
||||
<Text style={styles.label} numberOfLines={1} accessibilityLabel={shown.label}>
|
||||
{shown.send ? undoSendLabel(shown.send, now) : shown.label}
|
||||
</Text>
|
||||
{shown.send && (
|
||||
<Pressable
|
||||
onPress={onSendNow}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Tablet, landscape: the first message opened after a relaunch and a
|
||||
// rotation showed an empty reading pane (toolbar and bottom bar only). The
|
||||
// pager laid its pages out at the window's width before it knew the pane's,
|
||||
// so initialScrollIndex put the open message at the wrong offset, and
|
||||
// removeClippedSubviews could leave that page detached. The pages now wait
|
||||
// for the measured width. No React Native renderer in the test env, so this
|
||||
// checks the wiring.
|
||||
|
||||
const src = fs.readFileSync(path.resolve(__dirname, '..', '..', 'screens/EmailThreadScreen.tsx'), 'utf8');
|
||||
|
||||
describe('reader pager width', () => {
|
||||
it('renders the pages only once the pager is measured', () => {
|
||||
expect(src).toMatch(/<View style=\{styles\.pagerViewport\} onLayout=\{onPagerLayout\}>\s+\{measuredWidth > 0 && \(\s+<FlatList/);
|
||||
expect(src).toMatch(/const pageWidth = measuredWidth \|\| windowWidth;/);
|
||||
});
|
||||
|
||||
it('does not clip the pages away', () => {
|
||||
const pager = src.slice(src.indexOf('<FlatList'), src.indexOf('renderItem={({ item, index })'));
|
||||
expect(pager).not.toMatch(/^\s+removeClippedSubviews\s*$/m);
|
||||
});
|
||||
|
||||
it('re-renders the pages when their width changes', () => {
|
||||
expect(src).toMatch(/\(\) => \[cacheVersion, neighboursReady, pageWidth\]/);
|
||||
});
|
||||
});
|
||||
@@ -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,/);
|
||||
});
|
||||
});
|
||||
@@ -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\}/);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useTypography>;
|
||||
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',
|
||||
|
||||
@@ -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<typeof useTypography>;
|
||||
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 (
|
||||
<View style={bare ? (content.fill ? styles.bareFill : undefined) : styles.expanded}>
|
||||
{!bare && (
|
||||
<Pressable onPress={onToggleExpanded} style={styles.collapseHandle} hitSlop={6} accessibilityLabel={t('threads.collapse', 'Collapse conversation')} />
|
||||
)}
|
||||
<MessageContent {...content} compact={false} onToggleStar={bare ? undefined : onToggleStar} />
|
||||
{!bare && (
|
||||
{!bare && replyActions !== 'hidden' && (
|
||||
<View style={[styles.actions, !replyable && styles.actionsDisabled]}>
|
||||
<Pressable style={styles.actionBtn} onPress={reply('reply')} hitSlop={6}>
|
||||
<Pressable style={styles.actionBtn} onPress={reply('reply')} disabled={!replyable} hitSlop={6} {...actionA11y}>
|
||||
<Reply size={16} color={c.text} />
|
||||
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} hitSlop={6}>
|
||||
<Pressable style={styles.actionBtn} onPress={reply('replyAll')} disabled={!replyable} hitSlop={6} {...actionA11y}>
|
||||
<ReplyAll size={16} color={c.text} />
|
||||
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.actionBtn} onPress={reply('forward')} hitSlop={6}>
|
||||
<Pressable style={styles.actionBtn} onPress={reply('forward')} disabled={!replyable} hitSlop={6} {...actionA11y}>
|
||||
<Forward size={16} color={c.text} />
|
||||
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
|
||||
</Pressable>
|
||||
@@ -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 (
|
||||
<View style={styles.collapsed}>
|
||||
<View style={styles.placeholderAvatar} />
|
||||
@@ -116,7 +130,7 @@ export function ThreadCardPlaceholder() {
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) {
|
||||
return StyleSheet.create({
|
||||
collapsed: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | number>) =>
|
||||
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(/<View style=\{\[styles\.filterMenu, \{ maxHeight: filterFrame\.maxHeight \}\]\}>/);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | number>) =>
|
||||
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 \}\}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cardReplyActions } from '../thread-card-actions';
|
||||
|
||||
// A draft in a conversation showed Reply / Reply all / Forward greyed out,
|
||||
// announced to screen readers as enabled buttons. The webmail renders no
|
||||
// reply buttons on a draft or a scheduled message; neither does the card.
|
||||
|
||||
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8');
|
||||
const NONE = new Set<string>();
|
||||
|
||||
describe('reply row of a conversation card', () => {
|
||||
it('hides the row on a draft and on a scheduled message', () => {
|
||||
expect(cardReplyActions({ id: 'd1', keywords: { $draft: true } }, true, NONE)).toBe('hidden');
|
||||
expect(cardReplyActions({ id: 'd1', keywords: { $draft: true } }, false, NONE)).toBe('hidden');
|
||||
expect(cardReplyActions({ id: 's1', keywords: {} }, true, new Set(['s1']))).toBe('hidden');
|
||||
});
|
||||
|
||||
it('shows it disabled while the body loads, then enabled', () => {
|
||||
expect(cardReplyActions({ id: 'm1', keywords: { $seen: true } }, false, NONE)).toBe('disabled');
|
||||
expect(cardReplyActions({ id: 'm1', keywords: { $seen: true } }, true, NONE)).toBe('enabled');
|
||||
});
|
||||
|
||||
it('renders no row for a hidden state and marks disabled buttons for screen readers', () => {
|
||||
const card = read('components/email/ThreadMessageCard.tsx');
|
||||
expect(card).toMatch(/\{!bare && replyActions !== 'hidden' && \(/);
|
||||
expect(card).toMatch(/accessibilityState: \{ disabled: !replyable \}/);
|
||||
expect(read('screens/EmailThreadScreen.tsx')).toMatch(/replyActions=\{cardReplyActions\(m, !!full, scheduledIds\)\}/);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ describe('replying in a conversation (bulwarkmail/webmail#1404)', () => {
|
||||
|
||||
it('is wired into the conversation cards and the quick reply', () => {
|
||||
const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailThreadScreen.tsx'), 'utf8');
|
||||
expect(screen).toMatch(/replyable=\{!!full && isReplyableMember\(m, scheduledIds\)\}/);
|
||||
expect(screen).toMatch(/replyActions=\{cardReplyActions\(m, !!full, scheduledIds\)\}/);
|
||||
expect(screen).toMatch(/const newest = quickReplyTarget\(/);
|
||||
expect(screen).toMatch(/scheduledEmailIdsInThread\(threadId, jmapAccountId\)/);
|
||||
});
|
||||
|
||||
@@ -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<string, { at: number; patch: FlagsHint }>();
|
||||
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<void> {
|
||||
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<string, Email>();
|
||||
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<void> {
|
||||
if (ids.length === 0) return;
|
||||
const gen = generation;
|
||||
const startedAt = localClock;
|
||||
let gone = new Set<string>();
|
||||
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<void>
|
||||
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<void>
|
||||
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;
|
||||
|
||||
@@ -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<string, boolean> | 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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, string | number>) => 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
|
||||
|
||||
@@ -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, string | number>) => 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 };
|
||||
}
|
||||
@@ -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 : '';
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// The Reply / Reply all / Forward row under an open message of a
|
||||
// conversation. A draft or a scheduled message gets none (webmail
|
||||
// email-viewer.tsx: the reply buttons render only `!isDraft && !isScheduled`);
|
||||
// greyed-out buttons that a screen reader announced as enabled were left on
|
||||
// drafts. A message whose body is still loading shows them disabled.
|
||||
|
||||
import type { Email } from '../api/types';
|
||||
import { isReplyableMember } from './thread-utils';
|
||||
|
||||
export type CardReplyActions = 'hidden' | 'disabled' | 'enabled';
|
||||
|
||||
export function cardReplyActions(
|
||||
email: Pick<Email, 'id' | 'keywords'>,
|
||||
loaded: boolean,
|
||||
scheduledIds: ReadonlySet<string>,
|
||||
): CardReplyActions {
|
||||
if (!isReplyableMember(email, scheduledIds)) return 'hidden';
|
||||
return loaded ? 'enabled' : 'disabled';
|
||||
}
|
||||
@@ -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<Set<string>>(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<ScrollView>(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 ? (
|
||||
<SquareCheck size={18} color={c.primary} />
|
||||
@@ -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)}
|
||||
>
|
||||
<Filter size={18} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
|
||||
{activeFilterCount > 0 && (
|
||||
@@ -1642,13 +1695,13 @@ export default function EmailListScreen({
|
||||
)}
|
||||
{sizeFilterBytes(filters.minSizeKb) !== null ? (
|
||||
<FilterChip
|
||||
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.size_min', 'Larger than (KB)'), value: `${filters.minSizeKb} KB` })}
|
||||
label={sizeChipLabel('min', filters.minSizeKb ?? '', t)}
|
||||
onRemove={() => setFilterField('minSizeKb', undefined)}
|
||||
/>
|
||||
) : null}
|
||||
{sizeFilterBytes(filters.maxSizeKb) !== null ? (
|
||||
<FilterChip
|
||||
label={t('email_list.filter_chip', '{field}: {value}', { field: t('advanced_search.size_max', 'Smaller than (KB)'), value: `${filters.maxSizeKb} KB` })}
|
||||
label={sizeChipLabel('max', filters.maxSizeKb ?? '', t)}
|
||||
onRemove={() => 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)}
|
||||
>
|
||||
<TouchableWithoutFeedback accessible={false} onPress={() => setFilterMenuOpen(false)}>
|
||||
<View style={styles.filterBackdrop}>
|
||||
<View
|
||||
style={[styles.filterBackdrop, { paddingTop: filterFrame.paddingTop, paddingBottom: filterFrame.paddingBottom }]}
|
||||
onLayout={(e) => onFilterBackdropLayout(e.nativeEvent.layout.height)}
|
||||
>
|
||||
<TouchableWithoutFeedback accessible={false}>
|
||||
<View style={styles.filterMenu}>
|
||||
<View style={[styles.filterMenu, { maxHeight: filterFrame.maxHeight }]}>
|
||||
<View style={styles.filterMenuHeader}>
|
||||
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
|
||||
<View style={styles.filterMenuHeaderActions}>
|
||||
<Pressable
|
||||
onPress={() => { setCustomSize(false); setFilters({}); }}
|
||||
onPress={clearFilters}
|
||||
style={styles.filterMenuHeaderBtn}
|
||||
hitSlop={6}
|
||||
accessibilityRole="button"
|
||||
@@ -1840,7 +1897,13 @@ export default function EmailListScreen({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.filterMenuBody} keyboardShouldPersistTaps="handled">
|
||||
<ScrollView
|
||||
key={filterFormKey}
|
||||
ref={filterScrollRef}
|
||||
style={styles.filterMenuScroll}
|
||||
contentContainerStyle={styles.filterMenuBody}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.filterFieldRow}>
|
||||
<View style={styles.filterFieldHalf}>
|
||||
<Text style={styles.filterFieldLabel}>{t('advanced_search.from', 'From')}</Text>
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof useTypography>;
|
||||
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<EmailAddress | null>(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<Email | null> =>
|
||||
loadDetail(id, ownerAccountId, hintFor(id)).catch(() => peekDetail(id, ownerAccountId) ?? null),
|
||||
(id: string, onGone?: () => void): Promise<Email | null> =>
|
||||
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
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.pagerViewport} onLayout={onPagerLayout}>
|
||||
{measuredWidth > 0 && (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.pagerViewport}
|
||||
onLayout={onPagerLayout}
|
||||
onContentSizeChange={snapToActive}
|
||||
data={emails}
|
||||
extraData={pagerExtraData}
|
||||
@@ -811,7 +837,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
windowSize={3}
|
||||
initialNumToRender={1}
|
||||
maxToRenderPerBatch={2}
|
||||
removeClippedSubviews
|
||||
// Not removeClippedSubviews: with three pages it saves nothing,
|
||||
// and Android can leave the open page detached after a resize.
|
||||
scrollEnabled={!pagerLocked && kbObstruction === 0}
|
||||
onScrollBeginDrag={releaseNeighbours}
|
||||
onMomentumScrollEnd={onMomentumEnd}
|
||||
@@ -856,6 +883,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 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 (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
@@ -1650,7 +1682,8 @@ function ToolbarButton({
|
||||
icon, label, onPress,
|
||||
}: { icon: React.ReactNode; label: string; onPress?: () => 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,
|
||||
},
|
||||
|
||||
@@ -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<void>) => {
|
||||
if (onlineRun) await onlineRun();
|
||||
return { queued: false };
|
||||
});
|
||||
return {
|
||||
applyOrQueueBatch,
|
||||
applyOrQueue: async (op: unknown, onlineRun?: () => Promise<void>) => applyOrQueueBatch([op], onlineRun),
|
||||
useOutboxStore: {
|
||||
getState: () => ({ entries: [], count: () => 0, setAccount: vi.fn(async () => undefined), flush: vi.fn() }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../settings-store', () => {
|
||||
const settings: Record<string, unknown> = { 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<typeof vi.fn>;
|
||||
const mockMoveEmail = emailApi.moveEmail as ReturnType<typeof vi.fn>;
|
||||
const mockRestore = emailApi.restoreEmailMailboxes as ReturnType<typeof vi.fn>;
|
||||
const mockMarkAsSpam = emailApi.markAsSpam as ReturnType<typeof vi.fn>;
|
||||
const mockUndoSpam = emailApi.undoSpam as ReturnType<typeof vi.fn>;
|
||||
const mockArchive = emailApi.archiveEmails as ReturnType<typeof vi.fn>;
|
||||
const mockPatchKeywords = emailApi.patchKeywordsForEmails as ReturnType<typeof vi.fn>;
|
||||
const mockApplyOrQueueBatch = applyOrQueueBatch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
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<typeof vi.fn>;
|
||||
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<void>((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 });
|
||||
});
|
||||
});
|
||||
@@ -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<undefined>((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 } });
|
||||
|
||||
+93
-14
@@ -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<UndoEntry, 'keywordPatch' | 'items'> {
|
||||
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<void>;
|
||||
setKeywordForEmails: (
|
||||
emailIds: string[], token: string, on: boolean, viewed?: ViewedEmail, options?: KeywordChangeOptions,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
clearUndo: () => void;
|
||||
searchEmails: (query: string) => Promise<Email[]>;
|
||||
@@ -2156,10 +2206,18 @@ export const useEmailStore = create<EmailState>()(
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -394,6 +394,8 @@ export const useOutboxStore = create<OutboxState>((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<OutboxState>((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<OutboxState>((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<OutboxState>((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 {
|
||||
|
||||
@@ -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<PendingUndoSend, 'createdAt' | 'delaySeconds'>, 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<PendingUndoSend, 'createdAt' | 'delaySeconds'>, 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;
|
||||
|
||||
Reference in New Issue
Block a user