feat: let @ mentions be turned off and choose where a mentioned contact is added

This commit is contained in:
Linus Rath
2026-10-31 16:05:14 +01:00
parent fe3f860e73
commit ab226b03b4
6 changed files with 117 additions and 9 deletions
+3
View File
@@ -1253,6 +1253,9 @@
"plain_text_mode": {
"description_mobile": "Compose and read in plain text only."
},
"recipient_mentions": {
"description_mobile": "Typing @ in the message offers its recipients and inserts the chosen one's first name."
},
"send_delay": {
"description_mobile": "Hold outgoing mail for a few seconds so you can cancel it. Requires server support."
},
+27 -1
View File
@@ -5,7 +5,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store';
import { useSettingsStore, type MentionAddsRecipientTo, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingLocked } from '../../stores/policy-store';
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
@@ -34,6 +34,8 @@ export function ComposingSettings() {
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
const emptySubjectWarningEnabled = useSettingsStore((s) => s.emptySubjectWarningEnabled);
const placeholderWarningEnabled = useSettingsStore((s) => s.placeholderWarningEnabled);
const recipientMentionsEnabled = useSettingsStore((s) => s.recipientMentionsEnabled);
const mentionAddsRecipientTo = useSettingsStore((s) => s.mentionAddsRecipientTo);
const autoSaveDraftInterval = useSettingsStore((s) => s.autoSaveDraftInterval);
const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter);
const updateSetting = useSettingsStore((s) => s.updateSetting);
@@ -78,6 +80,12 @@ export function ComposingSettings() {
{ label: t('settings.email_behavior.signature_position.below_quote', 'After quoted text'), value: 'below_quote' },
];
const MENTION_ADDS_RECIPIENT: { label: string; value: MentionAddsRecipientTo }[] = [
{ label: t('settings.email_behavior.mention_adds_recipient.to', 'Add to To'), value: 'to' },
{ label: t('settings.email_behavior.mention_adds_recipient.cc', 'Add to Cc'), value: 'cc' },
{ label: t('settings.email_behavior.mention_adds_recipient.off', 'Only offer recipients'), value: 'off' },
];
const REPLY_IDENTITY_MATCHES: { label: string; value: ReplyIdentityMatch }[] = [
{ label: t('settings.email_behavior.reply_identity_match.exact', 'Exact address only'), value: 'exact' },
{ label: t('settings.email_behavior.reply_identity_match.domain', 'Any address on my domains'), value: 'domain' },
@@ -151,6 +159,24 @@ export function ComposingSettings() {
<ToggleSwitch checked={plainTextMode} onChange={(v) => updateSetting('plainTextMode', v)} />
</SettingItem>
<SettingItem
label={t('settings.email_behavior.recipient_mentions.label', 'Mention recipients with @')}
// The webmail's description names Enter and Tab, which a phone has not.
description={t('settings.email_behavior.recipient_mentions.description_mobile', "Typing @ in the message offers its recipients and inserts the chosen one's first name.")}
>
<ToggleSwitch checked={recipientMentionsEnabled} onChange={(v) => updateSetting('recipientMentionsEnabled', v)} />
</SettingItem>
{recipientMentionsEnabled && (
<View style={styles.subBlock}>
<Text style={styles.subLabel}>{t('settings.email_behavior.mention_adds_recipient.label', 'Contacts in @ mentions')}</Text>
<Text style={styles.subDescription}>
{t('settings.email_behavior.mention_adds_recipient.description', "Typing @ also offers your contacts and people from the directory. Picking someone who isn't a recipient yet adds them to the message.")}
</Text>
{segmented(MENTION_ADDS_RECIPIENT, mentionAddsRecipientTo, (v) => updateSetting('mentionAddsRecipientTo', v))}
</View>
)}
<SettingItem
label={t('settings.email_behavior.request_read_receipt.label', "Request read receipts by default")}
description={t('settings.email_behavior.request_read_receipt.description', "Pre-enable the read-receipt request when composing a new message.")}
+2
View File
@@ -118,6 +118,8 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.reply_identity_match',
'settings.email_behavior.plain_text_mode',
'settings.email_behavior.recipient_mentions',
'settings.email_behavior.mention_adds_recipient',
'settings.email_behavior.request_read_receipt',
'settings.email_behavior.empty_subject_warning',
'settings.email_behavior.placeholder_warning',
+16 -8
View File
@@ -998,6 +998,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
// ── "@" mentions in the body (webmail #1134) ──────────────────────────
// What follows the "@" being typed in the editor, null when none is.
const [mentionQuery, setMentionQuery] = React.useState<string | null>(null);
// Settings > Composing: mentions can be turned off, and a picked contact
// who isn't a recipient yet joins To, Cc or nothing (webmail
// recipientMentionsEnabled, mentionAddsRecipientTo; 'off' offers the
// recipients only).
const recipientMentionsEnabled = useSettingsStore((s) => s.recipientMentionsEnabled);
const mentionAddsRecipientTo = useSettingsStore((s) => s.mentionAddsRecipientTo);
// Who an "@" offers: To and Cc. Never Bcc - naming a blind-copied
// recipient in the text would disclose them to everyone else.
const mentionCandidates = React.useMemo(
@@ -1007,29 +1013,31 @@ export default function ComposeScreen({ route, navigation }: Props) {
// Contacts come after the recipients; anyone already on the message is
// left out, Bcc and the identity's own Bcc included.
const mentionItems = React.useMemo<MentionCandidate[]>(() => {
if (mentionQuery === null) return [];
if (mentionQuery === null || !recipientMentionsEnabled) return [];
const onMessage = new Set(
[...toRecipients, ...ccRecipients, ...bccRecipients]
.flatMap((r) => (r.group ? r.group.members.map((m) => m.email) : [r.email]))
.concat((primaryIdentity?.bcc ?? []).map((r) => r.email))
.map((e) => e.trim().toLowerCase()),
);
const contacts = mentionQuery
const contacts = mentionQuery && mentionAddsRecipientTo !== 'off'
? getAutocomplete(mentionQuery, 16)
.filter((s) => !s.group && !!s.email && !onMessage.has(s.email.trim().toLowerCase()))
.map((s) => ({ name: s.name || undefined, email: s.email }))
: [];
return mentionSuggestions(mentionCandidates, contacts, mentionQuery, 'to');
return mentionSuggestions(mentionCandidates, contacts, mentionQuery, mentionAddsRecipientTo === 'cc' ? 'cc' : 'to');
// contactsVersion/recentVersion re-run the lookup when the store loads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionQuery, mentionCandidates, toRecipients, ccRecipients, bccRecipients, primaryIdentity, getAutocomplete, contactsVersion, recentVersion]);
}, [mentionQuery, mentionCandidates, toRecipients, ccRecipients, bccRecipients, primaryIdentity, getAutocomplete, contactsVersion, recentVersion, recipientMentionsEnabled, mentionAddsRecipientTo]);
const pickMention = (candidate: MentionCandidate) => {
editorRef.current?.insertMention(candidate.label, mentionHref(candidate.email));
// A contact who isn't on the message yet joins To (webmail default).
if (candidate.addsTo === 'cc') {
// A contact who isn't on the message yet joins To or Cc, as set.
if (mentionAddsRecipientTo === 'off' || !candidate.addsTo) {
// A recipient already: nothing to add.
} else if (candidate.addsTo === 'cc') {
setCcRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email })));
setCcVisible(true);
} else if (candidate.addsTo) {
} else {
setToRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email })));
}
setMentionQuery(null);
@@ -3090,7 +3098,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChange={setBodyHtml}
onSelectionChange={setSelState}
onViewportShift={onEditorViewportShift}
mentions
mentions={recipientMentionsEnabled}
onMentionQuery={setMentionQuery}
/>
)}
@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { fromExportShape, mergeWithDefaults, toExportShape, useSettingsStore } from '../settings-store';
// "@" mentions in the composer (webmail recipientMentionsEnabled and
// mentionAddsRecipientTo, stores/settings-store.ts and
// components/settings/composing-settings.tsx): could not be turned off, and
// a picked contact always joined To. Kept in its own file so agents working
// on settings in parallel don't edit the same test file.
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8');
describe('mention settings', () => {
beforeEach(async () => {
await AsyncStorage.clear();
useSettingsStore.getState().resetToDefaults();
});
it('defaults to mentions on, adding a picked contact to To', () => {
expect(useSettingsStore.getState().recipientMentionsEnabled).toBe(true);
expect(useSettingsStore.getState().mentionAddsRecipientTo).toBe('to');
});
it('keeps valid values and drops anything else', () => {
expect(mergeWithDefaults({ recipientMentionsEnabled: false }).recipientMentionsEnabled).toBe(false);
expect(mergeWithDefaults({ mentionAddsRecipientTo: 'cc' }).mentionAddsRecipientTo).toBe('cc');
expect(mergeWithDefaults({ mentionAddsRecipientTo: 'off' }).mentionAddsRecipientTo).toBe('off');
expect(mergeWithDefaults({ mentionAddsRecipientTo: 'bcc' as never }).mentionAddsRecipientTo).toBe('to');
});
it('exports and imports them under the webmail names', () => {
const exported = toExportShape({ ...useSettingsStore.getState(), recipientMentionsEnabled: false, mentionAddsRecipientTo: 'cc' });
expect(exported).toHaveProperty('recipientMentionsEnabled', false);
expect(exported).toHaveProperty('mentionAddsRecipientTo', 'cc');
expect(fromExportShape({ recipientMentionsEnabled: false, mentionAddsRecipientTo: 'off' }))
.toEqual({ recipientMentionsEnabled: false, mentionAddsRecipientTo: 'off' });
});
it('has rows under Settings > Composing', () => {
const src = read('components/settings/ComposingSettings.tsx');
expect(src).toMatch(/<ToggleSwitch checked=\{recipientMentionsEnabled\} onChange=\{\(v\) => updateSetting\('recipientMentionsEnabled', v\)\} \/>/);
expect(src).toMatch(/\{recipientMentionsEnabled && \(/);
expect(src).toMatch(/segmented\(MENTION_ADDS_RECIPIENT, mentionAddsRecipientTo, \(v\) => updateSetting\('mentionAddsRecipientTo', v\)\)/);
});
it('drives the composer', () => {
const src = read('screens/ComposeScreen.tsx');
expect(src).toMatch(/mentions=\{recipientMentionsEnabled\}/);
expect(src).toMatch(/if \(mentionQuery === null \|\| !recipientMentionsEnabled\) return \[\];/);
expect(src).toMatch(/const contacts = mentionQuery && mentionAddsRecipientTo !== 'off'/);
expect(src).toMatch(/mentionSuggestions\(mentionCandidates, contacts, mentionQuery, mentionAddsRecipientTo === 'cc' \? 'cc' : 'to'\)/);
expect(src).toMatch(/if \(mentionAddsRecipientTo === 'off' \|\| !candidate\.addsTo\) \{/);
});
});
+13
View File
@@ -45,6 +45,8 @@ export type SignaturePosition = 'above_quote' | 'below_quote';
// With autoSelectReplyIdentity on: 'exact' = configured identities only,
// 'domain' = also same-domain catch-all addresses (rewrites From), #1000.
export type ReplyIdentityMatch = 'exact' | 'domain';
/** Where an "@" mention puts a picked contact who isn't a recipient yet; 'off' offers the recipients only. */
export type MentionAddsRecipientTo = 'to' | 'cc' | 'off';
// Actions that can be placed in the email reader's bottom quick-action bar.
// The first three are the reply family (the default bar); any reply-family
// action the user removes from the bar is relocated to the top toolbar so it
@@ -359,6 +361,12 @@ export interface PersistedSettings {
// coloured bar at the start, instead of solid blocks (webmail
// calendarTintedEvents, same key).
calendarTintedEvents: boolean;
// mentions: "@" in the message body offers the recipients and inserts a
// first name (webmail recipientMentionsEnabled), and where a picked
// contact who isn't a recipient yet goes (mentionAddsRecipientTo). Same
// keys as the webmail's for export/import.
recipientMentionsEnabled: boolean;
mentionAddsRecipientTo: MentionAddsRecipientTo;
// Keys the user changed themselves (webmail #1248, same key). An
// organisation default only fills in settings not listed here.
explicitSettings: string[];
@@ -533,6 +541,9 @@ const DEFAULT_PERSISTED: PersistedSettings = {
quickReplyEnabled: true,
// calendar2:
calendarTintedEvents: false,
// mentions:
recipientMentionsEnabled: true,
mentionAddsRecipientTo: 'to',
explicitSettings: [],
};
@@ -694,6 +705,8 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
offlineCacheMaxMB: intBetween(1, 100000),
// platform:
toastDuration: oneOf([-1, 0, 5, 10, 20, 60]),
// mentions:
mentionAddsRecipientTo: oneOf(['to', 'cc', 'off']),
explicitSettings: stringArray,
sidebarApps: (v) => Array.isArray(v) && v.every((a) =>
a && typeof a === 'object'