mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: drafts with autosave and a save/discard close guard, Bcc, signatures, templates, sub-addressing, From override, read receipts, upload progress, inline-image hydration and an undo-send snackbar in the composer (#188, #849, #848, #540, #570, #684, #163)
This commit is contained in:
Generated
+4
-4
@@ -18,7 +18,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "^8.0.7",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
@@ -5238,9 +5238,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-clipboard": {
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz",
|
||||
"integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==",
|
||||
"version": "8.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz",
|
||||
"integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "^8.0.7",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-crypto": "~15.0.9",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
|
||||
+84
-8
@@ -6,6 +6,7 @@ import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './typ
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||
import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
|
||||
import { buildMdnMessage, type MdnOptions } from '../lib/mdn';
|
||||
|
||||
export const EMAIL_LIST_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
@@ -989,6 +990,12 @@ export interface OutgoingEmail {
|
||||
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
|
||||
messageId?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
/**
|
||||
* SMTP MAIL FROM when it must differ from the header From - a catch-all
|
||||
* alias on an owned domain is sent through the identity's envelope while
|
||||
* the visible From keeps the alias (webmail #246).
|
||||
*/
|
||||
envelopeMailFrom?: string;
|
||||
}
|
||||
|
||||
export interface SendEmailResult {
|
||||
@@ -1128,19 +1135,19 @@ export async function sendEmail(
|
||||
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
||||
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
||||
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
|
||||
// server derive mailFrom from the Identity, dropping our parameter).
|
||||
if (holdForSeconds && holdForSeconds > 0) {
|
||||
// server derive mailFrom from the Identity, dropping our parameter). An
|
||||
// explicit envelope sender (catch-all From override) needs it as well.
|
||||
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
|
||||
if (holdFor > 0 || email.envelopeMailFrom) {
|
||||
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
||||
.map((r) => r.email.trim())
|
||||
.filter(Boolean)
|
||||
.map((address) => ({ email: address }));
|
||||
submissionCreate.envelope = {
|
||||
mailFrom: {
|
||||
email: email.from[0]?.email,
|
||||
parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) },
|
||||
},
|
||||
rcptTo,
|
||||
const mailFrom: Record<string, unknown> = {
|
||||
email: email.envelopeMailFrom || email.from[0]?.email,
|
||||
};
|
||||
if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) };
|
||||
submissionCreate.envelope = { mailFrom, rcptTo };
|
||||
}
|
||||
|
||||
const submissionArgs: Record<string, unknown> = {
|
||||
@@ -1402,3 +1409,72 @@ export async function rescheduleScheduledSend(
|
||||
const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined;
|
||||
return { emailSubmissionId: created?.id, sendAt: created?.sendAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a message back into Drafts (out of Sent) and re-flag it `$draft` so
|
||||
* it can be edited and re-sent - used after cancelling a scheduled send for
|
||||
* editing (webmail `restoreEmailToDraft`).
|
||||
*/
|
||||
export async function restoreEmailToDraft(
|
||||
emailId: string,
|
||||
draftsMailboxId: string,
|
||||
sentMailboxId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<void> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const patch: Record<string, unknown> = {
|
||||
[mailboxPointer(draftsMailboxId)]: true,
|
||||
[keywordPointer('$draft')]: true,
|
||||
[keywordPointer('$seen')]: true,
|
||||
};
|
||||
if (sentMailboxId && sentMailboxId !== draftsMailboxId) patch[mailboxPointer(sentMailboxId)] = null;
|
||||
await emailSetBatched(accountId, { update: { [emailId]: patch } }, 'draft');
|
||||
}
|
||||
|
||||
export interface SendReadReceiptOptions extends MdnOptions {
|
||||
/** Identity the receipt is submitted through (its address is the MDN From). */
|
||||
identityId: string;
|
||||
/** Where the sent receipt is filed. */
|
||||
sentMailboxId: string;
|
||||
/** Submitting account (shared/group account); defaults to the primary. */
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an RFC 8098 read receipt (MDN). JMAP has no MDN primitive, so the
|
||||
* multipart/report is built client-side (`lib/mdn.ts`), uploaded as a blob,
|
||||
* imported into Sent and submitted with an explicit envelope - mirrors the
|
||||
* webmail's `client.sendReadReceipt`. The caller flags the original
|
||||
* `$mdnsent` afterwards.
|
||||
*/
|
||||
export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise<string> {
|
||||
const accountId = opts.accountId ?? jmapClient.accountId;
|
||||
const raw = buildMdnMessage(opts);
|
||||
const bytes = new TextEncoder().encode(raw);
|
||||
// Lazy: blob.ts pulls in expo-file-system, which this module otherwise
|
||||
// never needs (and which the node test environment cannot load).
|
||||
const { uploadBytes } = await import('./blob');
|
||||
const upload = await uploadBytes(bytes, 'message/rfc822', accountId);
|
||||
const emailId = await importEmailBlob(upload.blobId, opts.sentMailboxId, { $seen: true }, accountId);
|
||||
const res = await jmapClient.request(
|
||||
[
|
||||
['EmailSubmission/set', {
|
||||
accountId,
|
||||
create: {
|
||||
mdn: {
|
||||
emailId,
|
||||
identityId: opts.identityId,
|
||||
envelope: {
|
||||
mailFrom: { email: opts.fromEmail },
|
||||
rcptTo: [{ email: opts.to }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, '0'],
|
||||
],
|
||||
SUBMISSION_USING,
|
||||
);
|
||||
const body = requireMethodResult(res, '0', 'EmailSubmission/set');
|
||||
assertSetResult(body, ['mdn'], 'read receipt');
|
||||
return emailId;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export type RichTextCommand =
|
||||
| 'justifyRight'
|
||||
| 'removeFormat'
|
||||
| 'undo'
|
||||
| 'redo';
|
||||
| 'redo'
|
||||
// Text colour: a CSS colour after the colon, e.g. 'foreColor:#d97706'.
|
||||
| `foreColor:${string}`;
|
||||
|
||||
export interface RichTextSelectionState {
|
||||
bold: boolean;
|
||||
@@ -50,6 +52,8 @@ export interface RichTextEditorHandle {
|
||||
insertLink(url: string, label?: string): void;
|
||||
unsetLink(): void;
|
||||
insertImage(src: string, cid?: string, alt?: string): void;
|
||||
/** Insert an HTML fragment at the caret (template bodies, tables). */
|
||||
insertHtml(html: string): void;
|
||||
setHtml(html: string): void;
|
||||
/**
|
||||
* Read the live editor DOM content. Resolves with the current innerHTML or
|
||||
@@ -97,7 +101,17 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
[],
|
||||
);
|
||||
|
||||
// Scripts injected before the page has run its inline script are lost, and
|
||||
// the composer talks to the editor as soon as async data arrives (signature
|
||||
// after the identities load, hydrated inline images). Queue until the page
|
||||
// posts its first message, then replay in order.
|
||||
const readyRef = React.useRef(false);
|
||||
const queuedRef = React.useRef<string[]>([]);
|
||||
const call = React.useCallback((script: string) => {
|
||||
if (!readyRef.current) {
|
||||
queuedRef.current.push(script);
|
||||
return;
|
||||
}
|
||||
webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
}, []);
|
||||
|
||||
@@ -124,7 +138,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
exec: (command) => {
|
||||
const [name, value] = command.split(':');
|
||||
const sep = command.indexOf(':');
|
||||
const name = sep === -1 ? command : command.slice(0, sep);
|
||||
const value = sep === -1 ? undefined : command.slice(sep + 1);
|
||||
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
|
||||
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
|
||||
},
|
||||
@@ -137,6 +153,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
insertImage: (src, cid, alt) => {
|
||||
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
|
||||
},
|
||||
insertHtml: (html) => {
|
||||
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`);
|
||||
},
|
||||
setHtml: (next) => {
|
||||
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
|
||||
},
|
||||
@@ -162,6 +181,12 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!readyRef.current) {
|
||||
readyRef.current = true;
|
||||
const queued = queuedRef.current;
|
||||
queuedRef.current = [];
|
||||
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
|
||||
}
|
||||
switch (data.type) {
|
||||
case 'change':
|
||||
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X, Star, FileText } from 'lucide-react-native';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store';
|
||||
import { filterTemplates } from '../lib/template-utils';
|
||||
|
||||
interface TemplateSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onPick: (template: EmailTemplate) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom sheet listing the user's templates for insertion into the composer
|
||||
* (webmail template picker): favourites first, then the rest, with a search
|
||||
* box over name/subject/category.
|
||||
*/
|
||||
export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const templates = useTemplatesStore((s) => s.templates);
|
||||
const hydrated = useTemplatesStore((s) => s.hydrated);
|
||||
const hydrate = useTemplatesStore((s) => s.hydrate);
|
||||
const [query, setQuery] = React.useState('');
|
||||
const slideY = React.useRef(new Animated.Value(500)).current;
|
||||
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
||||
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setQuery('');
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideY, overlayOpacity]);
|
||||
|
||||
const visibleTemplates = React.useMemo(() => {
|
||||
const list = query.trim() ? filterTemplates(templates, query.trim()) : templates;
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.isFavorite !== b.isFavorite) return a.isFavorite ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [templates, query]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.sheetHandleHit}>
|
||||
<View style={styles.sheetHandle} />
|
||||
</View>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>{t('templates.picker_title', 'Choose a Template')}</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{templates.length > 4 && (
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder={t('templates.search_placeholder', 'Search templates...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.search}
|
||||
autoCorrect={false}
|
||||
/>
|
||||
)}
|
||||
<ScrollView style={styles.scrollList} keyboardShouldPersistTaps="handled">
|
||||
{visibleTemplates.length === 0 ? (
|
||||
<Text style={styles.empty}>
|
||||
{templates.length === 0
|
||||
? t('templates.no_templates', 'No templates yet')
|
||||
: t('templates.no_results', 'No templates found')}
|
||||
</Text>
|
||||
) : visibleTemplates.map((template) => (
|
||||
<Pressable
|
||||
key={template.id}
|
||||
onPress={() => {
|
||||
onPick(template);
|
||||
onClose();
|
||||
}}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
>
|
||||
<View style={styles.iconWrap}>
|
||||
{template.isFavorite
|
||||
? <Star size={16} color={c.primary} />
|
||||
: <FileText size={16} color={c.textSecondary} />}
|
||||
</View>
|
||||
<View style={styles.details}>
|
||||
<Text style={styles.name} numberOfLines={1}>{template.name}</Text>
|
||||
{!!(template.subject || template.category) && (
|
||||
<Text style={styles.subject} numberOfLines={1}>
|
||||
{[template.category, template.subject].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
sheetOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
sheetOverlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
maxHeight: '70%',
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
sheetHandleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
sheetHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
sheetTitle: { ...typography.bodySemibold, color: c.text },
|
||||
sheetClose: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
search: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 8,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
scrollList: { paddingVertical: spacing.xs },
|
||||
empty: { ...typography.body, color: c.textMuted, textAlign: 'center', padding: spacing.xl },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 52,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
iconWrap: {
|
||||
marginRight: spacing.md,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: radius.xs,
|
||||
backgroundColor: c.surface,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
details: { flex: 1, justifyContent: 'center' },
|
||||
name: { ...typography.bodyMedium, color: c.text },
|
||||
subject: { ...typography.caption, color: c.textSecondary, marginTop: 2 },
|
||||
});
|
||||
}
|
||||
@@ -1,33 +1,72 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useSendUndoStore, type PendingUndoSend } from '../stores/send-undo-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { getFullEmail, restoreEmailToDraft } from '../api/email';
|
||||
import { ownMailboxes } from '../lib/mailbox-tree';
|
||||
import { draftContextFromEmail } from '../lib/draft-context';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
|
||||
const VISIBLE_MS = 5000;
|
||||
|
||||
interface Shown {
|
||||
label: string;
|
||||
createdAt: number;
|
||||
visibleMs: number;
|
||||
/** Undo-send entries carry a second "Send now" action. */
|
||||
send?: PendingUndoSend;
|
||||
}
|
||||
|
||||
export function UndoSnackbar() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const entry = useEmailStore((s) => s.pendingUndo);
|
||||
const undoLast = useEmailStore((s) => s.undoLast);
|
||||
const clearUndo = useEmailStore((s) => s.clearUndo);
|
||||
const pendingSend = useSendUndoStore((s) => s.pending);
|
||||
const sendBusy = useSendUndoStore((s) => s.busy);
|
||||
const slideY = React.useRef(new Animated.Value(120)).current;
|
||||
const opacity = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
// The undo-send entry (composer) and the list-action entry (store) share
|
||||
// the bar; the newer one wins.
|
||||
const current = React.useMemo<Shown | null>(() => {
|
||||
const list: Shown | null = entry
|
||||
? { label: entry.label, createdAt: entry.createdAt, visibleMs: VISIBLE_MS }
|
||||
: null;
|
||||
const send: Shown | null = pendingSend
|
||||
? {
|
||||
label: t('email_composer.undo_send_label', 'Sending in {seconds}s…', { seconds: pendingSend.delaySeconds }),
|
||||
createdAt: pendingSend.createdAt,
|
||||
visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000),
|
||||
send: pendingSend,
|
||||
}
|
||||
: null;
|
||||
if (list && send) return list.createdAt >= send.createdAt ? list : send;
|
||||
return list ?? send;
|
||||
}, [entry, pendingSend, t]);
|
||||
|
||||
// Remember the last non-null entry so the bar's text/handlers stay valid
|
||||
// while it animates out after pendingUndo flips to null.
|
||||
const [shown, setShown] = React.useState(entry);
|
||||
// while it animates out after the entry flips to null.
|
||||
const [shown, setShown] = React.useState(current);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (entry) setShown(entry);
|
||||
}, [entry]);
|
||||
if (current) setShown(current);
|
||||
}, [current]);
|
||||
|
||||
// Drive the auto-dismiss off the entry's createdAt so re-renders during
|
||||
// animation don't reset the timer, and so consecutive actions reset it.
|
||||
React.useEffect(() => {
|
||||
if (!entry) {
|
||||
if (!current) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
||||
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
|
||||
@@ -42,16 +81,44 @@ export function UndoSnackbar() {
|
||||
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
|
||||
]).start();
|
||||
|
||||
const elapsed = Date.now() - entry.createdAt;
|
||||
const remaining = Math.max(0, VISIBLE_MS - elapsed);
|
||||
const elapsed = Date.now() - current.createdAt;
|
||||
const remaining = Math.max(0, current.visibleMs - elapsed);
|
||||
const id = setTimeout(() => {
|
||||
// Only clear if the entry is still the same one we scheduled for.
|
||||
if (useEmailStore.getState().pendingUndo === entry) {
|
||||
if (current.send) {
|
||||
if (useSendUndoStore.getState().pending === current.send) useSendUndoStore.getState().clear();
|
||||
} else if (useEmailStore.getState().pendingUndo === entry) {
|
||||
// Only clear if the entry is still the same one we scheduled for.
|
||||
clearUndo();
|
||||
}
|
||||
}, remaining);
|
||||
return () => clearTimeout(id);
|
||||
}, [entry, slideY, opacity, clearUndo]);
|
||||
}, [current, entry, slideY, opacity, clearUndo]);
|
||||
|
||||
const onUndo = async () => {
|
||||
if (!shown) return;
|
||||
if (!shown.send) {
|
||||
void undoLast();
|
||||
return;
|
||||
}
|
||||
const send = shown.send;
|
||||
const ok = await useSendUndoStore.getState().undo();
|
||||
if (!ok) return;
|
||||
// Bring the message back as an editable draft and reopen the composer.
|
||||
try {
|
||||
const own = ownMailboxes(useEmailStore.getState().mailboxes);
|
||||
const drafts = own.find((m) => m.role === 'drafts');
|
||||
const sent = own.find((m) => m.role === 'sent');
|
||||
if (drafts) await restoreEmailToDraft(send.emailId, drafts.id, sent?.id);
|
||||
const email = await getFullEmail(send.emailId);
|
||||
navigation.navigate('Compose', { draft: draftContextFromEmail(email) });
|
||||
} catch (err) {
|
||||
console.warn('[send-undo] reopen failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
const onSendNow = () => {
|
||||
void useSendUndoStore.getState().sendNow();
|
||||
};
|
||||
|
||||
if (!shown) return null;
|
||||
|
||||
@@ -65,12 +132,23 @@ export function UndoSnackbar() {
|
||||
>
|
||||
<View style={styles.bar}>
|
||||
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
|
||||
{shown.send && (
|
||||
<Pressable
|
||||
onPress={onSendNow}
|
||||
hitSlop={8}
|
||||
disabled={sendBusy}
|
||||
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
||||
>
|
||||
<Text style={styles.sendNowText}>{t('email_composer.send_now', 'Send now').toUpperCase()}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={() => { void undoLast(); }}
|
||||
onPress={() => { void onUndo(); }}
|
||||
hitSlop={8}
|
||||
disabled={sendBusy}
|
||||
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
|
||||
>
|
||||
<Text style={styles.undoText}>UNDO</Text>
|
||||
<Text style={styles.undoText}>{t('common.undo', 'Undo').toUpperCase()}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
@@ -117,5 +195,11 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.primary,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
sendNowText: {
|
||||
...typography.bodySemibold,
|
||||
color: c.background,
|
||||
letterSpacing: 0.5,
|
||||
opacity: 0.85,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function buildEditorHtml(opts: {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="editor" contenteditable="true" data-placeholder=${placeholderJson}></div>
|
||||
<div id="editor" contenteditable="true" dir="auto" data-placeholder=${placeholderJson}></div>
|
||||
<script>
|
||||
(function () {
|
||||
var editor = document.getElementById('editor');
|
||||
|
||||
+1659
-524
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
// A message sent with the undo-send delay: the submission is held by the
|
||||
// server (HOLDFOR) and can still be cancelled or released early. The composer
|
||||
// records it here after a delayed send; the UndoSnackbar on the mail list
|
||||
// offers "Undo" / "Send now" for the length of the window (webmail
|
||||
// `pendingUndoSend`, changelog 1.7.0).
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { cancelScheduledSend, rescheduleScheduledSend } from '../api/email';
|
||||
import type { EmailAddress } from '../api/types';
|
||||
|
||||
export interface PendingUndoSend {
|
||||
emailSubmissionId: string;
|
||||
emailId: string;
|
||||
identityId: string;
|
||||
from?: EmailAddress[];
|
||||
to?: EmailAddress[];
|
||||
/** ISO time the server will release the message. */
|
||||
sendAt?: string;
|
||||
/** Milliseconds the message is held (drives the snackbar timer). */
|
||||
delaySeconds: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface SendUndoState {
|
||||
pending: PendingUndoSend | null;
|
||||
busy: boolean;
|
||||
/** Set after an undo so the composer can be reopened with the draft. */
|
||||
restoredEmailId: string | null;
|
||||
setPending: (entry: PendingUndoSend) => void;
|
||||
clear: () => void;
|
||||
/** Cancel delivery. The message stays in Sent; the caller may re-open it. */
|
||||
undo: () => Promise<boolean>;
|
||||
/** Release the held message immediately. */
|
||||
sendNow: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useSendUndoStore = create<SendUndoState>((set, get) => ({
|
||||
pending: null,
|
||||
busy: false,
|
||||
restoredEmailId: null,
|
||||
|
||||
setPending: (entry) => set({ pending: entry, restoredEmailId: null }),
|
||||
clear: () => set({ pending: null, busy: false }),
|
||||
|
||||
undo: async () => {
|
||||
const entry = get().pending;
|
||||
if (!entry || get().busy) return false;
|
||||
set({ busy: true });
|
||||
try {
|
||||
await cancelScheduledSend(entry.emailSubmissionId);
|
||||
set({ pending: null, busy: false, restoredEmailId: entry.emailId });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[send-undo] cancel failed', err);
|
||||
set({ busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
sendNow: async () => {
|
||||
const entry = get().pending;
|
||||
if (!entry || get().busy) return false;
|
||||
set({ busy: true });
|
||||
try {
|
||||
await rescheduleScheduledSend(
|
||||
{
|
||||
emailSubmissionId: entry.emailSubmissionId,
|
||||
emailId: entry.emailId,
|
||||
identityId: entry.identityId,
|
||||
from: entry.from,
|
||||
to: entry.to,
|
||||
},
|
||||
0,
|
||||
);
|
||||
set({ pending: null, busy: false });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[send-undo] send now failed', err);
|
||||
set({ busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user