@bobfrankston/mailx 1.0.395 → 1.0.405

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.
@@ -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
- /** 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. */
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,21 @@ 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
+ /** Deterministic sender-avatar color from a seed string (typically the
104
+ * email address). Hash → hue at 12 evenly-spaced positions on the wheel.
105
+ * Saturation + lightness fixed so all colors carry the same visual weight
106
+ * regardless of hue, and so light/dark themes don't have to override. */
107
+ function senderColor(seed) {
108
+ let h = 0;
109
+ for (let i = 0; i < seed.length; i++)
110
+ h = (h * 31 + seed.charCodeAt(i)) | 0;
111
+ const hue = ((Math.abs(h) % 12) * 30) + 15; // 15, 45, 75, …, 345
112
+ return `oklch(0.62 0.14 ${hue})`;
67
113
  }
68
114
  /** Exit multi-select mode (entered via touch long-press). Clears selection
69
115
  * and the sticky body flag so subsequent taps open messages again. */
@@ -73,6 +119,28 @@ function exitMultiSelect() {
73
119
  return;
74
120
  body.classList.remove("multi-select-on");
75
121
  clearSelection();
122
+ updateBulkBar();
123
+ }
124
+ /** Refresh the bulk-actions bar visibility + "N selected" label. Called
125
+ * whenever selection or mode changes. Visible either when 2+ rows are
126
+ * selected (desktop Ctrl/Shift-click multi-selection) OR when touch
127
+ * multi-select mode is active (even with a single row, so the user sees
128
+ * the bar as the mode indicator). */
129
+ function updateBulkBar() {
130
+ const body = document.getElementById("ml-body");
131
+ const bar = document.getElementById("ml-bulkbar");
132
+ const count = document.getElementById("ml-bulk-count");
133
+ if (!body || !bar || !count)
134
+ return;
135
+ const active = body.classList.contains("multi-select-on");
136
+ const n = body.querySelectorAll(".ml-row.selected").length;
137
+ if (n >= 2 || (active && n > 0)) {
138
+ bar.hidden = false;
139
+ count.textContent = `${n} selected`;
140
+ }
141
+ else {
142
+ bar.hidden = true;
143
+ }
76
144
  }
77
145
  // Escape key + click-outside-list exit multi-select mode. Attached once
78
146
  // (idempotent because document only has one listener scope per handler).
@@ -88,10 +156,89 @@ if (!window.__mailxMultiSelectWired) {
88
156
  return;
89
157
  const target = e.target;
90
158
  // 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"))
159
+ // exit when the tap is on neutral ground (outside the list entirely
160
+ // and not on the bulk bar).
161
+ if (!target.closest(".ml-row") && !target.closest(".ml-bulkbar"))
93
162
  exitMultiSelect();
94
163
  }, true);
