@bobfrankston/rmfmail 1.2.247 → 1.2.250
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/app.bundle.js +239 -50
- package/client/app.bundle.js.map +4 -4
- package/client/app.js +23 -36
- package/client/app.js.map +1 -1
- package/client/app.ts +17 -36
- package/client/components/folder-picker.js +3 -2
- package/client/components/folder-picker.js.map +1 -1
- package/client/components/folder-picker.ts +3 -2
- package/client/components/folder-tree.js +90 -12
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +89 -12
- package/client/components/message-list.js +35 -2
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +32 -2
- package/client/components/message-viewer.js +149 -11
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +139 -11
- package/client/compose/compose.bundle.js +11 -0
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/help/search-help.js +6 -1
- package/client/help/search-help.js.map +1 -1
- package/client/help/search-help.ts +6 -1
- package/client/lib/api-client.js +14 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +9 -0
- package/client/lib/fold-text.js +18 -0
- package/client/lib/fold-text.js.map +1 -0
- package/client/lib/fold-text.ts +17 -0
- package/client/styles/components.css +23 -3
- package/package.json +1 -1
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/index.d.ts +16 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +49 -1
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +48 -1
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +2 -0
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/db.d.ts.map +1 -1
- package/packages/mailx-store/db.js +5 -2
- package/packages/mailx-store/db.js.map +1 -1
- package/packages/mailx-store/db.ts +5 -2
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-types/mailx-api.d.ts +7 -0
- package/packages/mailx-types/mailx-api.d.ts.map +1 -1
- package/packages/mailx-types/mailx-api.ts +4 -0
- package/packages/mailx-types/package.json +1 -1
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-16772 → node_modules.npmglobalize-stash-52956}/.package-lock.json +0 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, moveFolderToTrash, emptyFolder, setupAccount, getDeviceAccounts, getVersion, syncAccount, popoutMainWindow } from "../lib/api-client.js";
|
|
7
7
|
import { showContextMenu, type MenuItem } from "./context-menu.js";
|
|
8
8
|
import { openTab } from "./tabs.js";
|
|
9
|
+
import { foldText } from "../lib/fold-text.js";
|
|
9
10
|
|
|
10
11
|
type FolderSelectHandler = (accountId: string, folderId: number, folderName: string, specialUse: string) => void;
|
|
11
12
|
// Unified inbox uses folderId = -1 as a sentinel
|
|
@@ -47,6 +48,43 @@ function collapseDragExpanded(): boolean {
|
|
|
47
48
|
return true;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// ── Sidebar "Find folder" filter ──
|
|
52
|
+
// Matching runs against the folder DATA, not the rendered rows. Folders under
|
|
53
|
+
// a collapsed parent are never in the DOM at all (renderNode only recurses
|
|
54
|
+
// when expanded) and a collapsed ACCOUNT isn't even fetched, so the old
|
|
55
|
+
// DOM-hiding filter could only ever find the handful of folders already on
|
|
56
|
+
// screen — with ~90 mostly-collapsed folders that reads as "search doesn't
|
|
57
|
+
// find my folder" (Bob 2026-08-11). Now every account's full folder list is
|
|
58
|
+
// searched, name and path, case- and accent-insensitively, and the ancestors
|
|
59
|
+
// of each hit are force-expanded so the match is actually visible.
|
|
60
|
+
let folderFilter = "";
|
|
61
|
+
|
|
62
|
+
/** Set (or clear, with "") the sidebar folder filter and re-render the tree. */
|
|
63
|
+
export function setFolderFilter(query: string): void {
|
|
64
|
+
const next = foldText(query.trim());
|
|
65
|
+
if (next === folderFilter) return;
|
|
66
|
+
folderFilter = next;
|
|
67
|
+
const container = document.getElementById("folder-tree");
|
|
68
|
+
if (container) loadFolderTree(container);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Keep only branches containing a match, folding case and accents on both
|
|
72
|
+
* sides. A folder that matches keeps its whole subtree (so you can see what
|
|
73
|
+
* lives under the hit); a non-matching ancestor keeps only the branches that
|
|
74
|
+
* lead to one, and is recorded in `forceExpand` so it renders open whatever
|
|
75
|
+
* the user's saved collapse state says. */
|
|
76
|
+
function pruneToMatches(nodes: FolderNode[], q: string, forceExpand: Set<string>): FolderNode[] {
|
|
77
|
+
const kept: FolderNode[] = [];
|
|
78
|
+
for (const n of nodes) {
|
|
79
|
+
const selfMatch = foldText(n.name).includes(q) || foldText(n.path).includes(q);
|
|
80
|
+
const keptChildren = pruneToMatches(n.children, q, forceExpand);
|
|
81
|
+
if (!selfMatch && keptChildren.length === 0) continue;
|
|
82
|
+
kept.push({ ...n, children: selfMatch ? n.children : keptChildren });
|
|
83
|
+
if (keptChildren.length > 0) forceExpand.add(`${n.accountId}:${n.path}`);
|
|
84
|
+
}
|
|
85
|
+
return kept;
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
// Persist expand/collapse state in localStorage
|
|
51
89
|
const expandState: Record<string, boolean> = JSON.parse(localStorage.getItem("mailx-folders-expanded") || "{}");
|
|
52
90
|
|
|
@@ -218,11 +256,16 @@ function sortFolders(nodes: FolderNode[]): void {
|
|
|
218
256
|
}
|
|
219
257
|
}
|
|
220
258
|
|
|
221
|
-
/** Render a folder node and its children recursively
|
|
222
|
-
|
|
259
|
+
/** Render a folder node and its children recursively.
|
|
260
|
+
* `forceExpand` holds the keys of folders that sit on the path to a filter
|
|
261
|
+
* match — those default to open (the user can still collapse one explicitly,
|
|
262
|
+
* which is why this reads `!== false` rather than forcing `true`). */
|
|
263
|
+
function renderNode(node: FolderNode, container: HTMLElement, depth: number, forceExpand?: Set<string>): void {
|
|
223
264
|
const hasChildren = node.children.length > 0;
|
|
224
265
|
const expandKey = `${node.accountId}:${node.path}`;
|
|
225
|
-
const isExpanded =
|
|
266
|
+
const isExpanded = forceExpand?.has(expandKey)
|
|
267
|
+
? expandState[expandKey] !== false
|
|
268
|
+
: expandState[expandKey] === true; // default collapsed
|
|
226
269
|
|
|
227
270
|
const folderEl = document.createElement("div");
|
|
228
271
|
folderEl.className = "ft-folder";
|
|
@@ -598,7 +641,7 @@ function renderNode(node: FolderNode, container: HTMLElement, depth: number): vo
|
|
|
598
641
|
// Render children if expanded
|
|
599
642
|
if (hasChildren && isExpanded) {
|
|
600
643
|
for (const child of node.children) {
|
|
601
|
-
renderNode(child, container, depth + 1);
|
|
644
|
+
renderNode(child, container, depth + 1, forceExpand);
|
|
602
645
|
}
|
|
603
646
|
}
|
|
604
647
|
}
|
|
@@ -992,11 +1035,14 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
|
|
|
992
1035
|
}
|
|
993
1036
|
|
|
994
1037
|
// Fetch ALL account folder data in parallel BEFORE touching the DOM
|
|
1038
|
+
// A collapsed account normally isn't fetched at all. While a filter is
|
|
1039
|
+
// active we fetch every account — a folder you can't see is exactly
|
|
1040
|
+
// the one you're searching for.
|
|
995
1041
|
const accountFolderData: { account: any; folders: any[] }[] = await Promise.all(
|
|
996
1042
|
accounts.map(async (account: any) => {
|
|
997
1043
|
const accountKey = `account:${account.id}`;
|
|
998
1044
|
const accountExpanded = expandState[accountKey] !== false;
|
|
999
|
-
const folders = accountExpanded ? await getFolders(account.id) : [];
|
|
1045
|
+
const folders = (accountExpanded || folderFilter) ? await getFolders(account.id) : [];
|
|
1000
1046
|
return { account, folders };
|
|
1001
1047
|
})
|
|
1002
1048
|
);
|
|
@@ -1010,7 +1056,7 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
|
|
|
1010
1056
|
// Unified Inbox — always shown so startup auto-selects it consistently
|
|
1011
1057
|
// (with one account it's effectively that account's INBOX, but the UI
|
|
1012
1058
|
// stays uniform so the auto-select path doesn't fork on account count)
|
|
1013
|
-
if (accounts.length >= 1) {
|
|
1059
|
+
if (accounts.length >= 1 && (!folderFilter || foldText("All Inboxes").includes(folderFilter))) {
|
|
1014
1060
|
const unifiedEl = document.createElement("div");
|
|
1015
1061
|
unifiedEl.className = "ft-folder ft-unified";
|
|
1016
1062
|
unifiedEl.title = accounts.length > 1
|
|
@@ -1032,7 +1078,7 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
|
|
|
1032
1078
|
// queue, only shown when something is actually queued. Clicking
|
|
1033
1079
|
// opens the outbox-view modal (pink rows, cancellable). Lives at
|
|
1034
1080
|
// the top of the tree so a stuck send is impossible to miss.
|
|
1035
|
-
if (lastOutboxTotal > 0) {
|
|
1081
|
+
if (lastOutboxTotal > 0 && (!folderFilter || foldText("Send-pending").includes(folderFilter))) {
|
|
1036
1082
|
const pendingEl = document.createElement("div");
|
|
1037
1083
|
pendingEl.className = "ft-folder ft-unified ft-send-pending";
|
|
1038
1084
|
pendingEl.id = "ft-send-pending";
|
|
@@ -1064,7 +1110,9 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
|
|
|
1064
1110
|
accountEl.className = "ft-account";
|
|
1065
1111
|
|
|
1066
1112
|
const accountKey = `account:${account.id}`;
|
|
1067
|
-
|
|
1113
|
+
// A filter opens every account: its matches have to be reachable
|
|
1114
|
+
// even when the user left the account collapsed.
|
|
1115
|
+
const accountExpanded = folderFilter ? true : expandState[accountKey] !== false; // accounts default expanded
|
|
1068
1116
|
|
|
1069
1117
|
const header = document.createElement("div");
|
|
1070
1118
|
header.className = "ft-account-header";
|
|
@@ -1073,7 +1121,10 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
|
|
|
1073
1121
|
const disambiguator = isDup ? ` (${(account as any).email || account.id})` : "";
|
|
1074
1122
|
header.textContent = `${accountExpanded ? "▾" : "▸"} ${baseLabel}${disambiguator}`;
|
|
1075
1123
|
header.addEventListener("click", () => {
|
|
1076
|
-
|
|
1124
|
+
// Toggle the SAVED state, not the rendered one — while a
|
|
1125
|
+
// filter forces the account open, a click must still flip
|
|
1126
|
+
// what the user gets back when the filter clears.
|
|
1127
|
+
expandState[accountKey] = expandState[accountKey] === false;
|
|
1077
1128
|
saveExpandState();
|
|
1078
1129
|
const treeContainer = document.getElementById("folder-tree");
|
|
1079
1130
|
if (treeContainer) loadFolderTree(treeContainer);
|
|
@@ -1119,11 +1170,25 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
|
|
|
1119
1170
|
|
|
1120
1171
|
accountEl.appendChild(header);
|
|
1121
1172
|
|
|
1173
|
+
// With a filter active an account that has no hit is dropped
|
|
1174
|
+
// entirely (header included) rather than left as a bare label.
|
|
1175
|
+
let filteredOut = false;
|
|
1176
|
+
|
|
1122
1177
|
if (accountExpanded && folders.length > 0) {
|
|
1123
1178
|
const delimiter = folders[0]?.delimiter || ".";
|
|
1124
|
-
|
|
1179
|
+
let tree = buildTree(folders, delimiter, account.id);
|
|
1125
1180
|
sortFolders(tree);
|
|
1126
1181
|
|
|
1182
|
+
// Filter: prune to matching branches. An account whose own
|
|
1183
|
+
// label matches keeps all of its folders — "show me everything
|
|
1184
|
+
// under gmail" is the natural reading of typing the account
|
|
1185
|
+
// name into the folder box.
|
|
1186
|
+
const forceExpand = new Set<string>();
|
|
1187
|
+
if (folderFilter && !foldText(`${baseLabel} ${(account as any).email || ""} ${account.id}`).includes(folderFilter)) {
|
|
1188
|
+
tree = pruneToMatches(tree, folderFilter, forceExpand);
|
|
1189
|
+
if (tree.length === 0) filteredOut = true;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1127
1192
|
// Case-duplicate detection: fold folder paths to lowercase and
|
|
1128
1193
|
// flag any whose form matches another. Common with servers that
|
|
1129
1194
|
// let users create `Archive` and `archive` as distinct folders,
|
|
@@ -1139,7 +1204,7 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
|
|
|
1139
1204
|
for (const [k, c] of lowerCounts) if (c > 1) duplicatePaths.add(k);
|
|
1140
1205
|
|
|
1141
1206
|
for (const node of tree) {
|
|
1142
|
-
renderNode(node, accountEl, 1);
|
|
1207
|
+
renderNode(node, accountEl, 1, forceExpand);
|
|
1143
1208
|
}
|
|
1144
1209
|
|
|
1145
1210
|
if (duplicatePaths.size > 0) {
|
|
@@ -1152,9 +1217,21 @@ async function loadFolderTree(container: HTMLElement): Promise<void> {
|
|
|
1152
1217
|
}
|
|
1153
1218
|
});
|
|
1154
1219
|
}
|
|
1220
|
+
} else if (folderFilter) {
|
|
1221
|
+
filteredOut = true;
|
|
1155
1222
|
}
|
|
1156
1223
|
|
|
1157
|
-
fragment.appendChild(accountEl);
|
|
1224
|
+
if (!filteredOut) fragment.appendChild(accountEl);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Nothing matched anywhere — say so instead of showing a blank rail,
|
|
1228
|
+
// which reads as "the tree broke" (name the query, per the rule that
|
|
1229
|
+
// status text names its target).
|
|
1230
|
+
if (folderFilter && !fragment.querySelector(".ft-folder")) {
|
|
1231
|
+
const empty = document.createElement("div");
|
|
1232
|
+
empty.className = "ft-filter-empty";
|
|
1233
|
+
empty.textContent = `No folder matches "${(document.getElementById("ft-filter-input") as HTMLInputElement | null)?.value || folderFilter}"`;
|
|
1234
|
+
fragment.appendChild(empty);
|
|
1158
1235
|
}
|
|
1159
1236
|
|
|
1160
1237
|
// Atomic swap — single reflow, no intermediate empty state
|
|
@@ -7,6 +7,7 @@ import * as state from "../lib/message-state.js";
|
|
|
7
7
|
import { showMessage as viewerShow, clearViewer as viewerClear } from "./message-viewer.js";
|
|
8
8
|
import { showContextMenu } from "./context-menu.js";
|
|
9
9
|
import { pickFolder } from "./folder-picker.js";
|
|
10
|
+
import { foldText } from "../lib/fold-text.js";
|
|
10
11
|
import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";
|
|
11
12
|
let onMessageSelect;
|
|
12
13
|
let currentAccountId;
|
|
@@ -37,7 +38,7 @@ let liveFilterText = "";
|
|
|
37
38
|
* it (background reloads resume). Lower-cased once here so the per-row test
|
|
38
39
|
* is a plain substring check. */
|
|
39
40
|
export function setLiveFilter(query) {
|
|
40
|
-
liveFilterText = (query || "").trim()
|
|
41
|
+
liveFilterText = foldText((query || "").trim());
|
|
41
42
|
const body = document.getElementById("ml-body");
|
|
42
43
|
if (body)
|
|
43
44
|
applyLiveFilter(body);
|
|
@@ -51,7 +52,7 @@ function applyLiveFilter(body) {
|
|
|
51
52
|
return;
|
|
52
53
|
}
|
|
53
54
|
for (const row of body.querySelectorAll(".ml-row")) {
|
|
54
|
-
const text = row.textContent
|
|
55
|
+
const text = foldText(row.textContent);
|
|
55
56
|
row.classList.toggle("filter-hidden", !text.includes(liveFilterText));
|
|
56
57
|
}
|
|
57
58
|
}
|
|
@@ -1982,11 +1983,22 @@ class MessageRow {
|
|
|
1982
1983
|
const icons = document.createElement("span");
|
|
1983
1984
|
icons.className = "ml-status-icons";
|
|
1984
1985
|
renderStatusIcons(icons, msg);
|
|
1986
|
+
// Size, immediately left of the date (Bob 2026-08-10). It lives INSIDE
|
|
1987
|
+
// the date cell rather than as a sixth grid column so it lands in the
|
|
1988
|
+
// same place in all three list layouts — default, two-line and
|
|
1989
|
+
// eleanor-view each declare their own column set, and a new column
|
|
1990
|
+
// would have to be threaded through every one of them.
|
|
1991
|
+
const size = document.createElement("span");
|
|
1992
|
+
size.className = "ml-size";
|
|
1993
|
+
size.textContent = formatSize(msg.size);
|
|
1994
|
+
if (msg.size > 0)
|
|
1995
|
+
size.title = `${msg.size.toLocaleString()} bytes`;
|
|
1985
1996
|
const dateText = document.createElement("span");
|
|
1986
1997
|
dateText.className = "ml-date-text";
|
|
1987
1998
|
dateText.textContent = formatDate(effectiveDate(msg));
|
|
1988
1999
|
dateText.title = dateBasis === "received" ? "Received date" : "Sent date";
|
|
1989
2000
|
date.appendChild(icons);
|
|
2001
|
+
date.appendChild(size);
|
|
1990
2002
|
date.appendChild(dateText);
|
|
1991
2003
|
row.appendChild(avatar);
|
|
1992
2004
|
row.appendChild(flag);
|
|
@@ -2525,6 +2537,27 @@ function appendMessages(body, accountId, items) {
|
|
|
2525
2537
|
row.attach(body);
|
|
2526
2538
|
}
|
|
2527
2539
|
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Message size for the list, in the width of a few characters.
|
|
2542
|
+
*
|
|
2543
|
+
* Deliberately coarse: the value is there to answer "is this a note or a
|
|
2544
|
+
* 9 MB bounce", not to be an accounting figure — the exact byte count is on
|
|
2545
|
+
* the tooltip. Sizes round to K below a megabyte and to one decimal above,
|
|
2546
|
+
* so the column never grows past five characters and nothing shifts as you
|
|
2547
|
+
* scroll. Anything under 1 KB reads "<1K" rather than "0K", which would look
|
|
2548
|
+
* like a broken message rather than a short one.
|
|
2549
|
+
*/
|
|
2550
|
+
function formatSize(bytes) {
|
|
2551
|
+
if (!bytes || bytes <= 0)
|
|
2552
|
+
return "";
|
|
2553
|
+
if (bytes < 1024)
|
|
2554
|
+
return "<1K";
|
|
2555
|
+
const kb = bytes / 1024;
|
|
2556
|
+
if (kb < 1024)
|
|
2557
|
+
return `${Math.round(kb)}K`;
|
|
2558
|
+
const mb = kb / 1024;
|
|
2559
|
+
return mb < 10 ? `${mb.toFixed(1)}M` : `${Math.round(mb)}M`;
|
|
2560
|
+
}
|
|
2528
2561
|
function formatDate(epochMs) {
|
|
2529
2562
|
const d = new Date(epochMs);
|
|
2530
2563
|
const now = new Date();
|