Files
native/src/components/email/QuickReplyBox.tsx
T

250 lines
11 KiB
TypeScript

import React from 'react';
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert, Keyboard } from 'react-native';
import { Send, Maximize2 } from '../icons';
import type { Email } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useEmailStore } from '../../stores/email-store';
import { toast } from '../../stores/toast-store';
import { currentSendLogin, useSendUndoStore } from '../../stores/send-undo-store';
import { useAccountStore } from '../../stores/account-store';
import { loginMovedFrom } from '../../lib/compose-login';
import { sendEmail, patchKeywordsForEmails } from '../../api/email';
import { jmapClient } from '../../api/jmap-client';
import { pruneEmailSubmissions } from '../../api/submissions';
import { buildReplyRecipients } from '../../lib/reply-recipients';
import { partlyRefusedWarning, sendFailureMessage } from '../../lib/send-failure-message';
import { buildReplySubject } from '../../lib/subject-prefix';
import { computeReplyThreadingHeaders } from '../../lib/email-threading';
import { resolveReplyIdentity } from '../../lib/reply-identity';
import { signatureIdentityFor, signPlainTextReply } from '../../lib/signature-utils';
import { pickEmailBody, plainTextBody } from '../../lib/email-body';
import { htmlToPlainText } from '../../lib/compose-html';
import { mailboxesOfAccount } from '../../lib/mailbox-tree';
import { emailDisplayDate } from '../../lib/email-date';
import { buildQuoteHeader, quoteHeaderLabels } from '../../lib/quote-header';
interface Props {
email: Email;
jmapAccountId?: string;
/** Open the full composer with the draft text carried over. */
onMoreOptions: (draft: string) => void;
/** Reflect `$answered` in the caller's cache. */
onSent?: (email: Email) => void;
onFocus?: () => void;
onBlur?: () => void;
}
/**
* Inline reply box under the message (webmail quick reply): plain-text reply
* to the sender with the original quoted, sent through the identity that
* received the message. "More options" hands the text to the full composer.
*/
export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onFocus, onBlur }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const identities = useSettingsStore((s) => s.identities);
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
const signaturePosition = useSettingsStore((s) => s.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled);
const mailboxes = useEmailStore((s) => s.mailboxes);
const [text, setText] = React.useState('');
const [sending, setSending] = React.useState(false);
React.useEffect(() => { setText(''); }, [email.id]);
// The login the message on screen belongs to: the one active when it was
// shown. A switch while it stays on screen must not send the reply, or
// flag the original, through the other login (lib/compose-login).
const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId);
React.useEffect(() => { ownerLoginRef.current = useAccountStore.getState().activeAccountId; }, [email.id]);
const from = email.from?.[0];
if (!from?.email || email.keywords?.$draft) return null;
const send = async () => {
const body = text.trim();
if (!body || sending) return;
if (loginMovedFrom(ownerLoginRef.current)) {
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t('email_viewer.quick_reply_login_switched', 'This message is in another account. Switch back to it to reply.'),
);
return;
}
const ownEmails = identities.map((i) => i.email).filter(Boolean);
// The own identity the message was delivered to (or, for our own message,
// the one that sent it). Never the catch-all From rewrite: this box has no
// From row to show or correct it.
const resolved = resolveReplyIdentity(identities, {
from, to: email.to ?? undefined, cc: email.cc ?? undefined, bcc: email.bcc ?? undefined,
}, { ownEmails, catchAll: false });
const identity = identities.find((i) => i.id === resolved?.identityId) ?? identities[0];
if (!identity) {
Alert.alert(t('common.error', 'Error'), t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
return;
}
// Sent/Drafts of the account the reply is submitted from: the message's.
const scoped = mailboxesOfAccount(mailboxes, jmapAccountId);
const sent = scoped.find((m) => m.role === 'sent');
const drafts = scoped.find((m) => m.role === 'drafts');
if (!sent) {
Alert.alert(t('common.error', 'Error'), t('email_composer.no_sent_folder', 'No Sent folder found'));
return;
}
setSending(true);
try {
const recipients = buildReplyRecipients(
{ from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc },
'reply',
ownEmails,
);
const picked = pickEmailBody(email);
const original = picked.text ?? (picked.html ? htmlToPlainText(picked.html) : plainTextBody(email));
const quoted = original.split('\n').map((l) => `> ${l}`).join('\n');
// The header the composer puts above a quoted reply ("On …, X wrote:").
const header = buildQuoteHeader({
mode: 'reply',
email: { from, subject: email.subject, receivedAt: emailDisplayDate(email) },
timeFormat,
locale,
unknownLabel: t('common.unknown', 'Unknown'),
labels: quoteHeaderLabels(t),
});
const threading = computeReplyThreadingHeaders(email);
const holdFor = jmapClient.undoSendHold(sendDelaySeconds, jmapAccountId);
// Where the reply goes, read before it goes: Undo must reach that login
// and account whatever the app switches to meanwhile.
const sentThrough = { login: currentSendLogin(), accountId: jmapAccountId ?? jmapClient.connectedAccountId ?? undefined };
const result = await sendEmail(
{
from: [{ name: identity.name, email: identity.email }],
to: recipients.to.filter((r) => !!r.email).map((r) => ({ email: r.email!, name: r.name })),
cc: recipients.cc.filter((r) => !!r.email).map((r) => ({ email: r.email!, name: r.name })),
subject: buildReplySubject(email.subject, t('email_composer.prefix.reply', 'Re:')),
// Signed as the composer signs a plain-text reply; an alias without a
// signature of its own carries the primary identity's.
textBody: signPlainTextReply(body, `${header.text}${quoted}`, signatureIdentityFor(identity, identities), {
position: signaturePosition,
separator: signatureSeparatorEnabled,
}),
inReplyTo: threading?.inReplyTo,
references: threading?.references,
},
identity.id,
sent.originalId ?? sent.id,
holdFor,
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
);
// Held for the undo-send delay: the undo bar offers Undo / Send now.
// Recorded before the flag below so its round trip doesn't eat the window.
useSendUndoStore.getState().recordHeldSend(result, holdFor, {
identityId: identity.id,
...sentThrough,
from: [{ name: identity.name, email: identity.email }],
});
void pruneEmailSubmissions();
try {
// After a switch during the send the id names another message.
if (!loginMovedFrom(ownerLoginRef.current)) {
await patchKeywordsForEmails([email.id], { $answered: true }, jmapAccountId);
}
} catch { /* the reply is out; the flag is cosmetic */ }
onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } });
setText('');
// The keyboard would cover the undo bar or the toast.
Keyboard.dismiss();
if (result.refusedRecipients?.length) {
// As in the composer: nothing else would say who never gets it.
const { title, message } = partlyRefusedWarning(result.refusedRecipients, t);
toast.warning(title, { message, duration: 10_000 });
} else if (!result.scheduled) {
// A reply held for the undo-send delay has not gone out yet (webmail b03a0c1d).
toast.success(t('notifications.email_sent', 'Email sent successfully'));
}
} catch (err) {
Alert.alert(t('email_composer.send_failed', 'Failed to send'), sendFailureMessage(err, t));
} finally {
setSending(false);
}
};
return (
<View style={styles.box}>
<TextInput
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
value={text}
onChangeText={setText}
onFocus={onFocus}
onBlur={onBlur}
placeholder={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
placeholderTextColor={c.mutedForeground}
style={styles.input}
multiline
editable={!sending}
/>
<View style={styles.actions}>
<Pressable style={styles.more} onPress={() => onMoreOptions(text)} hitSlop={10} accessibilityRole="button">
<Maximize2 size={14} color={c.textSecondary} />
<Text style={styles.moreText}>{t('email_viewer.more_options', 'More options')}</Text>
</Pressable>
<Pressable
style={[styles.send, (!text.trim() || sending) && styles.sendDisabled]}
onPress={send}
disabled={!text.trim() || sending}
hitSlop={10}
accessibilityRole="button"
accessibilityState={{ disabled: !text.trim() || sending, busy: sending }}
>
{sending ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Send size={14} color={c.primaryForeground} />}
<Text style={styles.sendText}>{sending ? t('email_viewer.sending', 'Sending...') : t('email_viewer.send', 'Send')}</Text>
</Pressable>
</View>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
box: {
marginHorizontal: spacing.lg,
marginVertical: spacing.md,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.md,
backgroundColor: c.background,
padding: spacing.sm,
gap: spacing.sm,
},
input: {
...typography.body,
color: c.text,
minHeight: 44,
maxHeight: 160,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
textAlignVertical: 'top',
},
actions: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
more: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingVertical: 4 },
moreText: { ...typography.caption, color: c.textSecondary },
send: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: c.primary,
paddingHorizontal: spacing.md,
minHeight: 36,
borderRadius: radius.sm,
},
sendDisabled: { opacity: 0.5 },
sendText: { ...typography.bodyMedium, color: c.primaryForeground },
});
}