Skip to main content

max / goingson

Compose unification stages 3-5 + snoozed-events filter Stage 3 — shared HTML template. composeForm.buildFieldsHtml(opts) emits the canonical From/To/CC/BCC/Subject/Body/Attachments markup using compose.html's pre-existing IDs. Modal switched from openFormModal to GoingsOn.modal.openModal + shared HTML; CC/BCC now sit after To. Field- row, autocomplete-wrapper, body-textarea, toggle-cc-btn, and attachment CSS promoted from compose.html's inline <style> to css/styles.css. Stage 4 — shared behavior bindings. composeForm.bindBehaviors(opts) owns autocomplete (self-contained), address-highlight (auto-detects helper), CC/BCC toggle, signature swap on From change, and attachment picker / render / remove (event delegation, no inline onclick). Both surfaces become layout-only shells. compose.html drops ~250 lines of inline binders; emails.js drops its modal attachment helpers. Stage 5 — desktop-only features promoted to shared opts. bindBehaviors now accepts enableAutosave/saveDraft/getReplyContext/onDraftStatus/ enableReplyIndicator/initialDraftId. Owns debounced 2s draft autosave, send-pause via setSending, and reply indicator + #send-btn relabel. The modal opts in: gains a Save Draft footer button + reply indicator, consumes pf.draftId for draft continuation, and the touch-side openDraft now passes cc/bcc on restore. Dead showSaveContactPrompt + .save-contact- bar removed (implicit-contact prompts already moved to the main-app shell). .reply-indicator promoted to css/styles.css. Snoozed-events filter. Events list gets a Show Snoozed checkbox parallel to the tasks filter-snoozed toggle. events.load() merges list_snoozed_events into the main list when checked, de-duped by id against the recurring-expansion set. Closes the Tier 5 follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-21 06:29 UTC
Commit: 0daf7b3efdd4397dcdef0abcd9ef51b3fe660d31
Parent: b524e19
6 files changed, +779 insertions, -595 deletions
@@ -34,80 +34,9 @@
34 34 background: var(--bg-card);
35 35 }
36 36
37 - .header-row {
38 - display: flex;
39 - align-items: center;
40 - padding: 0.75rem 1rem;
41 - border-bottom: 1px solid var(--border-color);
42 - gap: 0.5rem;
43 - }
44 -
45 - .header-label {
46 - width: 80px;
47 - color: var(--text-secondary);
48 - font-size: 0.875rem;
49 - flex-shrink: 0;
50 - }
51 -
52 - .header-input {
53 - flex: 1;
54 - background: transparent;
55 - border: none;
56 - color: var(--text-primary);
57 - font-size: 0.875rem;
58 - padding: 0.5rem;
59 - outline: none;
60 - }
61 -
62 - .header-input::placeholder {
63 - color: var(--text-muted);
64 - }
65 -
66 - .autocomplete-wrapper {
67 - background: transparent;
68 - border-radius: var(--radius-sm);
69 - }
70 - .autocomplete-wrapper:focus-within {
71 - background: var(--bg-secondary);
72 - }
73 - .header-input:focus {
74 - background: transparent;
75 - }
76 -
77 - .header-select {
78 - flex: 1;
79 - background: var(--bg-primary);
80 - border: 1px solid var(--border-color);
81 - color: var(--text-primary);
82 - font-size: 0.875rem;
83 - padding: 0.5rem;
84 - border-radius: var(--radius-sm);
85 - outline: none;
86 - }
87 -
88 - .body-container {
89 - flex: 1;
90 - display: flex;
91 - flex-direction: column;
92 - overflow: hidden;
93 - }
94 -
95 - .body-textarea {
96 - flex: 1;
97 - background: var(--bg-card);
98 - border: none;
99 - color: var(--text-primary);
100 - font-size: 0.9375rem;
101 - font-family: inherit;
102 - padding: 1rem;
103 - resize: none;
104 - outline: none;
105 - line-height: 1.6;
106 - }
107 -
108 - .body-textarea::placeholder {
109 - color: var(--text-muted);
110 - }
37 + /* Field-row, autocomplete-wrapper, body-container/textarea, and
38 + toggle-cc-btn styles moved to css/styles.css as part of stage 3 —
39 + shared with the in-app compose modal. */
111 40
112 41 .status-bar {
113 42 padding: 0.5rem 1rem;
@@ -130,147 +59,10 @@
130 59 pointer-events: none;
131 60 }
132 61
133 - /* Attachment list in compose */
134 - .compose-attachments {
135 - padding: 0.5rem 1rem;
136 - border-top: 1px solid var(--border-color);
137 - background: var(--bg-secondary);
138 - font-size: 0.8125rem;
139 - }
140 -
141 - .compose-attachment-item {
142 - display: flex;
143 - align-items: center;
144 - gap: 0.5rem;
145 - padding: 0.25rem 0;
146 - }
147 -
148 - .compose-attachment-name {
149 - flex: 1;
150 - overflow: hidden;
151 - text-overflow: ellipsis;
152 - white-space: nowrap;
153 - }
154 -
155 - .compose-attachment-size {
156 - color: var(--text-muted);
157 - flex-shrink: 0;
158 - }
159 -
160 - .compose-attachment-remove {
161 - background: none;
162 - border: none;
163 - color: var(--accent-red);
164 - cursor: pointer;
165 - font-size: 1rem;
166 - padding: 0 0.25rem;
167 - }
168 -
169 - .compose-attachment-total {
170 - display: flex;
171 - gap: 0.5rem;
172 - align-items: baseline;
173 - padding-top: 0.5rem;
174 - margin-top: 0.25rem;
175 - font-size: 0.8125rem;
176 - color: var(--text-muted);
177 - border-top: 1px dashed var(--border-color);
178 - }
179 - .compose-attachment-total.over-warn { color: var(--accent-yellow); }
180 - .compose-attachment-total.over-cap { color: var(--accent-red); }
181 - .compose-attachment-warn {
182 - font-style: italic;
183 - }
184 -
185 - /* Autocomplete dropdown */
186 - .autocomplete-wrapper {
187 - position: relative;
188 - flex: 1;
189 - }
190 -
191 - .autocomplete-dropdown {
192 - position: absolute;
193 - top: 100%;
194 - left: 0;
195 - right: 0;
196 - background: var(--bg-card);
197 - border: 1px solid var(--border-color);
198 - border-radius: var(--radius-sm);
199 - box-shadow: var(--shadow-brutal);
200 - z-index: 100;
201 - max-height: 200px;
202 - overflow-y: auto;
203 - }
204 -
205 - .autocomplete-item {
206 - padding: 0.5rem 0.75rem;
207 - cursor: pointer;
208 - font-size: 0.875rem;
209 - }
210 -
211 - .autocomplete-item:hover,
212 - .autocomplete-item.active {
213 - background: var(--bg-secondary);
214 - }
215 -
216 - .autocomplete-name {
217 - font-weight: 500;
218 - }
219 -
220 - .autocomplete-email {
221 - color: var(--text-secondary);
222 - margin-left: 0.5rem;
223 - }
224 -
225 - .reply-indicator {
226 - display: none;
227 - color: var(--text-secondary);
228 - font-size: 0.8125rem;
229 - align-self: center;
230 - }
231 - .header-row--tight {
232 - padding: 0.25rem 1rem;
233 - }
234 - .toggle-cc-btn {
235 - background: none;
236 - border: none;
237 - color: var(--text-secondary);
238 - font-size: 0.8125rem;
239 - cursor: pointer;
240 - padding: 0;
241 - }
242 - .save-contact-bar {
243 - position: fixed;
244 - bottom: 2rem;
245 - left: 50%;
246 - transform: translateX(-50%);
247 - background: var(--bg-secondary);
248 - border: 1px solid var(--border-color);
249 - border-radius: var(--radius-md);
250 - padding: 0.75rem 1rem;
251 - display: flex;
252 - align-items: center;
253 - gap: 0.75rem;
254 - z-index: 1000;
255 - box-shadow: var(--shadow-brutal-md);
256 - font-size: 0.875rem;
257 - }
258 - .save-contact-bar .save-btn {
259 - background: var(--accent-primary);
260 - color: var(--text-on-accent);
261 - border: none;
262 - padding: 0.375rem 0.75rem;
263 - border-radius: var(--radius-xs);
264 - cursor: pointer;
265 - font-size: 0.8125rem;
266 - }
267 - .save-contact-bar .dismiss-btn {
268 - background: none;
269 - border: none;
270 - color: var(--text-secondary);
271 - cursor: pointer;
272 - font-size: 0.8125rem;
273 - }
62 + /* Attachment list, autocomplete-dropdown, header-row--tight,
63 + toggle-cc-btn, reply-indicator now live in css/styles.css
64 + (stages 3/5 shared). save-contact-bar removed: implicit-contact
65 + prompts moved to the main-app shell in Phase 7 Tier 1 #3. */
274 66 </style>
275 67 </head>
276 68 <body>
@@ -278,48 +70,16 @@
278 70 <button class="btn btn-primary" id="send-btn" onclick="sendEmail()">Send</button>
279 71 <span id="reply-indicator" class="reply-indicator"></span>
280 72 <button class="btn btn-secondary" onclick="saveDraft()">Save Draft</button>
281 - <button class="btn btn-secondary" onclick="pickAttachment()">Attach</button>
73 + <button class="btn btn-secondary" id="attach-btn">Attach</button>
282 74 <div class="toolbar-spacer"></div>
283 75 <button class="btn btn-secondary" onclick="discardAndClose()">Discard</button>
284 76 </div>
285 77
286 - <form class="compose-form" id="compose-form">
287 - <div class="header-row">
288 - <label class="header-label">From:</label>
289 - <select class="header-select" id="from-account" required>
290 - <option value="">Loading accounts...</option>
291 - </select>
292 - </div>
293 - <div class="header-row">
294 - <label class="header-label">To:</label>
295 - <div class="autocomplete-wrapper">
296 - <input type="text" class="header-input" id="to-address" placeholder="recipient@example.com (comma-separated)" required autocomplete="off">
297 - </div>
298 - </div>
299 - <div class="header-row" id="cc-row" style="display: none;">
300 - <label class="header-label">CC:</label>
301 - <div class="autocomplete-wrapper">
302 - <input type="text" class="header-input" id="cc-address" placeholder="cc@example.com (comma-separated)" autocomplete="off">
303 - </div>
304 - </div>
305 - <div class="header-row" id="bcc-row" style="display: none;">
306 - <label class="header-label">BCC:</label>
307 - <div class="autocomplete-wrapper">
308 - <input type="text" class="header-input" id="bcc-address" placeholder="bcc@example.com (comma-separated)" autocomplete="off">
309 - </div>
310 - </div>
311 - <div class="header-row header-row--tight">
312 - <span class="header-label"></span>
313 - <button type="button" id="toggle-cc" class="toggle-cc-btn" onclick="toggleCcBcc()">Show CC/BCC</button>
314 - </div>
315 - <div class="header-row">
316 - <label class="header-label">Subject:</label>
317 - <input type="text" class="header-input" id="subject" placeholder="Subject">
318 - </div>
319 - <div class="body-container">
320 - <textarea class="body-textarea" id="body" placeholder="Write your message..."></textarea>
321 - </div>
322 - </form>
78 + <!-- Fields rendered at DOMContentLoaded by composeForm.buildFieldsHtml.
79 + Attachments bar stays outside the form (window-only chrome ordering);
80 + the modal opts into the in-template attachments-bar instead.
81 + toggle-cc binds via JS, not an inline onclick. -->
82 + <form class="compose-form" id="compose-form" onsubmit="event.preventDefault(); return false;"></form>
323 83
324 84 <div class="compose-attachments" id="attachments-bar" style="display: none;"></div>
325 85 <div class="status-bar" id="status-bar">Ready</div>
@@ -393,25 +153,12 @@
393 153 }
394 154 }
395 155
396 - function showSaveContactPrompt(name, onSave, onDismiss) {
397 - const bar = document.createElement('div');
398 - bar.className = 'save-contact-bar';
399 - bar.innerHTML = `
400 - <span>Save <strong>${name.replace(/</g, '&lt;')}</strong> as a contact?</span>
401 - <button class="save-btn">Save</button>
402 - <button class="dismiss-btn">Dismiss</button>
403 - `;
404 - bar.querySelector('button:first-of-type').onclick = () => { bar.remove(); onSave(); };
405 - bar.querySelector('button:last-of-type').onclick = () => { bar.remove(); onDismiss(); };
406 - document.body.appendChild(bar);
407 - }
408 -
409 156 async function sendEmail() {
410 - isSending = true;
411 - clearTimeout(autosaveTimer);
157 + if (composeCtrl) composeCtrl.setSending(true);
412 158
413 - // Stage 1 of compose unification — payload shape + validation
414 - // now live in compose-form.js, shared with the in-app modal.
159 + // Stages 1/4 of compose unification — payload shape + validation
160 + // live in compose-form.js; attached files come from the controller.
161 + const attachedFiles = composeCtrl ? composeCtrl.getAttachedFiles() : [];
415 162 const input = composeForm.collectInput({
416 163 accountId: document.getElementById('from-account').value,
417 164 toAddress: document.getElementById('to-address').value,
@@ -425,7 +172,7 @@
425 172 const result = composeForm.validateForSend(input, attachedFiles);
426 173 if (!result.ok) {
427 174 setStatus(result.message, 'error');
428 - isSending = false;
175 + if (composeCtrl) composeCtrl.setSending(false);
429 176 return;
430 177 }
431 178
@@ -449,76 +196,13 @@
449 196 }, 250);
450 197 } catch (err) {
451 198 document.body.classList.remove('compose-loading');
452 - isSending = false;
199 + if (composeCtrl) composeCtrl.setSending(false);
453 200 setStatus('Failed to queue send: ' + err, 'error');
454 201 }
455 202 }
456 203
457 - let currentDraftId = null;
458 - let autosaveTimer = null;
459 - let isSending = false;
460 -
461 - /**
462 - * Collect current form data into a DraftInput object.
463 - */
464 - function collectDraftInput() {
465 - return {
466 - id: currentDraftId || null,
467 - accountId: document.getElementById('from-account').value || null,
468 - toAddress: document.getElementById('to-address').value.trim() || null,
469 - ccAddress: document.getElementById('cc-address').value.trim() || null,
470 - bccAddress: document.getElementById('bcc-address').value.trim() || null,
471 - subject: document.getElementById('subject').value.trim() || null,
472 - body: document.getElementById('body').value || null,
473 - inReplyTo: replyContext.inReplyTo,
474 - references: replyContext.references,
475 - threadId: replyContext.threadId,
476 - };
477 - }
478 -
479 - /**
480 - * Check if the compose form has any meaningful content worth saving.
481 - */
482 - function hasContent() {
483 - const to = document.getElementById('to-address').value.trim();
484 - const subject = document.getElementById('subject').value.trim();
485 - const body = document.getElementById('body').value.trim();
486 - // Don't count signature-only body as content
487 - const sigOnly = currentSignature && body === '-- \n' + currentSignature;
488 - return !!(to || subject || (body && !sigOnly));
489 - }
490 -
491 - /**
492 - * Debounced autosave — saves draft 2s after last keystroke.
493 - * Only saves if there's meaningful content. Shows subtle status update.
494 - */
495 - function scheduleAutosave() {
496 - if (isSending) return;
497 - clearTimeout(autosaveTimer);
498 - autosaveTimer = setTimeout(autosaveDraft, 2000);
499 - }
500 -
501 - async function autosaveDraft() {
502 - if (isSending || !hasContent()) return;
503 -
504 - try {
505 - const result = await invoke('save_email_draft', { input: collectDraftInput() });
506 - currentDraftId = result.id;
507 - setStatus('Draft auto-saved', 'success');
508 - } catch (_) {
509 - // Autosave failures are silent — manual save still works
510 - }
511 - }
512 -
513 - async function saveDraft() {
514 - clearTimeout(autosaveTimer); // Cancel pending autosave
515 - try {
516 - const result = await invoke('save_email_draft', { input: collectDraftInput() });
517 - currentDraftId = result.id;
518 - setStatus('Draft saved!', 'success');
519 - } catch (err) {
520 - setStatus('Failed to save draft: ' + err, 'error');
521 - }
204 + function saveDraft() {
205 + if (composeCtrl) composeCtrl.saveDraftNow();
522 206 }
523 207
524 208 function discardAndClose() {
@@ -535,146 +219,15 @@
535 219 window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close();
536 220 }
537 221
538 - function toggleCcBcc() {
539 - const ccRow = document.getElementById('cc-row');
540 - const bccRow = document.getElementById('bcc-row');
541 - const btn = document.getElementById('toggle-cc');
542 - const visible = ccRow.style.display !== 'none';
543 - ccRow.style.display = visible ? 'none' : 'flex';
544 - bccRow.style.display = visible ? 'none' : 'flex';
545 - btn.textContent = visible ? 'Show CC/BCC' : 'Hide CC/BCC';
546 - }
547 -
548 - function escapeHtml(text) {
549 - const div = document.createElement('div');
550 - div.textContent = text || '';
551 - return div.innerHTML;
552 - }
553 -
554 - // ============ Attachments ============
222 + // Stage 4 of compose unification — autocomplete, address highlight,
223 + // CC/BCC toggle, signature swap, and attachment picker/render/remove
224 + // now live in composeForm.bindBehaviors. The window keeps a thin
225 + // contacts loader (contactEmails) since it doesn't bootstrap the
226 + // GoingsOn namespace; the controller's getContacts callback reads it.
555 227
556 - let attachedFiles = []; // [{path, name, size}]
557 -
558 - // Phase 7 Tier 6 #3 stage 1: caps + formatters now live on
559 - // window.GoingsOnComposeForm. The compose webview doesn't bootstrap
560 - // the GoingsOn namespace, hence the `window.GoingsOnComposeForm`
561 - // export rather than `GoingsOn.composeForm`.
562 228 const composeForm = window.GoingsOnComposeForm;
563 - const ATTACHMENT_HARD_CAP = composeForm.ATTACHMENT_HARD_CAP_BYTES;
564 - const ATTACHMENT_WARN_CAP = composeForm.ATTACHMENT_WARN_CAP_BYTES;
565 - const formatBytes = composeForm.formatBytes;
566 -
567 - function totalAttachmentBytes() {
568 - return composeForm.totalAttachmentBytes(attachedFiles);
569 - }
570 -
571 - function attachmentsExceedCap() {
572 - return composeForm.exceedsAttachmentCap(attachedFiles).over;
573 - }
574 -
575 - async function pickAttachment() {
576 - try {
577 - const { open } = window.__TAURI__.dialog;
578 - const selected = await open({
579 - multiple: true,
580 - title: 'Select files to attach',
581 - });
582 - if (!selected) return;
583 -
584 - const paths = Array.isArray(selected) ? selected : [selected];
585 - for (const p of paths) {
586 - const filePath = typeof p === 'string' ? p : p.path;
587 - if (!filePath) continue;
588 - // Avoid duplicates
589 - if (attachedFiles.some(f => f.path === filePath)) continue;
590 - const name = filePath.split(/[/\\]/).pop() || 'file';
591 - let size = 0;
592 - try {
593 - size = await invoke('get_file_size', { filePath });
594 - } catch (_) {
595 - // If we can't stat the file, leave size 0; the running
596 - // total will under-report but won't block the send.
597 - }
598 - attachedFiles.push({ path: filePath, name, size });
599 - }
600 - renderAttachments();
601 - } catch (err) {
602 - if (err && err.toString().includes('cancelled')) return;
603 - setStatus('Failed to pick file: ' + err, 'error');
604 - }
605 - }
606 -
607 - function removeAttachment(index) {
608 - attachedFiles.splice(index, 1);
609 - renderAttachments();
610 - }
611 -
612 - function renderAttachments() {
613 - const bar = document.getElementById('attachments-bar');
614 - if (attachedFiles.length === 0) {
615 - bar.style.display = 'none';
616 - bar.innerHTML = '';
617 - return;
618 - }
619 - const total = totalAttachmentBytes();
620 - const overCap = total > ATTACHMENT_HARD_CAP;
621 - const overWarn = total > ATTACHMENT_WARN_CAP;
622 - const totalClass = overCap ? 'compose-attachment-total over-cap'
623 - : overWarn ? 'compose-attachment-total over-warn'
624 - : 'compose-attachment-total';
625 -
Lines truncated
@@ -2505,9 +2505,128 @@ body {
2505 2505 }
2506 2506 .email-draft-subject { font-weight: 500; }
2507 2507
2508 - /* Mobile compose modal attachment total / size-cap warning.
2509 - Mirrors the inline rules in compose.html so the mobile modal shows the
2510 - same affordance. Phase 7 Tier 1 #4. */
2508 + /* Compose form fields — shared between the desktop compose.html window
2509 + and the in-app modal (Phase 7 Tier 6 #3 stage 3). The composeForm.buildFieldsHtml
2510 + helper emits markup using these classes; both surfaces render the same
2511 + HTML so spacing/typography stays unified. Window-only chrome
2512 + (.compose-toolbar, .status-bar, .save-contact-bar, .reply-indicator)
2513 + still lives in compose.html since it has no modal analogue yet. */
2514 + .header-row {
2515 + display: flex;
2516 + align-items: center;
2517 + padding: 0.75rem 1rem;
2518 + border-bottom: 1px solid var(--border-color);
2519 + gap: 0.5rem;
2520 + }
2521 + .header-row--tight {
2522 + padding: 0.25rem 1rem;
2523 + }
2524 + .header-label {
2525 + width: 80px;
2526 + color: var(--text-secondary);
2527 + font-size: 0.875rem;
2528 + flex-shrink: 0;
2529 + }
2530 + .header-input {
2531 + flex: 1;
2532 + background: transparent;
2533 + border: none;
2534 + color: var(--text-primary);
2535 + font-size: 0.875rem;
2536 + padding: 0.5rem;
2537 + outline: none;
2538 + }
2539 + .header-input::placeholder { color: var(--text-muted); }
2540 + .header-input:focus { background: transparent; }
2541 + .header-select {
2542 + flex: 1;
2543 + background: var(--bg-primary);
2544 + border: 1px solid var(--border-color);
2545 + color: var(--text-primary);
2546 + font-size: 0.875rem;
2547 + padding: 0.5rem;
2548 + border-radius: var(--radius-sm);
2549 + outline: none;
2550 + }
2551 + .autocomplete-wrapper {
2552 + position: relative;
2553 + flex: 1;
2554 + background: transparent;
2555 + border-radius: var(--radius-sm);
2556 + }
2557 + .autocomplete-wrapper:focus-within {
2558 + background: var(--bg-secondary);
2559 + }
2560 + .body-container {
2561 + flex: 1;
2562 + display: flex;
2563 + flex-direction: column;
2564 + overflow: hidden;
2565 + }
2566 + .body-textarea {
2567 + flex: 1;
2568 + min-height: 12rem;
2569 + background: var(--bg-card);
2570 + border: none;
2571 + color: var(--text-primary);
2572 + font-size: 0.9375rem;
2573 + font-family: inherit;
2574 + padding: 1rem;
2575 + resize: vertical;
2576 + outline: none;
2577 + line-height: 1.6;
2578 + }
2579 + .body-textarea::placeholder { color: var(--text-muted); }
2580 + .toggle-cc-btn {
2581 + background: none;
2582 + border: none;
2583 + color: var(--text-secondary);
2584 + font-size: 0.8125rem;
2585 + cursor: pointer;
2586 + padding: 0;
2587 + }
2588 +
2589 + /* Reply indicator — shared between compose.html's toolbar (where it
2590 + benefits from align-self:center inside a flex row) and the in-app
2591 + compose modal (where it's just an inline label above the form). */
2592 + .reply-indicator {
2593 + display: none;
2594 + color: var(--text-secondary);
2595 + font-size: 0.8125rem;
2596 + align-self: center;
2597 + }
2598 +
2599 + /* Attachment list — rendered into #attachments-bar by composeForm. */
2600 + .compose-attachments {
2601 + padding: 0.5rem 1rem;
2602 + border-top: 1px solid var(--border-color);
2603 + background: var(--bg-secondary);
2604 + font-size: 0.8125rem;
2605 + }
2606 + .compose-attachment-item {
2607 + display: flex;
2608 + align-items: center;
2609 + gap: 0.5rem;
2610 + padding: 0.25rem 0;
2611 + }
2612 + .compose-attachment-name {
2613 + flex: 1;
2614 + overflow: hidden;
2615 + text-overflow: ellipsis;
2616 + white-space: nowrap;
2617 + }
2618 + .compose-attachment-size {
2619 + color: var(--text-muted);
2620 + flex-shrink: 0;
2621 + }
2622 + .compose-attachment-remove {
2623 + background: none;
2624 + border: none;
2625 + color: var(--accent-red);
2626 + cursor: pointer;
2627 + font-size: 1rem;
2628 + padding: 0 0.25rem;
2629 + }
2511 2630 .compose-attachment-total {
2512 2631 display: flex;
2513 2632 gap: var(--space-2);
@@ -345,6 +345,12 @@
345 345 <h2 class="page-title">Events</h2>
346 346 <button class="btn btn-primary" onclick="GoingsOn.events.openNew()" title="New event (n)">+ New Event <kbd class="kbd-hint">n</kbd></button>
347 347 </div>
348 + <div class="filter-bar" id="events-filter-bar">
349 + <label class="filter-checkbox" title="Include events hidden until a later date">
350 + <input type="checkbox" id="filter-events-snoozed" onchange="GoingsOn.events.onFilterChange()">
351 + Show Snoozed
352 + </label>
353 + </div>
348 354 <div id="events-bulk-bar" class="bulk-actions-bar hidden">
349 355 <span class="bulk-count">0 selected</span>
350 356 <button class="btn btn-sm" onclick="GoingsOn.events.selectAllEvents()">Select All</button>
@@ -121,6 +121,494 @@
121 121 return { ok: true };
122 122 }
123 123
124 + // ============ Shared HTML template (stage 3) ============
125 + //
126 + // `buildFieldsHtml(opts)` returns the form-fields HTML used by both the
127 + // desktop `compose.html` window and the in-app `openComposeModal`. The
128 + // canonical IDs (`from-account`, `to-address`, `cc-row`, `cc-address`,
129 + // `bcc-row`, `bcc-address`, `subject`, `body`, `attachments-bar`,
130 + // `toggle-cc`) match compose.html's pre-existing inline JS so it
131 + // keeps binding by `getElementById` unchanged. The modal opts into the
132 + // same IDs to share behaviors.
133 + //
134 + // Each surface owns its chrome:
135 + // - compose.html: outer <form>, toolbar, status bar
136 + // - modal: GoingsOn.modal.openModal wrapper, footer action row
137 + //
138 + // Options:
139 + // accounts: Array<{id, account_name?, email_address?, email?, ...}> — From-select rows
140 + // selectedAccountId: id of the option pre-selected in From
141 + // accountsLoading: boolean — if true, render a placeholder option
142 + // prefill: { to, cc, bcc, subject, body } — pre-filled values
143 + // showCcBcc: boolean — if true, CC/BCC rows are visible at render time
144 + // showAttachments: boolean — include the #attachments-bar container
145 + // bodyRows: number — initial `rows` attribute on the body textarea (modal honours this)
146 + function buildFieldsHtml(opts) {
147 + const o = opts || {};
148 + const accounts = o.accounts || [];
149 + const prefill = o.prefill || {};
150 + const showCcBcc = !!o.showCcBcc;
151 + const showAttachments = o.showAttachments !== false;
152 + const selectedId = o.selectedAccountId || null;
153 + const bodyRows = o.bodyRows || 0;
154 +
155 + const escHtml = (s) => {
156 + const d = (typeof document !== 'undefined') ? document.createElement('div') : null;
157 + if (d) { d.textContent = s == null ? '' : String(s); return d.innerHTML; }
158 + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
159 + '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
160 + })[c]);
161 + };
162 + const escAttr = (s) => escHtml(s).replace(/"/g, '&quot;');
163 +
164 + let accountOptionsHtml;
165 + if (o.accountsLoading) {
166 + accountOptionsHtml = '<option value="">Loading accounts…</option>';
167 + } else if (accounts.length === 0) {
168 + accountOptionsHtml = '<option value="">No email accounts configured</option>';
169 + } else {
170 + accountOptionsHtml = accounts.map((a) => {
171 + const id = a.id;
172 + const name = a.account_name || a.accountName || '';
173 + const addr = a.email_address || a.email || '';
174 + const label = name ? `${name} <${addr}>` : addr;
175 + const sel = id === selectedId ? ' selected' : '';
176 + return `<option value="${escAttr(id)}"${sel}>${escHtml(label)}</option>`;
177 + }).join('');
178 + }
179 +
180 + const ccRowStyle = showCcBcc ? '' : ' style="display: none;"';
181 + const toggleLabel = showCcBcc ? 'Hide CC/BCC' : 'Show CC/BCC';
182 + const bodyRowsAttr = bodyRows ? ` rows="${bodyRows}"` : '';
183 +
184 + const attachmentsBlock = showAttachments
185 + ? '<div class="compose-attachments" id="attachments-bar" style="display: none;"></div>'
186 + : '';
187 +
188 + return `
189 + <div class="header-row">
190 + <label class="header-label" for="from-account">From:</label>
191 + <select class="header-select" id="from-account" name="from-account" required>
192 + ${accountOptionsHtml}
193 + </select>
194 + </div>
195 + <div class="header-row">
196 + <label class="header-label" for="to-address">To:</label>
197 + <div class="autocomplete-wrapper">
198 + <input type="text" class="header-input" id="to-address" name="to-address" placeholder="recipient@example.com (comma-separated)" required autocomplete="off" value="${escAttr(prefill.to || '')}">
199 + </div>
200 + </div>
201 + <div class="header-row" id="cc-row"${ccRowStyle}>
202 + <label class="header-label" for="cc-address">CC:</label>
203 + <div class="autocomplete-wrapper">
204 + <input type="text" class="header-input" id="cc-address" name="cc-address" placeholder="cc@example.com (comma-separated)" autocomplete="off" value="${escAttr(prefill.cc || '')}">
205 + </div>
206 + </div>
207 + <div class="header-row" id="bcc-row"${ccRowStyle}>
208 + <label class="header-label" for="bcc-address">BCC:</label>
209 + <div class="autocomplete-wrapper">
210 + <input type="text" class="header-input" id="bcc-address" name="bcc-address" placeholder="bcc@example.com (comma-separated)" autocomplete="off" value="${escAttr(prefill.bcc || '')}">
211 + </div>
212 + </div>
213 + <div class="header-row header-row--tight">
214 + <span class="header-label"></span>
215 + <button type="button" id="toggle-cc" class="toggle-cc-btn">${toggleLabel}</button>
216 + </div>
217 + <div class="header-row">
218 + <label class="header-label" for="subject">Subject:</label>
219 + <input type="text" class="header-input" id="subject" name="subject" placeholder="Subject" value="${escAttr(prefill.subject || '')}">
220 + </div>
221 + <div class="body-container">
222 + <textarea class="body-textarea" id="body" name="body"${bodyRowsAttr} placeholder="Write your message...">${escHtml(prefill.body || '')}</textarea>
223 + </div>
224 + ${attachmentsBlock}
225 + `;
226 + }
227 +
228 + // ============ Shared behaviors (stage 4) ============
229 + //
230 + // `bindBehaviors(opts)` wires every interactive behavior the compose
231 + // surfaces share: autocomplete on To/CC/BCC, address-highlight, CC/BCC
232 + // show/hide toggle, signature swap on From change, and attachment
233 + // picker / render / remove (delegated, no inline onclick). Each surface
234 + // mounts `buildFieldsHtml`, then calls this once.
235 + //
236 + // Returns a controller the surface uses for its chrome:
237 + // { pickAttachment, removeAttachment, renderAttachments,
238 + // getAttachedFiles, setAttachedFiles, appendSignatureForAccount,
239 + // toggleCcBcc }
240 + //
241 + // Options:
242 + // accounts: same shape as buildFieldsHtml
243 + // initialSignature: optional string to treat as the "currently appended" sig
244 + // (so the first From-change knows what trailing block to strip)
245 + // getContacts: () => [{name, email, isImplicit?}] — autocomplete source
246 + // onError: (message) => void — surface-specific error sink
247 + // (window uses setStatus; modal uses showToast)
248 + // onAttachmentsChange: (files) => void — optional callback after picker/remove
249 + function bindBehaviors(opts) {
250 + const o = opts || {};
251 + const accounts = o.accounts || [];
252 + const getContacts = typeof o.getContacts === 'function' ? o.getContacts : () => [];
253 + const onError = typeof o.onError === 'function' ? o.onError : null;
254 + const onAttachmentsChange = typeof o.onAttachmentsChange === 'function' ? o.onAttachmentsChange : null;
255 +
256 + const fromEl = document.getElementById('from-account');
257 + const toEl = document.getElementById('to-address');
258 + const ccEl = document.getElementById('cc-address');
259 + const bccEl = document.getElementById('bcc-address');
260 + const ccRow = document.getElementById('cc-row');
261 + const bccRow = document.getElementById('bcc-row');
262 + const toggleBtn = document.getElementById('toggle-cc');
263 + const bodyEl = document.getElementById('body');
264 + const attachmentsBar = document.getElementById('attachments-bar');
265 +
266 + let attachedFiles = [];
267 + let currentSignature = o.initialSignature || '';
268 +
269 + const escHtmlLocal = (s) => {
270 + const d = document.createElement('div');
271 + d.textContent = s == null ? '' : String(s);
272 + return d.innerHTML;
273 + };
274 + const escAttrLocal = (s) => escHtmlLocal(s).replace(/"/g, '&quot;');
275 +
276 + // ---------- Autocomplete (self-contained — compose.html doesn't load js/autocomplete.js) ----------
277 + function getLastToken(input) {
278 + const val = input.value;
279 + const cursor = input.selectionStart || val.length;
280 + const before = val.slice(0, cursor);
281 + const lastComma = before.lastIndexOf(',');
282 + return { token: before.slice(lastComma + 1).trim() };
283 + }
284 +
285 + function attachAutocomplete(input) {
286 + if (!input) return;
287 + let dropdown = null;
288 + let activeIndex = -1;
289 + let matches = [];
290 +
291 + function hide() {
292 + if (dropdown) { dropdown.remove(); dropdown = null; activeIndex = -1; matches = []; }
293 + }
294 +
295 + function selectMatch(email) {
296 + const val = input.value;
297 + const cursor = input.selectionStart || val.length;
298 + const before = val.slice(0, cursor);
299 + const after = val.slice(cursor);
300 + const lastComma = before.lastIndexOf(',');
301 + const prefix = lastComma >= 0 ? before.slice(0, lastComma + 1) + ' ' : '';
302 + input.value = prefix + email + ', ' + after.trimStart();
303 + const newCursor = (prefix + email + ', ').length;
304 + input.setSelectionRange(newCursor, newCursor);
305 + input.focus();
306 + hide();
307 + }
308 +
309 + function show(filtered) {
310 + hide();
311 + if (filtered.length === 0) return;
312 + matches = filtered;
313 + dropdown = document.createElement('div');
314 + dropdown.className = 'autocomplete-dropdown';
315 + filtered.forEach((m) => {
316 + const item = document.createElement('div');
317 + item.className = 'autocomplete-item';
318 + item.innerHTML = `<span class="autocomplete-name">${escHtmlLocal(m.name)}</span> <span class="autocomplete-email">${escHtmlLocal(m.email)}</span>`;
319 + item.addEventListener('mousedown', (e) => { e.preventDefault(); selectMatch(m.email); });
320 + dropdown.appendChild(item);
321 + });
322 + const wrapper = input.parentElement;
323 + wrapper.appendChild(dropdown);
324 + }
325 +
326 + input.addEventListener('input', () => {
327 + const { token } = getLastToken(input);
328 + if (!token) { hide(); return; }
329 + const q = token.toLowerCase();
330 + const filtered = (getContacts() || [])
331 + .filter(c => (c.email || '').toLowerCase().includes(q) || (c.name || '').toLowerCase().includes(q))
332 + .sort((a, b) => {
333 + if (!!a.isImplicit !== !!b.isImplicit) return a.isImplicit ? 1 : -1;
334 + const ap = (a.email || '').toLowerCase().startsWith(q) ? 0 : 1;
335 + const bp = (b.email || '').toLowerCase().startsWith(q) ? 0 : 1;
336 + return ap - bp;
337 + })
338 + .slice(0, 8);
339 + show(filtered);
340 + });
341 +
342 + input.addEventListener('blur', () => setTimeout(hide, 150));
343 +
344 + input.addEventListener('keydown', (e) => {
345 + if (!dropdown) return;
346 + const items = dropdown.querySelectorAll('.autocomplete-item');
347 + if (e.key === 'ArrowDown') {
348 + e.preventDefault();
349 + activeIndex = Math.min(activeIndex + 1, items.length - 1);
350 + items.forEach((el, i) => el.classList.toggle('active', i === activeIndex));
351 + } else if (e.key === 'ArrowUp') {
352 + e.preventDefault();
353 + activeIndex = Math.max(activeIndex - 1, 0);
354 + items.forEach((el, i) => el.classList.toggle('active', i === activeIndex));
355 + } else if (e.key === 'Enter' || e.key === 'Tab') {
356 + if (activeIndex >= 0 && activeIndex < matches.length) {
357 + e.preventDefault();
358 + selectMatch(matches[activeIndex].email);
359 + }
360 + } else if (e.key === 'Escape') {
361 + hide();
362 + }
363 + });
364 + }
365 + attachAutocomplete(toEl);
366 + attachAutocomplete(ccEl);
367 + attachAutocomplete(bccEl);
368 +
369 + // ---------- Address highlight (uses whichever helper the surface loaded) ----------
370 + const ahAttach = (window.GoingsOn && window.GoingsOn.addressHighlight && window.GoingsOn.addressHighlight.attach)
371 + || (typeof window.attachAddressHighlight === 'function' ? window.attachAddressHighlight : null);
372 + if (ahAttach) {
373 + const ahOpts = { contacts: getContacts };
374 + // Tauri's `invoke` is needed by the IMAP-backed lookup in
375 + // address-highlight.js; pass through if present.
376 + if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke) {
377 + ahOpts.invoke = window.__TAURI__.core.invoke;
378 + }
379 + [toEl, ccEl, bccEl].forEach(el => { if (el) ahAttach(el, ahOpts); });
380 + }
381 +
382 + // ---------- CC/BCC toggle ----------
383 + function toggleCcBcc() {
384 + if (!ccRow || !bccRow || !toggleBtn) return;
385 + const visible = ccRow.style.display !== 'none';
386 + ccRow.style.display = visible ? 'none' : 'flex';
387 + bccRow.style.display = visible ? 'none' : 'flex';
388 + toggleBtn.textContent = visible ? 'Show CC/BCC' : 'Hide CC/BCC';
389 + }
390 + if (toggleBtn) toggleBtn.addEventListener('click', toggleCcBcc);
391 +
392 + // ---------- Signature swap on From change ----------
393 + function appendSignatureForAccount(accountId) {
394 + if (!bodyEl) return;
395 + let body = bodyEl.value;
396 + if (currentSignature) {
397 + const block = '\n\n-- \n' + currentSignature;
398 + if (body.endsWith(block)) body = body.slice(0, -block.length);
399 + }
400 + const account = accounts.find(a => a.id === accountId);
401 + const sig = (account && (account.emailSignature || account.email_signature)) || '';
402 + currentSignature = sig;
403 + bodyEl.value = body + (sig ? '\n\n-- \n' + sig : '');
404 + }
405 + if (fromEl) fromEl.addEventListener('change', (e) => appendSignatureForAccount(e.target.value));
406 +
407 + // ---------- Attachments (picker, render, remove) ----------
408 + function renderAttachments() {
409 + if (!attachmentsBar) return;
410 + if (attachedFiles.length === 0) {
411 + attachmentsBar.style.display = 'none';
412 + attachmentsBar.innerHTML = '';
413 + return;
414 + }
415 + const items = attachedFiles.map((f, i) => `
416 + <div class="compose-attachment-item">
417 + <span class="compose-attachment-name" title="${escAttrLocal(f.path)}">${escHtmlLocal(f.name)}</span>
418 + <span class="compose-attachment-size">${formatBytes(f.size || 0)}</span>
419 + <button type="button" class="compose-attachment-remove" data-compose-remove-attachment="${i}" title="Remove">&times;</button>
420 + </div>
421 + `).join('');
422 + const cap = exceedsAttachmentCap(attachedFiles);
423 + const cls = cap.over ? 'compose-attachment-total over-cap'
424 + : cap.warn ? 'compose-attachment-total over-warn'
425 + : 'compose-attachment-total';
426 + const warn = cap.over
427 + ? '<span class="compose-attachment-warn">— most mail servers will reject this. Remove some files or use a file-share link.</span>'
428 + : cap.warn
429 + ? '<span class="compose-attachment-warn">— large attachment; your mail server may reject this.</span>'
430 + : '';
431 + const totalLine = `<div class="${cls}"><span>Total: ${formatBytes(cap.totalBytes)} / ${formatBytes(ATTACHMENT_HARD_CAP_BYTES)}</span>${warn}</div>`;
432 + attachmentsBar.style.display = 'block';
433 + attachmentsBar.innerHTML = items + totalLine;
434 + }
435 +
436 + if (attachmentsBar) {
437 + // Event delegation on the bar — survives re-renders without re-binding.
438 + attachmentsBar.addEventListener('click', (e) => {
439 + const btn = e.target && e.target.closest && e.target.closest('[data-compose-remove-attachment]');
440 + if (!btn) return;
441 + const idx = Number(btn.getAttribute('data-compose-remove-attachment'));
442 + if (!Number.isInteger(idx)) return;
443 + removeAttachment(idx);
444 + });
445 + }
446 +
447 + function removeAttachment(index) {
448 + if (index < 0 || index >= attachedFiles.length) return;
449 + attachedFiles.splice(index, 1);
450 + renderAttachments();
451 + if (onAttachmentsChange) onAttachmentsChange(attachedFiles);
452 + }
453 +
454 + async function pickAttachment() {
455 + try {
456 + if (!window.__TAURI__ || !window.__TAURI__.dialog) {
457 + if (onError) onError('File picker unavailable');
458 + return;
459 + }
460 + const { open } = window.__TAURI__.dialog;
461 + const selected = await open({ multiple: true, title: 'Select files to attach' });
462 + if (!selected) return;
463 + const paths = Array.isArray(selected) ? selected : [selected];
464 + const invoke = window.__TAURI__.core && window.__TAURI__.core.invoke;
465 + for (const p of paths) {
466 + const filePath = typeof p === 'string' ? p : p && p.path;
467 + if (!filePath) continue;
468 + if (attachedFiles.some(f => f.path === filePath)) continue;
469 + const name = filePath.split(/[/\\]/).pop() || 'file';
470 + let size = 0;
471 + if (invoke) {
472 + try { size = await invoke('get_file_size', { filePath }); } catch (_) { /* leave 0 */ }
473 + }
474 + attachedFiles.push({ path: filePath, name, size });
475 + }
476 + renderAttachments();
477 + if (onAttachmentsChange) onAttachmentsChange(attachedFiles);
478 + } catch (err) {
479 + if (err && String(err).includes('cancelled')) return;
480 + if (onError) onError('Failed to pick file: ' + err);
481 + }
482 + }
483 +
484 + renderAttachments();
485 +
486 + // ---------- Draft autosave + reply indicator (stage 5) ----------
487 + const enableAutosave = !!o.enableAutosave;
488 + const saveDraft = typeof o.saveDraft === 'function' ? o.saveDraft : null;
489 + const getReplyContext = typeof o.getReplyContext === 'function' ? o.getReplyContext : () => ({});
490 + const onDraftStatus = typeof o.onDraftStatus === 'function' ? o.onDraftStatus : null;
491 + const enableReplyIndicator = !!o.enableReplyIndicator;
492 +
493 + let currentDraftId = o.initialDraftId || null;
494 + let autosaveTimer = null;
495 + let isSending = false;
496 +
497 + function collectDraftInput() {
498 + const rc = getReplyContext() || {};
499 + const subjEl = document.getElementById('subject');
500 + const trim = (s) => (typeof s === 'string' ? s.trim() : '');
501 + const orNull = (s) => (s && s.length ? s : null);
502 + return {
503 + id: currentDraftId || null,
504 + accountId: (fromEl && fromEl.value) || null,
505 + toAddress: orNull(trim(toEl && toEl.value)),
506 + ccAddress: orNull(trim(ccEl && ccEl.value)),
507 + bccAddress: orNull(trim(bccEl && bccEl.value)),
508 + subject: orNull(trim(subjEl && subjEl.value)),
509 + body: (bodyEl && bodyEl.value) || null,
510 + inReplyTo: rc.inReplyTo || null,
511 + references: rc.references || null,
512 + threadId: rc.threadId || null,
513 + };
514 + }
515 +
516 + function hasContent() {
517 + const subjEl = document.getElementById('subject');
518 + const to = (toEl && toEl.value.trim()) || '';
519 + const subject = (subjEl && subjEl.value.trim()) || '';
520 + const body = (bodyEl && bodyEl.value.trim()) || '';
521 + // Signature-only body shouldn't trigger autosave.
522 + const sigOnly = currentSignature && body === '-- \n' + currentSignature;
523 + return !!(to || subject || (body && !sigOnly));
524 + }
525 +
526 + async function autosaveNow() {
527 + if (!enableAutosave || !saveDraft) return;
528 + if (isSending || !hasContent()) return;
529 + try {
530 + const result = await saveDraft(collectDraftInput());
531 + if (result && result.id) currentDraftId = result.id;
532 + if (onDraftStatus) onDraftStatus('saved', 'Draft auto-saved');
533 + } catch (_) {
534 + // Silent — manual save still works.
535 + }
536 + }
537 +
538 + function scheduleAutosave() {
539 + if (!enableAutosave || isSending) return;
540 + if (autosaveTimer) clearTimeout(autosaveTimer);
541 + autosaveTimer = setTimeout(autosaveNow, 2000);
542 + }
543 +
544 + async function saveDraftNow() {
545 + if (!saveDraft) return null;
546 + if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
547 + try {
548 + const result = await saveDraft(collectDraftInput());
549 + if (result && result.id) currentDraftId = result.id;
550 + if (onDraftStatus) onDraftStatus('saved', 'Draft saved!');
551 + return result;
552 + } catch (err) {
553 + if (onDraftStatus) onDraftStatus('error', 'Failed to save draft: ' + err);
554 + return null;
555 + }
556 + }
557 +
558 + function cancelAutosave() {
559 + if (autosaveTimer) { clearTimeout(autosaveTimer); autosaveTimer = null; }
560 + }
561 +
562 + function setSending(b) {
563 + isSending = !!b;
564 + if (isSending) cancelAutosave();
565 + }
566 +
567 + if (enableAutosave) {
568 + const autosaveIds = ['to-address', 'cc-address', 'bcc-address', 'subject', 'body'];
569 + for (const id of autosaveIds) {
570 + const el = document.getElementById(id);
571 + if (el) el.addEventListener('input', scheduleAutosave);
572 + }
573 + if (fromEl) fromEl.addEventListener('change', scheduleAutosave);
574 + }
575 +
576 + function updateReplyIndicator() {
577 + if (!enableReplyIndicator) return;
578 + const rc = getReplyContext() || {};
579 + const isReply = !!rc.inReplyTo;
580 + const indicator = document.getElementById('reply-indicator');
581 + if (indicator) {
582 + indicator.style.display = isReply ? 'inline' : 'none';
583 + indicator.textContent = isReply ? 'Replying to thread' : '';
584 + }
585 + // Both surfaces' send buttons (compose.html: #send-btn, modal: #compose-modal-send)
586 + // get the "Send Reply" relabel.
587 + const sendBtn = document.getElementById('send-btn') || document.getElementById('compose-modal-send');
588 + if (sendBtn) sendBtn.textContent = isReply ? 'Send Reply' : 'Send';
589 + }
590 + updateReplyIndicator();
591 +
592 + return {
593 + pickAttachment,
594 + removeAttachment,
595 + renderAttachments,
596 + getAttachedFiles: () => attachedFiles,
597 + setAttachedFiles: (arr) => { attachedFiles = Array.isArray(arr) ? arr.slice() : []; renderAttachments(); },
598 + appendSignatureForAccount,
599 + getCurrentSignature: () => currentSignature,
600 + toggleCcBcc,
601 + // Stage 5
602 + scheduleAutosave,
603 + saveDraftNow,
604 + cancelAutosave,
605 + setSending,
606 + getCurrentDraftId: () => currentDraftId,
607 + setCurrentDraftId: (id) => { currentDraftId = id || null; },
608 + updateReplyIndicator,
609 + };
610 + }
611 +
124 612 // ============ Export ============
125 613
126 614 const api = {
@@ -132,6 +620,8 @@
132 620 collectInput,
133 621 validate,
134 622 validateForSend,
623 + buildFieldsHtml,
624 + bindBehaviors,
135 625 };
Lines truncated
@@ -176,141 +176,131 @@
176 176 }
177 177 }
178 178
179 - let modalAttachedFiles = [];
179 + // Modal compose controller — populated after openComposeModal mounts and
180 + // composeForm.bindBehaviors wires up the shared behaviors.
181 + let modalComposeCtrl = null;
180 182
181 183 function openComposeModal(prefill) {
182 - modalAttachedFiles = [];
184 + modalComposeCtrl = null;
183 185 const accounts = GoingsOn.getEmailAccountsCache();
184 186 const pf = prefill || {};
185 187
186 - const accountOptions = accounts.map(a => ({
187 - value: a.id,
188 - label: a.email,
189 - selected: a.id === pf.accountId,
190 - }));
188 + // Stage 3/4 of compose unification — markup and behaviors come from
189 + // composeForm. The chrome (modal wrapper, footer action row, Attach
190 + // button) stays modal-specific. CC/BCC sit after To, matching compose.html.
191 191
192 - // Append signature for the default/selected account
192 + // Pre-append signature for the default/selected account; bindBehaviors
193 + // tracks it so the first From-change knows what trailing block to strip.
193 194 const selectedAccount = accounts.find(a => a.id === pf.accountId) || accounts[0];
194 - const sig = selectedAccount?.emailSignature;
195 + const selectedAccountId = selectedAccount?.id || null;
196 + const sig = selectedAccount?.emailSignature || '';
195 197 const bodyWithSig = (pf.body || '') + (sig ? '\n\n-- \n' + sig : '');
196 198
197 - // Stage 2 of compose unification — CC/BCC on the modal, behind a
198 - // toggle that mirrors compose.html lines 299-314. Auto-expand when
199 - // a prefill provides cc/bcc (matches compose.html's draft-restore
200 - // behavior). Rendered via extraContent because openFormModal only
201 - // appends extras after declared fields; the resulting visual order
202 - // (CC/BCC below Body) is fixed up by stage 3's shared template.
203 199 const ccPrefill = pf.cc || '';
204 200 const bccPrefill = pf.bcc || '';
205 201 const ccBccExpanded = !!(ccPrefill || bccPrefill);
206 - const renderField = GoingsOn.ui.renderFormField;
207 - const ccBccHtml = `
208 - <div id="modal-cc-row" class="form-group" style="display: ${ccBccExpanded ? '' : 'none'};">
209 - <label class="form-label" for="modal-cc-address">CC</label>
210 - <input type="text" class="form-input" id="modal-cc-address" name="modal-cc-address" placeholder="cc@example.com (comma-separated)" autocomplete="off" value="${GoingsOn.utils.escapeAttr(ccPrefill)}">
211 - </div>
212 - <div id="modal-bcc-row" class="form-group" style="display: ${ccBccExpanded ? '' : 'none'};">
213 - <label class="form-label" for="modal-bcc-address">BCC</label>
214 - <input type="text" class="form-input" id="modal-bcc-address" name="modal-bcc-address" placeholder="bcc@example.com (comma-separated)" autocomplete="off" value="${GoingsOn.utils.escapeAttr(bccPrefill)}">
215 - </div>
216 - <div style="margin-bottom: 0.5rem;">
217 - <button type="button" class="btn btn-secondary btn-sm" id="modal-toggle-cc">${ccBccExpanded ? 'Hide CC/BCC' : 'Show CC/BCC'}</button>
218 - </div>
219 - `;
220 202
221 - GoingsOn.ui.openFormModal({
222 - title: 'Compose Email',
223 - entityType: 'email',
224 - isEdit: false,
225 - fields: [
226 - { name: 'account_id', type: 'select', label: 'From', options: accountOptions, required: true },
227 - { name: 'to', type: 'text', label: 'To', required: true, placeholder: 'recipient@example.com', value: pf.to || '' },
228 - { name: 'subject', type: 'text', label: 'Subject', required: true, value: pf.subject || '' },
229 - { name: 'body', type: 'textarea', label: 'Body', rows: 8, value: bodyWithSig },
230 - ],
231 - extraContent: `
232 - ${ccBccHtml}
233 - <div style="margin-bottom: 0.5rem;">
234 - <button type="button" class="btn btn-secondary btn-sm" onclick="GoingsOn.emails._pickModalAttachment()">Attach File</button>
235 - <div id="modal-attachments" class="modal-attachments"></div>
203 + const fieldsHtml = GoingsOn.composeForm.buildFieldsHtml({
204 + accounts,
205 + selectedAccountId,
206 + prefill: {
207 + to: pf.to || '',
208 + cc: ccPrefill,
209 + bcc: bccPrefill,
210 + subject: pf.subject || '',
211 + body: bodyWithSig,
212 + },
213 + showCcBcc: ccBccExpanded,
214 + showAttachments: true,
215 + bodyRows: 8,
216 + });
217 +
218 + const replyContext = {
219 + inReplyTo: pf.inReplyTo || null,
220 + references: pf.references || null,
221 + threadId: pf.threadId || null,
222 + };
223 +
224 + const content = `
225 + <form id="compose-modal-form" onsubmit="event.preventDefault(); return false;">
226 + <span id="reply-indicator" class="reply-indicator"></span>
227 + ${fieldsHtml}
228 + <div class="form-actions" style="margin-top: 0.75rem;">
229 + <button type="button" class="btn btn-secondary" id="compose-modal-attach">Attach File</button>
230 + <button type="button" class="btn btn-secondary" id="compose-modal-save-draft">Save Draft</button>
231 + <div style="flex: 1;"></div>
232 + <button type="button" class="btn btn-secondary" id="compose-modal-cancel">Cancel</button>
233 + <button type="submit" class="btn btn-primary" id="compose-modal-send">Send</button>
236 234 </div>
237 - `,
238 - onSubmit: async (data) => {
239 - // Stage 1 of compose unification — collectInput owns the
240 - // canonical SendEmailInput shape; validateForSend owns the
241 - // attachment cap. Both surfaces share the contract now.
242 - const ccEl = document.getElementById('modal-cc-address');
243 - const bccEl = document.getElementById('modal-bcc-address');
235 + </form>
236 + `;
237 +
238 + GoingsOn.modal.openModal('Compose Email', content);
239 +
240 + setTimeout(() => {
241 + const form = document.getElementById('compose-modal-form');
242 + if (!form) return;
243 +
244 + // Stage 4/5 — single bindBehaviors call owns autocomplete,
245 + // address highlight, CC/BCC toggle, signature swap, attachment
246 + // picker/render/remove, draft autosave, and reply indicator.
247 + modalComposeCtrl = GoingsOn.composeForm.bindBehaviors({
248 + accounts,
249 + initialSignature: sig,
250 + getContacts: GoingsOn.autocomplete.getContacts,
251 + onError: (msg) => GoingsOn.ui.showToast(msg, 'error', { duration: 8000 }),
252 + enableAutosave: true,
253 + initialDraftId: pf.draftId || null,
254 + saveDraft: (input) => GoingsOn.api.emails.saveDraft(input),
255 + getReplyContext: () => replyContext,
256 + onDraftStatus: (kind, message) => {
257 + if (kind === 'error') {
258 + GoingsOn.ui.showToast(message, 'error', { duration: 6000 });
259 + } else {
260 + GoingsOn.ui.showToast(message, 'success', { duration: 2500 });
261 + }
262 + },
263 + enableReplyIndicator: true,
264 + });
265 +
266 + const attachBtn = document.getElementById('compose-modal-attach');
267 + const cancelBtn = document.getElementById('compose-modal-cancel');
268 + const sendBtn = document.getElementById('compose-modal-send');
269 + const saveDraftBtn = document.getElementById('compose-modal-save-draft');
270 + if (attachBtn) attachBtn.addEventListener('click', modalComposeCtrl.pickAttachment);
271 + if (saveDraftBtn) saveDraftBtn.addEventListener('click', () => modalComposeCtrl.saveDraftNow());
272 + if (cancelBtn) cancelBtn.addEventListener('click', () => GoingsOn.ui.closeModal());
273 +
274 + const submit = async () => {
275 + modalComposeCtrl.setSending(true);
276 + const accountSelect = document.getElementById('from-account');
277 + const toEl = document.getElementById('to-address');
278 + const ccEl = document.getElementById('cc-address');
279 + const bccEl = document.getElementById('bcc-address');
280 + const subjectEl = document.getElementById('subject');
281 + const bodyEl = document.getElementById('body');
282 + const attachedFiles = modalComposeCtrl.getAttachedFiles();
244 283 const input = GoingsOn.composeForm.collectInput({
245 - accountId: data.account_id,
246 - toAddress: data.to,
284 + accountId: accountSelect ? accountSelect.value : null,
285 + toAddress: toEl ? toEl.value : '',
247 286 ccAddress: ccEl ? ccEl.value : '',
248 287 bccAddress: bccEl ? bccEl.value : '',
249 - subject: data.subject,
250 - body: data.body || '',
251 - attachedFiles: modalAttachedFiles,
252 - replyContext: {
253 - inReplyTo: pf.inReplyTo || null,
254 - references: pf.references || null,
255 - threadId: pf.threadId || null,
256 - },
288 + subject: subjectEl ? subjectEl.value : '',
289 + body: bodyEl ? bodyEl.value : '',
290 + attachedFiles,
291 + replyContext,
257 292 });
258 - const result = GoingsOn.composeForm.validateForSend(input, modalAttachedFiles);
293 + const result = GoingsOn.composeForm.validateForSend(input, attachedFiles);
259 294 if (!result.ok) {
260 295 GoingsOn.ui.showToast(result.message, 'error', { duration: 8000 });
296 + modalComposeCtrl.setSending(false);
261 297 return;
262 298 }
263 - modalAttachedFiles = [];
299 + GoingsOn.ui.closeModal();
264 300 queueSend({ input });
265 - },
266 - });
267 -
268 - // Attach autocomplete and signature switching after modal renders
269 - setTimeout(() => {
270 - const attachAddressInput = (el) => {
271 - if (!el) return;
272 - GoingsOn.autocomplete.attach(el);
273 - if (GoingsOn.addressHighlight) {
274 - GoingsOn.addressHighlight.attach(el, {
275 - contacts: GoingsOn.autocomplete.getContacts,
276 - });
277 - }
278 301 };
279 - attachAddressInput(document.querySelector('[name="to"]'));
280 - attachAddressInput(document.getElementById('modal-cc-address'));
281 - attachAddressInput(document.getElementById('modal-bcc-address'));
282 -
283 - // CC/BCC toggle — mirrors compose.html's toggleCcBcc behavior.
284 - const ccRow = document.getElementById('modal-cc-row');
285 - const bccRow = document.getElementById('modal-bcc-row');
286 - const toggleBtn = document.getElementById('modal-toggle-cc');
287 - if (ccRow && bccRow && toggleBtn) {
288 - toggleBtn.addEventListener('click', () => {
289 - const hidden = ccRow.style.display === 'none';
290 - ccRow.style.display = hidden ? '' : 'none';
291 - bccRow.style.display = hidden ? '' : 'none';
292 - toggleBtn.textContent = hidden ? 'Hide CC/BCC' : 'Show CC/BCC';
293 - });
294 - }
295 -
296 - // Handle account change → swap signature
297 - const accountSelect = document.querySelector('[name="account_id"]');
298 - if (accountSelect) {
299 - let prevSig = sig || '';
300 - accountSelect.addEventListener('change', () => {
301 - const bodyEl = document.querySelector('[name="body"]');
302 - if (!bodyEl) return;
303 - let body = bodyEl.value;
304 - if (prevSig) {
305 - const block = '\n\n-- \n' + prevSig;
306 - if (body.endsWith(block)) body = body.slice(0, -block.length);
307 - }
308 - const newAccount = accounts.find(a => a.id === accountSelect.value);
309 - const newSig = newAccount?.emailSignature || '';
310 - prevSig = newSig;
311 - bodyEl.value = body + (newSig ? '\n\n-- \n' + newSig : '');
312 - });
313 - }
302 + form.addEventListener('submit', (e) => { e.preventDefault(); submit(); });
303 + if (sendBtn) sendBtn.addEventListener('click', (e) => { e.preventDefault(); submit(); });
314 304 }, 50);
315 305 }
316 306
@@ -905,61 +895,10 @@
905 895 }
906 896 }
907 897
908 - // Phase 7 Tier 6 #3 stage 1: attachment caps + formatBytes now live on
909 - // `GoingsOn.composeForm`. These local aliases keep the surrounding call
910 - // sites readable.
911 - const ATTACHMENT_HARD_CAP = GoingsOn.composeForm.ATTACHMENT_HARD_CAP_BYTES;
912 - const ATTACHMENT_WARN_CAP = GoingsOn.composeForm.ATTACHMENT_WARN_CAP_BYTES;
913 - const _formatBytes = GoingsOn.composeForm.formatBytes;
914 -
915 - async function pickModalAttachment() {
916 - try {
917 - const { open } = window.__TAURI__.dialog;
918 - const selected = await open({ multiple: true, title: 'Select files to attach' });
919 - if (!selected) return;
920 - const paths = Array.isArray(selected) ? selected : [selected];
921 - for (const p of paths) {
922 - const filePath = typeof p === 'string' ? p : p.path;
923 - if (!filePath || modalAttachedFiles.some(f => f.path === filePath)) continue;
924 - const name = filePath.split(/[/\\]/).pop() || 'file';
925 - let size = 0;
926 - try { size = await GoingsOn.api.attachments.fileSize(filePath); } catch (_) { /* leave 0 */ }
927 - modalAttachedFiles.push({ path: filePath, name, size });
928 - }
929 - renderModalAttachments();
930 - } catch (err) {
931 - if (err && err.toString().includes('cancelled')) return;
932 - }
933 - }
934 -
935 - function renderModalAttachments() {
936 - const el = document.getElementById('modal-attachments');
937 - if (!el) return;
938 - const tags = modalAttachedFiles.map((f, i) =>
939 - `<span class="email-attachment-tag">${esc(f.name)} <span class="email-attachment-size">${_formatBytes(f.size || 0)}</span> <button type="button" class="email-attachment-remove-btn" onclick="GoingsOn.emails._removeModalAttachment(${i})">&times;</button></span>`
940 - ).join('');
941 - const total = modalAttachedFiles.reduce((s, f) => s + (f.size || 0), 0);
942 - let totalLine = '';
943 - if (modalAttachedFiles.length > 0) {
944 - const overCap = total > ATTACHMENT_HARD_CAP;
945 - const overWarn = total > ATTACHMENT_WARN_CAP;
946 - const cls = overCap ? 'compose-attachment-total over-cap'
947 - : overWarn ? 'compose-attachment-total over-warn'
948 - : 'compose-attachment-total';
949 - const warn = overCap
950 - ? '<span class="compose-attachment-warn">— most servers will reject this.</span>'
951 - : overWarn
952 - ? '<span class="compose-attachment-warn">— may be rejected by your mail server.</span>'
953 - : '';
954 - totalLine = `<div class="${cls}"><span>Total: ${_formatBytes(total)} / ${_formatBytes(ATTACHMENT_HARD_CAP)}</span>${warn}</div>`;
955 - }
956 - el.innerHTML = tags + totalLine;
957 - }
958 -
959 - function removeModalAttachment(index) {
960 - modalAttachedFiles.splice(index, 1);
961 - renderModalAttachments();
962 - }
898 + // Stage 4: the modal's attachment picker / render / remove are owned by
899 + // composeForm.bindBehaviors. The local pickModalAttachment /
900 + // renderModalAttachments / removeModalAttachment helpers and the
901 + // modalAttachedFiles array are gone; access via modalComposeCtrl instead.
963 902
964 903 // ============ Drafts ============
965 904
@@ -1006,6 +945,8 @@
1006 945 if (!draft) return;
1007 946 openComposeModal({
1008 947 to: draft.to || '',
948 + cc: draft.ccAddress || '',
949 + bcc: draft.bccAddress || '',
1009 950 subject: draft.subject || '',
1010 951 body: draft.body || '',
1011 952 accountId: draft.draftAccountId || '',
@@ -1455,8 +1396,6 @@
1455 1396 openBlob,
1456 1397 saveBlob,
1457 1398 search: searchEmails,
1458 - _pickModalAttachment: pickModalAttachment,
1459 - _removeModalAttachment: removeModalAttachment,
1460 1399 filterByFolder,
1461 1400 filterByLabel,
1462 1401 editLabels,
@@ -271,6 +271,15 @@
271 271 * Upcoming in the middle, Past (collapsed) at the bottom. Recurring instances are
272 272 * shown in Upcoming/Past based on their occurrence date.
273 273 */
274 + /**
275 + * Re-fetch events after a filter checkbox change. Invalidates the
276 + * view cache so load() doesn't short-circuit on the freshness check.
277 + */
278 + function onFilterChange() {
279 + GoingsOn.cache.invalidate('events');
280 + load();
281 + }
282 +
274 283 async function load() {
275 284 if (GoingsOn.cache.isFresh('events')) return;
276 285
@@ -285,7 +294,24 @@
285 294 const eventTable = document.getElementById('event-table');
286 295
287 296 try {
288 - let events = await GoingsOn.api.events.list();
297 + const showSnoozed = document.getElementById('filter-events-snoozed')?.checked || false;
298 + // list_events excludes snoozed by default; merge in list_snoozed_events
299 + // when the user opts in. De-dupe by id since recurring expansion may
300 + // collide with a snoozed template.
301 + const [mainEvents, snoozedEvents] = await Promise.all([
302 + GoingsOn.api.events.list(),
303 + showSnoozed ? GoingsOn.api.events.listSnoozed() : Promise.resolve([]),
304 + ]);
305 + let events = mainEvents;
306 + if (snoozedEvents.length > 0) {
307 + const seen = new Set(events.map(e => e.id));
308 + for (const ev of snoozedEvents) {
309 + if (!seen.has(ev.id)) {
310 + events.push(ev);
311 + seen.add(ev.id);
312 + }
313 + }
314 + }
289 315
290 316 if (events.length === 0) {
291 317 eventTable.style.display = 'none';
@@ -806,6 +832,7 @@
806 832
807 833 GoingsOn.events = {
808 834 load,
835 + onFilterChange,
809 836 openNew,
810 837 openNewForProject,
811 838 create,