1 parent
1bb398acd1
commit
1309358b68
17 files changed
+239
-50
No files matched your search
@@ -595,7 +595,7 @@
|
||||
width: 100%;
|
||||
min-height: 68vh;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
background: var(--reader-bg);
|
||||
}
|
||||
|
||||
.message-body pre {
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
realtimeSeenEvents: new Map(),
|
||||
realtimeNotifiedMessages: new Map(),
|
||||
realtimeNotificationSuppressions: new Map(),
|
||||
realtimeNotificationSuppressedMessages: new Map(),
|
||||
realtimeNotificationsReady: false,
|
||||
realtimeNotificationsArmRequested: false,
|
||||
realtimeNotificationsReadyTimer: null,
|
||||
@@ -166,7 +167,17 @@
|
||||
}
|
||||
|
||||
function sanitizeMessagePreviewHtml(html) {
|
||||
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html");
|
||||
const source = stripRustBoxUiLeak(html);
|
||||
const sourceDoc = new DOMParser().parseFromString(source, "text/html");
|
||||
const headStyles = [...sourceDoc.head.querySelectorAll("style")]
|
||||
.map((node) => String(node.textContent || "").replace(/@import\s+(?:url\()?[^;]+;?/gi, ""))
|
||||
.filter((css) => css.trim());
|
||||
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(source), "text/html");
|
||||
for (const css of headStyles.reverse()) {
|
||||
const style = doc.createElement("style");
|
||||
style.textContent = css;
|
||||
doc.body.prepend(style);
|
||||
}
|
||||
doc.querySelectorAll("a[href]").forEach((node) => {
|
||||
if (!node.hasAttribute("target")) node.setAttribute("target", "_blank");
|
||||
const target = String(node.getAttribute("target") || "").toLowerCase();
|
||||
|
||||
@@ -862,7 +862,10 @@ function parseAdaptiveEmailColor(value) {
|
||||
silver: [192, 192, 192], navy: [0, 0, 128], red: [255, 0, 0], green: [0, 128, 0],
|
||||
blue: [0, 0, 255], yellow: [255, 255, 0], maroon: [128, 0, 0], purple: [128, 0, 128],
|
||||
teal: [0, 128, 128], olive: [128, 128, 0], aqua: [0, 255, 255], fuchsia: [255, 0, 255],
|
||||
lime: [0, 255, 0], whitesmoke: [245, 245, 245], gainsboro: [220, 220, 220],
|
||||
lime: [0, 255, 0], darkgray: [169, 169, 169], darkgrey: [169, 169, 169],
|
||||
lightgray: [211, 211, 211], lightgrey: [211, 211, 211], whitesmoke: [245, 245, 245],
|
||||
ghostwhite: [248, 248, 255], floralwhite: [255, 250, 240], linen: [250, 240, 230],
|
||||
cornsilk: [255, 248, 220], gainsboro: [220, 220, 220],
|
||||
};
|
||||
if (named[raw]) return { r: named[raw][0], g: named[raw][1], b: named[raw][2], a: 1 };
|
||||
let match = raw.match(/^#([0-9a-f]{3,8})$/i);
|
||||
@@ -877,10 +880,28 @@ function parseAdaptiveEmailColor(value) {
|
||||
}
|
||||
}
|
||||
match = raw.match(/^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i);
|
||||
if (match) {
|
||||
const alpha = match[4]?.endsWith("%") ? Number(match[4].slice(0, -1)) / 100 : Number(match[4] ?? 1);
|
||||
const clamp = (value) => Math.max(0, Math.min(255, Math.round(Number(value))));
|
||||
return { r: clamp(match[1]), g: clamp(match[2]), b: clamp(match[3]), a: Math.max(0, Math.min(1, alpha)) };
|
||||
}
|
||||
match = raw.match(/^hsla?\(\s*(-?[0-9.]+)(?:deg)?\s*[, ]\s*([0-9.]+)%\s*[, ]\s*([0-9.]+)%(?:\s*[,/]\s*([0-9.]+%?))?\s*\)$/i);
|
||||
if (!match) return null;
|
||||
const hue = ((Number(match[1]) % 360) + 360) % 360 / 360;
|
||||
const saturation = Math.max(0, Math.min(1, Number(match[2]) / 100));
|
||||
const lightness = Math.max(0, Math.min(1, Number(match[3]) / 100));
|
||||
const alpha = match[4]?.endsWith("%") ? Number(match[4].slice(0, -1)) / 100 : Number(match[4] ?? 1);
|
||||
const clamp = (value) => Math.max(0, Math.min(255, Math.round(Number(value))));
|
||||
return { r: clamp(match[1]), g: clamp(match[2]), b: clamp(match[3]), a: Math.max(0, Math.min(1, alpha)) };
|
||||
const channel = (offset) => {
|
||||
const k = (offset + hue * 12) % 12;
|
||||
const amount = saturation * Math.min(lightness, 1 - lightness);
|
||||
return lightness - amount * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
||||
};
|
||||
return {
|
||||
r: Math.round(channel(0) * 255),
|
||||
g: Math.round(channel(8) * 255),
|
||||
b: Math.round(channel(4) * 255),
|
||||
a: Math.max(0, Math.min(1, alpha)),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptiveEmailRgbCss(color) {
|
||||
@@ -928,7 +949,7 @@ function adaptiveEmailContrast(a, b) {
|
||||
}
|
||||
|
||||
function adaptiveEmailReadableColor(color, background, preferred) {
|
||||
if (!color || !background || adaptiveEmailContrast(color, background) >= 3.5) return color;
|
||||
if (!color || !background || adaptiveEmailContrast(color, background) >= 4.5) return color;
|
||||
for (let step = 0.18; step <= 1.001; step += 0.14) {
|
||||
const candidate = adaptiveEmailMix(color, preferred, step);
|
||||
if (adaptiveEmailContrast(candidate, background) >= 4.5) return candidate;
|
||||
@@ -969,38 +990,68 @@ function adaptiveEmailThemePalette(body, targetTheme) {
|
||||
|
||||
function adaptiveEmailMapCssDeclarations(css, mapBackground, mapText, mapBorder) {
|
||||
if (!css) return css;
|
||||
const colorToken = /#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|\b(?:black|white|gray|grey|silver|navy|red|green|blue|yellow|maroon|purple|teal|olive|aqua|fuchsia|lime|whitesmoke|gainsboro)\b/gi;
|
||||
const colorToken = /#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|\b(?:black|white|gray|grey|silver|navy|red|green|blue|yellow|maroon|purple|teal|olive|aqua|fuchsia|lime|darkgray|darkgrey|lightgray|lightgrey|whitesmoke|ghostwhite|floralwhite|linen|cornsilk|gainsboro)\b/gi;
|
||||
return css.replace(/(^|[;{])\s*([a-z-]+)\s*:\s*([^;{}]+)/gi, (whole, prefix, property, value) => {
|
||||
const prop = String(property || "").toLowerCase();
|
||||
let mapper = null;
|
||||
if (prop === "color") mapper = mapText;
|
||||
else if (prop === "background-color" || (prop === "background" && !/url\s*\(/i.test(value))) mapper = mapBackground;
|
||||
else if (prop === "background-color" || prop === "background") mapper = mapBackground;
|
||||
else if (/^border(?:-(?:top|right|bottom|left))?(?:-color)?$/.test(prop)) mapper = mapBorder;
|
||||
if (!mapper) return whole;
|
||||
const next = String(value).replace(colorToken, (token) => {
|
||||
const protectedParts = [];
|
||||
const masked = String(value).replace(/(?:url|var)\([^)]*\)/gi, (part) => {
|
||||
const token = `__RUSTBOX_CSS_PROTECTED_${protectedParts.length}__`;
|
||||
protectedParts.push(part);
|
||||
return token;
|
||||
});
|
||||
let next = masked.replace(colorToken, (token) => {
|
||||
const parsed = parseAdaptiveEmailColor(token);
|
||||
const mapped = parsed ? mapper(parsed) : null;
|
||||
return mapped ? adaptiveEmailRgbCss(mapped) : token;
|
||||
});
|
||||
protectedParts.forEach((part, index) => { next = next.replace(`__RUSTBOX_CSS_PROTECTED_${index}__`, part); });
|
||||
return `${prefix}${property}:${next}`;
|
||||
});
|
||||
}
|
||||
|
||||
function messagePreviewBodyPresentation(html) {
|
||||
const doc = new DOMParser().parseFromString(String(html || ""), "text/html");
|
||||
const sourceBody = doc.body;
|
||||
if (!sourceBody) return { className: "", id: "", dir: "", lang: "", style: "" };
|
||||
const css = [];
|
||||
const style = sourceBody.style;
|
||||
for (const [property, value] of [
|
||||
["font-family", style.fontFamily],
|
||||
["font-size", style.fontSize],
|
||||
["font-weight", style.fontWeight],
|
||||
["line-height", style.lineHeight],
|
||||
["text-align", style.textAlign],
|
||||
["direction", style.direction],
|
||||
]) {
|
||||
if (value) css.push(`${property}:${value}`);
|
||||
}
|
||||
return {
|
||||
className: String(sourceBody.getAttribute("class") || "").trim(),
|
||||
id: String(sourceBody.getAttribute("id") || "").trim(),
|
||||
dir: String(sourceBody.getAttribute("dir") || "").trim(),
|
||||
lang: String(sourceBody.getAttribute("lang") || "").trim(),
|
||||
style: css.join(";"),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptMessagePreviewColors(html, detail, body) {
|
||||
const targetTheme = resolveTheme(state.prefs.theme);
|
||||
const targetTheme = ["dark", "light"].includes(root.dataset.theme) ? root.dataset.theme : resolveTheme(state.prefs.theme);
|
||||
const targetDark = targetTheme === "dark";
|
||||
const palette = adaptiveEmailThemePalette(body, targetTheme);
|
||||
const surface = palette.surface;
|
||||
const text = palette.text;
|
||||
const mailTheme = String(detail?.email_theme || "").toLowerCase();
|
||||
const themeAlreadyMatches = mailTheme === targetTheme;
|
||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
|
||||
|
||||
const remapBackground = (color) => {
|
||||
if (!color || color.a < 0.45) return null;
|
||||
const brightness = adaptiveEmailBrightness(color);
|
||||
const saturation = adaptiveEmailSaturation(color);
|
||||
if (saturation > 0.38) return null;
|
||||
if (saturation > 0.46) return null;
|
||||
if (targetDark && brightness >= 0.54) {
|
||||
const depth = Math.max(0, Math.min(0.14, (1 - brightness) * 0.30));
|
||||
return adaptiveEmailMix(surface, text, depth);
|
||||
@@ -1016,14 +1067,14 @@ function adaptMessagePreviewColors(html, detail, body) {
|
||||
if (!color || color.a < 0.45) return null;
|
||||
const brightness = adaptiveEmailBrightness(color);
|
||||
const saturation = adaptiveEmailSaturation(color);
|
||||
if (saturation > 0.32) return null;
|
||||
if (saturation > 0.40) return null;
|
||||
if (targetDark && brightness <= 0.52) return adaptiveEmailMix(text, surface, brightness * 0.24);
|
||||
if (!targetDark && brightness >= 0.56) return adaptiveEmailMix(text, surface, (1 - brightness) * 0.24);
|
||||
return null;
|
||||
};
|
||||
|
||||
const remapBorder = (color) => {
|
||||
if (!color || color.a < 0.35 || themeAlreadyMatches) return null;
|
||||
if (!color || color.a < 0.35) return null;
|
||||
const saturation = adaptiveEmailSaturation(color);
|
||||
if (saturation > 0.28) return null;
|
||||
const brightness = adaptiveEmailBrightness(color);
|
||||
@@ -1032,10 +1083,10 @@ function adaptMessagePreviewColors(html, detail, body) {
|
||||
return null;
|
||||
};
|
||||
|
||||
const mapBackground = (color) => themeAlreadyMatches ? null : remapBackground(color);
|
||||
const mapBackground = (color) => remapBackground(color);
|
||||
const mapText = (color, background = surface) => {
|
||||
if (!color) return null;
|
||||
const neutralMapped = themeAlreadyMatches ? null : remapNeutralText(color);
|
||||
const neutralMapped = remapNeutralText(color);
|
||||
const candidate = neutralMapped || color;
|
||||
const readable = adaptiveEmailReadableColor(candidate, background, text);
|
||||
return neutralMapped || readable !== candidate ? readable : null;
|
||||
@@ -1135,17 +1186,27 @@ function renderMessageBody(detail, forceRemote = false) {
|
||||
const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:";
|
||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
|
||||
let safeHtml = sanitizeMessagePreviewHtml(detail.html);
|
||||
const bodyPresentation = messagePreviewBodyPresentation(detail.html);
|
||||
let previewTheme = { targetTheme: "light", surface: "#fff", text: "#202622" };
|
||||
if (state.prefs.adaptiveEmailColors) {
|
||||
previewTheme = adaptMessagePreviewColors(safeHtml, detail, body);
|
||||
safeHtml = previewTheme.html;
|
||||
}
|
||||
iframe.style.background = previewTheme.surface;
|
||||
iframe.style.colorScheme = previewTheme.targetTheme;
|
||||
const quoteColor = state.prefs.adaptiveEmailColors ? `color-mix(in srgb, ${previewTheme.text} 25%, ${previewTheme.surface})` : "#d9ddda";
|
||||
const quoteStyle = state.mailPrefs.collapse_quotes !== false ? `blockquote{max-height:16em;overflow:auto;border-left:2px solid ${quoteColor};padding-left:12px;margin-left:0}` : `blockquote{border-left:2px solid ${quoteColor};padding-left:12px;margin-left:0}`;
|
||||
const previewStyle = state.prefs.adaptiveEmailColors
|
||||
? `body{margin:0;padding:22px;color:${previewTheme.text};background:${previewTheme.surface};font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:inherit;text-decoration:underline}pre{white-space:pre-wrap}${quoteStyle}`
|
||||
: `body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}`;
|
||||
iframe.srcdoc = `<!doctype html><html><head><meta charset="utf-8">${csp}<meta name="color-scheme" content="${previewTheme.targetTheme}"><style>${previewStyle}</style></head><body>${safeHtml}</body></html>`;
|
||||
const bodyAttributes = [
|
||||
bodyPresentation.className ? `class="${escapeHtml(bodyPresentation.className)}"` : "",
|
||||
bodyPresentation.id ? `id="${escapeHtml(bodyPresentation.id)}"` : "",
|
||||
bodyPresentation.dir ? `dir="${escapeHtml(bodyPresentation.dir)}"` : "",
|
||||
bodyPresentation.lang ? `lang="${escapeHtml(bodyPresentation.lang)}"` : "",
|
||||
bodyPresentation.style ? `style="${escapeHtml(bodyPresentation.style)}"` : "",
|
||||
].filter(Boolean).join(" ");
|
||||
iframe.srcdoc = `<!doctype html><html><head><meta charset="utf-8">${csp}<meta name="color-scheme" content="${previewTheme.targetTheme}"><style>${previewStyle}</style></head><body${bodyAttributes ? ` ${bodyAttributes}` : ""}>${safeHtml}</body></html>`;
|
||||
body.append(iframe);
|
||||
}
|
||||
|
||||
|
||||
@@ -1404,6 +1404,10 @@ function scheduleUnusedInlineAttachmentCleanup() {
|
||||
}
|
||||
|
||||
async function cleanupUnusedInlineAttachments() {
|
||||
if (state.composeUploading) {
|
||||
scheduleUnusedInlineAttachmentCleanup();
|
||||
return;
|
||||
}
|
||||
syncComposeInlineAttachments();
|
||||
const referenced = composeInlineAttachmentIds();
|
||||
const staleSignatureIds = [...state.signatureAttachmentIds].filter((id) => !referenced.has(id));
|
||||
|
||||
@@ -40,16 +40,30 @@ async function toggleFlagged() {
|
||||
try { await updateFlag("\\Flagged", !flagged); toast(flagged ? "Flag removed" : "Message flagged", "success"); } catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function suppressIntentionalMoveNotification(destination, result) {
|
||||
const uid = Number(result?.undo_move?.uid);
|
||||
if (!destination || !Number.isFinite(uid) || uid <= 0) return;
|
||||
if (typeof suppressRealtimeNotificationForMessage === "function") {
|
||||
suppressRealtimeNotificationForMessage(destination, uid);
|
||||
}
|
||||
}
|
||||
|
||||
function moveUndoAction(result, label = "Undo") {
|
||||
const undo = result?.undo_move;
|
||||
if (!undo?.from_folder || !undo?.to_folder || !Number.isFinite(Number(undo.uid))) return null;
|
||||
return {
|
||||
label,
|
||||
onClick: async () => {
|
||||
await request("/messages/move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }),
|
||||
});
|
||||
const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(undo.to_folder);
|
||||
try {
|
||||
const result = await request("/messages/move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }),
|
||||
});
|
||||
suppressIntentionalMoveNotification(undo.to_folder, result);
|
||||
} finally {
|
||||
releaseRealtimeNotificationSuppression();
|
||||
}
|
||||
state.selected = null;
|
||||
state.currentDetail = null;
|
||||
state.selectedMessages.clear();
|
||||
@@ -68,10 +82,16 @@ function bulkMoveUndoAction(undoMoves) {
|
||||
onClick: async () => {
|
||||
let restored = 0;
|
||||
for (const undo of [...undoMoves].reverse()) {
|
||||
await request("/messages/move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }),
|
||||
});
|
||||
const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(undo.to_folder);
|
||||
try {
|
||||
const result = await request("/messages/move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }),
|
||||
});
|
||||
suppressIntentionalMoveNotification(undo.to_folder, result);
|
||||
} finally {
|
||||
releaseRealtimeNotificationSuppression();
|
||||
}
|
||||
restored += 1;
|
||||
}
|
||||
state.selected = null;
|
||||
@@ -87,7 +107,14 @@ function bulkMoveUndoAction(undoMoves) {
|
||||
|
||||
async function moveMessageByDrag(fromFolder, uid, targetFolder) {
|
||||
if (!targetFolder || targetFolder === fromFolder) return;
|
||||
const result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: fromFolder, to_folder: targetFolder, uid }) });
|
||||
const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(targetFolder);
|
||||
let result;
|
||||
try {
|
||||
result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: fromFolder, to_folder: targetFolder, uid }) });
|
||||
suppressIntentionalMoveNotification(targetFolder, result);
|
||||
} finally {
|
||||
releaseRealtimeNotificationSuppression();
|
||||
}
|
||||
const wasCurrentFolder = state.folder === fromFolder;
|
||||
if (wasCurrentFolder) {
|
||||
state.messages = state.messages.filter((message) => !(message.uid === uid && (message.folder || state.folder) === fromFolder));
|
||||
@@ -127,15 +154,21 @@ async function moveCurrentTo(targetFolder, successMessage = null) {
|
||||
if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return;
|
||||
const detail = state.currentDetail;
|
||||
const nextTarget = nextMessageTargetAfterRemoval(detail.folder, detail.uid);
|
||||
const releaseRealtimeNotificationSuppression = suppressRealtimeNotificationsForFolder(targetFolder);
|
||||
try {
|
||||
const result = await request("/messages/move", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ from_folder: detail.folder, to_folder: targetFolder, uid: detail.uid }),
|
||||
});
|
||||
suppressIntentionalMoveNotification(targetFolder, result);
|
||||
await removeCurrentMessageFromView(detail.folder, detail.uid, nextTarget);
|
||||
await refreshFolderListOnly();
|
||||
toast(successMessage || `Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result));
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
releaseRealtimeNotificationSuppression();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCurrentTo(targetFolder) {
|
||||
@@ -277,6 +310,7 @@ async function performBulk(action, targetFolder = null) {
|
||||
if (destination && destination !== folder) {
|
||||
movedOperations += 1;
|
||||
const result = await request("/messages/move", { method: "POST", body: JSON.stringify({ from_folder: folder, to_folder: destination, uid: message.uid }) });
|
||||
suppressIntentionalMoveNotification(destination, result);
|
||||
if (result?.undo_move) undoMoves.push(result.undo_move);
|
||||
} else if (action === "delete") {
|
||||
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
|
||||
|
||||
@@ -335,16 +335,41 @@ async function loadIdentities() {
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function activeAccountEmail() {
|
||||
return String(state.accounts.find((account) => account.id === state.accountId)?.email || "").trim();
|
||||
}
|
||||
|
||||
function preferredComposeIdentity() {
|
||||
const defaultIdentity = state.identities.find((identity) => identity.is_default);
|
||||
if (defaultIdentity) return defaultIdentity;
|
||||
const accountEmail = activeAccountEmail().toLowerCase();
|
||||
if (accountEmail) {
|
||||
const matchingIdentity = state.identities.find((identity) => String(identity.email || "").trim().toLowerCase() === accountEmail);
|
||||
if (matchingIdentity) return matchingIdentity;
|
||||
}
|
||||
return state.identities[0] || null;
|
||||
}
|
||||
|
||||
function renderComposeIdentities() {
|
||||
const select = $("#compose-from");
|
||||
if (!select) return;
|
||||
select.replaceChildren(new Option("Account default", ""));
|
||||
const accountEmail = activeAccountEmail();
|
||||
const normalizedAccountEmail = accountEmail.toLowerCase();
|
||||
const hasAccountIdentity = Boolean(normalizedAccountEmail) && state.identities.some((identity) =>
|
||||
String(identity.email || "").trim().toLowerCase() === normalizedAccountEmail
|
||||
);
|
||||
|
||||
select.replaceChildren();
|
||||
if (!hasAccountIdentity) {
|
||||
select.add(new Option(accountEmail ? `${accountEmail} (account)` : "Account default", ""));
|
||||
}
|
||||
for (const identity of state.identities) {
|
||||
const label = identity.display_name ? `${identity.display_name} <${identity.email}>` : identity.email;
|
||||
const option = new Option(label, String(identity.id));
|
||||
if (identity.is_default) option.selected = true;
|
||||
select.add(option);
|
||||
select.add(new Option(label, String(identity.id)));
|
||||
}
|
||||
const preferred = preferredComposeIdentity();
|
||||
if (preferred) select.value = String(preferred.id);
|
||||
else if (!hasAccountIdentity) select.value = "";
|
||||
}
|
||||
|
||||
function renderIdentities() {
|
||||
@@ -440,7 +465,7 @@ async function deleteIdentity() {
|
||||
|
||||
function selectedIdentity() {
|
||||
const id = Number($("#compose-from")?.value || 0);
|
||||
return state.identities.find((identity) => identity.id === id) || state.identities.find((identity) => identity.is_default) || null;
|
||||
return state.identities.find((identity) => identity.id === id) || preferredComposeIdentity();
|
||||
}
|
||||
|
||||
function signatureEnabledForCompose(identity, action = state.composeAction || "new") {
|
||||
|
||||
@@ -263,6 +263,32 @@ function realtimeNotificationSuppressionKey(folder, accountId = state.accountId)
|
||||
return `${accountId || ""}|${folder || "INBOX"}`;
|
||||
}
|
||||
|
||||
function realtimeSuppressedMessageKey(folder, uid, accountId = state.accountId) {
|
||||
return `${accountId || ""}|${folder || "INBOX"}|${Number(uid) || 0}`;
|
||||
}
|
||||
|
||||
function suppressRealtimeNotificationForMessage(folder, uid, accountId = state.accountId, ttlMs = 15000) {
|
||||
const numericUid = Number(uid);
|
||||
if (!folder || !Number.isFinite(numericUid) || numericUid <= 0) return;
|
||||
state.realtimeNotificationSuppressedMessages.set(
|
||||
realtimeSuppressedMessageKey(folder, numericUid, accountId),
|
||||
Date.now() + Math.max(3000, Number(ttlMs) || 15000),
|
||||
);
|
||||
}
|
||||
|
||||
function consumeRealtimeMessageNotificationSuppression(data, accountId) {
|
||||
const now = Date.now();
|
||||
for (const [key, expiresAt] of state.realtimeNotificationSuppressedMessages) {
|
||||
if (Number(expiresAt) <= now) state.realtimeNotificationSuppressedMessages.delete(key);
|
||||
}
|
||||
const uid = Number(data?.latest_uid);
|
||||
if (!Number.isFinite(uid) || uid <= 0) return false;
|
||||
const key = realtimeSuppressedMessageKey(data?.folder, uid, accountId);
|
||||
if (!state.realtimeNotificationSuppressedMessages.has(key)) return false;
|
||||
state.realtimeNotificationSuppressedMessages.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
function suppressRealtimeNotificationsForFolder(folder, accountId = state.accountId) {
|
||||
if (!folder) return () => { };
|
||||
const key = realtimeNotificationSuppressionKey(folder, accountId);
|
||||
@@ -294,6 +320,7 @@ function realtimeFolderIsDrafts(folderName) {
|
||||
function realtimeNewMailNotificationsSuppressed(data, accountId) {
|
||||
if (!state.realtimeNotificationsReady) return true;
|
||||
if (realtimeFolderIsDrafts(data?.folder)) return true;
|
||||
if (consumeRealtimeMessageNotificationSuppression(data, accountId)) return true;
|
||||
return (state.realtimeNotificationSuppressions.get(realtimeNotificationSuppressionKey(data?.folder, accountId)) || 0) > 0;
|
||||
}
|
||||
|
||||
@@ -317,6 +344,7 @@ function resetRealtimeNotificationGate() {
|
||||
state.realtimeNotificationsReady = false;
|
||||
state.realtimeNotificationsArmRequested = false;
|
||||
state.realtimeNotificationSuppressions.clear();
|
||||
state.realtimeNotificationSuppressedMessages.clear();
|
||||
state.realtimeNotifiedMessages.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
|
||||
function bindEditor() {
|
||||
const composeEditor = $("#compose-editor");
|
||||
composeEditor?.addEventListener("keydown", (event) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const command = key === "z" ? (event.shiftKey ? "redo" : "undo") : (key === "y" && !event.shiftKey ? "redo" : null);
|
||||
if (!command) return;
|
||||
if (typeof document.execCommand !== "function") return;
|
||||
event.preventDefault();
|
||||
composeEditor.focus();
|
||||
document.execCommand(command, false, null);
|
||||
scheduleDraftSave();
|
||||
scheduleUnusedInlineAttachmentCleanup();
|
||||
updateComposeMessageSize();
|
||||
});
|
||||
$$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => {
|
||||
$("#compose-editor").focus();
|
||||
document.execCommand(button.dataset.command, false, button.dataset.commandValue || null);
|
||||
|
||||
Reference in new issue
Block a user