@bobfrankston/mailx 1.0.393 → 1.0.395
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/android.html +23 -6
- package/client/app.js +13 -1
- package/client/components/message-list.js +83 -13
- package/client/index.html +10 -9
- package/client/styles/components.css +17 -0
- package/client/styles/layout.css +40 -14
- package/package.json +1 -1
- package/packages/mailx-store-web/android-bootstrap.js +28 -10
- package/packages/mailx-store-web/sync-manager.js +39 -10
- package/rebuild.cmd +23 -0
- package/unbash.cmd +55 -0
package/client/android.html
CHANGED
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
<body>
|
|
60
60
|
<header class="toolbar">
|
|
61
61
|
<div class="toolbar-left">
|
|
62
|
-
<button class="tb-btn" id="btn-menu" title="
|
|
62
|
+
<button class="tb-btn" id="btn-menu" title="Menu / rail" hidden>☰</button>
|
|
63
|
+
<button class="tb-btn" id="btn-folder-toggle" title="Show / hide folders" hidden>📁</button>
|
|
63
64
|
<button class="tb-btn" id="btn-compose" title="Compose (Ctrl+N)">
|
|
64
65
|
<span class="tb-icon">✏</span> Compose
|
|
65
66
|
</button>
|
|
@@ -68,11 +69,11 @@
|
|
|
68
69
|
<div class="tb-menu" id="view-menu">
|
|
69
70
|
<button class="tb-btn" id="btn-view">View</button>
|
|
70
71
|
<div class="tb-menu-dropdown" id="view-dropdown" hidden>
|
|
71
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-two-line"> Two-line view</label>
|
|
72
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
|
|
73
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
|
|
74
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
75
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
|
|
72
|
+
<label class="tb-menu-item" title="Stack From + Subject on two lines per row, date to the right — denser on narrow windows"><input type="checkbox" id="opt-two-line"> Two-line view</label>
|
|
73
|
+
<label class="tb-menu-item" title="Show the reading pane below/beside the message list"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
|
|
74
|
+
<label class="tb-menu-item" title="Show a short body-text preview (first ~80 chars) beneath each row's subject"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
|
|
75
|
+
<label class="tb-menu-item" title="Show only flagged (★) messages in the list"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
76
|
+
<label class="tb-menu-item" title="Show unread/total counts next to each folder in the tree"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
|
|
76
77
|
</div>
|
|
77
78
|
</div>
|
|
78
79
|
<div class="tb-menu" id="settings-menu">
|
|
@@ -109,6 +110,22 @@
|
|
|
109
110
|
<button class="alert-dismiss" id="alert-dismiss" title="Dismiss">×</button>
|
|
110
111
|
</div>
|
|
111
112
|
|
|
113
|
+
<aside class="icon-rail" id="icon-rail" aria-label="App rail">
|
|
114
|
+
<div class="rail-top">
|
|
115
|
+
<button class="rail-btn" id="rail-compose" title="Compose" aria-label="Compose">✏</button>
|
|
116
|
+
<button class="rail-btn" id="rail-inbox" title="Inbox" aria-label="Inbox" data-active="true">✉</button>
|
|
117
|
+
<button class="rail-btn" id="rail-unified" title="All Inboxes" aria-label="All Inboxes">⌘</button>
|
|
118
|
+
<button class="rail-btn" id="rail-contacts" title="Contacts" aria-label="Contacts">👤</button>
|
|
119
|
+
<button class="rail-btn" id="rail-calendar" title="Calendar" aria-label="Calendar">📅</button>
|
|
120
|
+
<button class="rail-btn" id="rail-tasks" title="Tasks" aria-label="Tasks">☑</button>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="rail-bottom">
|
|
123
|
+
<button class="rail-btn" id="rail-settings" title="Settings" aria-label="Settings">⚙</button>
|
|
124
|
+
<button class="rail-btn" id="rail-theme" title="Theme" aria-label="Theme">◐</button>
|
|
125
|
+
<button class="rail-btn" id="rail-help" title="Help" aria-label="Help">?</button>
|
|
126
|
+
</div>
|
|
127
|
+
</aside>
|
|
128
|
+
|
|
112
129
|
<div class="folder-panel">
|
|
113
130
|
<div class="ft-filter">
|
|
114
131
|
<input type="text" id="ft-filter-input" placeholder="Find folder..." autocomplete="off">
|
package/client/app.js
CHANGED
|
@@ -390,9 +390,21 @@ if (messageList) {
|
|
|
390
390
|
}
|
|
391
391
|
}).observe(messageList);
|
|
392
392
|
}
|
|
393
|
-
// ── Narrow
|
|
393
|
+
// ── Narrow/medium drawer toggles ──
|
|
394
|
+
// Hamburger (☰): rail drawer on narrow; on wider tiers the rail is already
|
|
395
|
+
// visible so this is a no-op visually (the toggle still fires but the rail
|
|
396
|
+
// has no `.open` style to invoke).
|
|
397
|
+
// Folder (📁): folder-panel drawer on any tier where it's positioned as an
|
|
398
|
+
// overlay (medium + narrow).
|
|
394
399
|
document.getElementById("btn-menu")?.addEventListener("click", () => {
|
|
400
|
+
document.querySelector(".icon-rail")?.classList.toggle("open");
|
|
401
|
+
// Rail drawer and folder drawer are mutually exclusive — opening one
|
|
402
|
+
// closes the other so they don't fight for the left edge.
|
|
403
|
+
document.querySelector(".folder-panel")?.classList.remove("open");
|
|
404
|
+
});
|
|
405
|
+
document.getElementById("btn-folder-toggle")?.addEventListener("click", () => {
|
|
395
406
|
document.querySelector(".folder-panel")?.classList.toggle("open");
|
|
407
|
+
document.querySelector(".icon-rail")?.classList.remove("open");
|
|
396
408
|
});
|
|
397
409
|
const backToList = (e) => {
|
|
398
410
|
e.preventDefault();
|
|
@@ -24,6 +24,16 @@ let touchWasScroll = false;
|
|
|
24
24
|
// (text columns default asc, date defaults desc).
|
|
25
25
|
let currentSort = "date";
|
|
26
26
|
let currentSortDir = "desc";
|
|
27
|
+
/** Atomic focus: update shared state + notify viewer in one call.
|
|
28
|
+
* First slice of S56 (row-objects-own-preview) — consolidates the two
|
|
29
|
+
* parallel selection paths (state.select + onMessageSelect) so the eventual
|
|
30
|
+
* Row-class migration touches exactly one call site. The viewer's `gen`
|
|
31
|
+
* token still cancels stale fetches; this just makes the transition
|
|
32
|
+
* indivisible at the caller level. */
|
|
33
|
+
function focusMessage(accountId, msg) {
|
|
34
|
+
state.select(msg);
|
|
35
|
+
onMessageSelect(accountId, msg.uid, msg.folderId);
|
|
36
|
+
}
|
|
27
37
|
/** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
|
|
28
38
|
* Called from the bodyCached service event — covers both background prefetch
|
|
29
39
|
* and on-demand fetch. No-op for rows not currently rendered. */
|
|
@@ -55,6 +65,34 @@ function clearSelection() {
|
|
|
55
65
|
if (body)
|
|
56
66
|
body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
|
|
57
67
|
}
|
|
68
|
+
/** Exit multi-select mode (entered via touch long-press). Clears selection
|
|
69
|
+
* and the sticky body flag so subsequent taps open messages again. */
|
|
70
|
+
function exitMultiSelect() {
|
|
71
|
+
const body = document.getElementById("ml-body");
|
|
72
|
+
if (!body?.classList.contains("multi-select-on"))
|
|
73
|
+
return;
|
|
74
|
+
body.classList.remove("multi-select-on");
|
|
75
|
+
clearSelection();
|
|
76
|
+
}
|
|
77
|
+
// Escape key + click-outside-list exit multi-select mode. Attached once
|
|
78
|
+
// (idempotent because document only has one listener scope per handler).
|
|
79
|
+
if (!window.__mailxMultiSelectWired) {
|
|
80
|
+
window.__mailxMultiSelectWired = true;
|
|
81
|
+
document.addEventListener("keydown", (e) => {
|
|
82
|
+
if (e.key === "Escape")
|
|
83
|
+
exitMultiSelect();
|
|
84
|
+
});
|
|
85
|
+
document.addEventListener("pointerdown", (e) => {
|
|
86
|
+
const body = document.getElementById("ml-body");
|
|
87
|
+
if (!body?.classList.contains("multi-select-on"))
|
|
88
|
+
return;
|
|
89
|
+
const target = e.target;
|
|
90
|
+
// A tap on a row is handled by the row's own click listener; only
|
|
91
|
+
// exit when the tap is on neutral ground (outside the list entirely).
|
|
92
|
+
if (!target.closest(".ml-row"))
|
|
93
|
+
exitMultiSelect();
|
|
94
|
+
}, true);
|
|
95
|
+
}
|
|
58
96
|
function selectRange(from, to) {
|
|
59
97
|
const body = document.getElementById("ml-body");
|
|
60
98
|
if (!body)
|
|
@@ -473,8 +511,7 @@ export async function showThreadPopup(pillEl, headMsg) {
|
|
|
473
511
|
item.appendChild(date);
|
|
474
512
|
item.appendChild(subject);
|
|
475
513
|
item.addEventListener("click", async () => {
|
|
476
|
-
|
|
477
|
-
onMessageSelect(msg.accountId, msg.uid, msg.folderId);
|
|
514
|
+
focusMessage(msg.accountId, { accountId: msg.accountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject, from: msg.from, to: msg.to, cc: msg.cc, date: msg.date, flags: msg.flags, size: msg.size, preview: msg.preview, hasAttachments: msg.hasAttachments });
|
|
478
515
|
popup.remove();
|
|
479
516
|
});
|
|
480
517
|
popup.appendChild(item);
|
|
@@ -627,6 +664,15 @@ function appendMessages(body, accountId, items) {
|
|
|
627
664
|
touchWasScroll = false;
|
|
628
665
|
return;
|
|
629
666
|
}
|
|
667
|
+
// Multi-select mode (entered via long-press on touch): taps toggle
|
|
668
|
+
// rows instead of opening messages. Exit mode when the user taps
|
|
669
|
+
// outside any row or presses Escape (handled at the body level).
|
|
670
|
+
const body = row.parentElement;
|
|
671
|
+
if (body?.classList.contains("multi-select-on")) {
|
|
672
|
+
row.classList.toggle("selected");
|
|
673
|
+
lastClickedRow = row;
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
630
676
|
if (e.shiftKey && lastClickedRow) {
|
|
631
677
|
clearSelection();
|
|
632
678
|
selectRange(lastClickedRow, row);
|
|
@@ -640,9 +686,7 @@ function appendMessages(body, accountId, items) {
|
|
|
640
686
|
}
|
|
641
687
|
lastClickedRow = row;
|
|
642
688
|
row.classList.remove("unread");
|
|
643
|
-
|
|
644
|
-
state.select(msg);
|
|
645
|
-
onMessageSelect(msgAccountId, msg.uid, msg.folderId);
|
|
689
|
+
focusMessage(msgAccountId, msg);
|
|
646
690
|
});
|
|
647
691
|
// Q64: double-click → pop out the message in a floating overlay so
|
|
648
692
|
// the user can read it without losing the selected list context.
|
|
@@ -693,12 +737,30 @@ function appendMessages(body, accountId, items) {
|
|
|
693
737
|
clearTimeout(longPressTimer);
|
|
694
738
|
longPressTimer = setTimeout(() => {
|
|
695
739
|
longPressTimer = null;
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
740
|
+
// Long-press semantics:
|
|
741
|
+
// - If the list is already in multi-select mode, toggle this
|
|
742
|
+
// row's selected state (so the user can extend a selection
|
|
743
|
+
// without needing a second long-press-and-menu dance).
|
|
744
|
+
// - Otherwise enter multi-select mode: mark THIS row selected
|
|
745
|
+
// and add a sticky class on the body so future taps toggle
|
|
746
|
+
// instead of opening messages. Tap elsewhere or press
|
|
747
|
+
// Escape to exit.
|
|
748
|
+
const body = row.parentElement;
|
|
749
|
+
const alreadyMulti = body?.classList.contains("multi-select-on");
|
|
750
|
+
if (alreadyMulti) {
|
|
751
|
+
row.classList.toggle("selected");
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
clearSelection();
|
|
755
|
+
row.classList.add("selected");
|
|
756
|
+
body?.classList.add("multi-select-on");
|
|
757
|
+
}
|
|
758
|
+
lastClickedRow = row;
|
|
759
|
+
// Haptic hint if the platform supports it (Android WebView does).
|
|
760
|
+
try {
|
|
761
|
+
navigator.vibrate?.(20);
|
|
762
|
+
}
|
|
763
|
+
catch { /* */ }
|
|
702
764
|
}, LONG_PRESS_MS);
|
|
703
765
|
}, { passive: true });
|
|
704
766
|
const cancelLongPress = () => {
|
|
@@ -718,8 +780,7 @@ function appendMessages(body, accountId, items) {
|
|
|
718
780
|
clearSelection();
|
|
719
781
|
row.classList.add("selected");
|
|
720
782
|
lastClickedRow = row;
|
|
721
|
-
|
|
722
|
-
onMessageSelect(msgAccountId, msg.uid, msg.folderId);
|
|
783
|
+
focusMessage(msgAccountId, msg);
|
|
723
784
|
}
|
|
724
785
|
const isSeen = msg.flags.includes("\\Seen");
|
|
725
786
|
const isFlagged = msg.flags.includes("\\Flagged");
|
|
@@ -794,6 +855,15 @@ function appendMessages(body, accountId, items) {
|
|
|
794
855
|
action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
|
|
795
856
|
},
|
|
796
857
|
{ label: "", action: () => { }, separator: true },
|
|
858
|
+
{
|
|
859
|
+
label: "⚠ Mark as spam",
|
|
860
|
+
action: () => document.getElementById("btn-spam")?.click(),
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
label: "🚫 Report spam",
|
|
864
|
+
action: () => document.getElementById("btn-spam-report")?.click(),
|
|
865
|
+
},
|
|
866
|
+
{ label: "", action: () => { }, separator: true },
|
|
797
867
|
{
|
|
798
868
|
label: "Copy Message-ID",
|
|
799
869
|
action: async () => {
|
package/client/index.html
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
<body>
|
|
14
14
|
<header class="toolbar">
|
|
15
15
|
<div class="toolbar-left">
|
|
16
|
-
<button class="tb-btn" id="btn-menu" title="
|
|
16
|
+
<button class="tb-btn" id="btn-menu" title="Menu / rail" hidden>☰</button>
|
|
17
|
+
<button class="tb-btn" id="btn-folder-toggle" title="Show / hide folders" hidden>📁</button>
|
|
17
18
|
<button class="tb-btn" id="btn-compose" title="Compose (Ctrl+N)">
|
|
18
19
|
<span class="tb-icon">✏</span><span class="tb-label"> Compose</span>
|
|
19
20
|
</button>
|
|
@@ -22,14 +23,14 @@
|
|
|
22
23
|
<div class="tb-menu" id="view-menu">
|
|
23
24
|
<button class="tb-btn" id="btn-view">View</button>
|
|
24
25
|
<div class="tb-menu-dropdown" id="view-dropdown" hidden>
|
|
25
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-two-line"> Two-line view</label>
|
|
26
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
|
|
27
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
|
|
28
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-threaded"> Group by thread</label>
|
|
29
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-thread-filter"> Only this conversation</label>
|
|
30
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
31
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
|
|
32
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
|
|
26
|
+
<label class="tb-menu-item" title="Stack From + Subject on two lines per row, date to the right — denser on narrow windows"><input type="checkbox" id="opt-two-line"> Two-line view</label>
|
|
27
|
+
<label class="tb-menu-item" title="Show the reading pane to the right of the message list"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
|
|
28
|
+
<label class="tb-menu-item" title="Show a short body-text preview (first ~80 chars) beneath each row's subject"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
|
|
29
|
+
<label class="tb-menu-item" title="Collapse reply chains to one row showing the newest message; a pill shows thread size"><input type="checkbox" id="opt-threaded"> Group by thread</label>
|
|
30
|
+
<label class="tb-menu-item" title="Filter the list to rows sharing the selected message's thread (set selection first)"><input type="checkbox" id="opt-thread-filter"> Only this conversation</label>
|
|
31
|
+
<label class="tb-menu-item" title="Show only flagged (★) messages in the list"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
32
|
+
<label class="tb-menu-item" title="Show unread/total counts next to each folder in the tree"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
|
|
33
|
+
<label class="tb-menu-item" title="Show the right-side calendar/tasks sidebar (Thunderbird-Lightning style)"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
|
|
33
34
|
</div>
|
|
34
35
|
</div>
|
|
35
36
|
<div class="tb-menu" id="settings-menu">
|
|
@@ -900,6 +900,23 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
900
900
|
list empty when the selection is a singleton thread. */
|
|
901
901
|
.ml-row.thread-filter-hidden { display: none; }
|
|
902
902
|
|
|
903
|
+
/* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
|
|
904
|
+
desktop). Add a left-edge accent bar so it's visually clear the list is
|
|
905
|
+
in selection mode, not navigation mode. */
|
|
906
|
+
#ml-body.multi-select-on .ml-row::before {
|
|
907
|
+
content: "";
|
|
908
|
+
position: absolute;
|
|
909
|
+
left: 0;
|
|
910
|
+
top: 0;
|
|
911
|
+
bottom: 0;
|
|
912
|
+
width: 3px;
|
|
913
|
+
background: var(--color-brand, oklch(0.65 0.14 250));
|
|
914
|
+
opacity: 0.25;
|
|
915
|
+
}
|
|
916
|
+
#ml-body.multi-select-on .ml-row.selected::before {
|
|
917
|
+
opacity: 1;
|
|
918
|
+
}
|
|
919
|
+
|
|
903
920
|
/* Info banner inside a modal — used by the Add-contact dialog to surface
|
|
904
921
|
"already in address book" duplicate notices. Amber tone so it reads as
|
|
905
922
|
"heads up" not "error". */
|
package/client/styles/layout.css
CHANGED
|
@@ -153,10 +153,15 @@ body.calendar-sidebar-on {
|
|
|
153
153
|
"status status";
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
/* Folder panel:
|
|
156
|
+
/* Folder panel: FULLY off-screen when closed — previously `calc(48px -
|
|
157
|
+
280px) = -232px` left a 48 px tail showing behind the rail, which on
|
|
158
|
+
Android (where the rail isn't always rendered) leaked a strip of
|
|
159
|
+
folder-row badges at the left edge of the viewport. Now the closed
|
|
160
|
+
panel is flush off-screen at `-280px` and the `.open` slide-in starts
|
|
161
|
+
at the rail's right edge. */
|
|
157
162
|
.folder-panel {
|
|
158
163
|
position: fixed;
|
|
159
|
-
left:
|
|
164
|
+
left: -280px;
|
|
160
165
|
top: var(--toolbar-height);
|
|
161
166
|
bottom: var(--statusbar-height);
|
|
162
167
|
width: 280px;
|
|
@@ -168,14 +173,19 @@ body.calendar-sidebar-on {
|
|
|
168
173
|
}
|
|
169
174
|
.folder-panel.open { left: var(--rail-width, 48px); }
|
|
170
175
|
|
|
171
|
-
/*
|
|
172
|
-
|
|
176
|
+
/* Medium tier has the rail visible permanently — no hamburger needed.
|
|
177
|
+
Show the folder-toggle (📁) so the user can still reveal the folder
|
|
178
|
+
tree on demand. */
|
|
179
|
+
#btn-menu { display: none !important; }
|
|
180
|
+
#btn-folder-toggle { display: inline-flex !important; }
|
|
173
181
|
}
|
|
174
182
|
|
|
175
183
|
/* Responsive: narrow OR short viewport — single panel navigation */
|
|
176
184
|
@media (max-width: 768px), (max-height: 600px) {
|
|
177
|
-
/*
|
|
178
|
-
|
|
185
|
+
/* Preview snippets remain visible on narrow — user-controlled via the
|
|
186
|
+
View menu's "Preview snippets" checkbox. Previously this rule forced
|
|
187
|
+
them hidden on any narrow viewport, so Android users never saw the
|
|
188
|
+
checkbox take effect. */
|
|
179
189
|
/* Column headers (From/Date/Subject) take space without being useful on narrow */
|
|
180
190
|
.ml-header { display: none; }
|
|
181
191
|
/* Current folder name shown above the list, Dovecot-style */
|
|
@@ -208,12 +218,25 @@ body.calendar-sidebar-on {
|
|
|
208
218
|
"status";
|
|
209
219
|
}
|
|
210
220
|
|
|
211
|
-
/* Rail
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
221
|
+
/* Rail on narrow: slide-in drawer triggered by the hamburger (☰). The
|
|
222
|
+
rail is the entry point to Inbox / All-Inboxes / Contacts / Calendar /
|
|
223
|
+
Tasks / Settings — without this drawer, narrow-tier users couldn't
|
|
224
|
+
reach any of those. */
|
|
225
|
+
.icon-rail {
|
|
226
|
+
display: flex;
|
|
227
|
+
position: fixed;
|
|
228
|
+
left: -64px;
|
|
229
|
+
top: var(--toolbar-height);
|
|
230
|
+
bottom: var(--statusbar-height);
|
|
231
|
+
z-index: 60;
|
|
232
|
+
transition: left 0.2s ease;
|
|
233
|
+
box-shadow: 2px 0 8px rgba(0,0,0,0.3);
|
|
234
|
+
}
|
|
235
|
+
.icon-rail.open { left: 0; }
|
|
215
236
|
|
|
216
|
-
/* Folder panel:
|
|
237
|
+
/* Folder panel: slide-in drawer triggered by the folder icon (📁).
|
|
238
|
+
Kept separate from the rail drawer so the user can have folders open
|
|
239
|
+
while the rail is closed, or vice versa — matching the desktop flow. */
|
|
217
240
|
.folder-panel {
|
|
218
241
|
position: fixed;
|
|
219
242
|
left: -280px;
|
|
@@ -246,8 +269,10 @@ body.calendar-sidebar-on {
|
|
|
246
269
|
}
|
|
247
270
|
.message-list.narrow-hidden { display: none; }
|
|
248
271
|
|
|
249
|
-
/*
|
|
272
|
+
/* Hamburger opens the rail drawer; folder-toggle opens the folder drawer.
|
|
273
|
+
Both always visible on narrow so the user can reach either. */
|
|
250
274
|
#btn-menu { display: inline-flex !important; }
|
|
275
|
+
#btn-folder-toggle { display: inline-flex !important; }
|
|
251
276
|
/* Back button: only show when viewer is active (message list hidden) */
|
|
252
277
|
#btn-back { display: none !important; }
|
|
253
278
|
.message-viewer.narrow-active ~ * #btn-back,
|
|
@@ -280,7 +305,8 @@ body.calendar-sidebar-on {
|
|
|
280
305
|
}
|
|
281
306
|
}
|
|
282
307
|
|
|
283
|
-
/* Hide hamburger and back on wide screens (folder panel
|
|
308
|
+
/* Hide hamburger, folder-toggle, and back on wide screens (folder panel
|
|
309
|
+
permanent column, rail permanent column, no drawer toggles needed). */
|
|
284
310
|
@media (min-width: 1101px) {
|
|
285
|
-
#btn-menu, #btn-back { display: none !important; }
|
|
311
|
+
#btn-menu, #btn-folder-toggle, #btn-back { display: none !important; }
|
|
286
312
|
}
|
package/package.json
CHANGED
|
@@ -365,9 +365,13 @@ class AndroidSyncManager {
|
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
367
|
async fetchMessageBody(accountId, folderId, uid) {
|
|
368
|
+
const t0 = Date.now();
|
|
368
369
|
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
369
|
-
|
|
370
|
+
const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
|
|
371
|
+
console.log(`[fetchBody] cache hit ${accountId}/${folderId}/${uid} (${Date.now() - t0}ms)`);
|
|
372
|
+
return cached;
|
|
370
373
|
}
|
|
374
|
+
console.log(`[fetchBody] cache miss ${accountId}/${folderId}/${uid} — fetching`);
|
|
371
375
|
const provider = this.getProvider(accountId);
|
|
372
376
|
if (!provider) {
|
|
373
377
|
console.warn(`[fetchBody] No provider for ${accountId}`);
|
|
@@ -376,27 +380,41 @@ class AndroidSyncManager {
|
|
|
376
380
|
// Look up the Gmail providerId stored in body_path during sync
|
|
377
381
|
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
378
382
|
const bp = envelope?.bodyPath || "";
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
383
|
+
// 60 s wall-clock cap — infinite hang was the user-reported symptom
|
|
384
|
+
// ("fetch message body on android is infinite"). A dead BridgeTransport
|
|
385
|
+
// socket won't recover by waiting. Legit fetches finish in seconds.
|
|
386
|
+
const FETCH_TIMEOUT_MS = 60_000;
|
|
387
|
+
const fetchPromise = (async () => {
|
|
388
|
+
if (bp.startsWith("gmail:") && provider.fetchById) {
|
|
389
|
+
const providerId = bp.substring(6);
|
|
390
|
+
return provider.fetchById(providerId, { source: true });
|
|
391
|
+
}
|
|
386
392
|
const folders = this.db.getFolders(accountId);
|
|
387
393
|
const folder = folders.find(f => f.id === folderId);
|
|
388
394
|
if (!folder)
|
|
389
395
|
return null;
|
|
390
|
-
|
|
396
|
+
return provider.fetchOne(folder.path, uid, { source: true });
|
|
397
|
+
})();
|
|
398
|
+
let msg = null;
|
|
399
|
+
try {
|
|
400
|
+
msg = await Promise.race([
|
|
401
|
+
fetchPromise,
|
|
402
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`body-fetch timeout ${FETCH_TIMEOUT_MS / 1000}s (${accountId}/${folderId}/${uid})`)), FETCH_TIMEOUT_MS)),
|
|
403
|
+
]);
|
|
404
|
+
}
|
|
405
|
+
catch (e) {
|
|
406
|
+
console.error(`[fetchBody] failed ${accountId}/${folderId}/${uid} after ${Date.now() - t0}ms: ${e?.message || e}`);
|
|
407
|
+
throw e;
|
|
391
408
|
}
|
|
392
409
|
if (!msg?.source) {
|
|
393
|
-
console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp})`);
|
|
410
|
+
console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, ${Date.now() - t0}ms)`);
|
|
394
411
|
return null;
|
|
395
412
|
}
|
|
396
413
|
// Encode the UTF-8 string back to bytes for storage
|
|
397
414
|
const raw = new TextEncoder().encode(msg.source);
|
|
398
415
|
await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
399
416
|
this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
|
|
417
|
+
console.log(`[fetchBody] fetched + cached ${accountId}/${folderId}/${uid} (${raw.byteLength} bytes, ${Date.now() - t0}ms)`);
|
|
400
418
|
return raw;
|
|
401
419
|
}
|
|
402
420
|
async updateFlagsLocal(accountId, uid, folderId, flags) {
|
|
@@ -241,33 +241,62 @@ export class SyncManager {
|
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
async fetchMessageBody(accountId, folderId, uid) {
|
|
244
|
+
const t0 = Date.now();
|
|
245
|
+
// Cache first — IndexedDB lookup is O(1) and should always win on a
|
|
246
|
+
// previously-fetched body. If this path misses on something the user
|
|
247
|
+
// clearly fetched before, the cache is broken (wrong key shape, wiped
|
|
248
|
+
// IndexedDB, account-id change) and needs investigation — log so it's
|
|
249
|
+
// visible.
|
|
244
250
|
if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
|
|
245
|
-
|
|
251
|
+
const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
|
|
252
|
+
console.log(`[fetchBody] cache hit ${accountId}/${folderId}/${uid} (${Date.now() - t0}ms)`);
|
|
253
|
+
return cached;
|
|
246
254
|
}
|
|
255
|
+
console.log(`[fetchBody] cache miss ${accountId}/${folderId}/${uid} — fetching from provider`);
|
|
247
256
|
const provider = this.getProvider(accountId);
|
|
248
|
-
if (!provider)
|
|
257
|
+
if (!provider) {
|
|
258
|
+
console.warn(`[fetchBody] no provider for ${accountId}`);
|
|
249
259
|
return null;
|
|
260
|
+
}
|
|
250
261
|
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
251
262
|
const bp = envelope?.bodyPath || "";
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
263
|
+
// Wall-clock timeout — without this, an IMAP provider that dangles
|
|
264
|
+
// (Dovecot silently dropped the socket, BridgeTransport stalled) hung
|
|
265
|
+
// the viewer forever. 60 s is generous for a single-message BODY[]
|
|
266
|
+
// fetch on a phone connection; legit large bodies finish in seconds,
|
|
267
|
+
// and anything longer means the socket is dead and retrying on a fresh
|
|
268
|
+
// one is faster than waiting.
|
|
269
|
+
const FETCH_TIMEOUT_MS = 60_000;
|
|
270
|
+
const fetchPromise = (async () => {
|
|
271
|
+
if (bp.startsWith("gmail:") && provider.fetchById) {
|
|
272
|
+
const providerId = bp.substring(6);
|
|
273
|
+
return provider.fetchById(providerId, { source: true });
|
|
274
|
+
}
|
|
258
275
|
const folders = this.db.getFolders(accountId);
|
|
259
276
|
const folder = folders.find((f) => f.id === folderId);
|
|
260
277
|
if (!folder)
|
|
261
278
|
return null;
|
|
262
|
-
|
|
279
|
+
return provider.fetchOne(folder.path, uid, { source: true });
|
|
280
|
+
})();
|
|
281
|
+
let msg = null;
|
|
282
|
+
try {
|
|
283
|
+
msg = await Promise.race([
|
|
284
|
+
fetchPromise,
|
|
285
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`body-fetch timeout ${FETCH_TIMEOUT_MS / 1000}s (${accountId}/${folderId}/${uid})`)), FETCH_TIMEOUT_MS)),
|
|
286
|
+
]);
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
console.error(`[fetchBody] failed ${accountId}/${folderId}/${uid} after ${Date.now() - t0}ms: ${e?.message || e}`);
|
|
290
|
+
throw e;
|
|
263
291
|
}
|
|
264
292
|
if (!msg?.source) {
|
|
265
|
-
console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp})`);
|
|
293
|
+
console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, ${Date.now() - t0}ms)`);
|
|
266
294
|
return null;
|
|
267
295
|
}
|
|
268
296
|
const raw = new TextEncoder().encode(msg.source);
|
|
269
297
|
await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
270
298
|
this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
|
|
299
|
+
console.log(`[fetchBody] fetched + cached ${accountId}/${folderId}/${uid} (${raw.byteLength} bytes, ${Date.now() - t0}ms)`);
|
|
271
300
|
return raw;
|
|
272
301
|
}
|
|
273
302
|
async updateFlagsLocal(accountId, uid, folderId, flags) {
|
package/rebuild.cmd
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM ─────────────────────────────────────────────────────────────────────────
|
|
3
|
+
REM rebuild.cmd — one-shot release: npmglobalize + APK.
|
|
4
|
+
REM Runs the desktop release workflow (npmglobalize commits, tags, bumps,
|
|
5
|
+
REM publishes the npm package) and, only if that succeeds, rebuilds the
|
|
6
|
+
REM Android MAUI APK + copies it into download/apks/ + updates versions.json.
|
|
7
|
+
REM Anything non-zero from npmglobalize aborts before the APK stage — no
|
|
8
|
+
REM point building an APK against a failed publish.
|
|
9
|
+
REM ─────────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
cls
|
|
12
|
+
setlocal
|
|
13
|
+
cd /d %~dp0
|
|
14
|
+
|
|
15
|
+
call npmglobalize
|
|
16
|
+
if errorlevel 1 (
|
|
17
|
+
echo [rebuild] npmglobalize failed with errorlevel %ERRORLEVEL% — skipping build-apk
|
|
18
|
+
endlocal
|
|
19
|
+
exit /b %ERRORLEVEL%
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
call "%~dp0build-apk.cmd"
|
|
23
|
+
endlocal
|
package/unbash.cmd
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM ─────────────────────────────────────────────────────────────────────────
|
|
3
|
+
REM unbash.cmd — surgically remove a stuck session-env subdir so Claude
|
|
4
|
+
REM Code's Bash tool can recreate it. One subdir per concurrent Claude
|
|
5
|
+
REM session; only nuke the stuck one, not all of them.
|
|
6
|
+
REM
|
|
7
|
+
REM Usage:
|
|
8
|
+
REM unbash.cmd List existing session-env subdirs.
|
|
9
|
+
REM unbash.cmd <uuid> Remove that specific subdir.
|
|
10
|
+
REM unbash.cmd stale Remove subdirs with no writes in >60 min
|
|
11
|
+
REM (abandoned from crashed sessions).
|
|
12
|
+
REM unbash.cmd all Remove every subdir (nuclear — kills
|
|
13
|
+
REM state of live concurrent sessions too).
|
|
14
|
+
REM ─────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
setlocal ENABLEEXTENSIONS
|
|
17
|
+
set BASE=%USERPROFILE%\.claude\session-env
|
|
18
|
+
|
|
19
|
+
if not exist "%BASE%" (
|
|
20
|
+
echo [unbash] No session-env directory at %BASE% — nothing to do.
|
|
21
|
+
endlocal
|
|
22
|
+
exit /b 0
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if "%~1"=="" (
|
|
26
|
+
echo [unbash] Subdirs under %BASE%:
|
|
27
|
+
dir /b /ad "%BASE%" 2>nul
|
|
28
|
+
echo.
|
|
29
|
+
echo [unbash] Pass a UUID to remove just one, 'stale' to remove old ones,
|
|
30
|
+
echo or 'all' to nuke everything ^(kills live sessions too^).
|
|
31
|
+
endlocal
|
|
32
|
+
exit /b 0
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if /I "%~1"=="all" (
|
|
36
|
+
rmdir /s /q "%BASE%"
|
|
37
|
+
echo [unbash] Removed all session-env subdirs.
|
|
38
|
+
endlocal
|
|
39
|
+
exit /b 0
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if /I "%~1"=="stale" (
|
|
43
|
+
powershell -NoProfile -Command "Get-ChildItem -Directory '%BASE%' | Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-60) } | ForEach-Object { Write-Host ('[unbash] removing stale ' + $_.Name); Remove-Item -Recurse -Force $_.FullName }"
|
|
44
|
+
endlocal
|
|
45
|
+
exit /b 0
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if not exist "%BASE%\%~1" (
|
|
49
|
+
echo [unbash] No subdir %~1 under %BASE%.
|
|
50
|
+
endlocal
|
|
51
|
+
exit /b 1
|
|
52
|
+
)
|
|
53
|
+
rmdir /s /q "%BASE%\%~1"
|
|
54
|
+
echo [unbash] Removed %BASE%\%~1
|
|
55
|
+
endlocal
|