From ab226b03b456efec357d285ae920eedfed2d54c3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:05:14 +0100 Subject: [PATCH] feat: let @ mentions be turned off and choose where a mentioned contact is added --- locales/rn/en.json | 3 + src/components/settings/ComposingSettings.tsx | 28 +++++++++- src/lib/settings-search.ts | 2 + src/screens/ComposeScreen.tsx | 24 +++++--- .../__tests__/settings-store-mentions.test.ts | 56 +++++++++++++++++++ src/stores/settings-store.ts | 13 +++++ 6 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 src/stores/__tests__/settings-store-mentions.test.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index f6ef4eb6..414d1f28 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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." }, diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index e2d5231c..dd047214 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -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() { updateSetting('plainTextMode', v)} /> + + updateSetting('recipientMentionsEnabled', v)} /> + + + {recipientMentionsEnabled && ( + + {t('settings.email_behavior.mention_adds_recipient.label', 'Contacts in @ mentions')} + + {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.")} + + {segmented(MENTION_ADDS_RECIPIENT, mentionAddsRecipientTo, (v) => updateSetting('mentionAddsRecipientTo', v))} + + )} + = { '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', diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 785f05e1..4f731f37 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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(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(() => { - 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} /> )} diff --git a/src/stores/__tests__/settings-store-mentions.test.ts b/src/stores/__tests__/settings-store-mentions.test.ts new file mode 100644 index 00000000..941c2f0a --- /dev/null +++ b/src/stores/__tests__/settings-store-mentions.test.ts @@ -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(/ 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\) \{/); + }); +}); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 438e5eb3..3d6d702c 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -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 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'