@bobfrankston/mailx 1.0.395 → 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.
@@ -83,7 +83,7 @@
83
83
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
84
84
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
85
85
  <hr class="tb-menu-sep">
86
- <label class="tb-menu-item"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
86
+ <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>
87
87
  </div>
88
88
  </div>
89
89
  <span id="app-version" class="app-version">mailx</span>
@@ -150,6 +150,16 @@
150
150
  <span class="ml-col ml-col-date" data-sort="date">Date</span>
151
151
  <span class="ml-col ml-col-subject">Subject</span>
152
152
  </div>
153
+ <div class="ml-bulkbar" id="ml-bulkbar" hidden>
154
+ <button type="button" class="ml-bulk-cancel" id="ml-bulk-cancel" title="Exit multi-select">✕</button>
155
+ <span class="ml-bulk-count" id="ml-bulk-count">0 selected</span>
156
+ <span style="flex:1"></span>
157
+ <button type="button" class="ml-bulk-btn" data-bulk="markread" title="Mark read">◉</button>
158
+ <button type="button" class="ml-bulk-btn" data-bulk="flag" title="Flag">⚑</button>
159
+ <button type="button" class="ml-bulk-btn" data-bulk="move" title="Move to folder…">➜</button>
160
+ <button type="button" class="ml-bulk-btn" data-bulk="spam" title="Mark as spam">⚠</button>
161
+ <button type="button" class="ml-bulk-btn ml-bulk-danger" data-bulk="delete" title="Delete">🗑</button>
162
+ </div>
153
163
  <div class="ml-body" id="ml-body">
154
164
  <div class="ml-empty">Select a folder to view messages</div>
155
165
  </div>
package/client/app.js CHANGED
@@ -1056,14 +1056,27 @@ document.getElementById("rail-contacts")?.addEventListener("click", async () =>
1056
1056
  openAddressBook();
1057
1057
  setRailActive("rail-contacts");
1058
1058
  });
1059
+ // Q114 decided 2026-04-24: full-screen calendar/tasks modals are
1060
+ // temporarily retired — the right-docked sidebar (calendar-sidebar.ts)
1061
+ // owns both views. Rail buttons now just reveal the sidebar. Files kept
1062
+ // (`calendar.ts`, `tasks.ts`) for potential revival; not imported.
1059
1063
  document.getElementById("rail-calendar")?.addEventListener("click", async () => {
1060
- const { openCalendar } = await import("./components/calendar.js");
1061
- openCalendar();
1064
+ const { showCalendarSidebar } = await import("./components/calendar-sidebar.js");
1065
+ await showCalendarSidebar();
1066
+ // Flip the View-menu checkbox so the on-state stays coherent across paths.
1067
+ const optSidebar = document.getElementById("opt-calendar-sidebar");
1068
+ if (optSidebar)
1069
+ optSidebar.checked = true;
1062
1070
  setRailActive("rail-calendar");
1063
1071
  });
1064
1072
  document.getElementById("rail-tasks")?.addEventListener("click", async () => {
1065
- const { openTasks } = await import("./components/tasks.js");
1066
- openTasks();
1073
+ const { showCalendarSidebar } = await import("./components/calendar-sidebar.js");
1074
+ await showCalendarSidebar();
1075
+ // Scroll the sidebar to the tasks section if possible.
1076
+ document.getElementById("cal-side-tasks")?.scrollIntoView({ block: "start", behavior: "smooth" });
1077
+ const optSidebar = document.getElementById("opt-calendar-sidebar");
1078
+ if (optSidebar)
1079
+ optSidebar.checked = true;
1067
1080
  setRailActive("rail-tasks");
1068
1081
  });
1069
1082
  document.getElementById("rail-settings")?.addEventListener("click", () => {
@@ -2043,6 +2056,18 @@ function applyThreadFilter() {
2043
2056
  hideCalendarSidebar();
2044
2057
  });
2045
2058
  })();
