@bobfrankston/mailx 1.0.131 → 1.0.132

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.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * mailx client entry point.
3
3
  * Wires together all UI components and WebSocket connection.
4
4
  */
5
- import { initFolderTree, refreshFolderTree } from "./components/folder-tree.js";
5
+ import { initFolderTree, refreshFolderTree, updateFolderCounts } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
8
8
  import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer } from "./lib/api-client.js";
@@ -582,7 +582,8 @@ onWsEvent((event) => {
582
582
  break;
583
583
  }
584
584
  case "folderCountsChanged": {
585
- refreshFolderTree();
585
+ // Incremental count update — no DOM rebuild, no jitter
586
+ updateFolderCounts();
586
587
  updateNewMessageCount();
587
588
  // Reload message list but keep current scroll position and selection
588
589
  reloadCurrentFolder();
@@ -9,6 +9,9 @@ let onUnifiedInbox = null;
9
9
  let selectedElement;
10
10
  let selectedAccountId = null;
11
11
  let selectedFolderId = null;
12
+ let isFirstLoad = true; // only auto-select on first load
13
+ // Debounce timer for refreshFolderTree
14
+ let refreshDebounceTimer = null;
12
15
  // Persist expand/collapse state in localStorage
13
16
  const expandState = JSON.parse(localStorage.getItem("mailx-folders-expanded") || "{}");
14
17
  function saveExpandState() {
@@ -114,6 +117,7 @@ function renderNode(node, container, depth) {
114
117
  folderEl.dataset.accountId = node.accountId;
115
118
  folderEl.dataset.folderId = String(node.id);
116
119
  folderEl.dataset.folderPath = node.path;
120
+ folderEl.dataset.specialUse = node.specialUse || "";
117
121
  folderEl.style.paddingLeft = `${depth * 16 + 8}px`;
118
122
  // Expand/collapse toggle
119
123
  const toggle = document.createElement("span");
@@ -433,8 +437,17 @@ async function loadFolderTree(container) {
433
437
  }
434
438
  return;
435
439
  }
436
- // Clear loading state now that we have data
437
- container.innerHTML = "";
440
+ // Fetch ALL account folder data in parallel BEFORE touching the DOM
441
+ const accountFolderData = await Promise.all(accounts.map(async (account) => {
442
+ const accountKey = `account:${account.id}`;
443
+ const accountExpanded = expandState[accountKey] !== false;
444
+ const folders = accountExpanded ? await getFolders(account.id) : [];
445
+ return { account, folders };
446
+ }));
447
+ // Save scroll position before rebuild
448
+ const savedScroll = container.scrollTop;
449
+ // Build entire new tree into a DocumentFragment (off-screen, no reflows)
450
+ const fragment = document.createDocumentFragment();
438
451
  // Unified Inbox (if multiple accounts)
439
452
  if (accounts.length > 1) {
440
453
  const unifiedEl = document.createElement("div");
@@ -450,9 +463,9 @@ async function loadFolderTree(container) {
450
463
  if (onUnifiedInbox)
451
464
  onUnifiedInbox();
452
465
  });
453
- container.appendChild(unifiedEl);
466
+ fragment.appendChild(unifiedEl);
454
467
  }
455
- for (const account of accounts) {
468
+ for (const { account, folders } of accountFolderData) {
456
469
  const accountEl = document.createElement("div");
457
470
  accountEl.className = "ft-account";
458
471
  const accountKey = `account:${account.id}`;
@@ -474,10 +487,10 @@ async function loadFolderTree(container) {
474
487
  const allExpanded = Object.entries(expandState)
475
488
  .filter(([k]) => k.startsWith(`${account.id}:`))
476
489
  .every(([, v]) => v);
477
- const allFolders = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
490
+ const allFolderKeys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
478
491
  // If some expanded, collapse all. If all collapsed, expand all.
479
492
  const newState = !allExpanded;
480
- for (const key of allFolders)
493
+ for (const key of allFolderKeys)
481
494
  expandState[key] = newState;
482
495
  expandState[accountKey] = newState;
483
496
  saveExpandState();
@@ -486,18 +499,21 @@ async function loadFolderTree(container) {
486
499
  loadFolderTree(treeContainer);
487
500
  });
488
501
  accountEl.appendChild(header);
489
- if (accountExpanded) {
490
- const allFolders = await getFolders(account.id);
491
- const delimiter = allFolders[0]?.delimiter || ".";
492
- const tree = buildTree(allFolders, delimiter, account.id);
502
+ if (accountExpanded && folders.length > 0) {
503
+ const delimiter = folders[0]?.delimiter || ".";
504
+ const tree = buildTree(folders, delimiter, account.id);
493
505
  sortFolders(tree);
494
506
  for (const node of tree) {
495
507
  renderNode(node, accountEl, 1);
496
508
  }
497
509
  }
498
- container.appendChild(accountEl);
510
+ fragment.appendChild(accountEl);
499
511
  }
500
- // Re-select previous folder on refresh, or auto-select Inbox on first load
512
+ // Atomic swap single reflow, no intermediate empty state
513
+ container.replaceChildren(fragment);
514
+ // Restore scroll position
515
+ container.scrollTop = savedScroll;
516
+ // Re-select previous folder, or auto-select on first load
501
517
  const allFolderEls = container.querySelectorAll('.ft-folder');
502
518
  let target = null;
503
519
  if (selectedFolderId === -1) {
@@ -520,14 +536,13 @@ async function loadFolderTree(container) {
520
536
  }
521
537
  }
522
538
  }
523
- if (!target) {
524
- // Prefer unified inbox if available, otherwise pick inbox with most unread
539
+ if (!target && isFirstLoad) {
540
+ // Auto-select only on first load not on refresh (prevents jumping)
525
541
  const unified = container.querySelector('.ft-unified');
526
542
  if (unified) {
527
543
  target = unified;
528
544
  }
529
545
  else {
530
- // Find all inboxes, pick the one with a badge (unread count)
531
546
  let bestInbox = null;
532
547
  let bestCount = -1;
533
548
  for (const f of allFolderEls) {
@@ -548,6 +563,7 @@ async function loadFolderTree(container) {
548
563
  if (target)
549
564
  target.click();
550
565
  }
566
+ isFirstLoad = false;
551
567
  // Dismiss startup overlay once tree is loaded
552
568
  const overlay = document.getElementById("startup-overlay");
553
569
  if (overlay)
@@ -556,7 +572,10 @@ async function loadFolderTree(container) {
556
572
  setTimeout(() => overlay?.remove(), 400);
557
573
  }
558
574
  catch (e) {
559
- container.innerHTML = `<div class="folder-loading">Error loading folders: ${e.message}</div>`;
575
+ const errEl = document.createElement("div");
576
+ errEl.className = "folder-loading";
577
+ errEl.textContent = `Error loading folders: ${e.message}`;
578
+ container.replaceChildren(errEl);
560
579
  // Dismiss overlay on error too — show the error, not a spinner
561
580
  const overlay = document.getElementById("startup-overlay");
562
581
  if (overlay) {
@@ -567,10 +586,128 @@ async function loadFolderTree(container) {
567
586
  }
568
587
  }
569
588
  }
570
- /** Refresh folder tree (e.g., after sync) */
589
+ /** Refresh folder tree (e.g., after sync) — debounced to prevent rapid rebuilds */
571
590
  export function refreshFolderTree() {
591
+ if (refreshDebounceTimer)
592
+ clearTimeout(refreshDebounceTimer);
593
+ refreshDebounceTimer = setTimeout(() => {
594
+ refreshDebounceTimer = null;
595
+ const container = document.getElementById("folder-tree");
596
+ if (container)
597
+ loadFolderTree(container);
598
+ }, 300);
599
+ }
600
+ /**
601
+ * Incremental count update — patches badge counts in-place without rebuilding the DOM.
602
+ * Used for folderCountsChanged events to avoid jitter. Falls back to full rebuild
603
+ * if the folder structure has changed.
604
+ */
605
+ export async function updateFolderCounts() {
572
606
  const container = document.getElementById("folder-tree");
573
- if (container)
574
- loadFolderTree(container);
607
+ if (!container)
608
+ return;
609
+ // If tree hasn't loaded yet, do a full load
610
+ if (container.children.length === 0 || container.querySelector(".folder-loading")) {
611
+ refreshFolderTree();
612
+ return;
613
+ }
614
+ try {
615
+ const accounts = await getAccounts();
616
+ // Fetch all folder data in parallel
617
+ const allFolderData = await Promise.all(accounts.map(async (account) => {
618
+ const folders = await getFolders(account.id);
619
+ return { accountId: account.id, folders };
620
+ }));
621
+ // Build a lookup: accountId+folderId → { unreadCount, totalCount }
622
+ // Also rebuild trees to get aggregated counts
623
+ const countMap = new Map();
624
+ for (const { accountId, folders } of allFolderData) {
625
+ const delimiter = folders[0]?.delimiter || ".";
626
+ const tree = buildTree(folders, delimiter, accountId);
627
+ // Walk the tree and collect counts (buildTree already aggregates)
628
+ function collectCounts(nodes) {
629
+ for (const n of nodes) {
630
+ countMap.set(`${n.accountId}:${n.id}`, { unread: n.unreadCount, total: n.totalCount });
631
+ collectCounts(n.children);
632
+ }
633
+ }
634
+ collectCounts(tree);
635
+ }
636
+ // Patch existing DOM elements in-place
637
+ const folderEls = container.querySelectorAll(".ft-folder[data-account-id][data-folder-id]");
638
+ let structureChanged = false;
639
+ for (const el of folderEls) {
640
+ const htmlEl = el;
641
+ const key = `${htmlEl.dataset.accountId}:${htmlEl.dataset.folderId}`;
642
+ const counts = countMap.get(key);
643
+ if (!counts)
644
+ continue; // folder not found — structure may have changed
645
+ // Update unread badge
646
+ const isOutbox = htmlEl.dataset.specialUse === "outbox" || htmlEl.dataset.folderPath?.toLowerCase() === "outbox";
647
+ let badge = htmlEl.querySelector(".ft-badge");
648
+ const outboxBadge = htmlEl.querySelector(".ft-badge-outbox");
649
+ if (isOutbox) {
650
+ if (counts.total > 0) {
651
+ if (outboxBadge) {
652
+ outboxBadge.textContent = String(counts.total);
653
+ }
654
+ else {
655
+ const b = document.createElement("span");
656
+ b.className = "ft-badge ft-badge-outbox";
657
+ b.textContent = String(counts.total);
658
+ htmlEl.querySelector(".ft-folder-name")?.after(b);
659
+ }
660
+ }
661
+ else if (outboxBadge) {
662
+ outboxBadge.remove();
663
+ }
664
+ }
665
+ else {
666
+ if (counts.unread > 0) {
667
+ if (badge) {
668
+ badge.textContent = String(counts.unread);
669
+ }
670
+ else {
671
+ const b = document.createElement("span");
672
+ b.className = "ft-badge";
673
+ b.textContent = String(counts.unread);
674
+ htmlEl.querySelector(".ft-folder-name")?.after(b);
675
+ }
676
+ }
677
+ else if (badge && !badge.classList.contains("ft-badge-outbox")) {
678
+ badge.remove();
679
+ }
680
+ }
681
+ // Update total count
682
+ let totalEl = htmlEl.querySelector(".ft-total-count");
683
+ if (counts.total > 0) {
684
+ if (totalEl) {
685
+ totalEl.textContent = String(counts.total);
686
+ }
687
+ else {
688
+ const t = document.createElement("span");
689
+ t.className = "ft-total-count";
690
+ t.textContent = String(counts.total);
691
+ htmlEl.appendChild(t);
692
+ }
693
+ }
694
+ else if (totalEl) {
695
+ totalEl.remove();
696
+ }
697
+ }
698
+ // Check if folder count changed (new folders added or removed)
699
+ const existingCount = folderEls.length;
700
+ let serverCount = 0;
701
+ for (const { folders } of allFolderData)
702
+ serverCount += folders.length;
703
+ if (Math.abs(existingCount - serverCount) > 2) {
704
+ // Structure changed significantly — do a full rebuild
705
+ refreshFolderTree();
706
+ }
707
+ }
708
+ catch {
709
+ // If count update fails, fall back to full rebuild
710
+ refreshFolderTree();
711
+ }
575
712
  }
576
713
  //# sourceMappingURL=folder-tree.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-client",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.131",
3
+ "version": "1.0.132",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-server",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",