// static/js/emailLibrary/reader.js // // The two ways an email opens outside the list: as a docked tab (a numbered // slot in the dock, `_openEmailAsTab`) and as a floating window // (`_openEmailWindow`). Plus the AI summary panel, which both of them and the // inline expanded card share. // // Reader slot numbers are deliberately sticky: once a reader is tab 2 it stays // tab 2 until it closes, even if tab 1 closes first. The slot map here is what // holds that. import spinnerModule from '../spinner.js'; import * as Modals from '../modalManager.js'; import { showToast } from '../ui.js?v=20260916largetoolscroll1'; import { state } from './state.js'; import { _esc, _extractName, _renderEmailSummaryError } from './utils.js'; import { _aiReplyIcon, _handleAiReplyButton, _hasMultipleRecipients } from './aiReply.js'; import { _buildAttsHtmlFor, _loadDeferredAttachmentsIntoReader, _wireEmailAttachmentWrap, } from './attachments.js'; import { _renderEmailBody, _safeRenderEmailBody, _wireEmailInlineImages, } from './bodyRender.js'; import { _acct, _emailReaderSkeletonHtml, _makeDraggable, _markEmailReaderActive, _maybeAutoTranslateEmail, _normalizeEmailStateFlags, _recipientChipHtml, _recipientMetaToggleHtml, _setSummaryCollapsedPref, _showEmailReaderLoadError, _snapEmailModalToLeftSidebar, _splitRecipientList, _stampReaderContext, _summaryCollapsedPref, _syncEmailDoneState, _syncEmailReadState, _toggleCardPreview, _wireMetaToggle, _wireReaderActionOverflow, _wireRecipientChips, } from './index.js'; import { _showReaderMoreMenu } from './menus.js'; const API_BASE = window.location.origin; // "Open in new tab" — the email opens in the library (expanded inline) // AND a separate floating "email viewer" overlay modal is created. The // overlay starts minimized as a chip in the dock; tapping the chip // brings the viewer up over the library. Multiple tabs = multiple // overlay modals + chips, each independent. const _EMAIL_ICON_PATH = 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7'; let _emailTabSeq = 0; // Persistent slot numbers per reader modalId. Once a reader is "tab 2" // it stays "tab 2" until it's closed — even if tab 1 closes first, the // remaining reader doesn't renumber down to 1. New tabs claim the // lowest unused slot. const _emailReaderSlots = new Map(); // modalId -> slot (1, 2, 3, ...) function _allocReaderSlot(modalId) { if (_emailReaderSlots.has(modalId)) return _emailReaderSlots.get(modalId); const used = new Set(_emailReaderSlots.values()); let n = 1; while (used.has(n)) n++; _emailReaderSlots.set(modalId, n); return n; } function _freeReaderSlot(modalId) { _emailReaderSlots.delete(modalId); } // JS-driven gate: sets [data-email-tabs="N"] on
so CSS can show // the per-chip number badge only when 2+ tabs exist. function _syncEmailTabsCount() { const tabs = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-view-"]'); document.body.dataset.emailTabs = String(tabs.length); } // Recompute the email menu chip's tab-count whenever the dock contents // change. Counts "email-view-*" chips both inside #minimized-dock and // at body level (free-positioned chips on mobile). Result is written to // the email-lib-modal chip's data-tab-count attribute; CSS reads it via // attr() to render the badge. function _syncEmailTabBadge() { const readers = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-reader-"]'); document.body.dataset.emailReaders = String(readers.length); // Stamp each chip with its persistent slot number. CSS reads // data-tab-num via attr() instead of using a counter so the number // stays stable when other tabs close. readers.forEach(chip => { const slot = _emailReaderSlots.get(chip.dataset.modalId); if (slot) chip.dataset.tabNum = String(slot); }); } let _emailTabObserverWired = false; let _badgeSyncScheduled = false; function _ensureEmailTabObserver() { if (_emailTabObserverWired) return; _emailTabObserverWired = true; // Debounce so a burst of mutations (e.g. _renderDock rebuilding the // whole dock in one pass) collapses to a single sync per animation // frame. Without this the chip badge could flicker as the observer // fires repeatedly during dock rerenders. const handler = () => { if (_badgeSyncScheduled) return; _badgeSyncScheduled = true; requestAnimationFrame(() => { _badgeSyncScheduled = false; _syncEmailTabBadge(); }); }; const tryWire = () => { const dock = document.getElementById('minimized-dock'); if (!dock) { setTimeout(tryWire, 200); return; } // Only watch what we care about: chip add/remove in the dock. const obs = new MutationObserver(handler); obs.observe(dock, { childList: true }); // Watch the library grid so toggling a card expanded/collapsed // updates the lib chip's "has-expanded" badge in real time. const wireGridObs = () => { const grid = document.getElementById('email-lib-grid'); if (!grid) { setTimeout(wireGridObs, 500); return; } const gridObs = new MutationObserver(handler); gridObs.observe(grid, { subtree: true, attributes: true, attributeFilter: ['class'] }); }; wireGridObs(); handler(); }; tryWire(); } // Hybrid model: // - email-lib-modal (the inbox library) is unique. Its chip just // restores it. // - Each "Open in new tab" creates a separate per-email reader modal // (id "email-reader-{uid}-{seq}") with the SAME structure & classes // as the library's inline reader, so they look identical. Each // reader registers its own dock chip with a number badge. export async function _openEmailAsTab(em, folder) { const useFolder = folder || state._libFolder || 'INBOX'; _emailTabSeq += 1; const modalId = `email-reader-${em.uid}-${_emailTabSeq}`; _allocReaderSlot(modalId); // Build the modal shell. Uses the same doclib-modal-content sizing // as the email library so it feels like a sibling window. The reader // body inside uses the exact same email-card-reader / email-reader-* // classes the inline reader uses → identical styling. const modal = document.createElement('div'); modal.className = 'modal email-reader-tab-modal'; modal.id = modalId; modal.innerHTML = ` `; document.body.appendChild(modal); // Inherit display from .modal (flex-center). z-index above the library // (which uses default .modal z-index 250) so the new tab sits on top. modal.style.zIndex = '270'; // Opened last → email windows in front of any open doc (alternation flag). document.body.classList.add('email-front'); Modals.register(modalId, { label: 'Email', icon: _EMAIL_ICON_PATH, closeFn: () => { modal.remove(); _freeReaderSlot(modalId); Promise.resolve().then(_syncEmailTabBadge); }, restoreFn: () => { // Reopened last → bring the email windows in front of any open doc. document.body.classList.add('email-front'); // Mobile: only one email window visible at a time. Tapping this // chip chips down the library + any other reader, so the user // toggles between them via the dock instead of stacking. if (window.innerWidth <= 768) { try { if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) { Modals.minimize('email-lib-modal'); } } catch {} document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => { if (other.id === modalId) return; try { if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) { Modals.minimize(other.id); } } catch {} }); } }, }); // Wire the `_` minimize button via modalManager (it sees our .minimize-btn // already exists and just binds the click handler). try { Modals.injectMinimizeButton(modal, modalId); } catch {} // X button fully closes the tab (tears down and unregisters). modal.querySelector('.close-btn')?.addEventListener('click', (ev) => { ev.stopPropagation(); Modals.close(modalId); }); // Wire dragging on the header (desktop only). Matches the global pattern // in app.js initUIVisibility, but that runs once at boot and doesn't see // dynamically-created modals — so we replicate it here. const content = modal.querySelector('.modal-content'); const mh = modal.querySelector('.modal-header'); if (mh && content) { let dragX = 0, dragY = 0, startLeft = 0, startTop = 0, dragging = false; const startDrag = (clientX, clientY) => { dragging = true; const rect = content.getBoundingClientRect(); dragX = clientX; dragY = clientY; startLeft = rect.left; startTop = rect.top; content.style.position = 'fixed'; content.style.left = startLeft + 'px'; content.style.top = startTop + 'px'; content.style.margin = '0'; }; const onDrag = (e) => { if (!dragging) return; content.style.left = (startLeft + e.clientX - dragX) + 'px'; content.style.top = (startTop + e.clientY - dragY) + 'px'; }; const stopDrag = () => { dragging = false; document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag); }; mh.addEventListener('mousedown', (e) => { if (e.target.closest('.close-btn, .minimize-btn, .modal-minimize-btn')) return; e.preventDefault(); startDrag(e.clientX, e.clientY); document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); }); } // Open the new tab in front, on top of the email library. The user // can tap `_` to tab it down to a chip when they're done reading. // // Mobile: bottom-sheet windows fill the viewport, so stacking multiple // readers on top of each other is confusing — only one window can be // meaningfully visible at a time. So when the new tab opens, chip down // the library AND any other email-reader-* tab that's currently up. // The user gets a stack of mini chips to toggle between them. if (window.innerWidth <= 768) { try { if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) { Modals.minimize('email-lib-modal'); } } catch {} document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => { if (other.id === modalId) return; try { if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) { Modals.minimize(other.id); } } catch {} }); } _ensureEmailTabObserver(); _syncEmailTabBadge(); // Fetch + render the email body using the exact same template as // _toggleCardPreview so the visuals match perfectly. const reader = modal.querySelector('.email-card-reader'); const showFailedTab = (message) => { try { showToast(message || 'Failed to load email'); } catch (_) {} _showEmailReaderLoadError(reader, message, () => { try { Modals.close(modalId); } catch (_) { try { modal.remove(); } catch (_) {} } setTimeout(() => { _openEmailAsTab(em, useFolder); }, 0); }); }; _markEmailReaderActive(reader); const loading = modal.querySelector('.email-reader-tab-loading'); if (loading) loading.remove(); if (reader) { reader.classList.add('email-card-reader-loading'); reader.innerHTML = _emailReaderSkeletonHtml(); } try { const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); let data = await res.json(); if (data.error) { showFailedTab(`Failed to load email: ${data.error}`); return; } data = _normalizeEmailStateFlags({ ...em, ...data }); Object.assign(em, data); _syncEmailDoneState(em.uid, data.is_answered); _syncEmailReadState(em.uid, true); _stampReaderContext(reader, data, useFolder, state._libAccountId); const buildChips = (str) => { if (!str) return ''; return _splitRecipientList(str).map(a => { const name = _extractName(a); return _recipientChipHtml(a, name); }).join(''); }; const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip'); let attsHtml = ''; try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {} reader.innerHTML = `