2059
+ // P17 / Q104: alarm subsystem — Thunderbird/Outlook-style popup with
2060
+ // snooze + dismiss. Covers calendar events + tasks today; mail reminders
2061
+ // will slot in here when the mail-reminder feature lands.
2062
+ (async () => {
2063
+ try {
2064
+ const { startAlarmPoller } = await import("./components/alarms.js");
2065
+ startAlarmPoller();
2066
+ }
2067
+ catch (e) {
2068
+ console.error("alarm poller init failed:", e?.message || e);
2069
+ }
2070
+ })();
2046
2071
  // Two-line toggle
2047
2072
  optTwoLine?.addEventListener("change", () => {
2048
2073
  const list = document.getElementById("message-list");
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Alarm subsystem — Thunderbird/Outlook-style reminder popups.
3
+ *
4
+ * Design decisions (2026-04-24):
5
+ * - One shared subsystem for calendar events + tasks (+ future mail reminders).
6
+ * - Popup shows item title, scheduled time, Snooze (5 / 15 / 30 min / 1 hr /
7
+ * custom), Dismiss. Snooze delays the alarm by the chosen interval; Dismiss
8
+ * suppresses it permanently.
9
+ * - Dismissed / snoozed state lives in localStorage (per-device). Google
10
+ * Calendar's own reminders aren't mutated by this — mailx's popup is a
11
+ * local convenience, not a reminder-authority replacement.
12
+ * - Default lead time: 10 min before calendar event start; at task due time
13
+ * for tasks. Per-event overrides from Google Calendar's `reminders.overrides`
14
+ * are a follow-up (we don't currently fetch that field).
15
+ *
16
+ * Check cadence: every 30 s while the tab/window is focused. No check when
17
+ * hidden — alarm won't fire until user returns, which matches Thunderbird's
18
+ * behavior (focus-gated). OS-level notifications are a separate follow-up.
19
+ */
20
+ import { getCalendarEvents, getTasks } from "../lib/api-client.js";
21
+ const DISMISSED_KEY = "mailx-alarm-dismissed"; // { uuid: true }
22
+ const SNOOZED_KEY = "mailx-alarm-snoozed"; // { uuid: epoch-ms-end }
23
+ const CAL_LEAD_MS = 10 * 60 * 1000; // 10 min before cal event start
24
+ const LOOKBACK_MS = 60 * 60 * 1000; // fire past-due alarms from the last 60 min
25
+ const LOOKAHEAD_MS = 2 * 60 * 60 * 1000; // poll window: upcoming 2 hr
26
+ const POLL_INTERVAL_MS = 30_000;
27
+ function loadDismissed() {
28
+ try {
29
+ return JSON.parse(localStorage.getItem(DISMISSED_KEY) || "{}");
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }
35
+ function saveDismissed(map) {
36
+ try {
37
+ localStorage.setItem(DISMISSED_KEY, JSON.stringify(map));
38
+ }
39
+ catch { /* private mode */ }
40
+ }
41
+ function loadSnoozed() {
42
+ try {
43
+ return JSON.parse(localStorage.getItem(SNOOZED_KEY) || "{}");
44
+ }
45
+ catch {
46
+ return {};
47
+ }
48
+ }
49
+ function saveSnoozed(map) {
50
+ try {
51
+ localStorage.setItem(SNOOZED_KEY, JSON.stringify(map));
52
+ }
53
+ catch { /* */ }
54
+ }
55
+ /** Prune expired entries so the maps don't grow forever. */
56
+ function pruneState(now) {
57
+ const snoozed = loadSnoozed();
58
+ let changed = false;
59
+ for (const [uuid, until] of Object.entries(snoozed)) {
60
+ // Snoozed entries older than 7 days have been fired already (or the
61
+ // event is long past); drop them.
62
+ if (until < now - 7 * 86400_000) {
63
+ delete snoozed[uuid];
64
+ changed = true;
65
+ }
66
+ }
67
+ if (changed)
68
+ saveSnoozed(snoozed);
69
+ // Dismissed is sparse — don't bother pruning aggressively.
70
+ }
71
+ async function collectDueAlarms(now) {
72
+ const dismissed = loadDismissed();
73
+ const snoozed = loadSnoozed();
74
+ const items = [];
75
+ try {
76
+ const events = await getCalendarEvents(now - LOOKBACK_MS, now + LOOKAHEAD_MS);
77
+ for (const ev of events) {
78
+ if (!ev.uuid)
79
+ continue;
80
+ if (dismissed[ev.uuid])
81
+ continue;
82
+ const alarm = (ev.start || 0) - CAL_LEAD_MS;
83
+ const effective = snoozed[ev.uuid] || alarm;
84
+ if (effective <= now && effective > now - LOOKBACK_MS) {
85
+ items.push({
86
+ uuid: ev.uuid, kind: "calendar",
87
+ title: ev.title || "(no title)",
88
+ alarmMs: effective, whenMs: ev.start || 0,
89
+ htmlLink: ev.htmlLink,
90
+ });
91
+ }
92
+ }
93
+ }
94
+ catch { /* sidebar will surface API errors; alarm path stays quiet */ }
95
+ try {
96
+ const tasks = await getTasks(false);
97
+ for (const t of tasks) {
98
+ if (!t.uuid || !t.dueMs)
99
+ continue;
100
+ if (dismissed[t.uuid])
101
+ continue;
102
+ const effective = snoozed[t.uuid] || t.dueMs;
103
+ if (effective <= now && effective > now - LOOKBACK_MS) {
104
+ items.push({
105
+ uuid: t.uuid, kind: "task",
106
+ title: t.title || "(no title)",
107
+ alarmMs: effective, whenMs: t.dueMs,
108
+ });
109
+ }
110
+ }
111
+ }
112
+ catch { /* */ }
113
+ return items;
114
+ }
115
+ function formatWhen(ms) {
116
+ if (!ms)
117
+ return "";
118
+ const d = new Date(ms);
119
+ return d.toLocaleString(undefined, { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
120
+ }
121
+ let currentPopup = null;
122
+ let firedThisSession = new Set();
123
+ function showPopup(items) {
124
+ if (currentPopup)
125
+ return; // another popup already up
126
+ if (items.length === 0)
127
+ return;
128
+ const overlay = document.createElement("div");
129
+ overlay.className = "alarm-overlay";
130
+ const panel = document.createElement("div");
131
+ panel.className = "alarm-panel";
132
+ panel.innerHTML = `
133
+ <div class="alarm-head">
134
+ <span class="alarm-icon">⏰</span>
135
+ <span class="alarm-title">${items.length} reminder${items.length > 1 ? "s" : ""}</span>
136
+ <button type="button" class="alarm-close" aria-label="Close">&times;</button>
137
+ </div>
138
+ <div class="alarm-list"></div>
139
+ <div class="alarm-foot">
140
+ <label class="alarm-snooze-label">Snooze for
141
+ <select class="alarm-snooze-sel">
142
+ <option value="5">5 minutes</option>
143
+ <option value="15" selected>15 minutes</option>
144
+ <option value="30">30 minutes</option>
145
+ <option value="60">1 hour</option>
146
+ <option value="240">4 hours</option>
147
+ <option value="1440">1 day</option>
148
+ <option value="custom">custom…</option>
149
+ </select>
150
+ </label>
151
+ <span style="flex:1"></span>
152
+ <button type="button" class="alarm-btn alarm-btn-snooze">Snooze all</button>
153
+ <button type="button" class="alarm-btn alarm-btn-primary alarm-btn-dismiss">Dismiss all</button>
154
+ </div>
155
+ `;
156
+ const list = panel.querySelector(".alarm-list");
157
+ for (const it of items) {
158
+ const row = document.createElement("div");
159
+ row.className = "alarm-row";
160
+ row.dataset.uuid = it.uuid;
161
+ row.innerHTML = `
162
+ <div class="alarm-row-main">
163
+ <span class="alarm-row-kind">${it.kind === "calendar" ? "📅" : "☑"}</span>
164
+ <span class="alarm-row-title"></span>
165
+ <span class="alarm-row-when"></span>
166
+ </div>
167
+ <div class="alarm-row-actions">
168
+ ${it.htmlLink ? `<button type="button" class="alarm-row-link" data-link="${it.htmlLink}" title="View in browser">↗</button>` : ""}
169
+ <button type="button" class="alarm-row-dismiss" title="Dismiss this one">✕</button>
170
+ </div>
171
+ `;
172
+ row.querySelector(".alarm-row-title").textContent = it.title;
173
+ row.querySelector(".alarm-row-when").textContent = formatWhen(it.whenMs);
174
+ list.appendChild(row);
175
+ }
176
+ overlay.appendChild(panel);
177
+ document.body.appendChild(overlay);
178
+ currentPopup = overlay;
179
+ const snoozeMinutes = () => {
180
+ const sel = panel.querySelector(".alarm-snooze-sel");
181
+ if (sel.value === "custom") {
182
+ const v = prompt("Snooze for how many minutes?", "30");
183
+ const n = parseInt(v || "", 10);
184
+ return Number.isFinite(n) && n > 0 ? n : null;
185
+ }
186
+ return parseInt(sel.value, 10);
187
+ };
188
+ const snoozeAll = (minutes) => {
189
+ const now = Date.now();
190
+ const map = loadSnoozed();
191
+ for (const it of items)
192
+ map[it.uuid] = now + minutes * 60_000;
193
+ saveSnoozed(map);
194
+ for (const it of items)
195
+ firedThisSession.delete(it.uuid);
196
+ close();
197
+ };
198
+ const dismissAll = () => {
199
+ const map = loadDismissed();
200
+ for (const it of items)
201
+ map[it.uuid] = true;
202
+ saveDismissed(map);
203
+ close();
204
+ };
205
+ const close = () => {
206
+ overlay.remove();
207
+ currentPopup = null;
208
+ };
209
+ panel.querySelector(".alarm-close")?.addEventListener("click", close);
210
+ panel.querySelector(".alarm-btn-snooze")?.addEventListener("click", () => {
211
+ const n = snoozeMinutes();
212
+ if (n)
213
+ snoozeAll(n);
214
+ });
215
+ panel.querySelector(".alarm-btn-dismiss")?.addEventListener("click", dismissAll);
216
+ panel.querySelectorAll(".alarm-row-dismiss").forEach(btn => {
217
+ btn.addEventListener("click", () => {
218
+ const row = btn.closest(".alarm-row");
219
+ const uuid = row?.dataset.uuid;
220
+ if (!uuid)
221
+ return;
222
+ const map = loadDismissed();
223
+ map[uuid] = true;
224
+ saveDismissed(map);
225
+ row.remove();
226
+ if (panel.querySelector(".alarm-list")?.childElementCount === 0)
227
+ close();
228
+ });
229
+ });
230
+ panel.querySelectorAll(".alarm-row-link").forEach(btn => {
231
+ btn.addEventListener("click", () => {
232
+ const url = btn.dataset.link;
233
+ if (!url)
234
+ return;
235
+ const api = window.mailxapi;
236
+ if (api?.openExternal)
237
+ api.openExternal(url);
238
+ else
239
+ window.open(url, "_blank");
240
+ });
241
+ });
242
+ // Escape closes without action (same as X) — user can re-open with next
243
+ // poll tick by not acting on it.
244
+ const onKey = (e) => {
245
+ if (e.key === "Escape") {
246
+ e.stopPropagation();
247
+ close();
248
+ document.removeEventListener("keydown", onKey, true);
249
+ }
250
+ };
251
+ document.addEventListener("keydown", onKey, true);
252
+ }
253
+ async function pollAlarms() {
254
+ if (document.hidden)
255
+ return; // don't fire while tab hidden
256
+ if (currentPopup)
257
+ return; // user hasn't acknowledged last one
258
+ const now = Date.now();
259
+ pruneState(now);
260
+ const due = await collectDueAlarms(now);
261
+ // Only show items we haven't already fired this session (prevents the
262
+ // popup from immediately re-appearing if the user closes it without
263
+ // dismissing — the item still matches the "due" filter next tick).
264
+ const fresh = due.filter(d => !firedThisSession.has(d.uuid));
265
+ if (fresh.length === 0)
266
+ return;
267
+ for (const d of fresh)
268
+ firedThisSession.add(d.uuid);
269
+ showPopup(fresh);
270
+ }
271
+ /** Start the alarm poller. Called from app.ts on startup. */
272
+ export function startAlarmPoller() {
273
+ if (window.__mailxAlarmPollerRunning)
274
+ return;
275
+ window.__mailxAlarmPollerRunning = true;
276
+ // First check after 5 s so startup isn't jittered by a modal.
277
+ setTimeout(() => { pollAlarms().catch(() => { }); }, 5000);
278
+ setInterval(() => { pollAlarms().catch(() => { }); }, POLL_INTERVAL_MS);
279
+ // Re-check on visibility change — if the user comes back to the tab
280
+ // after an hour away, they should see anything they missed immediately.
281
+ document.addEventListener("visibilitychange", () => {
282
+ if (!document.hidden)
283
+ pollAlarms().catch(() => { });
284
+ });
285
+ }
286
+ //# sourceMappingURL=alarms.js.map
@@ -14,6 +14,7 @@
14
14
  * and tasks tables); this file does not use localStorage for data.
15
15
  */
16
16
  import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, reauthGoogleScopes, } from "../lib/api-client.js";
17
+ import { showContextMenu } from "./context-menu.js";
17
18
  const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
18
19
  const SHOW_RECURRING_PREF = "mailx-cal-show-recurring";
19
20
  const SHOW_DONE_PREF = "mailx-task-show-done";
@@ -118,16 +119,35 @@ function renderEvents(events) {
118
119
  body.innerHTML = html;
119
120
  // Click-to-open — interim per user 2026-04-23: route to Google Calendar's
120
121
  // web UI via openExternal until we build an in-app event editor.
122
+ // Right-click gives a context menu with "View in browser" explicit.
123
+ const openInBrowser = (url) => {
124
+ const api = window.mailxapi;
125
+ if (api?.openExternal)
126
+ api.openExternal(url);
127
+ else
128
+ window.open(url, "_blank");
129
+ };
121
130
  body.querySelectorAll(".cal-side-event").forEach(el => {
122
131
  el.addEventListener("click", () => {
123
132
  const link = el.dataset.link;
124
- if (link) {
125
- const api = window.mailxapi;
126
- if (api?.openExternal)
127
- api.openExternal(link);
128
- else
129
- window.open(link, "_blank");
130
- }
133
+ if (link)
134
+ openInBrowser(link);
135
+ });
136
+ el.addEventListener("contextmenu", (e) => {
137
+ e.preventDefault();
138
+ const link = el.dataset.link;
139
+ const items = [];
140
+ if (link)
141
+ items.push({
142
+ label: "View in browser",
143
+ action: () => openInBrowser(link),
144
+ });
145
+ items.push({
146
+ label: "Open Google Calendar",
147
+ action: () => openInBrowser("https://calendar.google.com/"),
148
+ });
149
+ if (items.length > 0)
150
+ showContextMenu(e.clientX, e.clientY, items);
131
151
  });
132
152
  });
133
153
  }
@@ -152,6 +172,13 @@ async function renderTasks() {
152
172
  </div>`;
153
173
  }
154
174
  host.innerHTML = html;
175
+ const openInBrowser = (url) => {
176
+ const api = window.mailxapi;
177
+ if (api?.openExternal)
178
+ api.openExternal(url);
179
+ else
180
+ window.open(url, "_blank");
181
+ };
155
182
  host.querySelectorAll(".cal-side-task").forEach(row => {
156
183
  const uuid = row.dataset.uuid;
157
184
  row.querySelector(".cal-side-task-check")?.addEventListener("change", async (e) => {
@@ -163,6 +190,15 @@ async function renderTasks() {
163
190
  await deleteTask(uuid);
164
191
  renderTasks();
165
192
  });
193
+ row.addEventListener("contextmenu", (e) => {
194
+ e.preventDefault();
195
+ // Google Tasks doesn't have a per-task web URL; open the list view.
196
+ showContextMenu(e.clientX, e.clientY, [
197
+ { label: "View in Google Tasks", action: () => openInBrowser("https://tasks.google.com/") },
198
+ { label: "", action: () => { }, separator: true },
199
+ { label: "Delete task", action: async () => { await deleteTask(uuid); renderTasks(); } },
200
+ ]);
201
+ });
166
202
  });
167
203
  }
168
204
  async function refresh() {