@bobfrankston/mailx 1.0.394 → 1.0.399
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 +34 -7
- package/client/app.js +42 -5
- package/client/components/alarms.js +286 -0
- package/client/components/calendar-sidebar.js +43 -7
- package/client/components/message-list.js +223 -16
- package/client/compose/compose.js +24 -0
- package/client/index.html +21 -10
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +1 -0
- package/client/styles/components.css +200 -0
- package/client/styles/layout.css +40 -14
- package/package.json +1 -1
- package/packages/mailx-service/index.d.ts +4 -0
- package/packages/mailx-service/index.js +6 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-store/db.d.ts +8 -0
- package/packages/mailx-store/db.js +26 -0
- package/packages/mailx-store-web/android-bootstrap.js +60 -0
- package/packages/mailx-store-web/db.d.ts +4 -0
- package/packages/mailx-store-web/db.js +25 -0
- package/packages/mailx-store-web/sync-manager.d.ts +7 -0
- package/packages/mailx-store-web/sync-manager.js +55 -0
- package/packages/mailx-store-web/web-service.d.ts +4 -0
- package/packages/mailx-store-web/web-service.js +7 -0
- package/tdview.cmd +1 -0
|
@@ -24,15 +24,46 @@ let touchWasScroll = false;
|
|
|
24
24
|
// (text columns default asc, date defaults desc).
|
|
25
25
|
let currentSort = "date";
|
|
26
26
|
let currentSortDir = "desc";
|
|
27
|
-
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
27
|
+
/** S56 refactor slice — per-row focus/unfocus.
|
|
28
|
+
*
|
|
29
|
+
* Every rendered message row has an associated Row record tracking the DOM
|
|
30
|
+
* element + account/msg pair. `focusRow` runs the atomic transition:
|
|
31
|
+
*
|
|
32
|
+
* 1. unfocus the previously-focused row (clears `.selected` class)
|
|
33
|
+
* 2. mark the new row `.selected`
|
|
34
|
+
* 3. update shared state.selected + notify viewer
|
|
35
|
+
*
|
|
36
|
+
* The viewer's `showMessageGeneration` token still cancels stale body
|
|
37
|
+
* fetches during the transition — that's the "async cancellation" piece of
|
|
38
|
+
* S56. The "atomic DOM transition" piece is now owned here.
|
|
39
|
+
*
|
|
40
|
+
* Full abort-signal plumbing through getMessage → fetchMessageBody is a
|
|
41
|
+
* separate follow-up; for now the gen token does the job. */
|
|
42
|
+
let currentFocusedRow = null;
|
|
43
|
+
function focusRow(row, accountId, msg) {
|
|
44
|
+
if (currentFocusedRow && currentFocusedRow !== row) {
|
|
45
|
+
// Unfocus the previous atomically — clearing .selected AND
|
|
46
|
+
// triggering the viewer's stale-fetch cancel (via the bump inside
|
|
47
|
+
// the next showMessage() call).
|
|
48
|
+
currentFocusedRow.classList.remove("selected");
|
|
49
|
+
}
|
|
50
|
+
if (!row.classList.contains("selected"))
|
|
51
|
+
row.classList.add("selected");
|
|
52
|
+
currentFocusedRow = row;
|
|
53
|
+
state.select(msg);
|
|
54
|
+
onMessageSelect(accountId, msg.uid, msg.folderId);
|
|
55
|
+
}
|
|
56
|
+
/** Back-compat shim — some call sites still use `focusMessage(accountId, msg)`
|
|
57
|
+
* without a DOM row (e.g. thread-popup click). Fall back to the previous
|
|
58
|
+
* behavior (state + viewer only) so nothing regresses. */
|
|
33
59
|
function focusMessage(accountId, msg) {
|
|
34
60
|
state.select(msg);
|
|
35
61
|
onMessageSelect(accountId, msg.uid, msg.folderId);
|
|
62
|
+
// Clear the focused-row reference since we don't have a DOM row here
|
|
63
|
+
// — the next row click will unfocus whatever was selected anyway.
|
|
64
|
+
if (currentFocusedRow)
|
|
65
|
+
currentFocusedRow.classList.remove("selected");
|
|
66
|
+
currentFocusedRow = null;
|
|
36
67
|
}
|
|
37
68
|
/** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
|
|
38
69
|
* Called from the bodyCached service event — covers both background prefetch
|
|
@@ -64,6 +95,139 @@ function clearSelection() {
|
|
|
64
95
|
const body = document.getElementById("ml-body");
|
|
65
96
|
if (body)
|
|
66
97
|
body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
|
|
98
|
+
// S56 seam: the focused-row invariant is "the Row whose .selected is
|
|
99
|
+
// currently mine". clearSelection wipes all .selected, so the invariant
|
|
100
|
+
// would break if we kept a stale pointer.
|
|
101
|
+
currentFocusedRow = null;
|
|
102
|
+
}
|
|
103
|
+
/** Exit multi-select mode (entered via touch long-press). Clears selection
|
|
104
|
+
* and the sticky body flag so subsequent taps open messages again. */
|
|
105
|
+
function exitMultiSelect() {
|
|
106
|
+
const body = document.getElementById("ml-body");
|
|
107
|
+
if (!body?.classList.contains("multi-select-on"))
|
|
108
|
+
return;
|
|
109
|
+
body.classList.remove("multi-select-on");
|
|
110
|
+
clearSelection();
|
|
111
|
+
updateBulkBar();
|
|
112
|
+
}
|
|
113
|
+
/** Refresh the bulk-actions bar visibility + "N selected" label. Called
|
|
114
|
+
* whenever selection or mode changes. Visible either when 2+ rows are
|
|
115
|
+
* selected (desktop Ctrl/Shift-click multi-selection) OR when touch
|
|
116
|
+
* multi-select mode is active (even with a single row, so the user sees
|
|
117
|
+
* the bar as the mode indicator). */
|
|
118
|
+
function updateBulkBar() {
|
|
119
|
+
const body = document.getElementById("ml-body");
|
|
120
|
+
const bar = document.getElementById("ml-bulkbar");
|
|
121
|
+
const count = document.getElementById("ml-bulk-count");
|
|
122
|
+
if (!body || !bar || !count)
|
|
123
|
+
return;
|
|
124
|
+
const active = body.classList.contains("multi-select-on");
|
|
125
|
+
const n = body.querySelectorAll(".ml-row.selected").length;
|
|
126
|
+
if (n >= 2 || (active && n > 0)) {
|
|
127
|
+
bar.hidden = false;
|
|
128
|
+
count.textContent = `${n} selected`;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
bar.hidden = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Escape key + click-outside-list exit multi-select mode. Attached once
|
|
135
|
+
// (idempotent because document only has one listener scope per handler).
|
|
136
|
+
if (!window.__mailxMultiSelectWired) {
|
|
137
|
+
window.__mailxMultiSelectWired = true;
|
|
138
|
+
document.addEventListener("keydown", (e) => {
|
|
139
|
+
if (e.key === "Escape")
|
|
140
|
+
exitMultiSelect();
|
|
141
|
+
});
|
|
142
|
+
document.addEventListener("pointerdown", (e) => {
|
|
143
|
+
const body = document.getElementById("ml-body");
|
|
144
|
+
if (!body?.classList.contains("multi-select-on"))
|
|
145
|
+
return;
|
|
146
|
+
const target = e.target;
|
|
147
|
+
// A tap on a row is handled by the row's own click listener; only
|
|
148
|
+
// exit when the tap is on neutral ground (outside the list entirely
|
|
149
|
+
// and not on the bulk bar).
|
|
150
|
+
if (!target.closest(".ml-row") && !target.closest(".ml-bulkbar"))
|
|
151
|
+
exitMultiSelect();
|
|
152
|
+
}, true);
|
|
153
|
+
// Wire bulk-bar buttons — each delegates to the single-message handlers
|
|
154
|
+
// but iterates over every .ml-row.selected in the body.
|
|
155
|
+
document.addEventListener("click", async (e) => {
|
|
156
|
+
const target = e.target;
|
|
157
|
+
if (target.closest("#ml-bulk-cancel")) {
|
|
158
|
+
exitMultiSelect();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const btn = target.closest(".ml-bulk-btn");
|
|
162
|
+
if (!btn)
|
|
163
|
+
return;
|
|
164
|
+
const op = btn.dataset.bulk;
|
|
165
|
+
const body = document.getElementById("ml-body");
|
|
166
|
+
if (!body?.classList.contains("multi-select-on"))
|
|
167
|
+
return;
|
|
168
|
+
const selected = getSelectedMessages();
|
|
169
|
+
if (selected.length === 0)
|
|
170
|
+
return;
|
|
171
|
+
try {
|
|
172
|
+
if (op === "delete") {
|
|
173
|
+
// Delegate to the existing Del-key handler so the same
|
|
174
|
+
// tombstone/undo logic runs for bulk deletes.
|
|
175
|
+
document.dispatchEvent(new CustomEvent("mailx-delete"));
|
|
176
|
+
}
|
|
177
|
+
else if (op === "flag") {
|
|
178
|
+
for (const m of selected) {
|
|
179
|
+
const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
|
|
180
|
+
if (!row)
|
|
181
|
+
continue;
|
|
182
|
+
const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
|
|
183
|
+
if (!msgData)
|
|
184
|
+
continue;
|
|
185
|
+
const isFlagged = msgData.flags?.includes("\\Flagged");
|
|
186
|
+
const newFlags = isFlagged
|
|
187
|
+
? msgData.flags.filter((f) => f !== "\\Flagged")
|
|
188
|
+
: [...(msgData.flags || []), "\\Flagged"];
|
|
189
|
+
await updateFlags(m.accountId, m.uid, newFlags);
|
|
190
|
+
msgData.flags = newFlags;
|
|
191
|
+
state.updateMessageFlags(m.accountId, m.uid, newFlags);
|
|
192
|
+
row.classList.toggle("flagged");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (op === "markread") {
|
|
196
|
+
for (const m of selected) {
|
|
197
|
+
const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
|
|
198
|
+
if (!row)
|
|
199
|
+
continue;
|
|
200
|
+
const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
|
|
201
|
+
if (!msgData)
|
|
202
|
+
continue;
|
|
203
|
+
if (msgData.flags?.includes("\\Seen"))
|
|
204
|
+
continue;
|
|
205
|
+
const newFlags = [...(msgData.flags || []), "\\Seen"];
|
|
206
|
+
await updateFlags(m.accountId, m.uid, newFlags);
|
|
207
|
+
msgData.flags = newFlags;
|
|
208
|
+
state.updateMessageFlags(m.accountId, m.uid, newFlags);
|
|
209
|
+
row.classList.remove("unread");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else if (op === "move") {
|
|
213
|
+
// Use the first selection's account/folder for the picker scope.
|
|
214
|
+
const { accountId, folderId } = selected[0];
|
|
215
|
+
const pick = await pickFolder(accountId, { excludeFolderIds: [folderId] });
|
|
216
|
+
if (!pick)
|
|
217
|
+
return;
|
|
218
|
+
const uids = selected.map(s => s.uid);
|
|
219
|
+
await apiMoveMessages(accountId, uids, pick.folderId);
|
|
220
|
+
state.removeMessages(uids.map(u => ({ accountId, uid: u })));
|
|
221
|
+
}
|
|
222
|
+
else if (op === "spam") {
|
|
223
|
+
document.getElementById("btn-spam")?.click();
|
|
224
|
+
}
|
|
225
|
+
exitMultiSelect();
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
alert(`Bulk ${op} failed: ${err.message || err}`);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
67
231
|
}
|
|
68
232
|
function selectRange(from, to) {
|
|
69
233
|
const body = document.getElementById("ml-body");
|
|
@@ -352,6 +516,10 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
352
516
|
export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
|
|
353
517
|
searchMode = false;
|
|
354
518
|
unifiedMode = false;
|
|
519
|
+
// Folder switch clears any in-progress multi-select — carrying a "3
|
|
520
|
+
// selected" state across folders would lie about what rows the bulk
|
|
521
|
+
// buttons would act on.
|
|
522
|
+
exitMultiSelect();
|
|
355
523
|
// specialUse is either the DB tag ("sent"/"drafts"/"outbox") or the
|
|
356
524
|
// folder path lowercased (folder-tree fallback when tag is missing — common
|
|
357
525
|
// on Dovecot which doesn't advertise \Sent). Match both cases.
|
|
@@ -636,20 +804,35 @@ function appendMessages(body, accountId, items) {
|
|
|
636
804
|
touchWasScroll = false;
|
|
637
805
|
return;
|
|
638
806
|
}
|
|
807
|
+
// Multi-select mode (entered via long-press on touch): taps toggle
|
|
808
|
+
// rows instead of opening messages. Exit mode when the user taps
|
|
809
|
+
// outside any row or presses Escape (handled at the body level).
|
|
810
|
+
const body = row.parentElement;
|
|
811
|
+
if (body?.classList.contains("multi-select-on")) {
|
|
812
|
+
row.classList.toggle("selected");
|
|
813
|
+
lastClickedRow = row;
|
|
814
|
+
updateBulkBar();
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
639
817
|
if (e.shiftKey && lastClickedRow) {
|
|
640
818
|
clearSelection();
|
|
641
819
|
selectRange(lastClickedRow, row);
|
|
820
|
+
lastClickedRow = row;
|
|
821
|
+
row.classList.remove("unread");
|
|
822
|
+
focusMessage(msgAccountId, msg);
|
|
642
823
|
}
|
|
643
824
|
else if (e.ctrlKey || e.metaKey) {
|
|
644
825
|
row.classList.toggle("selected");
|
|
826
|
+
lastClickedRow = row;
|
|
645
827
|
}
|
|
646
828
|
else {
|
|
829
|
+
// Atomic unfocus-previous + focus-this.
|
|
647
830
|
clearSelection();
|
|
648
|
-
row
|
|
831
|
+
focusRow(row, msgAccountId, msg);
|
|
832
|
+
lastClickedRow = row;
|
|
833
|
+
row.classList.remove("unread");
|
|
649
834
|
}
|
|
650
|
-
|
|
651
|
-
row.classList.remove("unread");
|
|
652
|
-
focusMessage(msgAccountId, msg);
|
|
835
|
+
updateBulkBar();
|
|
653
836
|
});
|
|
654
837
|
// Q64: double-click → pop out the message in a floating overlay so
|
|
655
838
|
// the user can read it without losing the selected list context.
|
|
@@ -700,12 +883,31 @@ function appendMessages(body, accountId, items) {
|
|
|
700
883
|
clearTimeout(longPressTimer);
|
|
701
884
|
longPressTimer = setTimeout(() => {
|
|
702
885
|
longPressTimer = null;
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
886
|
+
// Long-press semantics:
|
|
887
|
+
// - If the list is already in multi-select mode, toggle this
|
|
888
|
+
// row's selected state (so the user can extend a selection
|
|
889
|
+
// without needing a second long-press-and-menu dance).
|
|
890
|
+
// - Otherwise enter multi-select mode: mark THIS row selected
|
|
891
|
+
// and add a sticky class on the body so future taps toggle
|
|
892
|
+
// instead of opening messages. Tap elsewhere or press
|
|
893
|
+
// Escape to exit.
|
|
894
|
+
const body = row.parentElement;
|
|
895
|
+
const alreadyMulti = body?.classList.contains("multi-select-on");
|
|
896
|
+
if (alreadyMulti) {
|
|
897
|
+
row.classList.toggle("selected");
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
clearSelection();
|
|
901
|
+
row.classList.add("selected");
|
|
902
|
+
body?.classList.add("multi-select-on");
|
|
903
|
+
}
|
|
904
|
+
lastClickedRow = row;
|
|
905
|
+
updateBulkBar();
|
|
906
|
+
// Haptic hint if the platform supports it (Android WebView does).
|
|
907
|
+
try {
|
|
908
|
+
navigator.vibrate?.(20);
|
|
909
|
+
}
|
|
910
|
+
catch { /* */ }
|
|
709
911
|
}, LONG_PRESS_MS);
|
|
710
912
|
}, { passive: true });
|
|
711
913
|
const cancelLongPress = () => {
|
|
@@ -800,6 +1002,11 @@ function appendMessages(body, accountId, items) {
|
|
|
800
1002
|
action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
|
|
801
1003
|
},
|
|
802
1004
|
{ label: "", action: () => { }, separator: true },
|
|
1005
|
+
{
|
|
1006
|
+
label: "⚠ Mark as spam",
|
|
1007
|
+
action: () => document.getElementById("btn-spam")?.click(),
|
|
1008
|
+
},
|
|
1009
|
+
{ label: "", action: () => { }, separator: true },
|
|
803
1010
|
{
|
|
804
1011
|
label: "Copy Message-ID",
|
|
805
1012
|
action: async () => {
|
|
@@ -361,6 +361,30 @@ function applyInit(init) {
|
|
|
361
361
|
if (ccBtn)
|
|
362
362
|
ccBtn.classList.add("active");
|
|
363
363
|
}
|
|
364
|
+
else if (init.to && init.to.length === 1) {
|
|
365
|
+
// Q49: heuristic auto-expand — when replying/composing to a single
|
|
366
|
+
// recipient, check sent-history. If the user has previously Cc'd
|
|
367
|
+
// anyone on a message to this recipient, expand the Cc row (empty,
|
|
368
|
+
// just visible) so they're prompted to fill it. Fire-and-forget; if
|
|
369
|
+
// the service call fails or the user starts typing Cc manually
|
|
370
|
+
// before it resolves, the answer doesn't matter.
|
|
371
|
+
const firstEmail = init.to[0]?.address || "";
|
|
372
|
+
if (firstEmail) {
|
|
373
|
+
import("../lib/api-client.js").then(({ hasCcHistoryTo }) => hasCcHistoryTo(firstEmail)
|
|
374
|
+
.then(res => {
|
|
375
|
+
if (!res?.hasCc)
|
|
376
|
+
return;
|
|
377
|
+
const ccRowEl = document.getElementById("compose-cc-row");
|
|
378
|
+
const ccBtn = document.getElementById("btn-toggle-cc");
|
|
379
|
+
// Only expand if user hasn't already interacted
|
|
380
|
+
if (ccRowEl?.hidden && !ccInput.value) {
|
|
381
|
+
ccRowEl.hidden = false;
|
|
382
|
+
ccBtn?.classList.add("active");
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
.catch(() => { }));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
364
388
|
// C42: append the account's signature (if configured) BEFORE rendering
|
|
365
389
|
// the body. For new mode: just signature. For reply/forward: appended
|
|
366
390
|
// after the quoted block. Drafts are skipped — the signature is already
|
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">
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
|
|
45
46
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
|
|
46
47
|
<hr class="tb-menu-sep">
|
|
47
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
|
|
48
|
+
<label class="tb-menu-item" title="Ghost-text completions while composing — Ollama / Claude / OpenAI back-end, off by default"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
|
|
48
49
|
<label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
|
|
49
50
|
<label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
|
|
50
51
|
<hr class="tb-menu-sep">
|
|
@@ -122,6 +123,16 @@
|
|
|
122
123
|
<span class="ml-col ml-col-date ml-col-sortable" data-sort="date">Date</span>
|
|
123
124
|
<span class="ml-col ml-col-subject ml-col-sortable" data-sort="subject">Subject</span>
|
|
124
125
|
</div>
|
|
126
|
+
<div class="ml-bulkbar" id="ml-bulkbar" hidden>
|
|
127
|
+
<button type="button" class="ml-bulk-cancel" id="ml-bulk-cancel" title="Exit multi-select (Esc)">✕</button>
|
|
128
|
+
<span class="ml-bulk-count" id="ml-bulk-count">0 selected</span>
|
|
129
|
+
<span style="flex:1"></span>
|
|
130
|
+
<button type="button" class="ml-bulk-btn" data-bulk="markread" title="Mark read">◉</button>
|
|
131
|
+
<button type="button" class="ml-bulk-btn" data-bulk="flag" title="Flag">⚑</button>
|
|
132
|
+
<button type="button" class="ml-bulk-btn" data-bulk="move" title="Move to folder…">➜</button>
|
|
133
|
+
<button type="button" class="ml-bulk-btn" data-bulk="spam" title="Mark as spam">⚠</button>
|
|
134
|
+
<button type="button" class="ml-bulk-btn ml-bulk-danger" data-bulk="delete" title="Delete (Del)">🗑</button>
|
|
135
|
+
</div>
|
|
125
136
|
<div class="ml-body" id="ml-body">
|
|
126
137
|
<div class="ml-empty">Select a folder to view messages</div>
|
|
127
138
|
</div>
|
package/client/lib/api-client.js
CHANGED
|
@@ -191,6 +191,9 @@ export function cancelQueuedOutgoing(p) {
|
|
|
191
191
|
export function searchContacts(query) {
|
|
192
192
|
return ipc().searchContacts(query);
|
|
193
193
|
}
|
|
194
|
+
export function hasCcHistoryTo(email) {
|
|
195
|
+
return ipc().hasCcHistoryTo(email);
|
|
196
|
+
}
|
|
194
197
|
export function listContacts(query, page = 1, pageSize = 100) {
|
|
195
198
|
return ipc().listContacts(query, page, pageSize);
|
|
196
199
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -181,6 +181,7 @@
|
|
|
181
181
|
cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
|
|
182
182
|
reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
|
|
183
183
|
reauthGoogleScopes: function() { return callNode("reauthGoogleScopes"); },
|
|
184
|
+
hasCcHistoryTo: function(email) { return callNode("hasCcHistoryTo", { email: email }); },
|
|
184
185
|
|
|
185
186
|
// Bulk operations
|
|
186
187
|
deleteMessages: function(accountId, uids) {
|
|
@@ -900,6 +900,206 @@ 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
|
+
/* Per-row ⋮ touch-menu button removed 2026-04-24 — user feedback: "nice
|
|
904
|
+
idea but better when we have a second-stage plan strategy". Touch users
|
|
905
|
+
still reach the menu via long-press → multi-select → bulk-bar, or by
|
|
906
|
+
using a stylus/mouse for contextmenu. Revisit when we've decided what
|
|
907
|
+
additional per-message actions belong here. */
|
|
908
|
+
|
|
909
|
+
/* ── Alarm popup (P17 / Q104) ── */
|
|
910
|
+
/* Fullscreen backdrop + centered panel; mirrors .mailx-modal* patterns but
|
|
911
|
+
kept in its own namespace so alarm behavior (snooze/dismiss) can evolve
|
|
912
|
+
without dragging the whole modal system along. */
|
|
913
|
+
.alarm-overlay {
|
|
914
|
+
position: fixed;
|
|
915
|
+
inset: 0;
|
|
916
|
+
background: rgba(0, 0, 0, 0.45);
|
|
917
|
+
z-index: 9000;
|
|
918
|
+
display: flex;
|
|
919
|
+
align-items: center;
|
|
920
|
+
justify-content: center;
|
|
921
|
+
}
|
|
922
|
+
.alarm-panel {
|
|
923
|
+
background: var(--color-bg);
|
|
924
|
+
color: var(--color-text);
|
|
925
|
+
border: 1px solid var(--color-border);
|
|
926
|
+
border-radius: 8px;
|
|
927
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
|
928
|
+
min-width: 380px;
|
|
929
|
+
max-width: 560px;
|
|
930
|
+
width: 90vw;
|
|
931
|
+
display: flex;
|
|
932
|
+
flex-direction: column;
|
|
933
|
+
max-height: 80vh;
|
|
934
|
+
}
|
|
935
|
+
.alarm-head {
|
|
936
|
+
display: flex;
|
|
937
|
+
align-items: center;
|
|
938
|
+
gap: var(--gap-sm);
|
|
939
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
940
|
+
border-bottom: 1px solid var(--color-border);
|
|
941
|
+
font-weight: 600;
|
|
942
|
+
}
|
|
943
|
+
.alarm-icon { font-size: 1.4em; }
|
|
944
|
+
.alarm-title { flex: 1; }
|
|
945
|
+
.alarm-close {
|
|
946
|
+
border: 0;
|
|
947
|
+
background: transparent;
|
|
948
|
+
color: var(--color-text-muted);
|
|
949
|
+
cursor: pointer;
|
|
950
|
+
font-size: 1.4em;
|
|
951
|
+
line-height: 1;
|
|
952
|
+
padding: 0 0.4em;
|
|
953
|
+
}
|
|
954
|
+
.alarm-close:hover { color: var(--color-text); }
|
|
955
|
+
.alarm-list {
|
|
956
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
957
|
+
overflow-y: auto;
|
|
958
|
+
display: flex;
|
|
959
|
+
flex-direction: column;
|
|
960
|
+
gap: var(--gap-xs);
|
|
961
|
+
}
|
|
962
|
+
.alarm-row {
|
|
963
|
+
display: flex;
|
|
964
|
+
align-items: center;
|
|
965
|
+
gap: var(--gap-sm);
|
|
966
|
+
padding: var(--gap-xs) 0;
|
|
967
|
+
border-bottom: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
|
|
968
|
+
}
|
|
969
|
+
.alarm-row:last-child { border-bottom: none; }
|
|
970
|
+
.alarm-row-main {
|
|
971
|
+
flex: 1;
|
|
972
|
+
display: flex;
|
|
973
|
+
align-items: center;
|
|
974
|
+
gap: var(--gap-xs);
|
|
975
|
+
min-width: 0;
|
|
976
|
+
}
|
|
977
|
+
.alarm-row-kind { font-size: 1.1em; }
|
|
978
|
+
.alarm-row-title {
|
|
979
|
+
font-weight: 500;
|
|
980
|
+
overflow: hidden;
|
|
981
|
+
text-overflow: ellipsis;
|
|
982
|
+
white-space: nowrap;
|
|
983
|
+
flex: 1;
|
|
984
|
+
}
|
|
985
|
+
.alarm-row-when {
|
|
986
|
+
color: var(--color-text-muted);
|
|
987
|
+
font-size: var(--font-size-sm);
|
|
988
|
+
font-variant-numeric: tabular-nums;
|
|
989
|
+
white-space: nowrap;
|
|
990
|
+
}
|
|
991
|
+
.alarm-row-actions { display: flex; gap: var(--gap-xs); }
|
|
992
|
+
.alarm-row-link,
|
|
993
|
+
.alarm-row-dismiss {
|
|
994
|
+
border: 0;
|
|
995
|
+
background: transparent;
|
|
996
|
+
color: var(--color-text-muted);
|
|
997
|
+
cursor: pointer;
|
|
998
|
+
font-size: 1em;
|
|
999
|
+
padding: 0 0.3em;
|
|
1000
|
+
border-radius: 3px;
|
|
1001
|
+
}
|
|
1002
|
+
.alarm-row-link:hover,
|
|
1003
|
+
.alarm-row-dismiss:hover {
|
|
1004
|
+
background: var(--color-bg-hover);
|
|
1005
|
+
color: var(--color-text);
|
|
1006
|
+
}
|
|
1007
|
+
.alarm-foot {
|
|
1008
|
+
display: flex;
|
|
1009
|
+
align-items: center;
|
|
1010
|
+
gap: var(--gap-sm);
|
|
1011
|
+
padding: var(--gap-sm) var(--gap-md);
|
|
1012
|
+
border-top: 1px solid var(--color-border);
|
|
1013
|
+
background: var(--color-bg-toolbar);
|
|
1014
|
+
border-radius: 0 0 8px 8px;
|
|
1015
|
+
}
|
|
1016
|
+
.alarm-snooze-label {
|
|
1017
|
+
font-size: var(--font-size-sm);
|
|
1018
|
+
color: var(--color-text-muted);
|
|
1019
|
+
display: flex;
|
|
1020
|
+
align-items: center;
|
|
1021
|
+
gap: var(--gap-xs);
|
|
1022
|
+
}
|
|
1023
|
+
.alarm-snooze-sel {
|
|
1024
|
+
background: var(--color-bg);
|
|
1025
|
+
color: var(--color-text);
|
|
1026
|
+
border: 1px solid var(--color-border);
|
|
1027
|
+
border-radius: 4px;
|
|
1028
|
+
padding: 0.25em 0.5em;
|
|
1029
|
+
font-size: var(--font-size-sm);
|
|
1030
|
+
}
|
|
1031
|
+
.alarm-btn {
|
|
1032
|
+
border: 1px solid var(--color-border);
|
|
1033
|
+
background: var(--color-bg);
|
|
1034
|
+
color: var(--color-text);
|
|
1035
|
+
cursor: pointer;
|
|
1036
|
+
padding: 0.4em 1em;
|
|
1037
|
+
border-radius: 4px;
|
|
1038
|
+
font-size: var(--font-size-sm);
|
|
1039
|
+
}
|
|
1040
|
+
.alarm-btn:hover { background: var(--color-bg-hover); }
|
|
1041
|
+
.alarm-btn-primary {
|
|
1042
|
+
background: var(--color-brand, oklch(0.65 0.14 250));
|
|
1043
|
+
color: #fff;
|
|
1044
|
+
border-color: var(--color-brand, oklch(0.65 0.14 250));
|
|
1045
|
+
}
|
|
1046
|
+
.alarm-btn-primary:hover { filter: brightness(1.1); }
|
|
1047
|
+
|
|
1048
|
+
/* Bulk-actions bar — appears over the list header when multi-select mode is
|
|
1049
|
+
active. Shows "N selected" + Mark-read / Flag / Move / Spam / Delete
|
|
1050
|
+
buttons + Cancel. Kept in sibling position to ml-header so it uses the
|
|
1051
|
+
same horizontal space. */
|
|
1052
|
+
.ml-bulkbar {
|
|
1053
|
+
display: flex;
|
|
1054
|
+
align-items: center;
|
|
1055
|
+
gap: var(--gap-sm);
|
|
1056
|
+
padding: var(--gap-xs) var(--gap-sm);
|
|
1057
|
+
background: var(--color-brand, oklch(0.65 0.14 250));
|
|
1058
|
+
color: #fff;
|
|
1059
|
+
font-size: var(--font-size-sm);
|
|
1060
|
+
border-bottom: 1px solid var(--color-border);
|
|
1061
|
+
grid-column: 1 / -1;
|
|
1062
|
+
}
|
|
1063
|
+
.ml-bulk-count { font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
1064
|
+
.ml-bulk-cancel,
|
|
1065
|
+
.ml-bulk-btn {
|
|
1066
|
+
border: 0;
|
|
1067
|
+
background: transparent;
|
|
1068
|
+
color: inherit;
|
|
1069
|
+
cursor: pointer;
|
|
1070
|
+
padding: 0.25em 0.55em;
|
|
1071
|
+
border-radius: 4px;
|
|
1072
|
+
font-size: 1rem;
|
|
1073
|
+
}
|
|
1074
|
+
.ml-bulk-cancel:hover,
|
|
1075
|
+
.ml-bulk-btn:hover {
|
|
1076
|
+
background: oklch(1 0 0 / 0.15);
|
|
1077
|
+
}
|
|
1078
|
+
.ml-bulk-btn.ml-bulk-danger:hover {
|
|
1079
|
+
background: oklch(0.65 0.22 25 / 0.4);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
|
|
1083
|
+
desktop). Add a left-edge accent bar so it's visually clear the list is
|
|
1084
|
+
in selection mode, not navigation mode. */
|
|
1085
|
+
|
|
1086
|
+
/* Multi-select mode (entered by long-press on touch or Ctrl/Shift click on
|
|
1087
|
+
desktop). Add a left-edge accent bar so it's visually clear the list is
|
|
1088
|
+
in selection mode, not navigation mode. */
|
|
1089
|
+
#ml-body.multi-select-on .ml-row::before {
|
|
1090
|
+
content: "";
|
|
1091
|
+
position: absolute;
|
|
1092
|
+
left: 0;
|
|
1093
|
+
top: 0;
|
|
1094
|
+
bottom: 0;
|
|
1095
|
+
width: 3px;
|
|
1096
|
+
background: var(--color-brand, oklch(0.65 0.14 250));
|
|
1097
|
+
opacity: 0.25;
|
|
1098
|
+
}
|
|
1099
|
+
#ml-body.multi-select-on .ml-row.selected::before {
|
|
1100
|
+
opacity: 1;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
903
1103
|
/* Info banner inside a modal — used by the Add-contact dialog to surface
|
|
904
1104
|
"already in address book" duplicate notices. Amber tone so it reads as
|
|
905
1105
|
"heads up" not "error". */
|