mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
feat: open scheduled messages in a read-only view, and send cancelled ones back to Drafts #84
This commit is contained in:
@@ -690,15 +690,18 @@
|
|||||||
"title": "Get notified about new mail"
|
"title": "Get notified about new mail"
|
||||||
},
|
},
|
||||||
"scheduled": {
|
"scheduled": {
|
||||||
|
"actions": "Actions",
|
||||||
"already_sent": "This message was already sent.",
|
"already_sent": "This message was already sent.",
|
||||||
"cancel_body": "The message will not be delivered. A copy stays in your Sent folder.",
|
"cancel_body": "The message will not be delivered. A copy stays in your Sent folder.",
|
||||||
"cancel_failed": "Cancel failed",
|
"cancel_failed": "Cancel failed",
|
||||||
|
"cancel_to_drafts_body": "The message will not be sent. It goes back to your Drafts.",
|
||||||
"edit_failed": "Could not open the message for editing",
|
"edit_failed": "Could not open the message for editing",
|
||||||
"keep_scheduled": "Keep scheduled",
|
"keep_scheduled": "Keep scheduled",
|
||||||
"no_longer_scheduled": "This message is no longer scheduled.",
|
"no_longer_scheduled": "This message is no longer scheduled.",
|
||||||
"reschedule_failed": "Reschedule failed",
|
"reschedule_failed": "Reschedule failed",
|
||||||
"reschedule_lost": "The message is no longer scheduled. Check Drafts and Sent before sending it again.",
|
"reschedule_lost": "The message is no longer scheduled. Check Drafts and Sent before sending it again.",
|
||||||
"reschedule_lost_drafts": "The message is no longer scheduled. It is back in Drafts, where you can send it again.",
|
"reschedule_lost_drafts": "The message is no longer scheduled. It is back in Drafts, where you can send it again.",
|
||||||
|
"send_cancelled": "Send cancelled",
|
||||||
"send_now_failed": "Could not send now"
|
"send_now_failed": "Could not send now"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet, Pressable, ScrollView, ActivityIndicator } from 'react-native';
|
||||||
|
import { ArrowLeft, Clock, Send, Pencil, XCircle } from '../icons';
|
||||||
|
import type { Email } from '../../api/types';
|
||||||
|
import type { ScheduledEmail } from '../../api/email';
|
||||||
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||||
|
import { useColors } from '../../theme/colors';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
import { useSettingsStore } from '../../stores/settings-store';
|
||||||
|
import { MessageContent } from '../email/MessageContent';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: ScheduledEmail;
|
||||||
|
/** The full message; null while it loads. */
|
||||||
|
email: Email | null;
|
||||||
|
error: string | null;
|
||||||
|
/** The account to read the message's blobs from: undefined for the own one. */
|
||||||
|
jmapAccountId?: string;
|
||||||
|
busy: boolean;
|
||||||
|
formatWhen: (iso: string) => string;
|
||||||
|
onBack: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onSendNow: () => void;
|
||||||
|
onReschedule: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onCancelSend: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scheduled message, read-only, with the banner the webmail shows above
|
||||||
|
* it: when it goes out, and Send now / Reschedule / Edit / Cancel send
|
||||||
|
* (webmail email-viewer.tsx, native #84).
|
||||||
|
*/
|
||||||
|
export function ScheduledMessageView({
|
||||||
|
item, email, error, jmapAccountId, busy, formatWhen,
|
||||||
|
onBack, onRetry, onSendNow, onReschedule, onEdit, onCancelSend,
|
||||||
|
}: Props) {
|
||||||
|
const c = useColors();
|
||||||
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
|
const identities = useSettingsStore((s) => s.identities);
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ key: 'send', label: t('email_viewer.send_now', 'Send now'), icon: Send, onPress: onSendNow },
|
||||||
|
{ key: 'reschedule', label: t('email_viewer.reschedule_send', 'Reschedule'), icon: Clock, onPress: onReschedule },
|
||||||
|
{ key: 'edit', label: t('email_viewer.cancel_and_edit', 'Cancel and edit'), icon: Pencil, onPress: onEdit },
|
||||||
|
{ key: 'cancel', label: t('email_viewer.cancel_scheduled_send', 'Cancel send'), icon: XCircle, onPress: onCancelSend, destructive: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onBack}
|
||||||
|
style={styles.headerBtn}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('common.back', 'Back')}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={22} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
|
{item.subject || t('email_composer.no_subject', '(No Subject)')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.banner} accessibilityRole="summary">
|
||||||
|
<View style={styles.bannerRow}>
|
||||||
|
<Clock size={16} color={c.primary} />
|
||||||
|
<Text style={styles.bannerText}>
|
||||||
|
{t('email_viewer.scheduled_banner', 'Scheduled to send at {date}', { date: formatWhen(item.sendAt) })}
|
||||||
|
</Text>
|
||||||
|
{busy && <ActivityIndicator size="small" color={c.primary} />}
|
||||||
|
</View>
|
||||||
|
<View style={styles.bannerActions}>
|
||||||
|
{actions.map(({ key, label, icon: Icon, onPress, destructive }) => (
|
||||||
|
<Pressable
|
||||||
|
key={key}
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={busy}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled: busy }}
|
||||||
|
style={({ pressed }) => [styles.bannerBtn, pressed && styles.bannerBtnPressed, busy && styles.bannerBtnDisabled]}
|
||||||
|
>
|
||||||
|
<Icon size={14} color={destructive ? c.error : c.text} />
|
||||||
|
<Text style={[styles.bannerBtnText, destructive && { color: c.error }]} numberOfLines={1}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={styles.error}>{error}</Text>
|
||||||
|
<Pressable onPress={onRetry}>
|
||||||
|
<Text style={styles.retry}>{t('common.retry', 'Retry')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : !email ? (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator color={c.primary} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<MessageContent
|
||||||
|
email={email}
|
||||||
|
jmapAccountId={jmapAccountId}
|
||||||
|
identities={identities}
|
||||||
|
// As in Sent: no read receipt banner for the user's own message.
|
||||||
|
currentMailboxRole="sent"
|
||||||
|
active={false}
|
||||||
|
onAddressPress={() => undefined}
|
||||||
|
onEmailPatched={() => undefined}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeStyles(c: ThemePalette) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: c.background },
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: componentSizes.headerHeight,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: c.border,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
headerBtn: {
|
||||||
|
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
|
||||||
|
},
|
||||||
|
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||||
|
banner: {
|
||||||
|
margin: spacing.md,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
backgroundColor: c.surface,
|
||||||
|
},
|
||||||
|
bannerRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
|
||||||
|
bannerText: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||||
|
bannerActions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
|
||||||
|
bannerBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
minHeight: 36,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: c.border,
|
||||||
|
backgroundColor: c.background,
|
||||||
|
},
|
||||||
|
bannerBtnPressed: { opacity: 0.7 },
|
||||||
|
bannerBtnDisabled: { opacity: 0.5 },
|
||||||
|
bannerBtnText: { ...typography.caption, color: c.text, fontWeight: '600' },
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.lg },
|
||||||
|
error: { ...typography.body, color: c.error, textAlign: 'center' },
|
||||||
|
retry: { ...typography.bodyMedium, color: c.primary, marginTop: spacing.sm },
|
||||||
|
content: { paddingBottom: spacing.xl },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const { api } = vi.hoisted(() => ({
|
||||||
|
api: {
|
||||||
|
cancelScheduledSend: vi.fn(async () => undefined),
|
||||||
|
restoreEmailToDraft: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('../../api/email', () => api);
|
||||||
|
|
||||||
|
import type { Mailbox } from '../../api/types';
|
||||||
|
import { cancelScheduledToDrafts, scheduledAccountOverride } from '../compose-scheduled';
|
||||||
|
|
||||||
|
function mailbox(role: string, id: string, shared?: string): Mailbox {
|
||||||
|
return {
|
||||||
|
id: shared ? `${shared}:${id}` : id,
|
||||||
|
originalId: shared ? id : undefined,
|
||||||
|
name: role,
|
||||||
|
role,
|
||||||
|
accountId: shared,
|
||||||
|
isShared: !!shared,
|
||||||
|
} as Mailbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAILBOXES = [
|
||||||
|
mailbox('drafts', 'own-drafts'),
|
||||||
|
mailbox('sent', 'own-sent'),
|
||||||
|
mailbox('drafts', 'team-drafts', 'team'),
|
||||||
|
mailbox('sent', 'team-sent', 'team'),
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
// native #84: "Cancel send" left the message in Sent, where it passed for a
|
||||||
|
// sent one; like Edit, it now goes back to Drafts.
|
||||||
|
describe('Cancel send of a scheduled message', () => {
|
||||||
|
it('cancels the submission and moves an own message back into the own Drafts', async () => {
|
||||||
|
const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
|
||||||
|
await expect(cancelScheduledToDrafts(item, MAILBOXES, 'own')).resolves.toBe(true);
|
||||||
|
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'own');
|
||||||
|
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'own-drafts', 'own-sent', 'own');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a group account's message back into that account's Drafts", async () => {
|
||||||
|
const item = { accountId: 'team', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
|
||||||
|
await cancelScheduledToDrafts(item, MAILBOXES, 'own');
|
||||||
|
expect(api.cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'team');
|
||||||
|
expect(api.restoreEmailToDraft).toHaveBeenCalledWith('mail-1', 'team-drafts', 'team-sent', 'team');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves nothing when the cancel fails', async () => {
|
||||||
|
api.cancelScheduledSend.mockRejectedValueOnce(new Error('already sent'));
|
||||||
|
const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
|
||||||
|
await expect(cancelScheduledToDrafts(item, MAILBOXES, 'own')).rejects.toThrow('already sent');
|
||||||
|
expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the account has no Drafts folder', async () => {
|
||||||
|
const item = { accountId: 'own', emailSubmissionId: 'sub-1', emailId: 'mail-1' };
|
||||||
|
await expect(cancelScheduledToDrafts(item, [mailbox('sent', 'own-sent')], 'own')).resolves.toBe(false);
|
||||||
|
expect(api.restoreEmailToDraft).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the own account as no override', () => {
|
||||||
|
expect(scheduledAccountOverride({ accountId: 'own' }, 'own')).toBeUndefined();
|
||||||
|
expect(scheduledAccountOverride({ accountId: 'team' }, 'own')).toBe('team');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// "Cancel send" and "Edit" of a scheduled message: the submission is
|
||||||
|
// cancelled and the message goes back to Drafts, so nothing sits in Sent
|
||||||
|
// looking sent (webmail cancelScheduledEmailForEdit, 780b64461). A message
|
||||||
|
// held in a shared or group account goes back to that account's Drafts.
|
||||||
|
|
||||||
|
import { cancelScheduledSend, restoreEmailToDraft, type ScheduledEmail } from '../api/email';
|
||||||
|
import type { Mailbox } from '../api/types';
|
||||||
|
import { mailboxesOfAccount } from './mailbox-tree';
|
||||||
|
|
||||||
|
/** The account to pass to the mail calls for `item`: undefined for the own one. */
|
||||||
|
export function scheduledAccountOverride(item: Pick<ScheduledEmail, 'accountId'>, ownAccountId: string): string | undefined {
|
||||||
|
return item.accountId !== ownAccountId ? item.accountId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the scheduled send and move the message back into Drafts. Resolves
|
||||||
|
* with whether it is in Drafts now: false when the account has no Drafts
|
||||||
|
* folder, where it stays in Sent. A failed cancel throws and moves nothing.
|
||||||
|
*/
|
||||||
|
export async function cancelScheduledToDrafts(
|
||||||
|
item: Pick<ScheduledEmail, 'accountId' | 'emailSubmissionId' | 'emailId'>,
|
||||||
|
mailboxes: Mailbox[],
|
||||||
|
ownAccountId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
await cancelScheduledSend(item.emailSubmissionId, item.accountId);
|
||||||
|
const scope = mailboxesOfAccount(mailboxes, scheduledAccountOverride(item, ownAccountId));
|
||||||
|
const drafts = scope.find((m) => m.role === 'drafts');
|
||||||
|
const sent = scope.find((m) => m.role === 'sent');
|
||||||
|
if (!drafts) return false;
|
||||||
|
await restoreEmailToDraft(
|
||||||
|
item.emailId,
|
||||||
|
drafts.originalId ?? drafts.id,
|
||||||
|
sent ? (sent.originalId ?? sent.id) : undefined,
|
||||||
|
item.accountId,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -9,9 +9,7 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
|||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import {
|
import {
|
||||||
listScheduledEmails,
|
listScheduledEmails,
|
||||||
cancelScheduledSend,
|
|
||||||
rescheduleScheduledSend,
|
rescheduleScheduledSend,
|
||||||
restoreEmailToDraft,
|
|
||||||
getFullEmail,
|
getFullEmail,
|
||||||
type ScheduledEmail,
|
type ScheduledEmail,
|
||||||
} from '../api/email';
|
} from '../api/email';
|
||||||
@@ -23,7 +21,6 @@ import { useLocaleStore } from '../stores/locale-store';
|
|||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useSendUndoStore } from '../stores/send-undo-store';
|
import { useSendUndoStore } from '../stores/send-undo-store';
|
||||||
import { toast } from '../stores/toast-store';
|
import { toast } from '../stores/toast-store';
|
||||||
import { ownMailboxes } from '../lib/mailbox-tree';
|
|
||||||
import { draftContextFromEmail } from '../lib/draft-context';
|
import { draftContextFromEmail } from '../lib/draft-context';
|
||||||
import { formatQuoteDate } from '../lib/quote-header';
|
import { formatQuoteDate } from '../lib/quote-header';
|
||||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||||
@@ -31,6 +28,10 @@ import { useColors } from '../theme/colors';
|
|||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||||
import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key';
|
import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key';
|
||||||
import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule';
|
import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule';
|
||||||
|
import { cancelScheduledToDrafts, scheduledAccountOverride } from '../lib/compose-scheduled';
|
||||||
|
import { useBackWhileFocused } from '../lib/use-back-while-focused';
|
||||||
|
import { ScheduledMessageView } from '../components/compose/ScheduledMessageView';
|
||||||
|
import type { Email } from '../api/types';
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
|
type Props = NativeStackScreenProps<RootStackParamList, 'Scheduled'>;
|
||||||
|
|
||||||
@@ -50,6 +51,28 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
const [rescheduleFor, setRescheduleFor] = React.useState<ScheduledEmail | null>(null);
|
const [rescheduleFor, setRescheduleFor] = React.useState<ScheduledEmail | null>(null);
|
||||||
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
|
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
|
||||||
const customDraftRef = React.useRef<Date>(new Date());
|
const customDraftRef = React.useRef<Date>(new Date());
|
||||||
|
// The scheduled message open in the read-only view (native #84).
|
||||||
|
const [viewing, setViewing] = React.useState<ScheduledEmail | null>(null);
|
||||||
|
const [viewEmail, setViewEmail] = React.useState<Email | null>(null);
|
||||||
|
const [viewError, setViewError] = React.useState<string | null>(null);
|
||||||
|
const viewSeq = React.useRef(0);
|
||||||
|
const openView = React.useCallback((item: ScheduledEmail) => {
|
||||||
|
const seq = ++viewSeq.current;
|
||||||
|
setViewing(item);
|
||||||
|
setViewEmail(null);
|
||||||
|
setViewError(null);
|
||||||
|
getFullEmail(item.emailId, scheduledAccountOverride(item, jmapClient.accountId)).then(
|
||||||
|
(email) => { if (viewSeq.current === seq) setViewEmail(email); },
|
||||||
|
(e: unknown) => { if (viewSeq.current === seq) setViewError(e instanceof Error ? e.message : String(e)); },
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
const closeView = React.useCallback(() => {
|
||||||
|
viewSeq.current += 1;
|
||||||
|
setViewing(null);
|
||||||
|
setViewEmail(null);
|
||||||
|
setViewError(null);
|
||||||
|
}, []);
|
||||||
|
useBackWhileFocused(!!viewing, closeView);
|
||||||
|
|
||||||
const formatWhen = React.useCallback(
|
const formatWhen = React.useCallback(
|
||||||
(iso: string) => formatQuoteDate(iso, timeFormat, locale),
|
(iso: string) => formatQuoteDate(iso, timeFormat, locale),
|
||||||
@@ -90,9 +113,7 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
const restored = restoredSubmission(e);
|
const restored = restoredSubmission(e);
|
||||||
if (restored || rescheduleLostMessage(e)) {
|
if (restored || rescheduleLostMessage(e)) {
|
||||||
if (restored) {
|
if (restored) {
|
||||||
setItems((prev) => prev.map((i) => (sameScheduledSend(i, item)
|
updateItem(item, { emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? item.sendAt });
|
||||||
? { ...i, emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? i.sendAt }
|
|
||||||
: i)));
|
|
||||||
const pending = useSendUndoStore.getState().pending;
|
const pending = useSendUndoStore.getState().pending;
|
||||||
if (isPendingSend(pending, item, jmapClient.accountId)) {
|
if (isPendingSend(pending, item, jmapClient.accountId)) {
|
||||||
useSendUndoStore.getState().setPending({ ...pending!, emailSubmissionId: restored.emailSubmissionId });
|
useSendUndoStore.getState().setPending({ ...pending!, emailSubmissionId: restored.emailSubmissionId });
|
||||||
@@ -147,7 +168,17 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Submission ids repeat across accounts: a row is its account and id.
|
// Submission ids repeat across accounts: a row is its account and id.
|
||||||
const removeItem = (item: ScheduledEmail) => setItems((prev) => prev.filter((i) => !sameScheduledSend(i, item)));
|
const removeItem = (item: ScheduledEmail) => {
|
||||||
|
setItems((prev) => prev.filter((i) => !sameScheduledSend(i, item)));
|
||||||
|
// It is gone from Scheduled: the view of it closes too.
|
||||||
|
setViewing((v) => (v && sameScheduledSend(v, item) ? null : v));
|
||||||
|
};
|
||||||
|
const updateItem = (item: ScheduledEmail, patch: Pick<ScheduledEmail, 'emailSubmissionId' | 'sendAt'>) => {
|
||||||
|
setItems((prev) => prev
|
||||||
|
.map((i) => (sameScheduledSend(i, item) ? { ...i, ...patch } : i))
|
||||||
|
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()));
|
||||||
|
setViewing((v) => (v && sameScheduledSend(v, item) ? { ...v, ...patch } : v));
|
||||||
|
};
|
||||||
const clearUndoFor = (item: ScheduledEmail) => {
|
const clearUndoFor = (item: ScheduledEmail) => {
|
||||||
if (isPendingSend(useSendUndoStore.getState().pending, item, jmapClient.accountId)) useSendUndoStore.getState().clear();
|
if (isPendingSend(useSendUndoStore.getState().pending, item, jmapClient.accountId)) useSendUndoStore.getState().clear();
|
||||||
};
|
};
|
||||||
@@ -155,7 +186,7 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
const onCancel = (item: ScheduledEmail) => {
|
const onCancel = (item: ScheduledEmail) => {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('email_list.cancel_scheduled_send', 'Cancel send'),
|
t('email_list.cancel_scheduled_send', 'Cancel send'),
|
||||||
t('scheduled.cancel_body', 'The message will not be delivered. A copy stays in your Sent folder.'),
|
t('scheduled.cancel_to_drafts_body', 'The message will not be sent. It goes back to your Drafts.'),
|
||||||
[
|
[
|
||||||
{ text: t('scheduled.keep_scheduled', 'Keep scheduled'), style: 'cancel' },
|
{ text: t('scheduled.keep_scheduled', 'Keep scheduled'), style: 'cancel' },
|
||||||
{
|
{
|
||||||
@@ -163,9 +194,15 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: () => {
|
onPress: () => {
|
||||||
void runAction(item, async () => {
|
void runAction(item, async () => {
|
||||||
await cancelScheduledSend(item.emailSubmissionId, item.accountId);
|
const inDrafts = await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
|
||||||
clearUndoFor(item);
|
clearUndoFor(item);
|
||||||
removeItem(item);
|
removeItem(item);
|
||||||
|
toast.success(
|
||||||
|
t('scheduled.send_cancelled', 'Send cancelled'),
|
||||||
|
inDrafts
|
||||||
|
? { message: t('notifications.moved_to_mailbox', 'Moved to {mailbox}', { mailbox: t('sidebar.mailboxes.drafts', 'Drafts') }) }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
}, t('scheduled.cancel_failed', 'Cancel failed'));
|
}, t('scheduled.cancel_failed', 'Cancel failed'));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -202,11 +239,10 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
void runAction(item, async () => {
|
void runAction(item, async () => {
|
||||||
const result = await rescheduleScheduledSend(item, seconds);
|
const result = await rescheduleScheduledSend(item, seconds);
|
||||||
clearUndoFor(item);
|
clearUndoFor(item);
|
||||||
setItems((prev) => prev
|
updateItem(item, {
|
||||||
.map((i) => (sameScheduledSend(i, item)
|
emailSubmissionId: result.emailSubmissionId ?? item.emailSubmissionId,
|
||||||
? { ...i, emailSubmissionId: result.emailSubmissionId ?? i.emailSubmissionId, sendAt: result.sendAt ?? date.toISOString() }
|
sendAt: result.sendAt ?? date.toISOString(),
|
||||||
: i))
|
});
|
||||||
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()));
|
|
||||||
warnRefused(result.refusedRecipients);
|
warnRefused(result.refusedRecipients);
|
||||||
}, t('scheduled.reschedule_failed', 'Reschedule failed'));
|
}, t('scheduled.reschedule_failed', 'Reschedule failed'));
|
||||||
};
|
};
|
||||||
@@ -215,26 +251,12 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
// the composer (webmail cancelScheduledEmailForEdit).
|
// the composer (webmail cancelScheduledEmailForEdit).
|
||||||
const onEdit = (item: ScheduledEmail) => {
|
const onEdit = (item: ScheduledEmail) => {
|
||||||
void runAction(item, async () => {
|
void runAction(item, async () => {
|
||||||
await cancelScheduledSend(item.emailSubmissionId, item.accountId);
|
|
||||||
clearUndoFor(item);
|
|
||||||
// A message held in a shared account goes back to that account's Drafts.
|
// A message held in a shared account goes back to that account's Drafts.
|
||||||
const shared = item.accountId !== jmapClient.accountId;
|
await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
|
||||||
const scope = shared
|
clearUndoFor(item);
|
||||||
? mailboxes.filter((m) => m.isShared && m.accountId === item.accountId)
|
|
||||||
: ownMailboxes(mailboxes);
|
|
||||||
const drafts = scope.find((m) => m.role === 'drafts');
|
|
||||||
const sent = scope.find((m) => m.role === 'sent');
|
|
||||||
if (drafts) {
|
|
||||||
await restoreEmailToDraft(
|
|
||||||
item.emailId,
|
|
||||||
drafts.originalId ?? drafts.id,
|
|
||||||
sent ? (sent.originalId ?? sent.id) : undefined,
|
|
||||||
item.accountId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const email = await getFullEmail(item.emailId, item.accountId);
|
const email = await getFullEmail(item.emailId, item.accountId);
|
||||||
removeItem(item);
|
removeItem(item);
|
||||||
navigation.replace('Compose', { draft: draftContextFromEmail(email, shared ? item.accountId : undefined) });
|
navigation.replace('Compose', { draft: draftContextFromEmail(email, scheduledAccountOverride(item, jmapClient.accountId)) });
|
||||||
}, t('scheduled.edit_failed', 'Could not open the message for editing'));
|
}, t('scheduled.edit_failed', 'Could not open the message for editing'));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,7 +316,7 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
const renderItem = ({ item }: { item: ScheduledEmail }) => {
|
const renderItem = ({ item }: { item: ScheduledEmail }) => {
|
||||||
const busy = busyId === scheduledSendKey(item);
|
const busy = busyId === scheduledSendKey(item);
|
||||||
return (
|
return (
|
||||||
<Pressable style={styles.row} onPress={() => setActionsFor(item)} disabled={busy}>
|
<Pressable style={styles.row} onPress={() => openView(item)} disabled={busy}>
|
||||||
<View style={styles.rowMain}>
|
<View style={styles.rowMain}>
|
||||||
<View style={styles.sendAtRow}>
|
<View style={styles.sendAtRow}>
|
||||||
<Clock size={13} color={c.primary} />
|
<Clock size={13} color={c.primary} />
|
||||||
@@ -310,13 +332,20 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.actionBtn}>
|
<Pressable
|
||||||
|
style={styles.actionBtn}
|
||||||
|
onPress={() => setActionsFor(item)}
|
||||||
|
disabled={busy}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={t('scheduled.actions', 'Actions')}
|
||||||
|
>
|
||||||
{busy ? (
|
{busy ? (
|
||||||
<ActivityIndicator size="small" color={c.primary} />
|
<ActivityIndicator size="small" color={c.primary} />
|
||||||
) : (
|
) : (
|
||||||
<MoreHorizontal size={18} color={c.textSecondary} />
|
<MoreHorizontal size={18} color={c.textSecondary} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</Pressable>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -330,6 +359,23 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
|
<SafeAreaView style={styles.container} edges={['top', 'left', 'right']}>
|
||||||
|
{viewing ? (
|
||||||
|
<ScheduledMessageView
|
||||||
|
item={viewing}
|
||||||
|
email={viewEmail}
|
||||||
|
error={viewError}
|
||||||
|
jmapAccountId={scheduledAccountOverride(viewing, jmapClient.accountId)}
|
||||||
|
busy={busyId === scheduledSendKey(viewing)}
|
||||||
|
formatWhen={formatWhen}
|
||||||
|
onBack={closeView}
|
||||||
|
onRetry={() => openView(viewing)}
|
||||||
|
onSendNow={() => onSendNow(viewing)}
|
||||||
|
onReschedule={() => setRescheduleFor(viewing)}
|
||||||
|
onEdit={() => onEdit(viewing)}
|
||||||
|
onCancelSend={() => onCancel(viewing)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
@@ -375,6 +421,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
|||||||
onRefresh={() => { void load(); }}
|
onRefresh={() => { void load(); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Per-message actions */}
|
{/* Per-message actions */}
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user