164
+ // Wire bulk-bar buttons — each delegates to the single-message handlers
165
+ // but iterates over every .ml-row.selected in the body.
166
+ document.addEventListener("click", async (e) => {
167
+ const target = e.target;
168
+ if (target.closest("#ml-bulk-cancel")) {
169
+ exitMultiSelect();
170
+ return;
171
+ }
172
+ const btn = target.closest(".ml-bulk-btn");
173
+ if (!btn)
174
+ return;
175
+ const op = btn.dataset.bulk;
176
+ const body = document.getElementById("ml-body");
177
+ if (!body?.classList.contains("multi-select-on"))
178
+ return;
179
+ const selected = getSelectedMessages();
180
+ if (selected.length === 0)
181
+ return;
182
+ try {
183
+ if (op === "delete") {
184
+ // Delegate to the existing Del-key handler so the same
185
+ // tombstone/undo logic runs for bulk deletes.
186
+ document.dispatchEvent(new CustomEvent("mailx-delete"));
187
+ }
188
+ else if (op === "flag") {
189
+ for (const m of selected) {
190
+ const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
191
+ if (!row)
192
+ continue;
193
+ const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
194
+ if (!msgData)
195
+ continue;
196
+ const isFlagged = msgData.flags?.includes("\\Flagged");
197
+ const newFlags = isFlagged
198
+ ? msgData.flags.filter((f) => f !== "\\Flagged")
199
+ : [...(msgData.flags || []), "\\Flagged"];
200
+ await updateFlags(m.accountId, m.uid, newFlags);
201
+ msgData.flags = newFlags;
202
+ state.updateMessageFlags(m.accountId, m.uid, newFlags);
203
+ row.classList.toggle("flagged");
204
+ }
205
+ }
206
+ else if (op === "markread") {
207
+ for (const m of selected) {
208
+ const row = body.querySelector(`.ml-row[data-account-id="${m.accountId}"][data-uid="${m.uid}"]`);
209
+ if (!row)
210
+ continue;
211
+ const msgData = state.getMessages().find((x) => x.uid === m.uid && (x.accountId || "") === m.accountId);
212
+ if (!msgData)
213
+ continue;
214
+ if (msgData.flags?.includes("\\Seen"))
215
+ continue;
216
+ const newFlags = [...(msgData.flags || []), "\\Seen"];
217
+ await updateFlags(m.accountId, m.uid, newFlags);
218
+ msgData.flags = newFlags;
219
+ state.updateMessageFlags(m.accountId, m.uid, newFlags);
220
+ row.classList.remove("unread");
221
+ }
222
+ }
223
+ else if (op === "move") {
224
+ // Use the first selection's account/folder for the picker scope.
225
+ const { accountId, folderId } = selected[0];
226
+ const pick = await pickFolder(accountId, { excludeFolderIds: [folderId] });
227
+ if (!pick)
228
+ return;
229
+ const uids = selected.map(s => s.uid);
230
+ await apiMoveMessages(accountId, uids, pick.folderId);
231
+ state.removeMessages(uids.map(u => ({ accountId, uid: u })));
232
+ }
233
+ else if (op === "spam") {
234
+ document.getElementById("btn-spam")?.click();
235
+ }
236
+ exitMultiSelect();
237
+ }
238
+ catch (err) {
239
+ alert(`Bulk ${op} failed: ${err.message || err}`);
240
+ }
241
+ });
95
242
  }
96
243
  function selectRange(from, to) {
97
244
  const body = document.getElementById("ml-body");
@@ -380,6 +527,10 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
380
527
  export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
381
528
  searchMode = false;
382
529
  unifiedMode = false;
530
+ // Folder switch clears any in-progress multi-select — carrying a "3
531
+ // selected" state across folders would lie about what rows the bulk
532
+ // buttons would act on.
533
+ exitMultiSelect();
383
534
  // specialUse is either the DB tag ("sent"/"drafts"/"outbox") or the
384
535
  // folder path lowercased (folder-tree fallback when tag is missing — common
385
536
  // on Dovecot which doesn't advertise \Sent). Match both cases.
@@ -583,6 +734,41 @@ function appendMessages(body, accountId, items) {
583
734
  row.dataset.folderId = String(msg.folderId);
584
735
  if (msg.threadId)
585
736
  row.dataset.threadId = msg.threadId;
737
+ // Sender avatar \u2014 Thunderbird-style colored circle with the first
738
+ // initial of the sender's display name. Doubles as the multi-select
739
+ // affordance: in `multi-select-on` mode, the avatar swaps to a
740
+ // checkmark via CSS. Color is derived deterministically from the
741
+ // address so the same sender keeps the same color across rows.
742
+ const fromName = (showToInsteadOfFrom && msg.to?.length)
743
+ ? (msg.to[0].name || msg.to[0].address || "?")
744
+ : (msg.from?.name || msg.from?.address || "?");
745
+ const seedAddr = (msg.from?.address || msg.from?.name || "?").toLowerCase();
746
+ const initial = (fromName.replace(/^[\W_]+/, "") || "?").charAt(0).toUpperCase();
747
+ const avatar = document.createElement("span");
748
+ avatar.className = "ml-avatar";
749
+ avatar.textContent = initial;
750
+ avatar.style.background = senderColor(seedAddr);
751
+ avatar.title = msg.from?.address || "";
752
+ // Tapping the avatar enters multi-select mode (or toggles in it,
753
+ // mirroring Thunderbird/Gmail). Click bubbles to the row otherwise,
754
+ // which would open the message — stopPropagation here keeps the
755
+ // avatar a dedicated selection affordance.
756
+ avatar.addEventListener("click", (e) => {
757
+ e.stopPropagation();
758
+ const body = document.getElementById("ml-body");
759
+ if (!body)
760
+ return;
761
+ if (body.classList.contains("multi-select-on")) {
762
+ row.classList.toggle("selected");
763
+ }
764
+ else {
765
+ clearSelection();
766
+ row.classList.add("selected");
767
+ body.classList.add("multi-select-on");
768
+ }
769
+ lastClickedRow = row;
770
+ updateBulkBar();
771
+ });
586
772
  const flag = document.createElement("span");
587
773
  flag.className = "ml-flag";
588
774
  flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
@@ -611,6 +797,15 @@ function appendMessages(body, accountId, items) {
611
797
  folderTag.title = `In folder: ${msg.folderName}`;
612
798
  from.prepend(folderTag);
613
799
  }
800
+ // Unified inbox: same Message-ID exists under >=2 accounts → ⇆ badge.
801
+ // Tooltip names the count so the user knows "this appears on N".
802
+ if (msg.dupeCount >= 2) {
803
+ const dupe = document.createElement("span");
804
+ dupe.className = "ml-dupe-tag";
805
+ dupe.textContent = "⇆";
806
+ dupe.title = `Same message on ${msg.dupeCount} accounts`;
807
+ from.prepend(dupe);
808
+ }
614
809
  const subject = document.createElement("span");
615
810
  subject.className = "ml-subject";
616
811
  subject.innerHTML = escapeHtml(msg.subject);
@@ -655,6 +850,7 @@ function appendMessages(body, accountId, items) {
655
850
  }
656
851
  catch { /* ignore */ }
657
852
  });
