mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-12 12:37:32 +00:00
Polish mobile UI and editor workflows
This commit is contained in:
+130
-24
@@ -9,11 +9,10 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
||||
import { emailApiUrl, emailAccountQuery } from './emailShared.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
const _acct = () => window.__odysseusActiveEmailAccount
|
||||
? `&account_id=${encodeURIComponent(window.__odysseusActiveEmailAccount)}`
|
||||
: '';
|
||||
const _acct = () => emailAccountQuery('&');
|
||||
|
||||
const _emailSetupHint = () => '<div style="margin-top:6px;opacity:0.72;font-size:11px;">Setup: <span style="color:var(--accent,var(--red));">Settings › Integrations</span></div>';
|
||||
|
||||
@@ -27,6 +26,59 @@ const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="c
|
||||
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 _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _replySeparator = '---------- Previous message ----------';
|
||||
const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']);
|
||||
|
||||
function _openCalendarEventFromEmail(uid) {
|
||||
const target = String(uid || '').trim();
|
||||
if (!target) return;
|
||||
import('./calendar.js').then(mod => {
|
||||
const open = mod.openCalendarTo || (mod.default && mod.default.openCalendarTo);
|
||||
if (open) open(target);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function _openEmailTagFilter(tag) {
|
||||
const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-');
|
||||
if (!normalized || normalized === 'calendar') return;
|
||||
try { openEmailLibrary(); } catch (_) {}
|
||||
setTimeout(() => {
|
||||
document.dispatchEvent(new CustomEvent('odysseus:email-filter-tag', { detail: { tag: normalized } }));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function _emailTagPillHtml(tag, em) {
|
||||
const normalized = String(tag || '').trim().toLowerCase().replace(/_/g, '-');
|
||||
if (!normalized) return '';
|
||||
const eventUid = normalized === 'calendar' && Array.isArray(em?.calendar_event_uids)
|
||||
? String(em.calendar_event_uids[0] || '').trim()
|
||||
: '';
|
||||
if (normalized === 'calendar') {
|
||||
if (!eventUid) return '';
|
||||
return `<button type="button" class="email-tag email-tag-${_esc(normalized)} email-tag-clickable" data-calendar-event-uid="${_esc(eventUid)}" title="Open calendar event">${_esc(normalized)}</button>`;
|
||||
}
|
||||
return `<button type="button" class="email-tag email-tag-${_esc(normalized)} email-tag-clickable" data-email-filter-tag="${_esc(normalized)}" title="Show ${_esc(normalized)} emails">${_esc(normalized)}</button>`;
|
||||
}
|
||||
|
||||
function _emailTagGroupHtml(tags, em) {
|
||||
const visible = (Array.isArray(tags) ? tags : [])
|
||||
.map(t => _emailTagPillHtml(t, em))
|
||||
.filter(Boolean);
|
||||
if (!visible.length) return '';
|
||||
if (visible.length === 1) return `<span class="email-tags">${visible[0]}</span>`;
|
||||
const extra = visible.slice(1).map(html => `<span class="email-tag-extra">${html}</span>`).join('');
|
||||
return `<span class="email-tags email-tags-collapsed">${visible[0]}${extra}<button type="button" class="email-tags-more" data-email-tags-more aria-expanded="false" title="Show all tags">+${visible.length - 1}<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button></span>`;
|
||||
}
|
||||
|
||||
function _visibleEmailTagsForRender(em) {
|
||||
const tags = Array.isArray(em?.tags) ? em.tags : [];
|
||||
if (!em?.is_answered) return tags;
|
||||
return tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
|
||||
}
|
||||
|
||||
function _clearDoneResponseTagsLocal(em) {
|
||||
if (!em || !Array.isArray(em.tags)) return;
|
||||
em.tags = em.tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-')));
|
||||
}
|
||||
|
||||
function _cleanAiReplyText(text) {
|
||||
if (!text) return '';
|
||||
@@ -69,9 +121,14 @@ window.addEventListener('email-answered', (e) => {
|
||||
const uid = e.detail && e.detail.uid;
|
||||
if (uid == null) return;
|
||||
const em = _emails.find(x => String(x.uid) === String(uid));
|
||||
if (em) { em.is_answered = true; em.is_read = true; }
|
||||
if (em) {
|
||||
em.is_answered = true;
|
||||
em.is_read = true;
|
||||
_clearDoneResponseTagsLocal(em);
|
||||
}
|
||||
document.querySelectorAll('.email-item[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(item => {
|
||||
item.classList.remove('email-unread');
|
||||
item.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
|
||||
const check = item.querySelector('.email-done-check');
|
||||
if (check) check.classList.add('active');
|
||||
// Auto-mark from sending a reply — flash the row so the user sees the
|
||||
@@ -87,10 +144,15 @@ let _docModule = null;
|
||||
let _listSpinner = null;
|
||||
let _senderFilter = null; // email address (lowercased) to filter by, or null
|
||||
let _senderFilterLabel = null; // display label for the active filter chip
|
||||
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
|
||||
|
||||
export function init(documentModule) {
|
||||
_docModule = documentModule;
|
||||
_bindEvents();
|
||||
document.addEventListener('odysseus:email-tags-toggle', (e) => {
|
||||
_showEmailTags = e.detail?.show !== false;
|
||||
_renderList();
|
||||
});
|
||||
// Init the library popup with a callback to open emails
|
||||
initEmailLibrary({
|
||||
documentModule,
|
||||
@@ -136,6 +198,23 @@ export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply', pref
|
||||
}
|
||||
}
|
||||
|
||||
function _bringEmailReplyDraftToFrontOnMobile() {
|
||||
if (window.innerWidth > 768) return;
|
||||
document.body.classList.remove('email-front', 'email-doc-split-active');
|
||||
document.documentElement.style.removeProperty('--email-doc-split-left-x');
|
||||
document.documentElement.style.removeProperty('--email-doc-split-email-w');
|
||||
document.documentElement.style.removeProperty('--email-doc-split-right-x');
|
||||
// Keep the email sheet visible behind the reply document on mobile. The
|
||||
// document panel sits above it via the normal doc-view z-index rules, and
|
||||
// swiping the document down minimizes it to a chip to reveal the email.
|
||||
document.querySelectorAll('#email-lib-modal, .modal[id^="email-reader-"]').forEach(modal => {
|
||||
modal.classList.remove('email-snap-left', 'modal-left-docked', 'modal-right-docked');
|
||||
modal.style.removeProperty('z-index');
|
||||
});
|
||||
const docPane = document.getElementById('doc-editor-pane');
|
||||
if (docPane) docPane.style.setProperty('z-index', '10010', 'important');
|
||||
}
|
||||
|
||||
// When the document editor pane opens (body.doc-view turns on), make sure the
|
||||
// email modal is on the LEFT — even if it was previously docked RIGHT or
|
||||
// floating — so the email and the doc always end up side-by-side. The actual
|
||||
@@ -230,24 +309,24 @@ async function _refreshUnreadCount() {
|
||||
const dot = document.getElementById('email-unread-dot');
|
||||
if (dot && !dot._stickyState) dot.style.display = 'none';
|
||||
try {
|
||||
// Parallel: unread list + urgency state.
|
||||
const [listRes, urgRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/email/list?folder=INBOX&limit=50&filter=unread${_acct()}`),
|
||||
// Parallel: cheap unread state + urgency state.
|
||||
const [stateRes, urgRes] = await Promise.all([
|
||||
fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' })),
|
||||
fetch(`${API_BASE}/api/email/urgency-state`, { credentials: 'same-origin' }).catch(() => null),
|
||||
]);
|
||||
if (!listRes || !listRes.ok) return;
|
||||
const data = await listRes.json();
|
||||
if (!stateRes || !stateRes.ok) return;
|
||||
const data = await stateRes.json();
|
||||
if (!dot) return;
|
||||
|
||||
const emails = data.emails || [];
|
||||
if (emails.length === 0) {
|
||||
const unreadCount = Number(data.unread_count || 0);
|
||||
if (unreadCount <= 0) {
|
||||
dot.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare highest unread UID to the last-seen threshold in localStorage
|
||||
const lastSeen = parseInt(localStorage.getItem('odysseus-email-last-seen-uid') || '0', 10);
|
||||
const maxUid = Math.max(...emails.map(e => parseInt(e.uid, 10) || 0));
|
||||
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
|
||||
|
||||
// Only show dot if there's a new email above the threshold
|
||||
dot.style.display = maxUid > lastSeen ? '' : 'none';
|
||||
@@ -275,12 +354,11 @@ export function markInboxAsSeen() {
|
||||
// Called when the user opens the inbox popup — clears the notif dot
|
||||
try {
|
||||
// Find current max UID so subsequent arrivals trigger the dot
|
||||
fetch(`${API_BASE}/api/email/list?folder=INBOX&limit=1${_acct()}`)
|
||||
fetch(emailApiUrl('/api/email/unread-state', { folder: 'INBOX' }))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const emails = data.emails || [];
|
||||
if (emails.length > 0) {
|
||||
const maxUid = Math.max(...emails.map(e => parseInt(e.uid, 10) || 0));
|
||||
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
|
||||
if (maxUid > 0) {
|
||||
localStorage.setItem('odysseus-email-last-seen-uid', String(maxUid));
|
||||
}
|
||||
const dot = document.getElementById('email-unread-dot');
|
||||
@@ -308,7 +386,7 @@ export async function loadEmails(append = false) {
|
||||
|
||||
try {
|
||||
const fromQS = _senderFilter ? `&from=${encodeURIComponent(_senderFilter)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}&_=${Date.now()}`);
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`);
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
@@ -338,7 +416,8 @@ export async function loadEmails(append = false) {
|
||||
|
||||
async function loadFolders() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/folders?_=1${_acct()}`);
|
||||
const accountQS = _acct().replace(/^&/, '');
|
||||
const res = await fetch(`${API_BASE}/api/email/folders${accountQS ? `?${accountQS}` : ''}`);
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('email-folder-select');
|
||||
if (!select || !data.folders) return;
|
||||
@@ -511,12 +590,10 @@ function _createEmailItem(em) {
|
||||
? `<span class="email-unread-dot-inline" title="${_esc(_unreadTitle)}" style="display:inline-flex;align-items:center;flex-shrink:0;margin-left:4px;color:${_unreadColor}"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg></span>`
|
||||
: '';
|
||||
|
||||
const tags = Array.isArray(em.tags) ? em.tags : [];
|
||||
const tagPills = tags.length
|
||||
? `<span class="email-tags">${tags.map(t => `<span class="email-tag email-tag-${_esc(t)}">${_esc(t)}</span>`).join('')}</span>`
|
||||
: '';
|
||||
const tags = _showEmailTags ? _visibleEmailTagsForRender(em) : [];
|
||||
const tagPills = _emailTagGroupHtml(tags, em);
|
||||
|
||||
const spamTag = em.is_spam_verdict
|
||||
const spamTag = _showEmailTags && em.is_spam_verdict
|
||||
? `<span class="email-tag email-tag-spam" title="AI flagged as spam — click ✓ to unflag">spam <button class="email-spam-unflag" data-uid="${em.uid}" title="Not spam">\u2713</button></span>`
|
||||
: '';
|
||||
|
||||
@@ -534,6 +611,30 @@ function _createEmailItem(em) {
|
||||
|
||||
// Click sender name → filter list to that sender
|
||||
const senderEl = item.querySelector('.email-sender-clickable');
|
||||
item.querySelectorAll('[data-calendar-event-uid]').forEach(btn => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
_openCalendarEventFromEmail(btn.dataset.calendarEventUid);
|
||||
});
|
||||
});
|
||||
item.querySelectorAll('[data-email-filter-tag]').forEach(btn => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
_openEmailTagFilter(btn.dataset.emailFilterTag);
|
||||
});
|
||||
});
|
||||
item.querySelectorAll('[data-email-tags-more]').forEach(btn => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const wrap = btn.closest('.email-tags');
|
||||
if (!wrap) return;
|
||||
const expanded = wrap.classList.toggle('email-tags-expanded');
|
||||
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
});
|
||||
});
|
||||
if (senderEl) {
|
||||
senderEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -658,7 +759,8 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
try {
|
||||
let data = preloadedData;
|
||||
if (!data) {
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`);
|
||||
const fullQS = mode === 'forward' ? '&full=1' : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}${fullQS}`);
|
||||
data = await res.json();
|
||||
}
|
||||
if (data.error) {
|
||||
@@ -752,6 +854,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
if (data.attachments && data.attachments.length > 0) {
|
||||
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
|
||||
content += `\nX-Attachments: ${attStr}`;
|
||||
if (mode === 'forward') content += `\nX-Forward-Attachments: 1`;
|
||||
}
|
||||
content += '\n---\n';
|
||||
|
||||
@@ -895,6 +998,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
} else {
|
||||
await _docModule.loadDocument(doc.id);
|
||||
}
|
||||
_bringEmailReplyDraftToFrontOnMobile();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1099,9 +1203,11 @@ async function _toggleDone(em, itemEl) {
|
||||
if (newState) em.is_read = true; // mark-done implies mark-read
|
||||
if (itemEl) {
|
||||
if (newState) {
|
||||
_clearDoneResponseTagsLocal(em);
|
||||
itemEl.classList.remove('email-unread');
|
||||
// Also drop any inline unread indicator dots the renderer may have added
|
||||
itemEl.querySelectorAll('.email-unread-dot, [data-unread-dot]').forEach(n => n.remove());
|
||||
itemEl.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
|
||||
}
|
||||
const check = itemEl.querySelector('.email-done-check');
|
||||
if (check) check.classList.toggle('active', newState);
|
||||
|
||||
Reference in New Issue
Block a user