1 parent
47c3cd7f65
commit
cfc74d9db6
33 files changed
+850
-244
No files matched your search
@@ -147,6 +147,28 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.indexing-indicator {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.indexing-indicator svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
animation: rustbox-indexing-spin .9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rustbox-indexing-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indexing-indicator svg { animation: none; }
|
||||
}
|
||||
|
||||
.account-summary {
|
||||
display: grid;
|
||||
min-width: 120px;
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
<button id="contacts-btn" class="icon-button" type="button" title="Contacts" aria-label="Contacts"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
|
||||
</svg></button>
|
||||
<span id="indexing-indicator" class="indexing-indicator hidden" role="status" aria-live="polite"
|
||||
title="Indexing messages" aria-label="Indexing messages"><svg aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-refresh-cw"></use>
|
||||
</svg></span>
|
||||
<button id="calendar-btn" class="icon-button" type="button" title="Calendar" aria-label="Calendar"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-calendar-days"></use>
|
||||
</svg></button>
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
realtimePollTimer: null,
|
||||
realtimeSyncPromise: null,
|
||||
realtimeSeenEvents: new Map(),
|
||||
realtimeNotifiedMessages: new Map(),
|
||||
realtimeWatchedFolder: null,
|
||||
indexingActive: false,
|
||||
calendarReminderTimer: null,
|
||||
composeDraftId: null,
|
||||
composeAttachments: [],
|
||||
|
||||
@@ -143,6 +143,7 @@ function renderFolders() {
|
||||
list.append(row);
|
||||
}
|
||||
renderMoveOptions();
|
||||
syncRealtimeWatchedFolder();
|
||||
}
|
||||
|
||||
function updateUnreadAppBadge() {
|
||||
|
||||
@@ -20,11 +20,16 @@ async function loadMessages(reset = false, options = {}) {
|
||||
let loaded = [];
|
||||
let totalCount = 0;
|
||||
let flaggedOnTop = state.currentFolderFlaggedOnTop;
|
||||
let folderTotalCount = null;
|
||||
let folderUnreadCount = null;
|
||||
let lastLoadedPage = state.page;
|
||||
for (const page of pages) {
|
||||
const data = await request(`/folders/${encodeURIComponent(state.folder)}/messages?page=${page}&per_page=${state.perPage}&sort=${sort}`, { method: "GET" });
|
||||
const forceRefresh = options.force ? "&refresh=true" : "";
|
||||
const data = await request(`/folders/${encodeURIComponent(state.folder)}/messages?page=${page}&per_page=${state.perPage}&sort=${sort}${forceRefresh}`, { method: "GET" });
|
||||
totalCount = data.total_count || 0;
|
||||
flaggedOnTop = Boolean(data.flagged_on_top);
|
||||
folderTotalCount = Number(data.folder_total_count);
|
||||
folderUnreadCount = Number(data.unread_count);
|
||||
loaded.push(...(data.messages || []));
|
||||
lastLoadedPage = page;
|
||||
if (loaded.length >= totalCount) break;
|
||||
@@ -33,6 +38,13 @@ async function loadMessages(reset = false, options = {}) {
|
||||
state.total = totalCount;
|
||||
state.currentFolderFlaggedOnTop = flaggedOnTop;
|
||||
state.messages = reset ? loaded : [...state.messages, ...loaded];
|
||||
const folderState = state.folders.find((item) => item.name === state.folder);
|
||||
if (folderState) {
|
||||
if (Number.isFinite(folderTotalCount)) folderState.total_count = folderTotalCount;
|
||||
if (Number.isFinite(folderUnreadCount)) folderState.unread_count = folderUnreadCount;
|
||||
updateUnreadAppBadge();
|
||||
renderFolders();
|
||||
}
|
||||
$("#connection-label").textContent = "Connected";
|
||||
renderMessages();
|
||||
const desiredScroll = savedState?.scrollTop ?? previousScrollTop;
|
||||
|
||||
@@ -160,6 +160,58 @@ function playNotificationSound() {
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function setIndexingIndicator(active, pending = 0) {
|
||||
state.indexingActive = Boolean(active);
|
||||
const indicator = $("#indexing-indicator");
|
||||
if (!indicator) return;
|
||||
indicator.classList.toggle("hidden", !state.indexingActive);
|
||||
const count = Math.max(0, Number(pending) || 0);
|
||||
const label = count > 0 ? `Indexing messages (${count} pending)` : "Indexing messages";
|
||||
indicator.title = label;
|
||||
indicator.setAttribute("aria-label", label);
|
||||
}
|
||||
|
||||
function syncRealtimeWatchedFolder(force = false) {
|
||||
const socket = state.ws;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !state.accountId || !state.folder) return;
|
||||
const key = `${state.accountId}|${state.folder}`;
|
||||
if (!force && state.realtimeWatchedFolder === key) return;
|
||||
try {
|
||||
socket.send(JSON.stringify({ type: "watch_folder", accountId: state.accountId, folder: state.folder }));
|
||||
state.realtimeWatchedFolder = key;
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function newMailNotificationKey(data, accountId) {
|
||||
const uid = Number(data?.latest_uid);
|
||||
if (Number.isFinite(uid) && uid > 0) return `${accountId || ""}|${data?.folder || "INBOX"}|${uid}`;
|
||||
return `${accountId || ""}|${data?.folder || "INBOX"}|${data?.latest_sender || ""}|${data?.latest_subject || ""}|${data?.count || 1}`;
|
||||
}
|
||||
|
||||
function isDuplicateNewMailNotification(data, accountId) {
|
||||
const now = Date.now();
|
||||
for (const [key, seenAt] of state.realtimeNotifiedMessages) {
|
||||
if (now - seenAt > 10 * 60 * 1000) state.realtimeNotifiedMessages.delete(key);
|
||||
}
|
||||
const key = newMailNotificationKey(data, accountId);
|
||||
if (state.realtimeNotifiedMessages.has(key)) return true;
|
||||
state.realtimeNotifiedMessages.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
function toastNewMail(data, accountId) {
|
||||
const count = Math.max(1, Number(data?.count) || 1);
|
||||
const folder = data?.folder || "INBOX";
|
||||
const account = state.accounts.find((item) => item.id === accountId);
|
||||
const accountPrefix = accountId && accountId !== state.accountId && account?.email ? `${account.email} · ` : "";
|
||||
const sender = data?.latest_sender ? `${data.latest_sender}: ` : "";
|
||||
const subject = data?.latest_subject || (count === 1 ? "New message" : `${count} new messages`);
|
||||
const message = count === 1
|
||||
? `${accountPrefix}${folder} · ${sender}${subject}`
|
||||
: `${accountPrefix}${folder} · ${count} new messages${data?.latest_subject ? ` · ${data.latest_subject}` : ""}`;
|
||||
toast(message, "info", 8000, { label: "Open", onClick: () => openRealtimeNotificationTarget(accountId, data) });
|
||||
}
|
||||
|
||||
async function openRealtimeNotificationTarget(accountId, data) {
|
||||
window.focus();
|
||||
const folder = data?.folder || "INBOX";
|
||||
@@ -185,7 +237,8 @@ function notifyNewMail(data, accountId) {
|
||||
const account = state.accounts.find((item) => item.id === accountId);
|
||||
const title = data.latest_sender || account?.email || "New mail";
|
||||
const body = data.latest_subject || `${data.count || 1} new message(s) in ${data.folder}`;
|
||||
const notification = new Notification(title, { body, tag: `rustbox-${accountId}-${data.folder}`, renotify: true });
|
||||
const notificationTag = `rustbox-${accountId}-${data.folder}-${Number.isFinite(uid) && uid > 0 ? uid : "new"}`;
|
||||
const notification = new Notification(title, { body, tag: notificationTag, renotify: false });
|
||||
notification.onclick = () => { notification.close(); void openRealtimeNotificationTarget(accountId, data); };
|
||||
}
|
||||
if (prefs.sound) playNotificationSound();
|
||||
@@ -270,13 +323,14 @@ function scheduleRealtimeRefresh(event, accountId) {
|
||||
clearTimeout(state.realtimeRefreshTimer);
|
||||
state.realtimeRefreshTimer = setTimeout(async () => {
|
||||
const events = state.realtimeRefreshEvents.splice(0);
|
||||
await refreshFolderListOnly();
|
||||
const refreshCurrent = events.some((item) => ["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder);
|
||||
const refreshCurrent = events.some((item) => item.type === "FolderUpdated" || (["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder));
|
||||
const tasks = [refreshFolderListOnly()];
|
||||
if (refreshCurrent) {
|
||||
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true);
|
||||
if (state.searchContext) tasks.push(runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }));
|
||||
else tasks.push(loadMessages(true));
|
||||
}
|
||||
}, 180);
|
||||
await Promise.all(tasks);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function startRealtimeFallbackPolling() {
|
||||
@@ -325,7 +379,9 @@ function connectRealtime() {
|
||||
state.wsConnectedOnce = true;
|
||||
state.wsRetryAttempt = 0;
|
||||
state.wsLastHeartbeatAt = Date.now();
|
||||
state.realtimeWatchedFolder = null;
|
||||
startRealtimeWatchdog(socket);
|
||||
syncRealtimeWatchedFolder(true);
|
||||
if (reconnect) void resyncRealtimeState();
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
@@ -339,13 +395,24 @@ function connectRealtime() {
|
||||
}
|
||||
if (isDuplicateRealtimeEvent(payload)) return;
|
||||
const event = payload.event || {};
|
||||
if (event.type === "NewMessages") notifyNewMail(event.data || {}, payload.accountId);
|
||||
if (event.type === "NewMessages") {
|
||||
const data = event.data || {};
|
||||
if (!isDuplicateNewMailNotification(data, payload.accountId)) {
|
||||
toastNewMail(data, payload.accountId);
|
||||
notifyNewMail(data, payload.accountId);
|
||||
}
|
||||
}
|
||||
if (event.type === "IndexingStatus" && payload.accountId === state.accountId) {
|
||||
setIndexingIndicator(Boolean(event.data?.active), event.data?.pending);
|
||||
}
|
||||
if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
|
||||
} catch (error) { console.warn("Invalid realtime event", error); }
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (state.ws !== socket) return;
|
||||
state.ws = null;
|
||||
state.realtimeWatchedFolder = null;
|
||||
setIndexingIndicator(false);
|
||||
clearInterval(state.wsWatchdogTimer);
|
||||
state.wsWatchdogTimer = null;
|
||||
scheduleRealtimeReconnect();
|
||||
@@ -366,6 +433,8 @@ function disconnectRealtime() {
|
||||
state.realtimeRefreshEvents = [];
|
||||
state.wsRetryAttempt = 0;
|
||||
state.wsLastHeartbeatAt = 0;
|
||||
state.realtimeWatchedFolder = null;
|
||||
setIndexingIndicator(false);
|
||||
const socket = state.ws;
|
||||
state.ws = null;
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect");
|
||||
|
||||
@@ -61,21 +61,44 @@ function bindPaneResizer(selector, preferenceKey, minPx, maxPx) {
|
||||
});
|
||||
}
|
||||
|
||||
function toolbarMenuPopover(menu) {
|
||||
return menu?._rustboxPopover || menu?.querySelector(".toolbar-menu-popover") || null;
|
||||
}
|
||||
|
||||
function mountToolbarMenuPopover(menu) {
|
||||
const popover = toolbarMenuPopover(menu);
|
||||
if (!popover) return null;
|
||||
menu._rustboxPopover = popover;
|
||||
if (popover.parentElement !== document.body) document.body.append(popover);
|
||||
popover.dataset.toolbarMenuOwner = menu.id || "toolbar-menu";
|
||||
return popover;
|
||||
}
|
||||
|
||||
function restoreToolbarMenuPopover(menu) {
|
||||
const popover = toolbarMenuPopover(menu);
|
||||
if (!popover || !menu?.isConnected || popover.parentElement !== document.body) return;
|
||||
popover.removeAttribute("data-toolbar-menu-owner");
|
||||
menu.append(popover);
|
||||
}
|
||||
|
||||
function positionToolbarMenu(menu) {
|
||||
if (!menu?.open) return;
|
||||
const popover = menu.querySelector(".toolbar-menu-popover");
|
||||
const popover = mountToolbarMenuPopover(menu);
|
||||
const summary = menu.querySelector("summary");
|
||||
if (!popover || !summary) return;
|
||||
Object.assign(popover.style, { right: "auto", bottom: "auto" });
|
||||
const rect = summary.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const mobile = matchMedia("(max-width: 560px)").matches;
|
||||
const width = Math.min(Math.max(mobile ? 180 : 164, popover.offsetWidth || 164), window.innerWidth - (margin * 2));
|
||||
const viewportWidth = window.visualViewport?.width || window.innerWidth;
|
||||
const viewportHeight = window.visualViewport?.height || window.innerHeight;
|
||||
popover.style.maxWidth = `${Math.max(120, viewportWidth - (margin * 2))}px`;
|
||||
const width = Math.min(Math.max(mobile ? 180 : 164, popover.offsetWidth || 164), viewportWidth - (margin * 2));
|
||||
const height = popover.offsetHeight || 80;
|
||||
let left = Math.min(rect.left, window.innerWidth - width - margin);
|
||||
let left = Math.min(rect.left, viewportWidth - width - margin);
|
||||
left = Math.max(margin, left);
|
||||
let top = rect.bottom + 5;
|
||||
if (top + height > window.innerHeight - margin) top = Math.max(margin, rect.top - height - 5);
|
||||
if (top + height > viewportHeight - margin) top = Math.max(margin, rect.top - height - 5);
|
||||
popover.style.left = `${Math.round(left)}px`;
|
||||
popover.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
@@ -83,12 +106,19 @@ function positionToolbarMenu(menu) {
|
||||
function bindToolbarMenus() {
|
||||
const menus = $$(".toolbar-menu");
|
||||
menus.forEach((menu) => menu.addEventListener("toggle", () => {
|
||||
if (!menu.open) return;
|
||||
if (!menu.open) {
|
||||
restoreToolbarMenuPopover(menu);
|
||||
return;
|
||||
}
|
||||
menus.forEach((other) => { if (other !== menu) other.open = false; });
|
||||
mountToolbarMenuPopover(menu);
|
||||
requestAnimationFrame(() => positionToolbarMenu(menu));
|
||||
}));
|
||||
window.addEventListener("resize", () => menus.forEach(positionToolbarMenu), { passive: true });
|
||||
document.addEventListener("scroll", () => menus.forEach(positionToolbarMenu), true);
|
||||
const reposition = () => menus.forEach(positionToolbarMenu);
|
||||
window.addEventListener("resize", reposition, { passive: true });
|
||||
window.visualViewport?.addEventListener("resize", reposition, { passive: true });
|
||||
window.visualViewport?.addEventListener("scroll", reposition, { passive: true });
|
||||
document.addEventListener("scroll", reposition, true);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
@@ -203,7 +233,20 @@ function bindEvents() {
|
||||
$("#bulk-tag-select").addEventListener("change", async (event) => { const tagId = event.target.value; event.target.value = ""; if (tagId) await bulkAddTag(tagId); });
|
||||
$("#select-mode-btn").addEventListener("click", () => setSelectionMode(!state.selectionMode));
|
||||
$("#selection-done").addEventListener("click", () => setSelectionMode(false));
|
||||
$("#refresh-btn").addEventListener("click", () => loadFolders(false));
|
||||
$("#refresh-btn").addEventListener("click", async () => {
|
||||
const button = $("#refresh-btn");
|
||||
setBusy(button, true);
|
||||
try {
|
||||
const folderRefresh = refreshFolderListOnly();
|
||||
if (state.searchContext) {
|
||||
await Promise.all([folderRefresh, runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop })]);
|
||||
} else {
|
||||
await Promise.all([folderRefresh, loadMessages(true, { force: true })]);
|
||||
}
|
||||
} finally {
|
||||
setBusy(button, false);
|
||||
}
|
||||
});
|
||||
$("#message-sort").addEventListener("change", async (event) => {
|
||||
const value = event.target.value;
|
||||
state.mailPrefs.default_sort = value;
|
||||
@@ -445,7 +488,7 @@ function bindEvents() {
|
||||
bindEditor();
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest("#context-menu")) hideContextMenu();
|
||||
if (!event.target.closest(".toolbar-menu")) $$(".toolbar-menu[open]").forEach((menu) => { menu.open = false; });
|
||||
if (!event.target.closest(".toolbar-menu, .toolbar-menu-popover[data-toolbar-menu-owner]")) $$(".toolbar-menu[open]").forEach((menu) => { menu.open = false; });
|
||||
});
|
||||
window.addEventListener("blur", hideContextMenu);
|
||||
document.addEventListener("scroll", hideContextMenu, true);
|
||||
|
||||
Reference in new issue
Block a user