max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+792 insertions,
-303 deletions
| @@ -695,6 +695,9 @@ | |||
| 695 | 695 | <script src="js/events.js"></script> | |
| 696 | 696 | <script src="js/events-calendar.js"></script> | |
| 697 | 697 | <script src="js/emails-render.js"></script> | |
| 698 | + | <script src="js/emails-threads.js"></script> | |
| 699 | + | <script src="js/emails-reader.js"></script> | |
| 700 | + | <script src="js/emails-filter.js"></script> | |
| 698 | 701 | <script src="js/emails-compose.js"></script> | |
| 699 | 702 | <script src="js/emails.js"></script> | |
| 700 | 703 | <script src="js/email-accounts.js"></script> |
| @@ -186,6 +186,8 @@ | |||
| 186 | 186 | buildReplyPrefill: (id, replyAll) => invoke('build_reply_prefill', { id, replyAll }), // Rust builds reply recipients/subject/quote | |
| 187 | 187 | buildForwardPrefill: (id) => invoke('build_forward_prefill', { id }), // Rust builds Fwd: subject + forwarded body | |
| 188 | 188 | fetchFullBody: (id) => invoke('fetch_email_full_body', { id }), // Lazy-load a body truncated at sync (JMAP >100KB) | |
| 189 | + | createTaskFrom: (id) => invoke('create_task_from_email', { id }), // Rust derives + creates; resolves the sender contact server-side | |
| 190 | + | createEventFrom: (id) => invoke('create_event_from_email', { id }), // Same, with the next-whole-hour default span | |
| 189 | 191 | create: (input) => invoke('create_email', { input }), | |
| 190 | 192 | send: (input) => invoke('send_email', { input }), // SMTP send + save copy to local DB | |
| 191 | 193 | delete: (id) => invoke('delete_email', { id }), |
| @@ -1,6 +1,13 @@ | |||
| 1 | 1 | /** | |
| 2 | 2 | * GoingsOn - Emails Module | |
| 3 | - | * Email list, compose, threading, actions (archive/delete/mark). | |
| 3 | + | * The thread list: loading, paging, the virtual scroller, and the per-email | |
| 4 | + | * actions that mutate it. `GoingsOn.emails` is the public surface the dispatch | |
| 5 | + | * layer resolves against, so it re-exports the split-out modules: | |
| 6 | + | * emails-threads.js list-state mutations | |
| 7 | + | * emails-reader.js the reader modal, attachments, open-in-browser | |
| 8 | + | * emails-filter.js folder/label filters and their modals | |
| 9 | + | * emails-render.js the list row | |
| 10 | + | * emails-compose.js compose, reply, forward, drafts | |
| 4 | 11 | * Account management and OAuth live in email-accounts.js. | |
| 5 | 12 | */ | |
| 6 | 13 | ||
| @@ -10,8 +17,8 @@ | |||
| 10 | 17 | 'use strict'; | |
| 11 | 18 | const esc = GoingsOn.utils.escapeHtml; | |
| 12 | 19 | const escAttr = GoingsOn.utils.escapeAttrValue; | |
| 13 | - | const escArg = GoingsOn.utils.escapeHandlerArg; | |
| 14 | - | const escAttrVal = GoingsOn.utils.escapeAttrValue; | |
| 20 | + | const threads = GoingsOn.emailsThreads; | |
| 21 | + | const filters = GoingsOn.emailsFilter; | |
| 15 | 22 | ||
| 16 | 23 | // Email Selection & Pagination | |
| 17 | 24 | ||
| @@ -28,59 +35,17 @@ | |||
| 28 | 35 | // Email threads stored in centralized state for virtual scrolling | |
| 29 | 36 | GoingsOn.state.set('emailThreads', []); | |
| 30 | 37 | ||
| 31 | - | // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). Reading an email, the | |
| 32 | - | // single most frequent action in the app, used to refetch the whole 500-thread | |
| 33 | - | // window. Wire one subscriber so a surgical state.set('emailThreads', ...) | |
| 34 | - | // re-renders the list in place. `suppressEmailRender` gates it during the full | |
| 35 | - | // rebuild in load()/searchEmails so set() there doesn't refresh a scroller that | |
| 36 | - | // is about to be (re)created on the next line. | |
| 38 | + | // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). One subscriber, so a | |
| 39 | + | // surgical emailsThreads mutation re-renders the list in place instead of | |
| 40 | + | // refetching the whole window. `suppressEmailRender` gates it during the | |
| 41 | + | // full rebuild in load()/searchEmails so set() there doesn't refresh a | |
| 42 | + | // scroller that is about to be (re)created on the next line. | |
| 37 | 43 | let suppressEmailRender = false; | |
| 38 | 44 | GoingsOn.state.subscribe('emailThreads', () => { | |
| 39 | 45 | if (suppressEmailRender) return; | |
| 40 | 46 | if (emailScroller) emailScroller.refresh(); | |
| 41 | 47 | }); | |
| 42 | 48 | ||
| 43 | - | // Surgical single-thread list mutations (CHRONIC-E) | |
| 44 | - | // Each mutates state.emailThreads in place and lets the subscriber refresh the | |
| 45 | - | // scroller, replacing the old `reload: load` that refetched 500 threads on | |
| 46 | - | // every single-email action (ultra-fuzz Run #28 S4). Threads are matched by | |
| 47 | - | // their representative (most-recent) email id, which is the id the list rows | |
| 48 | - | // and the reader action bar operate on. | |
| 49 | - | ||
| 50 | - | /** Drop the thread whose representative email is `emailId` from the list. */ | |
| 51 | - | function _removeThread(emailId) { | |
| 52 | - | const threads = GoingsOn.state.emailThreads || []; | |
| 53 | - | GoingsOn.state.set('emailThreads', threads.filter(t => t.mostRecentEmail.id !== emailId)); | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | /** Toggle a thread's read state. Mirrors the bulk markRead pattern. */ | |
| 57 | - | function _setThreadRead(emailId, read) { | |
| 58 | - | const threads = GoingsOn.state.emailThreads || []; | |
| 59 | - | GoingsOn.state.set('emailThreads', threads.map(t => | |
| 60 | - | t.mostRecentEmail.id === emailId | |
| 61 | - | ? { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: read }, hasUnread: !read } | |
| 62 | - | : t | |
| 63 | - | )); | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | /** | |
| 67 | - | * Mark the opened message read and recompute the thread's unread flag from the | |
| 68 | - | * thread we just loaded. hasUnread is cleared only when no *other* message in | |
| 69 | - | * the thread is still unread, so a multi-message thread with remaining unread | |
| 70 | - | * keeps its dot rather than wrongly showing read (GO todo hazard note). | |
| 71 | - | */ | |
| 72 | - | function _markThreadReadForEmail(emailId, threadEmails) { | |
| 73 | - | const threads = GoingsOn.state.emailThreads || []; | |
| 74 | - | const stillUnread = (threadEmails || []).some(e => e.id !== emailId && !e.isRead); | |
| 75 | - | let changed = false; | |
| 76 | - | const next = threads.map(t => { | |
| 77 | - | if (t.mostRecentEmail.id !== emailId) return t; | |
| 78 | - | changed = true; | |
| 79 | - | return { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: true }, hasUnread: stillUnread }; | |
| 80 | - | }); | |
| 81 | - | if (changed) GoingsOn.state.set('emailThreads', next); | |
| 82 | - | } | |
| 83 | - | ||
| 84 | 49 | // Incremental pagination. The thread list streams in pages via the scroller's | |
| 85 | 50 | // onNeedMore hook instead of capping at a fixed 500 rows, so a large mailbox's | |
| 86 | 51 | // tail is reachable (ultra-fuzz Run #28 S4). baseFilters holds the current | |
| @@ -88,6 +53,25 @@ | |||
| 88 | 53 | const EMAIL_PAGE_SIZE = 200; | |
| 89 | 54 | const emailPaging = { loadedCount: 0, total: 0, baseFilters: null }; | |
| 90 | 55 | ||
| 56 | + | /** Scroller config shared by the list and search paths. */ | |
| 57 | + | function scrollerConfig(container, extra) { | |
| 58 | + | return { | |
| 59 | + | container: container, | |
| 60 | + | renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i), | |
| 61 | + | getItems: () => GoingsOn.state.emailThreads, | |
| 62 | + | rowHeight: { estimated: 90, measure: true }, | |
| 63 | + | overscan: 5, | |
| 64 | + | ...extra, | |
| 65 | + | }; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | function destroyScroller() { | |
| 69 | + | if (emailScroller) { | |
| 70 | + | emailScroller.destroy(); | |
| 71 | + | emailScroller = null; | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 91 | 75 | /** | |
| 92 | 76 | * Fetch and append the next page of threads. Wired to the scroller's | |
| 93 | 77 | * onNeedMore hook. No-ops once every thread is loaded; on error it surfaces a | |
| @@ -106,13 +90,13 @@ | |||
| 106 | 90 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load more emails'), 'error'); | |
| 107 | 91 | return; | |
| 108 | 92 | } | |
| 109 | - | const threads = (GoingsOn.state.emailThreads || []).concat(response.threads); | |
| 110 | - | GoingsOn.state.set('emails', threads.map(t => t.mostRecentEmail)); | |
| 111 | - | GoingsOn.state.set('emailThreads', threads); | |
| 112 | - | emailPaging.loadedCount = threads.length; | |
| 93 | + | const next = threads.all().concat(response.threads); | |
| 94 | + | GoingsOn.state.set('emails', next.map(t => t.mostRecentEmail)); | |
| 95 | + | GoingsOn.state.set('emailThreads', next); | |
| 96 | + | emailPaging.loadedCount = next.length; | |
| 113 | 97 | emailPaging.total = response.total; | |
| 114 | - | emailSelection.setItems(threads.map(t => ({ id: t.mostRecentEmail.id }))); | |
| 115 | - | _updateEmailCount(emailPaging.total, emailPaging.loadedCount); | |
| 98 | + | emailSelection.setItems(next.map(t => ({ id: t.mostRecentEmail.id }))); | |
| 99 | + | threads.updateCount(emailPaging.total, emailPaging.loadedCount); | |
| 116 | 100 | // Re-arms onNeedMore so the next page can load on further scroll. | |
| 117 | 101 | if (emailScroller) emailScroller.refresh(); | |
| 118 | 102 | } | |
| @@ -128,8 +112,7 @@ | |||
| 128 | 112 | // Phase 7 Tier 4, pull active folder/label/search from URL on first | |
| 129 | 113 | // load (e.g. after reload or deep-link). Subsequent filter changes | |
| 130 | 114 | // are already URL-mirrored by the respective handlers. | |
| 131 | - | restoreFiltersFromUrl(); | |
| 132 | - | const initialSearch = GoingsOn.queryState?.read('q'); | |
| 115 | + | const initialSearch = filters.restoreFromUrl(); | |
| 133 | 116 | if (initialSearch) { | |
| 134 | 117 | searchEmails(initialSearch); | |
| 135 | 118 | return; | |
| @@ -140,11 +123,7 @@ | |||
| 140 | 123 | suppressEmailRender = true; | |
| 141 | 124 | // Reset paging for the current folder/label and fetch the first page; the | |
| 142 | 125 | // list streams in later pages via the scroller's onNeedMore hook. | |
| 143 | - | const baseFilters = { | |
| 144 | - | includeArchived: false, | |
| 145 | - | folder: activeFolder || null, | |
| 146 | - | label: activeLabel || null, | |
| 147 | - | }; | |
| 126 | + | const baseFilters = { includeArchived: false, ...filters.current() }; | |
| 148 | 127 | emailPaging.baseFilters = baseFilters; | |
| 149 | 128 | emailPaging.loadedCount = 0; | |
| 150 | 129 | emailPaging.total = 0; | |
| @@ -157,7 +136,7 @@ | |||
| 157 | 136 | }); | |
| 158 | 137 | ||
| 159 | 138 | // Refresh filter dropdowns | |
| 160 | - | loadFilters(); | |
| 139 | + | filters.loadFilters(); | |
| 161 | 140 | ||
| 162 | 141 | // Update cache with most recent emails | |
| 163 | 142 | GoingsOn.state.set('emails', response.threads.map(t => t.mostRecentEmail)); | |
| @@ -167,7 +146,7 @@ | |||
| 167 | 146 | ||
| 168 | 147 | // Phase 7 Tier 2 #9, surface the count; reads "X of N" while later | |
| 169 | 148 | // pages stream in on scroll. | |
| 170 | - | _updateEmailCount(response.total, response.threads.length); | |
| 149 | + | threads.updateCount(response.total, response.threads.length); | |
| 171 | 150 | ||
| 172 | 151 | if (response.total === 0) { | |
| 173 | 152 | const hasAccounts = GoingsOn.getEmailAccountsCache().length > 0; | |
| @@ -177,11 +156,7 @@ | |||
| 177 | 156 | // Hide pagination | |
| 178 | 157 | const paginationEl = document.getElementById('email-pagination'); | |
| 179 | 158 | if (paginationEl) paginationEl.classList.add('hidden'); | |
| 180 | - | // Destroy scroller | |
| 181 | - | if (emailScroller) { | |
| 182 | - | emailScroller.destroy(); | |
| 183 | - | emailScroller = null; | |
| 184 | - | } | |
| 159 | + | destroyScroller(); | |
| 185 | 160 | return; | |
| 186 | 161 | } | |
| 187 | 162 | ||
| @@ -193,17 +168,12 @@ | |||
| 193 | 168 | if (paginationEl) paginationEl.classList.add('hidden'); | |
| 194 | 169 | ||
| 195 | 170 | // Initialize or refresh virtual scroller | |
| 196 | - | if (!emailScroller) { | |
| 197 | - | emailScroller = new GoingsOn.VirtualScroller({ | |
| 198 | - | container: container, | |
| 199 | - | renderItem: (t, i) => GoingsOn.emailsRender.renderEmailItem(t, i), | |
| 200 | - | getItems: () => GoingsOn.state.emailThreads, | |
| 201 | - | rowHeight: { estimated: 90, measure: true }, | |
| 202 | - | overscan: 5, | |
| 203 | - | onNeedMore: loadMoreEmails, | |
| 204 | - | }); | |
| 205 | - | } else { | |
| 171 | + | if (emailScroller) { | |
| 206 | 172 | emailScroller.refresh(); | |
| 173 | + | } else { | |
| 174 | + | emailScroller = new GoingsOn.VirtualScroller( | |
| 175 | + | scrollerConfig(container, { onNeedMore: loadMoreEmails }) | |
| 176 | + | ); | |
| 207 | 177 | } | |
| 208 | 178 | GoingsOn.cache.markLoaded('emails'); | |
| 209 | 179 | } catch (err) { | |
| @@ -217,12 +187,6 @@ | |||
| 217 | 187 | } | |
| 218 | 188 | } | |
| 219 | 189 | ||
| 220 | - | /** | |
| 221 | - | * Render a single email item (for virtual scrolling). | |
| 222 | - | * @param {Object} thread - Thread object with mostRecentEmail | |
| 223 | - | * @param {number} index - Item index | |
| 224 | - | * @returns {string} HTML string | |
| 225 | - | */ | |
| 226 | 190 | async function markAllRead() { | |
| 227 | 191 | await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), { | |
| 228 | 192 | successMessage: 'All emails marked as read!', | |
| @@ -231,214 +195,12 @@ | |||
| 231 | 195 | onSuccess: () => { | |
| 232 | 196 | GoingsOn.cache.invalidate('emails'); | |
| 233 | 197 | // Surgical: clear unread across the streamed threads in place. | |
| 234 | - | const threads = GoingsOn.state.emailThreads || []; | |
| 235 | - | GoingsOn.state.set('emailThreads', threads.map(t => ({ | |
| 236 | - | ...t, | |
| 237 | - | mostRecentEmail: { ...t.mostRecentEmail, isRead: true }, | |
| 238 | - | hasUnread: false, | |
| 239 | - | }))); | |
| 198 | + | threads.markAllRead(); | |
| 240 | 199 | }, | |
| 241 | 200 | }); | |
| 242 | 201 | } | |
| 243 | 202 | ||
| 244 | - | /** | |
| 245 | - | * Open an email in reader mode, loading its full thread if available. | |
| 246 | - | * Marks the email as read and shows sender contact info. | |
| 247 | - | * @param {string} id - Email ID to open | |
| 248 | - | */ | |
| 249 | - | async function open(id) { | |
| 250 | - | try { | |
| 251 | - | const email = await GoingsOn.api.emails.get(id); | |
| 252 | - | if (!email) return; | |
| 253 | - | ||
| 254 | - | // Mark as read | |
| 255 | - | await GoingsOn.api.emails.markRead(id); | |
| 256 | - | ||
| 257 | - | // Check if this email is part of a thread | |
| 258 | - | let threadEmails = [email]; | |
| 259 | - | if (email.threadId) { | |
| 260 | - | try { | |
| 261 | - | const thread = await GoingsOn.api.emails.listByThread(email.threadId); | |
| 262 | - | if (thread && thread.length > 1) { | |
| 263 | - | // Backend returns threads sorted by received_at ASC | |
| 264 | - | threadEmails = thread; | |
| 265 | - | } | |
| 266 | - | } catch (e) { | |
| 267 | - | console.error('Failed to load thread:', e); | |
| 268 | - | } | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | const isThread = threadEmails.length > 1; | |
| 272 | - | ||
| 273 | - | // Build action buttons for the most recent email | |
| 274 | - | const latestEmail = threadEmails[threadEmails.length - 1]; | |
| 275 | - | const archiveBtn = latestEmail.isArchived | |
| 276 | - | ? `<button class="btn btn-secondary" data-act="emails.unarchive" data-a1="${escAttr(latestEmail.id)}">Unarchive</button>` | |
| 277 | - | : `<button class="btn btn-secondary" data-act="emails.archive" data-a1="${escAttr(latestEmail.id)}">Archive</button>`; | |
| 278 | - | ||
| 279 | - | // Use pre-computed field from backend | |
| 280 | - | const isSnoozed = latestEmail.isSnoozed; | |
| 281 | - | const snoozeBtn = isSnoozed | |
| 282 | - | ? `<button class="btn btn-secondary" data-act="snooze.unsnooze" data-a1="email" data-a2="${escAttr(latestEmail.id)}">Unsnooze</button>` | |
| 283 | - | : `<button class="btn btn-secondary" data-act="snooze.openModal" data-a1="email" data-a2="${escAttr(latestEmail.id)}">Snooze</button>`; | |
| 284 | - | ||
| 285 | - | // Look up contact from sender email | |
| 286 | - | const parsed = GoingsOn.utils.parseEmailAddress(email.from); | |
| 287 | - | let senderContact = null; | |
| 288 | - | if (parsed.email) { | |
| 289 | - | try { | |
| 290 | - | senderContact = await GoingsOn.api.contacts.findByEmail(parsed.email); | |
| 291 | - | } catch (e) { | |
| 292 | - | console.error('Failed to look up contact:', e); | |
| 293 | - | } | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | // Build sender contact card | |
| 297 | - | let contactCardHtml = ''; | |
| 298 | - | if (senderContact) { | |
| 299 | - | const initials = (senderContact.displayName || senderContact.display_name || '?') | |
| 300 | - | .split(/\s+/).map(w => w[0]).join('').substring(0, 2).toUpperCase(); | |
| 301 | - | const company = senderContact.company ? esc(senderContact.company) : ''; | |
| 302 | - | contactCardHtml = ` | |
| 303 | - | <div class="email-sender-contact row-flex row-flex-2"> | |
| 304 | - | <div class="avatar avatar--sm">${initials}</div> | |
| 305 | - | <div class="email-sender-info"> | |
| 306 | - | <span class="email-sender-name">${esc(senderContact.displayName || senderContact.display_name)}</span> | |
| 307 | - | ${company ? `<span class="email-sender-company">${company}</span>` : ''} | |
| 308 | - | </div> | |
| 309 | - | <button class="btn btn-sm btn-secondary" data-act="ui.closeModalThen" data-a1="contacts.open" data-a2="${escAttr(senderContact.id)}">View Contact</button> | |
| 310 | - | </div> | |
| 311 | - | `; | |
| 312 | - | } else if (parsed.email) { | |
| 313 | - | contactCardHtml = ` | |
| 314 | - | <div class="email-sender-contact row-flex row-flex-2"> | |
| 315 | - | <div class="avatar avatar--sm avatar--unknown">?</div> | |
| 316 | - | <div class="email-sender-info"> | |
| 317 | - | <span class="email-sender-name">${esc(parsed.name || parsed.email)}</span> | |
| 318 | - | </div> | |
| 319 | - | <button class="btn btn-sm btn-secondary" data-act="emails.createContactFromSender" data-a1="${escAttr(id)}">+ Save Contact</button> | |
| 320 | - | </div> | |
| 321 | - | `; | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | // Build attachment panel from all emails in thread | |
| 325 | - | const allAttachments = threadEmails.flatMap(e => | |
| 326 | - | (e.attachments || []).map(a => ({ ...a, emailFrom: e.from })) | |
| 327 | - | ); | |
| 328 | - | let attachmentHtml = ''; | |
| 329 | - | if (allAttachments.length > 0) { | |
| 330 | - | const attachmentItems = allAttachments.map(a => { | |
| 331 | - | const icon = GoingsOn.attachments.getIcon(a.mimeType); | |
| 332 | - | return ` | |
| 333 | - | <div class="email-attachment-row"> | |
| 334 | - | <span>${icon}</span> | |
| 335 | - | <span class="attachment-filename email-attachment-name" | |
| 336 | - | title="${escAttr(a.filename)}">${esc(a.filename)}</span> | |
| 337 | - | <span class="email-attachment-size">${esc(a.sizeFormatted)}</span> | |
| 338 | - | <button class="btn btn-sm btn-secondary" data-act="emails.openBlob" data-a1="${escAttr(a.blobHash)}" data-a2="${escAttr(a.filename)}" title="Open">Open</button> | |
| 339 | - | <button class="btn btn-sm btn-secondary" data-act="emails.saveBlob" data-a1="${escAttr(a.blobHash)}" data-a2="${escAttr(a.filename)}" title="Save">Save</button> | |
| 340 | - | </div> | |
| 341 | - | `; | |
| 342 | - | }).join(''); | |
| 343 | - | ||
| 344 | - | attachmentHtml = ` | |
| 345 | - | <div class="email-attachments-block"> | |
| 346 | - | <div class="email-attachments-heading">Attachments (${allAttachments.length})</div> | |
| 347 | - | ${attachmentItems} | |
| 348 | - | </div> | |
| 349 | - | `; | |
| 350 | - | } | |
| 351 | - | ||
| 352 | - | // Render thread in forum style (oldest first) | |
| 353 | - | const threadContent = threadEmails.map((e, index) => { | |
| 354 | - | const isLatest = index === threadEmails.length - 1; | |
| 355 | - | const dateStr = new Date(e.receivedAt).toLocaleString(); | |
| 356 | - | const directionIcon = e.isOutgoing ? '↗' : '↙'; // arrows for direction | |
| 357 | - | const formattedBody = GoingsOn.utils.formatEmailBody(e.body); | |
| 358 | - | ||
| 359 | - | // Bodies over 100KB are truncated at sync; offer to load the rest. | |
| 360 | - | const truncatedNotice = e.bodyTruncated | |
| 361 | - | ? `<div class="email-body-truncated" id="email-trunc-${escAttr(e.id)}"> | |
| 362 | - | <span>Message truncated.</span> | |
| 363 | - | <button class="btn btn-sm btn-secondary" data-act="emails.loadFullBody" data-a1="${escAttr(e.id)}">Load full message</button> | |
| 364 | - | </div>` | |
| 365 | - | : ''; | |
| 366 | - | ||
| 367 | - | return ` | |
| 368 | - | <div class="thread-message ${isLatest ? 'thread-message-latest' : ''}"> | |
| 369 | - | <div class="thread-message-header"> | |
| 370 | - | <span>${directionIcon} <span class="thread-message-from">${esc(e.from)}</span></span> | |
| 371 | - | <span>${dateStr}</span> | |
| 372 | - | </div> | |
| 373 | - | <div class="email-reader-body" id="email-body-${escAttr(e.id)}">${formattedBody}</div> | |
| 374 | - | ${truncatedNotice} | |
| 375 | - | </div> | |
| 376 | - | `; | |
| 377 | - | }).join(''); | |
| 378 | - | ||
| 379 | - | const subjectLine = isThread | |
| 380 | - | ? `${esc(email.subject)} <span class="email-thread-count">(${threadEmails.length} messages)</span>` | |
| 381 | - | : esc(email.subject); | |
| 382 | - | ||
| 383 | - | const content = ` | |
| 384 | - | <div class="email-reader-container"> | |
| 385 | - | <div class="email-reader-header"> | |
| 386 | - | <div class="email-subject-line">${subjectLine}</div> | |
| 387 | - | <div class="email-meta-line"> | |
| 388 | - | From: ${esc(email.from)} | |
| 389 | - | ${email.isArchived ? ' · <em>Archived</em>' : ''} | |
| 390 | - | ${email.sourceFolder ? ` · ${esc(email.sourceFolder)}` : ''} | |
| 391 | - | ${(email.labels || []).length > 0 ? ' · ' + email.labels.map(l => `<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`).join(' ') : ''} | |
| 392 | - | ${isSnoozed ? ` · <span class="email-snoozed-tag"><em>Snoozed until ${esc(latestEmail.snoozedUntilFormatted || '')}</em></span>` : ''} | |
| 393 | - | </div> | |
| 394 | - | ${contactCardHtml} | |
| 395 | - | </div> | |
| 396 | - | ${attachmentHtml} | |
| 397 | - | <div class="email-reader-thread"> | |
| 398 | - | ${threadContent} | |
| 399 | - | </div> | |
| 400 | - | <div class="form-actions email-actions-bar"> | |
| 401 | - | <button class="btn btn-primary" data-act="emails.reply" data-a1="${escAttr(latestEmail.id)}">Reply</button> | |
| 402 | - | <button class="btn btn-secondary" data-act="emails.replyAll" data-a1="${escAttr(latestEmail.id)}">Reply All</button> | |
| 403 | - | <button class="btn btn-secondary" data-act="emails.forward" data-a1="${escAttr(latestEmail.id)}">Forward</button> | |
| 404 | - | <button class="btn btn-secondary text-accent-red" data-act="emails.delete" data-a1="${escAttr(latestEmail.id)}">Delete</button> | |
| 405 | - | ${archiveBtn} | |
| 406 | - | ${snoozeBtn} | |
| 407 | - | <button class="btn btn-secondary" data-act="emails.createTaskFromEmail" data-a1="${escAttr(latestEmail.id)}">Create Task</button> | |
| 408 | - | <div class="dropdown" style="position: relative;"> | |
| 409 | - | <button class="btn btn-secondary" data-act="ui.toggleMenu" data-a1="@el"> | |
| 410 | - | Actions â–¾ | |
| 411 | - | </button> | |
| 412 | - | <div class="dropdown-menu"> | |
| 413 | - | <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createTaskFromEmail" data-a3="${escAttr(latestEmail.id)}"> | |
| 414 | - | Convert to Task | |
| 415 | - | </button> | |
| 416 | - | <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createEventFromEmail" data-a3="${escAttr(latestEmail.id)}"> | |
| 417 | - | Convert to Event | |
| 418 | - | </button> | |
| 419 | - | <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.editLabels" data-a3="${escAttr(latestEmail.id)}" data-a4="${escAttr(JSON.stringify(latestEmail.labels || []))}"> | |
| 420 | - | Edit Labels | |
| 421 | - | </button> | |
| 422 | - | <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.moveToFolder" data-a3="${escAttr(latestEmail.id)}"> | |
| 423 | - | Move to Folder | |
| 424 | - | </button> | |
| 425 | - | </div> | |
| 426 | - | </div> | |
| 427 | - | <div class="flex-1"></div> | |
| 428 | - | <button class="btn btn-secondary" data-act="emails.openInBrowser" data-a1="${escAttr(latestEmail.id)}" title="Open in browser">Open in Browser</button> | |
| 429 | - | </div> | |
| 430 | - | </div> | |
| 431 | - | `; | |
| 432 | - | GoingsOn.ui.openModal(isThread ? 'Thread' : 'Email', content, { large: true }); | |
| 433 | - | // Surgically reflect the read state instead of refetching the whole | |
| 434 | - | // window (ultra-fuzz Run #28 S4), this is the single most frequent | |
| 435 | - | // action in the app. threadEmails is the thread we just loaded. | |
| 436 | - | GoingsOn.cache.invalidate('emails'); | |
| 437 | - | _markThreadReadForEmail(id, threadEmails); | |
| 438 | - | } catch (err) { | |
| 439 | - | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load email'), 'error'); | |
| 440 | - | } | |
| 441 | - | } | |
| 203 | + | // Single-Email Actions | |
| 442 | 204 | ||
| 443 | 205 | /** | |
| 444 | 206 | * Delete an email with confirmation dialog. | |
| @@ -452,7 +214,7 @@ | |||
| 452 | 214 | errorMessage: 'Failed to delete email', | |
| 453 | 215 | onSuccess: () => { | |
| 454 | 216 | GoingsOn.cache.invalidate('emails'); | |
| 455 | - | _removeThread(id); // surgical: the deleted email leaves the list | |
| 217 | + | threads.remove(id); // surgical: the deleted email leaves the list | |
| 456 | 218 | }, | |
| 457 | 219 | }); | |
| 458 | 220 | } | |
| @@ -467,7 +229,7 @@ | |||
| 467 | 229 | errorMessage: 'Failed to archive email', | |
| 468 | 230 | onSuccess: () => { | |
| 469 | 231 | GoingsOn.cache.invalidate('emails'); | |
| 470 | - | _removeThread(id); // surgical: archived email leaves the inbox view | |
| 232 | + | threads.remove(id); // surgical: archived email leaves the inbox view | |
| 471 | 233 | }, | |
| 472 | 234 | }); | |
| 473 | 235 | } | |
| @@ -484,7 +246,7 @@ | |||
| 484 | 246 | GoingsOn.cache.invalidate('emails'); | |
| 485 | 247 | // Surgical: drop it from the archived-folder view. In the inbox view | |
| 486 | 248 | // it isn't present, so this is a no-op there. | |
| 487 | - | _removeThread(id); | |
| 249 | + | threads.remove(id); | |
| 488 | 250 | }, | |
| 489 | 251 | }); | |
| 490 | 252 | } | |
| @@ -499,7 +261,7 @@ | |||
| 499 | 261 | closeModal: false, | |
| 500 | 262 | onSuccess: () => { | |
| 501 | 263 | GoingsOn.cache.invalidate('emails'); | |
| 502 | - | _setThreadRead(id, true); | |
| 264 | + | threads.setRead(id, true); | |
| 503 | 265 | }, | |
| 504 | 266 | }); | |
| 505 | 267 | } | |
| @@ -514,14 +276,18 @@ | |||
| 514 | 276 | closeModal: false, | |
| 515 | 277 | onSuccess: () => { | |
| 516 | 278 | GoingsOn.cache.invalidate('emails'); | |
| 517 | - | _setThreadRead(id, false); | |
| 279 | + | threads.setRead(id, false); | |
| 518 | 280 | }, | |
| 519 | 281 | }); | |
| 520 | 282 | } | |
| 521 | 283 | ||
| 284 | + | // Conversions | |
| 285 | + | // The derivation rules (subject to title, default event span, body excerpt, | |
| 286 | + | // sender-contact linking) live in goingson_core::email_convert and run in | |
| 287 | + | // one command, so these are just the UI half. | |
| 288 | + | ||
| 522 | 289 | /** | |
| 523 | 290 | * Create a new contact from an email's sender address. | |
| 524 | - | * Parses the From field, creates the contact, and adds the email address. | |
| 525 | 291 | * @param {string} emailId - Email ID to extract sender from | |
| 526 | 292 | */ | |
| 527 | 293 | async function createContactFromSender(emailId) { | |
| @@ -541,12 +307,7 @@ | |||
| 541 | 307 | // Split parsed name into first/last for display name | |
| 542 | 308 | const displayName = parsed.name || parsed.email.split('@')[0]; | |
| 543 | 309 | ||
| 544 | - | // Create the contact | |
| 545 | - | const contact = await GoingsOn.api.contacts.create({ | |
| 546 | - | displayName: displayName, | |
| 547 | - | }); | |
| 548 | - | ||
| 549 | - | // Add the email address to the new contact | |
| 310 | + | const contact = await GoingsOn.api.contacts.create({ displayName: displayName }); | |
| 550 | 311 | await GoingsOn.api.contacts.addEmail(contact.id, { | |
| 551 | 312 | address: parsed.email, | |
| 552 | 313 | label: 'Work', | |
| @@ -561,47 +322,19 @@ | |||
| 561 | 322 | GoingsOn.ui.showToast('Contact saved!', 'success'); | |
| 562 | 323 | ||
| 563 | 324 | // Re-open the email reader to show the updated contact card | |
| 564 | - | open(emailId); |
Lines truncated
| @@ -95,6 +95,7 @@ | |||
| 95 | 95 | window.ResizeObserver = globalThis.ResizeObserver; | |
| 96 | 96 | ||
| 97 | 97 | require('../virtual-scroller'); // GoingsOn.VirtualScroller | |
| 98 | + | require('../emails-threads'); // GoingsOn.emailsThreads (thread-list mutations) | |
| 98 | 99 | ||
| 99 | 100 | // Test: AppStateManager / GoingsOn.state | |
| 100 | 101 | ||
| @@ -718,6 +719,84 @@ | |||
| 718 | 719 | }); | |
| 719 | 720 | }); | |
| 720 | 721 | ||
| 722 | + | // Test: email thread-list mutations (CHRONIC-E surgical updates) | |
| 723 | + | ||
| 724 | + | describe('GoingsOn.emailsThreads', () => { | |
| 725 | + | const threads = () => GoingsOn.emailsThreads; | |
| 726 | + | ||
| 727 | + | // Two threads; the second carries a second unread message so the | |
| 728 | + | // "other message still unread" branch is reachable. | |
| 729 | + | function seed() { | |
| 730 | + | GoingsOn.state.set('emailThreads', [ | |
| 731 | + | { mostRecentEmail: { id: 'a', isRead: false, labels: [] }, hasUnread: true }, | |
| 732 | + | { mostRecentEmail: { id: 'b', isRead: false, labels: ['work'] }, hasUnread: true }, | |
| 733 | + | ]); | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | test('remove drops only the matching thread', () => { | |
| 737 | + | seed(); | |
| 738 | + | threads().remove('a'); | |
| 739 | + | const ids = GoingsOn.state.emailThreads.map(t => t.mostRecentEmail.id); | |
| 740 | + | assertDeepEqual(ids, ['b']); | |
| 741 | + | }); | |
| 742 | + | ||
| 743 | + | test('remove of an unknown id leaves the list intact', () => { | |
| 744 | + | seed(); | |
| 745 | + | threads().remove('nope'); | |
| 746 | + | assertEqual(GoingsOn.state.emailThreads.length, 2); | |
| 747 | + | }); | |
| 748 | + | ||
| 749 | + | test('setRead flips isRead and hasUnread together', () => { | |
| 750 | + | seed(); | |
| 751 | + | threads().setRead('a', true); | |
| 752 | + | const a = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'a'); | |
| 753 | + | const b = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'b'); | |
| 754 | + | assertEqual(a.mostRecentEmail.isRead, true); | |
| 755 | + | assertEqual(a.hasUnread, false); | |
| 756 | + | assertEqual(b.hasUnread, true, 'the other thread is untouched'); | |
| 757 | + | }); | |
| 758 | + | ||
| 759 | + | test('setLabels replaces labels on the matching thread only', () => { | |
| 760 | + | seed(); | |
| 761 | + | threads().setLabels('b', ['personal', 'urgent']); | |
| 762 | + | const b = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'b'); | |
| 763 | + | assertDeepEqual(b.mostRecentEmail.labels, ['personal', 'urgent']); | |
| 764 | + | }); | |
| 765 | + | ||
| 766 | + | test('markReadForEmail keeps hasUnread when another message is still unread', () => { | |
| 767 | + | seed(); | |
| 768 | + | threads().markReadForEmail('b', [ | |
| 769 | + | { id: 'b', isRead: true }, | |
| 770 | + | { id: 'b-older', isRead: false }, | |
| 771 | + | ]); | |
| 772 | + | const b = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'b'); | |
| 773 | + | assertEqual(b.mostRecentEmail.isRead, true, 'the opened message is read'); | |
| 774 | + | assertEqual(b.hasUnread, true, 'the thread still has an unread message'); | |
| 775 | + | }); | |
| 776 | + | ||
| 777 | + | test('markReadForEmail clears hasUnread when nothing else is unread', () => { | |
| 778 | + | seed(); | |
| 779 | + | threads().markReadForEmail('b', [ | |
| 780 | + | { id: 'b', isRead: true }, | |
| 781 | + | { id: 'b-older', isRead: true }, | |
| 782 | + | ]); | |
| 783 | + | const b = GoingsOn.state.emailThreads.find(t => t.mostRecentEmail.id === 'b'); | |
| 784 | + | assertEqual(b.hasUnread, false); | |
| 785 | + | }); | |
| 786 | + | ||
| 787 | + | test('markAllRead clears unread across every thread', () => { | |
| 788 | + | seed(); | |
| 789 | + | threads().markAllRead(); | |
| 790 | + | assert(GoingsOn.state.emailThreads.every(t => t.mostRecentEmail.isRead && !t.hasUnread)); | |
| 791 | + | }); | |
| 792 | + | ||
| 793 | + | test('patch reports whether it matched anything', () => { | |
| 794 | + | seed(); | |
| 795 | + | assertEqual(threads().patch('a', t => t), true); | |
| 796 | + | assertEqual(threads().patch('missing', t => t), false); | |
| 797 | + | }); | |
| 798 | + | }); | |
| 799 | + | ||
| 721 | 800 | // Report | |
| 722 | 801 | ||
| 723 | 802 | const success = report(); |
| @@ -1,0 +1,197 @@ | |||
| 1 | + | /** | |
| 2 | + | * GoingsOn - Email Filter Module | |
| 3 | + | * Folder/label filter state, its dropdowns, URL round-tripping, and the label | |
| 4 | + | * and move-to-folder modals. Loaded before emails.js, populates | |
| 5 | + | * GoingsOn.emailsFilter. | |
| 6 | + | * | |
| 7 | + | * This module owns the filter state but not the list: emails.js reads | |
| 8 | + | * current() when it builds a request and calls setFolder/setLabel from the | |
| 9 | + | * filter handlers, so the reload path stays in one place. | |
| 10 | + | */ | |
| 11 | + | ||
| 12 | + | (function() { | |
| 13 | + | 'use strict'; | |
| 14 | + | const esc = GoingsOn.utils.escapeHtml; | |
| 15 | + | const escAttr = GoingsOn.utils.escapeAttrValue; | |
| 16 | + | ||
| 17 | + | // Filter State | |
| 18 | + | ||
| 19 | + | let activeFolder = ''; | |
| 20 | + | let activeLabel = ''; | |
| 21 | + | ||
| 22 | + | /** Current folder/label as the list request wants them (null when unset). */ | |
| 23 | + | function current() { | |
| 24 | + | return { | |
| 25 | + | folder: activeFolder || null, | |
| 26 | + | label: activeLabel || null, | |
| 27 | + | }; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | function setFolder(folder) { | |
| 31 | + | activeFolder = folder; | |
| 32 | + | GoingsOn.queryState?.write('folder', folder); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | function setLabel(label) { | |
| 36 | + | activeLabel = label; | |
| 37 | + | GoingsOn.queryState?.write('label', label); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Filter Dropdowns | |
| 41 | + | ||
| 42 | + | /** | |
| 43 | + | * Repopulate the folder and label dropdowns from the server's current sets, | |
| 44 | + | * preserving whatever is selected. Filters are optional; a failure is silent. | |
| 45 | + | */ | |
| 46 | + | async function loadFilters() { | |
| 47 | + | try { | |
| 48 | + | const [folders, labels] = await Promise.all([ | |
| 49 | + | GoingsOn.api.emails.listFolders(), | |
| 50 | + | GoingsOn.api.emails.listLabels(), | |
| 51 | + | ]); | |
| 52 | + | ||
| 53 | + | const folderSelect = document.getElementById('email-folder-filter'); | |
| 54 | + | if (folderSelect) { | |
| 55 | + | const currentValue = folderSelect.value; | |
| 56 | + | folderSelect.innerHTML = '<option value="">All folders</option>' + | |
| 57 | + | folders.map(f => `<option value="${escAttr(f)}" ${f === currentValue ? 'selected' : ''}>${esc(f)}</option>`).join(''); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | const labelSelect = document.getElementById('email-label-filter'); | |
| 61 | + | if (labelSelect) { | |
| 62 | + | const currentValue = labelSelect.value; | |
| 63 | + | labelSelect.innerHTML = '<option value="">All labels</option>' + | |
| 64 | + | labels.map(l => `<option value="${escAttr(l)}" ${l === currentValue ? 'selected' : ''}>${esc(l)}</option>`).join(''); | |
| 65 | + | } | |
| 66 | + | } catch (_) { /* filters are optional */ } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /** | |
| 70 | + | * Phase 7 Tier 4, restore folder / label / search from URL on init. | |
| 71 | + | * Called once at first load; subsequent filter changes write back. | |
| 72 | + | * @returns {string} The restored search query, empty when there is none | |
| 73 | + | */ | |
| 74 | + | function restoreFromUrl() { | |
| 75 | + | if (!GoingsOn.queryState) return ''; | |
| 76 | + | const q = GoingsOn.queryState.readMany(['folder', 'label', 'q']); | |
| 77 | + | if (q.folder) { | |
| 78 | + | activeFolder = q.folder; | |
| 79 | + | const sel = document.getElementById('email-folder-filter'); | |
| 80 | + | if (sel) sel.value = q.folder; | |
| 81 | + | } | |
| 82 | + | if (q.label) { | |
| 83 | + | activeLabel = q.label; | |
| 84 | + | const sel = document.getElementById('email-label-filter'); | |
| 85 | + | if (sel) sel.value = q.label; | |
| 86 | + | } | |
| 87 | + | if (q.q) { | |
| 88 | + | const input = document.getElementById('email-search'); | |
| 89 | + | if (input) input.value = q.q; | |
| 90 | + | } | |
| 91 | + | return q.q || ''; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // Labels | |
| 95 | + | ||
| 96 | + | /** | |
| 97 | + | * Open a modal to edit labels on an email. | |
| 98 | + | * @param {string} emailId - Email ID | |
| 99 | + | * @param {string[]} currentLabels - Current labels | |
| 100 | + | */ | |
| 101 | + | async function editLabels(emailId, currentLabels) { | |
| 102 | + | const existing = await GoingsOn.api.emails.listLabels(); | |
| 103 | + | const content = ` | |
| 104 | + | <form id="label-form" data-submit="emails._saveLabels" data-a1="${escAttr(emailId)}"> | |
| 105 | + | <div class="form-group"> | |
| 106 | + | <label class="form-label">Labels (comma-separated)</label> | |
| 107 | + | <input type="text" class="form-input" id="label-input" value="${escAttr((currentLabels || []).join(', '))}" | |
| 108 | + | placeholder="work, important, follow-up" autofocus> | |
| 109 | + | ${existing.length > 0 ? `<div class="label-existing-line">Existing: ${existing.map(l => esc(l)).join(', ')}</div>` : ''} | |
| 110 | + | </div> | |
| 111 | + | <div class="form-actions"> | |
| 112 | + | <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button> | |
| 113 | + | <button type="submit" class="btn btn-primary">Save</button> | |
| 114 | + | </div> | |
| 115 | + | </form> | |
| 116 | + | `; | |
| 117 | + | GoingsOn.ui.openModal('Edit Labels', content); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | async function saveLabels(emailId) { | |
| 121 | + | const input = document.getElementById('label-input'); | |
| 122 | + | const labels = input.value.split(',').map(s => s.trim()).filter(Boolean); | |
| 123 | + | try { | |
| 124 | + | await GoingsOn.api.emails.setLabels(emailId, labels); | |
| 125 | + | GoingsOn.ui.showToast('Labels updated!', 'success'); | |
| 126 | + | GoingsOn.ui.closeModal(); | |
| 127 | + | GoingsOn.cache.invalidate('emails'); | |
| 128 | + | // Surgical: patch the thread's labels in place (ultra-fuzz Run #28 S4). | |
| 129 | + | GoingsOn.emailsThreads.setLabels(emailId, labels); | |
| 130 | + | loadFilters(); | |
| 131 | + | } catch (err) { | |
| 132 | + | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update labels'), 'error'); | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Folders | |
| 137 | + | ||
| 138 | + | /** | |
| 139 | + | * Open a modal to move an email to a different folder. | |
| 140 | + | * @param {string} emailId - Email ID | |
| 141 | + | */ | |
| 142 | + | async function moveToFolder(emailId) { | |
| 143 | + | try { | |
| 144 | + | const folders = await GoingsOn.api.emails.listFolders(); | |
| 145 | + | const content = ` | |
| 146 | + | <form data-submit="emails._doMoveToFolder" data-a1="${escAttr(emailId)}"> | |
| 147 | + | <div class="form-group"> | |
| 148 | + | <label class="form-label">Move to folder</label> | |
| 149 | + | <input type="text" class="form-input" id="move-folder-input" placeholder="INBOX, Archive, Sent, ..." | |
| 150 | + | list="folder-suggestions" autofocus> | |
| 151 | + | <datalist id="folder-suggestions"> | |
| 152 | + | ${folders.map(f => `<option value="${escAttr(f)}">`).join('')} | |
| 153 | + | </datalist> | |
| 154 | + | </div> | |
| 155 | + | <div class="form-actions"> | |
| 156 | + | <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button> | |
| 157 | + | <button type="submit" class="btn btn-primary">Move</button> | |
| 158 | + | </div> | |
| 159 | + | </form> | |
| 160 | + | `; | |
| 161 | + | GoingsOn.ui.openModal('Move to Folder', content); | |
| 162 | + | } catch (err) { | |
| 163 | + | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load folders'), 'error'); | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | async function doMoveToFolder(emailId) { | |
| 168 | + | const input = document.getElementById('move-folder-input'); | |
| 169 | + | const folder = input.value.trim(); | |
| 170 | + | if (!folder) return; | |
| 171 | + | try { | |
| 172 | + | await GoingsOn.api.emails.moveToFolder(emailId, folder); | |
| 173 | + | GoingsOn.ui.showToast(`Moved to ${folder}`, 'success'); | |
| 174 | + | GoingsOn.ui.closeModal(); | |
| 175 | + | GoingsOn.cache.invalidate('emails'); | |
| 176 | + | // Surgical: the moved email leaves the current folder/inbox view | |
| 177 | + | // (ultra-fuzz Run #28 S4). | |
| 178 | + | GoingsOn.emailsThreads.remove(emailId); | |
| 179 | + | loadFilters(); | |
| 180 | + | } catch (err) { | |
| 181 | + | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move email'), 'error'); | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | GoingsOn.emailsFilter = { | |
| 186 | + | current, | |
| 187 | + | setFolder, | |
| 188 | + | setLabel, | |
| 189 | + | loadFilters, | |
| 190 | + | restoreFromUrl, | |
| 191 | + | editLabels, | |
| 192 | + | saveLabels, | |
| 193 | + | moveToFolder, | |
| 194 | + | doMoveToFolder, | |
| 195 | + | }; | |
| 196 | + | ||
| 197 | + | })(); |
| @@ -1,0 +1,332 @@ | |||
| 1 | + | /** | |
| 2 | + | * GoingsOn - Email Reader Module | |
| 3 | + | * The reader modal (single message or full thread), its attachment handling, | |
| 4 | + | * and open-in-browser. Loaded before emails.js, populates GoingsOn.emailsReader. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | (function() { | |
| 8 | + | 'use strict'; | |
| 9 | + | const esc = GoingsOn.utils.escapeHtml; | |
| 10 | + | const escAttr = GoingsOn.utils.escapeAttrValue; | |
| 11 | + | ||
| 12 | + | // Reader Fragments | |
| 13 | + | ||
| 14 | + | /** | |
| 15 | + | * Sender card: the matched contact when one exists, otherwise the parsed | |
| 16 | + | * address with an offer to save it. | |
| 17 | + | * @param {Object|null} contact - Matched contact, or null | |
| 18 | + | * @param {Object} parsed - Output of utils.parseEmailAddress | |
| 19 | + | * @param {string} emailId - Email the card belongs to | |
| 20 | + | * @returns {string} HTML string, empty when there is no address to show | |
| 21 | + | */ | |
| 22 | + | function renderSenderCard(contact, parsed, emailId) { | |
| 23 | + | if (contact) { | |
| 24 | + | const name = contact.displayName || contact.display_name; | |
| 25 | + | const initials = (name || '?') | |
| 26 | + | .split(/\s+/).map(w => w[0]).join('').substring(0, 2).toUpperCase(); | |
| 27 | + | const company = contact.company ? esc(contact.company) : ''; | |
| 28 | + | return ` | |
| 29 | + | <div class="email-sender-contact row-flex row-flex-2"> | |
| 30 | + | <div class="avatar avatar--sm">${initials}</div> | |
| 31 | + | <div class="email-sender-info"> | |
| 32 | + | <span class="email-sender-name">${esc(name)}</span> | |
| 33 | + | ${company ? `<span class="email-sender-company">${company}</span>` : ''} | |
| 34 | + | </div> | |
| 35 | + | <button class="btn btn-sm btn-secondary" data-act="ui.closeModalThen" data-a1="contacts.open" data-a2="${escAttr(contact.id)}">View Contact</button> | |
| 36 | + | </div> | |
| 37 | + | `; | |
| 38 | + | } | |
| 39 | + | if (!parsed.email) return ''; | |
| 40 | + | return ` | |
| 41 | + | <div class="email-sender-contact row-flex row-flex-2"> | |
| 42 | + | <div class="avatar avatar--sm avatar--unknown">?</div> | |
| 43 | + | <div class="email-sender-info"> | |
| 44 | + | <span class="email-sender-name">${esc(parsed.name || parsed.email)}</span> | |
| 45 | + | </div> | |
| 46 | + | <button class="btn btn-sm btn-secondary" data-act="emails.createContactFromSender" data-a1="${escAttr(emailId)}">+ Save Contact</button> | |
| 47 | + | </div> | |
| 48 | + | `; | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /** | |
| 52 | + | * Attachment panel, pooled across every message in the thread. | |
| 53 | + | * @param {Array} attachments - Attachment records | |
| 54 | + | * @returns {string} HTML string, empty when there are none | |
| 55 | + | */ | |
| 56 | + | function renderAttachments(attachments) { | |
| 57 | + | if (attachments.length === 0) return ''; | |
| 58 | + | const rows = attachments.map(a => { | |
| 59 | + | const icon = GoingsOn.attachments.getIcon(a.mimeType); | |
| 60 | + | return ` | |
| 61 | + | <div class="email-attachment-row"> | |
| 62 | + | <span>${icon}</span> | |
| 63 | + | <span class="attachment-filename email-attachment-name" | |
| 64 | + | title="${escAttr(a.filename)}">${esc(a.filename)}</span> | |
| 65 | + | <span class="email-attachment-size">${esc(a.sizeFormatted)}</span> | |
| 66 | + | <button class="btn btn-sm btn-secondary" data-act="emails.openBlob" data-a1="${escAttr(a.blobHash)}" data-a2="${escAttr(a.filename)}" title="Open">Open</button> | |
| 67 | + | <button class="btn btn-sm btn-secondary" data-act="emails.saveBlob" data-a1="${escAttr(a.blobHash)}" data-a2="${escAttr(a.filename)}" title="Save">Save</button> | |
| 68 | + | </div> | |
| 69 | + | `; | |
| 70 | + | }).join(''); | |
| 71 | + | return ` | |
| 72 | + | <div class="email-attachments-block"> | |
| 73 | + | <div class="email-attachments-heading">Attachments (${attachments.length})</div> | |
| 74 | + | ${rows} | |
| 75 | + | </div> | |
| 76 | + | `; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /** | |
| 80 | + | * The thread body, forum style, oldest message first. | |
| 81 | + | * @param {Array} threadEmails - Messages ascending by received_at | |
| 82 | + | * @returns {string} HTML string | |
| 83 | + | */ | |
| 84 | + | function renderThreadMessages(threadEmails) { | |
| 85 | + | return threadEmails.map((e, index) => { | |
| 86 | + | const isLatest = index === threadEmails.length - 1; | |
| 87 | + | const dateStr = new Date(e.receivedAt).toLocaleString(); | |
| 88 | + | const directionIcon = e.isOutgoing ? '↗' : '↙'; // arrows for direction | |
| 89 | + | const formattedBody = GoingsOn.utils.formatEmailBody(e.body); | |
| 90 | + | ||
| 91 | + | // Bodies over 100KB are truncated at sync; offer to load the rest. | |
| 92 | + | const truncatedNotice = e.bodyTruncated | |
| 93 | + | ? `<div class="email-body-truncated" id="email-trunc-${escAttr(e.id)}"> | |
| 94 | + | <span>Message truncated.</span> | |
| 95 | + | <button class="btn btn-sm btn-secondary" data-act="emails.loadFullBody" data-a1="${escAttr(e.id)}">Load full message</button> | |
| 96 | + | </div>` | |
| 97 | + | : ''; | |
| 98 | + | ||
| 99 | + | return ` | |
| 100 | + | <div class="thread-message ${isLatest ? 'thread-message-latest' : ''}"> | |
| 101 | + | <div class="thread-message-header"> | |
| 102 | + | <span>${directionIcon} <span class="thread-message-from">${esc(e.from)}</span></span> | |
| 103 | + | <span>${dateStr}</span> | |
| 104 | + | </div> | |
| 105 | + | <div class="email-reader-body" id="email-body-${escAttr(e.id)}">${formattedBody}</div> | |
| 106 | + | ${truncatedNotice} | |
| 107 | + | </div> | |
| 108 | + | `; | |
| 109 | + | }).join(''); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /** | |
| 113 | + | * Action bar for the thread's most recent message. | |
| 114 | + | * @param {Object} latest - The most recent message in the thread | |
| 115 | + | * @returns {string} HTML string | |
| 116 | + | */ | |
| 117 | + | function renderActionBar(latest) { | |
| 118 | + | const id = escAttr(latest.id); | |
| 119 | + | const archiveBtn = latest.isArchived | |
| 120 | + | ? `<button class="btn btn-secondary" data-act="emails.unarchive" data-a1="${id}">Unarchive</button>` | |
| 121 | + | : `<button class="btn btn-secondary" data-act="emails.archive" data-a1="${id}">Archive</button>`; | |
| 122 | + | // isSnoozed is pre-computed by the backend. | |
| 123 | + | const snoozeBtn = latest.isSnoozed | |
| 124 | + | ? `<button class="btn btn-secondary" data-act="snooze.unsnooze" data-a1="email" data-a2="${id}">Unsnooze</button>` | |
| 125 | + | : `<button class="btn btn-secondary" data-act="snooze.openModal" data-a1="email" data-a2="${id}">Snooze</button>`; | |
| 126 | + | ||
| 127 | + | return ` | |
| 128 | + | <div class="form-actions email-actions-bar"> | |
| 129 | + | <button class="btn btn-primary" data-act="emails.reply" data-a1="${id}">Reply</button> | |
| 130 | + | <button class="btn btn-secondary" data-act="emails.replyAll" data-a1="${id}">Reply All</button> | |
| 131 | + | <button class="btn btn-secondary" data-act="emails.forward" data-a1="${id}">Forward</button> | |
| 132 | + | <button class="btn btn-secondary text-accent-red" data-act="emails.delete" data-a1="${id}">Delete</button> | |
| 133 | + | ${archiveBtn} | |
| 134 | + | ${snoozeBtn} | |
| 135 | + | <button class="btn btn-secondary" data-act="emails.createTaskFromEmail" data-a1="${id}">Create Task</button> | |
| 136 | + | <div class="dropdown" style="position: relative;"> | |
| 137 | + | <button class="btn btn-secondary" data-act="ui.toggleMenu" data-a1="@el"> | |
| 138 | + | Actions â–¾ | |
| 139 | + | </button> | |
| 140 | + | <div class="dropdown-menu"> | |
| 141 | + | <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createTaskFromEmail" data-a3="${id}"> | |
| 142 | + | Convert to Task | |
| 143 | + | </button> | |
| 144 | + | <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createEventFromEmail" data-a3="${id}"> | |
| 145 | + | Convert to Event | |
| 146 | + | </button> | |
| 147 | + | <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.editLabels" data-a3="${id}" data-a4="${escAttr(JSON.stringify(latest.labels || []))}"> | |
| 148 | + | Edit Labels | |
| 149 | + | </button> | |
| 150 | + | <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.moveToFolder" data-a3="${id}"> | |
| 151 | + | Move to Folder | |
| 152 | + | </button> | |
| 153 | + | </div> | |
| 154 | + | </div> | |
| 155 | + | <div class="flex-1"></div> | |
| 156 | + | <button class="btn btn-secondary" data-act="emails.openInBrowser" data-a1="${id}" title="Open in browser">Open in Browser</button> | |
| 157 | + | </div> | |
| 158 | + | `; | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /** | |
| 162 | + | * Header block: subject, metadata line, sender card. | |
| 163 | + | * @param {Object} email - The email that was opened | |
| 164 | + | * @param {Object} latest - The most recent message in the thread | |
| 165 | + | * @param {number} messageCount - Messages in the thread | |
| 166 | + | * @param {string} senderCardHtml - Output of renderSenderCard | |
| 167 | + | * @returns {string} HTML string | |
| 168 | + | */ | |
| 169 | + | function renderHeader(email, latest, messageCount, senderCardHtml) { | |
| 170 | + | const subjectLine = messageCount > 1 | |
| 171 | + | ? `${esc(email.subject)} <span class="email-thread-count">(${messageCount} messages)</span>` | |
| 172 | + | : esc(email.subject); | |
| 173 | + | const labels = (email.labels || []).length > 0 | |
| 174 | + | ? ' · ' + email.labels.map(l => `<span class="badge badge--xs badge--filled" data-color="blue">${esc(l)}</span>`).join(' ') | |
| 175 | + | : ''; | |
| 176 | + | const snoozed = latest.isSnoozed | |
| 177 | + | ? ` · <span class="email-snoozed-tag"><em>Snoozed until ${esc(latest.snoozedUntilFormatted || '')}</em></span>` | |
| 178 | + | : ''; | |
| 179 | + | ||
| 180 | + | return ` | |
| 181 | + | <div class="email-reader-header"> | |
| 182 | + | <div class="email-subject-line">${subjectLine}</div> | |
| 183 | + | <div class="email-meta-line"> | |
| 184 | + | From: ${esc(email.from)} | |
| 185 | + | ${email.isArchived ? ' · <em>Archived</em>' : ''} | |
| 186 | + | ${email.sourceFolder ? ` · ${esc(email.sourceFolder)}` : ''} | |
| 187 | + | ${labels} | |
| 188 | + | ${snoozed} | |
| 189 | + | </div> | |
| 190 | + | ${senderCardHtml} | |
| 191 | + | </div> | |
| 192 | + | `; | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // Reader | |
| 196 | + | ||
| 197 | + | /** | |
| 198 | + | * Open an email in reader mode, loading its full thread if available. | |
| 199 | + | * Marks the email as read and shows sender contact info. | |
| 200 | + | * @param {string} id - Email ID to open | |
| 201 | + | */ | |
| 202 | + | async function open(id) { | |
| 203 | + | try { | |
| 204 | + | const email = await GoingsOn.api.emails.get(id); | |
| 205 | + | if (!email) return; | |
| 206 | + | ||
| 207 | + | await GoingsOn.api.emails.markRead(id); | |
| 208 | + | ||
| 209 | + | // Backend returns threads sorted by received_at ASC. | |
| 210 | + | let threadEmails = [email]; | |
| 211 | + | if (email.threadId) { | |
| 212 | + | try { | |
| 213 | + | const thread = await GoingsOn.api.emails.listByThread(email.threadId); | |
| 214 | + | if (thread && thread.length > 1) threadEmails = thread; | |
| 215 | + | } catch (e) { | |
| 216 | + | console.error('Failed to load thread:', e); | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | const latest = threadEmails[threadEmails.length - 1]; | |
| 220 | + | ||
| 221 | + | const parsed = GoingsOn.utils.parseEmailAddress(email.from); | |
| 222 | + | let senderContact = null; | |
| 223 | + | if (parsed.email) { | |
| 224 | + | try { | |
| 225 | + | senderContact = await GoingsOn.api.contacts.findByEmail(parsed.email); | |
| 226 | + | } catch (e) { | |
| 227 | + | console.error('Failed to look up contact:', e); | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | const attachments = threadEmails.flatMap(e => | |
| 232 | + | (e.attachments || []).map(a => ({ ...a, emailFrom: e.from })) | |
| 233 | + | ); | |
| 234 | + | ||
| 235 | + | const content = ` | |
| 236 | + | <div class="email-reader-container"> | |
| 237 | + | ${renderHeader(email, latest, threadEmails.length, renderSenderCard(senderContact, parsed, id))} | |
| 238 | + | ${renderAttachments(attachments)} | |
| 239 | + | <div class="email-reader-thread"> | |
| 240 | + | ${renderThreadMessages(threadEmails)} | |
| 241 | + | </div> | |
| 242 | + | ${renderActionBar(latest)} | |
| 243 | + | </div> | |
| 244 | + | `; | |
| 245 | + | GoingsOn.ui.openModal(threadEmails.length > 1 ? 'Thread' : 'Email', content, { large: true }); | |
| 246 | + | // Surgically reflect the read state instead of refetching the whole | |
| 247 | + | // window (ultra-fuzz Run #28 S4); this is the single most frequent | |
| 248 | + | // action in the app. threadEmails is the thread we just loaded. | |
| 249 | + | GoingsOn.cache.invalidate('emails'); | |
| 250 | + | GoingsOn.emailsThreads.markReadForEmail(id, threadEmails); | |
| 251 | + | } catch (err) { | |
| 252 | + | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load email'), 'error'); | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /** | |
| 257 | + | * Lazily fetch the full body of an email truncated at sync (JMAP >100KB) | |
| 258 | + | * and swap it into the open reader, removing the truncation notice. | |
| 259 | + | * @param {string} id - Email ID | |
| 260 | + | */ | |
| 261 | + | async function loadFullBody(id) { | |
| 262 | + | const noticeEl = document.getElementById(`email-trunc-${id}`); | |
| 263 | + | if (noticeEl) noticeEl.textContent = 'Loading full message'; | |
| 264 | + | try { | |
| 265 | + | const body = await GoingsOn.api.emails.fetchFullBody(id); | |
| 266 | + | const bodyEl = document.getElementById(`email-body-${id}`); | |
| 267 | + | if (bodyEl) bodyEl.innerHTML = GoingsOn.utils.formatEmailBody(body); | |
| 268 | + | if (noticeEl) noticeEl.remove(); | |
| 269 | + | } catch (err) { | |
| 270 | + | if (noticeEl) noticeEl.textContent = ''; | |
| 271 | + | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load full message'), 'error'); | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Attachments & External Open | |
| 276 | + | ||
| 277 | + | /** | |
| 278 | + | * Open an email attachment blob with the system default app. | |
| 279 | + | * @param {string} blobHash - SHA-256 hash of the blob | |
| 280 | + | * @param {string} filename - Original filename | |
| 281 | + | */ | |
| 282 | + | async function openBlob(blobHash, filename) { | |
| 283 | + | try { | |
| 284 | + | await GoingsOn.api.attachments.openEmailBlob(blobHash, filename); | |
| 285 | + | } catch (err) { | |
| 286 | + | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open attachment'), 'error'); | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /** | |
| 291 | + | * Save an email attachment blob to a user-chosen location. | |
| 292 | + | * @param {string} blobHash - SHA-256 hash of the blob | |
| 293 | + | * @param {string} filename - Default filename for save dialog | |
| 294 | + | */ | |
| 295 | + | async function saveBlob(blobHash, filename) { | |
| 296 | + | try { | |
| 297 | + | const { save } = window.__TAURI__.dialog; | |
| 298 | + | const destination = await save({ | |
| 299 | + | defaultPath: filename, | |
| 300 | + | title: 'Save attachment as', | |
| 301 | + | }); | |
| 302 | + | if (!destination) return; | |
| 303 | + | ||
| 304 | + | await GoingsOn.api.attachments.saveEmailBlob(blobHash, destination); | |
| 305 | + | GoingsOn.ui.showToast('File saved!', 'success'); | |
| 306 | + | } catch (err) { | |
| 307 | + | if (err && err.toString().includes('cancelled')) return; | |
| 308 | + | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save attachment'), 'error'); | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /** | |
| 313 | + | * Render email HTML to a temp file and open in the system browser. | |
| 314 | + | * @param {string} emailId - Email ID to open | |
| 315 | + | */ | |
| 316 | + | async function openInBrowser(emailId) { | |
| 317 | + | try { | |
| 318 | + | await GoingsOn.api.window.openEmailInBrowser(emailId); | |
| 319 | + | } catch (err) { | |
| 320 | + | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open email in browser'), 'error'); | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | GoingsOn.emailsReader = { | |
| 325 | + | open, | |
| 326 | + | loadFullBody, | |
| 327 | + | openBlob, | |
| 328 | + | saveBlob, | |
| 329 | + | openInBrowser, | |
| 330 | + | }; | |
| 331 | + | ||
| 332 | + | })(); |
| @@ -1,0 +1,116 @@ | |||
| 1 | + | /** | |
| 2 | + | * GoingsOn - Email Thread List State | |
| 3 | + | * Surgical mutations of the loaded thread list, plus the list count chip. | |
| 4 | + | * Loaded before emails.js, populates GoingsOn.emailsThreads. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | (function() { | |
| 8 | + | 'use strict'; | |
| 9 | + | ||
| 10 | + | // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). Reading an email, the | |
| 11 | + | // single most frequent action in the app, used to refetch the whole 500-thread | |
| 12 | + | // window. Every mutation here edits state.emailThreads in place and lets | |
| 13 | + | // emails.js's subscriber refresh the scroller, replacing the old | |
| 14 | + | // `reload: load` that refetched on every single-email action. | |
| 15 | + | // | |
| 16 | + | // Threads are matched by their representative (most-recent) email id, which is | |
| 17 | + | // the id the list rows and the reader action bar operate on. | |
| 18 | + | ||
| 19 | + | /** Current thread list, never null. */ | |
| 20 | + | function all() { | |
| 21 | + | return GoingsOn.state.emailThreads || []; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /** Drop the thread whose representative email is `emailId` from the list. */ | |
| 25 | + | function remove(emailId) { | |
| 26 | + | GoingsOn.state.set('emailThreads', all().filter(t => t.mostRecentEmail.id !== emailId)); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /** | |
| 30 | + | * Replace the representative email of one thread via `patch`, leaving the rest | |
| 31 | + | * of the list untouched. `patch` receives the thread and returns the new one. | |
| 32 | + | */ | |
| 33 | + | function patch(emailId, patchFn) { | |
| 34 | + | let changed = false; | |
| 35 | + | const next = all().map(t => { | |
| 36 | + | if (t.mostRecentEmail.id !== emailId) return t; | |
| 37 | + | changed = true; | |
| 38 | + | return patchFn(t); | |
| 39 | + | }); | |
| 40 | + | if (changed) GoingsOn.state.set('emailThreads', next); | |
| 41 | + | return changed; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** Toggle a thread's read state. Mirrors the bulk markRead pattern. */ | |
| 45 | + | function setRead(emailId, read) { | |
| 46 | + | patch(emailId, t => ({ | |
| 47 | + | ...t, | |
| 48 | + | mostRecentEmail: { ...t.mostRecentEmail, isRead: read }, | |
| 49 | + | hasUnread: !read, | |
| 50 | + | })); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** Replace a thread's labels in place after an edit. */ | |
| 54 | + | function setLabels(emailId, labels) { | |
| 55 | + | patch(emailId, t => ({ ...t, mostRecentEmail: { ...t.mostRecentEmail, labels } })); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /** | |
| 59 | + | * Mark the opened message read and recompute the thread's unread flag from the | |
| 60 | + | * thread we just loaded. hasUnread is cleared only when no *other* message in | |
| 61 | + | * the thread is still unread, so a multi-message thread with remaining unread | |
| 62 | + | * keeps its dot rather than wrongly showing read (GO todo hazard note). | |
| 63 | + | */ | |
| 64 | + | function markReadForEmail(emailId, threadEmails) { | |
| 65 | + | const stillUnread = (threadEmails || []).some(e => e.id !== emailId && !e.isRead); | |
| 66 | + | patch(emailId, t => ({ | |
| 67 | + | ...t, | |
| 68 | + | mostRecentEmail: { ...t.mostRecentEmail, isRead: true }, | |
| 69 | + | hasUnread: stillUnread, | |
| 70 | + | })); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /** Clear unread across every loaded thread. */ | |
| 74 | + | function markAllRead() { | |
| 75 | + | GoingsOn.state.set('emailThreads', all().map(t => ({ | |
| 76 | + | ...t, | |
| 77 | + | mostRecentEmail: { ...t.mostRecentEmail, isRead: true }, | |
| 78 | + | hasUnread: false, | |
| 79 | + | }))); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /** | |
| 83 | + | * Update the "N threads" count chip. Both numbers are at thread granularity, | |
| 84 | + | * since the filter bar describes the visible list, which renders one row per | |
| 85 | + | * thread. | |
| 86 | + | */ | |
| 87 | + | function updateCount(total, shown) { | |
| 88 | + | const el = document.getElementById('email-count'); | |
| 89 | + | if (!el) return; | |
| 90 | + | if (typeof total !== 'number' || total < 0) { | |
| 91 | + | el.textContent = ''; | |
| 92 | + | el.classList.remove('filter-count--capped'); | |
| 93 | + | return; | |
| 94 | + | } | |
| 95 | + | const noun = total === 1 ? 'thread' : 'threads'; | |
| 96 | + | if (shown < total) { | |
| 97 | + | el.textContent = `${shown} of ${total} ${noun}, narrow with filters`; | |
| 98 | + | el.classList.add('filter-count--capped'); | |
| 99 | + | } else { | |
| 100 | + | el.textContent = `${total} ${noun}`; | |
| 101 | + | el.classList.remove('filter-count--capped'); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | GoingsOn.emailsThreads = { | |
| 106 | + | all, | |
| 107 | + | remove, | |
| 108 | + | patch, | |
| 109 | + | setRead, | |
| 110 | + | setLabels, | |
| 111 | + | markReadForEmail, | |
| 112 | + | markAllRead, | |
| 113 | + | updateCount, | |
| 114 | + | }; | |
| 115 | + | ||
| 116 | + | })(); |