import { Controller } from "@hotwired/stimulus"; import { Idiomorph } from "idiomorph"; import { leave, makeRoom, motionIsOff } from "../../motion.js"; // MailboxSpecialUse values (see App\Domain\Enum\MailboxSpecialUse) mapped to // the data-sync-scope tokens used by the list templates. /** The frame wrapping the list itself — see templates/_layout/_mailbox.html.twig. */ const LIST_FRAME_ID = "inbox-list-frame"; const SYNC_SCOPES = { "\\Inbox": "inbox", "\\Sent": "sent", "\\Trash": "trash", "\\Drafts": "drafts", "\\Junk": "junk", "\\Archive": "archive", }; /** * The floor between two list refreshes. * * A sync run publishes one mailbox.synced per mailbox per account, so a poll * across three accounts arrives as a burst rather than as one event. The old * in-flight guard only stopped two fetches overlapping, which left a burst of * eight producing eight sequential full-page requests — measured at eight * requests per ten seconds of an idle inbox. A burst is one refresh now; the * rest coalesce into a single trailing one. */ const MIN_REFRESH_MS = 15000; /** Asks the server for the list frame alone — see App\Twig\ListFragmentGlobal. */ const FRAGMENT_HEADER = "X-List-Fragment"; /** * The parts of the list frame a refresh is allowed to replace. * * A refresh used to assign over the whole frame, and the frame contains the * toolbar — so every refresh destroyed and rebuilt the bulk-action buttons. * That is fine while nobody is touching them and not fine at the one moment * they are certain to be touched: a refresh is fired after every bulk action * (see release), so doing two bulk actions in a row put a fresh fetch in flight * exactly as the second button was being pressed. The click landed on a node * that was replaced underneath it and did nothing — and because the swap also * rebuilt the rows, the selection it would have applied to was gone too, so the * failure was silent rather than loud. * * So the frame is no longer replaced; the parts of it that go stale are. The * toolbar's controls are not among them: they hold the selection and the * pointer, and nothing the server says about them can be news — their state is * derived from the checkboxes, which are right here. What IS news is the * category tabs' unread numbers and the "1–4 of 4" range, neither of which any * turbo-stream addresses, plus the rows themselves for a sync that brought new * mail. Those three are marked `data-list-region` in the templates. * * Order matters on the way out: rows last, so the selection is put back after * the elements holding it exist again. */ const REFRESHABLE_REGIONS = ["tabs", "pagination", "rows"]; /** * The one region that is morphed rather than assigned over, and why it is the * only one. * * `live.innerHTML = incoming.innerHTML` throws away the one thing the response * is richest in. The server sends the complete list — all fifty rows, freshly * rendered — so the difference between what is on screen and what should be is * sitting right there in the two trees, and assigning discards it. Every row * becomes a new node, which means the browser cannot tell one new mail from * fifty redrawn ones, an open row menu is destroyed, and focus inside the list * is lost. * * Morphing computes that difference instead, keyed on the row ids the templates * already emit (`id="thread_1234"`). Surviving rows keep their nodes; only what * actually changed is touched. Nothing about the server changes — it still * renders page one of fifty, still statelessly, and the response is still the * whole truth rather than a delta. That last part is the safety property: a * missed Mercure event costs a LATE redraw, never a wrong list, because there * is no accumulated client state to drift out of step. * * Tabs and pagination are morphed too, and the reasoning above is why this * comment used to say they were not. It said they "hold no state, no focus and * no identity, so morphing them would be machinery bought for nothing" — true * about state, and it missed what else morphing buys: the screen not moving. * * Assignment tears the tab strip down and builds it again on every refresh, so * the active tab, its underline and its count all blink — on the demo's Receive * button, which refreshes the list on purpose, that reads as the list flickering * rather than mail arriving. The rows animated beautifully and everything around * them flashed. * * Only the ROW morph needs the callbacks. The other two are plain content with * nothing the client owns, so they get a plain morph. */ const MORPHED_REGION = "rows"; /** * Attributes the morph must not touch, because the client owns them. * * `data-entered` and `data-enter` are written onto a row by motion.js when it * plays that row's entrance, `data-enter-scope` says WHICH entrance that was, * and `data-leaving` is written by the same file when one is on its way out. * None of them appear in the markup the server sends, and Idiomorph removes * attributes the incoming node does not have — so without this, every morph * would strip a row's record of what it is doing. The scope matters even * though the row is usually finished animating by the time a refresh lands: * a sync arriving mid-entrance would otherwise swap the row's timings under a * running animation. */ const CLIENT_OWNED = new Set([ "data-entered", "data-enter", "data-enter-scope", "data-leaving", ]); /** * A menu, which stops being the server's business the moment it is opened. * * Preserving the node is not enough on its own. ui--dropdown shows a menu by * clearing `hidden`, and the server always renders it set, so a faithful morph * closes a menu somebody is reading in the same breath as fixing the older bug * where the whole thing was destroyed. * * `hidden` on the menu itself was the obvious exception to make and the wrong * one: the snooze menu ALSO hides individual entries and un-hides them on open, * so exempting one attribute on one element left the menu standing with its * contents blanked. The rule that holds is the broader one — an open menu is * under somebody's pointer, and nothing a background sync has to say about it * can be more important than that. It is skipped whole, children included, and * becomes the server's again the moment it closes. */ const MENU = '[data-ui--dropdown-target="menu"]'; /** * One row of the list, as opposed to any of the hundreds of nodes inside one. * * The morph's add and remove hooks fire for every node they touch, most of * which are fragments being brought up to date — a span whose text changed, a * star that is no longer there. Both hooks below act only on whole rows, and * this is how they tell. * * Matched by selector rather than by asking whether the node's parent is the * list. That test looks equivalent and is not: Idiomorph inserts and morphs * through a proxy parent, so a node can be handed to a callback before it is * where it will end up, and `parentElement === live` is false for a row that is * unmistakably a row. Found the hard way, by an entrance that never got its * delay. */ const ROW = 'li[data-controller="mail--message-row"]'; /** The row checkboxes, whose `value` is the thread id. */ const ROW_SELECT = "input[data-thread-select]"; /** Told when the rows underneath it have been replaced. */ const TOOLBAR = "[data-controller~='mail--list-toolbar']"; export default class extends Controller { static targets = ["list", "reading"]; static values = { open: Boolean , mailBoxId: Number}; connect() { this._listUrl = this.openValue ? null : window.location.href; this._onPopState = this._handlePopState.bind(this); window.addEventListener("popstate", this._onPopState); // The sidebar's mail links navigate the LIST FRAME rather than the // page (so the calendar pane holds perfectly still). This pane sits // outside that frame, so a swap would otherwise leave the previous // message open beside a list it was never part of, with a back-URL // pointing at the label the user just left. A swap shows the new // list and adopts its URL as the place `close` returns to. this._onListSwap = (event) => { if (LIST_FRAME_ID !== event.target.id) { return; } this._listUrl = window.location.href; this._showList(); }; document.addEventListener("turbo:frame-load", this._onListSwap); // A refresh that came due while the tab was in the background is held // rather than dropped, and taken the moment it is looked at again. this._refreshPending = false; this._lastRefreshAt = 0; this._refreshTimer = null; // Depth, not a boolean, for the reason auto_refresh_controller gives: // a bulk action fires one write per selected row and they overlap, so // the first to finish would otherwise resume refreshing while the rest // are still in flight. this._holds = 0; this._onVisibility = () => { if (document.hidden) { return; } // Unconditionally, not only when a refresh was already pending. // // `_refreshPending` is set by a sync event that arrived while the // tab was hidden — and on a phone there are none to arrive. Android // suspends a backgrounded browser: the Mercure stream is dropped, // the poll timer stops, and server-sent events have no replay, so // everything that happened while the app was away is simply missed. // Coming back, nothing was pending, nothing refreshed, and the list // showed the state from before — mail read on another device still // bold, "New" badges another client had already retired. Reported as // read status and the New marker not updating on Android. // // The desktop case was hidden by the poll: a tab left open catches // up within a minute, which reads as "slow" rather than "wrong". // // One fragment fetch, and _refreshList() already coalesces bursts // and refuses while a write is in flight, so returning to the app // repeatedly costs at most one refresh per MIN_REFRESH_MS. this._refreshList(); }; document.addEventListener("visibilitychange", this._onVisibility); // Restore correct visual state on direct load / refresh if (this.openValue) { this._showReading(); } else { this._showList(); } } disconnect() { window.removeEventListener("popstate", this._onPopState); document.removeEventListener("turbo:frame-load", this._onListSwap); document.removeEventListener("visibilitychange", this._onVisibility); // A trailing refresh outlives this controller otherwise: Turbo replaces // on every visit, so an uncleared timer here is a fetch fired by // a controller that no longer has an element to write to — and one more // of them after every navigation. if (this._refreshTimer !== null) { clearTimeout(this._refreshTimer); this._refreshTimer = null; } } /** * A write started in the list — hold the refresh until it lands. * * The list refresh renders from server state, so one *issued* before a * write commits swaps the pre-write markup back in. That is bad enough on * its own; with a bulk action it is worse, because the rows are removed by * turbo-streams and a refresh landing mid-run replaces them with fresh * elements the remaining streams can no longer find. The archived rows then * stay on screen until something else redraws them. * * The same reasoning, and the same fix, as auto_refresh_controller#hold — * which is where this pattern is explained at length. */ hold() { this._holds++; } /** * The write finished. The list is re-read from the server. * * Unconditionally, where this used to refresh only if a sync had come due * while the write was held. That covered the rows — the streams the write * returns redraw those — and missed everything else in the frame, which no * stream addresses: the inbox's category tabs carry their own unread * numbers, and marking two threads unread left "General 2" over four bold * rows until a reload. The frame is one fragment fetch, and a person who * just pressed a bulk-action button is waiting on the answer. * * Still unconditional, and now safe to be. What made it unsafe was not the * fetch but what was done with the answer: the refresh assigned over the * whole frame, so the toolbar this was fired FROM was rebuilt underneath * the hand that fired it, and a second bulk action pressed straight after * the first landed on a replaced button. The refresh now leaves the toolbar * and the selection alone and updates only what has actually gone stale — * see REFRESHABLE_REGIONS. */ release() { this._holds = Math.max(0, this._holds - 1); if (0 !== this._holds) { return; } this._refreshList({ immediate: true }); } async open(event) { event.preventDefault(); const link = event.currentTarget; const url = link.href; // Remember where to go back to, if we don't already know. if (!this._listUrl) { this._listUrl = window.location.href; } await this._loadMessage(url); history.pushState({ mailPaneOpen: true }, "", url); } close(event) { if (event) { event.preventDefault(); } // The URL moves back to the list BEFORE the list is shown, because // showing it may have to fetch it and _refreshList asks for wherever the // page currently is. The other way round it would fetch the thread's own // URL — whose list frame is deliberately empty — and fill the list with // the emptiness this is meant to cure. if (this._listUrl) { history.pushState({ mailPaneOpen: false }, "", this._listUrl); this._showList({ revalidate: true }); return; } // No remembered list URL: the thread was loaded directly, so there is a // real previous entry and the browser renders it. popstate follows and // shows the list once the URL is the list's. history.back(); } async _handlePopState(event) { const state = event.state; if (state && state.mailPaneOpen) { await this._loadMessage(window.location.href); } else { // The browser's own Back out of a thread, which uncovers the same // snapshot the in-app arrow does. this._showList({ revalidate: true }); } } async _loadMessage(url) { const response = await fetch(url, { headers: { "X-Requested-With": "fetch" }, }); if (!response.ok) { window.location.href = url; // fall back to a real navigation on failure return; } const html = await response.text(); this.readingTarget.innerHTML = html; this._showReading(); } _showReading() { this.listTarget.classList.add("hidden"); this.readingTarget.classList.remove("hidden"); } /** * Reveal the list — and make sure there is one to reveal. * * A thread page renders the list frame empty on purpose * (templates/mail/thread.html.twig leaves `message_list` and `inbox_tabs` * blank, and the toolbar falls back to a total of zero), because the thread * route has no list to render. Going back from a thread therefore used to * uncover that empty frame — no rows, no tabs, no pagination — and it * stayed that way until the next poll happened to fetch a URL that had a * list in it. * * So the emptiness is asked about rather than assumed: the server marks the * frame with whether it actually rendered a list, and an unrendered one is * filled before it is shown. * * The other half of the same question is whether what IS there is still * true. Opening a conversation leaves the list in the DOM untouched, so * coming back uncovered the snapshot taken at the moment it was covered — * and the whole point of opening a thread is to change it. Reply to a * conversation and go back and the list still showed the message count, the * timestamp and the unread state from before the reply, until a reload. * * A revalidation cannot be allowed to reintroduce the blank pane, so the * two cases stay separate: an unrendered frame is filled BEFORE it is * shown, a rendered one is shown at once and corrected behind the reveal. * The list is therefore immediate and current, rather than one or the other. * * @param {{revalidate?: boolean}} options `revalidate` for the paths that * uncover a list which has been sitting behind something — going * back from a thread. Not for a frame that has just been rendered * by the server, which would be a second fetch of what just arrived. */ _showList({ revalidate = false } = {}) { if (this._listNeedsRendering()) { // Whatever is on screen stays there for the moment it takes, rather // than being replaced by a blank pane and then by the list. The // fetch is a fragment now, so that moment is short. this._refreshList({ immediate: true }).finally(() => this._reveal()); return; } this._reveal(); if (true === revalidate) { this._refreshList({ immediate: true }); } } _reveal() { this.readingTarget.classList.add("hidden"); this.listTarget.classList.remove("hidden"); } /** * Whether the frame currently holds a list that was actually rendered. * * `data-list-rendered` is written by the mailbox layout, so this is the * server's own answer rather than a guess from the DOM — an empty folder * legitimately has no rows and must not be confused with a frame that was * never populated. */ _listNeedsRendering() { const frame = document.getElementById(LIST_FRAME_ID); return frame !== null && frame.dataset.listRendered !== "1"; } onMailboxSynced(event) { const data = event.detail; // The list views are unified across accounts, so a synced mailbox is // relevant when its special-use role matches what the view shows. // Views that span every mailbox (label, search, starred) use "*". if (this._affectsCurrentView(data)) { this._refreshList(); } } /** * An account finished syncing, without saying which mailbox changed. * * Gmail and Graph publish this instead of mailbox.synced — they have no * per-mailbox sync to report — and so does a demo delivery, whose account * has no Mailbox rows at all because nothing ever connected to an IMAP * server to enumerate them. Nothing listened for it here, so on those * accounts new mail moved the sidebar counts and never appeared in the * list until the next navigation. On the demo that was starker: the * Receive button published, the badge moved, and the list sat unchanged. * * Treated like the polling fallback rather than like a mailbox event, * because it carries the same amount of information: something in this * account changed and there is no way to tell whether it was the view on * screen. Refreshing whatever is open is the only correct answer, and * _refreshList()'s own floor keeps a burst to one request. */ onAccountSynced() { if (false === this.hasListTarget) { return; } this._refreshList(); } /** * A bulk job moved a chunk, or finished. * * THE LIST HAD NO WAY OF FINDING OUT, and for as long as the work happened * inside the request nobody could tell. mail/_job_started.stream.html.twig * says the answer deliberately changes nothing in the list, because "the * list finds out the way it always does, from the writes the job makes" — * except that the only thing that re-read the list after a bulk action was * the toolbar's own `written` event, which fires when the REQUEST lands. * Inline, that was the same instant the work finished. Handed to a worker, * it is the instant the work was queued: the refresh ran against a mailbox * nothing had happened to yet, and the rows sat there afterwards for ever. * * COALESCED WHILE IT RUNS, IMMEDIATE WHEN IT ENDS, and the split is not a * nicety. JobNotifier publishes on every chunk, so a run over five thousand * conversations is fifty of these and the floor in _refreshList() is what * keeps that to one fetch. But the floor is MIN_REFRESH_MS = 15s measured * from the LAST refresh, and the bulk request itself has just caused one — * the toolbar's `written` fires when the response lands, which is now the * moment the work is QUEUED. A job that then finishes a second later had * its nudge deferred by the remaining fourteen, so the list sat showing * mail that was already archived for the rest of that window. It is the * same trap refreshNow() was written for, in a new place: the difference * that matters is whether anybody is waiting, and after "archive all" they * are watching the list to see it happen. * * Per-chunk nudges keep the floor, because those really are a burst and the * rows they move are moving either way. */ onJobChanged(event) { if (false === this.hasListTarget) { return; } const state = event?.detail?.state; this._refreshList({ immediate: "done" === state || "failed" === state }); } /** * Somebody pressed a button and is watching the list for the result. * * Skips the coalescing window, which is there for the opposite situation: a * sync run publishes one event per mailbox per account, so "a sync * happened" arrives as a burst and MIN_REFRESH_MS turns eight fetches into * one. A person pressing Receive is not a burst — and because the window is * measured from the LAST refresh, the press right after a delivery was the * one most likely to be inside it. The mail landed instantly the first * time and appeared to do nothing for the next thirteen seconds the second, * which reads as the button breaking after one use. * * Deliberately not folded into onAccountSynced(): that one is the server * volunteering that something changed, and it should stay coalesced. The * difference between the two is whether anybody is waiting. */ refreshNow() { if (false === this.hasListTarget) { return; } this._refreshList({ immediate: true }); } _affectsCurrentView(data) { if (!this.hasListTarget) { return false; } // The polling fallback carries no mailbox, because it is not reporting // one — it fires when the stream is down and the list may be stale for // any reason at all, so it has to refresh whatever view is open. if (data.poll) { return true; } // Off the FRAME, not off the pane around it. The pane is outside the // frame and survives a frame navigation, so a scope read from it is // the scope of the folder you were on before the click — see the // attribute's own note in _layout/_mailbox.html.twig. const scope = document.getElementById(LIST_FRAME_ID)?.dataset.syncScope || "*"; if (scope === "*") { return true; } return scope.split(" ").includes(SYNC_SCOPES[data.specialUse] ?? ""); } /** * Refresh the list after a sync, and nothing else. * * Fetched and swapped by hand rather than through Turbo. A page visit * replaces the whole document and takes an open dialog, a half-typed form * and the compose window with it — which is how connecting a mail account * kept destroying the setup wizard it was connected from, since the account * triggers the sync that triggers this. * * `frame.reload()` looked like the scoped answer and is not: this frame is * server-rendered with no `src`, so Turbo has nothing to re-fetch and falls * back to reloading the page — the very thing being avoided, and with * `data-turbo-action="advance"` on the frame it navigates too. * * So: ask for the current URL, take the matching frame out of the response, * and swap its contents in. It cannot navigate, because nothing here * navigates. * * The sidebar keeps its own counts up to date from the same Mercure * updates, so nothing outside the frame needs this to redraw it. * * Only the frame comes back now, not the page it lives in. The request * carries X-List-Fragment and the mailbox layout answers with the list * alone — the same content the DOMParser below was extracting from 80 KB of * document and discarding the rest of. * * @param {{immediate?: boolean}} options `immediate` skips the rate limit, * for a refresh a person is waiting on rather than one a sync asked * for — going back to an unrendered list, specifically. */ async _refreshList({ immediate = false } = {}) { const frame = document.getElementById(LIST_FRAME_ID); if (frame === null) { console.warn("[mail-pane] no list frame to refresh"); return; } // Nobody is looking. Remembered, not dropped: the visibilitychange // handler takes it the moment the tab is looked at again. if (document.hidden && !immediate) { this._refreshPending = true; return; } // The user is writing to this list right now. Held rather than dropped, // and taken by release() once the last write lands — see hold(). if (this._holds > 0) { this._refreshPending = true; return; } // One at a time: a burst of sync events would otherwise have several // responses racing to write the same element. if (this._refreshing === true) { return; } // A sync run publishes one event per mailbox per account, so "a sync // happened" arrives as a burst. Take the first and coalesce the rest // into one trailing refresh, instead of serialising the whole burst. const waited = Date.now() - this._lastRefreshAt; if (!immediate && waited < MIN_REFRESH_MS) { if (this._refreshTimer === null) { this._refreshTimer = setTimeout(() => { this._refreshTimer = null; this._refreshList(); }, MIN_REFRESH_MS - waited); } return; } this._refreshing = true; this._refreshPending = false; this._lastRefreshAt = Date.now(); try { const response = await fetch(window.location.href, { headers: { [FRAGMENT_HEADER]: LIST_FRAME_ID, Accept: "text/html", }, credentials: "same-origin", }); if (response.ok === false) { return; } const fresh = new DOMParser() .parseFromString(await response.text(), "text/html") .getElementById(LIST_FRAME_ID); if (fresh === null) { console.warn("[mail-pane] no list frame in the response"); return; } // A refresh asks for whatever URL the page is on, and while a // conversation is open that URL is the thread's — whose list frame // is deliberately empty. Copying that over a real list is what // emptied it, and the emptiness was only noticed later, on the way // back. A response that says it holds no list has nothing to swap // in, so it is not swapped in. if ("1" !== (fresh.dataset.listRendered ?? "1")) { return; } this._swapRegions(frame, fresh); // Carried over with the content: the response says whether it // actually rendered a list, and _listNeedsRendering() reads it back // off the live frame on the next Back. frame.dataset.listRendered = fresh.dataset.listRendered ?? "1"; } catch (error) { // A failed refresh is not worth surfacing: the next sync event, or // the next navigation, redraws it anyway. console.warn("[mail-pane] list refresh failed", error); } finally { this._refreshing = false; } } /** * Bring the frame up to date without rebuilding the parts being used. * * See REFRESHABLE_REGIONS for which parts those are and why the toolbar is * not one of them. * * @param {Element} frame the live frame * @param {Element} fresh the same frame as the server has just rendered it */ _swapRegions(frame, fresh) { const regions = REFRESHABLE_REGIONS.map((name) => [ name, frame.querySelector(`[data-list-region="${name}"]`), fresh.querySelector(`[data-list-region="${name}"]`), ]); // Something without the regions in it — a template that has not been // marked up, or a response from an older deploy mid-rollout. Replaced // whole, the way this always did: a stale list is a worse answer than a // rebuilt toolbar. if (regions.some(([, live, incoming]) => live === null || incoming === null)) { frame.innerHTML = fresh.innerHTML; return; } const selected = this._selectedRowIds(frame); regions.forEach(([name, live, incoming]) => { if (MORPHED_REGION === name) { this._morphRows(live, incoming); return; } // Morphed rather than assigned, so the tab strip and the pager // change only where they differ. Assignment rebuilt them whole and // made both blink on every refresh. Idiomorph.morph(live, incoming.innerHTML, { morphStyle: "innerHTML" }); }); this._restoreSelection(frame, selected); } /** * Bring the rows up to date by difference rather than by replacement. * * See MORPHED_REGION for why this region and no other. * * What comes out of it, for free, is the vocabulary the animation layer * wanted and could not have: a row that is genuinely new is a genuinely new * node, so motion.js's observer sees it and plays its entrance without any * id bookkeeping being consulted; a row that survived is the same node it * was, so it plays nothing; and a row that fell off the end is a removal we * can hold onto long enough to animate. * * The selection is still read before and restored after. Morphing preserves * the checkbox NODE but not its checked state — Idiomorph syncs `checked` * from the incoming input like any other attribute, and the incoming input * is the server's, which knows nothing about what is ticked. */ _morphRows(live, incoming) { // Brackets the morph: the measuring has to happen while the old list is // still standing, and the playing after the new one has been laid out. // See motion.js#makeRoom — a list that is replaced rather than inserted // into has no other moment at which a gap could be shown opening. const room = makeRoom(live); // `incoming.innerHTML`, not `incoming`. The second argument is the new // CONTENT, and an element handed over whole is content — so passing the // fresh