mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
feat: let @ mentions be turned off and choose where a mentioned contact is added
This commit is contained in:
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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.")}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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\) \{/);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user