mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +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": {
|
"plain_text_mode": {
|
||||||
"description_mobile": "Compose and read in plain text only."
|
"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": {
|
"send_delay": {
|
||||||
"description_mobile": "Hold outgoing mail for a few seconds so you can cancel it. Requires server support."
|
"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 Button from '../Button';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
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 { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { useSettingLocked } from '../../stores/policy-store';
|
import { useSettingLocked } from '../../stores/policy-store';
|
||||||
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
|
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
|
||||||
@@ -34,6 +34,8 @@ export function ComposingSettings() {
|
|||||||
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
|
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
|
||||||
const emptySubjectWarningEnabled = useSettingsStore((s) => s.emptySubjectWarningEnabled);
|
const emptySubjectWarningEnabled = useSettingsStore((s) => s.emptySubjectWarningEnabled);
|
||||||
const placeholderWarningEnabled = useSettingsStore((s) => s.placeholderWarningEnabled);
|
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 autoSaveDraftInterval = useSettingsStore((s) => s.autoSaveDraftInterval);
|
||||||
const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter);
|
const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter);
|
||||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
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' },
|
{ 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 }[] = [
|
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.exact', 'Exact address only'), value: 'exact' },
|
||||||
{ label: t('settings.email_behavior.reply_identity_match.domain', 'Any address on my domains'), value: 'domain' },
|
{ 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)} />
|
<ToggleSwitch checked={plainTextMode} onChange={(v) => updateSetting('plainTextMode', v)} />
|
||||||
</SettingItem>
|
</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
|
<SettingItem
|
||||||
label={t('settings.email_behavior.request_read_receipt.label', "Request read receipts by default")}
|
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.")}
|
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.auto_select_reply_identity',
|
||||||
'settings.email_behavior.reply_identity_match',
|
'settings.email_behavior.reply_identity_match',
|
||||||
'settings.email_behavior.plain_text_mode',
|
'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.request_read_receipt',
|
||||||
'settings.email_behavior.empty_subject_warning',
|
'settings.email_behavior.empty_subject_warning',
|
||||||
'settings.email_behavior.placeholder_warning',
|
'settings.email_behavior.placeholder_warning',
|
||||||
|
|||||||
@@ -998,6 +998,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
// ── "@" mentions in the body (webmail #1134) ──────────────────────────
|
// ── "@" mentions in the body (webmail #1134) ──────────────────────────
|
||||||
// What follows the "@" being typed in the editor, null when none is.
|
// What follows the "@" being typed in the editor, null when none is.
|
||||||
const [mentionQuery, setMentionQuery] = React.useState<string | null>(null);
|
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
|
// Who an "@" offers: To and Cc. Never Bcc - naming a blind-copied
|
||||||
// recipient in the text would disclose them to everyone else.
|
// recipient in the text would disclose them to everyone else.
|
||||||
const mentionCandidates = React.useMemo(
|
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
|
// Contacts come after the recipients; anyone already on the message is
|
||||||
// left out, Bcc and the identity's own Bcc included.
|
// left out, Bcc and the identity's own Bcc included.
|
||||||
const mentionItems = React.useMemo<MentionCandidate[]>(() => {
|
const mentionItems = React.useMemo<MentionCandidate[]>(() => {
|
||||||
if (mentionQuery === null) return [];
|
if (mentionQuery === null || !recipientMentionsEnabled) return [];
|
||||||
const onMessage = new Set(
|
const onMessage = new Set(
|
||||||
[...toRecipients, ...ccRecipients, ...bccRecipients]
|
[...toRecipients, ...ccRecipients, ...bccRecipients]
|
||||||
.flatMap((r) => (r.group ? r.group.members.map((m) => m.email) : [r.email]))
|
.flatMap((r) => (r.group ? r.group.members.map((m) => m.email) : [r.email]))
|
||||||
.concat((primaryIdentity?.bcc ?? []).map((r) => r.email))
|
.concat((primaryIdentity?.bcc ?? []).map((r) => r.email))
|
||||||
.map((e) => e.trim().toLowerCase()),
|
.map((e) => e.trim().toLowerCase()),
|
||||||
);
|
);
|
||||||
const contacts = mentionQuery
|
const contacts = mentionQuery && mentionAddsRecipientTo !== 'off'
|
||||||
? getAutocomplete(mentionQuery, 16)
|
? getAutocomplete(mentionQuery, 16)
|
||||||
.filter((s) => !s.group && !!s.email && !onMessage.has(s.email.trim().toLowerCase()))
|
.filter((s) => !s.group && !!s.email && !onMessage.has(s.email.trim().toLowerCase()))
|
||||||
.map((s) => ({ name: s.name || undefined, email: s.email }))
|
.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.
|
// contactsVersion/recentVersion re-run the lookup when the store loads.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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) => {
|
const pickMention = (candidate: MentionCandidate) => {
|
||||||
editorRef.current?.insertMention(candidate.label, mentionHref(candidate.email));
|
editorRef.current?.insertMention(candidate.label, mentionHref(candidate.email));
|
||||||
// A contact who isn't on the message yet joins To (webmail default).
|
// A contact who isn't on the message yet joins To or Cc, as set.
|
||||||
if (candidate.addsTo === 'cc') {
|
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 })));
|
setCcRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email })));
|
||||||
setCcVisible(true);
|
setCcVisible(true);
|
||||||
} else if (candidate.addsTo) {
|
} else {
|
||||||
setToRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email })));
|
setToRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email })));
|
||||||
}
|
}
|
||||||
setMentionQuery(null);
|
setMentionQuery(null);
|
||||||
@@ -3090,7 +3098,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
onChange={setBodyHtml}
|
onChange={setBodyHtml}
|
||||||
onSelectionChange={setSelState}
|
onSelectionChange={setSelState}
|
||||||
onViewportShift={onEditorViewportShift}
|
onViewportShift={onEditorViewportShift}
|
||||||
mentions
|
mentions={recipientMentionsEnabled}
|
||||||
onMentionQuery={setMentionQuery}
|
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,
|
// With autoSelectReplyIdentity on: 'exact' = configured identities only,
|
||||||
// 'domain' = also same-domain catch-all addresses (rewrites From), #1000.
|
// 'domain' = also same-domain catch-all addresses (rewrites From), #1000.
|
||||||
export type ReplyIdentityMatch = 'exact' | 'domain';
|
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.
|
// 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
|
// 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
|
// 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
|
// coloured bar at the start, instead of solid blocks (webmail
|
||||||
// calendarTintedEvents, same key).
|
// calendarTintedEvents, same key).
|
||||||
calendarTintedEvents: boolean;
|
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
|
// Keys the user changed themselves (webmail #1248, same key). An
|
||||||
// organisation default only fills in settings not listed here.
|
// organisation default only fills in settings not listed here.
|
||||||
explicitSettings: string[];
|
explicitSettings: string[];
|
||||||
@@ -533,6 +541,9 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
|||||||
quickReplyEnabled: true,
|
quickReplyEnabled: true,
|
||||||
// calendar2:
|
// calendar2:
|
||||||
calendarTintedEvents: false,
|
calendarTintedEvents: false,
|
||||||
|
// mentions:
|
||||||
|
recipientMentionsEnabled: true,
|
||||||
|
mentionAddsRecipientTo: 'to',
|
||||||
explicitSettings: [],
|
explicitSettings: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -694,6 +705,8 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
|
|||||||
offlineCacheMaxMB: intBetween(1, 100000),
|
offlineCacheMaxMB: intBetween(1, 100000),
|
||||||
// platform:
|
// platform:
|
||||||
toastDuration: oneOf([-1, 0, 5, 10, 20, 60]),
|
toastDuration: oneOf([-1, 0, 5, 10, 20, 60]),
|
||||||
|
// mentions:
|
||||||
|
mentionAddsRecipientTo: oneOf(['to', 'cc', 'off']),
|
||||||
explicitSettings: stringArray,
|
explicitSettings: stringArray,
|
||||||
sidebarApps: (v) => Array.isArray(v) && v.every((a) =>
|
sidebarApps: (v) => Array.isArray(v) && v.every((a) =>
|
||||||
a && typeof a === 'object'
|
a && typeof a === 'object'
|
||||||
|
|||||||
Reference in New Issue
Block a user