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 ### 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. - 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 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`). - 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`. - 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. - 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`). - 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. - 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 - [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`. - 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`. - 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 ### 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`). - 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. - 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 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`). - 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. - 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`). - 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. - 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 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. - 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. - Fix hint: port accountRowText.
+12
View File
@@ -37,6 +37,7 @@ const MAP = {
ArrowUpDown: 'arrows-sort', ArrowUpDown: 'arrows-sort',
ArrowUpNarrowWide: 'sort-ascending', ArrowUpNarrowWide: 'sort-ascending',
Ban: 'ban', Ban: 'ban',
Bed: 'bed',
Bell: 'bell', Bell: 'bell',
BellOff: 'bell-off', BellOff: 'bell-off',
BellRing: 'bell-ringing', BellRing: 'bell-ringing',
@@ -46,11 +47,15 @@ const MAP = {
BookUser: 'address-book', BookUser: 'address-book',
Bug: 'bug', Bug: 'bug',
Building: 'building', Building: 'building',
Bus: 'bus',
Cake: 'cake', Cake: 'cake',
Calendar: 'calendar', Calendar: 'calendar',
CalendarCheck: 'calendar-check',
CalendarDays: 'calendar-month', CalendarDays: 'calendar-month',
CalendarPlus: 'calendar-plus', CalendarPlus: 'calendar-plus',
CalendarX: 'calendar-x',
Camera: 'camera', Camera: 'camera',
Car: 'car',
Check: 'check', Check: 'check',
CheckCheck: 'checks', CheckCheck: 'checks',
CheckCircle: 'circle-check', CheckCircle: 'circle-check',
@@ -139,6 +144,7 @@ const MAP = {
MoreHorizontal: 'dots', MoreHorizontal: 'dots',
MoreVertical: 'dots-vertical', MoreVertical: 'dots-vertical',
MoveVertical: 'arrows-vertical', MoveVertical: 'arrows-vertical',
Package: 'package',
Palette: 'palette', Palette: 'palette',
Palmtree: 'beach', Palmtree: 'beach',
PanelLeftClose: 'layout-sidebar-left-collapse', PanelLeftClose: 'layout-sidebar-left-collapse',
@@ -150,6 +156,7 @@ const MAP = {
Phone: 'phone', Phone: 'phone',
Pin: 'pinned', Pin: 'pinned',
PinOff: 'pinned-off', PinOff: 'pinned-off',
Plane: 'plane',
Plus: 'plus', Plus: 'plus',
Puzzle: 'puzzle', Puzzle: 'puzzle',
QrCode: 'qrcode', QrCode: 'qrcode',
@@ -160,6 +167,7 @@ const MAP = {
Repeat: 'repeat', Repeat: 'repeat',
Reply: 'arrow-back-up', Reply: 'arrow-back-up',
ReplyAll: 'arrow-back-up-double', ReplyAll: 'arrow-back-up-double',
Restaurant: 'tools-kitchen-2',
RotateCcw: 'rotate', RotateCcw: 'rotate',
Rows2: 'layout-rows', Rows2: 'layout-rows',
Rss: 'rss', Rss: 'rss',
@@ -175,6 +183,7 @@ const MAP = {
ShieldAlert: 'shield-exclamation', ShieldAlert: 'shield-exclamation',
ShieldCheck: 'shield-check', ShieldCheck: 'shield-check',
ShieldQuestion: 'shield-question', ShieldQuestion: 'shield-question',
ShoppingBag: 'shopping-bag',
Shuffle: 'arrows-shuffle', Shuffle: 'arrows-shuffle',
Smartphone: 'device-mobile', Smartphone: 'device-mobile',
Square: 'square', Square: 'square',
@@ -188,8 +197,11 @@ const MAP = {
Tag: 'tag', Tag: 'tag',
Tags: 'tags', Tags: 'tags',
Terminal: 'terminal', Terminal: 'terminal',
Ticket: 'ticket',
Train: 'train',
Trash: 'trash', Trash: 'trash',
Trash2: 'trash', Trash2: 'trash',
Truck: 'truck',
Type: 'typography', Type: 'typography',
Underline: 'underline', Underline: 'underline',
Undo2: 'arrow-back', Undo2: 'arrow-back',
@@ -38,6 +38,8 @@ import { htmlToPlainText } from '../../lib/compose-html';
import { buildEmbeddedSignatureHtml, spliceSignature } from '../../lib/signature-utils'; import { buildEmbeddedSignatureHtml, spliceSignature } from '../../lib/signature-utils';
import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files'; import { attachmentFromFileNode, saveAttachmentToFiles } from '../../lib/compose-files';
import { servedLoginOf } from '../../lib/draft-login'; 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 { useAccountStore } from '../../stores/account-store';
import { useTemplatesStore } from '../../stores/templates-store'; import { useTemplatesStore } from '../../stores/templates-store';
@@ -315,6 +317,44 @@ live('live Stalwart: composer and sending (0.16 and 1.0)', () => {
}, 60_000); }, 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)', () => { 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 () => { 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 // The composer's body after a From switch with text typed under the
@@ -45,6 +45,8 @@ import {
import { updateAccountFilters } from '../sieve-rules'; import { updateAccountFilters } from '../sieve-rules';
import { useContactsStore } from '../../stores/contacts-store'; import { useContactsStore } from '../../stores/contacts-store';
import { readVacationFilters, useFilterStore } from '../../stores/filter-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 { generateVCard, parseVCard } from '../../lib/vcard';
import { generateScript } from '../../lib/sieve/generator'; import { generateScript } from '../../lib/sieve/generator';
import { parseScript } from '../../lib/sieve/parser'; 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 }); expect({ file, ...(await validateSieveScript(content)) }).toEqual({ file, isValid: true });
} }
}, 60_000); }, 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 { useLocaleStore } from '../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
import { useSheetInsets } from '../lib/use-layout'; import { useSheetInsets } from '../lib/use-layout';
import { toUnicodeEmail } from '../lib/idn';
interface IdentitySheetProps { interface IdentitySheetProps {
visible: boolean; visible: boolean;
@@ -97,7 +98,7 @@ export function IdentitySheet({
</Text> </Text>
)} )}
<Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}> <Text style={[styles.identityEmail, !identity.name && styles.identityEmailOnly]} numberOfLines={1}>
{identity.email} {toUnicodeEmail(identity.email)}
</Text> </Text>
</View> </View>
{isSelected && ( {isSelected && (
+10 -6
View File
@@ -21,7 +21,8 @@ import { useAccountStore } from '../stores/account-store';
import { useSettingsStore } from '../stores/settings-store'; import { useSettingsStore } from '../stores/settings-store';
import { useKeywordsStore, keywordToken } from '../stores/keywords-store'; import { useKeywordsStore, keywordToken } from '../stores/keywords-store';
import { useLocaleStore } from '../stores/locale-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 { import {
buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode, buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode,
} from '../lib/mailbox-tree'; } from '../lib/mailbox-tree';
@@ -290,6 +291,7 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
<Text style={styles.sheetTitle}>{title}</Text> <Text style={styles.sheetTitle}>{title}</Text>
{message ? <Text style={styles.promptMessage}>{message}</Text> : null} {message ? <Text style={styles.promptMessage}>{message}</Text> : null}
<TextInput <TextInput
accessibilityLabel={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
value={value} value={value}
onChangeText={setValue} onChangeText={setValue}
placeholder={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')} 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; const isActive = acc.id === activeAccountId;
let accHost = ''; let accHost = '';
try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; } try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; }
const accEmail = acc.email || (acc.username.includes('@') // The address leads when another account has the same name
? acc.username // (webmail 3bb9f7df8).
: accHost ? `${acc.username}@${accHost}` : acc.username); const rowText = accountRowText(acc, accounts);
return ( return (
<Pressable <Pressable
key={acc.id} key={acc.id}
@@ -893,13 +895,15 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
<View style={styles.accountMenuInfo}> <View style={styles.accountMenuInfo}>
<View style={styles.accountMenuNameRow}> <View style={styles.accountMenuNameRow}>
<Text style={styles.accountMenuName} numberOfLines={1}> <Text style={styles.accountMenuName} numberOfLines={1}>
{acc.displayName || acc.username} {toUnicodeEmail(rowText.title)}
</Text> </Text>
{acc.isDefault && ( {acc.isDefault && (
<Star size={12} color="#f59e0b" fill="#f59e0b" /> <Star size={12} color="#f59e0b" fill="#f59e0b" />
)} )}
</View> </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}> <View style={styles.accountMenuStatusRow}>
{acc.hasError ? ( {acc.hasError ? (
<> <>
+1
View File
@@ -149,6 +149,7 @@ export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps)
</View> </View>
{total > 4 && ( {total > 4 && (
<TextInput <TextInput
accessibilityLabel={t('templates.search_placeholder', 'Search templates...')}
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
placeholder={t('templates.search_placeholder', 'Search templates...')} 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 { resolveEmailTags } from '../../lib/thread-utils';
import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code'; import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code';
import { VerificationCodeChip } from './VerificationCodeChip'; import { VerificationCodeChip } from './VerificationCodeChip';
import { useOwnDomainAddress } from '../../lib/use-own-domain-address';
interface Props { interface Props {
email: Email; email: Email;
@@ -109,6 +110,9 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
const info = React.useMemo(() => headerInfo ?? deriveHeaderInfo(email), [headerInfo, email]); const info = React.useMemo(() => headerInfo ?? deriveHeaderInfo(email), [headerInfo, email]);
const from = email.from?.[0]; 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 starred = !!email.keywords?.$flagged;
const displayDate = emailDisplayDate(email); const displayDate = emailDisplayDate(email);
const spoofed = isAuthenticationSpoofed(info.auth); const spoofed = isAuthenticationSpoofed(info.auth);
@@ -170,7 +174,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
onPress={from ? () => onAddressPress(from) : undefined} onPress={from ? () => onAddressPress(from) : undefined}
hitSlop={4} hitSlop={4}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')} accessibilityLabel={from?.name || fromEmail || t('email_viewer.unknown_sender', 'Unknown')}
> >
<SenderAvatar <SenderAvatar
name={from?.name} name={from?.name}
@@ -182,10 +186,10 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
<View style={styles.info}> <View style={styles.info}>
<Pressable onPress={from ? () => onAddressPress(from) : undefined}> <Pressable onPress={from ? () => onAddressPress(from) : undefined}>
<Text style={styles.name} numberOfLines={1}> <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> </Text>
{from?.name && from?.email ? ( {from?.name && fromEmail ? (
<Text style={styles.email} numberOfLines={1}>{from.email}</Text> <Text style={styles.email} numberOfLines={1}>{fromEmail}</Text>
) : null} ) : null}
</Pressable> </Pressable>
{verificationText && ( {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 // No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field. // and the text around the field.
disableFullscreenUI disableFullscreenUI
accessibilityLabel={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
value={text} value={text}
onChangeText={setText} onChangeText={setText}
onFocus={onFocus} onFocus={onFocus}
+4 -1
View File
@@ -12,6 +12,7 @@ import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/
import { singleLine } from '../../lib/single-line'; import { singleLine } from '../../lib/single-line';
import { cleanPreview } from '../../lib/preview-text'; import { cleanPreview } from '../../lib/preview-text';
import { useSenderWarning } from './use-sender-warning'; import { useSenderWarning } from './use-sender-warning';
import { useOwnDomainAddress } from '../../lib/use-own-domain-address';
interface Props extends MessageContentProps { interface Props extends MessageContentProps {
expanded: boolean; expanded: boolean;
@@ -46,6 +47,8 @@ export function ThreadMessageCard({
const starred = !!email.keywords?.$flagged; const starred = !!email.keywords?.$flagged;
const date = emailDisplayDate(email); const date = emailDisplayDate(email);
const senderWarning = useSenderWarning(expanded ? undefined : 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) { if (!expanded) {
return ( return (
@@ -54,7 +57,7 @@ export function ThreadMessageCard({
<View style={styles.collapsedInfo}> <View style={styles.collapsedInfo}>
<View style={styles.collapsedTop}> <View style={styles.collapsedTop}>
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}> <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> </Text>
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />} {email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text> <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 { useLocaleStore } from '../../../stores/locale-store';
import { useColors } from '../../../theme/colors'; import { useColors } from '../../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens'; import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
import { ArrowRight, CalendarDays, CalendarPlus, ChevronDown } from '../../icons'; import { ArrowRight, CalendarCheck, CalendarDays, CalendarPlus, CalendarX, ChevronDown } from '../../icons';
import { CalendarCheck, CalendarX } from './icons';
import { useBookingCalendar } from './use-booking-calendar'; import { useBookingCalendar } from './use-booking-calendar';
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
@@ -4,7 +4,7 @@
// points the reading direction, where the webmail draws arrow icons. // points the reading direction, where the webmail draws arrow icons.
import React from 'react'; import React from 'react';
import { I18nManager, Text, View } from 'react-native'; 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 { import {
formatAddress, formatAddress,
formatMomentDate, formatMomentDate,
@@ -46,7 +46,6 @@ import {
useCardStyles, useCardStyles,
} from './card-parts'; } from './card-parts';
import { CopyValueChip } from './copy-value-chip'; import { CopyValueChip } from './copy-value-chip';
import { Bed, Bus, Car, Plane, Restaurant, Ticket, Train } from './icons';
export interface BookingCardProps<B extends Booking = Booking> { export interface BookingCardProps<B extends Booking = Booking> {
booking: B; 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). // b0782af2d (same rows, React Native views).
import React from 'react'; import React from 'react';
import { Image, Text, View } from 'react-native'; 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 { formatMomentDate, formatMoney, isWebLink, shipmentItems, summarizeItems } from '../../../lib/structured-mail/format';
import type { Item, Link, OrderStatus, Parcel, Shipment } from '../../../lib/structured-mail/types'; import type { Item, Link, OrderStatus, Parcel, Shipment } from '../../../lib/structured-mail/types';
import { useLocaleStore } from '../../../stores/locale-store'; import { useLocaleStore } from '../../../stores/locale-store';
import { CardLink, CardShell, MetaRow, Pill, useCardFormat, useCardStyles } from './card-parts'; import { CardLink, CardShell, MetaRow, Pill, useCardFormat, useCardStyles } from './card-parts';
import { CopyValueChip } from './copy-value-chip'; import { CopyValueChip } from './copy-value-chip';
import { Package, ShoppingBag, Truck } from './icons';
const STATUS_TONE: Record<OrderStatus, 'error' | 'warning' | 'success' | 'info' | 'muted'> = { const STATUS_TONE: Record<OrderStatus, 'error' | 'warning' | 'success' | 'info' | 'muted'> = {
payment_due: 'warning', 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"], '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"], '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"], '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': ["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-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"], '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"], '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"], '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"], '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"], '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': ["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-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-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"], '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"], 'check': ["M5 12l5 5l10 -10"],
'checks': ["M7 12l5 5l10 -10","M2 12l5 5m5 -5l5 -5"], 'checks': ["M7 12l5 5l10 -10","M2 12l5 5m5 -5l5 -5"],
'chevron-down': ["M6 9l6 6l6 -6"], 'chevron-down': ["M6 9l6 6l6 -6"],
@@ -121,6 +126,7 @@ const G = {
'minus': ["M5 12l14 0"], '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"], '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"], '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"], '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"], '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"], '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"], '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': ["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"], '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"], '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"], '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"], '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-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-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"], '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-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"], '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"], '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"], '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"], '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"], '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"], '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"], '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"], '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"], '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 ArrowUpDown = makeIcon('ArrowUpDown', G['arrows-sort']);
export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']); export const ArrowUpNarrowWide = makeIcon('ArrowUpNarrowWide', G['sort-ascending']);
export const Ban = makeIcon('Ban', G['ban']); export const Ban = makeIcon('Ban', G['ban']);
export const Bed = makeIcon('Bed', G['bed']);
export const Bell = makeIcon('Bell', G['bell']); export const Bell = makeIcon('Bell', G['bell']);
export const BellOff = makeIcon('BellOff', G['bell-off']); export const BellOff = makeIcon('BellOff', G['bell-off']);
export const BellRing = makeIcon('BellRing', G['bell-ringing']); 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 BookUser = makeIcon('BookUser', G['address-book']);
export const Bug = makeIcon('Bug', G['bug']); export const Bug = makeIcon('Bug', G['bug']);
export const Building = makeIcon('Building', G['building']); export const Building = makeIcon('Building', G['building']);
export const Bus = makeIcon('Bus', G['bus']);
export const Cake = makeIcon('Cake', G['cake']); export const Cake = makeIcon('Cake', G['cake']);
export const Calendar = makeIcon('Calendar', G['calendar']); 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 CalendarDays = makeIcon('CalendarDays', G['calendar-month']);
export const CalendarPlus = makeIcon('CalendarPlus', G['calendar-plus']); 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 Camera = makeIcon('Camera', G['camera']);
export const Car = makeIcon('Car', G['car']);
export const Check = makeIcon('Check', G['check']); export const Check = makeIcon('Check', G['check']);
export const CheckCheck = makeIcon('CheckCheck', G['checks']); export const CheckCheck = makeIcon('CheckCheck', G['checks']);
export const CheckCircle = makeIcon('CheckCircle', G['circle-check']); 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 MoreHorizontal = makeIcon('MoreHorizontal', G['dots']);
export const MoreVertical = makeIcon('MoreVertical', G['dots-vertical']); export const MoreVertical = makeIcon('MoreVertical', G['dots-vertical']);
export const MoveVertical = makeIcon('MoveVertical', G['arrows-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 Palette = makeIcon('Palette', G['palette']);
export const Palmtree = makeIcon('Palmtree', G['beach']); export const Palmtree = makeIcon('Palmtree', G['beach']);
export const PanelLeftClose = makeIcon('PanelLeftClose', G['layout-sidebar-left-collapse']); 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 Phone = makeIcon('Phone', G['phone']);
export const Pin = makeIcon('Pin', G['pinned']); export const Pin = makeIcon('Pin', G['pinned']);
export const PinOff = makeIcon('PinOff', G['pinned-off']); export const PinOff = makeIcon('PinOff', G['pinned-off']);
export const Plane = makeIcon('Plane', G['plane']);
export const Plus = makeIcon('Plus', G['plus']); export const Plus = makeIcon('Plus', G['plus']);
export const Puzzle = makeIcon('Puzzle', G['puzzle']); export const Puzzle = makeIcon('Puzzle', G['puzzle']);
export const QrCode = makeIcon('QrCode', G['qrcode']); 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 Repeat = makeIcon('Repeat', G['repeat']);
export const Reply = makeIcon('Reply', G['arrow-back-up']); export const Reply = makeIcon('Reply', G['arrow-back-up']);
export const ReplyAll = makeIcon('ReplyAll', G['arrow-back-up-double']); 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 RotateCcw = makeIcon('RotateCcw', G['rotate']);
export const Rows2 = makeIcon('Rows2', G['layout-rows']); export const Rows2 = makeIcon('Rows2', G['layout-rows']);
export const Rss = makeIcon('Rss', G['rss']); 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 ShieldAlert = makeIcon('ShieldAlert', G['shield-exclamation']);
export const ShieldCheck = makeIcon('ShieldCheck', G['shield-check']); export const ShieldCheck = makeIcon('ShieldCheck', G['shield-check']);
export const ShieldQuestion = makeIcon('ShieldQuestion', G['shield-question']); 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 Shuffle = makeIcon('Shuffle', G['arrows-shuffle']);
export const Smartphone = makeIcon('Smartphone', G['device-mobile']); export const Smartphone = makeIcon('Smartphone', G['device-mobile']);
export const Square = makeIcon('Square', G['square']); 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 Tag = makeIcon('Tag', G['tag']);
export const Tags = makeIcon('Tags', G['tags']); export const Tags = makeIcon('Tags', G['tags']);
export const Terminal = makeIcon('Terminal', G['terminal']); 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 Trash = makeIcon('Trash', G['trash']);
export const Trash2 = makeIcon('Trash2', 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 Type = makeIcon('Type', G['typography']);
export const Underline = makeIcon('Underline', G['underline']); export const Underline = makeIcon('Underline', G['underline']);
export const Undo2 = makeIcon('Undo2', G['arrow-back']); export const Undo2 = makeIcon('Undo2', G['arrow-back']);
@@ -14,6 +14,7 @@ export function ComposingSettings() {
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity); const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const replyIdentityMatch = useSettingsStore((s) => s.replyIdentityMatch); const replyIdentityMatch = useSettingsStore((s) => s.replyIdentityMatch);
const quickReplyEnabled = useSettingsStore((s) => s.quickReplyEnabled);
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity); const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled); const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
@@ -104,6 +105,13 @@ export function ComposingSettings() {
title={t('settings.composer.title', "Composer")} title={t('settings.composer.title', "Composer")}
description={t('settings.composer.description', "Configure email composition settings")} 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 <SettingItem
label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")} 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.")} 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 { useContactsStore } from '../../stores/contacts-store';
import { useHasContacts } from '../../lib/capabilities'; import { useHasContacts } from '../../lib/capabilities';
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders'; import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { removeTrustedSenderWithUndo } from '../../lib/settings-undo';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { ToastHost } from '../ToastHost';
interface TrustedRow { interface TrustedRow {
email: string; 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) => { const handleRemove = async (row: TrustedRow) => {
if (row.local) removeTrustedSender(row.email); if (row.synced) setBusy(row.email);
if (row.synced) { try {
setBusy(row.email); await removeTrustedSenderWithUndo(row, {
try { removeLocal: removeTrustedSender,
await removeFromTrustedSendersBook(row.email); addLocal: addTrustedSender,
} catch (err) { removeFromBook: removeFromTrustedSendersBook,
Alert.alert( addToBook: addToTrustedSendersBook,
t('settings.email_behavior.trusted_senders.sync_remove_failed', 'Could not remove from address book'), }, t);
err instanceof Error ? err.message : t('identities.validation_errors.unknown_error', 'Unknown error'), } finally {
); if (row.synced) setBusy(null);
} finally {
setBusy(null);
}
} }
}; };
@@ -269,6 +270,9 @@ export function ContentSendersSettings() {
)} )}
</Pressable> </Pressable>
</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> </Modal>
</View> </View>
); );
+28 -17
View File
@@ -22,6 +22,8 @@ import { SieveEditorSheet } from '../filters/SieveEditorSheet';
import type { FilterRule } from '../../lib/sieve/types'; import type { FilterRule } from '../../lib/sieve/types';
import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value'; import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value';
import { periodStatus, supportsPeriods } from '../../lib/sieve/period'; import { periodStatus, supportsPeriods } from '../../lib/sieve/period';
import { rulesForwardLimit } from '../../lib/sieve/forward-limit';
import { deleteFilterRuleWithUndo } from '../../lib/settings-undo';
import Badge from '../Badge'; import Badge from '../Badge';
type Translate = (key: string, fallback?: string) => string; type Translate = (key: string, fallback?: string) => string;
@@ -157,7 +159,8 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
const { const {
rules, isLoading, isSaving, error, isSupported, isOpaque, rawScript, vacationSettings, includeVacation, 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, setOpaqueScript, resetToVisualBuilder, validateScript,
} = useFilterStore(); } = useFilterStore();
const periodsSupported = useFilterStore((s) => supportsPeriods(s.sieveCapabilities?.sieveExtensions)); const periodsSupported = useFilterStore((s) => supportsPeriods(s.sieveCapabilities?.sieveExtensions));
@@ -200,23 +203,18 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
void persist(() => toggleRule(ruleId)); void persist(() => toggleRule(ruleId));
}, [toggleRule, persist]); }, [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) => { const handleDelete = useCallback((rule: FilterRule) => {
Alert.alert( void deleteFilterRuleWithUndo(rule.id, t);
t('settings.filters.delete_rule', 'Delete Rule'), }, [t]);
t('settings.filters.delete_confirm', 'Are you sure you want to delete this rule?'),
[ // A message can collect only so many forwards on this server; the rules
{ text: t('settings.filters.cancel', 'Cancel'), style: 'cancel' }, // run behind the out of office forwarding (#1155, #1208).
{ const forwardLimit = useMemo(
text: t('settings.filters.confirm_delete', 'Delete'), () => rulesForwardLimit(rules, vacationForward, sieveCapabilities?.maxNumberRedirects),
style: 'destructive', [rules, vacationForward, sieveCapabilities],
onPress: () => { );
deleteRule(rule.id);
void persist(() => addRule(rule));
},
},
],
);
}, [deleteRule, addRule, persist, t]);
const handleSaveRule = useCallback((rule: FilterRule) => { const handleSaveRule = useCallback((rule: FilterRule) => {
const previous = useFilterStore.getState().rules; const previous = useFilterStore.getState().rules;
@@ -362,6 +360,18 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
</View> </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 && ( {!isOpaque && rules.length > 0 && (
<View style={{ gap: spacing.sm }}> <View style={{ gap: spacing.sm }}>
{rules.map((rule) => { {rules.map((rule) => {
@@ -567,6 +577,7 @@ function makeStyles(c: ThemePalette) {
emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md }, emptyState: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.md },
emptyText: { ...typography.body, color: c.mutedForeground }, emptyText: { ...typography.body, color: c.mutedForeground },
forwardWarning: { ...typography.caption, color: c.warning },
ruleRow: { ruleRow: {
flexDirection: 'row', flexDirection: 'row',
+6 -13
View File
@@ -12,6 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store';
import { discoverKeywords } from '../../api/keyword-discovery'; import { discoverKeywords } from '../../api/keyword-discovery';
import { findUnrecognizedKeywords, type UnrecognizedKeyword } from '../../lib/keyword-discovery'; import { findUnrecognizedKeywords, type UnrecognizedKeyword } from '../../lib/keyword-discovery';
import { jmapClient } from '../../api/jmap-client'; import { jmapClient } from '../../api/jmap-client';
import { deleteKeywordWithUndo } from '../../lib/settings-undo';
type Keyword = KeywordDef; type Keyword = KeywordDef;
@@ -26,7 +27,6 @@ export function KeywordSettings() {
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
const addKeyword = useKeywordsStore((s) => s.add); const addKeyword = useKeywordsStore((s) => s.add);
const updateKeyword = useKeywordsStore((s) => s.update); const updateKeyword = useKeywordsStore((s) => s.update);
const removeKeyword = useKeywordsStore((s) => s.remove);
const resetDefaults = useKeywordsStore((s) => s.resetDefaults); const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
const hydrated = useKeywordsStore((s) => s.hydrated); const hydrated = useKeywordsStore((s) => s.hydrated);
const hydrate = useKeywordsStore((s) => s.hydrate); 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 // A tag can be put back as it was, so it goes at once with an Undo
// first, as the reset next to it does. // (webmail R3).
const deleteKeyword = (id: string, label: string) => { const deleteKeyword = (id: string) => {
Alert.alert( deleteKeywordWithUndo(id, t);
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) },
],
);
}; };
// One stray tap used to wipe a carefully built tag list (webmail removed // 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")}> <Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit', "Edit tag")}>
<Pencil size={14} color={c.mutedForeground} /> <Pencil size={14} color={c.mutedForeground} />
</Pressable> </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} /> <Trash2 size={14} color={c.mutedForeground} />
</Pressable> </Pressable>
</View> </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 () => { it('handles markRead action via detached JMAP', async () => {
const postCalls: any[] = []; const postCalls: any[] = [];
(secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => { (secureFetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, opts?: any) => {
@@ -759,9 +790,12 @@ describe('senderFaviconsAllowed', () => {
expect(await senderFaviconsAllowed()).toBe(true); 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); 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'); await AsyncStorage.setItem(KEY, '{not json');
expect(await senderFaviconsAllowed()).toBe(false); expect(await senderFaviconsAllowed()).toBe(false);
await AsyncStorage.removeItem(KEY); 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, expandRecipients,
splitPastedRecipients, splitPastedRecipients,
parseMailtoUrl, parseMailtoUrl,
outgoingAddress,
} from '../recipients'; } from '../recipients';
describe('isValidEmail', () => { describe('isValidEmail', () => {
@@ -24,6 +25,28 @@ describe('isValidEmail', () => {
expect(isValidEmail('<a@b.c>')).toBe(false); expect(isValidEmail('<a@b.c>')).toBe(false);
expect(isValidEmail('noat')).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', () => { describe('splitRecipients', () => {
@@ -122,6 +122,7 @@ describe('settings search index (English catalog)', () => {
expect(labelsFor('notifications')).toContain('Parse email invitations'); expect(labelsFor('notifications')).toContain('Parse email invitations');
expect(labelsFor('calendar')).toContain('Show week numbers'); expect(labelsFor('calendar')).toContain('Show week numbers');
expect(labelsFor('reading')).toContain('Clear search when switching folders'); expect(labelsFor('reading')).toContain('Clear search when switching folders');
expect(labelsFor('composing')).toContain('Quick reply');
expect(labelsFor('notifications')).toContain('Unread count on app icon'); expect(labelsFor('notifications')).toContain('Unread count on app icon');
// Webmail-only settings are not offered. // Webmail-only settings are not offered.
expect(labelsFor('calendar')).not.toContain('Free scrolling'); 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, getFaviconUrl,
} from './avatar-utils'; } from './avatar-utils';
import { singleLine } from './single-line'; import { singleLine } from './single-line';
import { cleanPreview } from './preview-text';
import { import {
lastNotifiedKey, lastNotifiedKey,
migrateLegacyPushKeys, migrateLegacyPushKeys,
@@ -67,13 +68,14 @@ async function emailNotificationsAllowed(): Promise<boolean> {
// The "Sender Favicons" setting covers notification icons as well as the // 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 // 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> { export async function senderFaviconsAllowed(): Promise<boolean> {
try { try {
const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY); 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; const parsed = JSON.parse(raw) as PushPersistedSettings;
return parsed.senderFavicons !== false; return parsed.senderFavicons === true;
} catch { } catch {
return false; return false;
} }
@@ -583,7 +585,8 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
const faviconDomain = favicons ? getFaviconDomain(address) : null; const faviconDomain = favicons ? getFaviconDomain(address) : null;
const iconUrl = faviconDomain ? getFaviconUrl(faviconDomain) : undefined; 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 markReadLabel = translate(locale, 'email_viewer.mark_read', 'Mark as read');
const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete'); const deleteLabel = translate(locale, 'email_viewer.delete', 'Delete');
const replyLabel = translate(locale, 'email_viewer.reply', 'Reply'); 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. // splitPastedRecipients, expandRecipients). All DOM-free.
import { splitMailbox } from './rfc5322-mailbox'; import { splitMailbox } from './rfc5322-mailbox';
import { toAsciiEmail } from './idn';
/** /**
* A composer recipient. Display name is optional; email is required - except * 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. * 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 { export function isValidEmail(email: string): boolean {
// Length check if (!email) return false;
if (!email || email.length > 254) return false;
// Security: Block control characters and header injection // Security: Block control characters and header injection
if (/[\r\n\0<>]/.test(email)) return false; 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) // 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])?)*$/; 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; 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 * 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 * 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', 'settings.email_behavior.clear_search_on_folder_change',
], ],
composing: [ composing: [
'settings.email_behavior.quick_reply',
'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',
+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, List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
Users, Tag, Type, Highlighter, Trash2, Users, Tag, Type, Highlighter, Trash2, Search,
} from '../components/icons'; } from '../components/icons';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
@@ -60,8 +60,10 @@ import {
import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format'; import { htmlComposeBodyToPlainText, initialPlainTextMode, plainComposeBodyToHtml } from '../lib/compose-format';
import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header'; import { buildQuoteHeader, formatQuoteDate, quoteHeaderLabels, type QuoteHeaderLabels } from '../lib/quote-header';
import { import {
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient, isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, outgoingAddress, type Recipient as ParsedRecipient,
} from '../lib/recipients'; } from '../lib/recipients';
import { toUnicodeEmail } from '../lib/idn';
import { mergeRecipientSuggestions } from '../lib/recipient-suggestions';
import { import {
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
} from '../lib/reply-identity'; } from '../lib/reply-identity';
@@ -188,8 +190,9 @@ function fromParsed(r: ParsedRecipient): Recipient {
return { name: r.name ?? '', email: r.email, group: r.group }; 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 { 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 { function chipIsValid(r: Recipient): boolean {
@@ -248,17 +251,21 @@ function initialsOf(name: string, email: string): string {
} }
function SuggestionList({ function SuggestionList({
suggestions, onPick, onPressIn, suggestions, onPick, onPressIn, onSearchServer, searchingServer,
}: { }: {
suggestions: RecipientSuggestion[]; suggestions: RecipientSuggestion[];
onPick: (s: RecipientSuggestion) => void; onPick: (s: RecipientSuggestion) => void;
onPressIn?: () => void; onPressIn?: () => void;
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
onSearchServer?: () => void;
searchingServer?: boolean;
}) { }) {
const c = useColors(); const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]); const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
return ( return (
<View style={styles.suggestionBox}> <View style={styles.suggestionBox}>
<ScrollView style={styles.suggestionScroll} keyboardShouldPersistTaps="always" nestedScrollEnabled>
{suggestions.map((s, i) => ( {suggestions.map((s, i) => (
<Pressable <Pressable
key={`${s.group?.id ?? s.email}-${i}`} key={`${s.group?.id ?? s.email}-${i}`}
@@ -285,6 +292,32 @@ function SuggestionList({
</View> </View>
</Pressable> </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> </View>
); );
} }
@@ -738,6 +771,22 @@ export default function ComposeScreen({ route, navigation }: Props) {
const loadRecentRecipients = useContactsStore((s) => s.loadRecentRecipients); const loadRecentRecipients = useContactsStore((s) => s.loadRecentRecipients);
const contactsVersion = useContactsStore((s) => s.contacts); const contactsVersion = useContactsStore((s) => s.contacts);
const recentVersion = useContactsStore((s) => s.recentRecipients); 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(() => { React.useEffect(() => {
if (sentMailbox?.id) void loadRecentRecipients(sentMailbox.id); if (sentMailbox?.id) void loadRecentRecipients(sentMailbox.id);
@@ -757,12 +806,32 @@ export default function ComposeScreen({ route, navigation }: Props) {
const suggestions = React.useMemo<RecipientSuggestion[]>(() => { const suggestions = React.useMemo<RecipientSuggestion[]>(() => {
const q = suggestionQuery.trim(); const q = suggestionQuery.trim();
if (q.length < 1) return []; if (q.length < 1) return [];
return getAutocomplete(q, 16) return mergeRecipientSuggestions({
.filter((s) => s.group || !alreadySelected.has(s.email.toLowerCase())) local: getAutocomplete(q, 16),
.slice(0, 8); isContact: (s) => !!s.group || !!findContactByEmail(s.email),
// contactsVersion/recentVersion re-run the lookup when the store loads. 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 // 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) => const setterFor = (field: Field) =>
field === 'to' ? setToRecipients : field === 'cc' ? setCcRecipients : setBccRecipients; field === 'to' ? setToRecipients : field === 'cc' ? setCcRecipients : setBccRecipients;
@@ -2606,7 +2675,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
const fromDisplay = React.useMemo(() => { const fromDisplay = React.useMemo(() => {
if (!primaryIdentity) return identityError ? t('email_composer.identity_unavailable', 'Identity unavailable') : t('common.loading', 'Loading...'); if (!primaryIdentity) return identityError ? t('email_composer.identity_unavailable', 'Identity unavailable') : t('common.loading', 'Loading...');
const { from } = senderAddress(primaryIdentity); 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]); }, [primaryIdentity, identityError, senderAddress, t]);
const draftStatusText = const draftStatusText =
@@ -2637,6 +2708,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
// No full-screen keyboard in landscape: it would hide the suggestions // No full-screen keyboard in landscape: it would hide the suggestions
// and the text around the field. // and the text around the field.
disableFullscreenUI disableFullscreenUI
accessibilityLabel={label}
style={[styles.recipientInput, invalidInput && styles.recipientInputInvalid]} style={[styles.recipientInput, invalidInput && styles.recipientInputInvalid]}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={c.textMuted} placeholderTextColor={c.textMuted}
@@ -2681,13 +2753,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
textContentType="none" textContentType="none"
/> />
</View> </View>
{activeField === field && suggestions.length > 0 && ( {activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
<SuggestionList <SuggestionList
suggestions={suggestions} suggestions={suggestions}
onPick={pickSuggestion} onPick={pickSuggestion}
onPressIn={() => { onPressIn={() => {
isPickingSuggestion.current = true; 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 && ( {field === 'to' && !ccVisible && (
@@ -2823,6 +2901,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput <TextInput
ref={subjectRef} ref={subjectRef}
disableFullscreenUI disableFullscreenUI
accessibilityLabel={t('email_composer.subject', 'Subject')}
style={styles.subjectInput} style={styles.subjectInput}
placeholder={t('email_composer.subject_placeholder', 'Subject')} placeholder={t('email_composer.subject_placeholder', 'Subject')}
placeholderTextColor={c.textMuted} placeholderTextColor={c.textMuted}
@@ -2859,6 +2938,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
<TextInput <TextInput
ref={plainBodyRef} ref={plainBodyRef}
disableFullscreenUI disableFullscreenUI
accessibilityLabel={t('email_composer.body_label', 'Message')}
style={styles.plainEditor} style={styles.plainEditor}
multiline multiline
value={plainBody} value={plainBody}
@@ -3021,6 +3101,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
{t('email_composer.link_url_prompt', 'Enter the URL')} {t('email_composer.link_url_prompt', 'Enter the URL')}
</Text> </Text>
<TextInput <TextInput
accessibilityLabel={t('email_composer.link_url_prompt', 'Enter the URL')}
style={styles.modalInput} style={styles.modalInput}
value={linkPromptValue} value={linkPromptValue}
onChangeText={setLinkPromptValue} onChangeText={setLinkPromptValue}
@@ -3268,6 +3349,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
{t(`templates.placeholders.${name}`, name.replace(/_/g, ' '))} {t(`templates.placeholders.${name}`, name.replace(/_/g, ' '))}
</Text> </Text>
<TextInput <TextInput
accessibilityLabel={t(`templates.placeholders.${name}`, name.replace(/_/g, ' '))}
style={styles.modalInput} style={styles.modalInput}
value={placeholderPrompt?.values[name] ?? ''} value={placeholderPrompt?.values[name] ?? ''}
onChangeText={(v) => setPlaceholderPrompt((p) => (p ? { ...p, values: { ...p.values, [name]: v } } : p))} 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> <Text style={styles.modalLabel}>{t('identities.sub_address.popover_title', 'Add Sub-Address Tag')}</Text>
<TextInput <TextInput
accessibilityLabel={t('identities.sub_address.popover_title', 'Add Sub-Address Tag')}
style={styles.modalInput} style={styles.modalInput}
value={tagDraft} value={tagDraft}
onChangeText={setTagDraft} onChangeText={setTagDraft}
@@ -3341,9 +3424,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
)} )}
{!!primaryIdentity && ( {!!primaryIdentity && (
<Text style={styles.modalLabel} numberOfLines={1}> <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) ? generateSubAddress(primaryIdentity.email, tagDraft, subAddressDelimiter)
: primaryIdentity.email} : primaryIdentity.email)}
</Text> </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> <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 <TextInput
accessibilityLabel={t('email_composer.from_override.name_label', 'From name')}
style={styles.modalInput} style={styles.modalInput}
value={overrideName} value={overrideName}
onChangeText={setOverrideName} onChangeText={setOverrideName}
@@ -3367,6 +3451,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
placeholderTextColor={c.textMuted} placeholderTextColor={c.textMuted}
/> />
<TextInput <TextInput
accessibilityLabel={t('email_composer.from_override.email_label', 'From email address')}
style={styles.modalInput} style={styles.modalInput}
value={overrideEmail} value={overrideEmail}
onChangeText={setOverrideEmail} onChangeText={setOverrideEmail}
@@ -3550,6 +3635,10 @@ function makeStyles(c: ThemePalette) {
gap: spacing.sm, gap: spacing.sm,
}, },
suggestionRowPressed: { backgroundColor: c.surfaceHover }, 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: { suggestionAvatar: {
width: 32, height: 32, width: 32, height: 32,
borderRadius: 16, borderRadius: 16,
+9
View File
@@ -67,6 +67,8 @@ interface KeywordsState {
add: (kw: KeywordDef) => void; add: (kw: KeywordDef) => void;
update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void; update: (id: string, patch: Partial<Omit<KeywordDef, 'id'>>) => void;
remove: (id: string) => void; remove: (id: string) => void;
/** Puts a tag back at `index` (the Undo after deleting it). */
insert: (kw: KeywordDef, index: number) => void;
resetDefaults: () => void; resetDefaults: () => void;
} }
@@ -167,6 +169,13 @@ export const useKeywordsStore = create<KeywordsState>((set, get) => ({
persist(next); 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: () => { resetDefaults: () => {
set({ keywords: DEFAULT_KEYWORDS }); set({ keywords: DEFAULT_KEYWORDS });
persist(DEFAULT_KEYWORDS); persist(DEFAULT_KEYWORDS);