853
+ row.appendChild(avatar);
658
854
  row.appendChild(flag);
659
855
  row.appendChild(from);
660
856
  row.appendChild(date);
@@ -671,22 +867,28 @@ function appendMessages(body, accountId, items) {
671
867
  if (body?.classList.contains("multi-select-on")) {
672
868
  row.classList.toggle("selected");
673
869
  lastClickedRow = row;
870
+ updateBulkBar();
674
871
  return;
675
872
  }
676
873
  if (e.shiftKey && lastClickedRow) {
677
874
  clearSelection();
678
875
  selectRange(lastClickedRow, row);
876
+ lastClickedRow = row;
877
+ row.classList.remove("unread");
878
+ focusMessage(msgAccountId, msg);
679
879
  }
680
880
  else if (e.ctrlKey || e.metaKey) {
681
881
  row.classList.toggle("selected");
882
+ lastClickedRow = row;
682
883
  }
683
884
  else {
885
+ // Atomic unfocus-previous + focus-this.
684
886
  clearSelection();
685
- row.classList.add("selected");
887
+ focusRow(row, msgAccountId, msg);
888
+ lastClickedRow = row;
889
+ row.classList.remove("unread");
686
890
  }
687
- lastClickedRow = row;
688
- row.classList.remove("unread");
689
- focusMessage(msgAccountId, msg);
891
+ updateBulkBar();
690
892
  });
691
893
  // Q64: double-click → pop out the message in a floating overlay so
692
894
  // the user can read it without losing the selected list context.
@@ -756,6 +958,7 @@ function appendMessages(body, accountId, items) {
756
958
  body?.classList.add("multi-select-on");
757
959
  }
758
960
  lastClickedRow = row;
961
+ updateBulkBar();
759
962
  // Haptic hint if the platform supports it (Android WebView does).
760
963
  try {
761
964
  navigator.vibrate?.(20);
@@ -859,10 +1062,6 @@ function appendMessages(body, accountId, items) {
859
1062
  label: "⚠ Mark as spam",
860
1063
  action: () => document.getElementById("btn-spam")?.click(),
861
1064
  },
862
- {
863
- label: "🚫 Report spam",
864
- action: () => document.getElementById("btn-spam-report")?.click(),
865
- },
866
1065
  { label: "", action: () => { }, separator: true },
867
1066
  {
868
1067
  label: "Copy Message-ID",
@@ -191,6 +191,17 @@ function installPreviewControls(iframe) {
191
191
  const pct = Math.round(previewZoom * 100);
192
192
  const sel = doc.defaultView?.getSelection();
193
193
  const selectedText = sel?.toString().trim() || "";
194
+ const runSearch = (query) => {
195
+ const input = document.getElementById("search-input");
196
+ if (!input)
197
+ return;
198
+ input.value = query;
199
+ // Trigger the existing search path — Enter keydown hits the
200
+ // immediate branch in app.ts's handler.
201
+ input.dispatchEvent(new Event("input"));
202
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
203
+ input.focus();
204
+ };
194
205
  const items = [
195
206
  { label: "Copy", action: () => doc.execCommand("copy") },
196
207
  { label: "Select all", action: () => {
@@ -202,14 +213,35 @@ function installPreviewControls(iframe) {
202
213
  s.removeAllRanges();
203
214
  s.addRange(range);
204
215
  } },
205
- { label: "", action: () => { }, separator: true },
206
- { label: selectedText ? "Translate selection" : "Translate message",
207
- action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) },
208
- { label: "", action: () => { }, separator: true },
209
- { label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) },
210
- { label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) },
211
- { label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) },
212
216
  ];
