mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
Polish email and cookbook flows
This commit is contained in:
+368
-70
@@ -27,6 +27,183 @@ const API_BASE = window.location.origin;
|
||||
let _emailUnreadChipClickWired = false;
|
||||
let _libLoadSeq = 0;
|
||||
let _libFolderSeq = 0;
|
||||
let _libSearchSeq = 0;
|
||||
let _libSearchHadResults = false;
|
||||
let _activeEmailReaderForSelectAll = null;
|
||||
|
||||
function _isEmailTypingTarget(t) {
|
||||
return !!(t && (
|
||||
t.tagName === 'INPUT' ||
|
||||
t.tagName === 'TEXTAREA' ||
|
||||
t.tagName === 'SELECT' ||
|
||||
t.isContentEditable
|
||||
));
|
||||
}
|
||||
|
||||
function _selectEmailReaderContents(reader) {
|
||||
if (!reader || !reader.isConnected) return false;
|
||||
const hiddenModal = reader.closest('.modal.hidden');
|
||||
if (hiddenModal) return false;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(reader);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _markEmailReaderActive(reader) {
|
||||
if (!reader) return;
|
||||
_activeEmailReaderForSelectAll = reader;
|
||||
if (reader.dataset.selectAllWired === '1') return;
|
||||
reader.dataset.selectAllWired = '1';
|
||||
reader.addEventListener('pointerdown', () => { _activeEmailReaderForSelectAll = reader; }, true);
|
||||
reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true);
|
||||
}
|
||||
|
||||
const _COPY_EMAIL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
|
||||
function _decodeAttrValue(v) {
|
||||
const tmp = document.createElement('textarea');
|
||||
tmp.innerHTML = v || '';
|
||||
return tmp.value;
|
||||
}
|
||||
|
||||
function _emailAddressFromRecipientText(text) {
|
||||
const raw = String(text || '').trim();
|
||||
const angle = raw.match(/<\s*([^<>@\s]+@[^<>\s]+)\s*>/);
|
||||
if (angle) return angle[1].trim();
|
||||
const any = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
|
||||
return any ? any[0].trim() : raw;
|
||||
}
|
||||
|
||||
function _splitRecipientList(raw) {
|
||||
const out = [];
|
||||
let cur = '';
|
||||
let quote = false;
|
||||
let angle = false;
|
||||
const s = String(raw || '');
|
||||
for (let i = 0; i < s.length; i += 1) {
|
||||
const ch = s[i];
|
||||
if (ch === '"' && s[i - 1] !== '\\') quote = !quote;
|
||||
else if (ch === '<' && !quote) angle = true;
|
||||
else if (ch === '>' && !quote) angle = false;
|
||||
|
||||
if (ch === ',' && !quote && !angle) {
|
||||
const part = cur.trim();
|
||||
if (part) out.push(part);
|
||||
cur = '';
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
const tail = cur.trim();
|
||||
if (tail) out.push(tail);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function _copyTextToClipboard(text) {
|
||||
const value = String(text || '');
|
||||
if (!value) return false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = value;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
ta.style.top = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return !!ok;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _recipientChipHtml(full, label, extraClass = '') {
|
||||
const fullText = String(full || '').trim();
|
||||
const addr = _emailAddressFromRecipientText(fullText);
|
||||
const labelText = String(label || addr || fullText || '').trim();
|
||||
const cls = `recipient-chip${extraClass ? ` ${extraClass}` : ''}`;
|
||||
return `<span class="${cls}" data-full="${_esc(fullText || labelText)}" data-email="${_esc(addr)}" title="Click for details"><span class="recipient-chip-label">${_esc(labelText)}</span><button type="button" class="recipient-chip-copy" title="Copy email" aria-label="Copy email" hidden>${_COPY_EMAIL_ICON}</button></span>`;
|
||||
}
|
||||
|
||||
function _wireRecipientChips(root) {
|
||||
if (!root || root.dataset.recipientChipsWired === '1') return;
|
||||
root.dataset.recipientChipsWired = '1';
|
||||
root.addEventListener('click', async (ev) => {
|
||||
const copyBtn = ev.target.closest?.('.recipient-chip-copy');
|
||||
if (copyBtn && root.contains(copyBtn)) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const chip = copyBtn.closest('.recipient-chip');
|
||||
const email = chip?.dataset.email || _emailAddressFromRecipientText(_decodeAttrValue(chip?.dataset.full || ''));
|
||||
if (!email) return;
|
||||
try {
|
||||
const copied = await _copyTextToClipboard(email);
|
||||
if (!copied) throw new Error('copy failed');
|
||||
copyBtn.classList.add('copied');
|
||||
copyBtn.title = 'Copied';
|
||||
showToast?.('Email copied');
|
||||
setTimeout(() => {
|
||||
copyBtn.classList.remove('copied');
|
||||
copyBtn.title = 'Copy email';
|
||||
}, 900);
|
||||
} catch (_) {
|
||||
showToast?.('Copy failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const chip = ev.target.closest?.('.recipient-chip');
|
||||
if (!chip || !root.contains(chip)) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const label = chip.querySelector('.recipient-chip-label');
|
||||
const copy = chip.querySelector('.recipient-chip-copy');
|
||||
if (chip.classList.contains('expanded')) {
|
||||
chip.classList.remove('expanded');
|
||||
if (label) label.textContent = chip.dataset.name || label.textContent;
|
||||
if (copy) copy.hidden = true;
|
||||
} else {
|
||||
if (!chip.dataset.name && label) chip.dataset.name = label.textContent.trim();
|
||||
chip.classList.add('expanded');
|
||||
const expandedText = _decodeAttrValue(chip.dataset.full || '').trim()
|
||||
|| chip.dataset.name
|
||||
|| chip.dataset.email
|
||||
|| label?.textContent?.trim()
|
||||
|| '';
|
||||
if (label && expandedText) label.textContent = expandedText;
|
||||
if (copy) copy.hidden = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _emailReaderForSelectAllTarget(target) {
|
||||
if (_isEmailTypingTarget(target)) return null;
|
||||
const direct = target?.closest?.('.email-card-reader, #email-lib-modal .doclib-card.doclib-card-expanded');
|
||||
if (direct) return direct.querySelector?.('.email-card-reader') || direct;
|
||||
const expanded = document.querySelector('#email-lib-modal:not(.hidden) .doclib-card.doclib-card-expanded .email-card-reader');
|
||||
if (expanded) return expanded;
|
||||
return _activeEmailReaderForSelectAll;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || String(e.key || '').toLowerCase() !== 'a') return;
|
||||
const reader = _emailReaderForSelectAllTarget(e.target);
|
||||
if (!_selectEmailReaderContents(reader)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation?.();
|
||||
}, true);
|
||||
|
||||
function _syncEmailReadState(uid, isRead = true) {
|
||||
if (uid == null) return;
|
||||
@@ -1047,10 +1224,26 @@ export function openEmailLibrary(opts = {}) {
|
||||
_bulkAction('delete');
|
||||
});
|
||||
|
||||
const selectExpandedEmailText = () => {
|
||||
const expanded = document.querySelector('#email-lib-modal .doclib-card.doclib-card-expanded');
|
||||
const reader = expanded?.querySelector('.email-card-reader') || expanded;
|
||||
return _selectEmailReaderContents(reader);
|
||||
};
|
||||
|
||||
// ESC to close + Arrow nav + Delete on the selected / currently-expanded email.
|
||||
state._libEscHandler = (e) => {
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
if (!modal || modal.classList.contains('hidden')) return;
|
||||
if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'a') {
|
||||
const t = e.target;
|
||||
if (_isEmailTypingTarget(t)) return;
|
||||
if (selectExpandedEmailText()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1067,7 +1260,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
}
|
||||
// Don't hijack arrows / delete while the user is typing somewhere.
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
if (_isEmailTypingTarget(t)) return;
|
||||
const isDeleteKey = e.key === 'Delete' || e.key === 'Backspace';
|
||||
if (isDeleteKey && state._selectMode && state._selectedUids.size > 0) {
|
||||
e.preventDefault();
|
||||
@@ -1193,6 +1386,23 @@ function _makeDraggable(content, modal, fsClass) {
|
||||
fsClass,
|
||||
skipSelector: '.close-btn, .modal-close',
|
||||
enableLeftDock: true, // park the email on the left while replying on the right
|
||||
onDragStart: ({ rect }) => {
|
||||
if (!modal.classList.contains('email-snap-left')) return;
|
||||
modal.classList.remove('email-snap-left');
|
||||
_clearEmailDocumentSplit();
|
||||
content.style.position = 'fixed';
|
||||
content.style.left = `${Math.round(rect.left)}px`;
|
||||
content.style.top = `${Math.round(rect.top)}px`;
|
||||
content.style.right = '';
|
||||
content.style.bottom = '';
|
||||
content.style.width = `${Math.max(420, Math.round(rect.width || 560))}px`;
|
||||
content.style.maxWidth = '';
|
||||
content.style.height = `${Math.max(320, Math.round(rect.height || 620))}px`;
|
||||
content.style.maxHeight = '85vh';
|
||||
content.style.borderRadius = '';
|
||||
content.style.transform = 'none';
|
||||
content.style.margin = '0';
|
||||
},
|
||||
onEnterFullscreen: fsClass ? enterFullscreen : null,
|
||||
onExitFullscreen: fsClass ? exitFullscreen : null,
|
||||
});
|
||||
@@ -1316,22 +1526,43 @@ function _crossFolderCandidates() {
|
||||
}
|
||||
|
||||
async function _doSearch() {
|
||||
const seq = ++_libSearchSeq;
|
||||
const q = state._libSearch.trim();
|
||||
if (q.length < 2) {
|
||||
// Empty or too short — show regular loaded emails
|
||||
// Empty or too short — restore the normal folder if a previous search
|
||||
// had replaced the grid contents.
|
||||
if (_libSearchHadResults) {
|
||||
_libSearchHadResults = false;
|
||||
state._libOffset = 0;
|
||||
await _loadEmails({ useCache: true });
|
||||
return;
|
||||
}
|
||||
_renderGrid();
|
||||
return;
|
||||
}
|
||||
const grid = document.getElementById('email-lib-grid');
|
||||
if (!grid) return;
|
||||
const sp = _renderEmailLoading(grid);
|
||||
const accountAtStart = state._libAccountId || '';
|
||||
const folderAtStart = state._libFolder || 'INBOX';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(state._libFolder)}${_acct()}&q=${encodeURIComponent(q)}&limit=100`);
|
||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`);
|
||||
const data = await res.json();
|
||||
sp.destroy();
|
||||
if (
|
||||
seq !== _libSearchSeq ||
|
||||
q !== state._libSearch.trim() ||
|
||||
accountAtStart !== (state._libAccountId || '') ||
|
||||
folderAtStart !== (state._libFolder || 'INBOX')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
const results = data.emails || [];
|
||||
_libSearchHadResults = true;
|
||||
state._libEmails = results; // temporarily replace with search results
|
||||
_renderGrid();
|
||||
|
||||
@@ -1895,8 +2126,9 @@ function _syncCardNavArrows(card) {
|
||||
}
|
||||
|
||||
const _emailReadPrefetching = new Set();
|
||||
let _emailReadPrefetchTimer = null;
|
||||
|
||||
function _prefetchAdjacentEmails(card, count = 3) {
|
||||
function _prefetchAdjacentEmails(card, count = 1) {
|
||||
if (!card || state._libFolder === '__scheduled__') return;
|
||||
const grid = card.closest('.doclib-grid');
|
||||
if (!grid) return;
|
||||
@@ -1910,16 +2142,19 @@ function _prefetchAdjacentEmails(card, count = 3) {
|
||||
if (targets.length < count) {
|
||||
for (let i = 1; targets.length < count && cards[idx - i]; i++) targets.push(cards[idx - i]);
|
||||
}
|
||||
for (const target of targets) {
|
||||
const uid = target.dataset.uid;
|
||||
if (!uid) continue;
|
||||
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
|
||||
if (_emailReadPrefetching.has(key)) continue;
|
||||
const target = targets.find(t => t?.dataset?.uid);
|
||||
const uid = target?.dataset?.uid;
|
||||
if (!uid) return;
|
||||
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
|
||||
if (_emailReadPrefetching.has(key) || _emailReadPrefetching.size > 0) return;
|
||||
if (_emailReadPrefetchTimer) clearTimeout(_emailReadPrefetchTimer);
|
||||
_emailReadPrefetchTimer = setTimeout(() => {
|
||||
_emailReadPrefetchTimer = null;
|
||||
_emailReadPrefetching.add(key);
|
||||
fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&mark_seen=false`)
|
||||
.catch(() => {})
|
||||
.finally(() => _emailReadPrefetching.delete(key));
|
||||
}
|
||||
}, 900);
|
||||
}
|
||||
|
||||
async function _toggleCardPreview(card, em) {
|
||||
@@ -1987,6 +2222,7 @@ async function _toggleCardPreview(card, em) {
|
||||
loadingWrap.appendChild(sp.element);
|
||||
reader.appendChild(loadingWrap);
|
||||
card.appendChild(reader);
|
||||
_markEmailReaderActive(reader);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
|
||||
@@ -2032,16 +2268,16 @@ async function _toggleCardPreview(card, em) {
|
||||
// Build recipient chip group from a comma-separated address list
|
||||
const buildRecipients = (str) => {
|
||||
if (!str) return '';
|
||||
const addrs = str.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const addrs = _splitRecipientList(str);
|
||||
if (addrs.length === 0) return '';
|
||||
return addrs.map(a => {
|
||||
const name = _extractName(a);
|
||||
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// Build the From chip too — single chip with name, click reveals address
|
||||
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} <${_esc(data.from_address)}>" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
|
||||
reader.innerHTML = `
|
||||
<div class="email-reader-header">
|
||||
@@ -2069,6 +2305,7 @@ async function _toggleCardPreview(card, em) {
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(reader);
|
||||
reader.classList.remove('email-card-reader-loading');
|
||||
reader.style.minHeight = '';
|
||||
|
||||
@@ -2218,32 +2455,9 @@ async function _toggleCardPreview(card, em) {
|
||||
_showCachedSummary(reader, data.cached_summary, sumBtn);
|
||||
}
|
||||
|
||||
// Event delegation for recipient chip clicks (toggle expand)
|
||||
reader.addEventListener('click', (ev) => {
|
||||
const chip = ev.target.closest('.recipient-chip');
|
||||
if (chip && reader.contains(chip)) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const full = chip.getAttribute('data-full') || '';
|
||||
if (chip.classList.contains('expanded')) {
|
||||
chip.classList.remove('expanded');
|
||||
const name = chip.getAttribute('data-name');
|
||||
if (name != null) chip.textContent = name;
|
||||
} else {
|
||||
if (!chip.hasAttribute('data-name')) {
|
||||
chip.setAttribute('data-name', chip.textContent.trim());
|
||||
}
|
||||
chip.classList.add('expanded');
|
||||
// Decode HTML entities from the data-full attribute
|
||||
const tmp = document.createElement('textarea');
|
||||
tmp.innerHTML = full;
|
||||
chip.textContent = tmp.value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Always stop bubbling so the card's click doesn't fire
|
||||
ev.stopPropagation();
|
||||
});
|
||||
_wireRecipientChips(reader);
|
||||
// Always stop bubbling so the card's click doesn't fire while reading.
|
||||
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
|
||||
} catch (e) {
|
||||
reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Failed to load email</div>`;
|
||||
}
|
||||
@@ -3716,6 +3930,7 @@ async function _openEmailAsTab(em, folder) {
|
||||
// Fetch + render the email body using the exact same template as
|
||||
// _toggleCardPreview so the visuals match perfectly.
|
||||
const reader = modal.querySelector('.email-card-reader');
|
||||
_markEmailReaderActive(reader);
|
||||
const sp = spinnerModule.createWhirlpool(28);
|
||||
const loading = modal.querySelector('.email-reader-tab-loading');
|
||||
if (loading) loading.appendChild(sp.element);
|
||||
@@ -3729,12 +3944,12 @@ async function _openEmailAsTab(em, folder) {
|
||||
_syncEmailReadState(em.uid, true);
|
||||
const buildChips = (str) => {
|
||||
if (!str) return '';
|
||||
return str.split(',').map(s => s.trim()).filter(Boolean).map(a => {
|
||||
return _splitRecipientList(str).map(a => {
|
||||
const name = _extractName(a);
|
||||
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} <${_esc(data.from_address)}>" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
|
||||
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 = `
|
||||
@@ -3763,6 +3978,8 @@ async function _openEmailAsTab(em, folder) {
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(reader);
|
||||
_wireRecipientChips(reader);
|
||||
try { _wireAttachmentHandlers(reader, useFolder); } catch {}
|
||||
const attsWrap = reader.querySelector('.email-reader-atts-wrap');
|
||||
if (attsWrap) {
|
||||
@@ -3875,18 +4092,19 @@ async function _openEmailWindow(em, folder) {
|
||||
// standalone viewer looks/feels exactly like a real email view.
|
||||
const _chipsFor = (addrs) => {
|
||||
if (!addrs) return '';
|
||||
const list = addrs.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const list = _splitRecipientList(addrs);
|
||||
return list.map(a => {
|
||||
const name = _extractName(a);
|
||||
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} <${_esc(data.from_address)}>" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
|
||||
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 {}
|
||||
// Repurpose bodyEl as a full email-card-reader so the inline reader's
|
||||
// CSS applies (sized header, action buttons in two rows, etc.).
|
||||
bodyEl.classList.add('email-card-reader');
|
||||
_markEmailReaderActive(bodyEl);
|
||||
bodyEl.style.padding = '0';
|
||||
bodyEl.innerHTML = `
|
||||
<div class="email-reader-header">
|
||||
@@ -3914,6 +4132,8 @@ async function _openEmailWindow(em, folder) {
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(bodyEl);
|
||||
_wireRecipientChips(bodyEl);
|
||||
// Wire all the same action handlers the inline reader has.
|
||||
try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {}
|
||||
const attsWrap = bodyEl.querySelector('.email-reader-atts-wrap');
|
||||
@@ -3986,11 +4206,22 @@ async function _swapReaderToUid(reader, uid, folder) {
|
||||
if (headerMeta) {
|
||||
const subj = data.subject || '(no subject)';
|
||||
const date = data.date ? new Date(data.date).toLocaleString() : '';
|
||||
const chipsFor = (addrs) => {
|
||||
if (!addrs) return '';
|
||||
return _splitRecipientList(addrs).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');
|
||||
headerMeta.innerHTML = `
|
||||
<div class="email-reader-meta-row"><strong>Subject:</strong> ${_esc(subj)}</div>
|
||||
<div class="email-reader-meta-row"><strong>From:</strong> ${_esc(data.from_name || data.from_address)} <${_esc(data.from_address)}></div>
|
||||
<div class="email-reader-meta-row"><strong>From:</strong><span class="recipient-chips">${fromChip}</span></div>
|
||||
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${chipsFor(data.to)}</span></div>` : ''}
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${chipsFor(data.cc)}</span></div>` : ''}
|
||||
${date ? `<div class="email-reader-meta-row"><strong>Date:</strong> ${_esc(date)}</div>` : ''}
|
||||
`;
|
||||
_wireRecipientChips(reader);
|
||||
}
|
||||
// Refresh the attachments block to match the new email. Build fresh HTML
|
||||
// and either replace the existing block, remove it (if the new email has
|
||||
@@ -4227,6 +4458,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
const _deleteForeverIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="14" y2="15"/><line x1="14" y1="11" x2="10" y2="15"/></svg>';
|
||||
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
||||
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
|
||||
const closeAndRemove = async () => {
|
||||
// Pick the next neighbour BEFORE we re-render so we know which email to
|
||||
@@ -4309,6 +4541,24 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: em.is_answered ? 'Not Done' : 'Done',
|
||||
icon: _checkIcon,
|
||||
action: async () => {
|
||||
const newState = !em.is_answered;
|
||||
em.is_answered = newState;
|
||||
if (newState) _syncEmailReadState(em.uid, true);
|
||||
try {
|
||||
if (newState) {
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else {
|
||||
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
}
|
||||
} catch (e) { console.error('Failed to toggle done:', e); }
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Archive',
|
||||
icon: _archIcon,
|
||||
@@ -4450,7 +4700,7 @@ function _showCardMenu(em, anchor) {
|
||||
const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
|
||||
const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
|
||||
actions.push({
|
||||
label: _currentlyDone ? 'Mark Not Done' : 'Mark Done',
|
||||
label: _currentlyDone ? 'Not Done' : 'Done',
|
||||
icon: _checkIcon,
|
||||
action: async () => {
|
||||
const card = anchor.closest('.doclib-card');
|
||||
@@ -4579,7 +4829,9 @@ function _showBulkActionsMenu(anchor) {
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
|
||||
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const items = [
|
||||
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
|
||||
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
|
||||
{ label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
|
||||
];
|
||||
@@ -4649,32 +4901,78 @@ async function _bulkAction(action) {
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
for (const uid of uids) {
|
||||
try {
|
||||
if (action === 'archive') {
|
||||
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else if (action === 'delete') {
|
||||
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
} else if (action === 'read' || action === 'unread') {
|
||||
const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
|
||||
const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (!res.ok || data?.success === false) {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
_syncEmailReadState(uid, action === 'read');
|
||||
}
|
||||
} catch (e) {
|
||||
if (action === 'read' || action === 'unread') failedReadSync += 1;
|
||||
console.error(`Failed to ${action} ${uid}:`, e);
|
||||
const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null;
|
||||
const actionsBtn = document.getElementById('email-lib-bulk-actions');
|
||||
const cancelBtn = document.getElementById('email-lib-bulk-cancel');
|
||||
const selectAll = document.getElementById('email-lib-select-all');
|
||||
const countEl = document.getElementById('email-lib-selected-count');
|
||||
const originalDeleteHtml = deleteBtn?.innerHTML || '';
|
||||
const originalCountText = countEl?.textContent || '';
|
||||
let busySpinner = null;
|
||||
if (action === 'delete') {
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.classList.add('email-bulk-loading');
|
||||
deleteBtn.innerHTML = '<span class="email-bulk-loading-label">Deleting</span>';
|
||||
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
|
||||
const spEl = busySpinner.createElement();
|
||||
spEl.classList.add('email-bulk-whirlpool');
|
||||
deleteBtn.appendChild(spEl);
|
||||
busySpinner.start();
|
||||
}
|
||||
if (actionsBtn) actionsBtn.disabled = true;
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
if (selectAll) selectAll.disabled = true;
|
||||
if (countEl) countEl.textContent = `Deleting ${uids.length}...`;
|
||||
}
|
||||
|
||||
if (action === 'archive' || action === 'delete') {
|
||||
await _animateEmailCardRemoval(uids);
|
||||
const removed = new Set(uids.map(uid => String(uid)));
|
||||
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
|
||||
try {
|
||||
for (const uid of uids) {
|
||||
try {
|
||||
if (action === 'archive') {
|
||||
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else if (action === 'delete') {
|
||||
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
} else if (action === 'done') {
|
||||
const em = state._libEmails.find(e => e.uid === uid);
|
||||
if (em) {
|
||||
em.is_answered = true;
|
||||
em.is_read = true;
|
||||
}
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else if (action === 'read' || action === 'unread') {
|
||||
const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
|
||||
const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (!res.ok || data?.success === false) {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
_syncEmailReadState(uid, action === 'read');
|
||||
}
|
||||
} catch (e) {
|
||||
if (action === 'read' || action === 'unread') failedReadSync += 1;
|
||||
console.error(`Failed to ${action} ${uid}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'archive' || action === 'delete') {
|
||||
await _animateEmailCardRemoval(uids);
|
||||
const removed = new Set(uids.map(uid => String(uid)));
|
||||
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
|
||||
}
|
||||
} finally {
|
||||
if (busySpinner) busySpinner.destroy();
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.classList.remove('email-bulk-loading');
|
||||
deleteBtn.innerHTML = originalDeleteHtml;
|
||||
}
|
||||
if (actionsBtn) actionsBtn.disabled = false;
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
if (selectAll) selectAll.disabled = false;
|
||||
if (countEl) countEl.textContent = originalCountText;
|
||||
}
|
||||
state._selectedUids.clear();
|
||||
state._selectMode = false;
|
||||
|
||||
Reference in New Issue
Block a user