mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
250 lines
11 KiB
TypeScript
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 },
|
|
});
|
|
}
|