217
+ if (selectedText) {
218
+ items.push({ label: "", action: () => { }, separator: true },
219
+ // Truncate long selections in the label so the menu doesn't
220
+ // blow out; full string is what we search for.
221
+ { label: `Search messages for "${selectedText.length > 40 ? selectedText.slice(0, 40) + "…" : selectedText}"`,
222
+ action: () => runSearch(selectedText) }, {
223
+ label: "Copy as quoted (> prefix)",
224
+ action: async () => {
225
+ // Prefix each line with "> " (RFC 3676 reply-quote).
226
+ // Useful when pasting a snippet into a compose window
227
+ // without the usual full-message blockquote wrapping.
228
+ const quoted = selectedText.split(/\r?\n/).map(l => "> " + l).join("\n");
229
+ try {
230
+ await navigator.clipboard.writeText(quoted);
231
+ }
232
+ catch { /* */ }
233
+ },
234
+ });
235
+ }
236
+ const senderAddr = currentMessage?.from?.address || "";
237
+ if (senderAddr) {
238
+ items.push({
239
+ label: `Search messages from ${senderAddr}`,
240
+ action: () => runSearch(`from:${senderAddr}`),
241
+ });
242
+ }
243
+ items.push({ label: "", action: () => { }, separator: true }, { label: selectedText ? "Translate selection" : "Translate message",
244
+ action: () => translateAndShow(selectedText || (doc.body?.innerText || "")) }, { label: "", action: () => { }, separator: true }, { label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) }, { label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) }, { label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) });
213
245
  showContextMenu(x, y, items);
214
246
  });
215
247
  };
@@ -239,8 +271,24 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
239
271
  const headerEl = document.getElementById("mv-header");
240
272
  const bodyEl = document.getElementById("mv-body");
241
273
  const attEl = document.getElementById("mv-attachments");
242
- bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
243
- headerEl.hidden = true;
274
+ // Envelope-first render: the row the user just clicked already has the
275
+ // subject / from / to / cc / date / preview in the message-state. Use
276
+ // that to populate the header + a snippet placeholder IMMEDIATELY so
277
+ // tapping a message never shows just "Fetching message body..." with
278
+ // nothing actionable. The full getMessage() call (which might block on
279
+ // a slow IMAP body fetch) only fills in the body and attachments.
280
+ const cached = state.getSelected();
281
+ if (cached && cached.uid === uid && (cached.accountId || accountId) === accountId) {
282
+ try {
283
+ renderHeaderFromEnvelope(headerEl, cached);
284
+ }
285
+ catch { /* */ }
286
+ bodyEl.innerHTML = `<div class="mv-empty">Fetching message body…<br><br><span style="color:var(--color-text-muted);font-size:0.9em">${escapeHtml(cached.preview || "")}</span></div>`;
287
+ }
288
+ else {
289
+ bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
290
+ headerEl.hidden = true;
291
+ }
244
292
  attEl.hidden = true;
245
293
  try {
246
294
  const msg = await getMessage(accountId, uid, false, folderId);
@@ -697,6 +745,29 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
697
745
  console.error(`Attachment download failed: ${err.message}`);
698
746
  }
699
747
  });
