mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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_search": {
|
||||||
|
"advanced_filters_count": "{name}, {count} active",
|
||||||
"after": "After",
|
"after": "After",
|
||||||
"before": "Before",
|
"before": "Before",
|
||||||
|
"larger_than_kb": "Larger than {size} KB",
|
||||||
"no_attachment": "No attachment",
|
"no_attachment": "No attachment",
|
||||||
"not_starred": "Not starred"
|
"not_starred": "Not starred",
|
||||||
|
"smaller_than_kb": "Smaller than {size} KB"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"a11y": {
|
"a11y": {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 { BellRing, X } from './icons';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
@@ -52,6 +53,8 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
// Full width above the tab bar on a phone; bottom right on a tablet.
|
// Full width above the tab bar on a phone; bottom right on a tablet.
|
||||||
const frame = useFloatingCardFrame();
|
const frame = useFloatingCardFrame();
|
||||||
|
// Above the reading pane's bottom bar on a split view.
|
||||||
|
const { isSplit } = useLayout();
|
||||||
const duration = useAnimDuration(200);
|
const duration = useAnimDuration(200);
|
||||||
const appear = React.useRef(new Animated.Value(0)).current;
|
const appear = React.useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
@@ -118,7 +121,7 @@ export function PushOnboardingPrompt(): React.ReactElement | null {
|
|||||||
style={[
|
style={[
|
||||||
styles.wrap,
|
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,
|
opacity: appear,
|
||||||
transform: [{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) }],
|
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.
|
// posts its first message, then replay in order.
|
||||||
const readyRef = React.useRef(false);
|
const readyRef = React.useRef(false);
|
||||||
const queuedRef = React.useRef<string[]>([]);
|
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) => {
|
const call = React.useCallback((script: string) => {
|
||||||
if (!readyRef.current) {
|
if (!readyRef.current) {
|
||||||
queuedRef.current.push(script);
|
queuedRef.current.push(script);
|
||||||
@@ -203,15 +216,17 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
call(`window.__rne && window.__rne.getHtml(${id})`);
|
call(`window.__rne && window.__rne.getHtml(${id})`);
|
||||||
}),
|
}),
|
||||||
focusEnd: () => {
|
focusEnd: () => {
|
||||||
|
requestNativeFocus();
|
||||||
call(`window.__rne && window.__rne.focusEnd()`);
|
call(`window.__rne && window.__rne.focusEnd()`);
|
||||||
},
|
},
|
||||||
focus: () => {
|
focus: () => {
|
||||||
|
requestNativeFocus();
|
||||||
call(`window.__rne && window.__rne.focus()`);
|
call(`window.__rne && window.__rne.focus()`);
|
||||||
},
|
},
|
||||||
blur: () => {
|
blur: () => {
|
||||||
call(`document.activeElement && document.activeElement.blur()`);
|
call(`document.activeElement && document.activeElement.blur()`);
|
||||||
},
|
},
|
||||||
}), [call]);
|
}), [call, requestNativeFocus]);
|
||||||
|
|
||||||
const onMessage = (event: WebViewMessageEvent) => {
|
const onMessage = (event: WebViewMessageEvent) => {
|
||||||
let data: { type: string; payload: unknown };
|
let data: { type: string; payload: unknown };
|
||||||
@@ -225,6 +240,12 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
perfEndAfterPaint('compose-open');
|
perfEndAfterPaint('compose-open');
|
||||||
const queued = queuedRef.current;
|
const queued = queuedRef.current;
|
||||||
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;`);
|
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||||
}
|
}
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
|
|||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { undoSnackbarColors } from './undo-snackbar-colors';
|
import { undoSnackbarColors } from './undo-snackbar-colors';
|
||||||
import { useEmailStore } from '../stores/email-store';
|
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 { useLocaleStore } from '../stores/locale-store';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { announce } from '../lib/announce';
|
import { announce } from '../lib/announce';
|
||||||
@@ -56,7 +56,8 @@ export function UndoSnackbar() {
|
|||||||
: null;
|
: null;
|
||||||
const send: Shown | null = pendingSend
|
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,
|
createdAt: pendingSend.createdAt,
|
||||||
visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000),
|
visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000),
|
||||||
send: pendingSend,
|
send: pendingSend,
|
||||||
@@ -74,6 +75,16 @@ export function UndoSnackbar() {
|
|||||||
if (current) setShown(current);
|
if (current) setShown(current);
|
||||||
}, [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
|
// 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.
|
// bar's live region. Once per entry, not on every countdown re-render.
|
||||||
const undoLabel = t('common.undo', 'Undo');
|
const undoLabel = t('common.undo', 'Undo');
|
||||||
@@ -148,7 +159,11 @@ export function UndoSnackbar() {
|
|||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.bar} accessibilityLiveRegion="polite">
|
<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 && (
|
{shown.send && (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onSendNow}
|
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,
|
Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import type { Email, EmailAddress, Identity } from '../../api/types';
|
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 { useColors } from '../../theme/colors';
|
||||||
import SenderAvatar from '../SenderAvatar';
|
import SenderAvatar from '../SenderAvatar';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
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) {
|
export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact, jmapAccountId }: Props) {
|
||||||
const c = useColors();
|
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 t = useLocaleStore((s) => s.t);
|
||||||
const locale = useLocaleStore((s) => s.locale);
|
const locale = useLocaleStore((s) => s.locale);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
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({
|
return StyleSheet.create({
|
||||||
block: {
|
block: {
|
||||||
backgroundColor: c.background,
|
backgroundColor: c.background,
|
||||||
@@ -430,7 +434,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
detailRow: { flexDirection: 'row', gap: spacing.sm },
|
detailRow: { flexDirection: 'row', gap: spacing.sm },
|
||||||
detailLabel: { ...typography.caption, color: c.textMuted, width: 92 },
|
detailLabel: { ...typography.caption, color: c.textMuted, width: 92 },
|
||||||
detailValue: { ...typography.caption, color: c.text, flex: 1 },
|
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 },
|
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginBottom: spacing.xs },
|
||||||
chip: {
|
chip: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import React from 'react';
|
|||||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
import { Paperclip, Reply, ReplyAll, Forward, Star } from '../icons';
|
import { Paperclip, Reply, ReplyAll, Forward, Star } from '../icons';
|
||||||
import type { Email } from '../../api/types';
|
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 { useColors } from '../../theme/colors';
|
||||||
import { useSettingsStore } from '../../stores/settings-store';
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
@@ -13,6 +16,7 @@ import { singleLine } from '../../lib/single-line';
|
|||||||
import { cleanPreview } from '../../lib/preview-text';
|
import { cleanPreview } from '../../lib/preview-text';
|
||||||
import { useSenderWarning } from './use-sender-warning';
|
import { useSenderWarning } from './use-sender-warning';
|
||||||
import { useOwnDomainAddress } from '../../lib/use-own-domain-address';
|
import { useOwnDomainAddress } from '../../lib/use-own-domain-address';
|
||||||
|
import type { CardReplyActions } from '../../lib/thread-card-actions';
|
||||||
|
|
||||||
interface Props extends MessageContentProps {
|
interface Props extends MessageContentProps {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
@@ -24,8 +28,11 @@ interface Props extends MessageContentProps {
|
|||||||
* the card so the content survives a conversation arriving around it.
|
* the card so the content survives a conversation arriving around it.
|
||||||
*/
|
*/
|
||||||
bare?: boolean;
|
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.
|
* actions - the webmail's thread-conversation-view cards.
|
||||||
*/
|
*/
|
||||||
export function ThreadMessageCard({
|
export function ThreadMessageCard({
|
||||||
expanded, onToggleExpanded, onReply, bare, replyable = true, ...content
|
expanded, onToggleExpanded, onReply, bare, replyActions = 'enabled', ...content
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { email, onToggleStar } = content;
|
const { email, onToggleStar } = content;
|
||||||
const c = useColors();
|
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 t = useLocaleStore((s) => s.t);
|
||||||
const locale = useLocaleStore((s) => s.locale);
|
const locale = useLocaleStore((s) => s.locale);
|
||||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
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
|
// The content keeps its place among the children whether bare or not, so
|
||||||
// it (and its WebView) is not remounted when a conversation arrives.
|
// 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 reply = (mode: 'reply' | 'replyAll' | 'forward') => (replyable ? () => onReply(mode, email) : undefined);
|
||||||
|
const actionA11y = {
|
||||||
|
accessibilityRole: 'button' as const,
|
||||||
|
accessibilityState: { disabled: !replyable },
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<View style={bare ? (content.fill ? styles.bareFill : undefined) : styles.expanded}>
|
<View style={bare ? (content.fill ? styles.bareFill : undefined) : styles.expanded}>
|
||||||
{!bare && (
|
{!bare && (
|
||||||
<Pressable onPress={onToggleExpanded} style={styles.collapseHandle} hitSlop={6} accessibilityLabel={t('threads.collapse', 'Collapse conversation')} />
|
<Pressable onPress={onToggleExpanded} style={styles.collapseHandle} hitSlop={6} accessibilityLabel={t('threads.collapse', 'Collapse conversation')} />
|
||||||
)}
|
)}
|
||||||
<MessageContent {...content} compact={false} onToggleStar={bare ? undefined : onToggleStar} />
|
<MessageContent {...content} compact={false} onToggleStar={bare ? undefined : onToggleStar} />
|
||||||
{!bare && (
|
{!bare && replyActions !== 'hidden' && (
|
||||||
<View style={[styles.actions, !replyable && styles.actionsDisabled]}>
|
<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} />
|
<Reply size={16} color={c.text} />
|
||||||
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
|
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
|
||||||
</Pressable>
|
</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} />
|
<ReplyAll size={16} color={c.text} />
|
||||||
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
|
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
|
||||||
</Pressable>
|
</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} />
|
<Forward size={16} color={c.text} />
|
||||||
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
|
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -104,7 +117,8 @@ export function ThreadMessageCard({
|
|||||||
*/
|
*/
|
||||||
export function ThreadCardPlaceholder() {
|
export function ThreadCardPlaceholder() {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const scaledType = useTypography();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]);
|
||||||
return (
|
return (
|
||||||
<View style={styles.collapsed}>
|
<View style={styles.collapsed}>
|
||||||
<View style={styles.placeholderAvatar} />
|
<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({
|
return StyleSheet.create({
|
||||||
collapsed: {
|
collapsed: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
vi.mock('../../api/jmap-client', () => ({
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
jmapClient: { accountId: 'own', serverUrl: 'https://mail.example', username: 'me' },
|
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 { useOfflineCacheStore } from '../../stores/offline-cache-store';
|
||||||
import { dispatchStateChange } from '../state-change-bus';
|
import { dispatchStateChange } from '../state-change-bus';
|
||||||
import {
|
import {
|
||||||
clearEmailDetailCache, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState,
|
clearEmailDetailCache, EmailNotFoundError, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState,
|
||||||
patchDetail, peekDetail, peekRow, peekThread, prefetchMessage, rememberRows, subscribeEmailCache,
|
patchDetail, peekDetail, peekRow, peekThread, prefetchMessage, rememberRows, subscribeEmailCache,
|
||||||
} from '../email-detail-cache';
|
} from '../email-detail-cache';
|
||||||
|
|
||||||
@@ -158,6 +160,31 @@ describe('loading a message', () => {
|
|||||||
expect(peekDetail('e1')).toBeUndefined();
|
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 () => {
|
it('rejects with the fetch error when nothing is held', async () => {
|
||||||
fullGet.mockRejectedValue(new Error('boom'));
|
fullGet.mockRejectedValue(new Error('boom'));
|
||||||
await expect(loadDetail('e1')).rejects.toThrow('boom');
|
await expect(loadDetail('e1')).rejects.toThrow('boom');
|
||||||
@@ -320,6 +347,49 @@ describe('loading a conversation', () => {
|
|||||||
expect(threadGet).toHaveBeenCalledWith(['t1'], 'group');
|
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 () => {
|
it('applies local keyword changes to the conversation headers too', async () => {
|
||||||
threadGet.mockResolvedValue(threadResponse('t1', headers(['m1'])));
|
threadGet.mockResolvedValue(threadResponse('t1', headers(['m1'])));
|
||||||
await loadThread('t1');
|
await loadThread('t1');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { listAddressLabel } from '../list-sender';
|
import { listAddressLabel, rowShowsRecipient } from '../list-sender';
|
||||||
import { toUnicodeEmailOnOwnDomains } from '../idn';
|
import { toUnicodeEmailOnOwnDomains } from '../idn';
|
||||||
|
|
||||||
// A sender with no display name on one of the user's own IDN domains showed
|
// 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/);
|
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', () => {
|
it('is wired into the conversation cards and the quick reply', () => {
|
||||||
const screen = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailThreadScreen.tsx'), 'utf8');
|
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(/const newest = quickReplyTarget\(/);
|
||||||
expect(screen).toMatch(/scheduledEmailIdsInThread\(threadId, jmapAccountId\)/);
|
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. */
|
/** The copy held for a message, if any. Synchronous: for rendering. */
|
||||||
export function peekDetail(id: string, accountId?: string): Email | undefined {
|
export function peekDetail(id: string, accountId?: string): Email | undefined {
|
||||||
return details.get(keyOf(id, accountId))?.email;
|
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 {
|
export function patchDetail(id: string, accountId: string | undefined, patch: FlagsHint): void {
|
||||||
const key = keyOf(id, accountId);
|
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;
|
let changed = false;
|
||||||
const entry = details.get(key);
|
const entry = details.get(key);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
@@ -217,6 +249,7 @@ export function clearEmailDetailCache(): void {
|
|||||||
pendingThreads.clear();
|
pendingThreads.clear();
|
||||||
threadBatches.clear();
|
threadBatches.clear();
|
||||||
latestStates.clear();
|
latestStates.clear();
|
||||||
|
localPatches.clear();
|
||||||
detailChars = 0;
|
detailChars = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,12 +285,15 @@ async function fetchBodies(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const gen = generation;
|
const gen = generation;
|
||||||
|
const startedAt = localClock;
|
||||||
try {
|
try {
|
||||||
const res = await getFullEmailsWithState(ids, accountId);
|
const res = await getFullEmailsWithState(ids, accountId);
|
||||||
const keep = gen === generation;
|
const keep = gen === generation;
|
||||||
if (keep) noteEmailState(accountId, res.state);
|
if (keep) noteEmailState(accountId, res.state);
|
||||||
const found = new Map<string, Email>();
|
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);
|
found.set(email.id, email);
|
||||||
if (!keep) continue;
|
if (!keep) continue;
|
||||||
putDetail(keyOf(email.id, accountId), { email, state: res.state, chars: bodyChars(email) });
|
putDetail(keyOf(email.id, accountId), { email, state: res.state, chars: bodyChars(email) });
|
||||||
@@ -267,7 +303,7 @@ async function fetchBodies(
|
|||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const email = found.get(id);
|
const email = found.get(id);
|
||||||
if (email) waiting.get(id)?.resolve(email);
|
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) {
|
} catch (err) {
|
||||||
for (const id of ids) waiting.get(id)?.reject(err);
|
for (const id of ids) waiting.get(id)?.reject(err);
|
||||||
@@ -281,6 +317,7 @@ async function revalidate(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
const gen = generation;
|
const gen = generation;
|
||||||
|
const startedAt = localClock;
|
||||||
let gone = new Set<string>();
|
let gone = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const res = await getEmailFlags(ids, accountId);
|
const res = await getEmailFlags(ids, accountId);
|
||||||
@@ -288,10 +325,11 @@ async function revalidate(
|
|||||||
noteEmailState(accountId, res.state);
|
noteEmailState(accountId, res.state);
|
||||||
gone = new Set(res.notFound);
|
gone = new Set(res.notFound);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const flags of res.list) {
|
for (const read of res.list) {
|
||||||
const key = keyOf(flags.id, accountId);
|
const key = keyOf(read.id, accountId);
|
||||||
const entry = details.get(key);
|
const entry = details.get(key);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
|
const flags = { ...read, ...localSince(key, startedAt) };
|
||||||
const same = sameFlags(flags.keywords, entry.email.keywords)
|
const same = sameFlags(flags.keywords, entry.email.keywords)
|
||||||
&& sameFlags(flags.mailboxIds, entry.email.mailboxIds);
|
&& sameFlags(flags.mailboxIds, entry.email.mailboxIds);
|
||||||
// Keep the object when nothing changed, so nothing re-renders.
|
// Keep the object when nothing changed, so nothing re-renders.
|
||||||
@@ -318,7 +356,7 @@ async function revalidate(
|
|||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const email = peekDetail(id, accountId);
|
const email = peekDetail(id, accountId);
|
||||||
if (email) waiting.get(id)?.resolve(email);
|
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;
|
const gen = generation;
|
||||||
// What was held when the load started: shown again if the refetch fails.
|
// 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 fallback = (threadId: string) => (gen === generation ? threads.get(keyOf(threadId, accountId)) : undefined);
|
||||||
|
const startedAt = localClock;
|
||||||
try {
|
try {
|
||||||
const res = await getThreadsHeaders(Array.from(waiting.keys()), accountId);
|
const res = await getThreadsHeaders(Array.from(waiting.keys()), accountId);
|
||||||
if (gen !== generation) throw new Error('cleared');
|
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`));
|
else d.reject(new Error(`Thread ${threadId} not found`));
|
||||||
continue;
|
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 = {
|
const entry: ThreadEntry = {
|
||||||
ids: found.emailIds,
|
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,
|
state: res.state,
|
||||||
};
|
};
|
||||||
putThread(keyOf(threadId, accountId), entry);
|
putThread(keyOf(threadId, accountId), entry);
|
||||||
// The headers were just read: a member's copy held from an earlier
|
// The headers were just read: a member's copy held from an earlier
|
||||||
// open takes their keywords and folders, which may have changed since.
|
// 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 memberKey = keyOf(header.id, accountId);
|
||||||
const held = details.get(memberKey);
|
const held = details.get(memberKey);
|
||||||
if (!held) continue;
|
if (!held) continue;
|
||||||
|
|||||||
@@ -11,3 +11,21 @@ export function listAddressLabel(
|
|||||||
): string {
|
): string {
|
||||||
return address?.name || (address?.email ? formatAddress(address.email) : '') || unknown;
|
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. */
|
/** 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';
|
export type SearchTextProperty = 'text' | 'from' | 'to' | 'subject' | 'body';
|
||||||
|
|
||||||
// A "quoted phrase" (the closing quote may still be missing while the user
|
// 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 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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||||
import * as DocumentPicker from 'expo-document-picker';
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
@@ -22,6 +22,8 @@ import SenderAvatar from '../components/SenderAvatar';
|
|||||||
import { SwipeableRow } from '../components/SwipeableRow';
|
import { SwipeableRow } from '../components/SwipeableRow';
|
||||||
import { MoveSheet } from '../components/MoveSheet';
|
import { MoveSheet } from '../components/MoveSheet';
|
||||||
import { TagSheet } from '../components/TagSheet';
|
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 { OfflineBanner } from '../components/OfflineBanner';
|
||||||
import { TrashRecoveryLink } from '../components/email/TrashRecoveryLink';
|
import { TrashRecoveryLink } from '../components/email/TrashRecoveryLink';
|
||||||
import {
|
import {
|
||||||
@@ -51,14 +53,14 @@ import { useContactsStore } from '../stores/contacts-store';
|
|||||||
import { useOutboxStore } from '../stores/outbox-store';
|
import { useOutboxStore } from '../stores/outbox-store';
|
||||||
import { toast } from '../stores/toast-store';
|
import { toast } from '../stores/toast-store';
|
||||||
import { getContactDisplayName } from '../lib/contact-utils';
|
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 { useOwnDomainAddress } from '../lib/use-own-domain-address';
|
||||||
import { formatListDate } from '../lib/date-format';
|
import { formatListDate } from '../lib/date-format';
|
||||||
import { singleLine } from '../lib/single-line';
|
import { singleLine } from '../lib/single-line';
|
||||||
import { cleanPreview } from '../lib/preview-text';
|
import { cleanPreview } from '../lib/preview-text';
|
||||||
import { SearchSnippetText } from '../components/email/SearchSnippetText';
|
import { SearchSnippetText } from '../components/email/SearchSnippetText';
|
||||||
import {
|
import {
|
||||||
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
|
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes, mailboxesOfAccount,
|
||||||
} from '../lib/mailbox-tree';
|
} from '../lib/mailbox-tree';
|
||||||
import { localizeMailboxName } from '../lib/mailbox-label';
|
import { localizeMailboxName } from '../lib/mailbox-label';
|
||||||
import {
|
import {
|
||||||
@@ -69,7 +71,10 @@ import { confirmMailRemoval } from '../lib/mail-removal-confirm';
|
|||||||
import { moveSheetCurrentId } from '../lib/move-targets';
|
import { moveSheetCurrentId } from '../lib/move-targets';
|
||||||
import { listViewKey } from '../lib/list-view-key';
|
import { listViewKey } from '../lib/list-view-key';
|
||||||
import { searchScopeFolders } from '../lib/search-scope-folders';
|
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 { draftContextFromEmail, isDraftEmail } from '../lib/draft-context';
|
||||||
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
import { getFullEmail, emptyMailbox as apiEmptyMailbox } from '../api/email';
|
||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
@@ -575,7 +580,18 @@ export default function EmailListScreen({
|
|||||||
}, [sharedTags, keywordDefs]);
|
}, [sharedTags, keywordDefs]);
|
||||||
const folderKeywordDefs = (currentOwnerAccountId && keywordDefsByAccount.get(currentOwnerAccountId)) || keywordDefs;
|
const folderKeywordDefs = (currentOwnerAccountId && keywordDefsByAccount.get(currentOwnerAccountId)) || keywordDefs;
|
||||||
const inJunk = currentRole === 'junk' || currentRole === 'spam';
|
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();
|
const formatAddress = useOwnDomainAddress();
|
||||||
|
|
||||||
// When threading is on, collapse same-thread emails so the list shows the
|
// 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.
|
// Batch (multi-select) sheets.
|
||||||
const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
|
const [batchMoveOpen, setBatchMoveOpen] = React.useState(false);
|
||||||
const [tagSheetOpen, setTagSheetOpen] = 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
|
// Selection state
|
||||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
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 selectedLabel = t('email_list.batch_actions.selected_messages', '{count, plural, one {1 email} other {# emails}} selected', { count: selectedIds.size });
|
||||||
const allSelected =
|
const allSelected =
|
||||||
visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(rowKeyOf(e)));
|
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.
|
// Refs so row press handlers stay referentially stable across renders.
|
||||||
// FlatList rows then skip re-render when the parent re-renders for unrelated
|
// FlatList rows then skip re-render when the parent re-renders for unrelated
|
||||||
@@ -891,7 +910,7 @@ export default function EmailListScreen({
|
|||||||
item={item}
|
item={item}
|
||||||
threadCount={threadCountFor(item)}
|
threadCount={threadCountFor(item)}
|
||||||
showPreview={showPreview}
|
showPreview={showPreview}
|
||||||
showRecipient={showRecipient}
|
showRecipient={showRecipientFor(item)}
|
||||||
formatAddress={formatAddress}
|
formatAddress={formatAddress}
|
||||||
tagIds={rowTagIds.get(key) ?? ''}
|
tagIds={rowTagIds.get(key) ?? ''}
|
||||||
keywordDefs={(item.jmapAccountId && keywordDefsByAccount.get(item.jmapAccountId)) || folderKeywordDefs}
|
keywordDefs={(item.jmapAccountId && keywordDefsByAccount.get(item.jmapAccountId)) || folderKeywordDefs}
|
||||||
@@ -911,7 +930,7 @@ export default function EmailListScreen({
|
|||||||
[
|
[
|
||||||
selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction,
|
selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction,
|
||||||
swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor,
|
swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor,
|
||||||
showPreview, showRecipient, formatAddress, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk,
|
showPreview, showRecipientFor, formatAddress, keywordDefsByAccount, folderKeywordDefs, inJunk, showAvatarsInJunk,
|
||||||
loadAttachments, openAttachment,
|
loadAttachments, openAttachment,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1030,7 +1049,7 @@ export default function EmailListScreen({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBatchTagToggle = (token: string, on: boolean) => {
|
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));
|
reportKeywordFailure(t('notifications.tag_failed', 'Tagging failed'), err));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1179,6 +1198,41 @@ export default function EmailListScreen({
|
|||||||
// size" (webmail search-filter-panel).
|
// size" (webmail search-filter-panel).
|
||||||
const [customSize, setCustomSize] = React.useState(false);
|
const [customSize, setCustomSize] = React.useState(false);
|
||||||
const sizePreset = customSize ? 'custom' : sizePresetOf(filters.minSizeKb, filters.maxSizeKb);
|
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) => {
|
const pickSizePreset = (key: string) => {
|
||||||
setCustomSize(key === 'custom');
|
setCustomSize(key === 'custom');
|
||||||
if (key === 'custom') return;
|
if (key === 'custom') return;
|
||||||
@@ -1271,8 +1325,8 @@ export default function EmailListScreen({
|
|||||||
onPress={toggleSelectAllVisible}
|
onPress={toggleSelectAllVisible}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
accessibilityRole="checkbox"
|
accessibilityRole="checkbox"
|
||||||
accessibilityLabel={t('email_list.batch_actions.select_all', 'Select all')}
|
accessibilityLabel={selectAll.label}
|
||||||
accessibilityState={{ checked: allSelected ? true : selectionMode ? 'mixed' : false }}
|
accessibilityState={{ checked: selectAll.checked }}
|
||||||
>
|
>
|
||||||
{allSelected ? (
|
{allSelected ? (
|
||||||
<SquareCheck size={18} color={c.primary} />
|
<SquareCheck size={18} color={c.primary} />
|
||||||
@@ -1336,8 +1390,7 @@ export default function EmailListScreen({
|
|||||||
style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]}
|
style={[styles.filterButton, activeFilterCount > 0 && styles.filterButtonActive]}
|
||||||
onPress={() => setFilterMenuOpen(true)}
|
onPress={() => setFilterMenuOpen(true)}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t('advanced_search.advanced_filters_tooltip', 'Advanced search filters')}
|
accessibilityLabel={filterButtonLabel(activeFilterCount, t)}
|
||||||
accessibilityValue={activeFilterCount > 0 ? { text: String(activeFilterCount) } : undefined}
|
|
||||||
>
|
>
|
||||||
<Filter size={18} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
|
<Filter size={18} color={activeFilterCount > 0 ? c.primary : c.textMuted} />
|
||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
@@ -1642,13 +1695,13 @@ export default function EmailListScreen({
|
|||||||
)}
|
)}
|
||||||
{sizeFilterBytes(filters.minSizeKb) !== null ? (
|
{sizeFilterBytes(filters.minSizeKb) !== null ? (
|
||||||
<FilterChip
|
<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)}
|
onRemove={() => setFilterField('minSizeKb', undefined)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{sizeFilterBytes(filters.maxSizeKb) !== null ? (
|
{sizeFilterBytes(filters.maxSizeKb) !== null ? (
|
||||||
<FilterChip
|
<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)}
|
onRemove={() => setFilterField('maxSizeKb', undefined)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1656,6 +1709,7 @@ export default function EmailListScreen({
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
setSearchInput('');
|
setSearchInput('');
|
||||||
setCustomSize(false);
|
setCustomSize(false);
|
||||||
|
setFilterFormKey((k) => k + 1);
|
||||||
clearSearchAndFilters();
|
clearSearchAndFilters();
|
||||||
}}
|
}}
|
||||||
style={styles.clearAllButton}
|
style={styles.clearAllButton}
|
||||||
@@ -1813,14 +1867,17 @@ export default function EmailListScreen({
|
|||||||
onRequestClose={() => setFilterMenuOpen(false)}
|
onRequestClose={() => setFilterMenuOpen(false)}
|
||||||
>
|
>
|
||||||
<TouchableWithoutFeedback accessible={false} onPress={() => 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}>
|
<TouchableWithoutFeedback accessible={false}>
|
||||||
<View style={styles.filterMenu}>
|
<View style={[styles.filterMenu, { maxHeight: filterFrame.maxHeight }]}>
|
||||||
<View style={styles.filterMenuHeader}>
|
<View style={styles.filterMenuHeader}>
|
||||||
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
|
<Text style={styles.filterMenuTitle}>{t('email_list.filter_title', 'Filter emails')}</Text>
|
||||||
<View style={styles.filterMenuHeaderActions}>
|
<View style={styles.filterMenuHeaderActions}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => { setCustomSize(false); setFilters({}); }}
|
onPress={clearFilters}
|
||||||
style={styles.filterMenuHeaderBtn}
|
style={styles.filterMenuHeaderBtn}
|
||||||
hitSlop={6}
|
hitSlop={6}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -1840,7 +1897,13 @@ export default function EmailListScreen({
|
|||||||
</View>
|
</View>
|
||||||
</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.filterFieldRow}>
|
||||||
<View style={styles.filterFieldHalf}>
|
<View style={styles.filterFieldHalf}>
|
||||||
<Text style={styles.filterFieldLabel}>{t('advanced_search.from', 'From')}</Text>
|
<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)')}
|
accessibilityLabel={t('advanced_search.size_min', 'Larger than (KB)')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
|
onFocus={revealSizeFields}
|
||||||
style={styles.filterFieldInput}
|
style={styles.filterFieldInput}
|
||||||
testID="advanced-search-min-size"
|
testID="advanced-search-min-size"
|
||||||
/>
|
/>
|
||||||
@@ -2022,6 +2086,7 @@ export default function EmailListScreen({
|
|||||||
accessibilityLabel={t('advanced_search.size_max', 'Smaller than (KB)')}
|
accessibilityLabel={t('advanced_search.size_max', 'Smaller than (KB)')}
|
||||||
placeholderTextColor={c.textMuted}
|
placeholderTextColor={c.textMuted}
|
||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
|
onFocus={revealSizeFields}
|
||||||
style={styles.filterFieldInput}
|
style={styles.filterFieldInput}
|
||||||
testID="advanced-search-max-size"
|
testID="advanced-search-max-size"
|
||||||
/>
|
/>
|
||||||
@@ -2447,7 +2512,6 @@ function makeStyles(c: ThemePalette) {
|
|||||||
filterMenu: {
|
filterMenu: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 380,
|
maxWidth: 380,
|
||||||
maxHeight: '85%',
|
|
||||||
backgroundColor: c.popover,
|
backgroundColor: c.popover,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
@@ -2491,6 +2555,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: radius.xs,
|
borderRadius: radius.xs,
|
||||||
},
|
},
|
||||||
|
filterMenuScroll: { flexGrow: 0, flexShrink: 1 },
|
||||||
filterMenuBody: {
|
filterMenuBody: {
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ import {
|
|||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
import { pickEmailBody } from '../lib/email-body';
|
import { pickEmailBody } from '../lib/email-body';
|
||||||
import { confirmMailRemoval } from '../lib/mail-removal-confirm';
|
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 { NewTagRow } from '../components/NewTagRow';
|
||||||
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
|
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
|
||||||
import { isSieveSupported } from '../api/sieve';
|
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 { useColors, useResolvedTheme } from '../theme/colors';
|
||||||
import { MoveSheet } from '../components/MoveSheet';
|
import { MoveSheet } from '../components/MoveSheet';
|
||||||
import { ThreadMessageCard, ThreadCardPlaceholder } from '../components/email/ThreadMessageCard';
|
import { ThreadMessageCard, ThreadCardPlaceholder } from '../components/email/ThreadMessageCard';
|
||||||
@@ -35,7 +40,7 @@ import {
|
|||||||
import { patchKeywordsForEmails } from '../api/email';
|
import { patchKeywordsForEmails } from '../api/email';
|
||||||
import {
|
import {
|
||||||
loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekRow, peekThread, subscribeEmailCache,
|
loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekRow, peekThread, subscribeEmailCache,
|
||||||
type FlagsHint,
|
EmailNotFoundError, type FlagsHint,
|
||||||
} from '../lib/email-detail-cache';
|
} from '../lib/email-detail-cache';
|
||||||
import { shareEmailEml } from '../lib/email-export';
|
import { shareEmailEml } from '../lib/email-export';
|
||||||
import { useKeywordsStore, useAccountKeywordDefs, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
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 { singleLine } from '../lib/single-line';
|
||||||
import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment';
|
import { buildForwardAsAttachmentPayload } from '../lib/forward-as-attachment';
|
||||||
import { viewerInstance, viewerPages, type ViewerInstance } from '../lib/viewer-pages';
|
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 { scheduledEmailIdsInThread } from '../api/thread-scheduled';
|
||||||
import type { Email, EmailAddress, Identity } from '../api/types';
|
import type { Email, EmailAddress, Identity } from '../api/types';
|
||||||
import type { RootStackParamList } from '../navigation/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) {
|
function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Props) {
|
||||||
const c = useColors();
|
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();
|
const { t } = useLocaleStore();
|
||||||
// The JMAP account the message lives in, as the screen that opened it named
|
// 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
|
// 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();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
// Pages are as wide as the pager itself, which is the window on a phone
|
// 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
|
// but only the detail pane in a split view. Measured, so a rotation or a
|
||||||
// pane resize re-sizes them.
|
// pane resize re-sizes them. The pages render only once it is measured:
|
||||||
const [pageWidth, setPageWidth] = React.useState(windowWidth);
|
// 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 markRead = useEmailStore((s) => s.markRead);
|
||||||
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
|
const setKeywordForEmails = useEmailStore((s) => s.setKeywordForEmails);
|
||||||
const deleteEmail = useEmailStore((s) => s.deleteEmail);
|
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 [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||||
const [tagMenuOpen, setTagMenuOpen] = 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 [rulesOpen, setRulesOpen] = React.useState(false);
|
||||||
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
||||||
// Per-message override of the light/dark rendering (More sheet toggle).
|
// 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
|
// 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.
|
// 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(
|
const ensureDetail = React.useCallback(
|
||||||
(id: string): Promise<Email | null> =>
|
(id: string, onGone?: () => void): Promise<Email | null> =>
|
||||||
loadDetail(id, ownerAccountId, hintFor(id)).catch(() => peekDetail(id, ownerAccountId) ?? null),
|
loadDetail(id, ownerAccountId, hintFor(id)).catch((err) => {
|
||||||
|
if (err instanceof EmailNotFoundError) onGone?.();
|
||||||
|
return peekDetail(id, ownerAccountId) ?? null;
|
||||||
|
}),
|
||||||
[ownerAccountId, hintFor],
|
[ownerAccountId, hintFor],
|
||||||
);
|
);
|
||||||
const ensureDetails = React.useCallback((ids: string[]) => {
|
const ensureDetails = React.useCallback((ids: string[]) => {
|
||||||
@@ -324,12 +341,12 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
}, [pageWidth]);
|
}, [pageWidth]);
|
||||||
const onPagerLayout = React.useCallback((e: LayoutChangeEvent) => {
|
const onPagerLayout = React.useCallback((e: LayoutChangeEvent) => {
|
||||||
const width = Math.round(e.nativeEvent.layout.width);
|
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(
|
const pagerExtraData = React.useMemo(
|
||||||
() => [cacheVersion, neighboursReady],
|
() => [cacheVersion, neighboursReady, pageWidth],
|
||||||
[cacheVersion, neighboursReady],
|
[cacheVersion, neighboursReady, pageWidth],
|
||||||
);
|
);
|
||||||
|
|
||||||
// While the body is pinch-zoomed the pager must not treat horizontal
|
// 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
|
// list row follows when the list holds that account (never a same-id row
|
||||||
// of another one).
|
// of another one).
|
||||||
const setKeyword = React.useCallback(
|
const setKeyword = React.useCallback(
|
||||||
(target: Email, token: string, on: boolean) =>
|
(target: Email, token: string, on: boolean, undoGroup?: string) =>
|
||||||
setKeywordForEmails([target.id], token, on, { email: target, accountId: ownerAccountId }),
|
setKeywordForEmails([target.id], token, on, { email: target, accountId: ownerAccountId }, { undoGroup }),
|
||||||
[setKeywordForEmails, ownerAccountId],
|
[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.
|
// this lands. Then mark it read per the user's delay.
|
||||||
setError(null);
|
setError(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const fetched = await ensureDetail(activeEmailId);
|
let gone = false;
|
||||||
|
const fetched = await ensureDetail(activeEmailId, () => { gone = true; });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!fetched) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
cancelRead = scheduleMarkRead(fetched);
|
cancelRead = scheduleMarkRead(fetched);
|
||||||
@@ -473,7 +498,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
if (next[token]) delete next[token];
|
if (next[token]) delete next[token];
|
||||||
else next[token] = true;
|
else next[token] = true;
|
||||||
updateLocalKeywords(email.id, next);
|
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);
|
updateLocalKeywords(email.id, email.keywords);
|
||||||
toastFailure(t('notifications.tag_failed', 'Tagging failed'), err);
|
toastFailure(t('notifications.tag_failed', 'Tagging failed'), err);
|
||||||
});
|
});
|
||||||
@@ -795,10 +820,11 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<View style={styles.pagerViewport} onLayout={onPagerLayout}>
|
||||||
|
{measuredWidth > 0 && (
|
||||||
<FlatList
|
<FlatList
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
style={styles.pagerViewport}
|
style={styles.pagerViewport}
|
||||||
onLayout={onPagerLayout}
|
|
||||||
onContentSizeChange={snapToActive}
|
onContentSizeChange={snapToActive}
|
||||||
data={emails}
|
data={emails}
|
||||||
extraData={pagerExtraData}
|
extraData={pagerExtraData}
|
||||||
@@ -811,7 +837,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
windowSize={3}
|
windowSize={3}
|
||||||
initialNumToRender={1}
|
initialNumToRender={1}
|
||||||
maxToRenderPerBatch={2}
|
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}
|
scrollEnabled={!pagerLocked && kbObstruction === 0}
|
||||||
onScrollBeginDrag={releaseNeighbours}
|
onScrollBeginDrag={releaseNeighbours}
|
||||||
onMomentumScrollEnd={onMomentumEnd}
|
onMomentumScrollEnd={onMomentumEnd}
|
||||||
@@ -856,6 +883,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */}
|
{/* Bottom action bar (hidden while keyboard is up so quick reply stays visible) */}
|
||||||
{kbObstruction === 0 && (
|
{kbObstruction === 0 && (
|
||||||
@@ -1257,7 +1286,7 @@ function EmailPane({
|
|||||||
fill={!asConversation}
|
fill={!asConversation}
|
||||||
email={full ?? m}
|
email={full ?? m}
|
||||||
expanded={open}
|
expanded={open}
|
||||||
replyable={!!full && isReplyableMember(m, scheduledIds)}
|
replyActions={cardReplyActions(m, !!full, scheduledIds)}
|
||||||
deferBody={!full || !bodyEnabled}
|
deferBody={!full || !bodyEnabled}
|
||||||
onBodySettled={onBodySettled}
|
onBodySettled={onBodySettled}
|
||||||
onToggleExpanded={() => toggleCard(m.id)}
|
onToggleExpanded={() => toggleCard(m.id)}
|
||||||
@@ -1382,7 +1411,8 @@ function MoreMenuSheet({
|
|||||||
onForwardAsAttachment, onRules, onViewSource, onExport,
|
onForwardAsAttachment, onRules, onViewSource, onExport,
|
||||||
}: MoreMenuSheetProps) {
|
}: MoreMenuSheetProps) {
|
||||||
const c = useColors();
|
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 t = useLocaleStore((s) => s.t);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const slideY = React.useRef(new Animated.Value(600)).current;
|
const slideY = React.useRef(new Animated.Value(600)).current;
|
||||||
@@ -1548,7 +1578,8 @@ interface TagMenuSheetProps {
|
|||||||
|
|
||||||
function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) {
|
function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }: TagMenuSheetProps) {
|
||||||
const c = useColors();
|
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 t = useLocaleStore((s) => s.t);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||||
@@ -1633,7 +1664,8 @@ function MoreMenuItem({
|
|||||||
icon, label, onPress, trailing,
|
icon, label, onPress, trailing,
|
||||||
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
|
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const scaledType = useTypography();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c, scaledType), [c, scaledType]);
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
@@ -1650,7 +1682,8 @@ function ToolbarButton({
|
|||||||
icon, label, onPress,
|
icon, label, onPress,
|
||||||
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
|
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
|
||||||
const c = useColors();
|
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.
|
// Icons only in a short (landscape) window, which leaves room for the message.
|
||||||
const labelsSetting = useSettingsStore((s) => s.showToolbarLabels);
|
const labelsSetting = useSettingsStore((s) => s.showToolbarLabels);
|
||||||
const { shortHeight } = useLayout();
|
const { shortHeight } = useLayout();
|
||||||
@@ -1672,7 +1705,8 @@ function BottomBarButton({
|
|||||||
icon, label, onPress, disabled,
|
icon, label, onPress, disabled,
|
||||||
}: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) {
|
}: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) {
|
||||||
const c = useColors();
|
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.
|
// Icons only in a short (landscape) window, which leaves room for the message.
|
||||||
const labelsSetting = useSettingsStore((s) => s.showToolbarLabels);
|
const labelsSetting = useSettingsStore((s) => s.showToolbarLabels);
|
||||||
const { shortHeight } = useLayout();
|
const { shortHeight } = useLayout();
|
||||||
@@ -1694,7 +1728,7 @@ function BottomBarButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStyles(c: ThemePalette) {
|
function makeStyles(c: ThemePalette, typography: ScaledTypography = tokenTypography) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: c.background },
|
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.
|
// Webmail viewer on a phone: text-lg font-bold tracking-tight.
|
||||||
subjectText: {
|
subjectText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 18,
|
// 18 / 26 at the default text size, scaled with it.
|
||||||
|
fontSize: typography.h3.fontSize,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
lineHeight: 26,
|
lineHeight: Math.round((26 * typography.h3.fontSize) / 18),
|
||||||
color: c.text,
|
color: c.text,
|
||||||
letterSpacing: -0.2,
|
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);
|
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 () => {
|
it('does nothing while offline', async () => {
|
||||||
useNetworkStore.setState({ online: false });
|
useNetworkStore.setState({ online: false });
|
||||||
useOutboxStore.getState().enqueue({ kind: 'keywords', emailId: 'e1', patch: { $seen: true } });
|
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 { useOfflineCacheStore } from './offline-cache-store';
|
||||||
import { useOutboxStore, applyOrQueue, applyOrQueueBatch, type OutboxOp } from './outbox-store';
|
import { useOutboxStore, applyOrQueue, applyOrQueueBatch, type OutboxOp } from './outbox-store';
|
||||||
import { useTagCountsStore } from './tag-counts-store';
|
import { useTagCountsStore } from './tag-counts-store';
|
||||||
|
import { patchDetail, peekDetail } from '../lib/email-detail-cache';
|
||||||
|
|
||||||
// ── Refresh coalescing ─────────────────────────────────────────────────
|
// ── Refresh coalescing ─────────────────────────────────────────────────
|
||||||
// Push events, mount effects and post-action follow-ups all call
|
// Push events, mount effects and post-action follow-ups all call
|
||||||
@@ -358,6 +359,11 @@ export interface UndoEntry {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Time the entry was created - the snackbar uses this to drive its timer. */
|
/** Time the entry was created - the snackbar uses this to drive its timer. */
|
||||||
createdAt: number;
|
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. */
|
/** JMAP account the messages live under; unset for the user's own mail. */
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
/**
|
/**
|
||||||
@@ -380,9 +386,46 @@ export interface UndoEntry {
|
|||||||
* (a list that spans accounts); `accountId` above applies otherwise.
|
* (a list that spans accounts); `accountId` above applies otherwise.
|
||||||
*/
|
*/
|
||||||
accountId?: string;
|
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).
|
// 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
|
// `queryState` is the JMAP queryState for the matching Email/query, used to
|
||||||
// drive Email/queryChanges on the next refresh.
|
// 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;
|
* `Email/set`. With `viewed`, on the viewer's message in its own account;
|
||||||
* the list row follows only when the list holds that 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>;
|
undoLast: () => Promise<void>;
|
||||||
clearUndo: () => void;
|
clearUndo: () => void;
|
||||||
searchEmails: (query: string) => Promise<Email[]>;
|
searchEmails: (query: string) => Promise<Email[]>;
|
||||||
@@ -2156,10 +2206,18 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
if (refusal) throw refusal;
|
if (refusal) throw refusal;
|
||||||
},
|
},
|
||||||
|
|
||||||
setKeywordForEmails: async (emailIds, token, on, viewed) => {
|
setKeywordForEmails: async (emailIds, token, on, viewed, options) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const { targets, listed } = actionTargets(state, emailIds, viewed);
|
const { targets, listed } = actionTargets(state, emailIds, viewed);
|
||||||
if (targets.length === 0) return;
|
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
|
// One Email/set per account the selection spans (#1082); the viewer's
|
||||||
// message goes to the account it names.
|
// message goes to the account it names.
|
||||||
const groups = viewed ? [{ accountId: viewed.accountId, emails: targets }] : groupByAccount(state, targets);
|
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 { accountId, emails } of written) {
|
||||||
for (const e of emails) patchCache(e.id, { keywords: patch }, accountId);
|
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
|
// Undo for a read, star or tag change from the list, or a tag change in
|
||||||
// back the keyword it had (the reader's own toggles get no bar).
|
// 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 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) {
|
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({
|
set({
|
||||||
pendingUndo: {
|
pendingUndo: {
|
||||||
kind: 'keywords',
|
kind: 'keywords',
|
||||||
label,
|
label,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
keywordPatch: patch,
|
...(undoGroup ? { undoGroup } : {}),
|
||||||
items: changed.map(({ email, accountId }) => ({
|
...joined,
|
||||||
email,
|
|
||||||
originalMailboxIds: { ...email.mailboxIds },
|
|
||||||
originalKeywords: { ...email.keywords },
|
|
||||||
accountId,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (refusal) throw refusal;
|
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 () => {
|
undoLast: async () => {
|
||||||
const entry = get().pendingUndo;
|
const entry = get().pendingUndo;
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
@@ -2246,6 +2319,7 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
id: it.email.id,
|
id: it.email.id,
|
||||||
key: rowKeyOf(it.email),
|
key: rowKeyOf(it.email),
|
||||||
accountId: it.accountId ?? entry.accountId,
|
accountId: it.accountId ?? entry.accountId,
|
||||||
|
listed: !it.unlisted,
|
||||||
patch: revertKeywordPatch(keywordPatch, it.originalKeywords),
|
patch: revertKeywordPatch(keywordPatch, it.originalKeywords),
|
||||||
}));
|
}));
|
||||||
const accounts = new Set(undo.map((u) => u.accountId ?? ''));
|
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') });
|
set({ error: err instanceof Error ? err.message : t('email_list.undo_failed', 'Undo failed') });
|
||||||
return;
|
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({
|
set({
|
||||||
emails: get().emails.map((e) => {
|
emails: get().emails.map((e) => {
|
||||||
const patch = byKey.get(rowKeyOf(e));
|
const patch = byKey.get(rowKeyOf(e));
|
||||||
return patch ? { ...e, keywords: applyKeywordPatch(e.keywords, patch) } : 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -394,6 +394,8 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
|||||||
set({ flushing: true });
|
set({ flushing: true });
|
||||||
let brokeTransient = false;
|
let brokeTransient = false;
|
||||||
let dropped = false;
|
let dropped = false;
|
||||||
|
// An entry that took on a later edit while its op was out runs again.
|
||||||
|
let changedInFlight = false;
|
||||||
try {
|
try {
|
||||||
// Process oldest-first so dependent moves replay in the order they were
|
// 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,
|
// 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 {
|
try {
|
||||||
await runOp(entry.op);
|
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;
|
retryAttempt = 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (get().activeAccountId !== accountId) break;
|
if (get().activeAccountId !== accountId) break;
|
||||||
@@ -446,6 +450,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
|||||||
set({ flushing: false });
|
set({ flushing: false });
|
||||||
}
|
}
|
||||||
if (brokeTransient && get().entries.length > 0) scheduleRetry();
|
if (brokeTransient && get().entries.length > 0) scheduleRetry();
|
||||||
|
else if (changedInFlight) void get().flush();
|
||||||
if (dropped) void refreshAfterDrop();
|
if (dropped) void refreshAfterDrop();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -502,12 +507,20 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
|
|||||||
|
|
||||||
// ── Internal helpers that mutate+persist the live list ──────────────────────
|
// ── 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();
|
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);
|
const entries = store.entries.filter((e) => e.id !== id);
|
||||||
useOutboxStore.setState({ entries });
|
useOutboxStore.setState({ entries });
|
||||||
persist(accountId, entries);
|
persist(accountId, entries);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveToFailed(accountId: string, id: string, err: unknown): void {
|
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 {
|
interface SendUndoState {
|
||||||
pending: PendingUndoSend | null;
|
pending: PendingUndoSend | null;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user