| 27 |
27 |
|
// Email threads stored in centralized state for virtual scrolling
|
| 28 |
28 |
|
GoingsOn.state.set('emailThreads', []);
|
| 29 |
29 |
|
|
|
30 |
+ |
// CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). Reading an email — the
|
|
31 |
+ |
// single most frequent action in the app — used to refetch the whole 500-thread
|
|
32 |
+ |
// window. Wire one subscriber so a surgical state.set('emailThreads', …)
|
|
33 |
+ |
// re-renders the list in place. `suppressEmailRender` gates it during the full
|
|
34 |
+ |
// rebuild in load()/searchEmails so set() there doesn't refresh a scroller that
|
|
35 |
+ |
// is about to be (re)created on the next line.
|
|
36 |
+ |
let suppressEmailRender = false;
|
|
37 |
+ |
GoingsOn.state.subscribe('emailThreads', () => {
|
|
38 |
+ |
if (suppressEmailRender) return;
|
|
39 |
+ |
if (emailScroller) emailScroller.refresh();
|
|
40 |
+ |
});
|
|
41 |
+ |
|
|
42 |
+ |
// ============ Surgical single-thread list mutations (CHRONIC-E) ============
|
|
43 |
+ |
// Each mutates state.emailThreads in place and lets the subscriber refresh the
|
|
44 |
+ |
// scroller — replacing the old `reload: load` that refetched 500 threads on
|
|
45 |
+ |
// every single-email action (ultra-fuzz Run #28 S4). Threads are matched by
|
|
46 |
+ |
// their representative (most-recent) email id, which is the id the list rows
|
|
47 |
+ |
// and the reader action bar operate on.
|
|
48 |
+ |
|
|
49 |
+ |
/** Drop the thread whose representative email is `emailId` from the list. */
|
|
50 |
+ |
function _removeThread(emailId) {
|
|
51 |
+ |
const threads = GoingsOn.state.emailThreads || [];
|
|
52 |
+ |
GoingsOn.state.set('emailThreads', threads.filter(t => t.mostRecentEmail.id !== emailId));
|
|
53 |
+ |
}
|
|
54 |
+ |
|
|
55 |
+ |
/** Toggle a thread's read state. Mirrors the bulk markRead pattern. */
|
|
56 |
+ |
function _setThreadRead(emailId, read) {
|
|
57 |
+ |
const threads = GoingsOn.state.emailThreads || [];
|
|
58 |
+ |
GoingsOn.state.set('emailThreads', threads.map(t =>
|
|
59 |
+ |
t.mostRecentEmail.id === emailId
|
|
60 |
+ |
? { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: read }, hasUnread: !read }
|
|
61 |
+ |
: t
|
|
62 |
+ |
));
|
|
63 |
+ |
}
|
|
64 |
+ |
|
|
65 |
+ |
/**
|
|
66 |
+ |
* Mark the opened message read and recompute the thread's unread flag from the
|
|
67 |
+ |
* thread we just loaded. hasUnread is cleared only when no *other* message in
|
|
68 |
+ |
* the thread is still unread, so a multi-message thread with remaining unread
|
|
69 |
+ |
* keeps its dot rather than wrongly showing read (GO todo hazard note).
|
|
70 |
+ |
*/
|
|
71 |
+ |
function _markThreadReadForEmail(emailId, threadEmails) {
|
|
72 |
+ |
const threads = GoingsOn.state.emailThreads || [];
|
|
73 |
+ |
const stillUnread = (threadEmails || []).some(e => e.id !== emailId && !e.isRead);
|
|
74 |
+ |
let changed = false;
|
|
75 |
+ |
const next = threads.map(t => {
|
|
76 |
+ |
if (t.mostRecentEmail.id !== emailId) return t;
|
|
77 |
+ |
changed = true;
|
|
78 |
+ |
return { ...t, mostRecentEmail: { ...t.mostRecentEmail, isRead: true }, hasUnread: stillUnread };
|
|
79 |
+ |
});
|
|
80 |
+ |
if (changed) GoingsOn.state.set('emailThreads', next);
|
|
81 |
+ |
}
|
|
82 |
+ |
|
|
83 |
+ |
// Incremental pagination. The thread list streams in pages via the scroller's
|
|
84 |
+ |
// onNeedMore hook instead of capping at a fixed 500 rows, so a large mailbox's
|
|
85 |
+ |
// tail is reachable (ultra-fuzz Run #28 S4). baseFilters holds the current
|
|
86 |
+ |
// folder/label so each page request stays consistent.
|
|
87 |
+ |
const EMAIL_PAGE_SIZE = 200;
|
|
88 |
+ |
const emailPaging = { loadedCount: 0, total: 0, baseFilters: null };
|
|
89 |
+ |
|
|
90 |
+ |
/**
|
|
91 |
+ |
* Fetch and append the next page of threads. Wired to the scroller's
|
|
92 |
+ |
* onNeedMore hook. No-ops once every thread is loaded; on error it surfaces a
|
|
93 |
+ |
* toast and stops paging rather than spinning.
|
|
94 |
+ |
*/
|
|
95 |
+ |
async function loadMoreEmails() {
|
|
96 |
+ |
if (emailPaging.loadedCount >= emailPaging.total || !emailPaging.baseFilters) return;
|
|
97 |
+ |
let response;
|
|
98 |
+ |
try {
|
|
99 |
+ |
response = await GoingsOn.api.emails.listThreaded({
|
|
100 |
+ |
...emailPaging.baseFilters,
|
|
101 |
+ |
offset: emailPaging.loadedCount,
|
|
102 |
+ |
limit: EMAIL_PAGE_SIZE,
|
|
103 |
+ |
});
|
|
104 |
+ |
} catch (err) {
|
|
105 |
+ |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load more emails'), 'error');
|
|
106 |
+ |
return;
|
|
107 |
+ |
}
|
|
108 |
+ |
const threads = (GoingsOn.state.emailThreads || []).concat(response.threads);
|
|
109 |
+ |
GoingsOn.state.set('emails', threads.map(t => t.mostRecentEmail));
|
|
110 |
+ |
GoingsOn.state.set('emailThreads', threads);
|
|
111 |
+ |
emailPaging.loadedCount = threads.length;
|
|
112 |
+ |
emailPaging.total = response.total;
|
|
113 |
+ |
emailSelection.setItems(threads.map(t => ({ id: t.mostRecentEmail.id })));
|
|
114 |
+ |
_updateEmailCount(emailPaging.total, emailPaging.loadedCount);
|
|
115 |
+ |
// Re-arms onNeedMore so the next page can load on further scroll.
|
|
116 |
+ |
if (emailScroller) emailScroller.refresh();
|
|
117 |
+ |
}
|
|
118 |
+ |
|
| 30 |
119 |
|
// ============ Core Functions ============
|
| 31 |
120 |
|
|
| 32 |
121 |
|
/**
|
| 46 |
135 |
|
}
|
| 47 |
136 |
|
|
| 48 |
137 |
|
const container = document.getElementById('email-list');
|
|
138 |
+ |
// Suppress the state subscriber for the whole rebuild (see subscribe() above).
|
|
139 |
+ |
suppressEmailRender = true;
|
|
140 |
+ |
// Reset paging for the current folder/label and fetch the first page; the
|
|
141 |
+ |
// list streams in later pages via the scroller's onNeedMore hook.
|
|
142 |
+ |
const baseFilters = {
|
|
143 |
+ |
includeArchived: false,
|
|
144 |
+ |
folder: activeFolder || null,
|
|
145 |
+ |
label: activeLabel || null,
|
|
146 |
+ |
};
|
|
147 |
+ |
emailPaging.baseFilters = baseFilters;
|
|
148 |
+ |
emailPaging.loadedCount = 0;
|
|
149 |
+ |
emailPaging.total = 0;
|
| 49 |
150 |
|
try {
|
| 50 |
|
- |
// Fetch pre-grouped threads from backend - fetch more for virtual scrolling
|
|
151 |
+ |
// Fetch the first page of pre-grouped threads from the backend.
|
| 51 |
152 |
|
const response = await GoingsOn.api.emails.listThreaded({
|
| 52 |
|
- |
includeArchived: false,
|
|
153 |
+ |
...baseFilters,
|
| 53 |
154 |
|
offset: 0,
|
| 54 |
|
- |
limit: 500, // Fetch more for virtual scrolling
|
| 55 |
|
- |
folder: activeFolder || null,
|
| 56 |
|
- |
label: activeLabel || null,
|
|
155 |
+ |
limit: EMAIL_PAGE_SIZE,
|
| 57 |
156 |
|
});
|
| 58 |
157 |
|
|
| 59 |
158 |
|
// Refresh filter dropdowns
|
| 62 |
161 |
|
// Update cache with most recent emails
|
| 63 |
162 |
|
GoingsOn.state.set('emails', response.threads.map(t => t.mostRecentEmail));
|
| 64 |
163 |
|
GoingsOn.state.set('emailThreads', response.threads);
|
|
164 |
+ |
emailPaging.loadedCount = response.threads.length;
|
|
165 |
+ |
emailPaging.total = response.total;
|
| 65 |
166 |
|
|
| 66 |
|
- |
// Phase 7 Tier 2 #9 — surface the count + 500-cap warning.
|
|
167 |
+ |
// Phase 7 Tier 2 #9 — surface the count; reads "X of N" while later
|
|
168 |
+ |
// pages stream in on scroll.
|
| 67 |
169 |
|
_updateEmailCount(response.total, response.threads.length);
|
| 68 |
170 |
|
|
| 69 |
171 |
|
if (response.total === 0) {
|
| 97 |
199 |
|
getItems: () => GoingsOn.state.emailThreads,
|
| 98 |
200 |
|
rowHeight: { estimated: 90, measure: true },
|
| 99 |
201 |
|
overscan: 5,
|
|
202 |
+ |
onNeedMore: loadMoreEmails,
|
| 100 |
203 |
|
});
|
| 101 |
204 |
|
} else {
|
| 102 |
205 |
|
emailScroller.refresh();
|
| 108 |
211 |
|
action: { label: 'Retry', fn: load },
|
| 109 |
212 |
|
duration: 8000,
|
| 110 |
213 |
|
});
|
|
214 |
+ |
} finally {
|
|
215 |
+ |
suppressEmailRender = false;
|
| 111 |
216 |
|
}
|
| 112 |
217 |
|
}
|
| 113 |
218 |
|
|
| 306 |
411 |
|
}
|
| 307 |
412 |
|
|
| 308 |
413 |
|
async function markAllRead() {
|
| 309 |
|
- |
GoingsOn.cache.invalidate('emails');
|
| 310 |
414 |
|
await GoingsOn.ui.apiCall(GoingsOn.api.emails.markAllRead(), {
|
| 311 |
415 |
|
successMessage: 'All emails marked as read!',
|
| 312 |
416 |
|
errorMessage: 'Failed to mark emails as read',
|
| 313 |
417 |
|
closeModal: false,
|
| 314 |
|
- |
reload: load,
|
|
418 |
+ |
onSuccess: () => {
|
|
419 |
+ |
GoingsOn.cache.invalidate('emails');
|
|
420 |
+ |
// Surgical: clear unread across the streamed threads in place.
|
|
421 |
+ |
const threads = GoingsOn.state.emailThreads || [];
|
|
422 |
+ |
GoingsOn.state.set('emailThreads', threads.map(t => ({
|
|
423 |
+ |
...t,
|
|
424 |
+ |
mostRecentEmail: { ...t.mostRecentEmail, isRead: true },
|
|
425 |
+ |
hasUnread: false,
|
|
426 |
+ |
})));
|
|
427 |
+ |
},
|
| 315 |
428 |
|
});
|
| 316 |
429 |
|
}
|
| 317 |
430 |
|
|
| 504 |
617 |
|
</div>
|
| 505 |
618 |
|
`;
|
| 506 |
619 |
|
GoingsOn.ui.openModal(isThread ? 'Thread' : 'Email', content, { large: true });
|
| 507 |
|
- |
load(); // Refresh to update read status
|
|
620 |
+ |
// Surgically reflect the read state instead of refetching the whole
|
|
621 |
+ |
// window (ultra-fuzz Run #28 S4) — this is the single most frequent
|
|
622 |
+ |
// action in the app. threadEmails is the thread we just loaded.
|
|
623 |
+ |
GoingsOn.cache.invalidate('emails');
|
|
624 |
+ |
_markThreadReadForEmail(id, threadEmails);
|
| 508 |
625 |
|
} catch (err) {
|
| 509 |
626 |
|
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load email'), 'error');
|
| 510 |
627 |
|
}
|
| 517 |
634 |
|
async function deleteEmail(id) {
|
| 518 |
635 |
|
if (!await GoingsOn.ui.confirmDelete('email')) return;
|
| 519 |
636 |
|
|
| 520 |
|
- |
GoingsOn.cache.invalidate('emails');
|
| 521 |
637 |
|
await GoingsOn.ui.apiCall(GoingsOn.api.emails.delete(id), {
|
| 522 |
638 |
|
successMessage: 'Email deleted!',
|
| 523 |
639 |
|
errorMessage: 'Failed to delete email',
|
| 524 |
|
- |
reload: load,
|
|
640 |
+ |
onSuccess: () => {
|
|
641 |
+ |
GoingsOn.cache.invalidate('emails');
|
|
642 |
+ |
_removeThread(id); // surgical: the deleted email leaves the list
|
|
643 |
+ |
},
|
| 525 |
644 |
|
});
|
| 526 |
645 |
|
}
|
| 527 |
646 |
|
|
| 530 |
649 |
|
* @param {string} id - Email ID to archive
|
| 531 |
650 |
|
*/
|
| 532 |
651 |
|
async function archive(id) {
|
| 533 |
|
- |
GoingsOn.cache.invalidate('emails');
|
| 534 |
652 |
|
await GoingsOn.ui.apiCall(GoingsOn.api.emails.archive(id), {
|
| 535 |
653 |
|
successMessage: 'Email archived!',
|
| 536 |
654 |
|
errorMessage: 'Failed to archive email',
|
| 537 |
|
- |
reload: load,
|
|
655 |
+ |
onSuccess: () => {
|
|
656 |
+ |
GoingsOn.cache.invalidate('emails');
|
|
657 |
+ |
_removeThread(id); // surgical: archived email leaves the inbox view
|
|
658 |
+ |
},
|
| 538 |
659 |
|
});
|
| 539 |
660 |
|
}
|
| 540 |
661 |
|
|
| 543 |
664 |
|
* @param {string} id - Email ID to unarchive
|
| 544 |
665 |
|
*/
|
| 545 |
666 |
|
async function unarchive(id) {
|
| 546 |
|
- |
GoingsOn.cache.invalidate('emails');
|
| 547 |
667 |
|
await GoingsOn.ui.apiCall(GoingsOn.api.emails.unarchive(id), {
|
| 548 |
668 |
|
successMessage: 'Email unarchived!',
|
| 549 |
669 |
|
errorMessage: 'Failed to unarchive email',
|
| 550 |
|
- |
reload: load,
|
|
670 |
+ |
onSuccess: () => {
|
|
671 |
+ |
GoingsOn.cache.invalidate('emails');
|
|
672 |
+ |
// Surgical: drop it from the archived-folder view. In the inbox view
|
|
673 |
+ |
// it isn't present, so this is a no-op there.
|
|
674 |
+ |
_removeThread(id);
|
|
675 |
+ |
},
|
| 551 |
676 |
|
});
|
| 552 |
677 |
|
}
|
| 553 |
678 |
|
|
| 556 |
681 |
|
* @param {string} id - Email ID
|
| 557 |
682 |
|
*/
|
| 558 |
683 |
|
async function markRead(id) {
|
| 559 |
|
- |
GoingsOn.cache.invalidate('emails');
|
| 560 |
684 |
|
await GoingsOn.ui.apiCall(GoingsOn.api.emails.markRead(id), {
|
| 561 |
685 |
|
errorMessage: 'Failed to mark email as read',
|
| 562 |
686 |
|
closeModal: false,
|
| 563 |
|
- |
reload: load,
|
|
687 |
+ |
onSuccess: () => {
|
|
688 |
+ |
GoingsOn.cache.invalidate('emails');
|
|
689 |
+ |
_setThreadRead(id, true);
|
|
690 |
+ |
},
|
| 564 |
691 |
|
});
|
| 565 |
692 |
|
}
|
| 566 |
693 |
|
|
| 569 |
696 |
|
* @param {string} id - Email ID
|
| 570 |
697 |
|
*/
|
| 571 |
698 |
|
async function markUnread(id) {
|
| 572 |
|
- |
GoingsOn.cache.invalidate('emails');
|
| 573 |
699 |
|
await GoingsOn.ui.apiCall(GoingsOn.api.emails.markUnread(id), {
|
| 574 |
700 |
|
errorMessage: 'Failed to mark email as unread',
|
| 575 |
701 |
|
closeModal: false,
|
| 576 |
|
- |
reload: load,
|
|
702 |
+ |
onSuccess: () => {
|
|
703 |
+ |
GoingsOn.cache.invalidate('emails');
|
|
704 |
+ |
_setThreadRead(id, false);
|
|
705 |
+ |
},
|
| 577 |
706 |
|
});
|
| 578 |
707 |
|
}
|
| 579 |
708 |
|
|
| 1100 |
1229 |
|
GoingsOn.ui.showToast('Labels updated!', 'success');
|
| 1101 |
1230 |
|
GoingsOn.ui.closeModal();
|
| 1102 |
1231 |
|
GoingsOn.cache.invalidate('emails');
|
| 1103 |
|
- |
load();
|
|
1232 |
+ |
// Surgical: patch the thread's labels in place (ultra-fuzz Run #28 S4).
|
|
1233 |
+ |
const threads = GoingsOn.state.emailThreads || [];
|
|
1234 |
+ |
GoingsOn.state.set('emailThreads', threads.map(t =>
|
|
1235 |
+ |
t.mostRecentEmail.id === emailId
|
|
1236 |
+ |
? { ...t, mostRecentEmail: { ...t.mostRecentEmail, labels } }
|
|
1237 |
+ |
: t
|
|
1238 |
+ |
));
|
| 1104 |
1239 |
|
loadFilters();
|
| 1105 |
1240 |
|
} catch (err) {
|
| 1106 |
1241 |
|
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update labels'), 'error');
|
| 1146 |
1281 |
|
GoingsOn.ui.showToast(`Moved to ${folder}`, 'success');
|
| 1147 |
1282 |
|
GoingsOn.ui.closeModal();
|
| 1148 |
1283 |
|
GoingsOn.cache.invalidate('emails');
|
| 1149 |
|
- |
load();
|
|
1284 |
+ |
// Surgical: the moved email leaves the current folder/inbox view
|
|
1285 |
+ |
// (ultra-fuzz Run #28 S4).
|
|
1286 |
+ |
_removeThread(emailId);
|
| 1150 |
1287 |
|
loadFilters();
|
| 1151 |
1288 |
|
} catch (err) {
|
| 1152 |
1289 |
|
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to move email'), 'error');
|
| 1168 |
1305 |
|
GoingsOn.queryState?.write('q', trimmed);
|
| 1169 |
1306 |
|
|
| 1170 |
1307 |
|
if (!trimmed) {
|
| 1171 |
|
- |
// Clear search — reload normal email list
|
|
1308 |
+ |
// Clear search — reload normal email list. Drop the search-built
|
|
1309 |
+ |
// scroller (which has no onNeedMore) so load() recreates it with the
|
|
1310 |
+ |
// paging hook re-armed (ultra-fuzz Run #28 S4).
|
|
1311 |
+ |
if (emailScroller) { emailScroller.destroy(); emailScroller = null; }
|
| 1172 |
1312 |
|
GoingsOn.cache.invalidate('emails');
|
| 1173 |
1313 |
|
load();
|
| 1174 |
1314 |
|
return;
|