@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
package/client/android.html
CHANGED
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
<body>
|
|
60
60
|
<header class="toolbar">
|
|
61
61
|
<div class="toolbar-left">
|
|
62
|
-
<button class="tb-btn" id="btn-menu" title="
|
|
62
|
+
<button class="tb-btn" id="btn-menu" title="Menu / rail" hidden>☰</button>
|
|
63
|
+
<button class="tb-btn" id="btn-folder-toggle" title="Show / hide folders" hidden>📁</button>
|
|
63
64
|
<button class="tb-btn" id="btn-compose" title="Compose (Ctrl+N)">
|
|
64
65
|
<span class="tb-icon">✏</span> Compose
|
|
65
66
|
</button>
|
|
@@ -68,11 +69,11 @@
|
|
|
68
69
|
<div class="tb-menu" id="view-menu">
|
|
69
70
|
<button class="tb-btn" id="btn-view">View</button>
|
|
70
71
|
<div class="tb-menu-dropdown" id="view-dropdown" hidden>
|
|
71
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-two-line"> Two-line view</label>
|
|
72
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
|
|
73
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
|
|
74
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
75
|
-
<label class="tb-menu-item"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
|
|
72
|
+
<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>
|
|
73
|
+
<label class="tb-menu-item" title="Show the reading pane below/beside the message list"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
|
|
74
|
+
<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>
|
|
75
|
+
<label class="tb-menu-item" title="Show only flagged (★) messages in the list"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
76
|
+
<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>
|
|
76
77
|
</div>
|
|
77
78
|
</div>
|
|
78
79
|
<div class="tb-menu" id="settings-menu">
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
|
|
83
84
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
|
|
84
85
|
<hr class="tb-menu-sep">
|
|
85
|
-
<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>
|
|
86
87
|
</div>
|
|
87
88
|
</div>
|
|
88
89
|
<span id="app-version" class="app-version">mailx</span>
|
|
@@ -109,6 +110,22 @@
|
|
|
109
110
|
<button class="alert-dismiss" id="alert-dismiss" title="Dismiss">×</button>
|
|
110
111
|
</div>
|
|
111
112
|
|
|
113
|
+
<aside class="icon-rail" id="icon-rail" aria-label="App rail">
|
|
114
|
+
<div class="rail-top">
|
|
115
|
+
<button class="rail-btn" id="rail-compose" title="Compose" aria-label="Compose">✏</button>
|
|
116
|
+
<button class="rail-btn" id="rail-inbox" title="Inbox" aria-label="Inbox" data-active="true">✉</button>
|
|
117
|
+
<button class="rail-btn" id="rail-unified" title="All Inboxes" aria-label="All Inboxes">⌘</button>
|
|
118
|
+
<button class="rail-btn" id="rail-contacts" title="Contacts" aria-label="Contacts">👤</button>
|
|
119
|
+
<button class="rail-btn" id="rail-calendar" title="Calendar" aria-label="Calendar">📅</button>
|
|
120
|
+
<button class="rail-btn" id="rail-tasks" title="Tasks" aria-label="Tasks">☑</button>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="rail-bottom">
|
|
123
|
+
<button class="rail-btn" id="rail-settings" title="Settings" aria-label="Settings">⚙</button>
|
|
124
|
+
<button class="rail-btn" id="rail-theme" title="Theme" aria-label="Theme">◐</button>
|
|
125
|
+
<button class="rail-btn" id="rail-help" title="Help" aria-label="Help">?</button>
|
|
126
|
+
</div>
|
|
127
|
+
</aside>
|
|
128
|
+
|
|
112
129
|
<div class="folder-panel">
|
|
113
130
|
<div class="ft-filter">
|
|
114
131
|
<input type="text" id="ft-filter-input" placeholder="Find folder..." autocomplete="off">
|
|
@@ -133,6 +150,16 @@
|
|
|
133
150
|
<span class="ml-col ml-col-date" data-sort="date">Date</span>
|
|
134
151
|
<span class="ml-col ml-col-subject">Subject</span>
|
|
135
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>
|
|
136
163
|
<div class="ml-body" id="ml-body">
|
|
137
164
|
<div class="ml-empty">Select a folder to view messages</div>
|
|
138
165
|
</div>
|
package/client/app.js
CHANGED
|
@@ -390,9 +390,21 @@ if (messageList) {
|
|
|
390
390
|
}
|
|
391
391
|
}).observe(messageList);
|
|
392
392
|
}
|
|
393
|
-
// ── Narrow
|
|
393
|
+
// ── Narrow/medium drawer toggles ──
|
|
394
|
+
// Hamburger (☰): rail drawer on narrow; on wider tiers the rail is already
|
|
395
|
+
// visible so this is a no-op visually (the toggle still fires but the rail
|
|
396
|
+
// has no `.open` style to invoke).
|
|
397
|
+
// Folder (📁): folder-panel drawer on any tier where it's positioned as an
|
|
398
|
+
// overlay (medium + narrow).
|
|
394
399
|
document.getElementById("btn-menu")?.addEventListener("click", () => {
|
|
400
|
+
document.querySelector(".icon-rail")?.classList.toggle("open");
|
|
401
|
+
// Rail drawer and folder drawer are mutually exclusive — opening one
|
|
402
|
+
// closes the other so they don't fight for the left edge.
|
|
403
|
+
document.querySelector(".folder-panel")?.classList.remove("open");
|
|
404
|
+
});
|
|
405
|
+
document.getElementById("btn-folder-toggle")?.addEventListener("click", () => {
|
|
395
406
|
document.querySelector(".folder-panel")?.classList.toggle("open");
|
|
407
|
+
document.querySelector(".icon-rail")?.classList.remove("open");
|
|
396
408
|
});
|
|
397
409
|
const backToList = (e) => {
|
|
398
410
|
e.preventDefault();
|
|
@@ -1044,14 +1056,27 @@ document.getElementById("rail-contacts")?.addEventListener("click", async () =>
|
|
|
1044
1056
|
openAddressBook();
|
|
1045
1057
|
setRailActive("rail-contacts");
|
|
1046
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.
|
|
1047
1063
|
document.getElementById("rail-calendar")?.addEventListener("click", async () => {
|
|
1048
|
-
const {
|
|
1049
|
-
|
|
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;
|
|
1050
1070
|
setRailActive("rail-calendar");
|
|
1051
1071
|
});
|
|
1052
1072
|
document.getElementById("rail-tasks")?.addEventListener("click", async () => {
|
|
1053
|
-
const {
|
|
1054
|
-
|
|
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;
|
|
1055
1080
|
setRailActive("rail-tasks");
|
|
1056
1081
|
});
|
|
1057
1082
|
document.getElementById("rail-settings")?.addEventListener("click", () => {
|
|
@@ -2031,6 +2056,18 @@ function applyThreadFilter() {
|
|
|
2031
2056
|
hideCalendarSidebar();
|
|
2032
2057
|
});
|
|
2033
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
|
+
})();
|
|
2034
2071
|
// Two-line toggle
|
|
2035
2072
|
optTwoLine?.addEventListener("change", () => {
|
|
2036
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">×</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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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() {
|