Skip to main content

max / goingson

Confirm a discard in the compose window without a native dialog compose-page.js called window.confirm, the one violation of the no-native-dialogs rule left in the tree. The rule points at showConfirmDialog, but the compose window could not reach it: it loads escape.js and no utils.js, and components-modal.js read the escapers off GoingsOn.utils at load time. It now reads them from GoingsOn.escape, the single source utils.js re-exports, so the module loads in either window. The compose window gets the same modal overlay markup and loads the module. Escape and overlay clicks are already handled inside it; the close button binds explicitly here because this window has no data-act dispatcher. A re-entry flag guards the discard path, since Escape reaches both the module's handler and this window's own Escape-to-discard, which would otherwise close the confirmation and immediately reopen it. Fixes a hang in both dialogs while wiring this. showConfirmDialog and showPromptDialog only settled from their own buttons, so dismissing one with Escape, an overlay click, or the close button left the promise pending forever and the awaiting caller never resumed. closeModal now runs a one-shot dismiss handler that resolves false or null, and openModal runs it for a dialog superseded without closing. The button paths clear the handler before closing, or its resolve would win the race and invert the answer.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 18:42 UTC
Signed with PGP, not checked
Commit: 2323ad994651ccacca506ab49dc073688d469543
Parent: 8b0bd23
3 files changed, +90 insertions, -21 deletions
@@ -26,7 +26,21 @@
26 26 <div class="compose-attachments hidden" id="attachments-bar"></div>
27 27 <div class="status-bar" id="status-bar">Ready</div>
28 28
29 + <!-- Same overlay the main window uses, so components-modal.js drives the
30 + discard confirmation here instead of a native dialog. The close button
31 + binds in compose-page.js: this window has no data-act dispatcher. -->
32 + <div id="modal-overlay" class="modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
33 + <div id="modal-container" class="modal-container">
34 + <div class="modal-header">
35 + <h2 id="modal-title">Modal Title</h2>
36 + <button class="modal-close" id="modal-close-btn" aria-label="Close modal">&times;</button>
37 + </div>
38 + <div id="modal-content" class="modal-content"></div>
39 + </div>
40 + </div>
41 +
29 42 <script src="js/escape.js"></script>
43 + <script src="js/components-modal.js"></script>
30 44 <script src="js/address-highlight.js"></script>
31 45 <script src="js/compose-form.js"></script>
32 46 <script src="js/compose-page.js"></script>
@@ -5,8 +5,11 @@
5 5
6 6 (function() {
7 7 'use strict';
8 - const escAttr = GoingsOn.utils.escapeAttrValue;
9 - const esc = GoingsOn.utils.escapeHtml;
8 + // Straight from the escape.js seal rather than the utils.js re-export, so this
9 + // module loads in the standalone compose window (escape.js, no utils.js) as
10 + // well as the main app. Same functions either way.
11 + const escAttr = GoingsOn.escape.escapeAttrValue;
12 + const esc = GoingsOn.escape.escapeHtml;
10 13
11 14 // Modal
12 15
@@ -18,6 +21,12 @@
18 21 * @param {boolean} [options.large] - Use large modal size (nearly full screen)
19 22 */
20 23 function openModal(title, content, options = {}) {
24 + // A modal that replaces another without closing it settles the outgoing
25 + // dialog as dismissed rather than leaving its promise pending.
26 + const superseded = onModalDismiss;
27 + onModalDismiss = null;
28 + if (superseded) superseded();
29 +
21 30 const overlay = document.getElementById('modal-overlay');
22 31 const container = overlay.querySelector('.modal-container');
23 32 const titleEl = document.getElementById('modal-title');
@@ -47,6 +56,22 @@
47 56 trapFocus(overlay);
48 57 }
49 58
59 + /**
60 + * Called once when the open modal closes by any path, then cleared. Lets an
61 + * awaited dialog settle its promise when the user dismisses it with Escape, an
62 + * overlay click, or the close button instead of one of its own buttons; without
63 + * it those promises stay pending forever and the caller never resumes.
64 + */
65 + let onModalDismiss = null;
66 +
67 + /**
68 + * Registers a dismissal callback for the currently open modal.
69 + * @param {Function|null} fn - Called with no arguments when the modal closes
70 + */
71 + function setModalDismissHandler(fn) {
72 + onModalDismiss = fn;
73 + }
74 +
50 75 /**
51 76 * Close the modal dialog.
52 77 */
@@ -56,6 +81,11 @@
56 81 overlay.classList.add('hidden');
57 82 overlay.setAttribute('aria-hidden', 'true');
58 83 releaseFocusTrap();
84 +
85 + // Cleared before the call so a handler that reopens a modal keeps its own.
86 + const dismiss = onModalDismiss;
87 + onModalDismiss = null;
88 + if (dismiss) dismiss();
59 89 }
60 90
61 91 // Focus trap for modal accessibility
@@ -342,25 +372,24 @@
342 372 `;
343 373
344 374 openModal(title, content);
375 + // Dismissing without answering is a decline.
376 + setModalDismissHandler(() => resolve(false));
345 377
346 378 // Attach event handlers after modal is opened
347 379 setTimeout(() => {
348 380 const confirmBtn = document.getElementById('confirm-dialog-confirm');
349 381 const cancelBtn = document.getElementById('confirm-dialog-cancel');
350 382
351 - if (confirmBtn) {
352 - confirmBtn.onclick = () => {
353 - closeModal();
354 - resolve(true);
355 - };
356 - }
383 + // Answering clears the dismiss handler first: closeModal() runs it
384 + // otherwise, and its resolve would win the race against this one.
385 + const answer = (value) => {
386 + setModalDismissHandler(null);
387 + closeModal();
388 + resolve(value);
389 + };
357 390
358 - if (cancelBtn) {
359 - cancelBtn.onclick = () => {
360 - closeModal();
361 - resolve(false);
362 - };
363 - }
391 + if (confirmBtn) confirmBtn.onclick = () => answer(true);
392 + if (cancelBtn) cancelBtn.onclick = () => answer(false);
364 393 }, 50);
365 394 });
366 395 }
@@ -419,6 +448,8 @@
419 448 `;
420 449
421 450 openModal(title, content);
451 + // Dismissing without answering is a cancel.
452 + setModalDismissHandler(() => resolve(null));
422 453
423 454 setTimeout(() => {
424 455 const input = document.getElementById(inputId);
@@ -426,6 +457,14 @@
426 457 const confirmBtn = document.getElementById('prompt-dialog-confirm');
427 458 const cancelBtn = document.getElementById('prompt-dialog-cancel');
428 459
460 + // Answering clears the dismiss handler first: closeModal() runs it
461 + // otherwise, and its resolve would win the race against this one.
462 + const answer = (value) => {
463 + setModalDismissHandler(null);
464 + closeModal();
465 + resolve(value);
466 + };
467 +
429 468 const submit = () => {
430 469 const value = (input?.value || '').trim();
431 470 if (validate) {
@@ -438,12 +477,11 @@
438 477 return;
439 478 }
440 479 }
441 - closeModal();
442 - resolve(value);
480 + answer(value);
443 481 };
444 482
445 483 if (confirmBtn) confirmBtn.onclick = submit;
446 - if (cancelBtn) cancelBtn.onclick = () => { closeModal(); resolve(null); };
484 + if (cancelBtn) cancelBtn.onclick = () => answer(null);
447 485 if (input) {
448 486 input.addEventListener('keydown', (e) => {
449 487 if (e.key === 'Enter') { e.preventDefault(); submit(); }
@@ -118,15 +118,28 @@
118 118 if (composeCtrl) composeCtrl.saveDraftNow();
119 119 }
120 120
121 - function discardAndClose() {
121 + // Guards the discard confirmation against re-entry. Escape reaches both
122 + // components-modal.js (which closes the dialog) and this window's own handler
123 + // below, so without the flag dismissing the confirm would immediately open a
124 + // second one.
125 + let discardConfirmOpen = false;
126 +
127 + async function discardAndClose() {
128 + if (discardConfirmOpen) return;
129 +
122 130 const body = document.getElementById('body').value;
123 131 const subject = document.getElementById('subject').value;
124 132 const to = document.getElementById('to-address').value;
125 133
126 134 if (body || subject || to) {
127 - if (!confirm('Discard this message?')) {
128 - return;
129 - }
135 + discardConfirmOpen = true;
136 + const discard = await GoingsOn.modal.showConfirmDialog(
137 + 'Discard Message',
138 + 'Discard this message? It will not be saved as a draft.',
139 + { confirmText: 'Discard', cancelText: 'Keep Editing', danger: true }
140 + );
141 + discardConfirmOpen = false;
142 + if (!discard) return;
130 143 }
131 144
132 145 window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
@@ -183,6 +196,10 @@
183 196 document.getElementById('send-btn').addEventListener('click', sendEmail);
184 197 document.getElementById('save-draft-btn').addEventListener('click', saveDraft);
185 198 document.getElementById('discard-btn').addEventListener('click', discardAndClose);
199 + // The main window closes modals through its data-act dispatcher, which this
200 + // window does not load; Escape and overlay clicks are already handled inside
201 + // components-modal.js.
202 + document.getElementById('modal-close-btn').addEventListener('click', GoingsOn.modal.closeModal);
186 203 form.addEventListener('submit', (e) => e.preventDefault());
187 204
188 205 await initTauri();