max / goingson
5 files changed,
+320 insertions,
-318 deletions
| @@ -28,292 +28,6 @@ | |||
| 28 | 28 | <script src="js/escape.js"></script> | |
| 29 | 29 | <script src="js/address-highlight.js"></script> | |
| 30 | 30 | <script src="js/compose-form.js"></script> | |
| 31 | - | <script> | |
| 32 | - | let accounts = []; | |
| 33 | - | let tauriInvoke = null; | |
| 34 | - | ||
| 35 | - | // Reply context from URL params (set when opening reply/reply-all) | |
| 36 | - | const replyContext = { | |
| 37 | - | inReplyTo: null, | |
| 38 | - | references: null, | |
| 39 | - | threadId: null, | |
| 40 | - | }; | |
| 41 | - | ||
| 42 | - | function getUrlParams() { | |
| 43 | - | const params = new URLSearchParams(window.location.search); | |
| 44 | - | return { | |
| 45 | - | to: params.get('to') || '', | |
| 46 | - | subject: params.get('subject') || '', | |
| 47 | - | body: params.get('body') || '', | |
| 48 | - | inReplyTo: params.get('inReplyTo') || null, | |
| 49 | - | references: params.get('references') || null, | |
| 50 | - | threadId: params.get('threadId') || null, | |
| 51 | - | accountId: params.get('accountId') || null, | |
| 52 | - | draftId: params.get('draftId') || null, | |
| 53 | - | }; | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | async function initTauri() { | |
| 57 | - | if (window.__TAURI__) { | |
| 58 | - | tauriInvoke = window.__TAURI__.core.invoke; | |
| 59 | - | return true; | |
| 60 | - | } | |
| 61 | - | return false; | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | async function invoke(command, args = {}) { | |
| 65 | - | if (!tauriInvoke) await initTauri(); | |
| 66 | - | if (!tauriInvoke) throw new Error('Tauri not available'); | |
| 67 | - | return tauriInvoke(command, args); | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | function setStatus(message, type = '') { | |
| 71 | - | const statusBar = document.getElementById('status-bar'); | |
| 72 | - | statusBar.textContent = message; | |
| 73 | - | statusBar.className = 'status-bar' + (type ? ' ' + type : ''); | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | async function loadAccounts() { | |
| 77 | - | try { | |
| 78 | - | accounts = await invoke('list_email_accounts'); | |
| 79 | - | const select = document.getElementById('from-account'); | |
| 80 | - | ||
| 81 | - | if (accounts.length === 0) { | |
| 82 | - | select.innerHTML = '<option value="">No email accounts configured</option>'; | |
| 83 | - | document.getElementById('send-btn').disabled = true; | |
| 84 | - | setStatus('No email accounts configured. Add one in Settings.', 'error'); | |
| 85 | - | return; | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | select.innerHTML = accounts.map(a => | |
| 89 | - | `<option value="${a.id}">${escapeHtml(a.account_name)} <${escapeHtml(a.email_address)}></option>` | |
| 90 | - | ).join(''); | |
| 91 | - | ||
| 92 | - | setStatus('Ready'); | |
| 93 | - | } catch (err) { | |
| 94 | - | setStatus('Failed to load accounts: ' + err, 'error'); | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | async function sendEmail() { | |
| 99 | - | if (composeCtrl) composeCtrl.setSending(true); | |
| 100 | - | ||
| 101 | - | // Stages 1/4 of compose unification — payload shape + validation | |
| 102 | - | // live in compose-form.js; attached files come from the controller. | |
| 103 | - | const attachedFiles = composeCtrl ? composeCtrl.getAttachedFiles() : []; | |
| 104 | - | const input = composeForm.collectInput({ | |
| 105 | - | accountId: document.getElementById('from-account').value, | |
| 106 | - | toAddress: document.getElementById('to-address').value, | |
| 107 | - | ccAddress: document.getElementById('cc-address').value, | |
| 108 | - | bccAddress: document.getElementById('bcc-address').value, | |
| 109 | - | subject: document.getElementById('subject').value, | |
| 110 | - | body: document.getElementById('body').value, | |
| 111 | - | attachedFiles, | |
| 112 | - | replyContext, | |
| 113 | - | }); | |
| 114 | - | const result = composeForm.validateForSend(input, attachedFiles); | |
| 115 | - | if (!result.ok) { | |
| 116 | - | setStatus(result.message, 'error'); | |
| 117 | - | if (composeCtrl) composeCtrl.setSending(false); | |
| 118 | - | return; | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | document.body.classList.add('compose-loading'); | |
| 122 | - | const isReply = !!input.inReplyTo; | |
| 123 | - | setStatus(isReply ? 'Queueing reply…' : 'Queueing send…'); | |
| 124 | - | ||
| 125 | - | // Send is queued in the main app with a 5 s undo window. The | |
| 126 | - | // compose window closes immediately; the main app handles the | |
| 127 | - | // actual send and any error / save-implicit-contact follow-up. | |
| 128 | - | // Implicit-contact prompts now live on the main-app shell instead | |
| 129 | - | // of the compose window — Phase 7 Tier 1 #3. | |
| 130 | - | try { | |
| 131 | - | await window.__TAURI__.event.emit('compose:queue-send', { | |
| 132 | - | input, | |
| 133 | - | delaySeconds: 5, | |
| 134 | - | }); | |
| 135 | - | setStatus(isReply ? 'Reply queued — undo in main window' : 'Email queued — undo in main window', 'success'); | |
| 136 | - | setTimeout(() => { | |
| 137 | - | window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close(); | |
| 138 | - | }, 250); | |
| 139 | - | } catch (err) { | |
| 140 | - | document.body.classList.remove('compose-loading'); | |
| 141 | - | if (composeCtrl) composeCtrl.setSending(false); | |
| 142 | - | setStatus('Failed to queue send: ' + err, 'error'); | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | function saveDraft() { | |
| 147 | - | if (composeCtrl) composeCtrl.saveDraftNow(); | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | function discardAndClose() { | |
| 151 | - | const body = document.getElementById('body').value; | |
| 152 | - | const subject = document.getElementById('subject').value; | |
| 153 | - | const to = document.getElementById('to-address').value; | |
| 154 | - | ||
| 155 | - | if (body || subject || to) { | |
| 156 | - | if (!confirm('Discard this message?')) { | |
| 157 | - | return; | |
| 158 | - | } | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close(); | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // Stage 4 of compose unification — autocomplete, address highlight, | |
| 165 | - | // CC/BCC toggle, signature swap, and attachment picker/render/remove | |
| 166 | - | // now live in composeForm.bindBehaviors. This inline script keeps a thin | |
| 167 | - | // contacts loader (contactEmails); the controller's getContacts callback | |
| 168 | - | // reads it. escape.js bootstraps GoingsOn here, so the compose API is on | |
| 169 | - | // the namespace. | |
| 170 | - | ||
| 171 | - | const composeForm = GoingsOn.composeForm; | |
| 172 | - | let composeCtrl = null; | |
| 173 | - | let contactEmails = []; // [{name, email, isImplicit}] | |
| 174 | - | ||
| 175 | - | async function loadContactEmails() { | |
| 176 | - | try { | |
| 177 | - | // Lightweight directory (one JOIN, name + address only) instead of | |
| 178 | - | // hydrating every contact's sub-collections just for autocomplete. | |
| 179 | - | const entries = await invoke('list_contact_email_directory', { includeImplicit: true }); | |
| 180 | - | contactEmails = entries.map(e => ({ | |
| 181 | - | name: e.name || '', | |
| 182 | - | email: e.email, | |
| 183 | - | isImplicit: e.isImplicit || false, | |
| 184 | - | })); | |
| 185 | - | } catch (_) { /* contacts unavailable */ } | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | // Handle keyboard shortcuts | |
| 189 | - | document.addEventListener('keydown', (e) => { | |
| 190 | - | if (e.key === 'Escape') { | |
| 191 | - | discardAndClose(); | |
| 192 | - | } | |
| 193 | - | if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| 194 | - | sendEmail(); | |
| 195 | - | } | |
| 196 | - | if ((e.ctrlKey || e.metaKey) && e.key === 's') { | |
| 197 | - | e.preventDefault(); | |
| 198 | - | saveDraft(); | |
| 199 | - | } | |
| 200 | - | }); | |
| 201 | - | ||
| 202 | - | // Initialize | |
| 203 | - | document.addEventListener('DOMContentLoaded', async () => { | |
| 204 | - | const form = document.getElementById('compose-form'); | |
| 205 | - | form.innerHTML = composeForm.buildFieldsHtml({ | |
| 206 | - | accountsLoading: true, | |
| 207 | - | showAttachments: false, // window keeps attachments-bar outside the form | |
| 208 | - | }); | |
| 209 | - | ||
| 210 | - | // Toolbar + form wiring (was inline on* attributes; moved to | |
| 211 | - | // addEventListener so the CSP nonce can drop 'unsafe-inline'). | |
| 212 | - | document.getElementById('send-btn').addEventListener('click', sendEmail); | |
| 213 | - | document.getElementById('save-draft-btn').addEventListener('click', saveDraft); | |
| 214 | - | document.getElementById('discard-btn').addEventListener('click', discardAndClose); | |
| 215 | - | form.addEventListener('submit', (e) => e.preventDefault()); | |
| 216 | - | ||
| 217 | - | await initTauri(); | |
| 218 | - | await loadAccounts(); | |
| 219 | - | await loadContactEmails(); | |
| 220 | - | ||
| 221 | - | // Stage 4 — single bindBehaviors call replaces the old | |
| 222 | - | // setupAutocomplete / attachAddressHighlight / appendSignatureForAccount | |
| 223 | - | // / toggleCcBcc / pickAttachment / renderAttachments wiring. Must | |
| 224 | - | // happen after loadAccounts so the controller sees the account list | |
| 225 | - | // for signature swaps. | |
| 226 | - | composeCtrl = composeForm.bindBehaviors({ | |
| 227 | - | accounts, | |
| 228 | - | getContacts: () => contactEmails, | |
| 229 | - | onError: (msg) => setStatus(msg, 'error'), | |
| 230 | - | enableAutosave: true, | |
| 231 | - | saveDraft: (input) => invoke('save_email_draft', { input }), | |
| 232 | - | getReplyContext: () => replyContext, | |
| 233 | - | onDraftStatus: (kind, message) => setStatus(message, kind === 'error' ? 'error' : 'success'), | |
| 234 | - | enableReplyIndicator: true, | |
| 235 | - | }); | |
| 236 | - | // Pause autosave during init so the URL-param / draft-resume path | |
| 237 | - | // doesn't race with the first keystroke and create an orphan draft. | |
| 238 | - | composeCtrl.setSending(true); | |
| 239 | - | document.getElementById('attach-btn').addEventListener('click', composeCtrl.pickAttachment); | |
| 240 | - | ||
| 241 | - | // Check for draft ID to resume editing | |
| 242 | - | const params = getUrlParams(); | |
| 243 | - | if (params.draftId) { | |
| 244 | - | try { | |
| 245 | - | const draft = await invoke('get_email', { id: params.draftId }); | |
| 246 | - | if (draft && draft.isDraft) { | |
| 247 | - | composeCtrl.setCurrentDraftId(draft.id); | |
| 248 | - | document.getElementById('to-address').value = draft.to || ''; | |
| 249 | - | document.getElementById('cc-address').value = draft.ccAddress || ''; | |
| 250 | - | document.getElementById('bcc-address').value = draft.bccAddress || ''; | |
| 251 | - | document.getElementById('subject').value = draft.subject || ''; | |
| 252 | - | document.getElementById('body').value = draft.body || ''; | |
| 253 | - | if (draft.ccAddress || draft.bccAddress) composeCtrl.toggleCcBcc(); | |
| 254 | - | if (draft.draftAccountId) { | |
| 255 | - | const select = document.getElementById('from-account'); | |
| 256 | - | if (select.querySelector(`option[value="${draft.draftAccountId}"]`)) { | |
| 257 | - | select.value = draft.draftAccountId; | |
| 258 | - | } | |
| 259 | - | } | |
| 260 | - | if (draft.inReplyTo) { | |
| 261 | - | replyContext.inReplyTo = draft.inReplyTo; | |
| 262 | - | replyContext.threadId = draft.threadId; | |
| 263 | - | } | |
| 264 | - | setStatus('Editing draft'); | |
| 265 | - | // Trigger address highlight sync for loaded values | |
| 266 | - | for (const id of ['to-address', 'cc-address', 'bcc-address']) { | |
| 267 | - | document.getElementById(id).dispatchEvent(new Event('input')); | |
| 268 | - | } | |
| 269 | - | } | |
| 270 | - | } catch (_) { /* draft not found, start fresh */ } | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | const hasResumedDraft = !!composeCtrl.getCurrentDraftId(); | |
| 274 | - | // Apply reply context from URL params (skip if draft was loaded) | |
| 275 | - | if (!hasResumedDraft && params.to) { | |
| 276 | - | document.getElementById('to-address').value = params.to; | |
| 277 | - | document.getElementById('to-address').dispatchEvent(new Event('input')); | |
| 278 | - | } | |
| 279 | - | if (!hasResumedDraft && params.subject) { | |
| 280 | - | document.getElementById('subject').value = params.subject; | |
| 281 | - | } | |
| 282 | - | if (!hasResumedDraft && params.body) { | |
| 283 | - | document.getElementById('body').value = params.body; | |
| 284 | - | } | |
| 285 | - | if (!hasResumedDraft && params.inReplyTo) { | |
| 286 | - | replyContext.inReplyTo = params.inReplyTo; | |
| 287 | - | replyContext.references = params.references; | |
| 288 | - | replyContext.threadId = params.threadId; | |
| 289 | - | } | |
| 290 | - | // Auto-select the From account that received the original email | |
| 291 | - | if (params.accountId) { | |
| 292 | - | const select = document.getElementById('from-account'); | |
| 293 | - | if (select.querySelector(`option[value="${params.accountId}"]`)) { | |
| 294 | - | select.value = params.accountId; | |
| 295 | - | } | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | // Reply indicator + send-button relabel — controller reads replyContext. | |
| 299 | - | composeCtrl.updateReplyIndicator(); | |
| 300 | - | ||
| 301 | - | // Append signature for the selected account | |
| 302 | - | composeCtrl.appendSignatureForAccount(document.getElementById('from-account').value); | |
| 303 | - | ||
| 304 | - | // Focus: body for replies (to/subject already filled), to for new compose | |
| 305 | - | if (params.inReplyTo) { | |
| 306 | - | document.getElementById('body').focus(); | |
| 307 | - | // Place cursor at the start (before quoted text) | |
| 308 | - | const bodyEl = document.getElementById('body'); | |
| 309 | - | bodyEl.setSelectionRange(0, 0); | |
| 310 | - | } else { | |
| 311 | - | document.getElementById('to-address').focus(); | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | // Init done — re-enable autosave on user input. | |
| 315 | - | composeCtrl.setSending(false); | |
| 316 | - | }); | |
| 317 | - | </script> | |
| 31 | + | <script src="js/compose-page.js"></script> | |
| 318 | 32 | </body> | |
| 319 | 33 | </html> |
| @@ -26,35 +26,7 @@ | |||
| 26 | 26 | on Safari 13+ reports as Mac by default; the maxTouchPoints branch | |
| 27 | 27 | catches that. Production lockdown of overrides is deferred to | |
| 28 | 28 | phase 5 of ui_mode_separation_plan.md. --> | |
| 29 | - | <script> | |
| 30 | - | (function () { | |
| 31 | - | var mode; | |
| 32 | - | try { | |
| 33 | - | var p = new URLSearchParams(location.search).get('ui'); | |
| 34 | - | if (p === 'mobile' || p === 'desktop') mode = p; | |
| 35 | - | } catch (e) {} | |
| 36 | - | if (!mode) { | |
| 37 | - | try { | |
| 38 | - | var ls = localStorage.getItem('goingson.uiMode'); | |
| 39 | - | if (ls === 'mobile' || ls === 'desktop') mode = ls; | |
| 40 | - | } catch (e) {} | |
| 41 | - | } | |
| 42 | - | if (!mode) { | |
| 43 | - | var uad = navigator.userAgentData; | |
| 44 | - | if (uad && typeof uad.mobile === 'boolean') { | |
| 45 | - | mode = uad.mobile ? 'mobile' : 'desktop'; | |
| 46 | - | } | |
| 47 | - | } | |
| 48 | - | if (!mode) { | |
| 49 | - | var ua = navigator.userAgent; | |
| 50 | - | var isMobileUA = /iPhone OS|iPad|Android/.test(ua); | |
| 51 | - | var isIPadAsMac = /Mac/.test(navigator.platform || '') && navigator.maxTouchPoints > 1; | |
| 52 | - | mode = (isMobileUA || isIPadAsMac) ? 'mobile' : 'desktop'; | |
| 53 | - | } | |
| 54 | - | document.documentElement.classList.add('ui-mode-' + mode); | |
| 55 | - | window.__GO_UI_MODE__ = mode; | |
| 56 | - | })(); | |
| 57 | - | </script> | |
| 29 | + | <script src="js/bootstrap-uimode.js"></script> | |
| 58 | 30 | ||
| 59 | 31 | <link rel="stylesheet" href="css/styles.min.css"> | |
| 60 | 32 | </head> |
| @@ -0,0 +1,30 @@ | |||
| 1 | + | /* UI-mode bootstrap. Runs before the stylesheet so the ui-mode class is on | |
| 2 | + | <html> when CSS evaluates. Externalized from an inline <script> so the CSP | |
| 3 | + | script-src can drop 'unsafe-inline'. */ | |
| 4 | + | (function () { | |
| 5 | + | var mode; | |
| 6 | + | try { | |
| 7 | + | var p = new URLSearchParams(location.search).get('ui'); | |
| 8 | + | if (p === 'mobile' || p === 'desktop') mode = p; | |
| 9 | + | } catch (e) {} | |
| 10 | + | if (!mode) { | |
| 11 | + | try { | |
| 12 | + | var ls = localStorage.getItem('goingson.uiMode'); | |
| 13 | + | if (ls === 'mobile' || ls === 'desktop') mode = ls; | |
| 14 | + | } catch (e) {} | |
| 15 | + | } | |
| 16 | + | if (!mode) { | |
| 17 | + | var uad = navigator.userAgentData; | |
| 18 | + | if (uad && typeof uad.mobile === 'boolean') { | |
| 19 | + | mode = uad.mobile ? 'mobile' : 'desktop'; | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | if (!mode) { | |
| 23 | + | var ua = navigator.userAgent; | |
| 24 | + | var isMobileUA = /iPhone OS|iPad|Android/.test(ua); | |
| 25 | + | var isIPadAsMac = /Mac/.test(navigator.platform || '') && navigator.maxTouchPoints > 1; | |
| 26 | + | mode = (isMobileUA || isIPadAsMac) ? 'mobile' : 'desktop'; | |
| 27 | + | } | |
| 28 | + | document.documentElement.classList.add('ui-mode-' + mode); | |
| 29 | + | window.__GO_UI_MODE__ = mode; | |
| 30 | + | })(); |
| @@ -0,0 +1,287 @@ | |||
| 1 | + | /* Compose-window page controller. Externalized from an inline <script> so | |
| 2 | + | the CSP script-src can drop 'unsafe-inline'. */ | |
| 3 | + | let accounts = []; | |
| 4 | + | let tauriInvoke = null; | |
| 5 | + | ||
| 6 | + | // Reply context from URL params (set when opening reply/reply-all) | |
| 7 | + | const replyContext = { | |
| 8 | + | inReplyTo: null, | |
| 9 | + | references: null, | |
| 10 | + | threadId: null, | |
| 11 | + | }; | |
| 12 | + | ||
| 13 | + | function getUrlParams() { | |
| 14 | + | const params = new URLSearchParams(window.location.search); | |
| 15 | + | return { | |
| 16 | + | to: params.get('to') || '', | |
| 17 | + | subject: params.get('subject') || '', | |
| 18 | + | body: params.get('body') || '', | |
| 19 | + | inReplyTo: params.get('inReplyTo') || null, | |
| 20 | + | references: params.get('references') || null, | |
| 21 | + | threadId: params.get('threadId') || null, | |
| 22 | + | accountId: params.get('accountId') || null, | |
| 23 | + | draftId: params.get('draftId') || null, | |
| 24 | + | }; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | async function initTauri() { | |
| 28 | + | if (window.__TAURI__) { | |
| 29 | + | tauriInvoke = window.__TAURI__.core.invoke; | |
| 30 | + | return true; | |
| 31 | + | } | |
| 32 | + | return false; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | async function invoke(command, args = {}) { | |
| 36 | + | if (!tauriInvoke) await initTauri(); | |
| 37 | + | if (!tauriInvoke) throw new Error('Tauri not available'); | |
| 38 | + | return tauriInvoke(command, args); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | function setStatus(message, type = '') { | |
| 42 | + | const statusBar = document.getElementById('status-bar'); | |
| 43 | + | statusBar.textContent = message; | |
| 44 | + | statusBar.className = 'status-bar' + (type ? ' ' + type : ''); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | async function loadAccounts() { | |
| 48 | + | try { | |
| 49 | + | accounts = await invoke('list_email_accounts'); | |
| 50 | + | const select = document.getElementById('from-account'); | |
| 51 | + | ||
| 52 | + | if (accounts.length === 0) { | |
| 53 | + | select.innerHTML = '<option value="">No email accounts configured</option>'; | |
| 54 | + | document.getElementById('send-btn').disabled = true; | |
| 55 | + | setStatus('No email accounts configured. Add one in Settings.', 'error'); | |
| 56 | + | return; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | select.innerHTML = accounts.map(a => | |
| 60 | + | `<option value="${a.id}">${escapeHtml(a.account_name)} <${escapeHtml(a.email_address)}></option>` | |
| 61 | + | ).join(''); | |
| 62 | + | ||
| 63 | + | setStatus('Ready'); | |
| 64 | + | } catch (err) { | |
| 65 | + | setStatus('Failed to load accounts: ' + err, 'error'); | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | async function sendEmail() { | |
| 70 | + | if (composeCtrl) composeCtrl.setSending(true); | |
| 71 | + | ||
| 72 | + | // Stages 1/4 of compose unification — payload shape + validation | |
| 73 | + | // live in compose-form.js; attached files come from the controller. | |
| 74 | + | const attachedFiles = composeCtrl ? composeCtrl.getAttachedFiles() : []; | |
| 75 | + | const input = composeForm.collectInput({ | |
| 76 | + | accountId: document.getElementById('from-account').value, | |
| 77 | + | toAddress: document.getElementById('to-address').value, | |
| 78 | + | ccAddress: document.getElementById('cc-address').value, | |
| 79 | + | bccAddress: document.getElementById('bcc-address').value, | |
| 80 | + | subject: document.getElementById('subject').value, | |
| 81 | + | body: document.getElementById('body').value, | |
| 82 | + | attachedFiles, | |
| 83 | + | replyContext, | |
| 84 | + | }); | |
| 85 | + | const result = composeForm.validateForSend(input, attachedFiles); | |
| 86 | + | if (!result.ok) { | |
| 87 | + | setStatus(result.message, 'error'); | |
| 88 | + | if (composeCtrl) composeCtrl.setSending(false); | |
| 89 | + | return; | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | document.body.classList.add('compose-loading'); | |
| 93 | + | const isReply = !!input.inReplyTo; | |
| 94 | + | setStatus(isReply ? 'Queueing reply…' : 'Queueing send…'); | |
| 95 | + | ||
| 96 | + | // Send is queued in the main app with a 5 s undo window. The | |
| 97 | + | // compose window closes immediately; the main app handles the | |
| 98 | + | // actual send and any error / save-implicit-contact follow-up. | |
| 99 | + | // Implicit-contact prompts now live on the main-app shell instead | |
| 100 | + | // of the compose window — Phase 7 Tier 1 #3. | |
| 101 | + | try { | |
| 102 | + | await window.__TAURI__.event.emit('compose:queue-send', { | |
| 103 | + | input, | |
| 104 | + | delaySeconds: 5, | |
| 105 | + | }); | |
| 106 | + | setStatus(isReply ? 'Reply queued — undo in main window' : 'Email queued — undo in main window', 'success'); | |
| 107 | + | setTimeout(() => { | |
| 108 | + | window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close(); | |
| 109 | + | }, 250); | |
| 110 | + | } catch (err) { | |
| 111 | + | document.body.classList.remove('compose-loading'); | |
| 112 | + | if (composeCtrl) composeCtrl.setSending(false); | |
| 113 | + | setStatus('Failed to queue send: ' + err, 'error'); | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | function saveDraft() { | |
| 118 | + | if (composeCtrl) composeCtrl.saveDraftNow(); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | function discardAndClose() { | |
| 122 | + | const body = document.getElementById('body').value; | |
| 123 | + | const subject = document.getElementById('subject').value; | |
| 124 | + | const to = document.getElementById('to-address').value; | |
| 125 | + | ||
| 126 | + | if (body || subject || to) { | |
| 127 | + | if (!confirm('Discard this message?')) { | |
| 128 | + | return; | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | window.__TAURI__.webviewWindow.getCurrentWebviewWindow().close(); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // Stage 4 of compose unification — autocomplete, address highlight, | |
| 136 | + | // CC/BCC toggle, signature swap, and attachment picker/render/remove | |
| 137 | + | // now live in composeForm.bindBehaviors. This inline script keeps a thin | |
| 138 | + | // contacts loader (contactEmails); the controller's getContacts callback | |
| 139 | + | // reads it. escape.js bootstraps GoingsOn here, so the compose API is on | |
| 140 | + | // the namespace. | |
| 141 | + | ||
| 142 | + | const composeForm = GoingsOn.composeForm; | |
| 143 | + | let composeCtrl = null; | |
| 144 | + | let contactEmails = []; // [{name, email, isImplicit}] | |
| 145 | + | ||
| 146 | + | async function loadContactEmails() { | |
| 147 | + | try { | |
| 148 | + | // Lightweight directory (one JOIN, name + address only) instead of | |
| 149 | + | // hydrating every contact's sub-collections just for autocomplete. | |
| 150 | + | const entries = await invoke('list_contact_email_directory', { includeImplicit: true }); | |
| 151 | + | contactEmails = entries.map(e => ({ | |
| 152 | + | name: e.name || '', | |
| 153 | + | email: e.email, | |
| 154 | + | isImplicit: e.isImplicit || false, | |
| 155 | + | })); | |
| 156 | + | } catch (_) { /* contacts unavailable */ } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Handle keyboard shortcuts | |
| 160 | + | document.addEventListener('keydown', (e) => { | |
| 161 | + | if (e.key === 'Escape') { | |
| 162 | + | discardAndClose(); | |
| 163 | + | } | |
| 164 | + | if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { | |
| 165 | + | sendEmail(); | |
| 166 | + | } | |
| 167 | + | if ((e.ctrlKey || e.metaKey) && e.key === 's') { | |
| 168 | + | e.preventDefault(); | |
| 169 | + | saveDraft(); | |
| 170 | + | } | |
| 171 | + | }); | |
| 172 | + | ||
| 173 | + | // Initialize | |
| 174 | + | document.addEventListener('DOMContentLoaded', async () => { | |
| 175 | + | const form = document.getElementById('compose-form'); | |
| 176 | + | form.innerHTML = composeForm.buildFieldsHtml({ | |
| 177 | + | accountsLoading: true, | |
| 178 | + | showAttachments: false, // window keeps attachments-bar outside the form | |
| 179 | + | }); | |
| 180 | + | ||
| 181 | + | // Toolbar + form wiring (was inline on* attributes; moved to | |
| 182 | + | // addEventListener so the CSP nonce can drop 'unsafe-inline'). | |
| 183 | + | document.getElementById('send-btn').addEventListener('click', sendEmail); | |
| 184 | + | document.getElementById('save-draft-btn').addEventListener('click', saveDraft); | |
| 185 | + | document.getElementById('discard-btn').addEventListener('click', discardAndClose); | |
| 186 | + | form.addEventListener('submit', (e) => e.preventDefault()); | |
| 187 | + | ||
| 188 | + | await initTauri(); | |
| 189 | + | await loadAccounts(); | |
| 190 | + | await loadContactEmails(); | |
| 191 | + | ||
| 192 | + | // Stage 4 — single bindBehaviors call replaces the old | |
| 193 | + | // setupAutocomplete / attachAddressHighlight / appendSignatureForAccount | |
| 194 | + | // / toggleCcBcc / pickAttachment / renderAttachments wiring. Must | |
| 195 | + | // happen after loadAccounts so the controller sees the account list | |
| 196 | + | // for signature swaps. | |
| 197 | + | composeCtrl = composeForm.bindBehaviors({ | |
| 198 | + | accounts, | |
| 199 | + | getContacts: () => contactEmails, | |
| 200 | + | onError: (msg) => setStatus(msg, 'error'), | |
| 201 | + | enableAutosave: true, | |
| 202 | + | saveDraft: (input) => invoke('save_email_draft', { input }), | |
| 203 | + | getReplyContext: () => replyContext, | |
| 204 | + | onDraftStatus: (kind, message) => setStatus(message, kind === 'error' ? 'error' : 'success'), | |
| 205 | + | enableReplyIndicator: true, | |
| 206 | + | }); | |
| 207 | + | // Pause autosave during init so the URL-param / draft-resume path | |
| 208 | + | // doesn't race with the first keystroke and create an orphan draft. | |
| 209 | + | composeCtrl.setSending(true); | |
| 210 | + | document.getElementById('attach-btn').addEventListener('click', composeCtrl.pickAttachment); | |
| 211 | + | ||
| 212 | + | // Check for draft ID to resume editing | |
| 213 | + | const params = getUrlParams(); | |
| 214 | + | if (params.draftId) { | |
| 215 | + | try { | |
| 216 | + | const draft = await invoke('get_email', { id: params.draftId }); | |
| 217 | + | if (draft && draft.isDraft) { | |
| 218 | + | composeCtrl.setCurrentDraftId(draft.id); | |
| 219 | + | document.getElementById('to-address').value = draft.to || ''; | |
| 220 | + | document.getElementById('cc-address').value = draft.ccAddress || ''; | |
| 221 | + | document.getElementById('bcc-address').value = draft.bccAddress || ''; | |
| 222 | + | document.getElementById('subject').value = draft.subject || ''; | |
| 223 | + | document.getElementById('body').value = draft.body || ''; | |
| 224 | + | if (draft.ccAddress || draft.bccAddress) composeCtrl.toggleCcBcc(); | |
| 225 | + | if (draft.draftAccountId) { | |
| 226 | + | const select = document.getElementById('from-account'); | |
| 227 | + | if (select.querySelector(`option[value="${draft.draftAccountId}"]`)) { | |
| 228 | + | select.value = draft.draftAccountId; | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | if (draft.inReplyTo) { | |
| 232 | + | replyContext.inReplyTo = draft.inReplyTo; | |
| 233 | + | replyContext.threadId = draft.threadId; | |
| 234 | + | } | |
| 235 | + | setStatus('Editing draft'); | |
| 236 | + | // Trigger address highlight sync for loaded values | |
| 237 | + | for (const id of ['to-address', 'cc-address', 'bcc-address']) { | |
| 238 | + | document.getElementById(id).dispatchEvent(new Event('input')); | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | } catch (_) { /* draft not found, start fresh */ } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | const hasResumedDraft = !!composeCtrl.getCurrentDraftId(); | |
| 245 | + | // Apply reply context from URL params (skip if draft was loaded) | |
| 246 | + | if (!hasResumedDraft && params.to) { | |
| 247 | + | document.getElementById('to-address').value = params.to; | |
| 248 | + | document.getElementById('to-address').dispatchEvent(new Event('input')); | |
| 249 | + | } | |
| 250 | + | if (!hasResumedDraft && params.subject) { | |
| 251 | + | document.getElementById('subject').value = params.subject; | |
| 252 | + | } | |
| 253 | + | if (!hasResumedDraft && params.body) { | |
| 254 | + | document.getElementById('body').value = params.body; | |
| 255 | + | } | |
| 256 | + | if (!hasResumedDraft && params.inReplyTo) { | |
| 257 | + | replyContext.inReplyTo = params.inReplyTo; | |
| 258 | + | replyContext.references = params.references; | |
| 259 | + | replyContext.threadId = params.threadId; | |
| 260 | + | } | |
| 261 | + | // Auto-select the From account that received the original email | |
| 262 | + | if (params.accountId) { | |
| 263 | + | const select = document.getElementById('from-account'); | |
| 264 | + | if (select.querySelector(`option[value="${params.accountId}"]`)) { | |
| 265 | + | select.value = params.accountId; | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // Reply indicator + send-button relabel — controller reads replyContext. | |
| 270 | + | composeCtrl.updateReplyIndicator(); | |
| 271 | + | ||
| 272 | + | // Append signature for the selected account | |
| 273 | + | composeCtrl.appendSignatureForAccount(document.getElementById('from-account').value); | |
| 274 | + | ||
| 275 | + | // Focus: body for replies (to/subject already filled), to for new compose | |
| 276 | + | if (params.inReplyTo) { | |
| 277 | + | document.getElementById('body').focus(); | |
| 278 | + | // Place cursor at the start (before quoted text) | |
| 279 | + | const bodyEl = document.getElementById('body'); | |
| 280 | + | bodyEl.setSelectionRange(0, 0); | |
| 281 | + | } else { | |
| 282 | + | document.getElementById('to-address').focus(); | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // Init done — re-enable autosave on user input. | |
| 286 | + | composeCtrl.setSending(false); | |
| 287 | + | }); |
| @@ -23,8 +23,7 @@ | |||
| 23 | 23 | } | |
| 24 | 24 | ], | |
| 25 | 25 | "security": { | |
| 26 | - | "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", | |
| 27 | - | "dangerousDisableAssetCspModification": ["script-src"] | |
| 26 | + | "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: asset: http://asset.localhost; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" | |
| 28 | 27 | } | |
| 29 | 28 | }, | |
| 30 | 29 | "bundle": { |