@bobfrankston/rmfmail 1.2.274 → 1.2.277
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/.commitmsg +31 -0
- package/bin/build-tinymce.js +27 -0
- package/client/android-bootstrap.bundle.js +169 -0
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +231 -23
- package/client/app.bundle.js.map +4 -4
- package/client/app.js +81 -1
- package/client/app.js.map +1 -1
- package/client/app.ts +75 -1
- package/client/components/invite-card.js +107 -0
- package/client/components/invite-card.js.map +1 -0
- package/client/components/invite-card.ts +119 -0
- package/client/components/message-viewer.js +42 -17
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +42 -16
- package/client/compose/compose.bundle.js +90 -2
- package/client/compose/compose.bundle.js.map +4 -4
- package/client/compose/compose.js +26 -1
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +22 -1
- package/client/compose/editor.js +12 -1
- package/client/compose/editor.js.map +1 -1
- package/client/compose/editor.ts +17 -2
- package/client/index.html +1 -0
- package/client/lib/rmf-tiny.js +3 -0
- package/client/lib/tinymce/langs/ar-SA.js +440 -0
- package/client/lib/tinymce/langs/ar.js +441 -0
- package/client/lib/tinymce/langs/az.js +440 -0
- package/client/lib/tinymce/langs/be.js +440 -0
- package/client/lib/tinymce/langs/bg-BG.js +440 -0
- package/client/lib/tinymce/langs/bn-BD.js +440 -0
- package/client/lib/tinymce/langs/bs.js +440 -0
- package/client/lib/tinymce/langs/ca.js +440 -0
- package/client/lib/tinymce/langs/cs.js +440 -0
- package/client/lib/tinymce/langs/cy.js +440 -0
- package/client/lib/tinymce/langs/da.js +440 -0
- package/client/lib/tinymce/langs/de.js +440 -0
- package/client/lib/tinymce/langs/el.js +440 -0
- package/client/lib/tinymce/langs/eo.js +440 -0
- package/client/lib/tinymce/langs/es-MX.js +440 -0
- package/client/lib/tinymce/langs/es.js +440 -0
- package/client/lib/tinymce/langs/et.js +440 -0
- package/client/lib/tinymce/langs/eu.js +440 -0
- package/client/lib/tinymce/langs/fa.js +441 -0
- package/client/lib/tinymce/langs/fi.js +440 -0
- package/client/lib/tinymce/langs/fr-FR.js +440 -0
- package/client/lib/tinymce/langs/gl.js +440 -0
- package/client/lib/tinymce/langs/he-IL.js +440 -0
- package/client/lib/tinymce/langs/hr.js +440 -0
- package/client/lib/tinymce/langs/hu-HU.js +440 -0
- package/client/lib/tinymce/langs/hy.js +440 -0
- package/client/lib/tinymce/langs/id.js +440 -0
- package/client/lib/tinymce/langs/index.json +63 -0
- package/client/lib/tinymce/langs/is-IS.js +440 -0
- package/client/lib/tinymce/langs/it.js +440 -0
- package/client/lib/tinymce/langs/ja.js +440 -0
- package/client/lib/tinymce/langs/ka-GE.js +440 -0
- package/client/lib/tinymce/langs/kab.js +440 -0
- package/client/lib/tinymce/langs/kk.js +440 -0
- package/client/lib/tinymce/langs/ko-KR.js +440 -0
- package/client/lib/tinymce/langs/lt.js +440 -0
- package/client/lib/tinymce/langs/lv.js +440 -0
- package/client/lib/tinymce/langs/nb-NO.js +440 -0
- package/client/lib/tinymce/langs/ne.js +440 -0
- package/client/lib/tinymce/langs/nl-BE.js +440 -0
- package/client/lib/tinymce/langs/nl.js +440 -0
- package/client/lib/tinymce/langs/oc.js +440 -0
- package/client/lib/tinymce/langs/pl.js +440 -0
- package/client/lib/tinymce/langs/pt-BR.js +440 -0
- package/client/lib/tinymce/langs/pt-PT.js +440 -0
- package/client/lib/tinymce/langs/ro.js +440 -0
- package/client/lib/tinymce/langs/ru.js +440 -0
- package/client/lib/tinymce/langs/sk.js +440 -0
- package/client/lib/tinymce/langs/sl-SI.js +440 -0
- package/client/lib/tinymce/langs/sr.js +440 -0
- package/client/lib/tinymce/langs/sv-SE.js +440 -0
- package/client/lib/tinymce/langs/ta.js +440 -0
- package/client/lib/tinymce/langs/tg.js +440 -0
- package/client/lib/tinymce/langs/th-TH.js +440 -0
- package/client/lib/tinymce/langs/tr.js +440 -0
- package/client/lib/tinymce/langs/ug.js +440 -0
- package/client/lib/tinymce/langs/uk.js +440 -0
- package/client/lib/tinymce/langs/vi.js +440 -0
- package/client/lib/tinymce/langs/zh-CN.js +440 -0
- package/client/lib/tinymce/langs/zh-HK.js +440 -0
- package/client/lib/tinymce/langs/zh-MO.js +440 -0
- package/client/lib/tinymce/langs/zh-TW.js +440 -0
- package/client/lib/tinymce-langs.js +96 -0
- package/client/lib/tinymce-langs.js.map +1 -0
- package/client/lib/tinymce-langs.ts +99 -0
- package/client/package.json +1 -1
- package/client/styles/components.css +48 -0
- package/docs/editor.md +29 -3
- package/docs/preferences.md +2 -1
- package/npmchanges.md +29 -0
- package/package.json +18 -15
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +2 -2
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store/store.d.ts +5 -0
- package/packages/mailx-store/store.d.ts.map +1 -1
- package/packages/mailx-store/store.js +21 -0
- package/packages/mailx-store/store.js.map +1 -1
- package/packages/mailx-store/store.ts +27 -0
- package/packages/mailx-types/index.d.ts +56 -0
- package/packages/mailx-types/index.d.ts.map +1 -1
- package/packages/mailx-types/index.js +187 -0
- package/packages/mailx-types/index.js.map +1 -1
- package/packages/mailx-types/index.ts +192 -0
- package/packages/mailx-types/package.json +1 -1
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-31304 → node_modules.npmglobalize-stash-48352}/.package-lock.json +0 -0
package/client/app.ts
CHANGED
|
@@ -156,6 +156,9 @@ propagateAppName();
|
|
|
156
156
|
let baseTitle = APP_NAME;
|
|
157
157
|
let lastSeenCount = 0;
|
|
158
158
|
let badgeCount = 0;
|
|
159
|
+
/** Last count pushed to the Windows taskbar overlay, so an unchanged count
|
|
160
|
+
* is not re-pushed. -1 = nothing pushed yet. See updateBadge. */
|
|
161
|
+
let lastOverlayCount = -1;
|
|
159
162
|
|
|
160
163
|
function updateBadge(count: number): void {
|
|
161
164
|
badgeCount = count;
|
|
@@ -211,7 +214,19 @@ function updateBadge(count: number): void {
|
|
|
211
214
|
// taskbar for the whole life of this feature (Bob 2026-05-28 "are you
|
|
212
215
|
// able to show the number of new messages").
|
|
213
216
|
const hostApi: any = (window as any).mailxapi;
|
|
214
|
-
|
|
217
|
+
// Push ONLY when the number actually changes. Windows keeps the
|
|
218
|
+
// overlay icon alive until the next SetOverlayIcon, so the host
|
|
219
|
+
// cannot free the icon it just handed over — each push costs the
|
|
220
|
+
// host process one HICON plus the two bitmaps inside it, and the
|
|
221
|
+
// unread-count refresh fires every few seconds. That reached the
|
|
222
|
+
// 10,000-object per-process GDI cap in about a day, after which the
|
|
223
|
+
// host logged "CreateIconIndirect failed: Not enough memory
|
|
224
|
+
// resources" on every tick and could create no further GDI objects
|
|
225
|
+
// (Bob 2026-08-23, measured: rmfmail.exe at GDI=9995). msger now
|
|
226
|
+
// frees the PREVIOUS icon so the leak is bounded either way; this
|
|
227
|
+
// just stops the pointless churn (Claude Code 2026-08-23).
|
|
228
|
+
if (hostApi?.setTaskbarOverlay && count !== lastOverlayCount) {
|
|
229
|
+
lastOverlayCount = count;
|
|
215
230
|
if (count > 0) {
|
|
216
231
|
// BADGE-ONLY overlay. SetOverlayIcon composites the supplied
|
|
217
232
|
// icon onto the bottom-right corner of the taskbar button —
|
|
@@ -5655,6 +5670,65 @@ document.getElementById("opt-compose-font-size")?.addEventListener("change", (e)
|
|
|
5655
5670
|
}).catch(() => {});
|
|
5656
5671
|
});
|
|
5657
5672
|
|
|
5673
|
+
// TinyMCE UI-language picker. The options are whatever language packs
|
|
5674
|
+
// build-tinymce.js actually copied next to the script (langs/index.json), so
|
|
5675
|
+
// the menu can never offer a pack that would 404 at editor init — TinyMCE
|
|
5676
|
+
// raises a visible error notification for a missing langs/<code>.js.
|
|
5677
|
+
//
|
|
5678
|
+
// These translate the editor CHROME only. Content, the spellcheck dictionary
|
|
5679
|
+
// and Harper are untouched and stay English, which is why the control sits
|
|
5680
|
+
// under Editor rather than anywhere that reads as a global language setting.
|
|
5681
|
+
//
|
|
5682
|
+
// TinyMCE itself has no detection — "Auto" is ours, resolving the browser's
|
|
5683
|
+
// ordered language list against the shipped packs. The Auto row names what it
|
|
5684
|
+
// currently resolves to so the choice is not a guess. (Claude Code 2026-08-23)
|
|
5685
|
+
const optEditorLanguage = document.getElementById("opt-editor-language") as HTMLSelectElement;
|
|
5686
|
+
if (optEditorLanguage) {
|
|
5687
|
+
(async () => {
|
|
5688
|
+
const scriptUrl = localStorage.getItem("mailx-tinymce-cdn") || "lib/tinymce/tinymce.min.js";
|
|
5689
|
+
const { availableLanguages, resolveLanguage, languageLabel, LANGUAGE_AUTO, LANGUAGE_ENGLISH } =
|
|
5690
|
+
await import("./lib/tinymce-langs.js");
|
|
5691
|
+
const available = await availableLanguages(scriptUrl);
|
|
5692
|
+
const autoPick = resolveLanguage(LANGUAGE_AUTO, available);
|
|
5693
|
+
const add = (value: string, text: string) => {
|
|
5694
|
+
const o = document.createElement("option");
|
|
5695
|
+
o.value = value;
|
|
5696
|
+
o.textContent = text;
|
|
5697
|
+
optEditorLanguage.appendChild(o);
|
|
5698
|
+
};
|
|
5699
|
+
add(LANGUAGE_AUTO, `Auto — ${languageLabel(autoPick)}`);
|
|
5700
|
+
add(LANGUAGE_ENGLISH, languageLabel(LANGUAGE_ENGLISH));
|
|
5701
|
+
for (const code of [...available].sort((a, b) => languageLabel(a).localeCompare(languageLabel(b)))) {
|
|
5702
|
+
add(code, `${languageLabel(code)} (${code})`);
|
|
5703
|
+
}
|
|
5704
|
+
if (!available.length) {
|
|
5705
|
+
optEditorLanguage.disabled = true;
|
|
5706
|
+
optEditorLanguage.title = "No language packs found next to tinymce.min.js — English only";
|
|
5707
|
+
}
|
|
5708
|
+
let current = LANGUAGE_AUTO;
|
|
5709
|
+
try { current = localStorage.getItem("mailx-editor-language") || LANGUAGE_AUTO; } catch { /* */ }
|
|
5710
|
+
try {
|
|
5711
|
+
const settings: any = await getSettings();
|
|
5712
|
+
if (typeof settings?.ui?.editorLanguage === "string" && settings.ui.editorLanguage) current = settings.ui.editorLanguage;
|
|
5713
|
+
} catch { /* cache value stands */ }
|
|
5714
|
+
optEditorLanguage.value = current;
|
|
5715
|
+
// A pinned pack that is no longer shipped would leave the select on
|
|
5716
|
+
// nothing at all; say so rather than showing a blank control.
|
|
5717
|
+
if (!optEditorLanguage.value) optEditorLanguage.value = LANGUAGE_AUTO;
|
|
5718
|
+
})().catch((e: any) => console.error("[settings] editor-language menu failed:", e?.message || e));
|
|
5719
|
+
|
|
5720
|
+
optEditorLanguage.addEventListener("change", () => {
|
|
5721
|
+
const value = optEditorLanguage.value;
|
|
5722
|
+
// Synchronous cache write for the same reason as saveEditorSetting:
|
|
5723
|
+
// compose reads it at module load, so the next compose-open is right.
|
|
5724
|
+
try { localStorage.setItem("mailx-editor-language", value); } catch { /* private mode */ }
|
|
5725
|
+
getSettings().then((settings: any) => {
|
|
5726
|
+
settings.ui = { ...settings.ui, editorLanguage: value };
|
|
5727
|
+
saveSettings(settings);
|
|
5728
|
+
}).catch((e: any) => console.error("[settings] editor language save failed:", e?.message || e));
|
|
5729
|
+
});
|
|
5730
|
+
}
|
|
5731
|
+
|
|
5658
5732
|
// Save editor choice to server settings
|
|
5659
5733
|
function saveEditorSetting(editor: string): void {
|
|
5660
5734
|
// Update the localStorage cache SYNCHRONOUSLY. compose.ts reads
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { inviteHasGoogleRsvp } from "@bobfrankston/mailx-types";
|
|
2
|
+
const PARTSTAT_LABEL = {
|
|
3
|
+
"ACCEPTED": "accepted",
|
|
4
|
+
"DECLINED": "declined",
|
|
5
|
+
"TENTATIVE": "maybe",
|
|
6
|
+
"NEEDS-ACTION": "no reply yet",
|
|
7
|
+
"DELEGATED": "delegated",
|
|
8
|
+
};
|
|
9
|
+
/** Format the event's span. An invite states wall-clock plus a zone; showing
|
|
10
|
+
* it in the reader's zone is the useful reading, but when the two differ the
|
|
11
|
+
* original is shown too — a 08:00 America/Los_Angeles meeting read as 11:00
|
|
12
|
+
* local is exactly where people mis-book. */
|
|
13
|
+
function formatWhen(inv) {
|
|
14
|
+
if (!inv.start)
|
|
15
|
+
return "";
|
|
16
|
+
if (inv.allDay) {
|
|
17
|
+
try {
|
|
18
|
+
const d = new Date(inv.start + "T00:00:00");
|
|
19
|
+
return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }) + " (all day)";
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return inv.start + " (all day)";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// A zoned wall-clock has no offset, so Date would read it as LOCAL time.
|
|
26
|
+
// Resolve it through the stated zone to get the real instant.
|
|
27
|
+
const instant = (() => {
|
|
28
|
+
if (/Z$/.test(inv.start))
|
|
29
|
+
return new Date(inv.start);
|
|
30
|
+
if (!inv.tzid)
|
|
31
|
+
return new Date(inv.start);
|
|
32
|
+
try {
|
|
33
|
+
// Probe: what does this wall-clock read as in the stated zone?
|
|
34
|
+
const guess = new Date(inv.start + "Z");
|
|
35
|
+
const asZone = new Date(guess.toLocaleString("en-US", { timeZone: inv.tzid }));
|
|
36
|
+
const asUtc = new Date(guess.toLocaleString("en-US", { timeZone: "UTC" }));
|
|
37
|
+
return new Date(guess.getTime() + (asUtc.getTime() - asZone.getTime()));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return new Date(inv.start);
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
43
|
+
if (isNaN(instant.getTime()))
|
|
44
|
+
return inv.start;
|
|
45
|
+
const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
46
|
+
const opts = {
|
|
47
|
+
weekday: "short", month: "short", day: "numeric",
|
|
48
|
+
hour: "numeric", minute: "2-digit",
|
|
49
|
+
};
|
|
50
|
+
let out = instant.toLocaleString(undefined, opts);
|
|
51
|
+
if (inv.end && !/Z$/.test(inv.end)) {
|
|
52
|
+
try {
|
|
53
|
+
const endInstant = new Date(instant.getTime() + (new Date(inv.end + "Z").getTime() - new Date(inv.start + "Z").getTime()));
|
|
54
|
+
out += " – " + endInstant.toLocaleString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
55
|
+
}
|
|
56
|
+
catch { /* single-point event */ }
|
|
57
|
+
}
|
|
58
|
+
if (inv.tzid && inv.tzid !== localZone) {
|
|
59
|
+
out += ` (sent as ${inv.start.slice(11, 16)} ${inv.tzid})`;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
const esc = (s) => (s || "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
64
|
+
/** Build the card, or null when there is nothing worth showing. */
|
|
65
|
+
export function buildInviteCard(inv, bodyHtml) {
|
|
66
|
+
if (!inv || (!inv.summary && !inv.start))
|
|
67
|
+
return null;
|
|
68
|
+
const card = document.createElement("div");
|
|
69
|
+
card.className = "mv-invite";
|
|
70
|
+
if (inv.method === "CANCEL")
|
|
71
|
+
card.classList.add("mv-invite-cancelled");
|
|
72
|
+
const when = formatWhen(inv);
|
|
73
|
+
const isMeetLink = /^https?:/; //i.test(inv.location);
|
|
74
|
+
const googleHandles = inv.fromGoogle && inviteHasGoogleRsvp(bodyHtml);
|
|
75
|
+
const rows = [];
|
|
76
|
+
if (when)
|
|
77
|
+
rows.push(`<div class="mv-invite-when">${esc(when)}</div>`);
|
|
78
|
+
if (inv.location) {
|
|
79
|
+
rows.push(isMeetLink
|
|
80
|
+
? `<div class="mv-invite-row"><span class="mv-invite-label">Where</span><a href="${esc(inv.location)}" target="_blank" rel="noopener noreferrer">${esc(inv.location)}</a></div>`
|
|
81
|
+
: `<div class="mv-invite-row"><span class="mv-invite-label">Where</span>${esc(inv.location)}</div>`);
|
|
82
|
+
}
|
|
83
|
+
if (inv.organizer.email) {
|
|
84
|
+
rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Organizer</span>${esc(inv.organizer.name)}${inv.organizer.name !== inv.organizer.email ? " <" + esc(inv.organizer.email) + ">" : ""}</div>`);
|
|
85
|
+
}
|
|
86
|
+
if (inv.attendees.length) {
|
|
87
|
+
const who = inv.attendees
|
|
88
|
+
.map(a => `${esc(a.name || a.email)} <span class="mv-invite-partstat">(${esc(PARTSTAT_LABEL[a.status] || a.status.toLowerCase())})</span>`)
|
|
89
|
+
.join(", ");
|
|
90
|
+
rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Guests</span>${who}</div>`);
|
|
91
|
+
}
|
|
92
|
+
if (inv.rrule)
|
|
93
|
+
rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Repeats</span><code>${esc(inv.rrule)}</code></div>`);
|
|
94
|
+
const badge = inv.method === "CANCEL" ? "Cancelled"
|
|
95
|
+
: inv.method === "REPLY" ? "Reply"
|
|
96
|
+
: inv.status === "TENTATIVE" ? "Tentative"
|
|
97
|
+
: "Invitation";
|
|
98
|
+
card.innerHTML =
|
|
99
|
+
`<div class="mv-invite-head"><span class="mv-invite-badge">${esc(badge)}</span>` +
|
|
100
|
+
`<span class="mv-invite-summary">${esc(inv.summary || "(no title)")}</span></div>` +
|
|
101
|
+
rows.join("") +
|
|
102
|
+
(googleHandles
|
|
103
|
+
? `<div class="mv-invite-note">Respond with the Yes / No / Maybe buttons in the message below — they go straight to Google Calendar.</div>`
|
|
104
|
+
: "");
|
|
105
|
+
return card;
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=invite-card.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invite-card.js","sourceRoot":"","sources":["invite-card.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar-invite card.
|
|
3
|
+
*
|
|
4
|
+
* A message carrying a text/calendar part gets a summary rendered ABOVE the
|
|
5
|
+
* body — time in your zone, location, organizer, who has replied — instead of
|
|
6
|
+
* mailx handing invite.ics to whatever program happens to own .ics on this
|
|
7
|
+
* machine (Bob 2026-08-22 saw a bare console window and asked "is this RMF
|
|
8
|
+
* Mail?"; it was not, and the fact that it could have been is the problem).
|
|
9
|
+
*
|
|
10
|
+
* RSVP is deliberately NOT duplicated for Google invitations. Google's own
|
|
11
|
+
* HTML body carries working Yes / No / Maybe links
|
|
12
|
+
* (calendar.google.com/calendar/event?action=RESPOND&rst=1|2|3), and two
|
|
13
|
+
* responders racing to set PARTSTAT is worse than one. For those we show the
|
|
14
|
+
* summary and point at the buttons already in the message.
|
|
15
|
+
*/
|
|
16
|
+
import type { ParsedInvite } from "@bobfrankston/mailx-types";
|
|
17
|
+
import { inviteHasGoogleRsvp } from "@bobfrankston/mailx-types";
|
|
18
|
+
|
|
19
|
+
const PARTSTAT_LABEL: Record<string, string> = {
|
|
20
|
+
"ACCEPTED": "accepted",
|
|
21
|
+
"DECLINED": "declined",
|
|
22
|
+
"TENTATIVE": "maybe",
|
|
23
|
+
"NEEDS-ACTION": "no reply yet",
|
|
24
|
+
"DELEGATED": "delegated",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Format the event's span. An invite states wall-clock plus a zone; showing
|
|
28
|
+
* it in the reader's zone is the useful reading, but when the two differ the
|
|
29
|
+
* original is shown too — a 08:00 America/Los_Angeles meeting read as 11:00
|
|
30
|
+
* local is exactly where people mis-book. */
|
|
31
|
+
function formatWhen(inv: ParsedInvite): string {
|
|
32
|
+
if (!inv.start) return "";
|
|
33
|
+
if (inv.allDay) {
|
|
34
|
+
try {
|
|
35
|
+
const d = new Date(inv.start + "T00:00:00");
|
|
36
|
+
return d.toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" }) + " (all day)";
|
|
37
|
+
} catch { return inv.start + " (all day)"; }
|
|
38
|
+
}
|
|
39
|
+
// A zoned wall-clock has no offset, so Date would read it as LOCAL time.
|
|
40
|
+
// Resolve it through the stated zone to get the real instant.
|
|
41
|
+
const instant = (() => {
|
|
42
|
+
if (/Z$/.test(inv.start)) return new Date(inv.start);
|
|
43
|
+
if (!inv.tzid) return new Date(inv.start);
|
|
44
|
+
try {
|
|
45
|
+
// Probe: what does this wall-clock read as in the stated zone?
|
|
46
|
+
const guess = new Date(inv.start + "Z");
|
|
47
|
+
const asZone = new Date(guess.toLocaleString("en-US", { timeZone: inv.tzid }));
|
|
48
|
+
const asUtc = new Date(guess.toLocaleString("en-US", { timeZone: "UTC" }));
|
|
49
|
+
return new Date(guess.getTime() + (asUtc.getTime() - asZone.getTime()));
|
|
50
|
+
} catch { return new Date(inv.start); }
|
|
51
|
+
})();
|
|
52
|
+
if (isNaN(instant.getTime())) return inv.start;
|
|
53
|
+
|
|
54
|
+
const localZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
55
|
+
const opts: Intl.DateTimeFormatOptions = {
|
|
56
|
+
weekday: "short", month: "short", day: "numeric",
|
|
57
|
+
hour: "numeric", minute: "2-digit",
|
|
58
|
+
};
|
|
59
|
+
let out = instant.toLocaleString(undefined, opts);
|
|
60
|
+
if (inv.end && !/Z$/.test(inv.end)) {
|
|
61
|
+
try {
|
|
62
|
+
const endInstant = new Date(instant.getTime() + (new Date(inv.end + "Z").getTime() - new Date(inv.start + "Z").getTime()));
|
|
63
|
+
out += " – " + endInstant.toLocaleString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
64
|
+
} catch { /* single-point event */ }
|
|
65
|
+
}
|
|
66
|
+
if (inv.tzid && inv.tzid !== localZone) {
|
|
67
|
+
out += ` (sent as ${inv.start.slice(11, 16)} ${inv.tzid})`;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const esc = (s: string): string =>
|
|
73
|
+
(s || "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!));
|
|
74
|
+
|
|
75
|
+
/** Build the card, or null when there is nothing worth showing. */
|
|
76
|
+
export function buildInviteCard(inv: ParsedInvite | null | undefined, bodyHtml: string): HTMLElement | null {
|
|
77
|
+
if (!inv || (!inv.summary && !inv.start)) return null;
|
|
78
|
+
|
|
79
|
+
const card = document.createElement("div");
|
|
80
|
+
card.className = "mv-invite";
|
|
81
|
+
if (inv.method === "CANCEL") card.classList.add("mv-invite-cancelled");
|
|
82
|
+
|
|
83
|
+
const when = formatWhen(inv);
|
|
84
|
+
const isMeetLink = /^https?:///i.test(inv.location);
|
|
85
|
+
const googleHandles = inv.fromGoogle && inviteHasGoogleRsvp(bodyHtml);
|
|
86
|
+
|
|
87
|
+
const rows: string[] = [];
|
|
88
|
+
if (when) rows.push(`<div class="mv-invite-when">${esc(when)}</div>`);
|
|
89
|
+
if (inv.location) {
|
|
90
|
+
rows.push(isMeetLink
|
|
91
|
+
? `<div class="mv-invite-row"><span class="mv-invite-label">Where</span><a href="${esc(inv.location)}" target="_blank" rel="noopener noreferrer">${esc(inv.location)}</a></div>`
|
|
92
|
+
: `<div class="mv-invite-row"><span class="mv-invite-label">Where</span>${esc(inv.location)}</div>`);
|
|
93
|
+
}
|
|
94
|
+
if (inv.organizer.email) {
|
|
95
|
+
rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Organizer</span>${esc(inv.organizer.name)}${inv.organizer.name !== inv.organizer.email ? " <" + esc(inv.organizer.email) + ">" : ""}</div>`);
|
|
96
|
+
}
|
|
97
|
+
if (inv.attendees.length) {
|
|
98
|
+
const who = inv.attendees
|
|
99
|
+
.map(a => `${esc(a.name || a.email)} <span class="mv-invite-partstat">(${esc(PARTSTAT_LABEL[a.status] || a.status.toLowerCase())})</span>`)
|
|
100
|
+
.join(", ");
|
|
101
|
+
rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Guests</span>${who}</div>`);
|
|
102
|
+
}
|
|
103
|
+
if (inv.rrule) rows.push(`<div class="mv-invite-row"><span class="mv-invite-label">Repeats</span><code>${esc(inv.rrule)}</code></div>`);
|
|
104
|
+
|
|
105
|
+
const badge = inv.method === "CANCEL" ? "Cancelled"
|
|
106
|
+
: inv.method === "REPLY" ? "Reply"
|
|
107
|
+
: inv.status === "TENTATIVE" ? "Tentative"
|
|
108
|
+
: "Invitation";
|
|
109
|
+
|
|
110
|
+
card.innerHTML =
|
|
111
|
+
`<div class="mv-invite-head"><span class="mv-invite-badge">${esc(badge)}</span>` +
|
|
112
|
+
`<span class="mv-invite-summary">${esc(inv.summary || "(no title)")}</span></div>` +
|
|
113
|
+
rows.join("") +
|
|
114
|
+
(googleHandles
|
|
115
|
+
? `<div class="mv-invite-note">Respond with the Yes / No / Maybe buttons in the message below — they go straight to Google Calendar.</div>`
|
|
116
|
+
: "");
|
|
117
|
+
|
|
118
|
+
return card;
|
|
119
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, openAttachment, getMessageSource, addContact, listContacts, upsertContact, unsubscribeOneClick, addPreferredContact, onEvent, subscribeStore } from "../lib/api-client.js";
|
|
12
12
|
import { isSenderApproved, noteApprovedLocally, refreshApprovedSenders } from "../lib/approved-senders.js";
|
|
13
|
+
import { buildInviteCard } from "./invite-card.js";
|
|
13
14
|
import { showContextMenu } from "./context-menu.js";
|
|
14
15
|
import { updateMessageFlags as stateUpdateFlags } from "../lib/message-state.js";
|
|
15
16
|
import { setRowSeen } from "./message-list.js";
|
|
@@ -788,23 +789,27 @@ function installPreviewControls(iframe) {
|
|
|
788
789
|
// the iframe selects exactly this document.
|
|
789
790
|
if (e.ctrlKey && !e.altKey && !e.metaKey && (e.key === "a" || e.key === "A"))
|
|
790
791
|
return;
|
|
791
|
-
//
|
|
792
|
-
//
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
//
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
792
|
+
// Everything else is FORWARDED BY THE IFRAME ITSELF — the inline
|
|
793
|
+
// script wrapHtmlBody installs posts every keydown to the parent
|
|
794
|
+
// as `previewKey`, and app.ts re-dispatches it on document. This
|
|
795
|
+
// listener must NOT forward as well.
|
|
796
|
+
//
|
|
797
|
+
// It used to, and both routes are live on desktop WebView2 (the
|
|
798
|
+
// srcdoc document is same-origin, so this contentDocument listener
|
|
799
|
+
// attaches, and the inline script runs too). One Ctrl+F in the
|
|
800
|
+
// preview therefore reached app.ts TWICE and opened TWO compose
|
|
801
|
+
// windows — one of which never finished loading and sat blank and
|
|
802
|
+
// "(Not Responding)" until it was killed (Bob 2026-08-23,
|
|
803
|
+
// "Compose: Fwd: FirstCare Slide Deck (Not Responding)"; daemon
|
|
804
|
+
// log shows openCompose-entry mode=forward twice in the same
|
|
805
|
+
// millisecond and two popoutCompose windows). Ctrl+D / Ctrl+P
|
|
806
|
+
// doubled the same way.
|
|
807
|
+
//
|
|
808
|
+
// The inline forwarder is the one that survives, because it is the
|
|
809
|
+
// only one that works on Android WebView, where parent-side
|
|
810
|
+
// contentDocument listeners never fire. Zoom and Ctrl+A stay here:
|
|
811
|
+
// they act on THIS document and the inline script deliberately
|
|
812
|
+
// skips them. (Claude Code 2026-08-23)
|
|
808
813
|
});
|
|
809
814
|
doc.addEventListener("wheel", (e) => {
|
|
810
815
|
if (!e.ctrlKey)
|
|
@@ -864,6 +869,10 @@ export function clearViewer() {
|
|
|
864
869
|
headerEl.hidden = true;
|
|
865
870
|
if (attEl)
|
|
866
871
|
attEl.hidden = true;
|
|
872
|
+
// The invite card is a SIBLING of the body (it sits after the header), so
|
|
873
|
+
// clearing bodyEl leaves it behind — a stale meeting hovering over "Select
|
|
874
|
+
// a message to read" is exactly the viewer-must-clear failure.
|
|
875
|
+
document.getElementById("mv-invite")?.remove();
|
|
867
876
|
// Divergence watchdog: highlight and viewer are supposed to be one
|
|
868
877
|
// thing. If a second after a clear some row still shows .selected while
|
|
869
878
|
// the pane shows the placeholder, that's the "message selected but it's
|
|
@@ -1342,6 +1351,22 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
1342
1351
|
fromEl.textContent = formatAddr(msg.from);
|
|
1343
1352
|
setRecipientLine(toEl, msg.to, msg.cc, msg.deliveredTo);
|
|
1344
1353
|
headerEl.querySelector(".mv-subject").textContent = msg.subject;
|
|
1354
|
+
// Calendar invite card. Lives BETWEEN the header and the body as its
|
|
1355
|
+
// own element, not inside .mv-body — the body's innerHTML is replaced
|
|
1356
|
+
// wholesale on every render, which would take the card with it.
|
|
1357
|
+
// Removed and rebuilt per message so a non-invite never inherits the
|
|
1358
|
+
// previous one (the "viewer must clear" rule).
|
|
1359
|
+
document.getElementById("mv-invite")?.remove();
|
|
1360
|
+
try {
|
|
1361
|
+
const card = buildInviteCard(msg.invite, String(msg.bodyHtml || ""));
|
|
1362
|
+
if (card) {
|
|
1363
|
+
card.id = "mv-invite";
|
|
1364
|
+
headerEl.insertAdjacentElement("afterend", card);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
catch (e) {
|
|
1368
|
+
console.warn("[viewer] invite card failed:", e?.message || e);
|
|
1369
|
+
}
|
|
1345
1370
|
document.dispatchEvent(new CustomEvent("mailx-message-shown", { detail: { accountId } }));
|
|
1346
1371
|
// Right-click on email addresses in header: copy name, copy address,
|
|
1347
1372
|
// copy both, add to contacts, plus reply actions for the whole message.
|