1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 00-bootstrap.js */
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
@@ -50,13 +50,23 @@
|
||||
markReadTimer: null,
|
||||
ws: null,
|
||||
wsRetryTimer: null,
|
||||
wsRetryAttempt: 0,
|
||||
wsWatchdogTimer: null,
|
||||
wsLastHeartbeatAt: 0,
|
||||
wsShouldConnect: false,
|
||||
wsConnectedOnce: false,
|
||||
realtimeRefreshTimer: null,
|
||||
realtimeRefreshEvents: [],
|
||||
realtimePollTimer: null,
|
||||
realtimeSyncPromise: null,
|
||||
realtimeSeenEvents: new Map(),
|
||||
calendarReminderTimer: null,
|
||||
composeDraftId: null,
|
||||
composeAttachments: [],
|
||||
composeDirty: false,
|
||||
composeNeedsRemoteSync: false,
|
||||
composeUploading: false,
|
||||
composeUploadController: null,
|
||||
composeReplyMeta: null,
|
||||
composeInsertRange: null,
|
||||
composeSelectedImage: null,
|
||||
@@ -79,6 +89,8 @@
|
||||
quotaWarningLevel: null,
|
||||
networkOnline: navigator.onLine,
|
||||
listStateTimer: null,
|
||||
readerFullscreenHistory: false,
|
||||
readerScrollTopBeforeFullscreen: 0,
|
||||
};
|
||||
|
||||
const $ = (selector, parent = document) => parent.querySelector(selector);
|
||||
@@ -105,8 +117,8 @@
|
||||
|
||||
function stripRustBoxUiLeak(value) {
|
||||
let text = String(value ?? "");
|
||||
// Strip only RustBox's own embedded SVG sprite fragments if malformed MIME
|
||||
// content captured adjacent UI markup. Legitimate SVG/code in mail remains.
|
||||
|
||||
|
||||
text = text.replace(/<svg\b[^>]*>\s*<use\b[^>]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, "");
|
||||
text = text.replace(/<svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?<\/svg>/gi, "");
|
||||
return text;
|
||||
|
||||
+19
-72
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 10-core.js */
|
||||
|
||||
function setConnectivityState(online, detail = "") {
|
||||
state.networkOnline = Boolean(online);
|
||||
document.body.classList.toggle("network-offline", !state.networkOnline);
|
||||
@@ -124,9 +124,17 @@ async function request(path, options = {}) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
function uploadRequest(path, formData, onProgress = null) {
|
||||
function uploadRequest(path, formData, onProgress = null, signal = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal?.removeEventListener("abort", abortUpload);
|
||||
callback(value);
|
||||
};
|
||||
const abortUpload = () => xhr.abort();
|
||||
xhr.open("POST", apiPath(path), true);
|
||||
xhr.withCredentials = true;
|
||||
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
|
||||
@@ -136,8 +144,8 @@ function uploadRequest(path, formData, onProgress = null) {
|
||||
if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
});
|
||||
}
|
||||
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
|
||||
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
|
||||
xhr.addEventListener("error", () => finish(reject, new Error("Upload failed: network error")));
|
||||
xhr.addEventListener("abort", () => finish(reject, new DOMException("Upload cancelled", "AbortError")));
|
||||
xhr.addEventListener("load", () => {
|
||||
const contentType = xhr.getResponseHeader("content-type") || "";
|
||||
let payload = xhr.responseText || "";
|
||||
@@ -148,11 +156,16 @@ function uploadRequest(path, formData, onProgress = null) {
|
||||
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
|
||||
const error = new Error(message);
|
||||
error.status = xhr.status;
|
||||
reject(error);
|
||||
finish(reject, error);
|
||||
return;
|
||||
}
|
||||
resolve(payload);
|
||||
finish(resolve, payload);
|
||||
});
|
||||
if (signal?.aborted) {
|
||||
finish(reject, new DOMException("Upload cancelled", "AbortError"));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", abortUpload, { once: true });
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
@@ -251,72 +264,6 @@ function toast(message, kind = "info", duration = null, action = null) {
|
||||
resume();
|
||||
}
|
||||
|
||||
function progressToast(message) {
|
||||
const node = el("div", "toast toast-info toast-progress");
|
||||
node.setAttribute("role", "status");
|
||||
const icon = el("span", "toast-icon");
|
||||
icon.innerHTML = svgIcon("paperclip");
|
||||
const content = el("span", "toast-progress-content");
|
||||
const text = el("span", "toast-message", message);
|
||||
const track = el("span", "toast-progress-track");
|
||||
const bar = el("span", "toast-progress-bar");
|
||||
track.append(bar);
|
||||
content.append(text, track);
|
||||
const value = el("span", "toast-progress-value", "0%");
|
||||
const close = el("button", "toast-close");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Dismiss notification");
|
||||
close.innerHTML = svgIcon("x");
|
||||
let stack = $("#toast-stack");
|
||||
const openDialogs = $$("dialog[open]");
|
||||
if (openDialogs.length) {
|
||||
const dialog = openDialogs[openDialogs.length - 1];
|
||||
stack = dialog.querySelector(":scope > .modal-toast-stack");
|
||||
if (!stack) {
|
||||
stack = el("div", "toast-stack modal-toast-stack");
|
||||
stack.setAttribute("aria-live", "polite");
|
||||
dialog.append(stack);
|
||||
}
|
||||
}
|
||||
node.append(icon, content, value, close);
|
||||
stack.append(node);
|
||||
requestAnimationFrame(() => node.classList.add("visible"));
|
||||
let dismissed = false;
|
||||
const dismiss = (delay = 0) => {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
setTimeout(() => {
|
||||
node.classList.remove("visible");
|
||||
setTimeout(() => node.remove(), 160);
|
||||
}, Math.max(0, delay));
|
||||
};
|
||||
close.addEventListener("click", () => dismiss());
|
||||
return {
|
||||
update(percent, label = null) {
|
||||
const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
|
||||
bar.style.width = `${normalized}%`;
|
||||
value.textContent = `${normalized}%`;
|
||||
if (label) text.textContent = label;
|
||||
},
|
||||
complete(label = trText("Upload complete")) {
|
||||
bar.style.width = "100%";
|
||||
value.textContent = "100%";
|
||||
text.textContent = label;
|
||||
node.classList.remove("toast-info");
|
||||
node.classList.add("toast-success");
|
||||
icon.innerHTML = svgIcon("check-circle");
|
||||
dismiss(1200);
|
||||
},
|
||||
fail(label) {
|
||||
text.textContent = label || trText("Upload failed");
|
||||
node.classList.remove("toast-info");
|
||||
node.classList.add("toast-error");
|
||||
icon.innerHTML = svgIcon("circle-alert");
|
||||
dismiss(4800);
|
||||
},
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 20-preferences-auth.js */
|
||||
|
||||
function resolveTheme(theme) {
|
||||
if (theme !== "system") return theme;
|
||||
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
|
||||
+437
-437
@@ -1,453 +1,453 @@
|
||||
/* RustBox UI module: 30-folders-tags.js */
|
||||
function folderIconName(folder) {
|
||||
const attrs = (folder.attributes || []).join(" ").toLowerCase();
|
||||
const name = String(folder.name || "").toLowerCase();
|
||||
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
|
||||
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
|
||||
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
|
||||
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
|
||||
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
|
||||
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
|
||||
return "folder";
|
||||
}
|
||||
|
||||
function orderedFolders() {
|
||||
return [...state.folders].sort((a, b) => {
|
||||
if (a.name.toUpperCase() === "INBOX") return -1;
|
||||
if (b.name.toUpperCase() === "INBOX") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
function folderIconName(folder) {
|
||||
const attrs = (folder.attributes || []).join(" ").toLowerCase();
|
||||
const name = String(folder.name || "").toLowerCase();
|
||||
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
|
||||
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
|
||||
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
|
||||
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
|
||||
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
|
||||
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
|
||||
return "folder";
|
||||
}
|
||||
|
||||
function orderedFolders() {
|
||||
return [...state.folders].sort((a, b) => {
|
||||
if (a.name.toUpperCase() === "INBOX") return -1;
|
||||
if (b.name.toUpperCase() === "INBOX") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function folderDepth(folder) {
|
||||
const delimiter = folder.delimiter || "/";
|
||||
if (!delimiter || !folder.name.includes(delimiter)) return 0;
|
||||
return Math.min(8, folder.name.split(delimiter).length - 1);
|
||||
}
|
||||
|
||||
function folderLeafName(folder) {
|
||||
const delimiter = folder.delimiter || "/";
|
||||
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
|
||||
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
|
||||
}
|
||||
|
||||
function folderHasChildren(folder, folders = state.folders) {
|
||||
const delimiter = folder.delimiter || "/";
|
||||
if (!delimiter) return false;
|
||||
const prefix = `${folder.name}${delimiter}`;
|
||||
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
|
||||
}
|
||||
|
||||
function folderAncestors(folder) {
|
||||
const delimiter = folder.delimiter || "/";
|
||||
if (!delimiter || !folder.name.includes(delimiter)) return [];
|
||||
const parts = folder.name.split(delimiter);
|
||||
const result = [];
|
||||
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
|
||||
return result;
|
||||
}
|
||||
|
||||
function collapsedFolderStorageKey() {
|
||||
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
|
||||
}
|
||||
|
||||
function loadCollapsedFolders() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
|
||||
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
|
||||
} catch (_) {
|
||||
state.collapsedFolders = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsedFolders() {
|
||||
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
|
||||
}
|
||||
|
||||
function folderIsHiddenByCollapsedAncestor(folder) {
|
||||
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
|
||||
}
|
||||
|
||||
function toggleFolderBranch(folder) {
|
||||
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
|
||||
else state.collapsedFolders.add(folder.name);
|
||||
saveCollapsedFolders();
|
||||
renderFolders();
|
||||
}
|
||||
|
||||
function setFolderDrawerOpen(open) {
|
||||
const shouldOpen = Boolean(open) && innerWidth <= 820;
|
||||
$("#folder-pane")?.classList.toggle("open", shouldOpen);
|
||||
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
|
||||
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
|
||||
}
|
||||
|
||||
function toggleFolderDrawer() {
|
||||
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
|
||||
}
|
||||
|
||||
function renderFolders() {
|
||||
const list = $("#folder-list");
|
||||
list.replaceChildren();
|
||||
const folders = orderedFolders();
|
||||
for (const folder of folders) {
|
||||
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
|
||||
const row = el("div", "folder-row");
|
||||
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
|
||||
|
||||
const hasChildren = folderHasChildren(folder, folders);
|
||||
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
|
||||
expander.type = "button";
|
||||
expander.tabIndex = hasChildren ? 0 : -1;
|
||||
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
|
||||
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
|
||||
if (hasChildren) {
|
||||
expander.innerHTML = svgIcon("chevron-right");
|
||||
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
|
||||
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
|
||||
}
|
||||
|
||||
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
|
||||
button.type = "button";
|
||||
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
|
||||
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
|
||||
button.addEventListener("dragover", (event) => {
|
||||
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
button.classList.add("drop-target");
|
||||
});
|
||||
}
|
||||
|
||||
function folderDepth(folder) {
|
||||
const delimiter = folder.delimiter || "/";
|
||||
if (!delimiter || !folder.name.includes(delimiter)) return 0;
|
||||
return Math.min(8, folder.name.split(delimiter).length - 1);
|
||||
}
|
||||
|
||||
function folderLeafName(folder) {
|
||||
const delimiter = folder.delimiter || "/";
|
||||
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
|
||||
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
|
||||
}
|
||||
|
||||
function folderHasChildren(folder, folders = state.folders) {
|
||||
const delimiter = folder.delimiter || "/";
|
||||
if (!delimiter) return false;
|
||||
const prefix = `${folder.name}${delimiter}`;
|
||||
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
|
||||
}
|
||||
|
||||
function folderAncestors(folder) {
|
||||
const delimiter = folder.delimiter || "/";
|
||||
if (!delimiter || !folder.name.includes(delimiter)) return [];
|
||||
const parts = folder.name.split(delimiter);
|
||||
const result = [];
|
||||
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
|
||||
return result;
|
||||
}
|
||||
|
||||
function collapsedFolderStorageKey() {
|
||||
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
|
||||
}
|
||||
|
||||
function loadCollapsedFolders() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
|
||||
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
|
||||
} catch (_) {
|
||||
state.collapsedFolders = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsedFolders() {
|
||||
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
|
||||
}
|
||||
|
||||
function folderIsHiddenByCollapsedAncestor(folder) {
|
||||
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
|
||||
}
|
||||
|
||||
function toggleFolderBranch(folder) {
|
||||
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
|
||||
else state.collapsedFolders.add(folder.name);
|
||||
saveCollapsedFolders();
|
||||
renderFolders();
|
||||
}
|
||||
|
||||
function setFolderDrawerOpen(open) {
|
||||
const shouldOpen = Boolean(open) && innerWidth <= 820;
|
||||
$("#folder-pane")?.classList.toggle("open", shouldOpen);
|
||||
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
|
||||
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
|
||||
}
|
||||
|
||||
function toggleFolderDrawer() {
|
||||
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
|
||||
}
|
||||
|
||||
function renderFolders() {
|
||||
const list = $("#folder-list");
|
||||
list.replaceChildren();
|
||||
const folders = orderedFolders();
|
||||
for (const folder of folders) {
|
||||
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
|
||||
const row = el("div", "folder-row");
|
||||
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
|
||||
|
||||
const hasChildren = folderHasChildren(folder, folders);
|
||||
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
|
||||
expander.type = "button";
|
||||
expander.tabIndex = hasChildren ? 0 : -1;
|
||||
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
|
||||
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
|
||||
if (hasChildren) {
|
||||
expander.innerHTML = svgIcon("chevron-right");
|
||||
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
|
||||
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
|
||||
}
|
||||
|
||||
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
|
||||
button.type = "button";
|
||||
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
|
||||
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
|
||||
button.addEventListener("dragover", (event) => {
|
||||
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
button.classList.add("drop-target");
|
||||
});
|
||||
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
|
||||
button.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
button.classList.remove("drop-target");
|
||||
try {
|
||||
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
|
||||
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
|
||||
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
|
||||
} catch (error) { toast(error.message || "Could not move message", "error"); }
|
||||
});
|
||||
button.addEventListener("click", async () => {
|
||||
saveMessageListState();
|
||||
state.folder = folder.name;
|
||||
state.selected = null;
|
||||
state.selectedMessages.clear();
|
||||
clearActiveSearchContext();
|
||||
state.currentDetail = null;
|
||||
renderFolders();
|
||||
closeReader();
|
||||
setFolderDrawerOpen(false);
|
||||
await loadMessages(true, { restoreState: true });
|
||||
});
|
||||
row.append(expander, button);
|
||||
list.append(row);
|
||||
}
|
||||
renderMoveOptions();
|
||||
}
|
||||
|
||||
function updateUnreadAppBadge() {
|
||||
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
|
||||
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
|
||||
try {
|
||||
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
|
||||
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
async function loadFolders(restoreView = false) {
|
||||
$("#connection-label").textContent = "Connecting";
|
||||
try {
|
||||
const data = await request("/folders", { method: "GET" });
|
||||
state.folders = data.folders || [];
|
||||
updateUnreadAppBadge();
|
||||
loadCollapsedFolders();
|
||||
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
|
||||
if (restoreView) {
|
||||
const remembered = localStorage.getItem(lastFolderStorageKey());
|
||||
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
|
||||
}
|
||||
if (!state.folders.some((folder) => folder.name === state.folder)) {
|
||||
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
|
||||
}
|
||||
renderFolders();
|
||||
$("#connection-label").textContent = "Connected";
|
||||
if (restoreView) {
|
||||
const restoredSearch = readActiveSearchContext();
|
||||
if (restoredSearch) {
|
||||
state.searchContext = restoredSearch;
|
||||
const restoredParams = new URLSearchParams(restoredSearch.params);
|
||||
$("#search-input").value = restoredParams.get("q") || "";
|
||||
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true, { restoreState: restoreView });
|
||||
} catch (error) {
|
||||
$("#connection-label").textContent = "Connection error";
|
||||
if (error.status === 401) return showLogin();
|
||||
toast(error.message, "error");
|
||||
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderMoveOptions() {
|
||||
const selects = [
|
||||
["#move-select", "Move to…"],
|
||||
["#copy-select", "Copy to…"],
|
||||
["#bulk-move-select", "Move to…"],
|
||||
["#bulk-copy-select", "Copy to…"],
|
||||
];
|
||||
for (const [selector, placeholder] of selects) {
|
||||
const select = $(selector);
|
||||
if (!select) continue;
|
||||
select.replaceChildren(new Option(placeholder, ""));
|
||||
for (const folder of orderedFolders()) {
|
||||
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
|
||||
select.add(new Option(folder.name, folder.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function messageKey(message) {
|
||||
return `${message.folder || state.folder}\u0000${message.uid}`;
|
||||
}
|
||||
|
||||
function activeSearchHighlightTerms() {
|
||||
if (!state.searchContext?.params) return [];
|
||||
const params = new URLSearchParams(state.searchContext.params);
|
||||
const terms = [];
|
||||
for (const key of ["from", "to", "subject"]) {
|
||||
const value = (params.get(key) || "").trim();
|
||||
if (value) terms.push(value);
|
||||
}
|
||||
const query = (params.get("q") || "").trim();
|
||||
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
||||
for (const token of tokens) {
|
||||
const cleaned = token.replace(/^"|"$/g, "");
|
||||
const colon = cleaned.indexOf(":");
|
||||
if (colon > 0) {
|
||||
const op = cleaned.slice(0, colon).toLowerCase();
|
||||
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
|
||||
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
|
||||
} else if (cleaned.length >= 2) {
|
||||
terms.push(cleaned);
|
||||
}
|
||||
}
|
||||
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function renderHighlightedText(node, value, terms) {
|
||||
const text = String(value || "");
|
||||
node.replaceChildren();
|
||||
if (!terms.length || !text) {
|
||||
node.textContent = text;
|
||||
return;
|
||||
}
|
||||
const lower = text.toLocaleLowerCase();
|
||||
let offset = 0;
|
||||
while (offset < text.length) {
|
||||
let matchIndex = -1;
|
||||
let matchTerm = "";
|
||||
for (const term of terms) {
|
||||
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
|
||||
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
|
||||
matchIndex = index;
|
||||
matchTerm = term;
|
||||
}
|
||||
}
|
||||
if (matchIndex < 0) {
|
||||
node.append(document.createTextNode(text.slice(offset)));
|
||||
break;
|
||||
}
|
||||
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
|
||||
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
|
||||
node.append(mark);
|
||||
offset = matchIndex + matchTerm.length;
|
||||
}
|
||||
}
|
||||
|
||||
function selectedMessages() {
|
||||
const source = state.searchAllResults || state.messages;
|
||||
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
|
||||
}
|
||||
|
||||
function renderBulkToolbar() {
|
||||
const selected = selectedMessages();
|
||||
const toolbar = $("#bulk-toolbar");
|
||||
toolbar.classList.toggle("hidden", !state.selectionMode);
|
||||
$("#bulk-count").textContent = `${selected.length} selected`;
|
||||
const selectionUniverse = state.searchAllResults || state.messages;
|
||||
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
|
||||
$("#bulk-select-all").checked = allLoaded;
|
||||
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
|
||||
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
|
||||
const selectSearch = $("#bulk-select-search");
|
||||
if (selectSearch) {
|
||||
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
|
||||
selectSearch.disabled = !state.searchContext;
|
||||
}
|
||||
const junkButton = $("#bulk-junk");
|
||||
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
|
||||
const toggle = $("#select-mode-btn");
|
||||
if (toggle) {
|
||||
toggle.classList.toggle("active", state.selectionMode);
|
||||
toggle.setAttribute("aria-pressed", String(state.selectionMode));
|
||||
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
|
||||
toggle.setAttribute("aria-label", toggle.title);
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectionMode(enabled) {
|
||||
state.selectionMode = Boolean(enabled);
|
||||
if (!state.selectionMode) {
|
||||
state.selectedMessages.clear();
|
||||
state.searchAllResults = null;
|
||||
}
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
function setMessageSelected(message, selected) {
|
||||
if (!state.selectionMode) state.selectionMode = true;
|
||||
const key = messageKey(message);
|
||||
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
function clearBulkSelection() {
|
||||
state.selectedMessages.clear();
|
||||
state.searchAllResults = null;
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
async function selectAllSearchResults() {
|
||||
if (!state.searchContext) return;
|
||||
const button = $("#bulk-select-search");
|
||||
setBusy(button, true, "Selecting…");
|
||||
try {
|
||||
const params = new URLSearchParams(state.searchContext.params);
|
||||
params.set("all", "true");
|
||||
params.delete("offset");
|
||||
const data = await request(`/search?${params.toString()}`, { method: "GET" });
|
||||
state.searchAllResults = data.results || [];
|
||||
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
|
||||
state.total = state.searchAllResults.length;
|
||||
renderMessages();
|
||||
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
setBusy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTagOptions() {
|
||||
const select = $("#bulk-tag-select");
|
||||
if (!select) return;
|
||||
select.replaceChildren(new Option("Add label…", ""));
|
||||
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
const list = $("#tag-list");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
for (const tag of state.tags) {
|
||||
const button = el("button", "tag-item");
|
||||
button.type = "button";
|
||||
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
|
||||
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
|
||||
button.addEventListener("click", () => loadTagMessages(tag));
|
||||
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
|
||||
list.append(button);
|
||||
}
|
||||
renderTagOptions();
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const data = await request("/tags", { method: "GET" });
|
||||
state.tags = data.tags || [];
|
||||
renderTags();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function loadTagMessages(tag) {
|
||||
try {
|
||||
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
|
||||
state.messages = data.messages || [];
|
||||
state.total = data.total_count || state.messages.length;
|
||||
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
|
||||
button.addEventListener("drop", async (event) => {
|
||||
event.preventDefault();
|
||||
button.classList.remove("drop-target");
|
||||
try {
|
||||
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
|
||||
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
|
||||
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
|
||||
} catch (error) { toast(error.message || "Could not move message", "error"); }
|
||||
});
|
||||
button.addEventListener("click", async () => {
|
||||
saveMessageListState();
|
||||
state.folder = folder.name;
|
||||
state.selected = null;
|
||||
state.selectedMessages.clear();
|
||||
clearActiveSearchContext();
|
||||
renderMessages();
|
||||
$("#folder-title").textContent = `Label: ${tag.name}`;
|
||||
$("#message-count").textContent = `${state.total} results`;
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
state.currentDetail = null;
|
||||
renderFolders();
|
||||
closeReader();
|
||||
setFolderDrawerOpen(false);
|
||||
await loadMessages(true, { restoreState: true });
|
||||
});
|
||||
row.append(expander, button);
|
||||
list.append(row);
|
||||
}
|
||||
renderMoveOptions();
|
||||
}
|
||||
|
||||
function openTagDialog(tag = null) {
|
||||
$("#tag-id").value = tag?.id || "";
|
||||
$("#tag-name").value = tag?.name || "";
|
||||
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280";
|
||||
$("#tag-delete").disabled = !tag;
|
||||
$("#tag-dialog").showModal();
|
||||
setTimeout(() => $("#tag-name").focus(), 20);
|
||||
function updateUnreadAppBadge() {
|
||||
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
|
||||
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
|
||||
try {
|
||||
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
|
||||
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
async function loadFolders(restoreView = false) {
|
||||
$("#connection-label").textContent = "Connecting";
|
||||
try {
|
||||
const data = await request("/folders", { method: "GET" });
|
||||
state.folders = data.folders || [];
|
||||
updateUnreadAppBadge();
|
||||
loadCollapsedFolders();
|
||||
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
|
||||
if (restoreView) {
|
||||
const remembered = localStorage.getItem(lastFolderStorageKey());
|
||||
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
|
||||
}
|
||||
if (!state.folders.some((folder) => folder.name === state.folder)) {
|
||||
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
|
||||
}
|
||||
renderFolders();
|
||||
$("#connection-label").textContent = "Connected";
|
||||
if (restoreView) {
|
||||
const restoredSearch = readActiveSearchContext();
|
||||
if (restoredSearch) {
|
||||
state.searchContext = restoredSearch;
|
||||
const restoredParams = new URLSearchParams(restoredSearch.params);
|
||||
$("#search-input").value = restoredParams.get("q") || "";
|
||||
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true, { restoreState: restoreView });
|
||||
} catch (error) {
|
||||
$("#connection-label").textContent = "Connection error";
|
||||
if (error.status === 401) return showLogin();
|
||||
toast(error.message, "error");
|
||||
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTag(event) {
|
||||
event.preventDefault();
|
||||
const id = $("#tag-id").value;
|
||||
const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value };
|
||||
if (!payload.name) return;
|
||||
try {
|
||||
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
|
||||
$("#tag-dialog").close();
|
||||
await loadTags();
|
||||
toast("Label saved");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
function renderMoveOptions() {
|
||||
const selects = [
|
||||
["#move-select", "Move to…"],
|
||||
["#copy-select", "Copy to…"],
|
||||
["#bulk-move-select", "Move to…"],
|
||||
["#bulk-copy-select", "Copy to…"],
|
||||
];
|
||||
for (const [selector, placeholder] of selects) {
|
||||
const select = $(selector);
|
||||
if (!select) continue;
|
||||
select.replaceChildren(new Option(placeholder, ""));
|
||||
for (const folder of orderedFolders()) {
|
||||
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
|
||||
select.add(new Option(folder.name, folder.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTag() {
|
||||
const id = $("#tag-id").value;
|
||||
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return;
|
||||
try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
function messageKey(message) {
|
||||
return `${message.folder || state.folder}\u0000${message.uid}`;
|
||||
}
|
||||
|
||||
function activeSearchHighlightTerms() {
|
||||
if (!state.searchContext?.params) return [];
|
||||
const params = new URLSearchParams(state.searchContext.params);
|
||||
const terms = [];
|
||||
for (const key of ["from", "to", "subject"]) {
|
||||
const value = (params.get(key) || "").trim();
|
||||
if (value) terms.push(value);
|
||||
}
|
||||
|
||||
async function promptTag(label, tags = state.tags) {
|
||||
if (!tags.length) { toast("Create a label first", "error"); return null; }
|
||||
const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" });
|
||||
return tags.find((tag) => String(tag.id) === String(answer)) || null;
|
||||
const query = (params.get("q") || "").trim();
|
||||
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
||||
for (const token of tokens) {
|
||||
const cleaned = token.replace(/^"|"$/g, "");
|
||||
const colon = cleaned.indexOf(":");
|
||||
if (colon > 0) {
|
||||
const op = cleaned.slice(0, colon).toLowerCase();
|
||||
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
|
||||
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
|
||||
} else if (cleaned.length >= 2) {
|
||||
terms.push(cleaned);
|
||||
}
|
||||
}
|
||||
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
async function addTagToMessage(message, tag) {
|
||||
const folder = message.folder || state.folder;
|
||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) });
|
||||
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
|
||||
function renderHighlightedText(node, value, terms) {
|
||||
const text = String(value || "");
|
||||
node.replaceChildren();
|
||||
if (!terms.length || !text) {
|
||||
node.textContent = text;
|
||||
return;
|
||||
}
|
||||
const lower = text.toLocaleLowerCase();
|
||||
let offset = 0;
|
||||
while (offset < text.length) {
|
||||
let matchIndex = -1;
|
||||
let matchTerm = "";
|
||||
for (const term of terms) {
|
||||
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
|
||||
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
|
||||
matchIndex = index;
|
||||
matchTerm = term;
|
||||
}
|
||||
}
|
||||
if (matchIndex < 0) {
|
||||
node.append(document.createTextNode(text.slice(offset)));
|
||||
break;
|
||||
}
|
||||
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
|
||||
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
|
||||
node.append(mark);
|
||||
offset = matchIndex + matchTerm.length;
|
||||
}
|
||||
}
|
||||
|
||||
function selectedMessages() {
|
||||
const source = state.searchAllResults || state.messages;
|
||||
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
|
||||
}
|
||||
|
||||
function renderBulkToolbar() {
|
||||
const selected = selectedMessages();
|
||||
const toolbar = $("#bulk-toolbar");
|
||||
toolbar.classList.toggle("hidden", !state.selectionMode);
|
||||
$("#bulk-count").textContent = `${selected.length} selected`;
|
||||
const selectionUniverse = state.searchAllResults || state.messages;
|
||||
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
|
||||
$("#bulk-select-all").checked = allLoaded;
|
||||
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
|
||||
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
|
||||
const selectSearch = $("#bulk-select-search");
|
||||
if (selectSearch) {
|
||||
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
|
||||
selectSearch.disabled = !state.searchContext;
|
||||
}
|
||||
const junkButton = $("#bulk-junk");
|
||||
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
|
||||
const toggle = $("#select-mode-btn");
|
||||
if (toggle) {
|
||||
toggle.classList.toggle("active", state.selectionMode);
|
||||
toggle.setAttribute("aria-pressed", String(state.selectionMode));
|
||||
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
|
||||
toggle.setAttribute("aria-label", toggle.title);
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectionMode(enabled) {
|
||||
state.selectionMode = Boolean(enabled);
|
||||
if (!state.selectionMode) {
|
||||
state.selectedMessages.clear();
|
||||
state.searchAllResults = null;
|
||||
}
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
function setMessageSelected(message, selected) {
|
||||
if (!state.selectionMode) state.selectionMode = true;
|
||||
const key = messageKey(message);
|
||||
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
function clearBulkSelection() {
|
||||
state.selectedMessages.clear();
|
||||
state.searchAllResults = null;
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
async function selectAllSearchResults() {
|
||||
if (!state.searchContext) return;
|
||||
const button = $("#bulk-select-search");
|
||||
setBusy(button, true, "Selecting…");
|
||||
try {
|
||||
const params = new URLSearchParams(state.searchContext.params);
|
||||
params.set("all", "true");
|
||||
params.delete("offset");
|
||||
const data = await request(`/search?${params.toString()}`, { method: "GET" });
|
||||
state.searchAllResults = data.results || [];
|
||||
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
|
||||
state.total = state.searchAllResults.length;
|
||||
renderMessages();
|
||||
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
setBusy(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTagOptions() {
|
||||
const select = $("#bulk-tag-select");
|
||||
if (!select) return;
|
||||
select.replaceChildren(new Option("Add label…", ""));
|
||||
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
|
||||
}
|
||||
|
||||
function renderTags() {
|
||||
const list = $("#tag-list");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
for (const tag of state.tags) {
|
||||
const button = el("button", "tag-item");
|
||||
button.type = "button";
|
||||
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
|
||||
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
|
||||
button.addEventListener("click", () => loadTagMessages(tag));
|
||||
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
|
||||
list.append(button);
|
||||
}
|
||||
renderTagOptions();
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const data = await request("/tags", { method: "GET" });
|
||||
state.tags = data.tags || [];
|
||||
renderTags();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function loadTagMessages(tag) {
|
||||
try {
|
||||
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
|
||||
state.messages = data.messages || [];
|
||||
state.total = data.total_count || state.messages.length;
|
||||
state.selectedMessages.clear();
|
||||
clearActiveSearchContext();
|
||||
renderMessages();
|
||||
$("#folder-title").textContent = `Label: ${tag.name}`;
|
||||
$("#message-count").textContent = `${state.total} results`;
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function openTagDialog(tag = null) {
|
||||
$("#tag-id").value = tag?.id || "";
|
||||
$("#tag-name").value = tag?.name || "";
|
||||
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280";
|
||||
$("#tag-delete").disabled = !tag;
|
||||
$("#tag-dialog").showModal();
|
||||
setTimeout(() => $("#tag-name").focus(), 20);
|
||||
}
|
||||
|
||||
async function saveTag(event) {
|
||||
event.preventDefault();
|
||||
const id = $("#tag-id").value;
|
||||
const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value };
|
||||
if (!payload.name) return;
|
||||
try {
|
||||
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
|
||||
$("#tag-dialog").close();
|
||||
await loadTags();
|
||||
toast("Label saved");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function deleteTag() {
|
||||
const id = $("#tag-id").value;
|
||||
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return;
|
||||
try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function promptTag(label, tags = state.tags) {
|
||||
if (!tags.length) { toast("Create a label first", "error"); return null; }
|
||||
const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" });
|
||||
return tags.find((tag) => String(tag.id) === String(answer)) || null;
|
||||
}
|
||||
|
||||
async function addTagToMessage(message, tag) {
|
||||
const folder = message.folder || state.folder;
|
||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) });
|
||||
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
|
||||
renderMessages();
|
||||
await loadTags();
|
||||
}
|
||||
|
||||
async function removeTagFromMessage(message, tag) {
|
||||
const folder = message.folder || state.folder;
|
||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
|
||||
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
|
||||
renderMessages();
|
||||
await loadTags();
|
||||
}
|
||||
|
||||
async function bulkAddTag(tagId) {
|
||||
const items = selectedMessages();
|
||||
const tag = state.tags.find((item) => item.id === tagId);
|
||||
if (!items.length || !tag) return;
|
||||
try {
|
||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) });
|
||||
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
|
||||
renderMessages();
|
||||
await loadTags();
|
||||
}
|
||||
|
||||
async function removeTagFromMessage(message, tag) {
|
||||
const folder = message.folder || state.folder;
|
||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
|
||||
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
|
||||
renderMessages();
|
||||
await loadTags();
|
||||
}
|
||||
|
||||
async function bulkAddTag(tagId) {
|
||||
const items = selectedMessages();
|
||||
const tag = state.tags.find((item) => item.id === tagId);
|
||||
if (!items.length || !tag) return;
|
||||
try {
|
||||
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) });
|
||||
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
|
||||
renderMessages();
|
||||
await loadTags();
|
||||
toast(`Label “${tag.name}” added`);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
toast(`Label “${tag.name}” added`);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 40-messages-reader.js */
|
||||
|
||||
async function loadMessages(reset = false, options = {}) {
|
||||
const list = $("#message-list");
|
||||
const restoreState = Boolean(options.restoreState);
|
||||
@@ -117,7 +117,17 @@ function renderMessages() {
|
||||
flagButton.setAttribute("aria-label", flagButton.title);
|
||||
flagButton.innerHTML = svgIcon("star");
|
||||
flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); });
|
||||
quick.append(readButton, flagButton);
|
||||
const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
|
||||
fullscreenButton.type = "button";
|
||||
fullscreenButton.title = "Open full screen";
|
||||
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
|
||||
fullscreenButton.innerHTML = svgIcon("external-link");
|
||||
fullscreenButton.addEventListener("click", async (event) => {
|
||||
event.stopPropagation();
|
||||
await openMessage(folder, message.uid);
|
||||
enterReaderFullscreen();
|
||||
});
|
||||
quick.append(readButton, flagButton, fullscreenButton);
|
||||
icons.append(quick);
|
||||
if (Array.isArray(message.tags) && message.tags.length) {
|
||||
const wrap = el("span", "message-tags");
|
||||
@@ -177,6 +187,84 @@ async function senderIsSavedContact(email) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function readerFullscreenUrl(detail = state.currentDetail) {
|
||||
const url = new URL(location.href);
|
||||
if (detail?.folder && Number.isFinite(Number(detail.uid))) {
|
||||
url.searchParams.set("mail_folder", detail.folder);
|
||||
url.searchParams.set("mail_uid", String(detail.uid));
|
||||
url.searchParams.set("mail_fullscreen", "1");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function restoreReaderScroll() {
|
||||
const reader = $("#reader-content");
|
||||
if (!reader) return;
|
||||
const top = Math.max(0, Number(state.readerScrollTopBeforeFullscreen) || 0);
|
||||
requestAnimationFrame(() => { reader.scrollTop = top; });
|
||||
}
|
||||
|
||||
function setReaderFullscreen(enabled, options = {}) {
|
||||
const reader = $("#reader");
|
||||
const button = $("#reader-fullscreen");
|
||||
if (!reader || !button) return;
|
||||
const next = Boolean(enabled);
|
||||
if (next === reader.classList.contains("reader-fullscreen")) return;
|
||||
if (next) {
|
||||
state.readerScrollTopBeforeFullscreen = $("#reader-content")?.scrollTop || 0;
|
||||
reader.classList.add("reader-fullscreen");
|
||||
document.body.classList.add("reader-fullscreen-open");
|
||||
button.setAttribute("aria-pressed", "true");
|
||||
button.title = "Exit full screen";
|
||||
button.setAttribute("aria-label", "Exit message full screen");
|
||||
if (options.pushHistory !== false && state.currentDetail) {
|
||||
history.pushState({ rustboxMailFullscreen: true, folder: state.currentDetail.folder, uid: state.currentDetail.uid }, "", readerFullscreenUrl());
|
||||
state.readerFullscreenHistory = true;
|
||||
}
|
||||
} else {
|
||||
reader.classList.remove("reader-fullscreen");
|
||||
document.body.classList.remove("reader-fullscreen-open");
|
||||
button.setAttribute("aria-pressed", "false");
|
||||
button.title = "Open full screen";
|
||||
button.setAttribute("aria-label", "Open message full screen");
|
||||
state.readerFullscreenHistory = false;
|
||||
if (options.keepUrl !== true && new URL(location.href).searchParams.get("mail_fullscreen") === "1") {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.delete("mail_fullscreen");
|
||||
history.replaceState(history.state && history.state.rustboxMailFullscreen ? null : history.state, "", url);
|
||||
}
|
||||
restoreReaderScroll();
|
||||
}
|
||||
}
|
||||
|
||||
function enterReaderFullscreen() {
|
||||
if (!state.currentDetail) return;
|
||||
setReaderFullscreen(true);
|
||||
}
|
||||
|
||||
function exitReaderFullscreen() {
|
||||
if (!$("#reader")?.classList.contains("reader-fullscreen")) return;
|
||||
if (state.readerFullscreenHistory) history.back();
|
||||
else setReaderFullscreen(false, { pushHistory: false });
|
||||
}
|
||||
|
||||
function toggleReaderFullscreen() {
|
||||
if ($("#reader")?.classList.contains("reader-fullscreen")) exitReaderFullscreen();
|
||||
else enterReaderFullscreen();
|
||||
}
|
||||
|
||||
async function openMessageTarget(accountId, folder, uid, fullscreen = false) {
|
||||
if (accountId && accountId !== state.accountId) await switchAccount(accountId);
|
||||
if (folder && folder !== state.folder) {
|
||||
state.folder = folder;
|
||||
renderFolders();
|
||||
await loadMessages(true);
|
||||
}
|
||||
if (folder && Number.isFinite(Number(uid))) await openMessage(folder, Number(uid));
|
||||
if (fullscreen && state.currentDetail) setReaderFullscreen(true, { pushHistory: false });
|
||||
}
|
||||
|
||||
async function openMessage(folder, uid) {
|
||||
clearTimeout(state.markReadTimer);
|
||||
state.markReadTimer = null;
|
||||
@@ -756,6 +844,7 @@ async function importVcardAttachment(folder, uid, attachment) {
|
||||
function closeReader() {
|
||||
clearTimeout(state.markReadTimer);
|
||||
state.markReadTimer = null;
|
||||
if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false });
|
||||
$("#reader").classList.remove("mobile-open");
|
||||
$("#reader-empty").classList.remove("hidden");
|
||||
$("#reader-content").classList.add("hidden");
|
||||
|
||||
+36
-25
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 50-compose.js */
|
||||
|
||||
function parseRecipients(raw) {
|
||||
return raw.split(",").map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ function estimatedMimeAttachmentBytes(rawBytes) {
|
||||
|
||||
function estimateComposeMessageBytes(extraFiles = []) {
|
||||
const editor = $("#compose-editor");
|
||||
let bytes = 4096; // envelope headers, MIME boundaries and normal metadata
|
||||
let bytes = 4096;
|
||||
for (const selector of ["#compose-to", "#compose-cc", "#compose-bcc", "#compose-subject"]) bytes += utf8Bytes($(selector)?.value);
|
||||
if (editor) {
|
||||
const plainBytes = utf8Bytes(editor.innerText);
|
||||
@@ -404,27 +404,37 @@ function setComposeUploadProgress(visible, percent = 0, label = trText("Uploadin
|
||||
}
|
||||
|
||||
async function uploadComposeFiles(files, options = {}) {
|
||||
const list = [...(files || [])];
|
||||
const announce = options.announce !== false;
|
||||
const incoming = [...(files || [])];
|
||||
if (!incoming.length) return [];
|
||||
if (state.composeUploading) return [];
|
||||
const existing = new Set(state.composeAttachments.map((attachment) => `${String(attachment.filename || "")}\u0000${Number(attachment.size || 0)}`));
|
||||
const selected = new Set();
|
||||
const list = incoming.filter((file) => {
|
||||
const key = `${String(file.name || "")}\u0000${Number(file.size || 0)}`;
|
||||
if (existing.has(key) || selected.has(key)) return false;
|
||||
selected.add(key);
|
||||
return true;
|
||||
});
|
||||
if (!list.length) return [];
|
||||
if (state.composeUploading) { if (announce) toast(trText("Another upload is already in progress"), "warning"); return []; }
|
||||
const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024));
|
||||
const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024)));
|
||||
const oversized = list.find((file) => Number(file.size || 0) > maxBytes);
|
||||
if (oversized) {
|
||||
toast(`${oversized.name}: ${trText("maximum attachment size is")} ${maxMiB} MiB`, "error");
|
||||
setComposeUploadProgress(true, 0, `${oversized.name}: ${trText("maximum attachment size is")} ${maxMiB} MiB`);
|
||||
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4200);
|
||||
return [];
|
||||
}
|
||||
const maxMessageBytes = Math.max(1, Number(root.dataset.maxMessageBytes || 50 * 1024 * 1024));
|
||||
const maxMessageMiB = Math.max(1, Number(root.dataset.maxMessageMb || Math.round(maxMessageBytes / 1024 / 1024)));
|
||||
const estimatedAfterUpload = updateComposeMessageSize(list);
|
||||
if (estimatedAfterUpload > maxMessageBytes) {
|
||||
toast(`${trText("Estimated message size exceeds the configured limit")} (${formatComposeBytes(estimatedAfterUpload)} / ${maxMessageMiB} MiB). ${trText("The exact size will be checked before sending.")}`, "warning");
|
||||
setComposeUploadProgress(true, 0, `${trText("Estimated message size exceeds the configured limit")} (${formatComposeBytes(estimatedAfterUpload)} / ${maxMessageMiB} MiB)`);
|
||||
}
|
||||
state.composeUploading = true;
|
||||
const controller = new AbortController();
|
||||
state.composeUploadController = controller;
|
||||
const uploadLabel = list.length === 1 ? trText("Uploading attachment…") : trText("Uploading files…");
|
||||
setComposeUploadProgress(true, 0, uploadLabel);
|
||||
const progress = progressToast(uploadLabel);
|
||||
await saveCurrentDraft(true, true);
|
||||
const id = ensureDraftId();
|
||||
const added = [];
|
||||
@@ -435,27 +445,28 @@ async function uploadComposeFiles(files, options = {}) {
|
||||
form.append("file", file, file.name);
|
||||
const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => {
|
||||
const overall = Math.round(((index + (percent / 100)) / list.length) * 100);
|
||||
const label = `${trText("Uploading attachment…")} ${file.name}`;
|
||||
setComposeUploadProgress(true, overall, `${label} · ${overall}%`);
|
||||
progress.update(overall, label);
|
||||
});
|
||||
added.push(...(data.attachments || []));
|
||||
setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`);
|
||||
}, controller.signal);
|
||||
const uploaded = data.attachments || [];
|
||||
added.push(...uploaded);
|
||||
state.composeAttachments.push(...uploaded);
|
||||
renderComposeAttachments();
|
||||
state.composeDirty = true;
|
||||
state.composeNeedsRemoteSync = true;
|
||||
storeLocalDraftSnapshot();
|
||||
}
|
||||
state.composeAttachments.push(...added);
|
||||
renderComposeAttachments();
|
||||
state.composeDirty = true;
|
||||
state.composeNeedsRemoteSync = true;
|
||||
storeLocalDraftSnapshot();
|
||||
setComposeUploadProgress(true, 100, trText("Upload complete"));
|
||||
progress.complete(added.length === 1 ? trText("Attachment uploaded") : `${added.length} ${trText("attachments uploaded")}`);
|
||||
if (announce) toast(`${added.length} attachment${added.length === 1 ? "" : "s"} added`, "success");
|
||||
setTimeout(() => setComposeUploadProgress(false), 450);
|
||||
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450);
|
||||
return added;
|
||||
} catch (error) {
|
||||
setComposeUploadProgress(false);
|
||||
progress.fail(error.message);
|
||||
return [];
|
||||
if (error?.name === "AbortError") setComposeUploadProgress(false);
|
||||
else {
|
||||
setComposeUploadProgress(true, 0, error?.message || trText("Upload failed"));
|
||||
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4800);
|
||||
}
|
||||
return added;
|
||||
} finally {
|
||||
if (state.composeUploadController === controller) state.composeUploadController = null;
|
||||
state.composeUploading = false;
|
||||
}
|
||||
}
|
||||
@@ -978,8 +989,8 @@ async function recoverDraft() {
|
||||
async function closeComposer() {
|
||||
clearTimeout(state.draftSaveTimer);
|
||||
clearTimeout(state.inlineImageCleanupTimer);
|
||||
if (state.composeUploadController) state.composeUploadController.abort();
|
||||
await cleanupUnusedInlineAttachments();
|
||||
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
|
||||
if (state.composeNeedsRemoteSync && draftHasContent()) {
|
||||
const saved = await saveCurrentDraft(true, true, true);
|
||||
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 60-mail-actions-search.js */
|
||||
|
||||
async function updateFlag(flag, add) {
|
||||
if (!state.currentDetail) return;
|
||||
const detail = state.currentDetail;
|
||||
@@ -485,6 +485,7 @@ function openMessageContextMenu(x, y, message) {
|
||||
const flagged = flags.includes("\\Flagged");
|
||||
showContextMenu(x, y, [
|
||||
{ label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) },
|
||||
{ label: "Open full screen", action: async () => { await ensureMessageOpen(message); enterReaderFullscreen(); } },
|
||||
{ label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } },
|
||||
{ label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } },
|
||||
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
|
||||
|
||||
+274
-274
@@ -1,294 +1,294 @@
|
||||
/* RustBox UI module: 70-contacts.js */
|
||||
async function openContacts() {
|
||||
$("#contacts-dialog").showModal();
|
||||
await loadContactGroups();
|
||||
await loadContacts();
|
||||
}
|
||||
|
||||
async function loadContactGroups() {
|
||||
try {
|
||||
const data = await request("/contact-groups", { method: "GET" });
|
||||
state.contactGroups = data.groups || [];
|
||||
renderContactGroupFilter();
|
||||
renderContactGroupMembership($("#contact-id").value || null, []);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
async function openContacts() {
|
||||
$("#contacts-dialog").showModal();
|
||||
await loadContactGroups();
|
||||
await loadContacts();
|
||||
}
|
||||
|
||||
function renderContactGroupFilter() {
|
||||
const select = $("#contact-group-filter");
|
||||
const current = select.value;
|
||||
select.replaceChildren(new Option("All contacts", ""));
|
||||
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
|
||||
if ([...select.options].some((option) => option.value === current)) select.value = current;
|
||||
const hasGroup = Boolean(select.value);
|
||||
$("#contact-group-rename").disabled = !hasGroup;
|
||||
$("#contact-group-delete").disabled = !hasGroup;
|
||||
}
|
||||
async function loadContactGroups() {
|
||||
try {
|
||||
const data = await request("/contact-groups", { method: "GET" });
|
||||
state.contactGroups = data.groups || [];
|
||||
renderContactGroupFilter();
|
||||
renderContactGroupMembership($("#contact-id").value || null, []);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function downloadContactExport() {
|
||||
try {
|
||||
const headers = new Headers();
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
|
||||
if (!response.ok) throw new Error(`Export failed (${response.status})`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "contacts.vcf";
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
toast("Calendar exported", "success");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
function renderContactGroupFilter() {
|
||||
const select = $("#contact-group-filter");
|
||||
const current = select.value;
|
||||
select.replaceChildren(new Option("All contacts", ""));
|
||||
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
|
||||
if ([...select.options].some((option) => option.value === current)) select.value = current;
|
||||
const hasGroup = Boolean(select.value);
|
||||
$("#contact-group-rename").disabled = !hasGroup;
|
||||
$("#contact-group-delete").disabled = !hasGroup;
|
||||
}
|
||||
|
||||
async function importContactVcard(files) {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name || "contacts.vcf");
|
||||
try {
|
||||
const data = await request("/contacts/import", { method: "POST", body: form });
|
||||
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
|
||||
await loadContacts($("#contact-search").value.trim());
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
finally { $("#contact-import-file").value = ""; }
|
||||
}
|
||||
async function downloadContactExport() {
|
||||
try {
|
||||
const headers = new Headers();
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
|
||||
if (!response.ok) throw new Error(`Export failed (${response.status})`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "contacts.vcf";
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
toast("Calendar exported", "success");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function showContactDuplicates() {
|
||||
try {
|
||||
const data = await request("/contacts/duplicates", { method: "GET" });
|
||||
const groups = data.duplicates || [];
|
||||
const list = $("#contact-duplicate-list");
|
||||
list.replaceChildren();
|
||||
if (!groups.length) {
|
||||
list.append(el("div", "empty-list", "No duplicate contacts found"));
|
||||
}
|
||||
for (const group of groups) {
|
||||
const card = el("section", "duplicate-contact-group");
|
||||
card.append(el("strong", "", group.email));
|
||||
const rows = el("div", "duplicate-contact-options");
|
||||
for (const contact of group.contacts || []) {
|
||||
const row = el("div", "duplicate-contact-row");
|
||||
const summary = el("div", "duplicate-contact-summary");
|
||||
summary.append(el("strong", "", contact.name || contact.email));
|
||||
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
|
||||
const keep = el("button", "button quiet", "Keep this");
|
||||
keep.type = "button";
|
||||
keep.addEventListener("click", async () => {
|
||||
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
|
||||
if (!sources.length) return;
|
||||
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
|
||||
try {
|
||||
let target = contact;
|
||||
for (const source of sources) {
|
||||
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
|
||||
}
|
||||
toast("Contacts merged");
|
||||
await loadContacts($("#contact-search").value.trim());
|
||||
await showContactDuplicates();
|
||||
editContact(target);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
});
|
||||
row.append(summary, keep); rows.append(row);
|
||||
}
|
||||
card.append(rows); list.append(card);
|
||||
}
|
||||
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
async function importContactVcard(files) {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name || "contacts.vcf");
|
||||
try {
|
||||
const data = await request("/contacts/import", { method: "POST", body: form });
|
||||
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
|
||||
await loadContacts($("#contact-search").value.trim());
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
finally { $("#contact-import-file").value = ""; }
|
||||
}
|
||||
|
||||
async function loadContacts(query = "") {
|
||||
try {
|
||||
const groupId = $("#contact-group-filter")?.value || "";
|
||||
let contacts;
|
||||
if (groupId) {
|
||||
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
|
||||
contacts = data.members || [];
|
||||
if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
|
||||
} else {
|
||||
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
|
||||
contacts = data.contacts || [];
|
||||
}
|
||||
state.contacts = contacts;
|
||||
renderContacts();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function createContactGroup() {
|
||||
const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
|
||||
if (!name?.trim()) return;
|
||||
try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function renameContactGroup() {
|
||||
const id = $("#contact-group-filter").value;
|
||||
const group = state.contactGroups.find((item) => item.id === id);
|
||||
if (!group) return;
|
||||
const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
|
||||
if (!name?.trim() || name.trim() === group.name) return;
|
||||
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function deleteContactGroup() {
|
||||
const id = $("#contact-group-filter").value;
|
||||
const group = state.contactGroups.find((item) => item.id === id);
|
||||
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
|
||||
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function loadContactMembership(contactId) {
|
||||
if (!contactId) return renderContactGroupMembership(null, []);
|
||||
try {
|
||||
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
|
||||
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function renderContactGroupMembership(contactId, selectedIds) {
|
||||
const list = $("#contact-group-membership");
|
||||
if (!list) return;
|
||||
const selected = new Set(selectedIds || []);
|
||||
async function showContactDuplicates() {
|
||||
try {
|
||||
const data = await request("/contacts/duplicates", { method: "GET" });
|
||||
const groups = data.duplicates || [];
|
||||
const list = $("#contact-duplicate-list");
|
||||
list.replaceChildren();
|
||||
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
|
||||
for (const group of state.contactGroups) {
|
||||
const label = el("label", "checkbox-row");
|
||||
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
|
||||
input.addEventListener("change", async () => {
|
||||
try {
|
||||
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
|
||||
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
|
||||
await loadContactGroups();
|
||||
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
|
||||
});
|
||||
label.append(input, el("span", "", group.name)); list.append(label);
|
||||
if (!groups.length) {
|
||||
list.append(el("div", "empty-list", "No duplicate contacts found"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderContacts() {
|
||||
const list = $("#contact-list");
|
||||
list.replaceChildren();
|
||||
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
|
||||
for (const contact of state.contacts) {
|
||||
const button = el("button", "contact-item");
|
||||
button.type = "button";
|
||||
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
|
||||
button.addEventListener("click", () => editContact(contact));
|
||||
list.append(button);
|
||||
for (const group of groups) {
|
||||
const card = el("section", "duplicate-contact-group");
|
||||
card.append(el("strong", "", group.email));
|
||||
const rows = el("div", "duplicate-contact-options");
|
||||
for (const contact of group.contacts || []) {
|
||||
const row = el("div", "duplicate-contact-row");
|
||||
const summary = el("div", "duplicate-contact-summary");
|
||||
summary.append(el("strong", "", contact.name || contact.email));
|
||||
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
|
||||
const keep = el("button", "button quiet", "Keep this");
|
||||
keep.type = "button";
|
||||
keep.addEventListener("click", async () => {
|
||||
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
|
||||
if (!sources.length) return;
|
||||
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
|
||||
try {
|
||||
let target = contact;
|
||||
for (const source of sources) {
|
||||
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
|
||||
}
|
||||
toast("Contacts merged");
|
||||
await loadContacts($("#contact-search").value.trim());
|
||||
await showContactDuplicates();
|
||||
editContact(target);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
});
|
||||
row.append(summary, keep); rows.append(row);
|
||||
}
|
||||
card.append(rows); list.append(card);
|
||||
}
|
||||
}
|
||||
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function editContact(contact = null) {
|
||||
$("#contact-id").value = contact?.id || "";
|
||||
$("#contact-name").value = contact?.name || "";
|
||||
$("#contact-email").value = contact?.email || "";
|
||||
$("#contact-emails").value = (contact?.emails || []).join("\n");
|
||||
$("#contact-company").value = contact?.company || "";
|
||||
$("#contact-phone").value = contact?.phone || "";
|
||||
$("#contact-phones").value = (contact?.phones || []).join("\n");
|
||||
$("#contact-job-title").value = contact?.job_title || "";
|
||||
$("#contact-website").value = contact?.website || "";
|
||||
$("#contact-birthday").value = contact?.birthday || "";
|
||||
$("#contact-address").value = contact?.address || "";
|
||||
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
|
||||
$("#contact-notes").value = contact?.notes || "";
|
||||
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
|
||||
$("#contact-delete").disabled = !contact;
|
||||
$("#contact-compose").disabled = !contact?.email;
|
||||
$("#contact-photo-upload").disabled = !contact?.id;
|
||||
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
|
||||
renderContactPhoto(contact);
|
||||
loadContactMembership(contact?.id || null);
|
||||
}
|
||||
async function loadContacts(query = "") {
|
||||
try {
|
||||
const groupId = $("#contact-group-filter")?.value || "";
|
||||
let contacts;
|
||||
if (groupId) {
|
||||
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
|
||||
contacts = data.members || [];
|
||||
if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
|
||||
} else {
|
||||
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
|
||||
contacts = data.contacts || [];
|
||||
}
|
||||
state.contacts = contacts;
|
||||
renderContacts();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function clearContactPhotoObjectUrl() {
|
||||
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
|
||||
state.contactPhotoUrl = null;
|
||||
}
|
||||
async function createContactGroup() {
|
||||
const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
|
||||
if (!name?.trim()) return;
|
||||
try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function renderContactPhoto(contact) {
|
||||
clearContactPhotoObjectUrl();
|
||||
const image = $("#contact-photo-preview");
|
||||
const placeholder = $("#contact-photo-placeholder");
|
||||
image.classList.add("hidden");
|
||||
image.removeAttribute("src");
|
||||
placeholder.classList.remove("hidden");
|
||||
if (!contact?.id || !contact?.has_photo) return;
|
||||
try {
|
||||
const headers = new Headers();
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
|
||||
if (!response.ok) return;
|
||||
const blob = await response.blob();
|
||||
state.contactPhotoUrl = URL.createObjectURL(blob);
|
||||
image.src = state.contactPhotoUrl;
|
||||
image.classList.remove("hidden");
|
||||
placeholder.classList.add("hidden");
|
||||
} catch (_) { }
|
||||
}
|
||||
async function renameContactGroup() {
|
||||
const id = $("#contact-group-filter").value;
|
||||
const group = state.contactGroups.find((item) => item.id === id);
|
||||
if (!group) return;
|
||||
const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
|
||||
if (!name?.trim() || name.trim() === group.name) return;
|
||||
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function uploadContactPhoto(files) {
|
||||
const id = $("#contact-id").value;
|
||||
const file = files?.[0];
|
||||
if (!id || !file) return;
|
||||
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; }
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name || "photo");
|
||||
try {
|
||||
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
|
||||
const contact = state.contacts.find((item) => item.id === id);
|
||||
if (contact) contact.has_photo = true;
|
||||
$("#contact-photo-delete").disabled = false;
|
||||
await renderContactPhoto({ id, has_photo: true });
|
||||
toast("Contact photo saved");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
finally { $("#contact-photo-file").value = ""; }
|
||||
}
|
||||
async function deleteContactGroup() {
|
||||
const id = $("#contact-group-filter").value;
|
||||
const group = state.contactGroups.find((item) => item.id === id);
|
||||
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
|
||||
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function deleteContactPhoto() {
|
||||
const id = $("#contact-id").value;
|
||||
if (!id) return;
|
||||
try {
|
||||
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" });
|
||||
const contact = state.contacts.find((item) => item.id === id);
|
||||
if (contact) contact.has_photo = false;
|
||||
$("#contact-photo-delete").disabled = true;
|
||||
await renderContactPhoto({ id, has_photo: false });
|
||||
toast("Contact photo removed");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
async function loadContactMembership(contactId) {
|
||||
if (!contactId) return renderContactGroupMembership(null, []);
|
||||
try {
|
||||
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
|
||||
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function saveContact(event) {
|
||||
event.preventDefault();
|
||||
const payload = {
|
||||
id: $("#contact-id").value || null,
|
||||
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
|
||||
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
|
||||
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
|
||||
is_favorite: $("#contact-favorite").checked, source: "manual",
|
||||
};
|
||||
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
function renderContactGroupMembership(contactId, selectedIds) {
|
||||
const list = $("#contact-group-membership");
|
||||
if (!list) return;
|
||||
const selected = new Set(selectedIds || []);
|
||||
list.replaceChildren();
|
||||
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
|
||||
for (const group of state.contactGroups) {
|
||||
const label = el("label", "checkbox-row");
|
||||
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
|
||||
input.addEventListener("change", async () => {
|
||||
try {
|
||||
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
|
||||
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
|
||||
await loadContactGroups();
|
||||
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
|
||||
});
|
||||
label.append(input, el("span", "", group.name)); list.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact() {
|
||||
const id = $("#contact-id").value;
|
||||
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return;
|
||||
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
function renderContacts() {
|
||||
const list = $("#contact-list");
|
||||
list.replaceChildren();
|
||||
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
|
||||
for (const contact of state.contacts) {
|
||||
const button = el("button", "contact-item");
|
||||
button.type = "button";
|
||||
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
|
||||
button.addEventListener("click", () => editContact(contact));
|
||||
list.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function composeToContact() {
|
||||
const email = $("#contact-email").value.trim();
|
||||
if (!email) return;
|
||||
$("#contacts-dialog").close();
|
||||
openCompose("new");
|
||||
$("#compose-to").value = email;
|
||||
}
|
||||
function editContact(contact = null) {
|
||||
$("#contact-id").value = contact?.id || "";
|
||||
$("#contact-name").value = contact?.name || "";
|
||||
$("#contact-email").value = contact?.email || "";
|
||||
$("#contact-emails").value = (contact?.emails || []).join("\n");
|
||||
$("#contact-company").value = contact?.company || "";
|
||||
$("#contact-phone").value = contact?.phone || "";
|
||||
$("#contact-phones").value = (contact?.phones || []).join("\n");
|
||||
$("#contact-job-title").value = contact?.job_title || "";
|
||||
$("#contact-website").value = contact?.website || "";
|
||||
$("#contact-birthday").value = contact?.birthday || "";
|
||||
$("#contact-address").value = contact?.address || "";
|
||||
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
|
||||
$("#contact-notes").value = contact?.notes || "";
|
||||
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
|
||||
$("#contact-delete").disabled = !contact;
|
||||
$("#contact-compose").disabled = !contact?.email;
|
||||
$("#contact-photo-upload").disabled = !contact?.id;
|
||||
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
|
||||
renderContactPhoto(contact);
|
||||
loadContactMembership(contact?.id || null);
|
||||
}
|
||||
|
||||
function clearContactPhotoObjectUrl() {
|
||||
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
|
||||
state.contactPhotoUrl = null;
|
||||
}
|
||||
|
||||
async function renderContactPhoto(contact) {
|
||||
clearContactPhotoObjectUrl();
|
||||
const image = $("#contact-photo-preview");
|
||||
const placeholder = $("#contact-photo-placeholder");
|
||||
image.classList.add("hidden");
|
||||
image.removeAttribute("src");
|
||||
placeholder.classList.remove("hidden");
|
||||
if (!contact?.id || !contact?.has_photo) return;
|
||||
try {
|
||||
const headers = new Headers();
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
|
||||
if (!response.ok) return;
|
||||
const blob = await response.blob();
|
||||
state.contactPhotoUrl = URL.createObjectURL(blob);
|
||||
image.src = state.contactPhotoUrl;
|
||||
image.classList.remove("hidden");
|
||||
placeholder.classList.add("hidden");
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
async function uploadContactPhoto(files) {
|
||||
const id = $("#contact-id").value;
|
||||
const file = files?.[0];
|
||||
if (!id || !file) return;
|
||||
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; }
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name || "photo");
|
||||
try {
|
||||
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
|
||||
const contact = state.contacts.find((item) => item.id === id);
|
||||
if (contact) contact.has_photo = true;
|
||||
$("#contact-photo-delete").disabled = false;
|
||||
await renderContactPhoto({ id, has_photo: true });
|
||||
toast("Contact photo saved");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
finally { $("#contact-photo-file").value = ""; }
|
||||
}
|
||||
|
||||
async function deleteContactPhoto() {
|
||||
const id = $("#contact-id").value;
|
||||
if (!id) return;
|
||||
try {
|
||||
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" });
|
||||
const contact = state.contacts.find((item) => item.id === id);
|
||||
if (contact) contact.has_photo = false;
|
||||
$("#contact-photo-delete").disabled = true;
|
||||
await renderContactPhoto({ id, has_photo: false });
|
||||
toast("Contact photo removed");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function saveContact(event) {
|
||||
event.preventDefault();
|
||||
const payload = {
|
||||
id: $("#contact-id").value || null,
|
||||
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
|
||||
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
|
||||
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
|
||||
is_favorite: $("#contact-favorite").checked, source: "manual",
|
||||
};
|
||||
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function deleteContact() {
|
||||
const id = $("#contact-id").value;
|
||||
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return;
|
||||
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function composeToContact() {
|
||||
const email = $("#contact-email").value.trim();
|
||||
if (!email) return;
|
||||
$("#contacts-dialog").close();
|
||||
openCompose("new");
|
||||
$("#compose-to").value = email;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 71-calendar.js */
|
||||
|
||||
function startOfCalendarDay(date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 72-openpgp-identities.js */
|
||||
|
||||
async function loadOpenPgp() {
|
||||
try {
|
||||
const [keys, prefs] = await Promise.all([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 80-settings-realtime.js */
|
||||
|
||||
async function loadQuota() {
|
||||
const summary = $("#quota-summary span");
|
||||
const bar = $("#quota-bar");
|
||||
@@ -158,17 +158,33 @@ function playNotificationSound() {
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
async function openRealtimeNotificationTarget(accountId, data) {
|
||||
window.focus();
|
||||
const folder = data?.folder || "INBOX";
|
||||
const uid = Number(data?.latest_uid);
|
||||
if (accountId && accountId !== state.accountId) await switchAccount(accountId);
|
||||
if (folder !== state.folder) {
|
||||
state.folder = folder;
|
||||
renderFolders();
|
||||
await loadMessages(true);
|
||||
}
|
||||
if (Number.isFinite(uid) && uid > 0) await openMessage(folder, uid);
|
||||
}
|
||||
|
||||
function notifyNewMail(data, accountId) {
|
||||
const prefs = state.notificationPrefs || {};
|
||||
if (!prefs.enabled || dndActive(prefs)) return;
|
||||
const folders = prefs.folders || [];
|
||||
if (folders.length && !folders.includes(data.folder)) return;
|
||||
const uid = Number(data.latest_uid);
|
||||
const alreadyOpen = Number(data.count || 1) === 1 && accountId === state.accountId && data.folder === state.currentDetail?.folder && Number.isFinite(uid) && uid === Number(state.currentDetail?.uid) && document.visibilityState === "visible" && document.hasFocus();
|
||||
if (alreadyOpen) return;
|
||||
if ("Notification" in window && Notification.permission === "granted") {
|
||||
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 });
|
||||
notification.onclick = () => { window.focus(); notification.close(); };
|
||||
notification.onclick = () => { notification.close(); void openRealtimeNotificationTarget(accountId, data); };
|
||||
}
|
||||
if (prefs.sound) playNotificationSound();
|
||||
}
|
||||
@@ -216,48 +232,141 @@ function startCalendarReminderWatcher() {
|
||||
state.calendarReminderTimer = setInterval(() => { void checkCalendarReminders(); }, 30000);
|
||||
}
|
||||
|
||||
function realtimeEventKey(payload) {
|
||||
if (payload.eventId) return String(payload.eventId);
|
||||
const event = payload.event || {};
|
||||
return `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
|
||||
}
|
||||
|
||||
function isDuplicateRealtimeEvent(payload) {
|
||||
const now = Date.now();
|
||||
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
|
||||
const event = payload.event || {};
|
||||
const fingerprint = `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
|
||||
const idKey = payload.eventId ? `id:${payload.eventId}` : null;
|
||||
const fingerprintKey = `event:${fingerprint}`;
|
||||
const duplicate = Boolean((idKey && state.realtimeSeenEvents.has(idKey)) || (state.realtimeSeenEvents.get(fingerprintKey) && now - state.realtimeSeenEvents.get(fingerprintKey) < 1500));
|
||||
if (idKey) state.realtimeSeenEvents.set(idKey, now);
|
||||
state.realtimeSeenEvents.set(fingerprintKey, now);
|
||||
return duplicate;
|
||||
}
|
||||
|
||||
async function resyncRealtimeState() {
|
||||
if (!state.accountId) return;
|
||||
if (state.realtimeSyncPromise) return state.realtimeSyncPromise;
|
||||
state.realtimeSyncPromise = (async () => {
|
||||
await refreshFolderListOnly();
|
||||
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true);
|
||||
})().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; });
|
||||
return state.realtimeSyncPromise;
|
||||
}
|
||||
|
||||
function scheduleRealtimeRefresh(event, accountId) {
|
||||
if (accountId !== state.accountId) return;
|
||||
state.realtimeRefreshEvents.push(event);
|
||||
clearTimeout(state.realtimeRefreshTimer);
|
||||
state.realtimeRefreshTimer = setTimeout(async () => {
|
||||
const events = state.realtimeRefreshEvents.splice(0);
|
||||
await refreshFolderListOnly();
|
||||
if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) {
|
||||
const refreshCurrent = events.some((item) => ["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder);
|
||||
if (refreshCurrent) {
|
||||
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true);
|
||||
}
|
||||
}, 250);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function startRealtimeFallbackPolling() {
|
||||
if (state.realtimePollTimer) return;
|
||||
state.realtimePollTimer = setInterval(() => {
|
||||
if (!state.accounts.length || document.visibilityState === "hidden") return;
|
||||
const open = state.ws?.readyState === WebSocket.OPEN;
|
||||
const stale = !state.wsLastHeartbeatAt || Date.now() - state.wsLastHeartbeatAt > 55000;
|
||||
if (!open || stale) void resyncRealtimeState();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
function startRealtimeWatchdog(socket) {
|
||||
clearInterval(state.wsWatchdogTimer);
|
||||
state.wsWatchdogTimer = setInterval(() => {
|
||||
if (state.ws !== socket || socket.readyState !== WebSocket.OPEN) return;
|
||||
if (Date.now() - state.wsLastHeartbeatAt > 60000) socket.close(4000, "Heartbeat timeout");
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function scheduleRealtimeReconnect() {
|
||||
if (!state.wsShouldConnect || !state.accounts.length || state.wsRetryTimer) return;
|
||||
const attempt = Math.min(state.wsRetryAttempt, 6);
|
||||
const base = Math.min(30000, 1000 * (2 ** attempt));
|
||||
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
|
||||
state.wsRetryAttempt += 1;
|
||||
state.wsRetryTimer = setTimeout(() => {
|
||||
state.wsRetryTimer = null;
|
||||
connectRealtime();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
state.wsShouldConnect = true;
|
||||
startRealtimeFallbackPolling();
|
||||
if (!state.accounts.length) return;
|
||||
if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return;
|
||||
clearTimeout(state.wsRetryTimer);
|
||||
state.wsRetryTimer = null;
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`);
|
||||
state.ws = socket;
|
||||
socket.onopen = () => {
|
||||
if (state.ws !== socket) return;
|
||||
const reconnect = state.wsConnectedOnce;
|
||||
state.wsConnectedOnce = true;
|
||||
state.wsRetryAttempt = 0;
|
||||
state.wsLastHeartbeatAt = Date.now();
|
||||
startRealtimeWatchdog(socket);
|
||||
if (reconnect) void resyncRealtimeState();
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
if (state.ws !== socket) return;
|
||||
state.wsLastHeartbeatAt = Date.now();
|
||||
try {
|
||||
const payload = JSON.parse(message.data);
|
||||
if (payload.type === "heartbeat") {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "heartbeat_ack", timestamp: payload.timestamp || Date.now() }));
|
||||
return;
|
||||
}
|
||||
if (isDuplicateRealtimeEvent(payload)) return;
|
||||
const event = payload.event || {};
|
||||
if (event.type === "NewMessages") notifyNewMail(event.data || {}, payload.accountId);
|
||||
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) state.ws = null;
|
||||
if (state.accounts.length) state.wsRetryTimer = setTimeout(connectRealtime, 2500);
|
||||
if (state.ws !== socket) return;
|
||||
state.ws = null;
|
||||
clearInterval(state.wsWatchdogTimer);
|
||||
state.wsWatchdogTimer = null;
|
||||
scheduleRealtimeReconnect();
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
socket.onerror = () => { if (socket.readyState < WebSocket.CLOSING) socket.close(); };
|
||||
}
|
||||
|
||||
function disconnectRealtime() {
|
||||
state.wsShouldConnect = false;
|
||||
clearTimeout(state.wsRetryTimer);
|
||||
clearTimeout(state.realtimeRefreshTimer);
|
||||
clearInterval(state.wsWatchdogTimer);
|
||||
clearInterval(state.realtimePollTimer);
|
||||
state.wsRetryTimer = null;
|
||||
state.realtimeRefreshTimer = null;
|
||||
state.wsWatchdogTimer = null;
|
||||
state.realtimePollTimer = null;
|
||||
state.realtimeRefreshEvents = [];
|
||||
state.wsRetryAttempt = 0;
|
||||
state.wsLastHeartbeatAt = 0;
|
||||
const socket = state.ws;
|
||||
state.ws = null;
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect");
|
||||
}
|
||||
|
||||
function capabilityBadge(label, supported) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 90-sieve.js */
|
||||
|
||||
async function loadSieve() {
|
||||
const status = $("#sieve-status");
|
||||
status.textContent = "Connecting…";
|
||||
@@ -960,7 +960,7 @@ async function restoreSieveBackup(file) {
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
// Validate the complete backup against the current server before writing any script.
|
||||
|
||||
for (const script of scripts) {
|
||||
const result = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: script.content }) });
|
||||
if (!result.valid) throw new Error(`Script “${script.name}” is not valid on this server: ${result.error || "validation failed"}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* RustBox UI module: 95-bindings-boot.js */
|
||||
|
||||
function bindEditor() {
|
||||
$$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => {
|
||||
$("#compose-editor").focus();
|
||||
@@ -66,15 +66,12 @@ function positionToolbarMenu(menu) {
|
||||
const popover = menu.querySelector(".toolbar-menu-popover");
|
||||
const summary = menu.querySelector("summary");
|
||||
if (!popover || !summary) return;
|
||||
if (matchMedia("(max-width: 560px)").matches) {
|
||||
Object.assign(popover.style, { left: "8px", right: "8px", top: "auto", bottom: "8px" });
|
||||
return;
|
||||
}
|
||||
Object.assign(popover.style, { right: "auto", bottom: "auto" });
|
||||
const rect = summary.getBoundingClientRect();
|
||||
const width = Math.max(164, popover.offsetWidth || 164);
|
||||
const height = popover.offsetHeight || 80;
|
||||
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 height = popover.offsetHeight || 80;
|
||||
let left = Math.min(rect.left, window.innerWidth - width - margin);
|
||||
left = Math.max(margin, left);
|
||||
let top = rect.bottom + 5;
|
||||
@@ -256,6 +253,7 @@ function bindEvents() {
|
||||
$("#reader-back").addEventListener("click", closeReader);
|
||||
$("#reader-prev").addEventListener("click", () => navigateMessage(-1));
|
||||
$("#reader-next").addEventListener("click", () => navigateMessage(1));
|
||||
$("#reader-fullscreen").addEventListener("click", toggleReaderFullscreen);
|
||||
$("#thread-toggle").addEventListener("click", () => {
|
||||
state.threadExpanded = !state.threadExpanded;
|
||||
localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded));
|
||||
@@ -484,6 +482,11 @@ function bindEvents() {
|
||||
else if (event.key === "!") { event.preventDefault(); junkCurrent(); }
|
||||
else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); }
|
||||
}
|
||||
if (event.key === "Escape" && $("#reader")?.classList.contains("reader-fullscreen")) {
|
||||
event.preventDefault();
|
||||
exitReaderFullscreen();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && innerWidth <= 820) {
|
||||
if ($("#folder-pane")?.classList.contains("open")) setFolderDrawerOpen(false);
|
||||
else closeReader();
|
||||
@@ -494,6 +497,16 @@ function bindEvents() {
|
||||
else if (state.prefs.theme === "system") applyPreferences();
|
||||
});
|
||||
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
|
||||
window.addEventListener("popstate", async (event) => {
|
||||
const full = event.state?.rustboxMailFullscreen === true;
|
||||
if (!full) {
|
||||
if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false });
|
||||
return;
|
||||
}
|
||||
const folder = event.state.folder;
|
||||
const uid = Number(event.state.uid);
|
||||
if (folder && Number.isFinite(uid)) await openMessageTarget(state.accountId, folder, uid, true);
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
@@ -501,7 +514,13 @@ async function boot() {
|
||||
bindEvents();
|
||||
await loadAuthPolicy();
|
||||
const authenticated = await loadAccounts();
|
||||
if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
|
||||
if (authenticated) {
|
||||
await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime();
|
||||
const params = new URLSearchParams(location.search);
|
||||
const folder = params.get("mail_folder");
|
||||
const uid = Number(params.get("mail_uid"));
|
||||
if (folder && Number.isFinite(uid) && uid > 0) await openMessageTarget(state.accountId, folder, uid, params.get("mail_fullscreen") === "1");
|
||||
} else { await loadTranslations(state.prefs.language); }
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
Reference in new issue
Block a user