@bobfrankston/rmfmail 1.1.44 → 1.1.49

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.
Files changed (46) hide show
  1. package/TODO.md +9 -0
  2. package/client/app.bundle.js +403 -83
  3. package/client/app.bundle.js.map +4 -4
  4. package/client/app.js +190 -35
  5. package/client/app.js.map +1 -1
  6. package/client/app.ts +175 -34
  7. package/client/components/calendar-sidebar.js +221 -88
  8. package/client/components/calendar-sidebar.js.map +1 -1
  9. package/client/components/calendar-sidebar.ts +224 -83
  10. package/client/components/message-viewer.js +24 -1
  11. package/client/components/message-viewer.js.map +1 -1
  12. package/client/components/message-viewer.ts +25 -1
  13. package/client/compose/compose.bundle.js +14 -0
  14. package/client/compose/compose.bundle.js.map +2 -2
  15. package/client/compose/spellcheck.js +15 -0
  16. package/client/compose/spellcheck.js.map +1 -1
  17. package/client/compose/spellcheck.ts +14 -0
  18. package/client/help/search-help.js +75 -0
  19. package/client/help/search-help.js.map +1 -0
  20. package/client/help/search-help.ts +75 -0
  21. package/client/index.html +7 -7
  22. package/client/lib/api-client.js +5 -0
  23. package/client/lib/api-client.js.map +1 -1
  24. package/client/lib/api-client.ts +5 -0
  25. package/client/lib/mailxapi.js +1 -0
  26. package/client/styles/components.css +204 -6
  27. package/docs/search.md +5 -1
  28. package/package.json +1 -1
  29. package/packages/mailx-service/google-sync.d.ts +3 -0
  30. package/packages/mailx-service/google-sync.d.ts.map +1 -1
  31. package/packages/mailx-service/google-sync.js +1 -0
  32. package/packages/mailx-service/google-sync.js.map +1 -1
  33. package/packages/mailx-service/google-sync.ts +4 -0
  34. package/packages/mailx-service/index.d.ts +11 -0
  35. package/packages/mailx-service/index.d.ts.map +1 -1
  36. package/packages/mailx-service/index.js +99 -127
  37. package/packages/mailx-service/index.js.map +1 -1
  38. package/packages/mailx-service/index.ts +91 -122
  39. package/packages/mailx-service/jsonrpc.js +2 -0
  40. package/packages/mailx-service/jsonrpc.js.map +1 -1
  41. package/packages/mailx-service/jsonrpc.ts +2 -0
  42. package/packages/mailx-settings/index.d.ts.map +1 -1
  43. package/packages/mailx-settings/index.js +4 -1
  44. package/packages/mailx-settings/index.js.map +1 -1
  45. package/packages/mailx-settings/index.ts +4 -1
  46. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-45524 → node_modules.npmglobalize-stash-43988}/.package-lock.json +0 -0
@@ -16,7 +16,7 @@
16
16
 
