Skip to main content

max / goingson

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