mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +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"
|
||||
},
|
||||
"scheduled": {
|
||||
"actions": "Actions",
|
||||
"already_sent": "This message was already sent.",
|
||||
"cancel_body": "The message will not be delivered. A copy stays in your Sent folder.",
|
||||
"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",
|
||||
"keep_scheduled": "Keep scheduled",
|
||||
"no_longer_scheduled": "This message is no longer scheduled.",
|
||||
"reschedule_failed": "Reschedule failed",
|
||||
"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.",
|
||||
"send_cancelled": "Send cancelled",
|
||||
"send_now_failed": "Could not send now"
|
||||
},
|
||||
"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 {
|
||||
listScheduledEmails,
|
||||
cancelScheduledSend,
|
||||
rescheduleScheduledSend,
|
||||
restoreEmailToDraft,
|
||||
getFullEmail,
|
||||
type ScheduledEmail,
|
||||
} from '../api/email';
|
||||
@@ -23,7 +21,6 @@ import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useSendUndoStore } from '../stores/send-undo-store';
|
||||
import { toast } from '../stores/toast-store';
|
||||
import { ownMailboxes } from '../lib/mailbox-tree';
|
||||
import { draftContextFromEmail } from '../lib/draft-context';
|
||||
import { formatQuoteDate } from '../lib/quote-header';
|
||||
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 { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key';
|
||||
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'>;
|
||||
|
||||
@@ -50,6 +51,28 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
const [rescheduleFor, setRescheduleFor] = React.useState<ScheduledEmail | null>(null);
|
||||
const [customStage, setCustomStage] = React.useState<'datetime' | 'date' | 'time' | null>(null);
|
||||
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(
|
||||
(iso: string) => formatQuoteDate(iso, timeFormat, locale),
|
||||
@@ -90,9 +113,7 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
const restored = restoredSubmission(e);
|
||||
if (restored || rescheduleLostMessage(e)) {
|
||||
if (restored) {
|
||||
setItems((prev) => prev.map((i) => (sameScheduledSend(i, item)
|
||||
? { ...i, emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? i.sendAt }
|
||||
: i)));
|
||||
updateItem(item, { emailSubmissionId: restored.emailSubmissionId, sendAt: restored.sendAt ?? item.sendAt });
|
||||
const pending = useSendUndoStore.getState().pending;
|
||||
if (isPendingSend(pending, item, jmapClient.accountId)) {
|
||||
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.
|
||||
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) => {
|
||||
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) => {
|
||||
Alert.alert(
|
||||
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' },
|
||||
{
|
||||
@@ -163,9 +194,15 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
void runAction(item, async () => {
|
||||
await cancelScheduledSend(item.emailSubmissionId, item.accountId);
|
||||
const inDrafts = await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
|
||||
clearUndoFor(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'));
|
||||
},
|
||||
},
|
||||
@@ -202,11 +239,10 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
void runAction(item, async () => {
|
||||
const result = await rescheduleScheduledSend(item, seconds);
|
||||
clearUndoFor(item);
|
||||
setItems((prev) => prev
|
||||
.map((i) => (sameScheduledSend(i, item)
|
||||
? { ...i, emailSubmissionId: result.emailSubmissionId ?? i.emailSubmissionId, sendAt: result.sendAt ?? date.toISOString() }
|
||||
: i))
|
||||
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()));
|
||||
updateItem(item, {
|
||||
emailSubmissionId: result.emailSubmissionId ?? item.emailSubmissionId,
|
||||
sendAt: result.sendAt ?? date.toISOString(),
|
||||
});
|
||||
warnRefused(result.refusedRecipients);
|
||||
}, t('scheduled.reschedule_failed', 'Reschedule failed'));
|
||||
};
|
||||
@@ -215,26 +251,12 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
// the composer (webmail cancelScheduledEmailForEdit).
|
||||
const onEdit = (item: ScheduledEmail) => {
|
||||
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.
|
||||
const shared = item.accountId !== jmapClient.accountId;
|
||||
const scope = shared
|
||||
? 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,
|
||||
);
|
||||
}
|
||||
await cancelScheduledToDrafts(item, mailboxes, jmapClient.accountId);
|
||||
clearUndoFor(item);
|
||||
const email = await getFullEmail(item.emailId, item.accountId);
|
||||
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'));
|
||||
};
|
||||
|
||||
@@ -294,7 +316,7 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
const renderItem = ({ item }: { item: ScheduledEmail }) => {
|
||||
const busy = busyId === scheduledSendKey(item);
|
||||
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.sendAtRow}>
|
||||
<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>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.actionBtn}>
|
||||
<Pressable
|
||||
style={styles.actionBtn}
|
||||
onPress={() => setActionsFor(item)}
|
||||
disabled={busy}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('scheduled.actions', 'Actions')}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
) : (
|
||||
<MoreHorizontal size={18} color={c.textSecondary} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
@@ -330,6 +359,23 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<Pressable
|
||||
onPress={() => navigation.goBack()}
|
||||
@@ -375,6 +421,8 @@ export default function ScheduledScreen({ navigation }: Props) {
|
||||
onRefresh={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Per-message actions */}
|
||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!actionsFor} transparent animationType="fade" onRequestClose={() => setActionsFor(null)}>
|
||||
|
||||
Reference in New Issue
Block a user