17
17
  import {
18
18
  getPrimaryAccount,
19
- getCalendarEvents, createCalendarEvent,
19
+ getCalendarEvents, getCalendars, createCalendarEvent,
20
20
  getTasks, createTask, updateTask, deleteTask,
21
21
  reauthGoogleScopes,
22
22
  getSettings, saveSettings,
@@ -61,6 +61,140 @@ let viewYear = new Date().getFullYear();
61
61
  let viewMonth = new Date().getMonth();
62
62
  let viewDay = new Date().getDate();
63
63
  let lastEvents: CalEvent[] = [];
64
+ /** Overdue tasks (due before today, not completed) — pinned to the top of
65
+ * the calendar list. Cached so renderEvents() can be re-run on a calendar
66
+ * visibility toggle without re-fetching tasks. */
67
+ let lastOverdueTasks: Array<{ uuid: string; title: string; dueMs?: number }> = [];
68
+
69
+ // ── Per-calendar metadata ─────────────────────────────────────────────
70
+ // The user curates which calendars exist by selecting them in Google
71
+ // Calendar; mailx enumerates them via getCalendars() and renders one
72
+ // checkbox + icon per calendar. Holiday calendars are no longer hard-coded.
73
+
74
+ type CalKind = "personal" | "usHoliday" | "jewishHoliday" | "birthday" | "otherHoliday" | "other";
75
+
76
+ interface CalInfo {
77
+ id: string;
78
+ name: string;
79
+ color: string;
80
+ primary: boolean;
81
+ }
82
+
83
+ /** Selected Google calendars, by id. The primary calendar is also keyed
84
+ * under the literal "primary" so legacy/stale rows (whose `calendar_id`
85
+ * predates per-calendar tagging) still resolve. */
86
+ const calById = new Map<string, CalInfo>();
87
+ let calendarList: CalInfo[] = [];
88
+ /** Calendar IDs the user has hidden in mailx (still selected in Google —
89
+ * hidden is a display filter, not a fetch filter). Persisted in
90
+ * settings.calendar.hiddenCalendars. */
91
+ let hiddenCalendars = new Set<string>();
92
+
93
+ /** Classify a calendar by its Google id. Birthdays live on Google's
94
+ * auto `addressbook#contacts` calendar; holiday calendars carry
95
+ * `#holiday@group.v.calendar.google.com`. The personal calendar is the
96
+ * `primary` one. Everything else is a generic named calendar. */
97
+ function calendarKind(id: string, primary: boolean): CalKind {
98
+ if (primary) return "personal";
99
+ const c = (id || "").toLowerCase();
100
+ if (c.includes("addressbook#contacts")) return "birthday";
101
+ if (c.includes("#holiday@") || c.includes("holiday@group")) {
102
+ if (c.includes("usa")) return "usHoliday";
103
+ if (c.includes("judaism") || c.includes("jewish")) return "jewishHoliday";
104
+ return "otherHoliday";
105
+ }
106
+ return "other";
107
+ }
108
+
109
+ /** Icon markup for a calendar: blue dot = personal, themed emoji for
110
+ * holiday/birthday calendars, monogram badge (first letter in a circle
111
+ * tinted with the Google calendar color) for any other named calendar. */
112
+ function calIconHtml(info: CalInfo): string {
113
+ const kind = calendarKind(info.id, info.primary);
114
+ const t = escapeHtml(info.name);
115
+ if (kind === "personal") return `<span class="cal-ico cal-ico-dot" title="${t}"></span>`;
116
+ if (kind === "usHoliday") return `<span class="cal-ico cal-ico-emoji" title="${t}">🇺🇸</span>`;
117
+ if (kind === "jewishHoliday") return `<span class="cal-ico cal-ico-emoji" title="${t}">✡️</span>`;
118
+ if (kind === "birthday") return `<span class="cal-ico cal-ico-emoji" title="${t}">🎂</span>`;
119
+ if (kind === "otherHoliday") return `<span class="cal-ico cal-ico-emoji" title="${t}">✦</span>`;
120
+ const letter = escapeHtml((info.name.trim()[0] || "?").toUpperCase());
121
+ const color = info.color || "#7a7a7a";
122
+ return `<span class="cal-ico cal-ico-mono" style="background:${escapeHtml(color)}" title="${t}">${letter}</span>`;
123
+ }
124
+
125
+ /** Resolve an event's `calendarId` to a CalInfo. Falls back to a
126
+ * synthesised entry (pattern-based kind, generic monogram) when the id
127
+ * isn't in the enumerated list — e.g. a stale row, or before
128
+ * getCalendars() has resolved. */
129
+ function calInfoFor(calendarId: string | undefined): CalInfo {
130
+ const id = calendarId || "primary";
131
+ const found = calById.get(id);
132
+ if (found) return found;
133
+ return { id, name: (id.split("@")[0] || id), color: "", primary: id === "primary" };
134
+ }
135
+
136
+ function isCalHidden(calendarId: string | undefined): boolean {
137
+ return hiddenCalendars.has(calInfoFor(calendarId).id);
138
+ }
139
+
140
+ async function loadHiddenCalendars(): Promise<void> {
141
+ try {
142
+ const s: any = await getSettings();
143
+ const arr = s?.calendar?.hiddenCalendars;
144
+ hiddenCalendars = new Set(Array.isArray(arr) ? arr : []);
145
+ } catch { hiddenCalendars = new Set(); }
146
+ }
147
+
148
+ async function saveHiddenCalendars(): Promise<void> {
149
+ try {
150
+ const s: any = await getSettings();
151
+ s.calendar = { ...(s.calendar || {}), hiddenCalendars: [...hiddenCalendars] };
152
+ await saveSettings(s);
153
+ } catch (e: any) { console.error("[cal] save hiddenCalendars failed:", e); }
154
+ }
155
+
156
+ /** Pull the user's selected Google calendars and render the per-calendar
157
+ * checkbox list. Each row toggles `hiddenCalendars` (a display filter —
158
+ * instant, no Google round-trip, no re-fetch). Called on init and on
159
+ * manual refresh; cheap enough but not run on every nav click. */
160
+ async function renderCalendarList(): Promise<void> {
161
+ const host = document.getElementById("cal-side-calendars");
162
+ let list: CalInfo[] = [];
163
+ try {
164
+ list = await getCalendars() as CalInfo[];
165
+ } catch { /* offline / no calendar scope — leave the list empty */ }
166
+ calendarList = list;
167
+ calById.clear();
168
+ for (const c of list) {
169
+ calById.set(c.id, c);
170
+ if (c.primary) calById.set("primary", c);
171
+ }
172
+ if (host) {
173
+ if (list.length === 0) {
174
+ host.innerHTML = "";
175
+ } else {
176
+ // Personal first, then alphabetical.
177
+ const sorted = [...list].sort((a, b) =>
178
+ (a.primary ? 0 : 1) - (b.primary ? 0 : 1) || a.name.localeCompare(b.name));
179
+ host.innerHTML = sorted.map(c => `<label class="cal-side-cal-row" title="${escapeHtml(c.name)}">
180
+ <input type="checkbox" class="cal-side-cal-check" data-cal-id="${escapeHtml(c.id)}" ${hiddenCalendars.has(c.id) ? "" : "checked"}>
181
+ ${calIconHtml(c)}
182
+ <span class="cal-side-cal-name">${escapeHtml(c.name)}</span>
183
+ </label>`).join("");
184
+ host.querySelectorAll<HTMLInputElement>(".cal-side-cal-check").forEach(cb => {
185
+ cb.addEventListener("change", async () => {
186
+ const id = cb.dataset.calId || "";
187
+ if (cb.checked) hiddenCalendars.delete(id);
188
+ else hiddenCalendars.add(id);
189
+ await saveHiddenCalendars();
190
+ renderEvents(lastEvents); // re-filter instantly
191
+ });
192
+ });
193
+ }
194
+ }
195
+ // Repaint events so rows pick up proper icons now the list is known.
196
+ if (lastEvents.length > 0) renderEvents(lastEvents);
197
+ }
64
198
 
65
199
  // Set of selected task UUIDs. Persists across renderTasks() calls so the
66
200
  // user keeps their selection when a re-render happens (e.g., after a
@@ -151,10 +285,28 @@ function renderHead(): void {
151
285
  dateEl.innerHTML = `<strong>${d.getDate()}</strong> ${d.toLocaleDateString(undefined, { weekday: "short" })} <span class="cal-side-date-month">${d.toLocaleDateString(undefined, { month: "short", year: "numeric" })}</span>`;
152
286
  }
153
287
 
288
+ /** Markup for one overdue-task row (pinned at the top of the calendar). */
289
+ function overdueTaskRowHtml(t: { uuid: string; title: string; dueMs?: number }): string {
290
+ let dueLabel = "";
291
+ if (t.dueMs) {
292
+ const d = new Date(t.dueMs);
293
+ const sameYear = d.getFullYear() === new Date().getFullYear();
294
+ dueLabel = sameYear ? `${d.getMonth() + 1}/${d.getDate()}` : d.toISOString().slice(0, 10);
295
+ }
296
+ return `<div class="cal-side-task cal-side-task-overdue" data-uuid="${escapeHtml(t.uuid)}">
297
+ <input type="checkbox" class="cal-side-overdue-check" title="Mark done">
298
+ <span class="cal-side-task-title">${escapeHtml(t.title)}</span>
299
+ <span class="cal-side-task-due overdue">${escapeHtml(dueLabel)}</span>
300
+ </div>`;
301
+ }
302
+
154
303
  function renderEvents(events: CalEvent[]): void {
155
304
  const body = document.getElementById("cal-side-body");
156
305
  if (!body) return;
157
- if (events.length === 0) {
306
+ // Drop events from calendars the user has hidden in mailx.
307
+ events = events.filter(e => !isCalHidden(e.calendarId));
308
+ const overdue = lastOverdueTasks;
309
+ if (events.length === 0 && overdue.length === 0) {
158
310
  body.innerHTML = `<div class="cal-side-empty">No upcoming events. Click + New event to add one.</div>`;
159
311
  return;
160
312
  }
@@ -196,12 +348,21 @@ function renderEvents(events: CalEvent[]): void {
196
348
  }
197
349
 
198
350
  let html = "";
351
+
352
+ // Overdue tasks pinned to the very top — a persistent "behind on these"
353
+ // block. Marking one done (the checkbox) completes it in Google and it
354
+ // drops off on the next render.
355
+ if (overdue.length > 0) {
356
+ html += `<div class="cal-side-day cal-side-day-overdue">Overdue tasks</div>`;
357
+ for (const t of overdue) html += overdueTaskRowHtml(t);
358
+ }
359
+
199
360
  if (dailyHeads.length > 0) {
200
361
  html += `<div class="cal-side-day cal-side-day-daily">Daily</div>`;
201
362
  for (const e of dailyHeads) {
202
363
  const link = e.htmlLink || "";
203
364
  html += `<div class="cal-side-event" data-id="${e.id}" data-link="${escapeHtml(link)}" ${link ? 'title="Click to open in Google Calendar"' : ""}>
204
- <span class="cal-side-event-dot ${e.source === "google" ? "g" : "l"}"></span>
365
+ ${calIconHtml(calInfoFor(e.calendarId))}
205
366
  <span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
206
367
  <span class="cal-side-event-title">${escapeHtml(e.title)}<span class="cal-side-event-recur" title="Daily">↻</span></span>
207
368
  </div>`;
@@ -214,10 +375,13 @@ function renderEvents(events: CalEvent[]): void {
214
375
  for (const e of events) {
215
376
  // Filter out daily-grouped instances — they're shown once at top.
216
377
  if (e.recurringEventId && dailyKeys.has(e.recurringEventId)) continue;
217
- // Holidays alone get the 2-week cap. Recurring events follow the
218
- // user's overall horizon (a weekly meeting whose next occurrence
219
- // is day 16 should still render in a 30-day view).
220
- if (e.isHoliday && e.start > holidayCutoff) continue;
378
+ const info = calInfoFor(e.calendarId);
379
+ const kind = calendarKind(info.id, info.primary);
380
+ const isHolidayKind = kind === "usHoliday" || kind === "jewishHoliday" || kind === "otherHoliday";
381
+ // Holiday calendars get the 2-week cap — they otherwise clutter a
382
+ // long horizon. Birthdays and recurring events follow the user's
383
+ // overall horizon.
384
+ if (isHolidayKind && e.start > holidayCutoff) continue;
221
385
  const d = new Date(e.start);
222
386
  const dayKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
223
387
  if (dayKey !== lastDayKey) {
@@ -226,44 +390,19 @@ function renderEvents(events: CalEvent[]): void {
226
390
  }
227
391
  const recurMark = e.recurringEventId ? `<span class="cal-side-event-recur" title="Recurring event">↻</span>` : "";
228
392
  const link = e.htmlLink || "";
229
- // Distinguish holiday sources for coloring: US (en.usa) → light
230
- // green; Jewish (en.jewish) → light blue. Anything else falls
231
- // through to a generic "h" class so a future calendar still gets
232
- // marked-as-holiday styling even if it isn't specifically themed.
233
- let holidayKind: string | undefined;
234
- if (e.isHoliday) {
235
- const cid = (e.calendarId || "").toLowerCase();
236
- if (cid.includes("usa")) holidayKind = "us";
237
- // Match both `en.judaism` (the actually-clean Jewish-only
238
- // calendar) and `en.jewish` (mis-curated by Google, retained
239
- // here so any leftover rows from the prior calendar ID still
240
- // render with the right symbol until reconcile evicts them).
241
- else if (cid.includes("judaism") || cid.includes("jewish")) holidayKind = "jewish";
242
- else holidayKind = "other";
243
- }
244
- const holidayAttr = e.isHoliday ? ` data-holiday="1" data-holiday-kind="${holidayKind}"` : "";
245
393
  const recurAttr = e.recurringEventId ? ' data-recurring="1"' : "";
246
- // Holidays don't get the Google Calendar deep-link (it's read-only)
247
- // suppress data-link and the "Click to open" hint for those rows.
248
- const clickable = !e.isHoliday;
249
- const titleAttr = clickable && link ? 'title="Click to open in Google Calendar"' : "";
250
- const dataLink = clickable ? `data-link="${escapeHtml(link)}"` : "";
251
- // Holidays render as a single full-width row (no time / dot
252
- // columns the day header already gives the date, and there's
253
- // typically no useful clock time). Regular events keep the
254
- // existing dot/time/title columns. A leading symbol identifies
255
- // the source at a glance: 🇺🇸 for US (federal-secular), Star of
256
- // David for Jewish, generic ✦ otherwise.
257
- if (e.isHoliday) {
258
- const symbol = holidayKind === "us" ? "🇺🇸"
259
- : holidayKind === "jewish" ? "✡️"
260
- : "✦";
261
- html += `<div class="cal-side-event"${holidayAttr} data-id="${e.id}">
262
- <span class="cal-side-event-title cal-side-event-holiday-title"><span class="cal-side-event-holiday-symbol">${symbol}</span> ${escapeHtml(e.title)}</span>
394
+ // Holiday + birthday calendars render as a single full-width row
395
+ // (all-day, no useful clock time) led by the calendar's icon.
396
+ // Regular events keep the icon/time/title columns and the
397
+ // click-to-open-in-Google deep link.
398
+ if (isHolidayKind || kind === "birthday") {
399
+ html += `<div class="cal-side-event" data-holiday="1" data-holiday-kind="${kind}" data-id="${e.id}">
400
+ <span class="cal-side-event-title cal-side-event-holiday-title">${calIconHtml(info)} ${escapeHtml(e.title)}</span>
263
401
  </div>`;
264
402
  } else {
265
- html += `<div class="cal-side-event" data-id="${e.id}"${recurAttr} ${dataLink} ${titleAttr}>
266
- <span class="cal-side-event-dot ${e.source === "google" ? "g" : "l"}"></span>
403
+ const titleAttr = link ? 'title="Click to open in Google Calendar"' : "";
404
+ html += `<div class="cal-side-event" data-id="${e.id}"${recurAttr} data-link="${escapeHtml(link)}" ${titleAttr}>
405
+ ${calIconHtml(info)}
267
406
  <span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
268
407
  <span class="cal-side-event-title">${escapeHtml(e.title)}${recurMark}</span>
269
408
  </div>`;
@@ -271,6 +410,18 @@ function renderEvents(events: CalEvent[]): void {
271
410
  }
272
411
  body.innerHTML = html;
273
412
 
413
+ // Overdue-task checkboxes — mark the task completed in Google (non-
414
+ // destructive; it survives in the Completed section) and re-render.
415
+ body.querySelectorAll<HTMLElement>(".cal-side-task-overdue").forEach(row => {
416
+ const uuid = row.dataset.uuid!;
417
+ row.querySelector<HTMLInputElement>(".cal-side-overdue-check")?.addEventListener("change", async () => {
418
+ await updateTask(uuid, { completedMs: Date.now() });
419
+ lastOverdueTasks = lastOverdueTasks.filter(t => t.uuid !== uuid);
420
+ renderEvents(lastEvents);
421
+ renderTasks();
422
+ });
423
+ });
424
+
274
425
  // Click-to-open — interim per user 2026-04-23: route to Google Calendar's
275
426
  // web UI via openExternal until we build an in-app event editor.
276
427
  // Right-click gives a context menu with "View in browser" explicit.
@@ -309,10 +460,12 @@ function renderEvents(events: CalEvent[]): void {
309
460
  });
310
461
  }
311
462
 
312
- async function renderTasks(): Promise<void> {
463
+ async function renderTasks(prefetched?: any[]): Promise<void> {
313
464
  const cb = document.getElementById("cal-side-show-done") as HTMLInputElement | null;
314
465
  const showDone = cb?.checked ?? false;
315
- const tasks = await getTasks(showDone);
466
+ // Reuse the list refresh() already fetched (avoids a duplicate getTasks
467
+ // IPC + Google pull); fall back to fetching when called standalone.
468
+ const tasks = prefetched ?? await getTasks(showDone);
316
469
  const host = document.getElementById("cal-side-tasks");
317
470
  if (!host) return;
318
471
  if (tasks.length === 0) {
@@ -406,14 +559,30 @@ async function refresh(): Promise<void> {
406
559
  // sidebar stuck on its initial "Loading…" placeholder — that was the
407
560
  // "calendar shows nothing" symptom. Render an explicit state either
408
561
  // way: events on success, an error line on failure.
562
+ // Tasks are fetched ONCE here and handed to renderTasks() so the events
563
+ // pane and the task pane don't each fire their own getTasks() — that
564
+ // doubled the Google Tasks API call rate and helped trip the 429 storm.
565
+ let prefetchedTasks: any[] | undefined;
409
566
  try {
410
- lastEvents = await fetchUpcoming(from);
567
+ const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0);
568
+ const showDone = (document.getElementById("cal-side-show-done") as HTMLInputElement | null)?.checked ?? false;
569
+ const [events, tasks] = await Promise.all([
570
+ fetchUpcoming(from),
571
+ getTasks(showDone).catch(() => [] as any[]),
572
+ ]);
573
+ lastEvents = events;
574
+ prefetchedTasks = tasks as any[];
575
+ // Overdue tasks (due before today, not completed) pin to the top of
576
+ // the calendar list.
577
+ lastOverdueTasks = prefetchedTasks
578
+ .filter(t => t.dueMs && !t.completedMs && t.dueMs < startOfToday.getTime())
579
+ .map(t => ({ uuid: t.uuid, title: t.title, dueMs: t.dueMs }));
411
580
  renderEvents(lastEvents);
412
581
  } catch (e: any) {
413
582
  const body = document.getElementById("cal-side-body");
414
583
  if (body) body.innerHTML = `<div class="cal-side-empty cal-side-quota-error">Couldn't load calendar: ${escapeHtml(e?.message || String(e))}</div>`;
415
584
  }
416
- renderTasks();
585
+ renderTasks(prefetchedTasks);
417
586
  }
418
587
 
419
588
  /** Open the natural-language new-event modal. The user types a free-form
@@ -562,6 +731,8 @@ export async function showCalendarSidebar(): Promise<void> {
562
731
  el.hidden = false;
563
732
  document.body.classList.add("calendar-sidebar-on");
564
733
  try { localStorage.setItem(SIDEBAR_PREF, "true"); } catch { /* */ }
734
+ await loadHiddenCalendars();
735
+ void renderCalendarList(); // async — repaints events when it resolves
565
736
  await refresh();
566
737
  }
567
738
 
@@ -631,7 +802,9 @@ export function initCalendarSidebar(): void {
631
802
  btn?.classList.add("cal-side-refreshing");
632
803
  if (btn) btn.disabled = true;
633
804
  try {
634
- await refresh();
805
+ // Also re-enumerate calendars — the user may have selected /
806
+ // deselected one in Google since the sidebar opened.
807
+ await Promise.all([renderCalendarList(), refresh()]);
635
808
  } finally {
636
809
  setTimeout(() => {
637
810
  btn?.classList.remove("cal-side-refreshing");
@@ -660,42 +833,10 @@ export function initCalendarSidebar(): void {
660
833
  refresh();
661
834
  });
662
835
  }
663
- // Holiday-calendar toggles. Each lives in service settings (not
664
- // localStorage) because the daemon's refresh loop decides which
665
- // calendars to pull the preference has to round-trip to it.
666
- // Two separate Google public calendars today: US federal holidays
667
- // and Jewish holidays. Generalization to arbitrary user-supplied
668
- // calendars is filed as Q140 (low priority unless the audience
669
- // broadens beyond Bob).
670
- const wireHolidayCheckbox = (
671
- cbId: string,
672
- settingsKey: "showHolidays" | "showJewishHolidays",
673
- ): void => {
674
- const cb = document.getElementById(cbId) as HTMLInputElement | null;
675
- if (!cb || (cb as any).__wired) return;
676
- (cb as any).__wired = true;
677
- // Race guard: the async getSettings() promise can resolve AFTER the
678
- // user clicks the checkbox, overwriting their input with the prior
679
- // value. If the user touched the checkbox while the initial load
680
- // was in flight, drop the late initial value on the floor — the
681
- // change handler has already taken control.
682
- let userTouched = false;
683
- getSettings().then((s: any) => {
684
- if (userTouched) return;
685
- cb.checked = !!s?.calendar?.[settingsKey];
686
- }).catch(() => { /* */ });
687
- cb.addEventListener("change", async () => {
688
- userTouched = true;
689
- try {
690
- const s = await getSettings();
691
- s.calendar = { ...(s.calendar || {}), [settingsKey]: cb.checked };
692
- await saveSettings(s);
693
- } catch (e: any) { console.error(`[cal] ${settingsKey} save failed:`, e); }
694
- refresh();
695
- });
696
- };
697
- wireHolidayCheckbox("cal-side-show-holidays", "showHolidays");
698
- wireHolidayCheckbox("cal-side-show-jewish-holidays", "showJewishHolidays");
836
+ // Per-calendar checkboxes are generated dynamically from the user's
837
+ // selected Google calendars see renderCalendarList(), driven from
838
+ // showCalendarSidebar() + the manual refresh button. No hard-coded
839
+ // holiday toggles any more.
699
840
  // Horizon input — how many days ahead to list events. Bounded 1..365.
700
841
  const horizonInput = document.getElementById("cal-side-horizon") as HTMLInputElement | null;
701
842
  if (horizonInput && !(horizonInput as any).__wired) {
@@ -1847,9 +1847,32 @@ function spawnDesktopPopout(msg, accountId) {
1847
1847
  wrapper.style.top = `${60 + existing * 28}px`;
1848
1848
  wrapper.style.right = `${20 + existing * 28}px`;
1849
1849
  }
1850
- void accountId; // accountId reserved for future per-account actions on the popout
1850
+ // Action toolbar Reply / Reply All / Forward / Delete, acting on THIS
1851
+ // popped-out message. The pop-out can't reach app.ts's openCompose
1852
+ // directly, so each button fires `mailx-popout-action`; app.ts routes it.
1853
+ const toolbar = document.createElement("div");
1854
+ toolbar.style.cssText = "display:flex;gap:6px;padding:6px 12px;border-bottom:1px solid var(--color-border, #ddd);flex-shrink:0;";
1855
+ const mkPopoutBtn = (label, title, onClick) => {
1856
+ const b = document.createElement("button");
1857
+ b.textContent = label;
1858
+ b.title = title;
1859
+ b.style.cssText = "padding:4px 10px;font-size:13px;cursor:pointer;border:1px solid var(--color-border, #ccc);border-radius:4px;background:var(--color-bg, #fff);color:var(--color-text, #000);";
1860
+ b.addEventListener("click", onClick);
1861
+ return b;
1862
+ };
1863
+ const firePopoutAction = (action) => {
1864
+ document.dispatchEvent(new CustomEvent("mailx-popout-action", { detail: { action, msg, accountId } }));
1865
+ };
1866
+ toolbar.appendChild(mkPopoutBtn("Reply", "Reply", () => firePopoutAction("reply")));
1867
+ toolbar.appendChild(mkPopoutBtn("Reply All", "Reply to all", () => firePopoutAction("replyAll")));
1868
+ toolbar.appendChild(mkPopoutBtn("Forward", "Forward", () => firePopoutAction("forward")));
1869
+ toolbar.appendChild(mkPopoutBtn("Delete", "Delete this message", () => {
1870
+ firePopoutAction("delete");
1871
+ wrapper.remove(); // the message is gone — close its window too
1872
+ }));
1851
1873
  wrapper.appendChild(titleBar);
1852
1874
  wrapper.appendChild(headerInfo);
1875
+ wrapper.appendChild(toolbar);
1853
1876
  wrapper.appendChild(bodyContainer);
1854
1877
  document.body.appendChild(wrapper);
1855
1878
  }