max / goingson
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, '<')}</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 | + | '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', | |
| 160 | + | })[c]); | |
| 161 | + | }; | |
| 162 | + | const escAttr = (s) => escHtml(s).replace(/"/g, '"'); | |
| 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, '"'); | |
| 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">×</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})">×</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, |