748
+ // Drag the chip to an external target (Explorer / Finder / Files app)
749
+ // to drop the file there. Uses the Chromium `DownloadURL` dataTransfer
750
+ // format: "mime:filename:blob-url". We fetch the attachment first so
751
+ // the blob URL is valid by the time the drop lands.
752
+ chip.draggable = true;
753
+ chip.addEventListener("dragstart", async (e) => {
754
+ if (!e.dataTransfer)
755
+ return;
756
+ try {
757
+ const data = await getAttachment(accountId, uid, i, msg.folderId);
758
+ const bytes = Uint8Array.from(atob(data.content), c => c.charCodeAt(0));
759
+ const blob = new Blob([bytes], { type: data.contentType || "application/octet-stream" });
760
+ const url = URL.createObjectURL(blob);
761
+ // Sanitize filename: no path separators, no newlines.
762
+ const safeName = (att.filename || "attachment").replace(/[\r\n"\/\\]/g, "_");
763
+ const downloadUrl = `${data.contentType || "application/octet-stream"}:${safeName}:${url}`;
764
+ e.dataTransfer.setData("DownloadURL", downloadUrl);
765
+ e.dataTransfer.effectAllowed = "copy";
766
+ }
767
+ catch (err) {
768
+ console.error(`Attachment drag-out failed: ${err.message || err}`);
769
+ }
770
+ });
700
771
  attEl.appendChild(chip);
701
772
  }
702
773
  }
@@ -731,6 +802,40 @@ function formatAddr(addr) {
731
802
  return `${addr.name} <${addr.address}>`;
732
803
  return addr.address;
733
804
  }
805
+ /** Render the viewer header from a list-row envelope (instant — no body
806
+ * fetch awaited). Used to populate the header pane the moment a message
807
+ * is clicked so the user always sees something actionable; getMessage()
808
+ * later overwrites the same fields with the authoritative values from the
809
+ * body parse (which can add Cc, Delivered-To, etc. that the list row
810
+ * doesn't track). */
811
+ function renderHeaderFromEnvelope(headerEl, env) {
812
+ headerEl.hidden = false;
813
+ const fromEl = headerEl.querySelector(".mv-from");
814
+ const toEl = headerEl.querySelector(".mv-to");
815
+ const subjEl = headerEl.querySelector(".mv-subject");
816
+ const dateEl = headerEl.querySelector(".mv-date");
817
+ if (fromEl)
818
+ fromEl.textContent = formatAddr(env.from);
819
+ if (toEl) {
820
+ let toLine = `To: ${(env.to || []).map(formatAddr).join(", ")}`;
821
+ if (env.cc?.length)
822
+ toLine += ` Cc: ${env.cc.map(formatAddr).join(", ")}`;
823
+ toEl.textContent = toLine;
824
+ }
825
+ if (subjEl)
826
+ subjEl.textContent = env.subject || "";
827
+ if (dateEl) {
828
+ try {
829
+ dateEl.textContent = new Date(env.date).toLocaleString();
830
+ }
831
+ catch {
832
+ dateEl.textContent = "";
833
+ }
834
+ }
835
+ }
836
+ function escapeHtml(s) {
837
+ return (s || "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
838
+ }
734
839
  /** Convert plain text URLs into clickable links, escaping HTML */
735
840
  function linkifyText(text) {
736
841
  // Escape HTML first
@@ -922,15 +1027,12 @@ ${csp}
922
1027
  document.addEventListener("touchcancel", function () {
923
1028
  lastTouchTarget = null; touchMoved = false;
924
1029
  }, { passive: true, capture: true });
925
- document.addEventListener("mouseover", function (e) {
926
- var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
927
- if (a) {
928
- var r = a.getBoundingClientRect();
929
- window.parent.postMessage({ type: "linkHover", url: a.href, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } }, "*");
930
- } else {
931
- window.parent.postMessage({ type: "linkHover", url: "" }, "*");
932
- }
933
- });
1030
+ // Link hover popover removed 2026-04-24 — user feedback: it persisted
1031
+ // over the message body when the dismissers (mousedown/scroll/blur)
1032
+ // didn't fire from inside the iframe, leaving a multi-line URL hanging
1033
+ // in the middle of the reading pane. Right-click (desktop) and long-
1034
+ // press (touch) on a link still open the existing C29 menu with Open /
1035
+ // Save-as / Copy URL / Copy link-text.
934
1036
  // C29: right-click on a link → ask parent for the Open/Save/Copy menu.
935
1037
  document.addEventListener("contextmenu", function (e) {
936
1038
  var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;