Merge branch 'cu-followup' into catch-up-2026-11

# Conflicts:
#	docs/parity/11-webmail-1.19.md
This commit is contained in:
Linus Rath
2026-10-31 05:34:13 +01:00
37 changed files with 982 additions and 106 deletions
+1 -1
View File
@@ -189,7 +189,7 @@ RN covers the visible surface reasonably well (list with alphabetical index, det
### Composer autocomplete
- [ ] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing` — event participants get groups and recent recipients in e988dc29; not done: on-demand server search and directory principals in the composer (compose agent's side)
- [x] **Autocomplete lacks groups, recent recipients (Sent), on-demand server search and directory principals** — `P2` — `missing` — fixed in a7a37573 (the composer's on-demand Sent search and directory people; event participants got groups and recent recipients in e988dc29)
- store-side done in 26a34d5 (`getAutocomplete` with group entries + recent recipients, `loadRecentRecipients`/`searchRecipients` fed by `src/api/recent-recipients.ts`, `getGroupRecipients`, #672 normalisation); the composer uses `getAutocomplete` and the recent Sent recipients since 5b72c4d. Still open: the on-demand server search (`searchRecipients` has no caller), directory principals, and the calendar's `ParticipantInput`, which still offers contacts only.
- What WEB does: `getAutocomplete` merges contacts, group entries (as one chip with `memberCount`), RFC 9670 directory principals and recent Sent recipients, deduped and sanitised (#672) (`stores/contact-store.ts:622-707, 23-49`; `loadRecentRecipients` `:1118-1142` fed from `mail-app.tsx:423`; `searchRecipients` `:1144-1154` used by `components/email/email-composer.tsx:1175-1212`; group chip insertion `:1218-1233`; changelogs 1.6.x "Recipient autocomplete from Sent, with on-demand server search", 1.7.x group chips).
- What RN does: suggestions come only from `individuals` matched with `matchesContactSearch` (`src/screens/ComposeScreen.tsx:367-392`); groups are explicitly filtered out and nothing else is consulted. `ParticipantInput` (calendar) likewise flattens contacts only (`src/components/calendar/ParticipantInput.tsx:34-51`).
+2 -2
View File
@@ -70,7 +70,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
- What RN does: clearAllFeatureStores (`src/stores/auth-store.ts:126-134`) never clears `src/stores/search-history-store.ts:44`.
- Fix hint: clear it, and check the other persisted stores in the same pass.
- [x] **Internationalized domains** — `P3` — `missing` — fixed in f5875f87 (sign-in, discovery, account rows; the composer's recipient check goes with the compose branch)
- [x] **Internationalized domains** — `P3` — `missing` — fixed in f5875f87 (sign-in, discovery, account rows) and 4932764a (the composer takes recipients on Unicode domains and sends them in ASCII form; the From identity and a sender on one of your own domains show as written in the composer and the reader; unit tests only, the test servers have no IDN domain); left: the sender in the mail list rows (EmailListScreen, UnifiedInboxScreen) still shows the xn-- form on your own domains
- What WEB does: `lib/idn.ts:25` (#1100). What RN does: ASCII-only checks (`src/lib/recipients.ts:31`, `src/lib/server-discovery.ts:78`).
- Fix hint: toASCII on the wire, Unicode on screen.
@@ -394,7 +394,7 @@ Stalwart 1.0.0 items found in the same pass (1.0 SSO scopes, Files on 1.0, #1385
- [x] **Create rules from a message** — `P2` — `missing` — fixed in d546d509, 7b7236f0
- What WEB does: `components/email/rules-menu.tsx`, `lib/filters/quick-rules.ts:187`. What RN does: `src/components/email/ActionSheet.tsx`, `src/components/filters/FilterRuleModal.tsx`.
- [ ] **Forward limit (#1155, #1208); vacation length warning; run a rule on a folder (#1166); Sieve editor loses typed text (#1162); size value raw into Sieve; undo after deleting tags/rules/trusted senders** — `P3` — `missing`/`bug` — forward limit and vacation length fixed in dcbfc907, Sieve editor text in 6c3046c8, size value in 2167a9d7 (before this pass); undo after deleting rules: store side in b95f2c29, FilterSettings (platform agent's file) still has to call deleteRuleNow; not done: undo after deleting tags and trusted senders (KeywordSettings and the trusted-senders list are not the calendar agent's files); run a rule on a folder: needs server-side run, its own feature
- [ ] **Forward limit (#1155, #1208); vacation length warning; run a rule on a folder (#1166); Sieve editor loses typed text (#1162); size value raw into Sieve; undo after deleting tags/rules/trusted senders** — `P3` — `missing`/`bug` — forward limit and vacation length fixed in dcbfc907, Sieve editor text in 6c3046c8, size value in 2167a9d7 (before this pass); undo after deleting rules (store side b95f2c29), tags and trusted senders, and the forward limit notes in the rule list, in 8ac19971; not done: run a rule on a folder, which needs a server-side run and is its own feature
- What WEB does: `lib/filters/forward-limit.ts:16`, `components/settings/vacation-settings.tsx:253`, `lib/filters/quick-rule-flow.ts:221`, `lib/sieve/generator.ts:63`. What RN does: `src/components/filters/FilterRuleModal.tsx:63`, `:413`, `src/components/settings/VacationSettings.tsx:106`, `src/components/filters/SieveEditorSheet.tsx:35-41`, `src/lib/sieve/generator.ts:69-74`, `src/components/settings/KeywordSettings.tsx:50-56`.
### Push, settings and core
+2 -2
View File
@@ -135,7 +135,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
- What RN does: no page (`src/screens/SettingsScreen.tsx:150-174`); text size 0.92/1/1.12 (`src/theme/dynamic.ts:14-18`). Text follows the system font scale; reduce motion follows the system (`src/theme/dynamic.ts:88-120`).
- Fix hint: a page that groups the existing switches, widens the text size range and takes the presets that make sense on a phone.
- [x] **Form fields have no name for screen readers** — `P2` — `bug` — fixed in ff5a8c91 (settings and sign-in; contact form and event editor are listed for their branches), mail search field, filter panel and unified search field in 3c3274e4
- [x] **Form fields have no name for screen readers** — `P2` — `bug` — fixed in ff5a8c91 (settings and sign-in), 98b962a8 (composer, quick reply, folder name prompt, template search), 3c3274e4 (mail search, filter panel, unified search); calendar, contacts, Files and the auto-reply editor in the second calendar pass
- What WEB does: every field has a label tied to it (`components/ui/field.tsx:42`; ef13556a0, 0e2a05e0e, dcc21acd0, 4e2affe40, f73643bbb).
- What RN does: the shared Input draws its label as separate Text (`src/components/Input.tsx:40-55`); the Security password and code fields have no label at all (`src/components/settings/AccountSecuritySettings.tsx:181`, `:191`, `:198`); contact fields rely on placeholders (`src/screens/ContactFormScreen.tsx:823-834`); the reminder field's only name is "30" (`src/components/calendar/EventModal.tsx:772`, `:817`).
- Fix hint: Input passes its label as accessibilityLabel; every bare TextInput gets one.
@@ -185,7 +185,7 @@ Classification: `bug` = fixed in the webmail, still live in the app; `missing`;
- What RN does: TotpRequiredError sets a notice (`src/screens/LoginScreen.tsx:389-391`) shown in the error tone (`src/screens/login/PasswordStep.tsx:151`, `src/screens/login/LoginNotice.tsx:15-24`).
- Fix hint: `tone="info"` for the TOTP step.
- [x] **Two accounts with the same name look alike in the switcher** — `P3` — `bug` — fixed in 505fbc59 (Settings > Account and the shared accountRowText; the switcher row in SidebarDrawer goes with the reader branch; the port waits for question 9)
- [x] **Two accounts with the same name look alike in the switcher** — `P3` — `bug` — fixed in 505fbc59 (Settings > Account and the shared accountRowText) and 97173d4a (the switcher rows in SidebarDrawer); the port waits for question 9
- What WEB does: the address becomes the title when another account shares the name (`lib/account-utils.ts:159`; 3bb9f7df8).
- What RN does: name as title, address below (`src/components/SidebarDrawer.tsx:884-891`, `src/components/settings/AccountSettings.tsx:186`). Report question 9 (port in the label) is the same area.
- Fix hint: port accountRowText.
+12
View File
@@ -37,6 +37,7 @@ const MAP = {
ArrowUpDown: 'arrows-sort',
ArrowUpNarrowWide: 'sort-ascending',
Ban: 'ban',
Bed: 'bed',
Bell: 'bell',
BellOff: 'bell-off',
BellRing: 'bell-ringing',
@@ -46,11 +47,15 @@ const MAP = {
BookUser: 'address-book',
Bug: 'bug',
Building: 'building',
Bus: 'bus',
Cake: 'cake',
Calendar: 'calendar',
CalendarCheck: 'calendar-check',
CalendarDays: 'calendar-month',
CalendarPlus: 'calendar-plus',
CalendarX: 'calendar-x',
Camera: 'camera',
Car: 'car',
Check: 'check',
CheckCheck: 'checks',
CheckCircle: 'circle-check',
@@ -139,6 +144,7 @@ const MAP = {
MoreHorizontal: 'dots',
MoreVertical: 'dots-vertical',
MoveVertical: 'arrows-vertical',
Package: 'package',
Palette: 'palette',
Palmtree: 'beach',
PanelLeftClose: 'layout-sidebar-left-collapse',
@@ -150,6 +156,7 @@ const MAP = {
Phone: 'phone',
Pin: 'pinned',
PinOff: 'pinned-off',
Plane: 'plane',
Plus: 'plus',
Puzzle: 'puzzle',
QrCode: 'qrcode',
@@ -160,6 +167,7 @@ const MAP = {
Repeat: 'repeat',
Reply: 'arrow-back-up',
ReplyAll: 'arrow-back-up-double',
Restaurant: 'tools-kitchen-2',
RotateCcw: 'rotate',
Rows2: 'layout-rows',
Rss: 'rss',
@@ -175,6 +183,7 @@ const MAP = {
ShieldAlert: 'shield-exclamation',
ShieldCheck: 'shield-check',
ShieldQuestion: 'shield-question',
ShoppingBag: 'shopping-bag',
Shuffle: 'arrows-shuffle',
Smartphone: 'device-mobile',
Square: 'square',
@@ -188,8 +197,11 @@ const MAP = {
Tag: 'tag',
Tags: 'tags',
Terminal: 'terminal',
Ticket: 'ticket',
Train: 'train',
Trash: 'trash',
Trash2: 'trash',
Truck: 'truck',
Type: 'typography',
Underline: 'underline',
Undo2: 'arrow-back',
@@ -38,6 +38,8 @@ import { htmlToPlainText } from '../../lib/compose-html';
import { buildEmbeddedSignatureHtml, spliceSignature } from '../../lib/signature-utils';
import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files';
import { servedLoginOf } from '../../lib/draft-login';
import { mergeRecipientSuggestions } from '../../lib/recipient-suggestions';
import { useContactsStore } from '../../stores/contacts-store';
import { useAccountStore } from '../../stores/account-store';
import { useTemplatesStore } from '../../stores/templates-store';
@@ -315,6 +317,44 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => {
}, 60_000);
});
// The composer's suggestions: "Search the server" finds a recipient in
// the Sent folder the recent list never loaded, and directory people are
// offered (parity 06).
describe('recipient suggestions', () => {
it('finds a Sent recipient on demand and suggests people from the directory', async () => {
const rcpt = `rcpt-${RUN}@example.net`;
const saved = await saveDraftVersion({
from: [{ email: USER }], to: [{ name: `Rcpt ${RUN}`, email: rcpt }], subject: `suggest ${tag}`, textBody: 'hi',
}, sent);
createdEmails.push(saved.id);
useContactsStore.getState().reset();
useContactsStore.setState({ sentMailboxId: sent, recentRecipientsLoaded: true });
const local = useContactsStore.getState().getAutocomplete(`rcpt-${RUN}`, 16);
expect(local).toEqual([]);
// The server indexes a new message shortly after it is stored.
let server: Awaited<ReturnType<ReturnType<typeof useContactsStore.getState>['searchRecipients']>> = [];
await vi.waitFor(async () => {
server = await useContactsStore.getState().searchRecipients(`rcpt-${RUN}`);
expect(server.map((s) => s.email)).toContain(rcpt);
}, { timeout: 20_000, interval: 1_000 });
const merged = mergeRecipientSuggestions({
local, isContact: () => false, directory: [], server, exclude: new Set(), limit: 8,
});
expect(merged).toEqual([{ name: `Rcpt ${RUN}`, email: rcpt }]);
await useContactsStore.getState().fetchDirectory();
const me = USER.split('@')[0];
const withDirectory = mergeRecipientSuggestions({
local: [], isContact: () => false,
directory: useContactsStore.getState().lookupDirectory(me, 16),
server: [], exclude: new Set(), limit: 8,
});
expect(withDirectory.map((s) => s.email.toLowerCase())).toContain(USER.toLowerCase());
useContactsStore.getState().reset();
}, 60_000);
});
describe('the body of a composed message (compose pass 2026-11)', () => {
it('keeps the quote marks, numbered list, signature delimiter and image text of the text part, and one signature', async () => {
// The composer's body after a From switch with text typed under the
@@ -45,6 +45,8 @@ import {
import { updateAccountFilters } from '../sieve-rules';
import { useContactsStore } from '../../stores/contacts-store';
import { readVacationFilters, useFilterStore } from '../../stores/filter-store';
import { useToastStore } from '../../stores/toast-store';
import { deleteFilterRuleWithUndo } from '../../lib/settings-undo';
import { generateVCard, parseVCard } from '../../lib/vcard';
import { generateScript } from '../../lib/sieve/generator';
import { parseScript } from '../../lib/sieve/parser';
@@ -336,5 +338,33 @@ live('live Stalwart: contacts and filters (0.16 and 1.0)', () => {
expect({ file, ...(await validateSieveScript(content)) }).toEqual({ file, isValid: true });
}
}, 60_000);
// Settings deletes a rule at once and offers Undo (webmail R3).
it('deletes a rule on the server at once and the toast Undo saves it back', async () => {
const rule = (id: string, name: string) => ({
id, name, enabled: true, matchType: 'all' as const,
conditions: [{ field: 'from' as const, comparator: 'contains' as const, value: `${id}-${RUN}@example.com` }],
actions: [{ type: 'mark_read' as const, value: '' }],
stopProcessing: false,
});
await updateSieveScript(created!, generateScript([rule('keep', `Keep ${RUN}`), rule('gone', `Gone ${RUN}`)]), true);
await useFilterStore.getState().selectAccount(null);
const gone = useFilterStore.getState().rules.find((r) => r.name === `Gone ${RUN}`)!;
expect(gone).toBeTruthy();
useToastStore.getState().clearToasts();
const serverContent = async () =>
getSieveScriptContent((await getSieveScripts()).find((x) => x.id === created)!.blobId);
await deleteFilterRuleWithUndo(gone.id, t);
expect(await serverContent()).not.toContain(`gone-${RUN}@example.com`);
expect(await serverContent()).toContain(`keep-${RUN}@example.com`);
const toast = useToastStore.getState().toasts.at(-1)!;
expect(toast.title).toBe('Filter rule deleted');
toast.action!.onPress();
await expect.poll(async () => serverContent(), { timeout: 15_000 }).toContain(`gone-${RUN}@example.com`);
expect(useFilterStore.getState().rules.map((r) => r.name)).toEqual([`Keep ${RUN}`, `Gone ${RUN}`]);
expect(useToastStore.getState().toasts.some((x) => x.type === 'error')).toBe(false);
}, 60_000);
});
});
+2 -1
View File
@@ -11,6 +11,7 @@ import type { Identity } from '../api/types';
import { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout';
import { toUnicodeEmail } from '../lib/idn';
interface IdentitySheetProps {
visible: boolean;
@@ -97,7 +98,7 @@ export function IdentitySheet({
</Text>
)}
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
{identity.email}
{toUnicodeEmail(identity.email)}
</Text>
</View>
{isSelected && (
+10 -6
View File
@@ -21,7 +21,8 @@ import { useAccountStore } from '../stores/account-store';
import { useSettingsStore } from '../stores/settings-store';
import { useKeywordsStore, keywordToken } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-store';
import { MAX_ACCOUNTS } from '../lib/account-utils';
import { MAX_ACCOUNTS, accountRowText } from '../lib/account-utils';
import { toUnicodeEmail } from '../lib/idn';
import {
buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode,
} from '../lib/mailbox-tree';
@@ -290,6 +291,7 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
<Text style={styles.sheetTitle}>{title}</Text>
{message ? <Text style={styles.promptMessage}>{message}</Text> : null}
<TextInput
accessibilityLabel={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
value={value}
onChangeText={setValue}
placeholder={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
@@ -857,9 +859,9 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
const isActive = acc.id === activeAccountId;
let accHost = '';
try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; }
const accEmail = acc.email || (acc.username.includes('@')
? acc.username
: accHost ? `${acc.username}@${accHost}` : acc.username);
// The address leads when another account has the same name
// (webmail 3bb9f7df8).
const rowText = accountRowText(acc, accounts);
return (
<Pressable
key={acc.id}
@@ -893,13 +895,15 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
<View style={styles.accountMenuInfo}>
<View style={styles.accountMenuNameRow}>
<Text style={styles.accountMenuName} numberOfLines={1}>
{acc.displayName || acc.username}
{toUnicodeEmail(rowText.title)}
</Text>
{acc.isDefault && (
<Star size={12} color="#f59e0b" fill="#f59e0b" />
)}
</View>
<Text style={styles.accountMenuHost} numberOfLines={1}>{accEmail}</Text>
{rowText.subtitle ? (
<Text style={styles.accountMenuHost} numberOfLines={1}>{toUnicodeEmail(rowText.subtitle)}</Text>
) : null}
<View style={styles.accountMenuStatusRow}>
{acc.hasError ? (
<>
+1
View File
@@ -149,6 +149,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
</View>
{total > 4 && (
<TextInput
accessibilityLabel={t('templates.search_placeholder', 'Search templates...')}
value={query}
onChangeText={setQuery}
placeholder={t('templates.search_placeholder', 'Search templates...')}
@@ -0,0 +1,56 @@
// Text fields need a name a screen reader can say (parity 11 "Form fields
// have no name for screen readers"; field-names.test.ts covers Settings and
// the sign-in steps). The composer, quick reply, folder prompt and template
// search had fields named only by their placeholder, or by nothing.
import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const SRC = join(__dirname, '..', '..');
function elementEnd(source: string, from: number): number {
let depth = 0;
for (let i = from; i < source.length; i++) {
const ch = source[i];
if (ch === '{') depth += 1;
else if (ch === '}') depth -= 1;
else if (ch === '/' && depth === 0 && source[i + 1] === '>') return i;
}
return source.length;
}
/** `file:line` of every TextInput element without an accessibilityLabel. */
function unnamedFields(file: string): string[] {
const source = readFileSync(join(SRC, file), 'utf8');
const out: string[] = [];
// `<TextInput` opening a JSX element, not `useRef<TextInput>`.
for (const m of source.matchAll(/<TextInput(?=[\s/])/g)) {
const element = source.slice(m.index!, elementEnd(source, m.index! + m[0].length));
if (/\baccessibilityLabel=/.test(element)) continue;
out.push(`${file}:${source.slice(0, m.index!).split('\n').length}`);
}
return out;
}
const COMPOSE = readdirSync(join(SRC, 'components', 'compose'))
.filter((name) => name.endsWith('.tsx'))
.map((name) => `components/compose/${name}`);
const FILES = [
'screens/ComposeScreen.tsx',
'components/email/QuickReplyBox.tsx',
'components/SidebarDrawer.tsx',
'components/TemplateSheet.tsx',
...COMPOSE,
];
describe('field names in mail and the composer', () => {
it('every field has a name', () => {
expect(FILES.flatMap(unnamedFields)).toEqual([]);
});
it('finds the composer fields at all', () => {
const source = readFileSync(join(SRC, 'screens/ComposeScreen.tsx'), 'utf8');
expect([...source.matchAll(/<TextInput(?=[\s/])/g)].length).toBeGreaterThanOrEqual(8);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Addresses on internationalized domains (webmail #1100): the composer sends
// a domain typed in Unicode in its ASCII form, shows the From identity as
// written, and a sender on one of the user's own IDN domains reads as
// written in the reader. No React Native renderer in the test env, so this
// checks the wiring; outgoingAddress and toUnicodeEmailOnOwnDomains have unit
// tests in recipients.test.ts and idn.test.ts.
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8');
describe('IDN addresses in the composer and reader', () => {
it('builds the outgoing To, Cc and Bcc through outgoingAddress', () => {
const src = read('screens/ComposeScreen.tsx');
expect(src).toMatch(/function toAddress\(r: \{ name\?: string; email: string \}\): EmailAddress \{\s*return outgoingAddress\(r\);/);
expect(src).toMatch(/to: expandRecipients\(finalTo\)\.map\(toAddress\)/);
});
it('shows the From identity with its domain as written', () => {
expect(read('screens/ComposeScreen.tsx')).toMatch(/const email = toUnicodeEmail\(from\.email\);/);
expect(read('components/IdentitySheet.tsx')).toMatch(/\{toUnicodeEmail\(identity\.email\)\}/);
});
it('formats the sender address in the reader through useOwnDomainAddress', () => {
const header = read('components/email/MessageHeader.tsx');
expect(header).toMatch(/const fromEmail = from\?\.email \? formatAddress\(from\.email\) : '';/);
expect(header).toMatch(/\{from\?\.name \|\| fromEmail \|\| t\('email_viewer\.unknown_sender'/);
expect(header).not.toMatch(/\{from\.email\}<\/Text>/);
const card = read('components/email/ThreadMessageCard.tsx');
expect(card).toMatch(/\{from\?\.name \|\| \(from\?\.email && formatAddress\(from\.email\)\) \|\|/);
});
});
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Two accounts with the same name looked alike in the account switcher: both
// rows led with the shared name (webmail 3bb9f7df8). The rows now use
// accountRowText, which leads with the address then, and show IDN addresses
// as written. No React Native renderer in the test env, so this checks the
// wiring; accountRowText itself is covered in account-row-text.test.ts.
describe('account switcher rows in the sidebar', () => {
const src = fs.readFileSync(path.resolve(__dirname, '../SidebarDrawer.tsx'), 'utf8');
it('titles each row from accountRowText over all accounts', () => {
expect(src).toMatch(/const rowText = accountRowText\(acc, accounts\);/);
expect(src).toMatch(/\{toUnicodeEmail\(rowText\.title\)\}/);
expect(src).not.toMatch(/\{acc\.displayName \|\| acc\.username\}/);
});
it('shows the address line only when accountRowText gives one', () => {
expect(src).toMatch(/\{rowText\.subtitle \? \(\s*<Text style=\{styles\.accountMenuHost\} numberOfLines=\{1\}>\{toUnicodeEmail\(rowText\.subtitle\)\}<\/Text>/);
expect(src).not.toMatch(/accEmail/);
});
});
+8 -4
View File
@@ -21,6 +21,7 @@ import { isSmimeEmail } from '../../lib/smime';
import { resolveEmailTags } from '../../lib/thread-utils';
import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code';
import { VerificationCodeChip } from './VerificationCodeChip';
import { useOwnDomainAddress } from '../../lib/use-own-domain-address';
interface Props {
email: Email;
@@ -109,6 +110,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
const info = React.useMemo(() => headerInfo ?? deriveHeaderInfo(email), [headerInfo, email]);
const from = email.from?.[0];
// An address on one of the user's own IDN domains reads as written (#1100).
const formatAddress = useOwnDomainAddress();
const fromEmail = from?.email ? formatAddress(from.email) : '';
const starred = !!email.keywords?.$flagged;
const displayDate = emailDisplayDate(email);
const spoofed = isAuthenticationSpoofed(info.auth);
@@ -170,7 +174,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
onPress={from ? () => onAddressPress(from) : undefined}
hitSlop={4}
accessibilityRole="button"
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
accessibilityLabel={from?.name || fromEmail || t('email_viewer.unknown_sender', 'Unknown')}
>
<SenderAvatar
name={from?.name}
@@ -182,10 +186,10 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
<View style={styles.info}>
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
<Text style={styles.name} numberOfLines={1}>
{from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
{from?.name || fromEmail || t('email_viewer.unknown_sender', 'Unknown')}
</Text>
{from?.name && from?.email ? (
<Text style={styles.email} numberOfLines={1}>{from.email}</Text>
{from?.name && fromEmail ? (
<Text style={styles.email} numberOfLines={1}>{fromEmail}</Text>
) : null}
</Pressable>
{verificationText && (
+1
View File
@@ -267,6 +267,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
accessibilityLabel={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
value={text}
onChangeText={setText}
onFocus={onFocus}
+4 -1
View File
@@ -12,6 +12,7 @@ import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/
import { singleLine } from '../../lib/single-line';
import { cleanPreview } from '../../lib/preview-text';
import { useSenderWarning } from './use-sender-warning';
import { useOwnDomainAddress } from '../../lib/use-own-domain-address';
interface Props extends MessageContentProps {
expanded: boolean;
@@ -46,6 +47,8 @@ export function ThreadMessageCard({
const starred = !!email.keywords?.$flagged;
const date = emailDisplayDate(email);
const senderWarning = useSenderWarning(expanded ? undefined : email);
// An address on one of the user's own IDN domains reads as written (#1100).
const formatAddress = useOwnDomainAddress();
if (!expanded) {
return (
@@ -54,7 +57,7 @@ export function ThreadMessageCard({
<View style={styles.collapsedInfo}>
<View style={styles.collapsedTop}>
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
{from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
{from?.name || (from?.email && formatAddress(from.email)) || t('email_viewer.unknown_sender', 'Unknown')}
</Text>
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
@@ -13,8 +13,7 @@ import type { RootStackParamList } from '../../../navigation/types';
import { useLocaleStore } from '../../../stores/locale-store';
import { useColors } from '../../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
import { ArrowRight, CalendarDays, CalendarPlus, ChevronDown } from '../../icons';
import { CalendarCheck, CalendarX } from './icons';
import { ArrowRight, CalendarCheck, CalendarDays, CalendarPlus, CalendarX, ChevronDown } from '../../icons';
import { useBookingCalendar } from './use-booking-calendar';
type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -4,7 +4,7 @@
// points the reading direction, where the webmail draws arrow icons.
import React from 'react';
import { I18nManager, Text, View } from 'react-native';
import { ArrowRight, Clock, MapPin, Phone, Users } from '../../icons';
import { ArrowRight, Bed, Bus, Car, Clock, MapPin, Phone, Plane, Restaurant, Ticket, Train, Users } from '../../icons';
import {
formatAddress,
formatMomentDate,
@@ -46,7 +46,6 @@ import {
useCardStyles,
} from './card-parts';
import { CopyValueChip } from './copy-value-chip';
import { Bed, Bus, Car, Plane, Restaurant, Ticket, Train } from './icons';
export interface BookingCardProps<B extends Booking = Booking> {
booking: B;
@@ -1,19 +0,0 @@
// The card glyphs the app's generated icon set (src/components/icons, from
// scripts/generate-icons.mjs) doesn't ship yet, from the same Tabler outline
// data (MIT) under the webmail's names. Once they are added to MAP in that
// script, import them from components/icons and delete this file.
import { makeIcon } from '../../icons/icon';
export const Bed = makeIcon('Bed', ["M5 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M22 17v-3h-20", "M2 8v9", "M12 14h10v-2a3 3 0 0 0 -3 -3h-7v5"]);
export const Bus = makeIcon('Bus', ["M4 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M16 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8", "M16 5l1.5 7l4.5 0", "M2 10l15 0", "M7 5l0 5", "M12 5l0 5"]);
export const CalendarCheck = makeIcon('CalendarCheck', ["M11.5 21h-5.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6", "M16 3v4", "M8 3v4", "M4 11h16", "M15 19l2 2l4 -4"]);
export const CalendarX = makeIcon('CalendarX', ["M13 21h-7a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6.5", "M16 3v4", "M8 3v4", "M4 11h16", "M22 22l-5 -5", "M17 22l5 -5"]);
export const Car = makeIcon('Car', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-6l2 -5h9l4 5h1a2 2 0 0 1 2 2v4h-2m-4 0h-6m-6 -6h15m-6 0v-5"]);
export const Package = makeIcon('Package', ["M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5", "M12 12l8 -4.5", "M12 12l0 9", "M12 12l-8 -4.5", "M16 5.25l-8 4.5"]);
export const Plane = makeIcon('Plane', ["M16 10h4a2 2 0 0 1 0 4h-4l-4 7h-3l2 -7h-4l-2 2h-3l2 -4l-2 -4h3l2 2h4l-2 -7h3l4 7"]);
export const Restaurant = makeIcon('Restaurant', ["M19 3v12h-5c-.023 -3.681 .184 -7.406 5 -12m0 12v6h-1v-3m-10 -14v17m-3 -17v3a3 3 0 1 0 6 0v-3"]);
export const ShoppingBag = makeIcon('ShoppingBag', ["M6.331 8h11.339a2 2 0 0 1 1.977 2.304l-1.255 8.152a3 3 0 0 1 -2.966 2.544h-6.852a3 3 0 0 1 -2.965 -2.544l-1.255 -8.152a2 2 0 0 1 1.977 -2.304", "M9 11v-5a3 3 0 0 1 6 0v5"]);
export const Ticket = makeIcon('Ticket', ["M15 5l0 2", "M15 11l0 2", "M15 17l0 2", "M5 5h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-3a2 2 0 0 0 0 -4v-3a2 2 0 0 1 2 -2"]);
export const Train = makeIcon('Train', ["M21 13c0 -3.87 -3.37 -7 -10 -7h-8", "M3 15h16a2 2 0 0 0 2 -2", "M3 6v5h17.5", "M3 11v4", "M8 11v-5", "M13 11v-4.5", "M3 19h18"]);
export const Truck = makeIcon('Truck', ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0", "M5 17h-2v-11a1 1 0 0 1 1 -1h9v12m-4 0h6m4 0h2v-6h-8m0 -5h5l3 5"]);
@@ -2,13 +2,12 @@
// b0782af2d (same rows, React Native views).
import React from 'react';
import { Image, Text, View } from 'react-native';
import { Clock, ExternalLink } from '../../icons';
import { Clock, ExternalLink, Package, ShoppingBag, Truck } from '../../icons';
import { formatMomentDate, formatMoney, isWebLink, shipmentItems, summarizeItems } from '../../../lib/structured-mail/format';
import type { Item, Link, OrderStatus, Parcel, Shipment } from '../../../lib/structured-mail/types';
import { useLocaleStore } from '../../../stores/locale-store';
import { CardLink, CardShell, MetaRow, Pill, useCardFormat, useCardStyles } from './card-parts';
import { CopyValueChip } from './copy-value-chip';
import { Package, ShoppingBag, Truck } from './icons';
const STATUS_TONE: Record<OrderStatus, 'error' | 'warning' | 'success' | 'info' | 'muted'> = {
payment_due: 'warning',
+24
View File
@@ -29,6 +29,7 @@ const G = {
'arrows-vertical': ["M8 7l4 -4l4 4","M8 17l4 4l4 -4","M12 3l0 18"],
'ban': ["M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0","M5.7 5.7l12.6 12.6"],
'beach': ["M17.553 16.75a7.5 7.5 0 0 0 -10.606 0","M18 3.804a6 6 0 0 0 -8.196 2.196l10.392 6a6 6 0 0 0 -2.196 -8.196","M16.732 10c1.658 -2.87 2.225 -5.644 1.268 -6.196c-.957 -.552 -3.075 1.326 -4.732 4.196","M15 9l-3 5.196","M3 19.25a2.4 2.4 0 0 1 1 -.25a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 1 .25"],
'bed': ["M5 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M22 17v-3h-20","M2 8v9","M12 14h10v-2a3 3 0 0 0 -3 -3h-7v5"],
'bell': ["M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1"],
'bell-off': ["M9.346 5.353c.21 -.129 .428 -.246 .654 -.353a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3m-1 3h-13a4 4 0 0 0 2 -3v-3a6.996 6.996 0 0 1 1.273 -3.707","M9 17v1a3 3 0 0 0 6 0v-1","M3 3l18 18"],
'bell-ringing': ["M10 5a2 2 0 0 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2 -3v-3a7 7 0 0 1 4 -6","M9 17v1a3 3 0 0 0 6 0v-1","M21 6.727a11.05 11.05 0 0 0 -2.794 -3.727","M3 6.727a11.05 11.05 0 0 1 2.792 -3.727"],
@@ -37,11 +38,15 @@ const G = {
'book-2': ["M19 4v16h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12","M19 16h-12a2 2 0 0 0 -2 2","M9 8h6"],
'bug': ["M9 9v-1a3 3 0 0 1 6 0v1","M8 9h8a6 6 0 0 1 1 3v3a5 5 0 0 1 -10 0v-3a6 6 0 0 1 1 -3","M3 13l4 0","M17 13l4 0","M12 20l0 -6","M4 19l3.35 -2","M20 19l-3.35 -2","M4 7l3.75 2.4","M20 7l-3.75 2.4"],
'building': ["M3 21l18 0","M9 8l1 0","M9 12l1 0","M9 16l1 0","M14 8l1 0","M14 12l1 0","M14 16l1 0","M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16"],
'bus': ["M4 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M16 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M4 17h-2v-11a1 1 0 0 1 1 -1h14a5 7 0 0 1 5 7v5h-2m-4 0h-8","M16 5l1.5 7l4.5 0","M2 10l15 0","M7 5l0 5","M12 5l0 5"],
'cake': ["M3 20h18v-8a3 3 0 0 0 -3 -3h-12a3 3 0 0 0 -3 3v8","M3 14.803c.312 .135 .654 .204 1 .197a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1a2.4 2.4 0 0 0 2 -1a2.4 2.4 0 0 1 2 -1a2.4 2.4 0 0 1 2 1a2.4 2.4 0 0 0 2 1c.35 .007 .692 -.062 1 -.197","M12 4l1.465 1.638a2 2 0 1 1 -3.015 .099l1.55 -1.737"],
'calendar': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M11 15h1","M12 15v3"],
'calendar-check': ["M11.5 21h-5.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6","M16 3v4","M8 3v4","M4 11h16","M15 19l2 2l4 -4"],
'calendar-month': ["M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12","M16 3v4","M8 3v4","M4 11h16","M8 14v4","M12 14v4","M16 14v4"],
'calendar-plus': ["M12.5 21h-6.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v5","M16 3v4","M8 3v4","M4 11h16","M16 19h6","M19 16v6"],
'calendar-x': ["M13 21h-7a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6.5","M16 3v4","M8 3v4","M4 11h16","M22 22l-5 -5","M17 22l5 -5"],
'camera': ["M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2","M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"],
'car': ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M5 17h-2v-6l2 -5h9l4 5h1a2 2 0 0 1 2 2v4h-2m-4 0h-6m-6 -6h15m-6 0v-5"],
'check': ["M5 12l5 5l10 -10"],
'checks': ["M7 12l5 5l10 -10","M2 12l5 5m5 -5l5 -5"],
'chevron-down': ["M6 9l6 6l6 -6"],
@@ -121,6 +126,7 @@ const G = {
'minus': ["M5 12l14 0"],
'moon': ["M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454l0 .008"],
'note': ["M13 20l7 -7","M13 20v-6a1 1 0 0 1 1 -1h6v-7a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7"],
'package': ["M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5","M12 12l8 -4.5","M12 12l0 9","M12 12l-8 -4.5","M16 5.25l-8 4.5"],
'palette': ["M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25","M7.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M11.5 7.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0","M15.5 10.5a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"],
'paperclip': ["M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"],
'pencil': ["M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4","M13.5 6.5l4 4"],
@@ -128,6 +134,7 @@ const G = {
'photo': ["M15 8h.01","M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12","M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5","M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"],
'pinned': ["M9 4v6l-2 4v2h10v-2l-2 -4v-6","M12 16l0 5","M8 4l8 0"],
'pinned-off': ["M9 4v1","M9 9v1l-2 4v2h9","M16 12l-1 -2v-6","M12 16v5","M8 4h8","M3 3l18 18"],
'plane': ["M16 10h4a2 2 0 0 1 0 4h-4l-4 7h-3l2 -7h-4l-2 2h-3l2 -4l-2 -4h3l2 2h4l-2 -7h3l4 7"],
'plus': ["M12 5l0 14","M5 12l14 0"],
'puzzle': ["M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1"],
'qrcode': ["M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 17l0 .01","M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M7 7l0 .01","M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4","M17 7l0 .01","M14 14l3 0","M20 14l0 .01","M14 14l0 3","M14 20l3 0","M17 17l3 0","M20 17l0 3"],
@@ -147,6 +154,7 @@ const G = {
'shield-check': ["M11.46 20.846a12 12 0 0 1 -7.96 -14.846a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 -.09 7.06","M15 19l2 2l4 -4"],
'shield-exclamation': ["M15.04 19.745c-.942 .551 -1.964 .976 -3.04 1.255a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3a12 12 0 0 1 .195 6.015","M19 16v3","M19 22v.01"],
'shield-question': ["M15.065 19.732c-.95 .557 -1.98 .986 -3.065 1.268a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3a12 12 0 0 0 8.5 3c.51 1.738 .617 3.55 .333 5.303","M19 22v.01","M19 19a2.003 2.003 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"],
'shopping-bag': ["M6.331 8h11.339a2 2 0 0 1 1.977 2.304l-1.255 8.152a3 3 0 0 1 -2.966 2.544h-6.852a3 3 0 0 1 -2.965 -2.544l-1.255 -8.152a2 2 0 0 1 1.977 -2.304","M9 11v-5a3 3 0 0 1 6 0v5"],
'sort-ascending': ["M4 6l7 0","M4 12l7 0","M4 18l9 0","M15 9l3 -3l3 3","M18 6l0 12"],
'sort-descending': ["M4 6l9 0","M4 12l7 0","M4 18l7 0","M15 15l3 3l3 -3","M18 6l0 12"],
'square': ["M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14"],
@@ -160,7 +168,11 @@ const G = {
'tags': ["M3 8v4.172a2 2 0 0 0 .586 1.414l5.71 5.71a2.41 2.41 0 0 0 3.408 0l3.592 -3.592a2.41 2.41 0 0 0 0 -3.408l-5.71 -5.71a2 2 0 0 0 -1.414 -.586h-4.172a2 2 0 0 0 -2 2","M18 19l1.592 -1.592a4.82 4.82 0 0 0 0 -6.816l-4.592 -4.592","M7 10h-.01"],
'template': ["M4 5a1 1 0 0 1 1 -1h14a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-14a1 1 0 0 1 -1 -1l0 -2","M4 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -6","M14 12l6 0","M14 16l6 0","M14 20l6 0"],
'terminal': ["M5 7l5 5l-5 5","M12 19l7 0"],
'ticket': ["M15 5l0 2","M15 11l0 2","M15 17l0 2","M5 5h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-3a2 2 0 0 0 0 -4v-3a2 2 0 0 1 2 -2"],
'tools-kitchen-2': ["M19 3v12h-5c-.023 -3.681 .184 -7.406 5 -12m0 12v6h-1v-3m-10 -14v17m-3 -17v3a3 3 0 1 0 6 0v-3"],
'train': ["M21 13c0 -3.87 -3.37 -7 -10 -7h-8","M3 15h16a2 2 0 0 0 2 -2","M3 6v5h17.5","M3 11v4","M8 11v-5","M13 11v-4.5","M3 19h18"],
'trash': ["M4 7l16 0","M10 11l0 6","M14 11l0 6","M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12","M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"],
'truck': ["M5 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M15 17a2 2 0 1 0 4 0a2 2 0 1 0 -4 0","M5 17h-2v-11a1 1 0 0 1 1 -1h9v12m-4 0h6m4 0h2v-6h-8m0 -5h5l3 5"],
'typography': ["M4 20l3 0","M14 20l7 0","M6.9 15l6.9 0","M10.2 6.3l5.8 13.7","M5 20l6 -16l2 0l7 16"],
'underline': ["M7 5v5a5 5 0 0 0 10 0v-5","M5 19h14"],
'unlink': ["M17 22v-2","M9 15l6 -6","M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464","M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463","M20 17h2","M2 7h2","M7 2v2"],
@@ -193,6 +205,7 @@ export const ArrowUp = makeIcon('ArrowUp', G['arrow-up']);
export const ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']);
export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']);
export const Ban = makeIcon('Ban', G['ban']);
export const Bed = makeIcon('Bed', G['bed']);
export const Bell = makeIcon('Bell', G['bell']);
export const BellOff = makeIcon('BellOff', G['bell-off']);
export const BellRing = makeIcon('BellRing', G['bell-ringing']);
@@ -202,11 +215,15 @@ export const BookOpen = makeIcon('BookOpen', G['book']);
export const BookUser = makeIcon('BookUser', G['address-book']);
export const Bug = makeIcon('Bug', G['bug']);
export const Building = makeIcon('Building', G['building']);
export const Bus = makeIcon('Bus', G['bus']);
export const Cake = makeIcon('Cake', G['cake']);
export const Calendar = makeIcon('Calendar', G['calendar']);
export const CalendarCheck = makeIcon('CalendarCheck', G['calendar-check']);
export const CalendarDays = makeIcon('CalendarDays', G['calendar-month']);
export const CalendarPlus = makeIcon('CalendarPlus', G['calendar-plus']);
export const CalendarX = makeIcon('CalendarX', G['calendar-x']);
export const Camera = makeIcon('Camera', G['camera']);
export const Car = makeIcon('Car', G['car']);
export const Check = makeIcon('Check', G['check']);
export const CheckCheck = makeIcon('CheckCheck', G['checks']);
export const CheckCircle = makeIcon('CheckCircle', G['circle-check']);
@@ -295,6 +312,7 @@ export const Moon = makeIcon('Moon', G['moon']);
export const MoreHorizontal = makeIcon('MoreHorizontal', G['dots']);
export const MoreVertical = makeIcon('MoreVertical', G['dots-vertical']);
export const MoveVertical = makeIcon('MoveVertical', G['arrows-vertical']);
export const Package = makeIcon('Package', G['package']);
export const Palette = makeIcon('Palette', G['palette']);
export const Palmtree = makeIcon('Palmtree', G['beach']);
export const PanelLeftClose = makeIcon('PanelLeftClose', G['layout-sidebar-left-collapse']);
@@ -306,6 +324,7 @@ export const Pencil = makeIcon('Pencil', G['pencil']);
export const Phone = makeIcon('Phone', G['phone']);
export const Pin = makeIcon('Pin', G['pinned']);
export const PinOff = makeIcon('PinOff', G['pinned-off']);
export const Plane = makeIcon('Plane', G['plane']);
export const Plus = makeIcon('Plus', G['plus']);
export const Puzzle = makeIcon('Puzzle', G['puzzle']);
export const QrCode = makeIcon('QrCode', G['qrcode']);
@@ -316,6 +335,7 @@ export const RemoveFormatting = makeIcon('RemoveFormatting', G['clear-formatting
export const Repeat = makeIcon('Repeat', G['repeat']);
export const Reply = makeIcon('Reply', G['arrow-back-up']);
export const ReplyAll = makeIcon('ReplyAll', G['arrow-back-up-double']);
export const Restaurant = makeIcon('Restaurant', G['tools-kitchen-2']);
export const RotateCcw = makeIcon('RotateCcw', G['rotate']);
export const Rows2 = makeIcon('Rows2', G['layout-rows']);
export const Rss = makeIcon('Rss', G['rss']);
@@ -331,6 +351,7 @@ export const Shield = makeIcon('Shield', G['shield']);
export const ShieldAlert = makeIcon('ShieldAlert', G['shield-exclamation']);
export const ShieldCheck = makeIcon('ShieldCheck', G['shield-check']);
export const ShieldQuestion = makeIcon('ShieldQuestion', G['shield-question']);
export const ShoppingBag = makeIcon('ShoppingBag', G['shopping-bag']);
export const Shuffle = makeIcon('Shuffle', G['arrows-shuffle']);
export const Smartphone = makeIcon('Smartphone', G['device-mobile']);
export const Square = makeIcon('Square', G['square']);
@@ -344,8 +365,11 @@ export const Table = makeIcon('Table', G['table']);
export const Tag = makeIcon('Tag', G['tag']);
export const Tags = makeIcon('Tags', G['tags']);
export const Terminal = makeIcon('Terminal', G['terminal']);
export const Ticket = makeIcon('Ticket', G['ticket']);
export const Train = makeIcon('Train', G['train']);
export const Trash = makeIcon('Trash', G['trash']);
export const Trash2 = makeIcon('Trash2', G['trash']);
export const Truck = makeIcon('Truck', G['truck']);
export const Type = makeIcon('Type', G['typography']);
export const Underline = makeIcon('Underline', G['underline']);
export const Undo2 = makeIcon('Undo2', G['arrow-back']);
@@ -14,6 +14,7 @@ export function ComposingSettings() {
const styles = React.useMemo(() => makeStyles(c), [c]);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const replyIdentityMatch = useSettingsStore((s) => s.replyIdentityMatch);
const quickReplyEnabled = useSettingsStore((s) => s.quickReplyEnabled);
const t = useLocaleStore((s) => s.t);
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
@@ -104,6 +105,13 @@ export function ComposingSettings() {
title={t('settings.composer.title', "Composer")}
description={t('settings.composer.description', "Configure email composition settings")}
>
<SettingItem
label={t('settings.email_behavior.quick_reply.label', 'Quick reply')}
description={t('settings.email_behavior.quick_reply.description', 'Show the quick reply box below messages.')}
>
<ToggleSwitch checked={quickReplyEnabled} onChange={(v) => updateSetting('quickReplyEnabled', v)} />
</SettingItem>
<SettingItem
label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")}
description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "Replies always come from the identity a message was sent to. With this on, a reply to another address on your domains (a catch-all alias) is sent from that address too.")}
@@ -11,8 +11,10 @@ import { useSettingsStore, type ExternalContentPolicy } from '../../stores/setti
import { useContactsStore } from '../../stores/contacts-store';
import { useHasContacts } from '../../lib/capabilities';
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { removeTrustedSenderWithUndo } from '../../lib/settings-undo';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { ToastHost } from '../ToastHost';
interface TrustedRow {
email: string;
@@ -107,20 +109,19 @@ export function ContentSendersSettings() {
}
};
// Taking a sender off the list is reversible: no question, a toast with
// Undo that adds the address back (webmail trusted-senders-modal R3).
const handleRemove = async (row: TrustedRow) => {
if (row.local) removeTrustedSender(row.email);
if (row.synced) {
setBusy(row.email);
try {
await removeFromTrustedSendersBook(row.email);
} catch (err) {
Alert.alert(
t('settings.email_behavior.trusted_senders.sync_remove_failed', 'Could not remove from address book'),
err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'),
);
} finally {
setBusy(null);
}
if (row.synced) setBusy(row.email);
try {
await removeTrustedSenderWithUndo(row, {
removeLocal: removeTrustedSender,
addLocal: addTrustedSender,
removeFromBook: removeFromTrustedSendersBook,
addToBook: addToTrustedSendersBook,
}, t);
} finally {
if (row.synced) setBusy(null);
}
};
@@ -269,6 +270,9 @@ export function ContentSendersSettings() {
)}
</Pressable>
</Pressable>
{/* The screen's toast host sits under this Modal; this one shows the
Undo after removing a sender above the list. */}
<ToastHost />
</Modal>
</View>
);
+28 -17
View File
@@ -22,6 +22,8 @@ import { SieveEditorSheet } from '../filters/SieveEditorSheet';
import type { FilterRule } from '../../lib/sieve/types';
import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value';
import { periodStatus, supportsPeriods } from '../../lib/sieve/period';
import { rulesForwardLimit } from '../../lib/sieve/forward-limit';
import { deleteFilterRuleWithUndo } from '../../lib/settings-undo';
import Badge from '../Badge';
type Translate = (key: string, fallback?: string) => string;
@@ -157,7 +159,8 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
const {
rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings, includeVacation,
selectAccount, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule,
vacationForward, sieveCapabilities,
selectAccount, saveFilters, addRule, updateRule, reorderRules, toggleRule,
setOpaqueScript, resetToVisualBuilder, validateScript,
} = useFilterStore();
const periodsSupported = useFilterStore((s) => supportsPeriods(s.sieveCapabilities?.sieveExtensions));
@@ -200,23 +203,18 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
void persist(() => toggleRule(ruleId));
}, [toggleRule, persist]);
// A rule can be put back as it was, so it goes at once with an Undo
// (webmail R3).
const handleDelete = useCallback((rule: FilterRule) => {
Alert.alert(
t('settings.filters.delete_rule', 'Delete Rule'),
t('settings.filters.delete_confirm', 'Are you sure you want to delete this rule?'),
[
{ text: t('settings.filters.cancel', 'Cancel'), style: 'cancel' },
{
text: t('settings.filters.confirm_delete', 'Delete'),
style: 'destructive',
onPress: () => {
deleteRule(rule.id);
void persist(() => addRule(rule));
},
},
],
);
}, [deleteRule, addRule, persist, t]);
void deleteFilterRuleWithUndo(rule.id, t);
}, [t]);
// A message can collect only so many forwards on this server; the rules
// run behind the out of office forwarding (#1155, #1208).
const forwardLimit = useMemo(
() => rulesForwardLimit(rules, vacationForward, sieveCapabilities?.maxNumberRedirects),
[rules, vacationForward, sieveCapabilities],
);
const handleSaveRule = useCallback((rule: FilterRule) => {
const previous = useFilterStore.getState().rules;
@@ -362,6 +360,18 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
</View>
)}
{!isOpaque && forwardLimit.ignored && (
<Text style={styles.forwardWarning}>
{t('settings.filters.forward_unavailable', 'This server does not allow forwarding. Forward actions in filter rules have no effect.')}
</Text>
)}
{!isOpaque && forwardLimit.tooMany && (
<Text style={styles.forwardWarning}>
{t('settings.filters.forward_limit', 'Forward limit per message on this server: {count}. Extra forwards are skipped.', { count: sieveCapabilities?.maxNumberRedirects ?? 0 })}
</Text>
)}
{!isOpaque && rules.length > 0 && (
<View style={{ gap: spacing.sm }}>
{rules.map((rule) => {
@@ -567,6 +577,7 @@ function makeStyles(c: ThemePalette) {
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
emptyText: { ...typography.body, color: c.mutedForeground },
forwardWarning: { ...typography.caption, color: c.warning },
ruleRow: {
flexDirection: 'row',
+6 -13
View File
@@ -12,6 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store';
import { discoverKeywords } from '../../api/keyword-discovery';
import { findUnrecognizedKeywords, type UnrecognizedKeyword } from '../../lib/keyword-discovery';
import { jmapClient } from '../../api/jmap-client';
import { deleteKeywordWithUndo } from '../../lib/settings-undo';
type Keyword = KeywordDef;
@@ -26,7 +27,6 @@ export function KeywordSettings() {
const t = useLocaleStore((s) => s.t);
const addKeyword = useKeywordsStore((s) => s.add);
const updateKeyword = useKeywordsStore((s) => s.update);
const removeKeyword = useKeywordsStore((s) => s.remove);
const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
const hydrated = useKeywordsStore((s) => s.hydrated);
const hydrate = useKeywordsStore((s) => s.hydrate);
@@ -47,17 +47,10 @@ export function KeywordSettings() {
}
};
// Deleting a tag takes it off every message's tag list on this device; ask
// first, as the reset next to it does.
const deleteKeyword = (id: string, label: string) => {
Alert.alert(
t('settings.keywords.delete_confirm_title', 'Delete tag "{label}"?', { label }),
undefined,
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{ text: t('settings.keywords.delete', 'Delete tag'), style: 'destructive', onPress: () => removeKeyword(id) },
],
);
// A tag can be put back as it was, so it goes at once with an Undo
// (webmail R3).
const deleteKeyword = (id: string) => {
deleteKeywordWithUndo(id, t);
};
// One stray tap used to wipe a carefully built tag list (webmail removed
@@ -130,7 +123,7 @@ export function KeywordSettings() {
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}>
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id, kw.label)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete', "Delete tag")}>
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
+36 -2
View File
@@ -325,6 +325,37 @@ describe('pushBackgroundTask notifications', () => {
}));
});
it('drops a leading style sheet from the notification preview (webmail #1136)', async () => {
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => ({
ok: true,
json: async () => (url.endsWith('/.well-known/jmap')
? {
apiUrl: 'https://mail.example.com/jmap/',
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-primary' },
accounts: { 'jmap-primary': {} },
}
: {
methodResponses: [[
'Email/get',
{ list: [{ id: 'm1', threadId: 't1', keywords: {}, subject: 'Sale', preview: '@media screen and (min-width:600px){.hide{display:none!important}} Our autumn sale\nstarts today', from: [{ name: 'Shop', email: 'news@shop.example' }] }] },
'0',
]],
}),
}));
await pushBackgroundTask({
kind: 'jmap-email-push',
accountLabel: 'alice',
accountId: 'jmap-primary',
emailIds: JSON.stringify(['m1']),
});
expect(showNotification).toHaveBeenCalledWith(expect.objectContaining({
emailId: 'm1',
preview: 'Our autumn sale starts today',
}));
});
it('handles markRead action via detached JMAP', async () => {
const postCalls: any[] = [];
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
@@ -759,9 +790,12 @@ describe('senderFaviconsAllowed', () => {
expect(await senderFaviconsAllowed()).toBe(true);
});
it('defaults to on like the store, but skips icons when the settings are unreadable', async () => {
it('defaults to off like the store, and skips icons when the settings are unreadable', async () => {
await AsyncStorage.removeItem(KEY);
expect(await senderFaviconsAllowed()).toBe(true);
expect(await senderFaviconsAllowed()).toBe(false);
// Settings saved before the key existed: the store fills in its default (off).
await AsyncStorage.setItem(KEY, JSON.stringify({ emailNotificationsEnabled: true }));
expect(await senderFaviconsAllowed()).toBe(false);
await AsyncStorage.setItem(KEY, '{not json');
expect(await senderFaviconsAllowed()).toBe(false);
await AsyncStorage.removeItem(KEY);
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { mergeRecipientSuggestions } from '../recipient-suggestions';
import type { RecipientSuggestion } from '../../stores/contacts-store';
// The composer's suggestions in the webmail's order: contacts and groups,
// directory people, recent recipients, then what "Search the server" found
// in the Sent folder (parity 06 autocomplete row).
const contact = (name: string, email: string): RecipientSuggestion => ({ name, email });
const contactEmails = new Set(['ann@example.com', 'nameless@example.com']);
const isContact = (s: RecipientSuggestion) => !!s.group || contactEmails.has(s.email);
describe('mergeRecipientSuggestions', () => {
it('puts directory people after contacts and before recent recipients', () => {
const out = mergeRecipientSuggestions({
local: [contact('Ann', 'ann@example.com'), contact('Recent Rob', 'rob@example.com')],
isContact,
directory: [contact('Dana Directory', 'dana@example.com')],
server: [],
exclude: new Set(),
limit: 8,
});
expect(out.map((s) => s.email)).toEqual(['ann@example.com', 'dana@example.com', 'rob@example.com']);
});
it('lists an address once and lends a directory name to a contact without one', () => {
const out = mergeRecipientSuggestions({
local: [contact('', 'nameless@example.com'), contact('Recent Dana', 'dana@example.com')],
isContact,
directory: [contact('Nora Nameless', 'Nameless@example.com'), contact('Dana Directory', 'dana@example.com')],
server: [],
exclude: new Set(),
limit: 8,
});
expect(out).toEqual([
{ name: 'Nora Nameless', email: 'nameless@example.com' },
{ name: 'Dana Directory', email: 'dana@example.com' },
]);
});
it('adds server hits below the rest, skipping known and selected addresses', () => {
const local = Array.from({ length: 10 }, (_, i) => contact(`Recent ${i}`, `r${i}@example.com`));
const out = mergeRecipientSuggestions({
local,
isContact,
directory: [],
server: [contact('R0 again', 'r0@example.com'), contact('Picked', 'picked@example.com'), contact('Found', 'found@example.com'), contact('R9', 'r9@example.com')],
exclude: new Set(['picked@example.com']),
limit: 8,
});
// Eight local ones, then the server's new ones; r9 fell past the limit
// and comes back from the server.
expect(out.map((s) => s.email)).toEqual([
...local.slice(0, 8).map((s) => s.email), 'found@example.com', 'r9@example.com',
]);
});
it('keeps groups and leaves out addresses already on the message', () => {
const group: RecipientSuggestion = { name: 'Team', email: '', group: { id: 'g1', memberCount: 3 } };
const out = mergeRecipientSuggestions({
local: [group, contact('Ann', 'ann@example.com')],
isContact,
directory: [contact('Ann', 'ANN@example.com')],
server: [],
exclude: new Set(['ann@example.com']),
limit: 8,
});
expect(out).toEqual([group]);
});
});
// No React Native renderer in the test env: the composer is checked for the
// wiring.
describe('composer suggestions wiring', () => {
const src = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
it('merges contacts, directory people and server hits', () => {
expect(src).toMatch(/mergeRecipientSuggestions\(\{\s*local: getAutocomplete\(q, 16\),/);
expect(src).toMatch(/directory: lookupDirectory\(q, 16\),/);
expect(src).toMatch(/server: serverHits\?\.query === q \? serverHits\.results : \[\],/);
expect(src).toMatch(/if \(directoryStatus === 'idle' && !loginMovedFrom\(ownerLoginRef\.current\)\) void fetchDirectory\(\);/);
});
it('offers "Search the server" when a Sent folder is known, on the composer\'s login only', () => {
expect(src).toMatch(/const results = await searchRecipients\(q\);/);
expect(src).toMatch(/if \(!q \|\| searchingServer \|\| loginMovedFrom\(ownerLoginRef\.current\)\) return;/);
expect(src).toMatch(/onSearchServer=\{canSearchServer \?/);
expect(src).toMatch(/t\('email_composer\.autocomplete_search_server', 'Search the server'\)/);
});
});
+23
View File
@@ -8,6 +8,7 @@ import {
expandRecipients,
splitPastedRecipients,
parseMailtoUrl,
outgoingAddress,
} from '../recipients';
describe('isValidEmail', () => {
@@ -24,6 +25,28 @@ describe('isValidEmail', () => {
expect(isValidEmail('<a@b.c>')).toBe(false);
expect(isValidEmail('noat')).toBe(false);
});
it('accepts a domain written in Unicode when its ASCII form is valid (webmail #1100)', () => {
expect(isValidEmail('anna@bücher.de')).toBe(true);
expect(isValidEmail('ivan@пример.рф')).toBe(true);
expect(isValidEmail('anna@xn--bcher-kva.de')).toBe(true);
// Still no spaces, no non-ASCII local part, no bogus xn-- labels.
expect(isValidEmail('anna@bü cher.de')).toBe(false);
expect(isValidEmail('änna@bücher.de')).toBe(false);
expect(isValidEmail('anna@xn--zzzz-.de')).toBe(false);
expect(isValidEmail('anna@bücher.de\r\nBcc: x@y.z')).toBe(false);
});
});
describe('outgoingAddress', () => {
it('sends a Unicode domain in its ASCII form and keeps the name', () => {
expect(outgoingAddress({ name: 'Anna', email: 'anna@bücher.de' })).toEqual({ name: 'Anna', email: 'anna@xn--bcher-kva.de' });
expect(outgoingAddress({ email: 'ivan@пример.рф' })).toEqual({ email: 'ivan@xn--e1afmkfd.xn--p1ai' });
});
it('leaves ASCII addresses and the local part alone', () => {
expect(outgoingAddress({ name: '', email: 'Anna.B@Example.org' })).toEqual({ email: 'Anna.B@Example.org' });
});
});
describe('splitRecipients', () => {
@@ -122,6 +122,7 @@ describe('settings search index (English catalog)', () => {
expect(labelsFor('notifications')).toContain('Parse email invitations');
expect(labelsFor('calendar')).toContain('Show week numbers');
expect(labelsFor('reading')).toContain('Clear search when switching folders');
expect(labelsFor('composing')).toContain('Quick reply');
expect(labelsFor('notifications')).toContain('Unread count on app icon');
// Webmail-only settings are not offered.
expect(labelsFor('calendar')).not.toContain('Free scrolling');
+190
View File
@@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../api/sieve', () => ({
createSieveScript: vi.fn(),
getSieveAccountId: vi.fn(() => 'own'),
getSieveCapabilities: vi.fn(() => null),
getSieveScriptContent: vi.fn(),
getSieveScripts: vi.fn(),
isSieveSupported: vi.fn(() => true),
updateSieveScript: vi.fn(),
validateSieveScript: vi.fn(async () => ({ isValid: true })),
}));
import fs from 'node:fs';
import path from 'node:path';
import * as sieve from '../../api/sieve';
import { generateScript } from '../sieve/generator';
import type { FilterRule } from '../sieve/types';
import { useFilterStore } from '../../stores/filter-store';
import { useKeywordsStore } from '../../stores/keywords-store';
import { useToastStore } from '../../stores/toast-store';
import {
deleteFilterRuleWithUndo, deleteKeywordWithUndo, removeTrustedSenderWithUndo, UNDO_TOAST_DURATION,
} from '../settings-undo';
// Deleting a filter rule, a tag or a trusted sender in Settings goes at once
// with an Undo in the toast instead of a confirm (webmail R3).
const api = vi.mocked(sieve);
const t = (key: string, fallback?: string) => `${key}|${fallback ?? ''}`;
const toasts = () => useToastStore.getState().toasts;
const lastToast = () => toasts()[toasts().length - 1];
function rule(id: string, name: string): FilterRule {
return {
id, name, enabled: true, matchType: 'all',
conditions: [{ field: 'from', comparator: 'contains', value: `${id}@example.com` }],
actions: [{ type: 'move', value: 'Archive' }],
stopProcessing: false,
};
}
beforeEach(() => {
vi.clearAllMocks();
api.getSieveAccountId.mockReturnValue('own');
api.isSieveSupported.mockReturnValue(true);
api.getSieveCapabilities.mockReturnValue(null);
useFilterStore.getState().clearState();
useToastStore.getState().clearToasts();
});
describe('deleteFilterRuleWithUndo', () => {
async function load() {
api.getSieveScripts.mockResolvedValue([{ id: 's1', name: 'filters', blobId: 'b1', isActive: true }]);
api.getSieveScriptContent.mockResolvedValue(generateScript([rule('a', 'A'), rule('b', 'B')]));
await useFilterStore.getState().selectAccount(null);
}
it('deletes and saves without asking, and the toast Undo saves the rule back', async () => {
await load();
api.updateSieveScript.mockResolvedValue(undefined);
await deleteFilterRuleWithUndo('a', t);
expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['b']);
expect(api.updateSieveScript).toHaveBeenCalledTimes(1);
expect(lastToast()).toMatchObject({ type: 'success', title: 'notifications.filters_deleted|Filter rule deleted' });
expect(lastToast().action?.label).toBe('notifications.rule_undo|Undo');
lastToast().action!.onPress();
await vi.waitFor(() => expect(api.updateSieveScript).toHaveBeenCalledTimes(2));
expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'b']);
});
it('says the save failed and keeps the rule when the server refuses', async () => {
await load();
api.updateSieveScript.mockRejectedValueOnce(new Error('down'));
await deleteFilterRuleWithUndo('a', t);
expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['a', 'b']);
expect(toasts()).toHaveLength(1);
expect(lastToast()).toMatchObject({ type: 'error', title: 'notifications.filters_save_failed|Failed to save filters' });
});
it('says the save failed when the Undo cannot be saved', async () => {
await load();
api.updateSieveScript.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('down'));
await deleteFilterRuleWithUndo('a', t);
lastToast().action!.onPress();
await vi.waitFor(() => expect(lastToast().type).toBe('error'));
expect(useFilterStore.getState().rules.map((r) => r.id)).toEqual(['b']);
});
});
describe('deleteKeywordWithUndo', () => {
beforeEach(() => {
useKeywordsStore.setState({
keywords: [
{ id: 'red', label: 'Red', color: 'red' },
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'home', label: 'Home', color: 'green' },
],
} as never);
});
it('deletes the tag at once and Undo puts it back where it was', () => {
deleteKeywordWithUndo('work', t);
expect(useKeywordsStore.getState().keywords.map((k) => k.id)).toEqual(['red', 'home']);
expect(lastToast()).toMatchObject({ type: 'success', title: 'settings.keywords.deleted|Tag deleted' });
lastToast().action!.onPress();
expect(useKeywordsStore.getState().keywords.map((k) => k.id)).toEqual(['red', 'work', 'home']);
// A second tap does not add it twice.
lastToast().action!.onPress();
expect(useKeywordsStore.getState().keywords.map((k) => k.id)).toEqual(['red', 'work', 'home']);
});
});
describe('removeTrustedSenderWithUndo', () => {
function stores() {
return {
removeLocal: vi.fn(),
addLocal: vi.fn(),
removeFromBook: vi.fn(async () => undefined),
addToBook: vi.fn(async () => undefined),
};
}
it('removes the sender from the list and the book, and Undo adds it back to both', async () => {
const s = stores();
await removeTrustedSenderWithUndo({ email: 'ann@example.com', local: true, synced: true }, s, t);
expect(s.removeFromBook).toHaveBeenCalledWith('ann@example.com');
expect(s.removeLocal).toHaveBeenCalledWith('ann@example.com');
expect(lastToast()).toMatchObject({
type: 'success',
title: 'settings.email_behavior.trusted_senders.removed|Removed from trusted senders',
message: 'ann@example.com',
duration: UNDO_TOAST_DURATION,
});
lastToast().action!.onPress();
expect(s.addLocal).toHaveBeenCalledWith('ann@example.com');
expect(s.addToBook).toHaveBeenCalledWith('ann@example.com');
});
it('only touches the places the sender was in', async () => {
const s = stores();
await removeTrustedSenderWithUndo({ email: 'bo@example.com', local: true, synced: false }, s, t);
lastToast().action!.onPress();
expect(s.removeFromBook).not.toHaveBeenCalled();
expect(s.addToBook).not.toHaveBeenCalled();
expect(s.addLocal).toHaveBeenCalledWith('bo@example.com');
});
it('changes nothing and says so when the address book cannot be written', async () => {
const s = stores();
s.removeFromBook.mockRejectedValueOnce(new Error('down'));
await removeTrustedSenderWithUndo({ email: 'ann@example.com', local: true, synced: true }, s, t);
expect(s.removeLocal).not.toHaveBeenCalled();
expect(toasts()).toHaveLength(1);
expect(lastToast()).toMatchObject({ type: 'error', title: "settings.email_behavior.trusted_senders.remove_error|Couldn't remove the sender" });
});
});
// No React Native renderer in the test env: the panes are checked for the
// wiring.
describe('settings panes', () => {
const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '../../components/settings', file), 'utf8');
it('filters: deletes with Undo and warns about the forward limit (#1155, #1208)', () => {
const src = read('FilterSettings.tsx');
expect(src).toMatch(/void deleteFilterRuleWithUndo\(rule\.id, t\)/);
expect(src).not.toMatch(/settings\.filters\.delete_confirm/);
expect(src).toMatch(/rulesForwardLimit\(rules, vacationForward, sieveCapabilities\?\.maxNumberRedirects\)/);
expect(src).toMatch(/forwardLimit\.ignored && \(\s*<Text[^>]*>\s*\{t\('settings\.filters\.forward_unavailable'/);
expect(src).toMatch(/forwardLimit\.tooMany && \(\s*<Text[^>]*>\s*\{t\('settings\.filters\.forward_limit'/);
});
it('tags: deletes with Undo', () => {
const src = read('KeywordSettings.tsx');
expect(src).toMatch(/deleteKeywordWithUndo\(id, t\)/);
expect(src).not.toMatch(/delete_confirm_title/);
});
it('trusted senders: removes with Undo, shown above the list modal', () => {
const src = read('ContentSendersSettings.tsx');
expect(src).toMatch(/await removeTrustedSenderWithUndo\(row, \{/);
expect(src).not.toMatch(/sync_remove_failed/);
expect(src).toMatch(/<ToastHost \/>\s*<\/Modal>/);
});
});
+7 -4
View File
@@ -13,6 +13,7 @@ import {
getFaviconUrl,
} from './avatar-utils';
import { singleLine } from './single-line';
import { cleanPreview } from './preview-text';
import {
lastNotifiedKey,
migrateLegacyPushKeys,
@@ -67,13 +68,14 @@ async function emailNotificationsAllowed(): Promise<boolean> {
// The "Sender Favicons" setting covers notification icons as well as the
// in-app avatars: with it off the tray shows initials, and the sender's domain
// and the device's IP never reach the icon service. Off when unreadable.
// and the device's IP never reach the icon service. Off by default, like the
// store, and off when unreadable.
export async function senderFaviconsAllowed(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
if (!raw) return true; // first launch: the store's default is on
if (!raw) return false; // first launch: the store's default is off
const parsed = JSON.parse(raw) as PushPersistedSettings;
return parsed.senderFavicons !== false;
return parsed.senderFavicons === true;
} catch {
return false;
}
@@ -583,7 +585,8 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
const faviconDomain = favicons ? getFaviconDomain(address) : null;
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined;
const preview = email.preview ? singleLine(email.preview) : undefined;
// No leading style sheet or preheader padding (webmail #1136).
const preview = singleLine(cleanPreview(email.preview)) || undefined;
const markReadLabel = translate(locale, 'email_viewer.mark_read', 'Mark as read');
const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete');
const replyLabel = translate(locale, 'email_viewer.reply', 'Reply');
+64
View File
@@ -0,0 +1,64 @@
import type { RecipientSuggestion } from '../stores/contacts-store';
// The composer's recipient suggestions, in the webmail's order
// (stores/contact-store.ts getAutocomplete, email-composer.tsx
// handleServerSearch): contacts and groups, then people in the company
// directory (RFC 9670 principals), then recent recipients from the Sent
// folder, then what an on-demand search of the Sent folder found. An address
// shows once, first where it comes up; a directory entry lends its name to a
// contact suggestion that has none.
export interface SuggestionSources {
/** getAutocomplete's answer: contacts and groups first, then recent recipients. */
local: RecipientSuggestion[];
/** Whether a local suggestion is a contact or a group (the rest are recent recipients). */
isContact: (s: RecipientSuggestion) => boolean;
/** lookupDirectory's answer. */
directory: RecipientSuggestion[];
/** searchRecipients' answer for the same query, once asked for. */
server: RecipientSuggestion[];
/** Addresses already on the message (lowercase); groups are always offered. */
exclude: ReadonlySet<string>;
/** How many contact, directory and recent suggestions to show. Server hits come on top. */
limit: number;
}
export function mergeRecipientSuggestions(src: SuggestionSources): RecipientSuggestion[] {
const out: RecipientSuggestion[] = [];
const seen = new Map<string, RecipientSuggestion>();
const offer = (s: RecipientSuggestion): boolean => {
if (s.group) {
out.push(s);
return true;
}
const key = s.email.trim().toLowerCase();
if (!key || seen.has(key) || src.exclude.has(key)) return false;
const copy = { ...s };
seen.set(key, copy);
out.push(copy);
return true;
};
const contacts = src.local.filter((s) => src.isContact(s));
const recent = src.local.filter((s) => !src.isContact(s));
for (const s of contacts) offer(s);
for (const s of src.directory) {
const known = seen.get(s.email.trim().toLowerCase());
if (known) {
if (!known.name && s.name) known.name = s.name;
continue;
}
offer(s);
}
for (const s of recent) offer(s);
const shown = out.slice(0, src.limit);
// What fell past the limit may come back from the server search.
const shownKeys = new Set(shown.filter((s) => !s.group).map((s) => s.email.trim().toLowerCase()));
for (const s of src.server) {
const key = s.email.trim().toLowerCase();
if (!key || shownKeys.has(key) || src.exclude.has(key)) continue;
shownKeys.add(key);
shown.push({ ...s });
}
return shown;
}
+20 -2
View File
@@ -4,6 +4,7 @@
// splitPastedRecipients, expandRecipients). All DOM-free.
import { splitMailbox } from './rfc5322-mailbox';
import { toAsciiEmail } from './idn';
/**
* A composer recipient. Display name is optional; email is required - except
@@ -19,14 +20,22 @@ export interface Recipient {
/**
* RFC 5322 compliant email validation with security enhancements.
*
* A domain written in Unicode (`anna@bücher.de`) counts when its ASCII form
* (`anna@xn--bcher-kva.de`) is valid; {@link outgoingAddress} sends that form
* (webmail #1100). The local part stays ASCII-only.
*/
export function isValidEmail(email: string): boolean {
// Length check
if (!email || email.length > 254) return false;
if (!email) return false;
// Security: Block control characters and header injection
if (/[\r\n\0<>]/.test(email)) return false;
email = toAsciiEmail(email);
// Length check
if (email.length > 254) return false;
// RFC 5322 compliant regex (simplified but secure)
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
@@ -46,6 +55,15 @@ export function isValidEmail(email: string): boolean {
return true;
}
/**
* A recipient as it goes into the message: the address with its domain in
* ASCII (xn--) form, so a domain typed in Unicode reaches every server.
*/
export function outgoingAddress(r: { name?: string; email: string }): { name?: string; email: string } {
const email = toAsciiEmail(r.email);
return r.name ? { name: r.name, email } : { email };
}
/**
* True when the angle run that is open at `from` closes before the next one
* opens. `from` is the index of the character under test (a separator, or the
+1
View File
@@ -107,6 +107,7 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
'settings.email_behavior.clear_search_on_folder_change',
],
composing: [
'settings.email_behavior.quick_reply',
'settings.email_behavior.auto_select_reply_identity',
'settings.email_behavior.reply_identity_match',
'settings.email_behavior.plain_text_mode',
+101
View File
@@ -0,0 +1,101 @@
// Deleting things in Settings that can be put back as they were: they go at
// once, with an Undo in the toast, instead of behind a confirm (webmail R3:
// filter-settings.tsx handleDelete, keyword-settings.tsx handleDelete,
// trusted-senders-modal.tsx handleRemoveSender).
import { toast } from '../stores/toast-store';
import { useFilterStore } from '../stores/filter-store';
import { useKeywordsStore } from '../stores/keywords-store';
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
/** How long a toast offering Undo stays (webmail lib/email-action-toast.ts). */
export const UNDO_TOAST_DURATION = 8000;
/** Deletes a filter rule on the server at once; the toast's Undo saves it back. */
export async function deleteFilterRuleWithUndo(ruleId: string, t: Translate): Promise<void> {
const failed = () => toast.error(t('notifications.filters_save_failed', 'Failed to save filters'));
let undo: (() => Promise<boolean>) | null;
try {
undo = await useFilterStore.getState().deleteRuleNow(ruleId);
} catch {
failed();
return;
}
if (!undo) return;
const putBack = undo;
toast.success(t('notifications.filters_deleted', 'Filter rule deleted'), {
action: {
label: t('notifications.rule_undo', 'Undo'),
onPress: () => { void putBack().catch(failed); },
},
});
}
/** Deletes a tag at once; the toast's Undo puts it back where it was. */
export function deleteKeywordWithUndo(id: string, t: Translate): void {
const before = useKeywordsStore.getState().keywords;
const index = before.findIndex((k) => k.id === id);
if (index < 0) return;
const removed = before[index];
useKeywordsStore.getState().remove(id);
toast.success(t('settings.keywords.deleted', 'Tag deleted'), {
action: {
label: t('notifications.rule_undo', 'Undo'),
onPress: () => {
// Added again meanwhile (same id): nothing to put back.
if (useKeywordsStore.getState().keywords.some((k) => k.id === id)) return;
useKeywordsStore.getState().insert(removed, index);
},
},
});
}
export interface TrustedSenderRemoval {
email: string;
/** On the list kept in the settings. */
local: boolean;
/** In the synced "Trusted Senders" address book. */
synced: boolean;
}
export interface TrustedSenderStores {
removeLocal: (email: string) => void;
addLocal: (email: string) => void;
removeFromBook: (email: string) => Promise<void>;
addToBook: (email: string) => Promise<void>;
}
/**
* Takes a sender off the trusted list (the settings list and the synced
* address book, wherever it is); the toast's Undo adds it back to the same
* places. Nothing changes when the address book can't be written.
*/
export async function removeTrustedSenderWithUndo(
row: TrustedSenderRemoval,
stores: TrustedSenderStores,
t: Translate,
): Promise<void> {
try {
if (row.synced) await stores.removeFromBook(row.email);
} catch {
toast.error(t('settings.email_behavior.trusted_senders.remove_error', "Couldn't remove the sender"));
return;
}
if (row.local) stores.removeLocal(row.email);
toast.success(t('settings.email_behavior.trusted_senders.removed', 'Removed from trusted senders'), {
message: row.email,
duration: UNDO_TOAST_DURATION,
action: {
label: t('notifications.undo', 'Undo'),
onPress: () => {
if (row.local) stores.addLocal(row.email);
if (row.synced) {
void stores.addToBook(row.email).catch(() => {
toast.error(t('settings.email_behavior.trusted_senders.save_error', 'Failed to save - check the Contacts debug log for details'));
});
}
},
},
});
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { useAuthStore } from '../stores/auth-store';
import { useSettingsStore } from '../stores/settings-store';
import { toUnicodeEmailOnOwnDomains } from './idn';
/**
* Formats an address from a message for display: on one of the signed-in
* user's own IDN domains it reads as written (`bücher.de`), anywhere else it
* stays as the message has it (see toUnicodeEmailOnOwnDomains; webmail
* hooks/use-own-domain-address.ts, #1100).
*/
export function useOwnDomainAddress(): (address: string) => string {
const username = useAuthStore((s) => s.username);
const identities = useSettingsStore((s) => s.identities);
const ownAddresses = React.useMemo(
() => [username, ...identities.map((i) => i.email)],
[username, identities],
);
return React.useCallback(
(address: string) => toUnicodeEmailOnOwnDomains(address, ownAddresses),
[ownAddresses],
);
}
+102 -13
View File
@@ -11,7 +11,7 @@ import {
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
Users, Tag, Type, Highlighter, Trash2,
Users, Tag, Type, Highlighter, Trash2, Search,
} from '../components/icons';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker';
@@ -60,8 +60,10 @@ import {
import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format';
import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header';
import {
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient,
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient,
} from '../lib/recipients';
import { toUnicodeEmail } from '../lib/idn';
import { mergeRecipientSuggestions } from '../lib/recipient-suggestions';
import {
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
} from '../lib/reply-identity';
@@ -188,8 +190,9 @@ function fromParsed(r: ParsedRecipient): Recipient {
return { name: r.name ?? '', email: r.email, group: r.group };
}
// A domain typed in Unicode goes out in its ASCII form (webmail #1100).
function toAddress(r: { name?: string; email: string }): EmailAddress {
return r.name ? { name: r.name, email: r.email } : { email: r.email };
return outgoingAddress(r);
}
function chipIsValid(r: Recipient): boolean {
@@ -248,17 +251,21 @@ function initialsOf(name: string, email: string): string {
}
function SuggestionList({
suggestions, onPick, onPressIn,
suggestions, onPick, onPressIn, onSearchServer, searchingServer,
}: {
suggestions: RecipientSuggestion[];
onPick: (s: RecipientSuggestion) => void;
onPressIn?: () => void;
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
onSearchServer?: () => void;
searchingServer?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<View style={styles.suggestionBox}>
<ScrollView style={styles.suggestionScroll} keyboardShouldPersistTaps="always" nestedScrollEnabled>
{suggestions.map((s, i) => (
<Pressable
key={`${s.group?.id ?? s.email}-${i}`}
@@ -285,6 +292,32 @@ function SuggestionList({
</View>
</Pressable>
))}
{onSearchServer && (
<Pressable
onPressIn={onPressIn}
onPress={() => { if (!searchingServer) onSearchServer(); }}
disabled={searchingServer}
accessibilityRole="button"
accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }}
style={({ pressed }) => [
styles.suggestionRow,
suggestions.length > 0 && styles.suggestionSearchRowDivided,
pressed && styles.suggestionRowPressed,
]}
>
<View style={styles.suggestionSearchIcon}>
{searchingServer
? <ActivityIndicator size="small" color={c.textSecondary} />
: <Search size={16} color={c.textSecondary} />}
</View>
<Text style={styles.suggestionSearchText} numberOfLines={1}>
{searchingServer
? t('email_composer.autocomplete_searching', 'Searching...')
: t('email_composer.autocomplete_search_server', 'Search the server')}
</Text>
</Pressable>
)}
</ScrollView>
</View>
);
}
@@ -738,6 +771,22 @@ export default function ComposeScreen({ route, navigation }: Props) {
const loadRecentRecipients = useContactsStore((s) => s.loadRecentRecipients);
const contactsVersion = useContactsStore((s) => s.contacts);
const recentVersion = useContactsStore((s) => s.recentRecipients);
const findContactByEmail = useContactsStore((s) => s.findContactByEmail);
const lookupDirectory = useContactsStore((s) => s.lookupDirectory);
const directoryVersion = useContactsStore((s) => s.directory);
const directoryStatus = useContactsStore((s) => s.directoryStatus);
const fetchDirectory = useContactsStore((s) => s.fetchDirectory);
const searchRecipients = useContactsStore((s) => s.searchRecipients);
// A known Sent folder makes the on-demand server search worth offering.
const canSearchServer = useContactsStore((s) => s.sentMailboxId != null);
const [serverHits, setServerHits] = React.useState<{ query: string; results: RecipientSuggestion[] } | null>(null);
const [searchingServer, setSearchingServer] = React.useState(false);
// People in the company directory are suggested too (webmail loads the
// directory at sign-in); the store keeps one load per login.
React.useEffect(() => {
if (directoryStatus === 'idle' && !loginMovedFrom(ownerLoginRef.current)) void fetchDirectory();
}, [directoryStatus, fetchDirectory]);
React.useEffect(() => {
if (sentMailbox?.id) void loadRecentRecipients(sentMailbox.id);
@@ -757,12 +806,32 @@ export default function ComposeScreen({ route, navigation }: Props) {
const suggestions = React.useMemo<RecipientSuggestion[]>(() => {
const q = suggestionQuery.trim();
if (q.length < 1) return [];
return getAutocomplete(q, 16)
.filter((s) => s.group || !alreadySelected.has(s.email.toLowerCase()))
.slice(0, 8);
// contactsVersion/recentVersion re-run the lookup when the store loads.
return mergeRecipientSuggestions({
local: getAutocomplete(q, 16),
isContact: (s) => !!s.group || !!findContactByEmail(s.email),
directory: lookupDirectory(q, 16),
server: serverHits?.query === q ? serverHits.results : [],
exclude: alreadySelected,
limit: 8,
});
// contactsVersion/recentVersion/directoryVersion re-run the lookup when the store loads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [suggestionQuery, alreadySelected, getAutocomplete, contactsVersion, recentVersion]);
}, [suggestionQuery, alreadySelected, getAutocomplete, findContactByEmail, lookupDirectory, serverHits, contactsVersion, recentVersion, directoryVersion]);
// On demand: search the Sent folder on the server for recipients matching
// what is typed, and add new ones below the suggestions (webmail
// handleServerSearch). Only for the login the message is written in.
const searchServer = async () => {
const q = suggestionQuery.trim();
if (!q || searchingServer || loginMovedFrom(ownerLoginRef.current)) return;
setSearchingServer(true);
try {
const results = await searchRecipients(q);
if (!loginMovedFrom(ownerLoginRef.current)) setServerHits({ query: q, results });
} finally {
setSearchingServer(false);
}
};
const setterFor = (field: Field) =>
field === 'to' ? setToRecipients : field === 'cc' ? setCcRecipients : setBccRecipients;
@@ -2606,7 +2675,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
const fromDisplay = React.useMemo(() => {
if (!primaryIdentity) return identityError ? t('email_composer.identity_unavailable', 'Identity unavailable') : t('common.loading', 'Loading...');
const { from } = senderAddress(primaryIdentity);
return from.name ? `${from.name} <${from.email}>` : from.email;
// An IDN domain arrives in its xn-- form; show it as written (#1100).
const email = toUnicodeEmail(from.email);
return from.name ? `${from.name} <${email}>` : email;
}, [primaryIdentity, identityError, senderAddress, t]);
const draftStatusText =
@@ -2637,6 +2708,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
// No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field.
disableFullscreenUI
accessibilityLabel={label}
style={[styles.recipientInput, invalidInput && styles.recipientInputInvalid]}
placeholder={placeholder}
placeholderTextColor={c.textMuted}
@@ -2681,13 +2753,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
textContentType="none"
/>
</View>
{activeField === field && suggestions.length > 0 && (
{activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
<SuggestionList
suggestions={suggestions}
onPick={pickSuggestion}
onPressIn={() => {
isPickingSuggestion.current = true;
}}
onSearchServer={canSearchServer ? () => {
// No pick: a later blur turns the typed text into a chip again.
setTimeout(() => { isPickingSuggestion.current = false; }, 250);
void searchServer();
} : undefined}
searchingServer={searchingServer}
/>
)}
{field === 'to' && !ccVisible && (
@@ -2823,6 +2901,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput
ref={subjectRef}
disableFullscreenUI
accessibilityLabel={t('email_composer.subject', 'Subject')}
style={styles.subjectInput}
placeholder={t('email_composer.subject_placeholder', 'Subject')}
placeholderTextColor={c.textMuted}
@@ -2859,6 +2938,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput
ref={plainBodyRef}
disableFullscreenUI
accessibilityLabel={t('email_composer.body_label', 'Message')}
style={styles.plainEditor}
multiline
value={plainBody}
@@ -3021,6 +3101,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
{t('email_composer.link_url_prompt', 'Enter the URL')}
</Text>
<TextInput
accessibilityLabel={t('email_composer.link_url_prompt', 'Enter the URL')}
style={styles.modalInput}
value={linkPromptValue}
onChangeText={setLinkPromptValue}
@@ -3268,6 +3349,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
{t(`templates.placeholders.${name}`, name.replace(/_/g, ' '))}
</Text>
<TextInput
accessibilityLabel={t(`templates.placeholders.${name}`, name.replace(/_/g, ' '))}
style={styles.modalInput}
value={placeholderPrompt?.values[name] ?? ''}
onChangeText={(v) => setPlaceholderPrompt((p) => (p ? { ...p, values: { ...p.values, [name]: v } } : p))}
@@ -3315,6 +3397,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<>
<Text style={styles.modalLabel}>{t('identities.sub_address.popover_title', 'Add Sub-Address Tag')}</Text>
<TextInput
accessibilityLabel={t('identities.sub_address.popover_title', 'Add Sub-Address Tag')}
style={styles.modalInput}
value={tagDraft}
onChangeText={setTagDraft}
@@ -3341,9 +3424,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
{!!primaryIdentity && (
<Text style={styles.modalLabel} numberOfLines={1}>
{t('identities.sub_address.preview_label', 'Preview:')} {tagDraft && !tagError
{t('identities.sub_address.preview_label', 'Preview:')} {toUnicodeEmail(tagDraft && !tagError
? generateSubAddress(primaryIdentity.email, tagDraft, subAddressDelimiter)
: primaryIdentity.email}
: primaryIdentity.email)}
</Text>
)}
</>
@@ -3360,6 +3443,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<>
<Text style={styles.modalLabel}>{t('email_composer.from_override.toggle_tooltip', 'Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.')}</Text>
<TextInput
accessibilityLabel={t('email_composer.from_override.name_label', 'From name')}
style={styles.modalInput}
value={overrideName}
onChangeText={setOverrideName}
@@ -3367,6 +3451,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
placeholderTextColor={c.textMuted}
/>
<TextInput
accessibilityLabel={t('email_composer.from_override.email_label', 'From email address')}
style={styles.modalInput}
value={overrideEmail}
onChangeText={setOverrideEmail}
@@ -3550,6 +3635,10 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm,
},
suggestionRowPressed: { backgroundColor: c.surfaceHover },
suggestionScroll: { maxHeight: 320 },
suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight },
suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 },
suggestionAvatar: {
width: 32, height: 32,
borderRadius: 16,
+9
View File
@@ -67,6 +67,8 @@ interface KeywordsState {
add: (kw: KeywordDef) => void;
update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void;
remove: (id: string) => void;
/** Puts a tag back at `index` (the Undo after deleting it). */
insert: (kw: KeywordDef, index: number) => void;
resetDefaults: () => void;
}
@@ -167,6 +169,13 @@ export const useKeywordsStore = create<KeywordsState>((set, get) => ({
persist(next);
},
insert: (kw, index) => {
const next = [...get().keywords];
next.splice(Math.min(Math.max(index, 0), next.length), 0, kw);
set({ keywords: next });
persist(next);
},
resetDefaults: () => {
set({ keywords: DEFAULT_KEYWORDS });
persist(DEFAULT_KEYWORDS);