mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
feat: folder actions, tags and unified views in the drawer, folder parent/role editing, tag discovery and account-routed unified actions (#404, #498, #495, #175, #658, #855)
This commit is contained in:
@@ -12,17 +12,17 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
|
||||
- What RN does: `deduplicate()` in `src/lib/mailbox-tree.ts:49-68` uses `lower.includes(rn) || rn.includes(lower)` — "Old Inbox", "2025 Archive", "Sent to Accounting" vanish from the drawer and the move sheet (also from `findTrashMailbox` etc. only indirectly).
|
||||
- Fix hint: replace the substring test with `r.name.trim().toLowerCase() === lower`; keep the nested/parent guards. Add a case to `src/lib/__tests__/mailbox-tree.test.ts`.
|
||||
|
||||
- [ ] **Role folders not localized (#404)** — `P3` — `missing`
|
||||
- [x] **Role folders not localized (#404)** — `P3` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: `localizeMailboxName(role, name, t)` maps inbox/sent/drafts/trash/archive/junk/important/flagged/all to translated labels (`lib/mailbox-label.ts:35`); used by sidebar (`components/layout/sidebar.tsx:520`), move menu, context menus.
|
||||
- What RN does: raw server name everywhere: drawer `src/components/SidebarDrawer.tsx:491`, list header `src/screens/EmailListScreen.tsx:637-641`, `MoveSheet.tsx:120`, `FolderSettings.tsx:26-29` (English-only pill).
|
||||
- Fix hint: add `src/lib/mailbox-label.ts` (port of WEB) using `useLocaleStore().t` keys `sidebar.mailboxes.*` (already present in `locales/*/common.json`), apply in the four places.
|
||||
|
||||
- [ ] **Setting to hide the total message count on folders (#498)** — `P3` — `missing`
|
||||
- [x] **Setting to hide the total message count on folders (#498)** — `P3` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: `showFolderTotalCount` setting (`stores/settings-store.ts`, `components/settings/layout-settings.tsx:236`) hides the `/ total` part in `SidebarRowCounts` (`components/layout/sidebar.tsx:207-209`).
|
||||
- What RN does: `RowCounts` always shows unread/total (`src/components/SidebarDrawer.tsx:68-79`).
|
||||
- Fix hint: add `showFolderTotalCount` to settings-store + LayoutSettings toggle; gate `total` in `RowCounts`.
|
||||
|
||||
- [ ] **Create subfolder / choose parent when creating** — `P3` — `partial`
|
||||
- [x] **Create subfolder / choose parent when creating** — `P3` — `partial` — fixed in PENDING-D
|
||||
- What WEB does: "New subfolder" in the folder context menu and `subfolder_of` in settings (`components/layout/mailbox-context-menu.tsx:187-193`, `components/settings/folder-settings.tsx:408-423`); store `createMailbox(client, name, parentId, accountId)` (`stores/email-store.ts:3753`), incl. creating in a shared account.
|
||||
- What RN does: `FolderSettings.saveDraft` always calls `createMailbox({ name })` (`src/components/settings/FolderSettings.tsx:96`) although `createMailbox` in `src/api/email.ts:156` already accepts `parentId` and `accountIdOverride`.
|
||||
- Fix hint: add a "Parent folder" select (own folders, or the shared account when creating there) to the editor modal and pass `parentId`.
|
||||
@@ -37,7 +37,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
|
||||
- What RN does: fixed role colours always on, `iconFor` knows only inbox/sent/drafts/trash/junk/archive/star (`src/components/SidebarDrawer.tsx:36-66`); no role assignment.
|
||||
- Fix hint: low priority; at least extend `iconFor` with the #288 roles (important/memos/scheduled/snoozed/shared) and ideally a role picker in FolderSettings using `updateMailbox(id, { role })` (needs `role` added to the `changes` type).
|
||||
|
||||
- [ ] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity`
|
||||
- [x] **Hide the server "Scheduled" role folder when the virtual Scheduled row is shown (#495)** — `P3` — `bugfix-parity` — fixed in PENDING-D
|
||||
- What WEB does: filters `role === 'scheduled'` nodes out of the own tree while the virtual row is rendered (`components/layout/sidebar.tsx:986-990`).
|
||||
- What RN does: drawer always renders the virtual "Scheduled" quick row (`src/components/SidebarDrawer.tsx:447-453`) and would also list a Stalwart `role: scheduled` mailbox as a plain folder.
|
||||
- Fix hint: skip nodes with `role === 'scheduled'` in `buildMailboxTree`/drawer when `jmapClient.hasDelayedSend()`.
|
||||
@@ -47,7 +47,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
|
||||
- What RN does: nothing — the only way is select-all-on-page + delete, page by page.
|
||||
- Fix hint: add `emptyMailbox(mailboxId, accountId?)` to `src/api/email.ts` mirroring the WEB loop (stop when `found.length === 0 || destroyed === 0 || found.length < batch`), a confirm `Alert`, a button in the list header when `currentMailbox.role` is trash/junk, then `refreshEmails()` + `fetchMailboxes()` and `dropFromCache`.
|
||||
|
||||
- [ ] **Mark folder / folder tree / all folders as read** — `P2` — `missing`
|
||||
- [x] **Mark folder / folder tree / all folders as read** — `P2` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: folder context menu → `markMailboxAsRead` (paged `Email/query notKeyword $seen` + `Email/set`, `lib/jmap/client.ts:2327-2364`), "mark folder tree read" and "mark all folders read" with confirm (`components/mail/mail-app.tsx:2571-2645`, `client.markAllAsRead` `:2366`); store zeroes unread counters (`stores/email-store.ts:4003-4043`).
|
||||
- What RN does: only per-message and batch-on-loaded-page mark read (`EmailListScreen.tsx:456-464`).
|
||||
- Fix hint: long-press on a drawer folder row → action sheet (Mark all read / Empty / Rename / Delete / New subfolder); api `markMailboxAsRead(rawId, accountId)` with the paged loop; then `fetchMailboxes()` and, if current, `refreshEmails()`.
|
||||
@@ -62,49 +62,49 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
|
||||
- What RN does: every push event awaits `fetchMailboxes()` then `refreshEmails()` (`src/stores/email-store.ts:981-1015`); overlapping pushes, the mount effects (`EmailListScreen.tsx:554-567`) and `archiveEmail`'s follow-up `fetchMailboxes` can all run in parallel; a `RateLimitError` (`src/api/jmap-client.ts:436-440`) is only logged. The tree itself is preserved on failure (`email-store.ts:604-612`), so this is perf/noise rather than data loss.
|
||||
- Fix hint: wrap `fetchMailboxes`/`refreshEmails` bodies in a small in-flight map keyed by `activeAccountId` with a single queued re-run, like WEB's `coalesceRefresh`.
|
||||
|
||||
- [ ] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing`
|
||||
- [x] **Tap the unread count of a folder to open it filtered to unread** — `P3` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: unread badge is a button that toggles `isUnread: true` on that folder (`components/layout/sidebar.tsx:570`, `components/mail/mail-app.tsx:2533-2567`).
|
||||
- What RN does: counts are static text (`SidebarDrawer.tsx:68-79`).
|
||||
- Fix hint: make the unread count pressable → `selectMailbox(id)` then `setFilters({ isUnread: true })`.
|
||||
|
||||
- [ ] **Shared-account header has no folder actions** — `P3` — `missing`
|
||||
- [x] **Shared-account header has no folder actions** — `P3` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: right-click on a shared account header offers "New folder" in that account (`components/layout/sidebar.tsx:handleSharedAccountContextMenu`, `mailbox-context-menu.tsx:119-147`; changelog 1.9.0 "route shared-folder management to the owner account").
|
||||
- What RN does: `FolderSettings` manages own folders only (`src/components/settings/FolderSettings.tsx:47-50`); the account node in the drawer only expands/collapses (`SidebarDrawer.tsx:499-503`).
|
||||
- Fix hint: after the subfolder/move work, add an account picker (own + shared accounts the user `mayCreateChild` in) to the folder editor and pass `accountIdOverride`.
|
||||
|
||||
### Unified mailbox / cross-account views
|
||||
|
||||
- [ ] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial`
|
||||
- [x] **Unified scope: account-bounded by default, cross-account opt-in; single-account + group inboxes case** — `P2` — `partial` — fixed in PENDING-D
|
||||
- What WEB does: unified views stay inside the active account (own + shared/group folders) unless `unifiedCrossAccount` is on *and* the admin gate allows it (`components/mail/mail-app.tsx:465-500`, `stores/settings-store.ts:405-431`); the section is shown with one account as soon as `includeGroupInUnified && hasGroupInboxes` (`components/layout/sidebar.tsx:905-907`).
|
||||
- What RN does: "All inboxes" is shown only when `accounts.length > 1` (`src/components/SidebarDrawer.tsx:438-446`) and always spans every account (`src/api/unified-inbox.ts:236-257`). A single account with group inboxes gets no unified view even with "Include Group Inboxes" on (`ReadingSettings.tsx:257`). Admin policy gates do not exist in RN (see N/A).
|
||||
- Fix hint: show the row when `accounts.length > 1 || (includeGroupInUnified && mailboxes.some(m => m.isShared))`; add a `unifiedCrossAccount` toggle (default off, like WEB) and pass only the active account id when it is off. Note WEB's default for `includeGroupInUnified` is `true` (`settings-store.ts:639`) while RN defaults to `false` (`src/stores/settings-store.ts:235`).
|
||||
|
||||
- [ ] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing`
|
||||
- [x] **Unified per-role views (All Sent/Drafts/Junk/Archive/Trash) with live counts** — `P3` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: one row per role present in any account with summed unread/total (`lib/unified-mailbox.ts:295-320`, sidebar rows `components/layout/sidebar.tsx` "unified_*"), counts projected from live mailbox lists.
|
||||
- What RN does: inbox only (`src/api/unified-inbox.ts:149`), no counts on the "All inboxes" row.
|
||||
- Fix hint: after the per-account mailbox fetch, pick `role` instead of hard-coding inbox; sum `unreadEmails` of each account's inbox for the drawer badge.
|
||||
|
||||
- [ ] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing`
|
||||
- [x] **All mail / Unread / Starred cross views + per-account folder selection** — `P2` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: `buildCrossFilter` (OR of included mailboxes AND `notKeyword $seen` / `hasKeyword $flagged`) fanned out per account (`lib/unified-mailbox.ts:338-492`); default = inbox + custom folders (`CROSS_EXCLUDED_ROLES`), narrowed by `allMailFolderIds[accountId]` chosen in Layout settings (`components/settings/layout-settings.tsx:291-340`); rows show the source folder chip (`components/email/thread-list-item.tsx:385`, `resolveSourceFolderName` `lib/unified-mailbox.ts:63`).
|
||||
- What RN does: none.
|
||||
- Fix hint: reuse `fetchInboxEmailsForJmapAccount` with a filter built like `buildCrossFilter` over `Mailbox/get` results minus excluded roles; add a `sourceFolder` label to `UnifiedEmail` rows; folder picker can come later.
|
||||
|
||||
- [ ] **Unified list has no pagination ("load more")** — `P2` — `missing`
|
||||
- [x] **Unified list has no pagination ("load more")** — `P2` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: `loadMoreUnifiedEmails` / cross-view load-more with `position` per account (`stores/email-store.ts:4079-4107`, `1473-1515`).
|
||||
- What RN does: fixed `perAccountLimit = 25`, no `position`, no `onEndReached` (`src/api/unified-inbox.ts:236-241`, `src/screens/UnifiedInboxScreen.tsx:175-182`).
|
||||
- Fix hint: keep a per-account `position`, add `onEndReached` that re-queries each account at its own offset and merges.
|
||||
|
||||
- [ ] **Unified search** — `P3` — `missing`
|
||||
- [x] **Unified search** — `P3` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: text + advanced filter fan-out in all unified/cross views (`lib/unified-mailbox.ts:177-207`, `455-492`; changelog 1.7.8).
|
||||
- What RN does: no search box on `UnifiedInboxScreen`.
|
||||
- Fix hint: add the same search bar as `EmailListScreen` and AND `{ text: toWildcardQuery(q) }` into each account's query.
|
||||
|
||||
- [ ] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing`
|
||||
- [x] **No actions on unified rows (swipe, star, read, delete, archive, spam, selection)** — `P2` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: every list action resolves the email's own client + owner account via `resolveEmailActionContext` (`stores/email-store.ts:581-640`), incl. batch actions grouped by `sourceAccountId` (`:2634-2660`, `:2740-2760`), archive into the owner's archive (`components/mail/mail-app.tsx:2141-2160`); changelog 1.7.5/1.7.7 "route counter/keyword updates to the email's own account in aggregate views".
|
||||
- What RN does: rows are plain `Pressable`s (`src/screens/UnifiedInboxScreen.tsx:95-138`); nothing but open.
|
||||
- Fix hint: wrap rows in `SwipeableRow`; because the JMAP client is single-account, actions on a *different registry account* need the detached `jmapPost` path (`unified-inbox.ts:116-131`) with `Email/set` against `email.jmapAccountId`; for the active account and its group accounts route through `setEmailKeywords(..., email.jmapAccountId)` / `moveEmail(...)` overrides.
|
||||
|
||||
- [ ] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial`
|
||||
- [x] **Unified list stays stale after acting on a message; just-read mail handling** — `P3` — `partial` — fixed in PENDING-D
|
||||
- What WEB does: aggregate views refresh through the fan-out on push (`refreshCurrentMailbox`, #791) and keep just-read/unstarred rows in the Unread/Starred views until re-opened (`retainedInViewIds`, `stores/email-store.ts:1007-1050`).
|
||||
- What RN does: `UnifiedInboxScreen` loads once per mount/`includeGroup` change (`:57-59`); after opening (mark read) or deleting in the thread screen and going back, the unread dot/row is stale until pull-to-refresh. No push hookup.
|
||||
- Fix hint: `useFocusEffect` → reload, or patch the local list from the thread screen result; when Unread/Starred views are added, keep the WEB retain semantics.
|
||||
@@ -114,7 +114,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
|
||||
- What RN does: `UnifiedInboxScreen.onOpen` switches account then navigates to `EmailThread` (`src/screens/UnifiedInboxScreen.tsx:61-86`). `EmailThreadScreen` pages over the *active folder's* `emails` (`src/screens/EmailThreadScreen.tsx:131-133`, FlatList `data={emails}` `:538`, `initialScrollIndex={Math.max(0, findIndex)}`). A group-inbox message (or any message not in the first page of the user's own inbox snapshot) is not in `emails`, so index 0 is shown: the pane renders `emails[0]` while the toolbar/`activeEmailId` (and delete/archive/spam) refer to the message that was tapped; with an empty snapshot the body area is blank. Bare-id `findIndex` also collides across accounts (Stalwart reuses id ranges, #847).
|
||||
- Fix hint: when `route.params.emailId` is not found in `emails` (or `jmapAccountId` is set), page over a one-element list `[{ id, threadId }]` instead of `emails`; compare ids together with the owning account.
|
||||
|
||||
- [ ] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug`
|
||||
- [x] **Unified fetch re-discovers the session and mailboxes on every refresh** — `P3` — `rn-only-bug` — fixed in PENDING-D
|
||||
- What RN does: `fetchInboxForAccount` does `/.well-known/jmap` + `Mailbox/get` + `Email/query` + `Email/get` per account per load (`src/api/unified-inbox.ts:175-234`); the active account is also fetched detached instead of via the live client/snapshot.
|
||||
- Fix hint: cache `apiUrl`/`primaryJmapId`/inbox id per account (keyed by serverUrl+username) for the session; use the live `jmapClient` + existing inbox snapshot for the active account.
|
||||
|
||||
@@ -130,7 +130,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
|
||||
- What RN does: `EmailRow` shows no tags; `tagPill/tagDot/tagText` styles exist but are unused (`src/screens/EmailListScreen.tsx:1481-1495`). Tags are only visible inside the thread screen tag menu.
|
||||
- Fix hint: compute tag ids from `item.keywords` (`$label:*`, `$color:*`), look up `useKeywordsStore().keywords` for label/colour (unknown ids → grey with raw id), render pills on the subject row; add a `tintListRowsByTag` setting if desired.
|
||||
|
||||
- [ ] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing`
|
||||
- [x] **Tag view (tap a tag → cross-folder `hasKeyword` list, #175) and tag counts in the drawer** — `P2` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: "Tags" sidebar section with unread/total per tag (`fetchTagCounts`, `stores/email-store.ts:1192`), visibility rules (`components/layout/sidebar.tsx:1002-1013`), `selectKeyword` → `getEmails(undefined, …, '$label:<id>')` across all folders (`stores/email-store.ts:1343-1420`, changelog 1.4.13 #175), counts kept in step with read/unread (1.7.8).
|
||||
- What RN does: no tag section in `SidebarDrawer`; `EmailFilters` has no keyword field (`src/stores/email-store.ts:140-149`).
|
||||
- Fix hint: add `keyword?: string` to `EmailFilters` and a "Tags" section in the drawer; when set, `buildJmapFilter` should omit `inMailbox` (search across folders) and add `{ hasKeyword: '$label:<id>' }`; counts via one `Email/query` (calculateTotal) per tag, optional.
|
||||
@@ -145,7 +145,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
|
||||
- What RN does: `togglePin` writes `$important` (`src/stores/email-store.ts:1151-1173`), `isPinned` reads `$important` (`src/screens/EmailListScreen.tsx:47-49`); no icon, no ordering. A pin set in RN is invisible in WEB and vice versa.
|
||||
- Fix hint: switch the keyword to `$pinned`; show a pin icon; optionally sort pinned to the top client-side (server sort needs the polarity probe, see next item).
|
||||
|
||||
- [x] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` — fixed in PENDING-C
|
||||
- [x] **Configurable message-list order presets/custom levels (#718) incl. Stalwart `hasKeyword` polarity probe; native issue #5** — `P2` — `partial` — fixed in bea3fc4
|
||||
- What WEB does: presets (unread/starred/tagged first) or up to 3 levels mapped to the JMAP sort, Inbox-only or all folders (`lib/message-list-order.ts`, `components/settings/message-list-order-settings.tsx:52-62`, `getMessageListOrderFor` `stores/settings-store.ts:1396`); polarity probe per account because Stalwart inverts `isAscending` on keyword comparators (`lib/jmap/client.ts:1376-1431`), `unsupportedSort` fallback (`:1490-1500`); thread grouping mirrors the order (`lib/thread-utils.ts:82`).
|
||||
- What RN does: only `receivedAt` asc/desc via `mailSortAscending` (persisted, header toggle `src/screens/EmailListScreen.tsx:699-711`, store `setSortAscending` `src/stores/email-store.ts:1039-1053`). This satisfies the "per default" part of native issue #5 but nothing else.
|
||||
- Fix hint: port `message-list-order.ts` (pure, no deps) and pass `buildEmailSort(levels, …)` as `sort` to `queryEmails`/`getEmailQueryChanges`; probe polarity once per account like `probeKeywordSortPolarity`; add a settings screen with the presets. Keep `setSortAscending`'s snapshot invalidation for any order change.
|
||||
@@ -219,7 +219,7 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
|
||||
- What RN does: `formatListDate` follows the app locale only (`src/lib/date-format.ts:17-31`), "Just now"/"m ago" are English (`:38-41`).
|
||||
- Fix hint: localize the relative strings via `t()`; add a `dateLocale` select if parity is wanted.
|
||||
|
||||
- [x] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` — fixed in PENDING-C
|
||||
- [x] **Show avatars in Junk (off by default, 1.5.1); avatar priority** — `P3` — `partial` — fixed in bea3fc4
|
||||
- What WEB does: `showAvatarsInJunk` hides favicon/photo images in Junk (`components/email/thread-list-item.tsx:137`); avatar priority contact photo > plugin (Gravatar) > favicon > initials (`components/ui/avatar.tsx:236`).
|
||||
- What RN does: favicons always load, including in Junk (`src/components/SenderAvatar.tsx:37`); no contact-photo lookup.
|
||||
- Fix hint: pass `disableImages={currentMailbox.role === 'junk' && !showAvatarsInJunk}` into `SenderAvatar`; contact photos optional.
|
||||
@@ -263,17 +263,17 @@ RN covers the core single-folder loop well (folder tree incl. shared/group accou
|
||||
- What RN does: `{ id, label, color }` only (`src/stores/keywords-store.ts:9-13`); `TagSheet` lists defined tags only (`src/components/TagSheet.tsx:76-95`), so a `$label:work/clients` set by WEB can neither be seen nor removed on RN. Tag definitions are device-local (see native issue #1).
|
||||
- Fix hint: show unknown `$label:*` ids found on the selected emails at the bottom of `TagSheet`; add `visibility` when the drawer tag section lands; nesting/reorder are lower priority.
|
||||
|
||||
- [ ] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity`
|
||||
- [x] **"Reset defaults" wipes the tag list with no confirmation (removed in WEB 1.8.1)** — `P3` — `bugfix-parity` — fixed in PENDING-D
|
||||
- What WEB does: removed the button ("one stray click wiped a carefully built tag list", changelog 1.8.1 Changes).
|
||||
- What RN does: `KeywordSettings.tsx:94-97` → `resetDefaults()` immediately (`src/stores/keywords-store.ts:85-88`).
|
||||
- Fix hint: remove the button or guard with `Alert.alert` confirm.
|
||||
|
||||
- [ ] **Scan for keywords no local tag explains (#658)** — `P3` — `missing`
|
||||
- [x] **Scan for keywords no local tag explains (#658)** — `P3` — `missing` — fixed in PENDING-D
|
||||
- What WEB does: `discoverKeywords` pages `Email/query`+`Email/get keywords` and proposes label/colour for unknown `$label:`/`$color:` ids (`lib/jmap/client.ts:1616-1676`, `lib/keyword-discovery.ts:140`, `components/settings/keyword-settings.tsx:374-490`).
|
||||
- What RN does: none; after a reinstall all tags must be re-typed with the exact id.
|
||||
- Fix hint: port `discoverKeywords` (cap 5000 like offline sync) + `findUnrecognizedKeywords` (pure) and an "Add" list in `KeywordSettings`.
|
||||
|
||||
- [ ] **Keyword palette parity** — `P3` — `partial`
|
||||
- [x] **Keyword palette parity** — `P3` — `partial` — fixed in PENDING-D
|
||||
- What WEB does: `KEYWORD_PALETTE` keys (`stores/settings-store.ts:226-280`) are the tag ids of the default tags and the colour names used by `suggestKeywordColor`.
|
||||
- What RN does: `colors.tags` keys in `src/theme/tokens.ts`; defaults are `important/work/personal/todo` (`src/stores/keywords-store.ts:15-20`) whereas WEB's `DEFAULT_KEYWORDS` (`settings-store.ts:281`) are colour-named. A message tagged on one client with a default tag shows as unknown on the other.
|
||||
- Fix hint: verify the key sets match; consider aligning the default ids with WEB's.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
|
||||
// Port of the webmail's JMAPClient.discoverKeywords (#658): page through the
|
||||
// account's messages newest-first (query + back-referenced Email/get of the
|
||||
// keywords only) and count every keyword that is set. Capped like the
|
||||
// offline sync so a noisy account can't be enumerated end to end.
|
||||
|
||||
export const DEFAULT_KEYWORD_SCAN_LIMIT = 5000;
|
||||
|
||||
export interface KeywordScanResult {
|
||||
/** keyword → number of scanned messages carrying it. */
|
||||
keywords: Record<string, number>;
|
||||
scanned: number;
|
||||
total: number;
|
||||
/** False when the cap stopped the scan before the end of the list. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export async function discoverKeywords(options?: {
|
||||
limit?: number;
|
||||
onProgress?: (scanned: number, total: number) => void;
|
||||
signal?: { aborted: boolean };
|
||||
accountId?: string;
|
||||
}): Promise<KeywordScanResult> {
|
||||
const accountId = options?.accountId ?? jmapClient.accountId;
|
||||
const cap = Math.max(0, options?.limit ?? DEFAULT_KEYWORD_SCAN_LIMIT);
|
||||
const pageSize = Math.max(1, Math.min(500, jmapClient.getMaxObjectsInGet()));
|
||||
const keywords: Record<string, number> = {};
|
||||
let scanned = 0;
|
||||
let total = 0;
|
||||
let complete = false;
|
||||
|
||||
while (scanned < cap) {
|
||||
if (options?.signal?.aborted) break;
|
||||
const limit = Math.min(pageSize, cap - scanned);
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId,
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
position: scanned,
|
||||
calculateTotal: scanned === 0,
|
||||
}, '0'],
|
||||
['Email/get', {
|
||||
accountId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['keywords'],
|
||||
}, '1'],
|
||||
]);
|
||||
const query = res.methodResponses?.[0]?.[1] ?? {};
|
||||
const got = res.methodResponses?.[1]?.[1] ?? {};
|
||||
const ids: string[] = (query.ids as string[] | undefined) ?? [];
|
||||
if (scanned === 0) total = (query.total as number | undefined) ?? 0;
|
||||
|
||||
for (const email of (got.list as Array<{ keywords?: Record<string, boolean> }> | undefined) ?? []) {
|
||||
for (const [keyword, isSet] of Object.entries(email.keywords ?? {})) {
|
||||
if (isSet) keywords[keyword] = (keywords[keyword] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Page by what the query returned, not by what the get did: a message
|
||||
// destroyed between the two lands in `notFound` and would otherwise
|
||||
// shift every later page by one.
|
||||
scanned += ids.length;
|
||||
options?.onProgress?.(scanned, Math.max(total, scanned));
|
||||
|
||||
// A short page is the end of the list, however much `total` claimed.
|
||||
if (ids.length === 0 || ids.length < limit) {
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { keywords, scanned, total: Math.max(total, scanned), complete };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import type { JMAPMethodCall } from './types';
|
||||
|
||||
export interface TagCount {
|
||||
/** Tag id (the part after `$label:`). */
|
||||
id: string;
|
||||
total: number;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread/total counts per tag across every folder of one account — two
|
||||
* `Email/query` calls (`calculateTotal`, `limit: 0`) per tag, batched to the
|
||||
* server's maxCallsInRequest. Port of the webmail's `fetchTagCounts`
|
||||
* (stores/email-store.ts). A tag whose queries fail is reported as 0/0
|
||||
* rather than sinking the whole batch.
|
||||
*/
|
||||
export async function fetchTagCounts(tagIds: string[], accountIdOverride?: string): Promise<TagCount[]> {
|
||||
if (tagIds.length === 0) return [];
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const perRequest = Math.max(2, jmapClient.getMaxCallsInRequest());
|
||||
const counts = new Map<string, TagCount>(tagIds.map((id) => [id, { id, total: 0, unread: 0 }]));
|
||||
|
||||
const calls: Array<{ id: string; kind: 'total' | 'unread'; call: JMAPMethodCall }> = [];
|
||||
for (const id of tagIds) {
|
||||
const keyword = `$label:${id}`;
|
||||
calls.push({
|
||||
id,
|
||||
kind: 'total',
|
||||
call: ['Email/query', { accountId, filter: { hasKeyword: keyword }, limit: 0, calculateTotal: true }, `t:${id}`],
|
||||
});
|
||||
calls.push({
|
||||
id,
|
||||
kind: 'unread',
|
||||
call: ['Email/query', {
|
||||
accountId,
|
||||
filter: { operator: 'AND', conditions: [{ hasKeyword: keyword }, { notKeyword: '$seen' }] },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `u:${id}`],
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < calls.length; i += perRequest) {
|
||||
const chunk = calls.slice(i, i + perRequest);
|
||||
const res = await jmapClient.request(chunk.map((c) => c.call));
|
||||
for (const [name, body, callId] of res.methodResponses) {
|
||||
if (name !== 'Email/query' || typeof callId !== 'string') continue;
|
||||
const [kind, ...rest] = callId.split(':');
|
||||
const id = rest.join(':');
|
||||
const entry = counts.get(id);
|
||||
if (!entry) continue;
|
||||
const total = typeof body.total === 'number' ? body.total : 0;
|
||||
if (kind === 't') entry.total = total;
|
||||
else if (kind === 'u') entry.unread = total;
|
||||
}
|
||||
}
|
||||
return tagIds.map((id) => counts.get(id)!);
|
||||
}
|
||||
+466
-125
@@ -1,21 +1,39 @@
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Email, JMAPSession, JMAPMethodCall, Mailbox } from './types';
|
||||
import { jmapClient, type StoredCredentials } from './jmap-client';
|
||||
import {
|
||||
jmapClient, rewriteSessionUrl, extractOrigin, parseRetryAfter, REQUEST_TIMEOUT_MS,
|
||||
type StoredCredentials,
|
||||
} from './jmap-client';
|
||||
import { keywordPointer, mailboxPointer } from './patch-pointer';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
import { refreshOAuthAccessToken, type OAuthTokens } from '../lib/oauth';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
|
||||
// Aggregated inbox across every logged-in account. Because the JMAP client is
|
||||
// a single-account singleton (one live session at a time), we fetch each
|
||||
// account's inbox "detached" here — reading its stored credentials directly and
|
||||
// issuing requests against its own API URL — so the active account's live
|
||||
// session is never disturbed.
|
||||
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
|
||||
// Unread / Starred). Because the JMAP client is a single-account singleton
|
||||
// (one live session at a time), the *active* registry account goes through
|
||||
// the live client while every other account is fetched "detached" — reading
|
||||
// its stored credentials directly and posting to its own API URL — so the
|
||||
// active session is never disturbed. Session discovery and the per-account
|
||||
// mailbox lists are cached for the session so a refresh is one Email/query +
|
||||
// Email/get per account instead of four round-trips.
|
||||
|
||||
const EMAIL_LIST_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
'receivedAt', 'from', 'to', 'subject', 'preview', 'hasAttachment',
|
||||
'receivedAt', 'from', 'to', 'cc', 'subject', 'preview', 'hasAttachment',
|
||||
];
|
||||
|
||||
const TOKEN_REFRESH_LEEWAY_MS = 60_000;
|
||||
const SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const MAILBOX_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
export type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
|
||||
export type CrossView = 'all' | 'unread' | 'starred';
|
||||
|
||||
/** Roles left out of the cross-account All mail / Unread / Starred views. */
|
||||
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
|
||||
'junk', 'spam', 'sent', 'archive', 'trash', 'drafts',
|
||||
]);
|
||||
|
||||
export interface UnifiedEmail extends Email {
|
||||
/** Registry account id this message belongs to (not the JMAP account id). */
|
||||
@@ -23,40 +41,104 @@ export interface UnifiedEmail extends Email {
|
||||
/**
|
||||
* JMAP account id the message lives under. Equals the registry account's
|
||||
* primary JMAP account for own mail, or a group/shared owner account when
|
||||
* the message came from a group inbox. Carried so opening can target the
|
||||
* right account via an Email/get override.
|
||||
* the message came from a group inbox. Carried so opening/acting can target
|
||||
* the right account via an Email/get or Email/set override.
|
||||
*/
|
||||
jmapAccountId: string;
|
||||
/** True when this message belongs to a group/shared inbox, not the user's own. */
|
||||
isShared: boolean;
|
||||
/** Human label for the owning account when shared (e.g. "Support"). */
|
||||
sharedLabel?: string;
|
||||
/** Name of the folder the message was found in (cross views). */
|
||||
sourceFolder?: string;
|
||||
}
|
||||
|
||||
export interface UnifiedInboxResult {
|
||||
emails: UnifiedEmail[];
|
||||
/** accountId → error message for accounts that could not be fetched. */
|
||||
errors: Record<string, string>;
|
||||
/** Per-target paging cursors to pass back as `positions` for the next page. */
|
||||
positions: Record<string, number>;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface UnifiedFetchOptions {
|
||||
/** Per-role view (default inbox) — ignored when `view` is set. */
|
||||
role?: UnifiedRole;
|
||||
/** Cross-folder view over inbox + custom folders. */
|
||||
view?: CrossView;
|
||||
/** Also scan group/shared accounts reachable through each login. */
|
||||
includeGroup?: boolean;
|
||||
/** Free-text search ANDed into every account's query. */
|
||||
query?: string;
|
||||
/** Cursors from the previous page (`UnifiedInboxResult.positions`). */
|
||||
positions?: Record<string, number>;
|
||||
}
|
||||
|
||||
// ── Detached transport ──────────────────────────────────────────────────
|
||||
|
||||
function authHeaderFor(creds: StoredCredentials): string {
|
||||
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
|
||||
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
|
||||
}
|
||||
|
||||
function originOf(url: string): string | null {
|
||||
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
|
||||
return m ? m[1] : null;
|
||||
/** Thrown when a detached account's server rate-limited us (429). */
|
||||
export class UnifiedRateLimitError extends Error {
|
||||
retryAfterMs: number;
|
||||
constructor(retryAfterMs: number) {
|
||||
super('Rate limited');
|
||||
this.name = 'UnifiedRateLimitError';
|
||||
this.retryAfterMs = retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
// Same intent as JMAPClient.rewriteSessionUrls: point the advertised apiUrl at
|
||||
// the origin we actually connected to (servers often self-report unreachable
|
||||
// container-internal hosts).
|
||||
function rewriteApiUrl(session: JMAPSession, serverUrl: string): string {
|
||||
const serverOrigin = originOf(serverUrl);
|
||||
const apiOrigin = originOf(session.apiUrl);
|
||||
if (!apiOrigin || !serverOrigin || apiOrigin === serverOrigin) return session.apiUrl;
|
||||
return serverOrigin + session.apiUrl.slice(apiOrigin.length);
|
||||
/**
|
||||
* `fetch` with the same deadline the live client applies
|
||||
* (`jmapClient.authenticatedFetch`): an AbortController-backed timeout, 401
|
||||
* → "Session expired", 429 → rate-limit error carrying Retry-After.
|
||||
*/
|
||||
export async function fetchWithDeadline(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs = REQUEST_TIMEOUT_MS,
|
||||
): Promise<Response> {
|
||||
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
const timer = setTimeout(() => controller?.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await secureFetch(url, { ...init, signal: controller?.signal });
|
||||
if (response.status === 401) throw new Error('Session expired');
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers?.get?.('Retry-After') ?? null;
|
||||
throw new UnifiedRateLimitError(parseRetryAfter(retryAfter));
|
||||
}
|
||||
if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`);
|
||||
return response;
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === 'AbortError') throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
type MethodResponses = Array<[string, Record<string, any>, string]>;
|
||||
|
||||
interface Transport {
|
||||
post: (methodCalls: JMAPMethodCall[]) => Promise<MethodResponses>;
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JMAPMethodCall[],
|
||||
): Promise<MethodResponses> {
|
||||
const response = await fetchWithDeadline(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||
body: JSON.stringify({ using: [CAPABILITIES.CORE, CAPABILITIES.MAIL], methodCalls }),
|
||||
});
|
||||
const body = await response.json();
|
||||
return (body.methodResponses ?? []) as MethodResponses;
|
||||
}
|
||||
|
||||
function resolveJmapAccountId(session: JMAPSession): string | null {
|
||||
@@ -68,6 +150,22 @@ function resolveJmapAccountId(session: JMAPSession): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
// Same rule as JMAPClient.getSharedMailAccounts(): Stalwart doesn't always
|
||||
// populate `accountCapabilities` on shared accounts, so an account counts as
|
||||
// mail-capable when it advertises the mail capability OR is non-personal.
|
||||
function sharedMailAccountsOf(session: JMAPSession, primaryId: string): { id: string; name: string }[] {
|
||||
const out: { id: string; name: string }[] = [];
|
||||
for (const [id, info] of Object.entries(session.accounts ?? {})) {
|
||||
if (id === primaryId) continue;
|
||||
const advertisesMail = info.accountCapabilities
|
||||
? CAPABILITIES.MAIL in info.accountCapabilities
|
||||
: false;
|
||||
if (!advertisesMail && info.isPersonal) continue;
|
||||
out.push({ id, name: info.name || id });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Refresh an about-to-expire OAuth token and persist it so the next fetch (and
|
||||
// a later account switch) reuse the fresh one. Returns possibly-updated creds.
|
||||
async function ensureFreshCredentials(
|
||||
@@ -113,139 +211,260 @@ async function ensureFreshCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
async function jmapPost(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
methodCalls: JMAPMethodCall[],
|
||||
using: string[],
|
||||
): Promise<Array<[string, Record<string, any>, string]>> {
|
||||
const response = await secureFetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
|
||||
body: JSON.stringify({ using, methodCalls }),
|
||||
});
|
||||
if (response.status === 401) throw new Error('Session expired');
|
||||
if (!response.ok) throw new Error(`JMAP request failed: ${response.status}`);
|
||||
const body = await response.json();
|
||||
return body.methodResponses ?? [];
|
||||
// ── Per-account session + mailbox cache ────────────────────────────────
|
||||
|
||||
interface AccountEntry {
|
||||
accountId: string;
|
||||
transport: Transport;
|
||||
primaryJmapId: string;
|
||||
shared: { id: string; name: string }[];
|
||||
/** Registry account served by the live jmapClient right now. */
|
||||
live: boolean;
|
||||
discoveredAt: number;
|
||||
mailboxes: Map<string, { list: Mailbox[]; at: number }>;
|
||||
}
|
||||
|
||||
// Fetch the most recent inbox messages for a single JMAP account reachable via
|
||||
// `apiUrl`/`authHeader`. Returns the bare Email objects; the caller decorates
|
||||
// them with registry/sharing metadata.
|
||||
async function fetchInboxEmailsForJmapAccount(
|
||||
apiUrl: string,
|
||||
authHeader: string,
|
||||
jmapAccountId: string,
|
||||
limit: number,
|
||||
): Promise<Email[]> {
|
||||
const mailboxResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Mailbox/get', { accountId: jmapAccountId, properties: ['id', 'role', 'name'] }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const mailboxes = (mailboxResponses[0]?.[1]?.list as Mailbox[]) ?? [];
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox') ?? mailboxes[0];
|
||||
if (!inbox) return [];
|
||||
const entries = new Map<string, AccountEntry>();
|
||||
|
||||
const queryResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Email/query', {
|
||||
accountId: jmapAccountId,
|
||||
filter: { inMailbox: inbox.id },
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
limit,
|
||||
}, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
const ids = (queryResponses[0]?.[1]?.ids as string[]) ?? [];
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const getResponses = await jmapPost(
|
||||
apiUrl,
|
||||
authHeader,
|
||||
[['Email/get', { accountId: jmapAccountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
|
||||
);
|
||||
return (getResponses[0]?.[1]?.list as Email[]) ?? [];
|
||||
/** Forget every cached session/mailbox list (logout, credential change, tests). */
|
||||
export function resetUnifiedCache(accountId?: string): void {
|
||||
if (accountId) entries.delete(accountId);
|
||||
else entries.clear();
|
||||
}
|
||||
|
||||
async function fetchInboxForAccount(
|
||||
accountId: string,
|
||||
limit: number,
|
||||
includeGroup: boolean,
|
||||
): Promise<UnifiedEmail[]> {
|
||||
function isLiveAccount(accountId: string): boolean {
|
||||
if (!jmapClient.isConnected) return false;
|
||||
const username = jmapClient.username;
|
||||
const serverUrl = jmapClient.serverUrl;
|
||||
if (!username || !serverUrl) return false;
|
||||
// Same rule as generateAccountId(username, serverUrl) in lib/account-utils.
|
||||
let host = serverUrl;
|
||||
try { host = new URL(serverUrl).hostname; } catch { host = serverUrl.replace(/^https?:\/\//, '').replace(/\/.*$/, ''); }
|
||||
return `${username}@${host}` === accountId;
|
||||
}
|
||||
|
||||
async function entryFor(accountId: string): Promise<AccountEntry> {
|
||||
const live = isLiveAccount(accountId);
|
||||
const cached = entries.get(accountId);
|
||||
if (cached && cached.live === live && Date.now() - cached.discoveredAt < SESSION_CACHE_TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (live && jmapClient.currentSession) {
|
||||
const entry: AccountEntry = {
|
||||
accountId,
|
||||
live: true,
|
||||
transport: { post: async (calls) => (await jmapClient.request(calls)).methodResponses as MethodResponses },
|
||||
primaryJmapId: jmapClient.accountId,
|
||||
shared: jmapClient.getSharedMailAccounts(),
|
||||
discoveredAt: Date.now(),
|
||||
mailboxes: cached?.mailboxes ?? new Map(),
|
||||
};
|
||||
entries.set(accountId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
let creds = await jmapClient.getStoredCredentials(accountId);
|
||||
if (!creds) throw new Error('No stored credentials');
|
||||
creds = await ensureFreshCredentials(accountId, creds);
|
||||
|
||||
const baseUrl = creds.serverUrl.replace(/\/+$/, '');
|
||||
const authHeader = authHeaderFor(creds);
|
||||
|
||||
// Session discovery.
|
||||
const sessionRes = await secureFetch(`${baseUrl}/.well-known/jmap`, {
|
||||
const sessionRes = await fetchWithDeadline(`${baseUrl}/.well-known/jmap`, {
|
||||
headers: { Authorization: authHeader, Accept: 'application/json' },
|
||||
});
|
||||
if (sessionRes.status === 401) throw new Error('Session expired');
|
||||
if (!sessionRes.ok) throw new Error(`Session discovery failed: ${sessionRes.status}`);
|
||||
const session = (await sessionRes.json()) as JMAPSession;
|
||||
const apiUrl = rewriteApiUrl(session, baseUrl);
|
||||
// Point the advertised apiUrl at the origin we actually connected to
|
||||
// (servers often self-report unreachable container-internal hosts) and
|
||||
// resolve relative URLs — same as the live client does.
|
||||
const apiUrl = rewriteSessionUrl(session.apiUrl, extractOrigin(baseUrl)) ?? session.apiUrl;
|
||||
const primaryJmapId = resolveJmapAccountId(session);
|
||||
if (!primaryJmapId) throw new Error('No mail account in session');
|
||||
|
||||
// Always include the user's own primary inbox. When group inboxes are on,
|
||||
// also scan every other (non-personal / shared) account reachable in the
|
||||
// session — these are the group/shared mailboxes the user belongs to.
|
||||
const targets: { jmapId: string; isShared: boolean; label?: string }[] = [
|
||||
{ jmapId: primaryJmapId, isShared: false },
|
||||
];
|
||||
if (includeGroup) {
|
||||
for (const [id, info] of Object.entries(session.accounts ?? {})) {
|
||||
if (id === primaryJmapId) continue;
|
||||
// Only accounts that expose the Mail capability can have an inbox.
|
||||
const hasMail =
|
||||
info.accountCapabilities == null || CAPABILITIES.MAIL in (info.accountCapabilities ?? {});
|
||||
if (!hasMail) continue;
|
||||
targets.push({ jmapId: id, isShared: true, label: info.name });
|
||||
}
|
||||
}
|
||||
|
||||
const perTarget = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const list = await fetchInboxEmailsForJmapAccount(apiUrl, authHeader, target.jmapId, limit);
|
||||
return list.map<UnifiedEmail>((e) => ({
|
||||
...e,
|
||||
sourceAccountId: accountId,
|
||||
jmapAccountId: target.jmapId,
|
||||
isShared: target.isShared,
|
||||
sharedLabel: target.isShared ? target.label : undefined,
|
||||
}));
|
||||
} catch {
|
||||
// A single inaccessible shared account shouldn't sink the whole
|
||||
// account's inbox; skip it and keep the others.
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return perTarget.flat();
|
||||
const entry: AccountEntry = {
|
||||
accountId,
|
||||
live: false,
|
||||
transport: {
|
||||
post: async (calls) => {
|
||||
try {
|
||||
return await jmapPost(apiUrl, authHeader, calls);
|
||||
} catch (err) {
|
||||
// A dead session must not be reused for the next refresh.
|
||||
if (err instanceof Error && err.message === 'Session expired') entries.delete(accountId);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
primaryJmapId,
|
||||
shared: sharedMailAccountsOf(session, primaryJmapId),
|
||||
discoveredAt: Date.now(),
|
||||
mailboxes: cached?.mailboxes ?? new Map(),
|
||||
};
|
||||
entries.set(accountId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function mailboxesFor(entry: AccountEntry, jmapAccountId: string, force = false): Promise<Mailbox[]> {
|
||||
const cached = entry.mailboxes.get(jmapAccountId);
|
||||
if (cached && !force && Date.now() - cached.at < MAILBOX_CACHE_TTL_MS) return cached.list;
|
||||
const res = await entry.transport.post([
|
||||
['Mailbox/get', {
|
||||
accountId: jmapAccountId,
|
||||
properties: ['id', 'role', 'name', 'parentId', 'unreadEmails', 'totalEmails', 'myRights'],
|
||||
}, '0'],
|
||||
]);
|
||||
const [name, body] = res[0] ?? [];
|
||||
if (name !== 'Mailbox/get') throw new Error(body?.description || 'Mailbox/get failed');
|
||||
const list = ((body.list as Mailbox[]) ?? []).map((m) => ({ ...m, accountId: jmapAccountId }));
|
||||
entry.mailboxes.set(jmapAccountId, { list, at: Date.now() });
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Cached mailbox lists per JMAP account of one registry account (for counts). */
|
||||
export function cachedUnifiedMailboxes(accountId: string): Array<{ jmapAccountId: string; mailboxes: Mailbox[] }> {
|
||||
const entry = entries.get(accountId);
|
||||
if (!entry) return [];
|
||||
return [...entry.mailboxes.entries()].map(([jmapAccountId, v]) => ({ jmapAccountId, mailboxes: v.list }));
|
||||
}
|
||||
|
||||
// ── Queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
function findByRole(mailboxes: Mailbox[], role: UnifiedRole): Mailbox | undefined {
|
||||
if (role === 'junk') return mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
|
||||
return mailboxes.find((m) => m.role === role);
|
||||
}
|
||||
|
||||
/** Folders of one JMAP account that feed the cross views: inbox + custom folders. */
|
||||
export function crossIncludedMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||
return mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? ''));
|
||||
}
|
||||
|
||||
function buildFilter(
|
||||
mailboxes: Mailbox[],
|
||||
opts: UnifiedFetchOptions,
|
||||
): Record<string, unknown> | null {
|
||||
const conditions: Record<string, unknown>[] = [];
|
||||
if (opts.view) {
|
||||
const included = crossIncludedMailboxes(mailboxes);
|
||||
if (included.length === 0) return null;
|
||||
conditions.push(
|
||||
included.length === 1
|
||||
? { inMailbox: included[0].id }
|
||||
: { operator: 'OR', conditions: included.map((m) => ({ inMailbox: m.id })) },
|
||||
);
|
||||
if (opts.view === 'unread') conditions.push({ notKeyword: '$seen' });
|
||||
if (opts.view === 'starred') conditions.push({ hasKeyword: '$flagged' });
|
||||
} else {
|
||||
const target = findByRole(mailboxes, opts.role ?? 'inbox');
|
||||
if (!target) return null;
|
||||
conditions.push({ inMailbox: target.id });
|
||||
}
|
||||
const q = opts.query?.trim();
|
||||
if (q) conditions.push({ text: toWildcardQuery(q) });
|
||||
return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions };
|
||||
}
|
||||
|
||||
function targetKey(accountId: string, jmapAccountId: string): string {
|
||||
return `${accountId}|${jmapAccountId}`;
|
||||
}
|
||||
|
||||
async function fetchTarget(
|
||||
entry: AccountEntry,
|
||||
target: { jmapId: string; isShared: boolean; label?: string },
|
||||
opts: UnifiedFetchOptions,
|
||||
position: number,
|
||||
limit: number,
|
||||
): Promise<{ emails: UnifiedEmail[]; hasMore: boolean }> {
|
||||
const mailboxes = await mailboxesFor(entry, target.jmapId);
|
||||
const filter = buildFilter(mailboxes, opts);
|
||||
if (!filter) return { emails: [], hasMore: false };
|
||||
|
||||
const res = await entry.transport.post([
|
||||
['Email/query', {
|
||||
accountId: target.jmapId,
|
||||
filter,
|
||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
||||
position,
|
||||
limit,
|
||||
calculateTotal: true,
|
||||
}, '0'],
|
||||
['Email/get', {
|
||||
accountId: target.jmapId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: EMAIL_LIST_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
const [qName, qBody] = res[0] ?? [];
|
||||
if (qName !== 'Email/query') throw new Error(qBody?.description || 'Email/query failed');
|
||||
const ids = (qBody.ids as string[]) ?? [];
|
||||
const total = typeof qBody.total === 'number' ? qBody.total : undefined;
|
||||
const list = ((res[1]?.[1]?.list as Email[]) ?? []);
|
||||
const byId = new Map(list.map((e) => [e.id, e]));
|
||||
const nameOf = (e: Email): string | undefined =>
|
||||
mailboxes.find((m) => e.mailboxIds?.[m.id])?.name;
|
||||
const emails = ids.flatMap((id) => {
|
||||
const e = byId.get(id);
|
||||
if (!e) return [];
|
||||
return [{
|
||||
...e,
|
||||
sourceAccountId: entry.accountId,
|
||||
jmapAccountId: target.jmapId,
|
||||
isShared: target.isShared,
|
||||
sharedLabel: target.isShared ? target.label : undefined,
|
||||
sourceFolder: opts.view ? nameOf(e) : undefined,
|
||||
} as UnifiedEmail];
|
||||
});
|
||||
const hasMore = total !== undefined ? position + ids.length < total : ids.length === limit;
|
||||
return { emails, hasMore };
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the unified view over `accountIds`. Every target (a registry
|
||||
* account's own mailbox plus, with `includeGroup`, each group/shared account
|
||||
* reachable through it) is queried at its own `positions` cursor and the
|
||||
* results are merged newest-first. Per-account failures are collected in
|
||||
* `errors` while the others still show.
|
||||
*/
|
||||
export async function fetchUnifiedInbox(
|
||||
accountIds: string[],
|
||||
perAccountLimit = 25,
|
||||
opts: { includeGroup?: boolean } = {},
|
||||
opts: UnifiedFetchOptions = {},
|
||||
): Promise<UnifiedInboxResult> {
|
||||
const { includeGroup = false } = opts;
|
||||
const errors: Record<string, string> = {};
|
||||
const positions: Record<string, number> = { ...(opts.positions ?? {}) };
|
||||
let hasMore = false;
|
||||
|
||||
const settled = await Promise.all(
|
||||
accountIds.map(async (id) => {
|
||||
accountIds.map(async (accountId) => {
|
||||
try {
|
||||
return await fetchInboxForAccount(id, perAccountLimit, includeGroup);
|
||||
const entry = await entryFor(accountId);
|
||||
const targets: { jmapId: string; isShared: boolean; label?: string }[] = [
|
||||
{ jmapId: entry.primaryJmapId, isShared: false },
|
||||
];
|
||||
if (includeGroup) {
|
||||
for (const acc of entry.shared) targets.push({ jmapId: acc.id, isShared: true, label: acc.name });
|
||||
}
|
||||
const perTarget = await Promise.all(targets.map(async (target) => {
|
||||
const key = targetKey(accountId, target.jmapId);
|
||||
const position = positions[key] ?? 0;
|
||||
try {
|
||||
const page = await fetchTarget(entry, target, opts, position, perAccountLimit);
|
||||
positions[key] = position + page.emails.length;
|
||||
if (page.hasMore) hasMore = true;
|
||||
return page.emails;
|
||||
} catch (err) {
|
||||
// A single inaccessible shared account shouldn't sink the whole
|
||||
// account's view; skip it and keep the others. Own-mail failures
|
||||
// are the account's failure.
|
||||
if (!target.isShared) throw err;
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}));
|
||||
return perTarget.flat();
|
||||
} catch (err) {
|
||||
errors[id] = err instanceof Error ? err.message : 'Failed to load';
|
||||
errors[accountId] = err instanceof Error ? err.message : 'Failed to load';
|
||||
return [] as UnifiedEmail[];
|
||||
}
|
||||
}),
|
||||
@@ -253,5 +472,127 @@ export async function fetchUnifiedInbox(
|
||||
const emails = settled
|
||||
.flat()
|
||||
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
return { emails, errors };
|
||||
return { emails, errors, positions, hasMore };
|
||||
}
|
||||
|
||||
// ── Actions routed to the owning account ────────────────────────────────
|
||||
|
||||
async function emailSet(
|
||||
accountId: string,
|
||||
jmapAccountId: string,
|
||||
update: Record<string, Record<string, unknown>>,
|
||||
destroy?: string[],
|
||||
): Promise<void> {
|
||||
const entry = await entryFor(accountId);
|
||||
const args: Record<string, unknown> = { accountId: jmapAccountId };
|
||||
if (Object.keys(update).length > 0) args.update = update;
|
||||
if (destroy && destroy.length > 0) args.destroy = destroy;
|
||||
const res = await entry.transport.post([['Email/set', args, '0']]);
|
||||
const [name, body] = res[0] ?? [];
|
||||
if (name !== 'Email/set') throw new Error(body?.description || 'Email/set failed');
|
||||
const failures = { ...(body.notUpdated ?? {}), ...(body.notDestroyed ?? {}) } as Record<string, { type?: string; description?: string }>;
|
||||
const first = Object.entries(failures)[0];
|
||||
if (first) throw new Error(`${first[1]?.type ?? 'error'}${first[1]?.description ? `: ${first[1].description}` : ''}`);
|
||||
}
|
||||
|
||||
function groupByOwner(emails: UnifiedEmail[]): Map<string, { accountId: string; jmapAccountId: string; emails: UnifiedEmail[] }> {
|
||||
const groups = new Map<string, { accountId: string; jmapAccountId: string; emails: UnifiedEmail[] }>();
|
||||
for (const e of emails) {
|
||||
const key = targetKey(e.sourceAccountId, e.jmapAccountId);
|
||||
const g = groups.get(key);
|
||||
if (g) g.emails.push(e);
|
||||
else groups.set(key, { accountId: e.sourceAccountId, jmapAccountId: e.jmapAccountId, emails: [e] });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Patch keywords (`{ $seen: true, $flagged: null }`) on messages of any account. */
|
||||
export async function patchUnifiedKeywords(
|
||||
emails: UnifiedEmail[],
|
||||
patch: Record<string, boolean | null>,
|
||||
): Promise<void> {
|
||||
const pointer: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(patch)) pointer[keywordPointer(k)] = v === false ? null : v;
|
||||
for (const g of groupByOwner(emails).values()) {
|
||||
const update: Record<string, Record<string, unknown>> = {};
|
||||
for (const e of g.emails) update[e.id] = { ...pointer };
|
||||
await emailSet(g.accountId, g.jmapAccountId, update);
|
||||
}
|
||||
}
|
||||
|
||||
export type UnifiedMoveTarget = 'archive' | 'trash' | 'junk' | 'inbox';
|
||||
|
||||
/**
|
||||
* Move messages into the role folder of their *own* account (archive into
|
||||
* the owner's Archive, …). Spam/not-spam also flip `$junk`/`$notjunk` (#850).
|
||||
* Throws when an owning account lacks the target folder.
|
||||
*/
|
||||
export async function moveUnifiedEmails(
|
||||
emails: UnifiedEmail[],
|
||||
target: UnifiedMoveTarget,
|
||||
opts: { markRead?: boolean } = {},
|
||||
): Promise<void> {
|
||||
for (const g of groupByOwner(emails).values()) {
|
||||
const entry = await entryFor(g.accountId);
|
||||
const mailboxes = await mailboxesFor(entry, g.jmapAccountId);
|
||||
const dest = findByRole(mailboxes, target);
|
||||
if (!dest) throw new Error(`No ${target} folder in this account`);
|
||||
const update: Record<string, Record<string, unknown>> = {};
|
||||
for (const e of g.emails) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const id of Object.keys(e.mailboxIds ?? {})) {
|
||||
if (id !== dest.id) patch[mailboxPointer(id)] = null;
|
||||
}
|
||||
patch[mailboxPointer(dest.id)] = true;
|
||||
if (target === 'junk') {
|
||||
patch[keywordPointer('$junk')] = true;
|
||||
patch[keywordPointer('$notjunk')] = null;
|
||||
} else if (target === 'inbox') {
|
||||
patch[keywordPointer('$junk')] = null;
|
||||
patch[keywordPointer('$notjunk')] = true;
|
||||
}
|
||||
if (opts.markRead) patch[keywordPointer('$seen')] = true;
|
||||
update[e.id] = patch;
|
||||
}
|
||||
await emailSet(g.accountId, g.jmapAccountId, update);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete like the folder list does: move to the owner's Trash, or destroy
|
||||
* outright when the message already sits in Trash or `permanent` is set.
|
||||
*/
|
||||
export async function deleteUnifiedEmails(
|
||||
emails: UnifiedEmail[],
|
||||
opts: { permanent?: boolean; markRead?: boolean } = {},
|
||||
): Promise<{ destroyed: number; trashed: number }> {
|
||||
let destroyed = 0;
|
||||
let trashed = 0;
|
||||
for (const g of groupByOwner(emails).values()) {
|
||||
const entry = await entryFor(g.accountId);
|
||||
const mailboxes = await mailboxesFor(entry, g.jmapAccountId);
|
||||
const trash = findByRole(mailboxes, 'trash');
|
||||
const toDestroy = g.emails.filter((e) => opts.permanent || !trash || !!(trash && e.mailboxIds?.[trash.id]));
|
||||
const toTrash = g.emails.filter((e) => !toDestroy.includes(e));
|
||||
if (toDestroy.length > 0 && !trash && !opts.permanent) {
|
||||
throw new Error('No Trash folder in this account');
|
||||
}
|
||||
if (toDestroy.length > 0) {
|
||||
await emailSet(g.accountId, g.jmapAccountId, {}, toDestroy.map((e) => e.id));
|
||||
destroyed += toDestroy.length;
|
||||
}
|
||||
if (toTrash.length > 0 && trash) {
|
||||
await moveUnifiedEmails(toTrash, 'trash', { markRead: opts.markRead });
|
||||
trashed += toTrash.length;
|
||||
}
|
||||
}
|
||||
return { destroyed, trashed };
|
||||
}
|
||||
|
||||
/** Whether a message sits in its account's Trash (drives the permanent-delete confirm). */
|
||||
export function isInUnifiedTrash(email: UnifiedEmail): boolean {
|
||||
const entry = entries.get(email.sourceAccountId);
|
||||
const list = entry?.mailboxes.get(email.jmapAccountId)?.list ?? [];
|
||||
const trash = findByRole(list, 'trash');
|
||||
return !!trash && !!email.mailboxIds?.[trash.id];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,47 +11,70 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
|
||||
Plus, Pencil, Trash2, X,
|
||||
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive, Flag, Star, Mails,
|
||||
StickyNote, Clock, AlarmClock, Users, Plus, Pencil, Trash2, X,
|
||||
} from 'lucide-react-native';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { SettingsSection, Select } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { ownMailboxes } from '../../lib/mailbox-tree';
|
||||
import { ownMailboxes, mailboxSubtreeIds } from '../../lib/mailbox-tree';
|
||||
import { localizeMailboxName } from '../../lib/mailbox-label';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
inbox: 'Inbox', drafts: 'Drafts', sent: 'Sent', trash: 'Trash',
|
||||
junk: 'Junk', archive: 'Archive', important: 'Important', all: 'All',
|
||||
};
|
||||
const ROLE_ICON: Record<string, any> = {
|
||||
inbox: Inbox, drafts: FileText, sent: Send, trash: Trash,
|
||||
junk: ShieldAlert, archive: Archive,
|
||||
junk: ShieldAlert, spam: ShieldAlert, archive: Archive, important: Flag, flagged: Star,
|
||||
all: Mails, memos: StickyNote, scheduled: Clock, snoozed: AlarmClock, shared: Users,
|
||||
};
|
||||
|
||||
// Special-use roles a user folder can be given (RFC 8621 §2 + the common
|
||||
// Stalwart extras). The server enforces uniqueness per account.
|
||||
const ASSIGNABLE_ROLES = ['inbox', 'drafts', 'sent', 'archive', 'junk', 'trash', 'important', 'all', 'flagged', 'memos', 'scheduled', 'snoozed'];
|
||||
|
||||
function getIcon(mb: Mailbox) {
|
||||
if (mb.role && ROLE_ICON[mb.role]) return ROLE_ICON[mb.role];
|
||||
return Folder;
|
||||
}
|
||||
|
||||
const NO_PARENT = '__root__';
|
||||
const OWN_ACCOUNT = '__own__';
|
||||
const NO_ROLE = '__none__';
|
||||
|
||||
type Editor =
|
||||
| { kind: 'create' }
|
||||
| { kind: 'rename'; mailbox: Mailbox };
|
||||
| { kind: 'edit'; mailbox: Mailbox };
|
||||
|
||||
/** "Parent / Child" path used to label folders in the pickers. */
|
||||
function pathOf(all: Mailbox[], mb: Mailbox, t: (k: string, f?: string) => string): string {
|
||||
const byId = new Map(all.map((m) => [m.id, m]));
|
||||
const parts = [localizeMailboxName(mb.role, mb.name, t)];
|
||||
let node: Mailbox | undefined = mb;
|
||||
let guard = 0;
|
||||
while (node?.parentId && guard++ < 16) {
|
||||
node = byId.get(node.parentId);
|
||||
if (node) parts.unshift(localizeMailboxName(node.role, node.name, t));
|
||||
}
|
||||
return parts.join(' / ');
|
||||
}
|
||||
|
||||
export function FolderSettings() {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
// Folder management (create/rename/delete) targets the user's own account;
|
||||
// shared (group account) folders are read-only here.
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const allMailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const mailboxes = React.useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]);
|
||||
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
||||
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [draftName, setDraftName] = useState('');
|
||||
const [draftParent, setDraftParent] = useState<string>(NO_PARENT);
|
||||
const [draftAccount, setDraftAccount] = useState<string>(OWN_ACCOUNT);
|
||||
const [draftRole, setDraftRole] = useState<string>(NO_ROLE);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -59,11 +82,38 @@ export function FolderSettings() {
|
||||
if (mailboxes.length === 0) void fetchMailboxes();
|
||||
}, [mailboxes.length, fetchMailboxes]);
|
||||
|
||||
// Shared/group accounts the user may create folders in (webmail: "New
|
||||
// folder" on a shared account header routes to the owner account).
|
||||
const sharedAccounts = React.useMemo(() => {
|
||||
const out: { id: string; name: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const m of allMailboxes) {
|
||||
if (!m.isShared || !m.accountId || seen.has(m.accountId)) continue;
|
||||
if (m.myRights?.mayCreateChild === false) continue;
|
||||
seen.add(m.accountId);
|
||||
out.push({ id: m.accountId, name: m.accountName || jmapClient.getAccountName(m.accountId) || m.accountId });
|
||||
}
|
||||
return out;
|
||||
}, [allMailboxes]);
|
||||
|
||||
// Folders of the account the editor targets, for the parent picker.
|
||||
const parentCandidates = React.useMemo(() => {
|
||||
const pool = draftAccount === OWN_ACCOUNT
|
||||
? mailboxes
|
||||
: allMailboxes.filter((m) => m.isShared && m.accountId === draftAccount);
|
||||
// Moving a folder under itself or one of its descendants is impossible.
|
||||
const excluded = editor?.kind === 'edit' ? new Set(mailboxSubtreeIds(pool, editor.mailbox.id)) : new Set<string>();
|
||||
return pool
|
||||
.filter((m) => !excluded.has(m.id) && m.myRights?.mayCreateChild !== false)
|
||||
.map((m) => ({ value: m.id, label: pathOf(pool, m, t) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [draftAccount, mailboxes, allMailboxes, editor, t]);
|
||||
|
||||
const sorted = [...mailboxes].sort((a, b) => {
|
||||
const ar = a.role ? 0 : 1;
|
||||
const br = b.role ? 0 : 1;
|
||||
if (ar !== br) return ar - br;
|
||||
return a.name.localeCompare(b.name);
|
||||
return pathOf(mailboxes, a, t).localeCompare(pathOf(mailboxes, b, t));
|
||||
});
|
||||
|
||||
const totalUnread = mailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0);
|
||||
@@ -71,15 +121,17 @@ export function FolderSettings() {
|
||||
const openCreate = () => {
|
||||
setEditor({ kind: 'create' });
|
||||
setDraftName('');
|
||||
setDraftParent(NO_PARENT);
|
||||
setDraftAccount(OWN_ACCOUNT);
|
||||
setDraftRole(NO_ROLE);
|
||||
};
|
||||
|
||||
const openRename = (mailbox: Mailbox) => {
|
||||
if (mailbox.role) {
|
||||
Alert.alert('Cannot rename', 'System folders cannot be renamed.');
|
||||
return;
|
||||
}
|
||||
setEditor({ kind: 'rename', mailbox });
|
||||
const openEdit = (mailbox: Mailbox) => {
|
||||
setEditor({ kind: 'edit', mailbox });
|
||||
setDraftName(mailbox.name);
|
||||
setDraftParent(mailbox.parentId ?? NO_PARENT);
|
||||
setDraftAccount(OWN_ACCOUNT);
|
||||
setDraftRole(mailbox.role ?? NO_ROLE);
|
||||
};
|
||||
|
||||
const closeEditor = () => setEditor(null);
|
||||
@@ -87,20 +139,37 @@ export function FolderSettings() {
|
||||
const saveDraft = async () => {
|
||||
const name = draftName.trim();
|
||||
if (!name) {
|
||||
Alert.alert('Name required');
|
||||
Alert.alert(t('settings.folders.name_required', 'Name required'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editor?.kind === 'create') {
|
||||
await createMailbox({ name });
|
||||
} else if (editor?.kind === 'rename') {
|
||||
await updateMailbox(editor.mailbox.id, { name });
|
||||
const accountId = draftAccount === OWN_ACCOUNT ? undefined : draftAccount;
|
||||
const parentId = draftParent === NO_PARENT ? null : draftParent;
|
||||
const raw = parentId
|
||||
? allMailboxes.find((m) => m.id === parentId)?.originalId ?? parentId
|
||||
: null;
|
||||
await createMailbox(
|
||||
{ name, parentId: raw, ...(draftRole !== NO_ROLE ? { role: draftRole } : {}) },
|
||||
accountId,
|
||||
);
|
||||
} else if (editor?.kind === 'edit') {
|
||||
const mb = editor.mailbox;
|
||||
const changes: { name?: string; parentId?: string | null; role?: string | null } = {};
|
||||
if (name !== mb.name && !mb.role) changes.name = name;
|
||||
const nextParent = draftParent === NO_PARENT ? null : draftParent;
|
||||
if ((mb.parentId ?? null) !== nextParent) changes.parentId = nextParent;
|
||||
const nextRole = draftRole === NO_ROLE ? null : draftRole;
|
||||
if ((mb.role ?? null) !== nextRole) changes.role = nextRole;
|
||||
if (Object.keys(changes).length > 0) await updateMailbox(mb.id, changes);
|
||||
}
|
||||
closeEditor();
|
||||
// A reparent moves the whole subtree: re-read the tree rather than
|
||||
// patching one node (#855).
|
||||
await fetchMailboxes();
|
||||
} catch (err) {
|
||||
Alert.alert('Save failed', err instanceof Error ? err.message : String(err));
|
||||
Alert.alert(t('settings.folders.save_failed', 'Save failed'), err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -108,47 +177,66 @@ export function FolderSettings() {
|
||||
|
||||
const confirmDelete = (mailbox: Mailbox) => {
|
||||
if (mailbox.role) {
|
||||
Alert.alert('Cannot delete', 'System folders cannot be removed.');
|
||||
Alert.alert(t('settings.folders.cannot_delete', 'Cannot delete'), t('settings.folders.system_folder_delete', 'System folders cannot be removed.'));
|
||||
return;
|
||||
}
|
||||
const name = mailbox.name;
|
||||
if ((mailbox.totalEmails ?? 0) > 0) {
|
||||
Alert.alert(
|
||||
'Folder not empty',
|
||||
`"${mailbox.name}" contains ${mailbox.totalEmails} email${mailbox.totalEmails === 1 ? '' : 's'}. Delete anyway?`,
|
||||
t('settings.folders.not_empty_title', 'Folder not empty'),
|
||||
t('settings.folders.not_empty_message', `"${name}" contains ${mailbox.totalEmails} emails. Delete anyway?`, { name, count: mailbox.totalEmails }),
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } },
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
||||
{ text: t('common.delete', 'Delete'), style: 'destructive', onPress: () => { void performDelete(mailbox, true); } },
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
'Delete folder',
|
||||
`Permanently delete "${mailbox.name}"?`,
|
||||
t('mailbox_context_menu.delete_confirm_title', 'Delete folder'),
|
||||
t('mailbox_context_menu.delete_confirm_message', `Permanently delete the folder "${name}"? This action cannot be undone.`, { name }),
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } },
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
||||
{ text: t('common.delete', 'Delete'), style: 'destructive', onPress: () => { void performDelete(mailbox, false); } },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const performDelete = async (mailbox: Mailbox) => {
|
||||
const performDelete = async (mailbox: Mailbox, removeEmails: boolean) => {
|
||||
setBusyId(mailbox.id);
|
||||
try {
|
||||
await deleteMailbox(mailbox.id);
|
||||
await deleteMailbox(mailbox.id, undefined, { onDestroyRemoveEmails: removeEmails });
|
||||
await fetchMailboxes();
|
||||
} catch (err) {
|
||||
Alert.alert('Delete failed', err instanceof Error ? err.message : String(err));
|
||||
Alert.alert(t('mailbox_context_menu.toast_error_delete', 'Failed to delete folder'), err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const roleOptions = React.useMemo(() => {
|
||||
const takenByOthers = new Set(
|
||||
mailboxes
|
||||
.filter((m) => m.role && (editor?.kind !== 'edit' || m.id !== editor.mailbox.id))
|
||||
.map((m) => m.role as string),
|
||||
);
|
||||
return [
|
||||
{ value: NO_ROLE, label: t('settings.folders.role_none', 'No special role') },
|
||||
...ASSIGNABLE_ROLES
|
||||
.filter((r) => !takenByOthers.has(r))
|
||||
.map((r) => ({ value: r, label: localizeMailboxName(r, r.charAt(0).toUpperCase() + r.slice(1), t) })),
|
||||
];
|
||||
}, [mailboxes, editor, t]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SettingsSection
|
||||
title="Folders"
|
||||
description={`${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread. Tap a folder to rename, long-press to delete.`}
|
||||
title={t('settings.folders.title', 'Folders')}
|
||||
description={t(
|
||||
'settings.folders.description_mobile',
|
||||
`${mailboxes.length} folders, ${totalUnread} unread. Tap a folder to edit, long-press to delete.`,
|
||||
{ count: mailboxes.length, unread: totalUnread },
|
||||
)}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<Button
|
||||
@@ -157,7 +245,7 @@ export function FolderSettings() {
|
||||
onPress={openCreate}
|
||||
icon={<Plus size={14} color={c.primaryForeground} />}
|
||||
>
|
||||
New folder
|
||||
{t('settings.folders.new_folder', 'New folder')}
|
||||
</Button>
|
||||
</View>
|
||||
{mailboxes.length === 0 ? (
|
||||
@@ -168,24 +256,24 @@ export function FolderSettings() {
|
||||
<View>
|
||||
{sorted.map((mb) => {
|
||||
const Icon = getIcon(mb);
|
||||
const editable = !mb.role;
|
||||
const depth = pathOf(mailboxes, mb, t).split(' / ').length - 1;
|
||||
return (
|
||||
<Pressable
|
||||
key={mb.id}
|
||||
onPress={() => editable && openRename(mb)}
|
||||
onLongPress={() => editable && confirmDelete(mb)}
|
||||
disabled={!editable && busyId !== mb.id}
|
||||
onPress={() => openEdit(mb)}
|
||||
onLongPress={() => !mb.role && confirmDelete(mb)}
|
||||
style={({ pressed }) => [
|
||||
styles.folderRow,
|
||||
editable && pressed && styles.folderRowPressed,
|
||||
pressed && styles.folderRowPressed,
|
||||
{ paddingLeft: spacing.md + depth * 12 },
|
||||
]}
|
||||
>
|
||||
<View style={styles.folderLeft}>
|
||||
<Icon size={16} color={mb.role ? c.primary : c.mutedForeground} />
|
||||
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
|
||||
{mb.role && ROLE_LABEL[mb.role] && (
|
||||
<Text style={styles.folderName} numberOfLines={1}>{localizeMailboxName(mb.role, mb.name, t)}</Text>
|
||||
{mb.role && (
|
||||
<View style={styles.rolePill}>
|
||||
<Text style={styles.rolePillText}>{ROLE_LABEL[mb.role]}</Text>
|
||||
<Text style={styles.rolePillText}>{mb.role}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -196,12 +284,10 @@ export function FolderSettings() {
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.total}>{mb.totalEmails}</Text>
|
||||
{editable && (
|
||||
busyId === mb.id ? (
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
) : (
|
||||
<Pencil size={14} color={c.textMuted} />
|
||||
)
|
||||
{busyId === mb.id ? (
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
) : (
|
||||
<Pencil size={14} color={c.textMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -216,23 +302,68 @@ export function FolderSettings() {
|
||||
<View style={styles.modalSheet}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>
|
||||
{editor?.kind === 'rename' ? `Rename "${editor.mailbox.name}"` : 'New folder'}
|
||||
{editor?.kind === 'edit'
|
||||
? t('settings.folders.edit_folder', `Edit "${editor.mailbox.name}"`, { name: editor.mailbox.name })
|
||||
: t('settings.folders.new_folder', 'New folder')}
|
||||
</Text>
|
||||
<Pressable onPress={closeEditor} hitSlop={8}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.modalBody}>
|
||||
<Text style={styles.fieldLabel}>Folder name</Text>
|
||||
<ScrollView contentContainerStyle={styles.modalBody} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.fieldLabel}>{t('settings.folders.folder_name', 'Folder name')}</Text>
|
||||
<TextInput
|
||||
value={draftName}
|
||||
onChangeText={setDraftName}
|
||||
placeholder="Receipts"
|
||||
placeholder={t('settings.folders.name_placeholder', 'Receipts')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
autoFocus
|
||||
editable={editor?.kind !== 'edit' || !editor.mailbox.role}
|
||||
autoFocus={editor?.kind === 'create'}
|
||||
/>
|
||||
{editor?.kind === 'rename' && (
|
||||
{editor?.kind === 'edit' && editor.mailbox.role ? (
|
||||
<Text style={styles.hint}>{t('settings.folders.system_folder_rename', 'System folders cannot be renamed.')}</Text>
|
||||
) : null}
|
||||
|
||||
{editor?.kind === 'create' && sharedAccounts.length > 0 && (
|
||||
<>
|
||||
<Text style={styles.fieldLabel}>{t('settings.folders.account', 'Account')}</Text>
|
||||
<Select
|
||||
value={draftAccount}
|
||||
onChange={(v) => { setDraftAccount(v); setDraftParent(NO_PARENT); }}
|
||||
options={[
|
||||
{ value: OWN_ACCOUNT, label: t('settings.folders.own_account', 'My folders') },
|
||||
...sharedAccounts.map((a) => ({ value: a.id, label: a.name })),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={styles.fieldLabel}>
|
||||
{editor?.kind === 'edit'
|
||||
? t('settings.folders.move_under', 'Move under')
|
||||
: t('settings.folders.subfolder_of', 'Parent folder')}
|
||||
</Text>
|
||||
<Select
|
||||
value={draftParent}
|
||||
onChange={setDraftParent}
|
||||
options={[
|
||||
{ value: NO_PARENT, label: t('settings.folders.top_level', 'Top level') },
|
||||
...parentCandidates,
|
||||
]}
|
||||
/>
|
||||
|
||||
{draftAccount === OWN_ACCOUNT && (
|
||||
<>
|
||||
<Text style={styles.fieldLabel}>{t('settings.folders.role', 'Special use')}</Text>
|
||||
<Select value={draftRole} onChange={setDraftRole} options={roleOptions} />
|
||||
<Text style={styles.hint}>
|
||||
{t('settings.folders.role_hint', 'Assign a special-use role (Archive, Junk, …) to this folder. Each role can be held by one folder.')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editor?.kind === 'edit' && !editor.mailbox.role && (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
closeEditor();
|
||||
@@ -241,19 +372,19 @@ export function FolderSettings() {
|
||||
style={styles.deleteRow}
|
||||
>
|
||||
<Trash2 size={14} color={c.error} />
|
||||
<Text style={styles.deleteRowText}>Delete folder</Text>
|
||||
<Text style={styles.deleteRowText}>{t('mailbox_context_menu.delete_folder', 'Delete folder')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
<View style={styles.modalActions}>
|
||||
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>Cancel</Button>
|
||||
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={() => { void saveDraft(); }}
|
||||
loading={saving}
|
||||
>
|
||||
Save
|
||||
{t('common.save', 'Save')}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -293,7 +424,7 @@ function makeStyles(c: ThemePalette) {
|
||||
modalSheet: {
|
||||
backgroundColor: c.background,
|
||||
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
|
||||
maxHeight: '80%',
|
||||
maxHeight: '85%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
@@ -303,6 +434,7 @@ function makeStyles(c: ThemePalette) {
|
||||
modalTitle: { ...typography.h3, color: c.text, flexShrink: 1 },
|
||||
modalBody: { padding: spacing.lg, gap: spacing.md },
|
||||
fieldLabel: { ...typography.captionMedium, color: c.textSecondary },
|
||||
hint: { ...typography.caption, color: c.textMuted },
|
||||
input: {
|
||||
...typography.body, color: c.text,
|
||||
backgroundColor: c.surface,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
|
||||
import { Plus, Pencil, Trash2, Check, X, RotateCcw } from 'lucide-react-native';
|
||||
import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native';
|
||||
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from 'lucide-react-native';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useKeywordsStore, type KeywordDef } from '../../stores/keywords-store';
|
||||
import { DARK_COLORS } from '../../theme/tokens';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { discoverKeywords } from '../../api/keyword-discovery';
|
||||
import { findUnrecognizedKeywords, type UnrecognizedKeyword } from '../../lib/keyword-discovery';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
|
||||
type Keyword = KeywordDef;
|
||||
|
||||
@@ -46,6 +49,51 @@ export function KeywordSettings() {
|
||||
removeKeyword(id);
|
||||
};
|
||||
|
||||
// One stray tap used to wipe a carefully built tag list (webmail removed
|
||||
// the button in 1.8.1); keep it behind a confirm.
|
||||
const confirmReset = () => {
|
||||
Alert.alert(
|
||||
t('settings.keywords.reset_defaults', 'Reset to Defaults'),
|
||||
t('settings.keywords.reset_confirm', 'Replace your tag list with the default tags? Tags already set on messages stay on the server.'),
|
||||
[
|
||||
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
||||
{ text: t('settings.keywords.reset_defaults', 'Reset to Defaults'), style: 'destructive', onPress: resetDefaults },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
// Scan the mailbox for `$label:` keywords no local tag explains (#658) and
|
||||
// offer to add them with a proposed name and colour.
|
||||
const [scan, setScan] = useState<{
|
||||
phase: 'idle' | 'running' | 'done' | 'error';
|
||||
scanned: number;
|
||||
total: number;
|
||||
complete: boolean;
|
||||
found: UnrecognizedKeyword[];
|
||||
error?: string;
|
||||
}>({ phase: 'idle', scanned: 0, total: 0, complete: false, found: [] });
|
||||
const scanAbort = React.useRef({ aborted: false });
|
||||
const runScan = async () => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
scanAbort.current = { aborted: false };
|
||||
setScan({ phase: 'running', scanned: 0, total: 0, complete: false, found: [] });
|
||||
try {
|
||||
const result = await discoverKeywords({
|
||||
signal: scanAbort.current,
|
||||
onProgress: (scanned, total) => setScan((s) => ({ ...s, scanned, total })),
|
||||
});
|
||||
const found = findUnrecognizedKeywords(result.keywords, useKeywordsStore.getState().keywords);
|
||||
setScan({ phase: 'done', scanned: result.scanned, total: result.total, complete: result.complete, found });
|
||||
} catch (err) {
|
||||
setScan((s) => ({ ...s, phase: 'error', error: err instanceof Error ? err.message : String(err) }));
|
||||
}
|
||||
};
|
||||
useEffect(() => () => { scanAbort.current.aborted = true; }, []);
|
||||
const adoptFound = (kw: UnrecognizedKeyword) => {
|
||||
addKeyword({ id: kw.id, label: kw.label, color: kw.color });
|
||||
setScan((s) => ({ ...s, found: s.found.filter((f) => f.id !== kw.id) }));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('settings.keywords.title', "Email Tags")} description={t('settings.keywords.description_mobile', "Colored tags to organize your mail.")}>
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
@@ -93,10 +141,56 @@ export function KeywordSettings() {
|
||||
<Plus size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.outlineBtnText}>{t('settings.keywords.add_keyword', "Add Tag")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.outlineBtn} onPress={resetDefaults}>
|
||||
<Pressable style={styles.outlineBtn} onPress={confirmReset}>
|
||||
<RotateCcw size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.outlineBtnText}>{t('settings.keywords.reset_defaults', "Reset to Defaults")}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.outlineBtn} onPress={() => { void runScan(); }} disabled={scan.phase === 'running'}>
|
||||
{scan.phase === 'running' ? (
|
||||
<ActivityIndicator size="small" color={c.mutedForeground} />
|
||||
) : (
|
||||
<ScanSearch size={14} color={c.mutedForeground} />
|
||||
)}
|
||||
<Text style={styles.outlineBtnText}>{t('settings.keywords.discover.scan', 'Find tags in mailbox')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{scan.phase === 'running' && (
|
||||
<Text style={styles.scanStatus}>
|
||||
{t('settings.keywords.discover.progress', `Scanned ${scan.scanned} of ${scan.total} messages…`, { scanned: scan.scanned, total: scan.total })}
|
||||
</Text>
|
||||
)}
|
||||
{scan.phase === 'error' && (
|
||||
<Text style={[styles.scanStatus, { color: c.error }]}>{scan.error}</Text>
|
||||
)}
|
||||
{scan.phase === 'done' && (
|
||||
<View style={styles.scanResults}>
|
||||
<Text style={styles.scanStatus}>
|
||||
{scan.found.length === 0
|
||||
? t('settings.keywords.discover.none', `No unknown tags found in ${scan.scanned} messages.`, { scanned: scan.scanned })
|
||||
: t('settings.keywords.discover.found', `${scan.found.length} tags found that are not defined here.`, { count: scan.found.length })}
|
||||
{!scan.complete ? ` ${t('settings.keywords.discover.partial', '(The scan stopped at the newest messages only.)')}` : ''}
|
||||
</Text>
|
||||
{scan.found.map((kw) => (
|
||||
<View key={kw.id} style={styles.kwRow}>
|
||||
<View style={[styles.kwDot, { backgroundColor: c.tags[kw.color]?.dot ?? c.textMuted }]} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.kwLabel}>{kw.label}</Text>
|
||||
<Text style={styles.kwId}>{kw.keyword} · {kw.count}</Text>
|
||||
</View>
|
||||
<Pressable style={styles.outlineBtn} onPress={() => adoptFound(kw)}>
|
||||
<Plus size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.outlineBtnText}>{t('common.add', 'Add')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
{scan.found.length > 1 && (
|
||||
<Pressable style={styles.outlineBtn} onPress={() => { for (const kw of [...scan.found]) adoptFound(kw); }}>
|
||||
<Plus size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.outlineBtnText}>{t('settings.keywords.discover.add_all', 'Add all')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -229,6 +323,8 @@ function makeStyles(c: ThemePalette) {
|
||||
borderColor: c.border,
|
||||
},
|
||||
outlineBtnText: { ...typography.caption, color: c.mutedForeground },
|
||||
scanStatus: { ...typography.caption, color: c.mutedForeground, paddingTop: spacing.xs },
|
||||
scanResults: { gap: spacing.sm },
|
||||
form: {
|
||||
gap: spacing.md,
|
||||
padding: spacing.md,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findUnrecognizedKeywords, suggestKeywordColor, suggestKeywordLabel } from '../keyword-discovery';
|
||||
|
||||
describe('keyword discovery (#658)', () => {
|
||||
it('proposes humanized labels', () => {
|
||||
expect(suggestKeywordLabel('q3-invoices')).toBe('Q3 Invoices');
|
||||
expect(suggestKeywordLabel('work/clients')).toBe('Work/Clients');
|
||||
});
|
||||
|
||||
it('keeps a palette-named id on its own colour and spreads the rest', () => {
|
||||
expect(suggestKeywordColor('blue')).toBe('blue');
|
||||
const a = suggestKeywordColor('alpha');
|
||||
const b = suggestKeywordColor('alpha', [a]);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('folds prefixes and case into one tag and skips defined ones', () => {
|
||||
const found = findUnrecognizedKeywords(
|
||||
{ '$label:work': 3, '$color:Work': 2, '$label:todo': 1, '$seen': 10, '$label:blue': 4 },
|
||||
[{ id: 'blue', label: 'Blue', color: 'blue' }],
|
||||
);
|
||||
expect(found.map((f) => [f.id, f.count])).toEqual([['work', 5], ['todo', 1]]);
|
||||
expect(found[0].keyword).toBe('$label:work');
|
||||
expect(found[0].label).toBe('Work');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
export const MAX_ACCOUNTS = 5;
|
||||
// Registry cap. Raised from 5: the per-account cost is one stored credential
|
||||
// set and one cached snapshot; the SSE socket budget is bounded separately
|
||||
// (MAX_SSE_STREAMS), so nothing scales with this number at runtime.
|
||||
export const MAX_ACCOUNTS = 10;
|
||||
|
||||
export function generateAccountId(username: string, serverUrl: string): string {
|
||||
let host = serverUrl;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { colors } from '../theme/tokens';
|
||||
import type { KeywordDef, KeywordColor } from '../stores/keywords-store';
|
||||
import { KEYWORD_PREFIX, tagIdFromKeyword } from './thread-utils';
|
||||
|
||||
// Port of the webmail's lib/keyword-discovery.ts (#658): propose a label and
|
||||
// colour for `$label:` / `$color:` keywords found on the server that no local
|
||||
// tag definition explains, so a reinstalled app can recover its tags without
|
||||
// retyping every id.
|
||||
|
||||
/** A keyword found on the server that no tag definition accounts for. */
|
||||
export interface UnrecognizedKeyword {
|
||||
/** The tag id: the keyword with its prefix removed. Fixed - never reworded. */
|
||||
id: string;
|
||||
/** The keyword as it is stored on messages, prefix included. */
|
||||
keyword: string;
|
||||
/** Proposed display name, derived from the id. */
|
||||
label: string;
|
||||
/** Proposed colour, a key of the tag palette. */
|
||||
color: KeywordColor;
|
||||
/**
|
||||
* How many scanned messages carry the tag, under either prefix. A floor
|
||||
* rather than a total when the scan that produced it was incomplete.
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
const KEYWORD_SEPARATOR = '/';
|
||||
const PALETTE_KEYS = Object.keys(colors.tags) as KeywordColor[];
|
||||
|
||||
function keywordLevels(id: string): string[] {
|
||||
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Turns one level of an id into words: `q3-invoices` -> `Q3 Invoices`. */
|
||||
function humanizeLevel(level: string): string {
|
||||
const words = level
|
||||
.split(/[-_]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1));
|
||||
return words.length > 0 ? words.join(' ') : level;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name to propose for a tag whose definition is gone. The native app has
|
||||
* no tag nesting, so the whole id is one opaque token: every level is named.
|
||||
*/
|
||||
export function suggestKeywordLabel(id: string): string {
|
||||
const levels = keywordLevels(id);
|
||||
if (levels.length === 0) return id;
|
||||
return levels.map(humanizeLevel).join(KEYWORD_SEPARATOR);
|
||||
}
|
||||
|
||||
/** A small stable spread over `range`, so the same id always starts at the same hue. */
|
||||
function hashIndex(id: string, range: number): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash) % Math.max(1, range);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour to propose for a tag whose definition is gone. An id that is
|
||||
* itself a palette key keeps that colour (the default tags are named after
|
||||
* their colour, `$label:blue`); otherwise a hue is picked by hashing the id
|
||||
* and walked forward past any colour in `taken`.
|
||||
*/
|
||||
export function suggestKeywordColor(id: string, taken: Iterable<string> = []): KeywordColor {
|
||||
const levels = keywordLevels(id);
|
||||
const own = levels.length > 0 ? levels[levels.length - 1] : id;
|
||||
if ((PALETTE_KEYS as string[]).includes(own)) return own as KeywordColor;
|
||||
|
||||
const used = new Set(taken);
|
||||
const start = hashIndex(id, PALETTE_KEYS.length);
|
||||
for (let step = 0; step < PALETTE_KEYS.length; step++) {
|
||||
const candidate = PALETTE_KEYS[(start + step) % PALETTE_KEYS.length];
|
||||
if (!used.has(candidate)) return candidate;
|
||||
}
|
||||
return PALETTE_KEYS[start];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags in `keywords` (a keyword-to-message-count map, as
|
||||
* `discoverKeywords` returns) that no definition in `defined` explains, each
|
||||
* with a name and colour to propose. Keywords are compared case-insensitively
|
||||
* and `$label:` / legacy `$color:` spellings are folded into one tag; results
|
||||
* are ordered by descending count, then id.
|
||||
*/
|
||||
export function findUnrecognizedKeywords(
|
||||
keywords: Record<string, number>,
|
||||
defined: KeywordDef[],
|
||||
): UnrecognizedKeyword[] {
|
||||
const known = new Set(defined.map((keyword) => keyword.id.toLowerCase()));
|
||||
const taken = new Set<string>(defined.map((keyword) => keyword.color));
|
||||
|
||||
const unrecognized = new Map<string, { id: string; count: number }>();
|
||||
for (const [keyword, count] of Object.entries(keywords)) {
|
||||
const id = tagIdFromKeyword(keyword);
|
||||
if (!id) continue;
|
||||
const folded = id.toLowerCase();
|
||||
if (known.has(folded)) continue;
|
||||
const existing = unrecognized.get(folded);
|
||||
if (existing) existing.count += count;
|
||||
else unrecognized.set(folded, { id, count });
|
||||
}
|
||||
|
||||
return [...unrecognized.values()]
|
||||
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
|
||||
.map(({ id, count }) => {
|
||||
const color = suggestKeywordColor(id, taken);
|
||||
taken.add(color);
|
||||
return {
|
||||
id,
|
||||
keyword: KEYWORD_PREFIX + id,
|
||||
label: suggestKeywordLabel(id),
|
||||
color,
|
||||
count,
|
||||
};
|
||||
});
|
||||
}
|
||||
+32
-2
@@ -129,8 +129,17 @@ const ACCOUNT_NODE_RIGHTS: Mailbox['myRights'] = {
|
||||
* (GitHub #151), so a Stalwart group mailbox shows up as its own section
|
||||
* rather than being mixed into the user's folder list.
|
||||
*/
|
||||
export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
const own = mailboxes.filter((m) => !m.isShared);
|
||||
export function buildMailboxTree(
|
||||
mailboxes: Mailbox[],
|
||||
opts: {
|
||||
/**
|
||||
* Role folders to leave out of the user's own tree — e.g. the server's
|
||||
* `scheduled` folder while the virtual Scheduled row is shown (#495).
|
||||
*/
|
||||
hideOwnRoles?: ReadonlySet<string>;
|
||||
} = {},
|
||||
): MailboxNode[] {
|
||||
const own = mailboxes.filter((m) => !m.isShared && !(m.role && opts.hideOwnRoles?.has(m.role)));
|
||||
const shared = mailboxes.filter((m) => m.isShared);
|
||||
|
||||
const roots = buildRoots(own);
|
||||
@@ -180,6 +189,27 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
|
||||
return [...roots, ...accountNodes];
|
||||
}
|
||||
|
||||
/** Every folder below `mailboxId` (any depth) plus the folder itself. */
|
||||
export function mailboxSubtreeIds(mailboxes: Mailbox[], mailboxId: string): string[] {
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const m of mailboxes) {
|
||||
if (!m.parentId) continue;
|
||||
const list = childrenOf.get(m.parentId);
|
||||
if (list) list.push(m.id);
|
||||
else childrenOf.set(m.parentId, [m.id]);
|
||||
}
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const walk = (id: string) => {
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
for (const child of childrenOf.get(id) ?? []) walk(child);
|
||||
};
|
||||
walk(mailboxId);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Flatten the tree in traversal order, skipping children of collapsed nodes.
|
||||
export function flattenVisible(
|
||||
nodes: MailboxNode[],
|
||||
|
||||
@@ -90,7 +90,14 @@ export type RootStackParamList = {
|
||||
GroupDetail: { groupId: string };
|
||||
AddAccount: undefined;
|
||||
Scheduled: undefined;
|
||||
UnifiedInbox: undefined;
|
||||
UnifiedInbox:
|
||||
| {
|
||||
/** Per-role unified view ("All Sent", …); defaults to the inbox. */
|
||||
role?: 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
|
||||
/** Cross-folder views: every included folder, unread only, starred only. */
|
||||
view?: 'all' | 'unread' | 'starred';
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export type MainTabsParamList = {
|
||||
|
||||
@@ -1,62 +1,149 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable,
|
||||
View, Text, StyleSheet, FlatList, ActivityIndicator, Pressable, TextInput, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { ArrowLeft, Star, Paperclip, AlertTriangle } from 'lucide-react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import {
|
||||
ArrowLeft, Star, Paperclip, AlertTriangle, Search, X, Square, SquareCheck,
|
||||
Mail as MailIcon, MailOpen, Archive, Trash2, ShieldAlert, ShieldCheck, Folder,
|
||||
} from 'lucide-react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import SenderAvatar from '../components/SenderAvatar';
|
||||
import { fetchUnifiedInbox, type UnifiedEmail } from '../api/unified-inbox';
|
||||
import { SwipeableRow } from '../components/SwipeableRow';
|
||||
import {
|
||||
fetchUnifiedInbox, patchUnifiedKeywords, moveUnifiedEmails, deleteUnifiedEmails, isInUnifiedTrash,
|
||||
type UnifiedEmail, type UnifiedRole, type CrossView,
|
||||
} from '../api/unified-inbox';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { formatListDate } from '../lib/date-format';
|
||||
import { isPermanentDelete, confirmPermanentDelete } from '../lib/delete-confirm';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'UnifiedInbox'>;
|
||||
|
||||
function senderName(email: UnifiedEmail): string {
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function senderName(email: UnifiedEmail, showRecipient: boolean): string {
|
||||
if (showRecipient) {
|
||||
const to = email.to?.[0];
|
||||
if (to) return to.name || to.email;
|
||||
}
|
||||
return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown';
|
||||
}
|
||||
|
||||
export default function UnifiedInboxScreen({ navigation }: Props) {
|
||||
function rowKey(e: UnifiedEmail): string {
|
||||
return `${e.sourceAccountId}:${e.jmapAccountId}:${e.id}`;
|
||||
}
|
||||
|
||||
export default function UnifiedInboxScreen({ navigation, route }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const role: UnifiedRole = route.params?.role ?? 'inbox';
|
||||
const view: CrossView | undefined = route.params?.view;
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const dateFormat = useSettingsStore((s) => s.dateFormat);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const includeGroup = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
const crossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
|
||||
const deleteAction = useSettingsStore((s) => s.deleteAction);
|
||||
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
|
||||
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
||||
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
||||
const swipeMode = useSettingsStore((s) => s.swipeMode);
|
||||
const showAvatarsInJunk = useSettingsStore((s) => s.showAvatarsInJunk);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
|
||||
const [emails, setEmails] = React.useState<UnifiedEmail[]>([]);
|
||||
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||
const [opening, setOpening] = React.useState(false);
|
||||
const [searchInput, setSearchInput] = React.useState('');
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [selected, setSelected] = React.useState<Set<string>>(new Set());
|
||||
const positionsRef = React.useRef<Record<string, number>>({});
|
||||
const hasMoreRef = React.useRef(false);
|
||||
const loadSeq = React.useRef(0);
|
||||
|
||||
const accountById = React.useMemo(
|
||||
() => new Map(accounts.map((a) => [a.id, a])),
|
||||
[accounts],
|
||||
);
|
||||
const accountIds = React.useMemo(() => accounts.map((a) => a.id), [accounts]);
|
||||
// Account-bounded by default (the active account + its shared/group
|
||||
// folders); every signed-in account only with the cross-account opt-in.
|
||||
const accountIds = React.useMemo(() => {
|
||||
if (crossAccount || !activeAccountId) return accounts.map((a) => a.id);
|
||||
return accounts.some((a) => a.id === activeAccountId) ? [activeAccountId] : accounts.map((a) => a.id);
|
||||
}, [accounts, activeAccountId, crossAccount]);
|
||||
|
||||
const fetchOpts = React.useMemo(
|
||||
() => ({ includeGroup, query, role, view }),
|
||||
[includeGroup, query, role, view],
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
const seq = ++loadSeq.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchUnifiedInbox(accountIds, 25, { includeGroup });
|
||||
const result = await fetchUnifiedInbox(accountIds, PAGE_SIZE, { ...fetchOpts, positions: {} });
|
||||
if (seq !== loadSeq.current) return;
|
||||
positionsRef.current = result.positions;
|
||||
hasMoreRef.current = result.hasMore;
|
||||
setEmails(result.emails);
|
||||
setErrors(result.errors);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
}, [accountIds, includeGroup]);
|
||||
}, [accountIds, fetchOpts]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (loading || loadingMore || !hasMoreRef.current) return;
|
||||
const seq = loadSeq.current;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await fetchUnifiedInbox(accountIds, PAGE_SIZE, {
|
||||
...fetchOpts,
|
||||
positions: positionsRef.current,
|
||||
});
|
||||
if (seq !== loadSeq.current) return;
|
||||
positionsRef.current = result.positions;
|
||||
hasMoreRef.current = result.hasMore;
|
||||
setEmails((prev) => {
|
||||
const seen = new Set(prev.map(rowKey));
|
||||
const merged = [...prev];
|
||||
for (const e of result.emails) if (!seen.has(rowKey(e))) merged.push(e);
|
||||
return merged.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
});
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [accountIds, fetchOpts, loading, loadingMore]);
|
||||
|
||||
// Reload when the view comes back into focus (a message read or deleted
|
||||
// in the thread screen is stale otherwise) and whenever the inputs change.
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
void load();
|
||||
}, [load]),
|
||||
);
|
||||
|
||||
// Search on submit / after a pause (2+ chars), like the folder list.
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
const trimmed = searchInput.trim();
|
||||
if (trimmed === query) return;
|
||||
if (trimmed.length > 0 && trimmed.length < 2) return;
|
||||
const id = setTimeout(() => setQuery(trimmed), 600);
|
||||
return () => clearTimeout(id);
|
||||
}, [searchInput, query]);
|
||||
|
||||
const onOpen = React.useCallback(
|
||||
(email: UnifiedEmail) => {
|
||||
@@ -91,79 +178,247 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
|
||||
[opening, switchAccount, navigation, emails],
|
||||
);
|
||||
|
||||
// ── Actions (routed to each message's own account) ────────────────────
|
||||
const removeRows = (targets: UnifiedEmail[]) => {
|
||||
const gone = new Set(targets.map(rowKey));
|
||||
setEmails((prev) => prev.filter((e) => !gone.has(rowKey(e))));
|
||||
};
|
||||
const patchRows = (targets: UnifiedEmail[], patch: Record<string, boolean | null>) => {
|
||||
const keys = new Set(targets.map(rowKey));
|
||||
setEmails((prev) => prev.map((e) => {
|
||||
if (!keys.has(rowKey(e))) return e;
|
||||
const keywords = { ...e.keywords };
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (v) keywords[k] = true;
|
||||
else delete keywords[k];
|
||||
}
|
||||
return { ...e, keywords };
|
||||
}));
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
Alert.alert(t('email_list.error', 'Error'), err instanceof Error ? err.message : String(err));
|
||||
void load();
|
||||
};
|
||||
|
||||
const setRead = (targets: UnifiedEmail[], read: boolean) => {
|
||||
patchRows(targets, { $seen: read ? true : null });
|
||||
patchUnifiedKeywords(targets, { $seen: read ? true : null }).catch(fail);
|
||||
};
|
||||
const setStarred = (targets: UnifiedEmail[], starred: boolean) => {
|
||||
patchRows(targets, { $flagged: starred ? true : null });
|
||||
patchUnifiedKeywords(targets, { $flagged: starred ? true : null }).catch(fail);
|
||||
};
|
||||
const archive = (targets: UnifiedEmail[]) => {
|
||||
removeRows(targets);
|
||||
moveUnifiedEmails(targets, 'archive').catch(fail);
|
||||
};
|
||||
const spam = (targets: UnifiedEmail[]) => {
|
||||
removeRows(targets);
|
||||
moveUnifiedEmails(targets, role === 'junk' ? 'inbox' : 'junk', {
|
||||
markRead: role !== 'junk' && deleteAction === 'trash-and-read',
|
||||
}).catch(fail);
|
||||
};
|
||||
const remove = async (targets: UnifiedEmail[]) => {
|
||||
const permanent = targets.some((e) => isPermanentDelete({
|
||||
inTrash: role === 'trash' || isInUnifiedTrash(e),
|
||||
inJunk: role === 'junk',
|
||||
deleteAction,
|
||||
permanentlyDeleteJunk,
|
||||
}));
|
||||
if (permanent && !(await confirmPermanentDelete(targets.length, t))) return;
|
||||
removeRows(targets);
|
||||
deleteUnifiedEmails(targets, {
|
||||
permanent: deleteAction === 'permanent' || (permanentlyDeleteJunk && role === 'junk'),
|
||||
markRead: deleteAction === 'trash-and-read',
|
||||
}).catch(fail);
|
||||
};
|
||||
|
||||
const handleSwipe = (email: UnifiedEmail, action: SwipeAction) => {
|
||||
switch (action) {
|
||||
case 'archive': if (role !== 'archive') archive([email]); break;
|
||||
case 'delete': void remove([email]); break;
|
||||
case 'spam': if (role !== 'sent' && role !== 'drafts') spam([email]); break;
|
||||
case 'read': setRead([email], !email.keywords?.$seen); break;
|
||||
case 'star': setStarred([email], !email.keywords?.$flagged); break;
|
||||
case 'pin':
|
||||
patchRows([email], { $pinned: email.keywords?.$pinned ? null : true });
|
||||
patchUnifiedKeywords([email], { $pinned: email.keywords?.$pinned ? null : true }).catch(fail);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
};
|
||||
|
||||
const selectionMode = selected.size > 0;
|
||||
const selectedEmails = React.useMemo(
|
||||
() => emails.filter((e) => selected.has(rowKey(e))),
|
||||
[emails, selected],
|
||||
);
|
||||
const toggleSelect = (email: UnifiedEmail) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
const key = rowKey(email);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const clearSelection = () => setSelected(new Set());
|
||||
const allRead = selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.$seen);
|
||||
const allStarred = selectedEmails.length > 0 && selectedEmails.every((e) => !!e.keywords?.$flagged);
|
||||
|
||||
const errorCount = Object.keys(errors).length;
|
||||
const showRecipient = role === 'sent' || role === 'drafts';
|
||||
const title = view
|
||||
? t(`sidebar.unified_all_${view === 'all' ? 'mail' : view}`, { all: 'All mail', unread: 'All unread', starred: 'All starred' }[view])
|
||||
: role === 'inbox'
|
||||
? t('sidebar.unified_inbox', 'All inboxes')
|
||||
: t(`sidebar.unified_${role}`, `All ${role}`);
|
||||
|
||||
const renderItem = ({ item }: { item: UnifiedEmail }) => {
|
||||
const acc = accountById.get(item.sourceAccountId);
|
||||
const unread = !item.keywords?.$seen;
|
||||
const starred = !!item.keywords?.$flagged;
|
||||
const isSelected = selected.has(rowKey(item));
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => onOpen(item)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
<SwipeableRow
|
||||
leftAction={selectionMode ? 'none' : swipeLeftAction}
|
||||
rightAction={selectionMode ? 'none' : swipeRightAction}
|
||||
mode={swipeMode}
|
||||
context={{ unread, starred, pinned: !!item.keywords?.$pinned, inJunk: role === 'junk' }}
|
||||
onAction={(action) => handleSwipe(item, action)}
|
||||
>
|
||||
{unread && <View style={styles.unreadDot} />}
|
||||
<View style={styles.avatarWrap}>
|
||||
<SenderAvatar
|
||||
name={senderName(item)}
|
||||
email={item.from?.[0]?.email}
|
||||
size={componentSizes.avatarMd}
|
||||
/>
|
||||
{acc && <View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />}
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.line}>
|
||||
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
|
||||
{senderName(item)}
|
||||
</Text>
|
||||
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
|
||||
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
|
||||
<Pressable
|
||||
onPress={() => (selectionMode ? toggleSelect(item) : onOpen(item))}
|
||||
onLongPress={() => toggleSelect(item)}
|
||||
delayLongPress={300}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed, isSelected && styles.rowSelected]}
|
||||
>
|
||||
{unread && <View style={styles.unreadDot} />}
|
||||
{selectionMode && (
|
||||
<View style={styles.checkbox}>
|
||||
{isSelected ? <SquareCheck size={16} color={c.primary} /> : <Square size={16} color={c.textMuted} />}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.avatarWrap}>
|
||||
<SenderAvatar
|
||||
name={senderName(item, showRecipient)}
|
||||
email={showRecipient ? item.to?.[0]?.email : item.from?.[0]?.email}
|
||||
size={componentSizes.avatarMd}
|
||||
disableImages={role === 'junk' && !showAvatarsInJunk}
|
||||
/>
|
||||
{acc && accountIds.length > 1 && (
|
||||
<View style={[styles.accountDot, { backgroundColor: acc.avatarColor }]} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
|
||||
{item.subject || '(no subject)'}
|
||||
</Text>
|
||||
<View style={styles.line}>
|
||||
{acc && (
|
||||
<Text style={styles.account} numberOfLines={1}>
|
||||
{acc.email || acc.username}
|
||||
<View style={styles.content}>
|
||||
<View style={styles.line}>
|
||||
<Text style={[styles.sender, unread && styles.bold]} numberOfLines={1}>
|
||||
{senderName(item, showRecipient)}
|
||||
</Text>
|
||||
)}
|
||||
{item.isShared && (
|
||||
<View style={styles.sharedBadge}>
|
||||
<Text style={styles.sharedBadgeText} numberOfLines={1}>
|
||||
{item.sharedLabel || 'Shared'}
|
||||
{starred && <Star size={12} color={c.starred} fill={c.starred} />}
|
||||
{item.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||
<Text style={styles.time}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale })}</Text>
|
||||
</View>
|
||||
<Text style={[styles.subject, unread && styles.bold]} numberOfLines={1}>
|
||||
{item.subject || '(no subject)'}
|
||||
</Text>
|
||||
<View style={styles.line}>
|
||||
{acc && accountIds.length > 1 && (
|
||||
<Text style={styles.account} numberOfLines={1}>
|
||||
{acc.email || acc.username}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
)}
|
||||
{item.isShared && (
|
||||
<View style={styles.sharedBadge}>
|
||||
<Text style={styles.sharedBadgeText} numberOfLines={1}>
|
||||
{item.sharedLabel || t('sidebar.shared', 'Shared')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.sourceFolder && (
|
||||
<View style={styles.sharedBadge}>
|
||||
<Folder size={10} color={c.mutedForeground} />
|
||||
<Text style={styles.sharedBadgeText} numberOfLines={1}>{item.sourceFolder}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{item.preview ? (
|
||||
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{item.preview ? (
|
||||
<Text style={styles.preview} numberOfLines={1}>{item.preview}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</SwipeableRow>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>All inboxes</Text>
|
||||
<View style={styles.headerBtn}>
|
||||
{opening ? <ActivityIndicator size="small" color={c.primary} /> : null}
|
||||
{selectionMode ? (
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={clearSelection} style={styles.headerBtn} hitSlop={8}>
|
||||
<X size={20} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{t('context_menu.items_selected', `${selected.size} selected`, { count: selected.size })}
|
||||
</Text>
|
||||
<Pressable onPress={() => { setStarred(selectedEmails, !allStarred); clearSelection(); }} style={styles.headerBtn} hitSlop={6}>
|
||||
<Star size={20} color={allStarred ? c.starred : c.text} fill={allStarred ? c.starred : 'transparent'} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => { setRead(selectedEmails, !allRead); clearSelection(); }} style={styles.headerBtn} hitSlop={6}>
|
||||
{allRead ? <MailIcon size={20} color={c.text} /> : <MailOpen size={20} color={c.text} />}
|
||||
</Pressable>
|
||||
{role !== 'sent' && role !== 'drafts' && (
|
||||
<Pressable onPress={() => { spam(selectedEmails); clearSelection(); }} style={styles.headerBtn} hitSlop={6}>
|
||||
{role === 'junk' ? <ShieldCheck size={20} color={c.text} /> : <ShieldAlert size={20} color={c.text} />}
|
||||
</Pressable>
|
||||
)}
|
||||
{role !== 'archive' && (
|
||||
<Pressable onPress={() => { archive(selectedEmails); clearSelection(); }} style={styles.headerBtn} hitSlop={6}>
|
||||
<Archive size={20} color={c.text} />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable onPress={() => { void remove(selectedEmails).then(clearSelection); }} style={styles.headerBtn} hitSlop={6}>
|
||||
<Trash2 size={20} color={c.text} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => navigation.goBack()} style={styles.headerBtn} hitSlop={8}>
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{title}</Text>
|
||||
<View style={styles.headerBtn}>
|
||||
{opening ? <ActivityIndicator size="small" color={c.primary} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={16} color={c.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder={t('email_list.search_placeholder', 'Search mail...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
value={searchInput}
|
||||
onChangeText={setSearchInput}
|
||||
onSubmitEditing={() => setQuery(searchInput.trim())}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
/>
|
||||
{searchInput.length > 0 && (
|
||||
<Pressable onPress={() => { setSearchInput(''); setQuery(''); }} hitSlop={8}>
|
||||
<X size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{errorCount > 0 && (
|
||||
<View style={styles.errorBanner}>
|
||||
<AlertTriangle size={14} color={c.error} />
|
||||
<Text style={styles.errorBannerText} numberOfLines={1}>
|
||||
{errorCount === 1
|
||||
? '1 account could not be loaded'
|
||||
: `${errorCount} accounts could not be loaded`}
|
||||
<Text style={styles.errorBannerText} numberOfLines={2}>
|
||||
{t('unified_mailbox.accounts_failed', `${errorCount} account(s) could not be loaded`, { count: errorCount })}
|
||||
{': '}
|
||||
{Object.values(errors)[0]}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -171,20 +426,26 @@ export default function UnifiedInboxScreen({ navigation }: Props) {
|
||||
{loading && emails.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
<Text style={styles.loadingText}>Loading all inboxes…</Text>
|
||||
<Text style={styles.loadingText}>{t('email_list.loading', 'Loading…')}</Text>
|
||||
</View>
|
||||
) : emails.length === 0 ? (
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.loadingText}>No mail across your accounts</Text>
|
||||
<Text style={styles.loadingText}>
|
||||
{query ? t('email_list.no_search_results', 'No emails found') : t('email_list.no_emails', 'No emails')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={emails}
|
||||
keyExtractor={(item) => `${item.sourceAccountId}:${item.id}`}
|
||||
keyExtractor={rowKey}
|
||||
renderItem={renderItem}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
refreshing={loading}
|
||||
onRefresh={() => { void load(); }}
|
||||
onEndReached={() => { void loadMore(); }}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListFooterComponent={loadingMore ? <ActivityIndicator style={{ padding: spacing.md }} color={c.primary} /> : null}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
@@ -207,6 +468,20 @@ function makeStyles(c: ThemePalette) {
|
||||
width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md,
|
||||
},
|
||||
headerTitle: { ...typography.h3, color: c.text, flex: 1 },
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: componentSizes.inputHeight,
|
||||
backgroundColor: c.surface,
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
searchInput: { flex: 1, ...typography.body, color: c.text },
|
||||
errorBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -224,8 +499,11 @@ function makeStyles(c: ThemePalette) {
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
rowSelected: { backgroundColor: c.selection },
|
||||
checkbox: { width: 16, height: componentSizes.avatarMd, alignItems: 'center', justifyContent: 'center' },
|
||||
// Same unread indicator as the mailbox list (and the webmail): an 8px
|
||||
// filled circle in the start gutter (#27). Anchored on the row's *first*
|
||||
// line — top padding + half an avatar — because centring it on the row
|
||||
@@ -256,11 +534,14 @@ function makeStyles(c: ThemePalette) {
|
||||
subject: { ...typography.body, color: c.text },
|
||||
account: { ...typography.caption, color: c.textMuted },
|
||||
sharedBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 1,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.muted,
|
||||
maxWidth: 120,
|
||||
maxWidth: 140,
|
||||
},
|
||||
sharedBadgeText: { fontSize: 10, fontWeight: '500', color: c.mutedForeground },
|
||||
preview: { ...typography.caption, color: c.textMuted },
|
||||
|
||||
@@ -12,13 +12,23 @@ export interface KeywordDef {
|
||||
color: KeywordColor;
|
||||
}
|
||||
|
||||
const DEFAULT_KEYWORDS: KeywordDef[] = [
|
||||
{ id: 'important', label: 'Important', color: 'red' },
|
||||
{ id: 'work', label: 'Work', color: 'blue' },
|
||||
{ id: 'personal', label: 'Personal', color: 'green' },
|
||||
{ id: 'todo', label: 'Todo', color: 'amber' },
|
||||
// Same ids/labels/colours as the webmail's DEFAULT_KEYWORDS
|
||||
// (stores/settings-store.ts): the default tags are named after their colour,
|
||||
// so a message tagged `$label:blue` on one client is the same tag on the
|
||||
// other. The colour keys match the webmail's KEYWORD_PALETTE base row.
|
||||
export const DEFAULT_KEYWORDS: KeywordDef[] = [
|
||||
{ id: 'red', label: 'Red', color: 'red' },
|
||||
{ id: 'orange', label: 'Orange', color: 'orange' },
|
||||
{ id: 'yellow', label: 'Yellow', color: 'yellow' },
|
||||
{ id: 'green', label: 'Green', color: 'green' },
|
||||
{ id: 'blue', label: 'Blue', color: 'blue' },
|
||||
{ id: 'purple', label: 'Purple', color: 'purple' },
|
||||
{ id: 'pink', label: 'Pink', color: 'pink' },
|
||||
];
|
||||
|
||||
/** The colour a tag falls back to when its definition is gone. */
|
||||
export const FALLBACK_KEYWORD_COLOR: KeywordColor = 'gray';
|
||||
|
||||
/**
|
||||
* JMAP keyword token used on emails for a given keyword id.
|
||||
* Matches webmail convention: `$label:<id>`.
|
||||
|
||||
Reference in New Issue
Block a user