@bobfrankston/mailx 1.0.228 → 1.0.229

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.
@@ -2,7 +2,7 @@
2
2
  * Message list component — renders paginated message rows.
3
3
  * Reads from message-state; operations mutate state, list reacts.
4
4
  */
5
- import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags } from "../lib/api-client.js";
5
+ import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags, getThreadMessages } from "../lib/api-client.js";
6
6
  import * as state from "../lib/message-state.js";
7
7
  import { showContextMenu } from "./context-menu.js";
8
8
  let onMessageSelect;
@@ -301,6 +301,61 @@ function restoreSelection(body, savedUid) {
301
301
  row.classList.add("selected");
302
302
  }
303
303
  }
304
+ /** Show a floating list of all messages in a thread when the pill is clicked.
305
+ * Each entry in the popup selects that message in the viewer when clicked.
306
+ * This is simpler than inline expansion and avoids duplicating the row builder. */
307
+ async function showThreadPopup(pillEl, headMsg) {
308
+ // Remove any existing popup
309
+ document.querySelectorAll(".ml-thread-popup").forEach(el => el.remove());
310
+ let thread = [];
311
+ try {
312
+ thread = await getThreadMessages(headMsg.accountId, headMsg.threadId);
313
+ }
314
+ catch { /* ignore */ }
315
+ if (!thread || thread.length === 0)
316
+ return;
317
+ thread.sort((a, b) => (a.date || 0) - (b.date || 0));
318
+ const popup = document.createElement("div");
319
+ popup.className = "ml-thread-popup";
320
+ for (const msg of thread) {
321
+ const item = document.createElement("div");
322
+ item.className = "ml-thread-popup-item";
323
+ if (!msg.flags.includes("\\Seen"))
324
+ item.classList.add("unread");
325
+ const from = document.createElement("span");
326
+ from.className = "ml-thread-popup-from";
327
+ from.textContent = msg.from?.name || msg.from?.address || "?";
328
+ const date = document.createElement("span");
329
+ date.className = "ml-thread-popup-date";
330
+ date.textContent = formatDate(msg.date);
331
+ const subject = document.createElement("span");
332
+ subject.className = "ml-thread-popup-subject";
333
+ subject.textContent = msg.subject || "(no subject)";
334
+ item.appendChild(from);
335
+ item.appendChild(date);
336
+ item.appendChild(subject);
337
+ item.addEventListener("click", async () => {
338
+ state.select({ 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 });
339
+ onMessageSelect(msg.accountId, msg.uid, msg.folderId);
340
+ popup.remove();
341
+ });
342
+ popup.appendChild(item);
343
+ }
344
+ document.body.appendChild(popup);
345
+ const rect = pillEl.getBoundingClientRect();
346
+ popup.style.left = `${rect.left}px`;
347
+ popup.style.top = `${rect.bottom + 4}px`;
348
+ // Dismiss on outside click
349
+ setTimeout(() => {
350
+ const dismiss = (e) => {
351
+ if (!popup.contains(e.target)) {
352
+ popup.remove();
353
+ document.removeEventListener("mousedown", dismiss, true);
354
+ }
355
+ };
356
+ document.addEventListener("mousedown", dismiss, true);
357
+ }, 0);
358
+ }
304
359
  function appendMessages(body, accountId, items) {
305
360
  // Thread grouping: when the list has the "threaded" class, collapse messages
306
361
  // sharing the same threadId to a single row showing the most recent message,
@@ -373,15 +428,20 @@ function appendMessages(body, accountId, items) {
373
428
  const subject = document.createElement("span");
374
429
  subject.className = "ml-subject";
375
430
  subject.innerHTML = escapeHtml(msg.subject);
376
- // Thread size pill: e.g. "(3)" next to the subject when this row
377
- // represents a collapsed thread with multiple messages.
431
+ // Thread size pill: click to show a popup list of the thread's messages.
378
432
  if (threadSize) {
379
433
  const n = threadSize.get(msg) || 1;
380
- if (n > 1) {
434
+ if (n > 1 && msg.threadId) {
435
+ row.classList.add("thread-head");
436
+ row.dataset.threadId = msg.threadId;
381
437
  const threadPill = document.createElement("span");
382
438
  threadPill.className = "ml-thread-pill";
383
439
  threadPill.textContent = String(n);
384
- threadPill.title = `${n} messages in this thread`;
440
+ threadPill.title = `${n} messages in this thread — click to see list`;
441
+ threadPill.addEventListener("click", async (e) => {
442
+ e.stopPropagation();
443
+ await showThreadPopup(threadPill, msg);
444
+ });
385
445
  subject.prepend(threadPill);
386
446
  }
387
447
  }
@@ -149,6 +149,9 @@ export function deleteDraft(accountId, draftUid, draftId) {
149
149
  export function addContact(name, email) {
150
150
  return ipc().addContact?.(name, email);
151
151
  }
152
+ export function getThreadMessages(accountId, threadId) {
153
+ return ipc().getThreadMessages?.(accountId, threadId);
154
+ }
152
155
  export function readJsoncFile(name) {
153
156
  return ipc().readJsoncFile?.(name);
154
157
  }
@@ -100,6 +100,9 @@
100
100
  addContact: function(name, email) {
101
101
  return callNode("addContact", { name: name, email: email });
102
102
  },
103
+ getThreadMessages: function(accountId, threadId) {
104
+ return callNode("getThreadMessages", { accountId: accountId, threadId: threadId });
105
+ },
103
106
  readJsoncFile: function(name) {
104
107
  return callNode("readJsoncFile", { name: name });
105
108
  },
@@ -411,6 +411,58 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
411
411
  vertical-align: baseline;
412
412
  min-width: 1.5em;
413
413
  text-align: center;
414
+ cursor: pointer;
415
+ }
416
+ .ml-thread-pill:hover { filter: brightness(1.15); }
417
+
418
+ /* Popup that lists the messages in a thread, anchored below the clicked pill. */
419
+ .ml-thread-popup {
420
+ position: fixed;
421
+ min-width: 320px;
422
+ max-width: 600px;
423
+ max-height: 60vh;
424
+ overflow-y: auto;
425
+ background: var(--color-bg);
426
+ color: var(--color-text);
427
+ border: 1px solid var(--color-border);
428
+ border-radius: var(--radius-md);
429
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
430
+ padding: var(--gap-xs);
431
+ z-index: 1500;
432
+ font-family: var(--font-ui);
433
+ font-size: var(--font-size-sm);
434
+ }
435
+ .ml-thread-popup-item {
436
+ display: grid;
437
+ grid-template-columns: minmax(0, 1fr) auto;
438
+ gap: 4px var(--gap-sm);
439
+ padding: 6px 10px;
440
+ border-radius: var(--radius-sm);
441
+ cursor: pointer;
442
+ }
443
+ .ml-thread-popup-item:hover { background: var(--color-bg-hover); }
444
+ .ml-thread-popup-item.unread .ml-thread-popup-from,
445
+ .ml-thread-popup-item.unread .ml-thread-popup-subject { font-weight: 600; }
446
+ .ml-thread-popup-from {
447
+ grid-column: 1;
448
+ grid-row: 1;
449
+ overflow: hidden;
450
+ text-overflow: ellipsis;
451
+ white-space: nowrap;
452
+ }
453
+ .ml-thread-popup-date {
454
+ grid-column: 2;
455
+ grid-row: 1;
456
+ color: var(--color-text-muted);
457
+ font-variant-numeric: tabular-nums;
458
+ }
459
+ .ml-thread-popup-subject {
460
+ grid-column: 1 / -1;
461
+ grid-row: 2;
462
+ color: var(--color-text-muted);
463
+ overflow: hidden;
464
+ text-overflow: ellipsis;
465
+ white-space: nowrap;
414
466
  }
415
467
 
416
468
  /* Generic modal — used by the JSONC config editor launched from Settings */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.228",
3
+ "version": "1.0.229",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.2",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.22",
27
- "@bobfrankston/msger": "^0.1.290",
27
+ "@bobfrankston/msger": "^0.1.291",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -78,7 +78,7 @@
78
78
  "@bobfrankston/iflow-node": "^0.1.2",
79
79
  "@bobfrankston/miscinfo": "^1.0.8",
80
80
  "@bobfrankston/oauthsupport": "^1.0.22",
81
- "@bobfrankston/msger": "^0.1.290",
81
+ "@bobfrankston/msger": "^0.1.291",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -59,6 +59,8 @@ export declare class MailxService {
59
59
  * action on From/To/Cc addresses in the message viewer. Just calls the same
60
60
  * validated upsert path as recordSentAddress. */
61
61
  addContact(name: string, email: string): boolean;
62
+ /** Get all messages in a thread (across folders) for an account. */
63
+ getThreadMessages(accountId: string, threadId: string): any;
62
64
  /** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
63
65
  * Names are whitelisted so the UI can't read arbitrary files. */
64
66
  readJsoncFile(name: string): Promise<string | null>;
@@ -659,6 +659,10 @@ export class MailxService {
659
659
  this.db.recordSentAddress(name || "", email);
660
660
  return true;
661
661
  }
662
+ /** Get all messages in a thread (across folders) for an account. */
663
+ getThreadMessages(accountId, threadId) {
664
+ return this.db.getThreadMessages(accountId, threadId);
665
+ }
662
666
  /** Read a JSONC config file from the shared cloud dir or local ~/.mailx.
663
667
  * Names are whitelisted so the UI can't read arbitrary files. */
664
668
  async readJsoncFile(name) {
@@ -96,6 +96,8 @@ async function dispatchAction(svc, action, p) {
96
96
  return svc.searchContacts(p.query);
97
97
  case "addContact":
98
98
  return { ok: svc.addContact(p.name, p.email) };
99
+ case "getThreadMessages":
100
+ return svc.getThreadMessages(p.accountId, p.threadId);
99
101
  case "readJsoncFile":
100
102
  return { content: await svc.readJsoncFile(p.name) };
101
103
  case "writeJsoncFile":