1 parent
648556f762
commit
99ee3385ac
69 files changed
+1423
-1365
No files matched your search
@@ -127,8 +127,7 @@ html[data-font-family="mono"] {
|
||||
--font-sans: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
/* Not derived from var(--line): a custom property referring to itself is a
|
||||
cycle, which made --line empty (no borders) wherever no palette overrode it. */
|
||||
/* Not var(--line) itself: a self-reference is invalid CSS. */
|
||||
html[data-contrast="soft"] {
|
||||
--line: color-mix(in srgb, var(--text) 11%, var(--panel));
|
||||
--line-strong: color-mix(in srgb, var(--text) 20%, var(--panel));
|
||||
|
||||
@@ -1416,8 +1416,7 @@ body.reader-fullscreen-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* Optional wide-pane single-line message rows. The preference is on by default,
|
||||
but the layout only activates once the message pane has enough horizontal room. */
|
||||
/* Single-line rows when the message pane is wide enough. */
|
||||
@container message-pane (min-width: 520px) {
|
||||
html[data-compact-wide-rows="true"] .message-row {
|
||||
min-height: min(var(--row-height), 36px);
|
||||
|
||||
@@ -192,9 +192,74 @@
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.about-list code {
|
||||
font: 10px var(--font-mono);
|
||||
overflow-wrap: anywhere;
|
||||
.about-list a,
|
||||
.about-card a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.about-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.about-hero h2 {
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
|
||||
.about-hero p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.about-logo {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 9px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--panel));
|
||||
color: var(--accent);
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.about-version {
|
||||
margin-left: 6px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.about-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.about-card p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.about-origin {
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.about-copyright {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
@@ -2635,8 +2700,7 @@ kbd {
|
||||
}
|
||||
|
||||
|
||||
/* 0.2.24: theme schedule, account colour, contrast hint, list skeleton and
|
||||
a short cross-fade when the theme or interface style changes. */
|
||||
/* Theme schedule, account colour, contrast hint, list skeleton, theme cross-fade. */
|
||||
.theme-schedule-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -2684,8 +2748,7 @@ kbd {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
padding: 6px 9px;
|
||||
/* Fixed colours on purpose: the warning must stay readable precisely when
|
||||
the user's own text/panel colours are not. */
|
||||
/* Fixed colours: must stay readable with any custom palette. */
|
||||
border: 1px solid #d9a93a;
|
||||
border-radius: var(--radius-control);
|
||||
background: #fff4d6;
|
||||
|
||||
@@ -1096,7 +1096,7 @@ html[data-interface-skin="notion"][data-theme="dark"] {
|
||||
}
|
||||
|
||||
|
||||
/* 0.2.23 predefined styles: light and dark palettes. */
|
||||
/* Editor and atmospheric styles: light and dark palettes. */
|
||||
html[data-interface-skin="dracula"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f4f1e6;
|
||||
@@ -1855,7 +1855,7 @@ html[data-interface-skin="retro"][data-theme="dark"] {
|
||||
|
||||
|
||||
|
||||
/* 0.2.24 predefined styles: light and dark palettes. */
|
||||
/* Retro styles: light and dark palettes. */
|
||||
html[data-interface-skin="winxp"][data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #ece9d8;
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
/*
|
||||
* RustBox interface-style standard (0.2.23).
|
||||
*
|
||||
* Every predefined style ships three layers:
|
||||
* 1. light and dark palettes (56-interface-styles.css / 55-roundcube-skins.css);
|
||||
* 2. a fixed layout profile (interfaceStyleProfiles in 20-preferences-auth.js);
|
||||
* 3. the structural tokens below, consumed only when the user selects
|
||||
* "Immersive" style depth (58-skin-immersive.css).
|
||||
*
|
||||
* Tokens here are always flat. Gradients live in 59-skin-gradients.css and
|
||||
* only apply with the separate Appearance switch (skin_gradients).
|
||||
*
|
||||
* Generated from one table; keep every style listed here.
|
||||
*/
|
||||
/* Structural tokens per style, used at "Immersive" depth (58-skin-immersive.css).
|
||||
Palettes: 55/56; layout profiles: 20-preferences-auth.js; gradients: 59. */
|
||||
|
||||
:root {
|
||||
--skin-font: var(--font-sans);
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
/*
|
||||
* "Immersive" style depth: applies the structural tokens from 57-skin-tokens.css.
|
||||
*
|
||||
* Every rule is scoped to :root[data-skin-depth="immersive"][data-theme] so it
|
||||
* outranks the per-style chrome rules (html[data-interface-skin=…][data-theme=…] …)
|
||||
* without !important, and has no effect at the "colors" or "standard" depth.
|
||||
* Inline custom properties written by applyPreferences() live on <html>, so the
|
||||
* shared radius/font variables are re-pointed on <body> where they inherit down.
|
||||
*/
|
||||
/* "Immersive" depth: applies the tokens from 57-skin-tokens.css. */
|
||||
|
||||
:root[data-skin-depth="immersive"][data-theme] body {
|
||||
--font-sans: var(--skin-font);
|
||||
@@ -100,8 +92,7 @@
|
||||
backdrop-filter: var(--skin-glass);
|
||||
}
|
||||
|
||||
/* Floating panes only where the three-pane desktop layout is shown; the
|
||||
phone/tablet drawer layout in 60-responsive.css stays untouched. */
|
||||
/* Floating panes only in the desktop three-pane layout. */
|
||||
@media (min-width: 821px) {
|
||||
:root[data-skin-depth="immersive"][data-theme] .mail-shell {
|
||||
gap: var(--skin-shell-gap);
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/*
|
||||
* Style gradients (0.2.24): generated from one table.
|
||||
*
|
||||
* Active only with Appearance -> "Style gradients" on, at every style depth,
|
||||
* for every predefined style except the RustBox custom style. Gradients are
|
||||
* background-image layers over the colours chosen by the palette and depth;
|
||||
* the defaults are translucent overlays, signature styles override them.
|
||||
*/
|
||||
/* Style gradients: only with the "Style gradients" switch on; not for the RustBox style. */
|
||||
|
||||
:root[data-skin-gradients="true"] {
|
||||
--grad-backdrop: radial-gradient(1100px 560px at 92% -12%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 62%);
|
||||
|
||||
@@ -350,11 +350,6 @@
|
||||
"Active sessions": "Aktywne sesje",
|
||||
"Live RustBox sessions for this account, including other browsers while the server is running.": "Aktywne sesje RustBox dla tego konta, także w innych przeglądarkach, gdy serwer działa.",
|
||||
"Revoke other sessions": "Unieważnij pozostałe sesje",
|
||||
"Webmail based on the Oxi codebase, with a RustBox-owned interface compiled into the Rust executable.": "Webmail oparty na kodzie Oxi, z interfejsem RustBox kompilowanym bezpośrednio do programu Rust.",
|
||||
"Upstream": "Projekt bazowy",
|
||||
"Oxi base": "Baza Oxi",
|
||||
"Frontend": "Frontend",
|
||||
"HTML, CSS and vanilla JavaScript embedded at Cargo build time": "HTML, CSS i czysty JavaScript osadzane podczas kompilacji Cargo",
|
||||
"Author": "Autor",
|
||||
"Keyboard shortcuts": "Skróty klawiaturowe",
|
||||
"Identity & signature": "Tożsamość i podpis",
|
||||
@@ -788,7 +783,15 @@
|
||||
"because the copied colours belong to it.": "bo skopiowane kolory należą do niego.",
|
||||
"Shapes, fonts, app bar and gradients specific to the style are not part of the copy.": "Kształty, czcionki, pasek aplikacji i gradienty charakterystyczne dla stylu nie wchodzą do kopii.",
|
||||
"Create a custom copy?": "Utworzyć własną kopię?",
|
||||
"Create custom copy": "Utwórz własną kopię"
|
||||
"Create custom copy": "Utwórz własną kopię",
|
||||
"Fast, self-hosted webmail written in Rust.": "Szybki webmail do samodzielnego hostowania, napisany w Rust.",
|
||||
"License": "Licencja",
|
||||
"Technology": "Technologia",
|
||||
"GNU AGPL v3 or commercial license": "GNU AGPL v3 lub licencja komercyjna",
|
||||
"Rust server with an embedded HTML, CSS and JavaScript interface": "Serwer w Rust z wbudowanym interfejsem HTML, CSS i JavaScript",
|
||||
"RustBox is free software under the GNU Affero General Public License v3: you may use, modify and share it, also in a company. If you run a modified version as a network service, you must offer its source code to its users.": "RustBox jest wolnym oprogramowaniem na licencji GNU Affero General Public License v3: możesz go używać, zmieniać i udostępniać, także w firmie. Jeśli uruchamiasz zmodyfikowaną wersję jako usługę sieciową, musisz udostępnić jej kod źródłowy jej użytkownikom.",
|
||||
"Companies that prefer different terms — for example to keep their changes private — can obtain a paid commercial license. Contacting the author is voluntary: it is never required for use that follows the AGPL.": "Firmy, które wolą inne warunki — na przykład zachować swoje zmiany dla siebie — mogą uzyskać płatną licencję komercyjną. Kontakt z autorem jest dobrowolny: nie jest wymagany przy korzystaniu zgodnym z AGPL.",
|
||||
"The first version of RustBox was created as a fork of the Oxi project (MIT License).": "Pierwsza wersja RustBox powstała jako fork projektu Oxi (licencja MIT)."
|
||||
},
|
||||
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję poniżej, aby dopasować jego układ i typografię.",
|
||||
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
|
||||
|
||||
+28
-9
@@ -1307,21 +1307,40 @@
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-panel hidden" data-panel="about">
|
||||
<h2>RustBox __RUSTBOX_VERSION__</h2>
|
||||
<p>Webmail based on the Oxi codebase, with a RustBox-owned interface compiled into the Rust executable.</p>
|
||||
<div class="about-hero">
|
||||
<svg class="about-logo" aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-mail"></use>
|
||||
</svg>
|
||||
<div>
|
||||
<h2>RustBox <span class="about-version">__RUSTBOX_VERSION__</span></h2>
|
||||
<p class="muted">Fast, self-hosted webmail written in Rust.</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="about-list">
|
||||
<dt>Upstream</dt>
|
||||
<dd>c0h1b4/oxi</dd>
|
||||
<dt>Oxi base</dt>
|
||||
<dd><code>__RUSTBOX_OXI_BASE__</code></dd>
|
||||
<dt>Frontend</dt>
|
||||
<dd>HTML, CSS and vanilla JavaScript embedded at Cargo build time</dd>
|
||||
<dt>Author</dt>
|
||||
<dd>@linuxiarz.pl</dd>
|
||||
<dd>Mateusz Gruszczyński · <a href="https://linuxiarz.pl" target="_blank"
|
||||
rel="noopener noreferrer">linuxiarz.pl</a></dd>
|
||||
<dt>Repository</dt>
|
||||
<dd><a href="https://git.linuxiarz.pl/gru/RustBox" target="_blank"
|
||||
rel="noopener noreferrer">git.linuxiarz.pl/gru/RustBox</a></dd>
|
||||
<dt>License</dt>
|
||||
<dd>GNU AGPL v3 or commercial license</dd>
|
||||
<dt>Technology</dt>
|
||||
<dd>Rust server with an embedded HTML, CSS and JavaScript interface</dd>
|
||||
</dl>
|
||||
<div class="about-card">
|
||||
<strong>License</strong>
|
||||
<p>RustBox is free software under the GNU Affero General Public License v3: you may use, modify and
|
||||
share it, also in a company. If you run a modified version as a network service, you must offer its
|
||||
source code to its users.</p>
|
||||
<p>Companies that prefer different terms — for example to keep their changes private — can obtain a
|
||||
paid commercial license. Contacting the author is voluntary: it is never required for use that
|
||||
follows the AGPL.</p>
|
||||
<p><a href="mailto:linuxiarz@linuxiarz.pl">linuxiarz@linuxiarz.pl</a></p>
|
||||
</div>
|
||||
<p class="about-origin muted">The first version of RustBox was created as a fork of the Oxi project (MIT
|
||||
License).</p>
|
||||
<p class="about-copyright muted">© 2026 Mateusz Gruszczyński</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,9 +231,7 @@ function resolvedSkinDepth(prefs = state.prefs) {
|
||||
return skinDepths.includes(prefs.skinDepth) ? prefs.skinDepth : "standard";
|
||||
}
|
||||
|
||||
// "colors" keeps only a predefined style's palette; "standard" and "immersive"
|
||||
// also apply its fixed layout profile ("immersive" adds the structural tokens
|
||||
// from 57-skin-tokens.css on top, purely in CSS).
|
||||
// Style depth: "colors" = palette only; "standard" adds the layout profile; "immersive" adds the structural tokens.
|
||||
function layoutInterfaceStyle(prefs = state.prefs) {
|
||||
return resolvedSkinDepth(prefs) === "colors" ? null : activeInterfaceStyle(prefs);
|
||||
}
|
||||
@@ -310,9 +308,7 @@ function applyPreferences() {
|
||||
const fixedStyle = layoutInterfaceStyle(p);
|
||||
const skinDepth = resolvedSkinDepth(p);
|
||||
const previousLook = `${root.dataset.theme}|${root.dataset.interfaceSkin}|${root.dataset.skinDepth}|${root.dataset.skinGradients}`;
|
||||
// A predefined style fixes the layout unless editing is allowed; then the
|
||||
// user's own values (seeded from the style when editing was enabled) win,
|
||||
// while the style's palette, tokens and gradients stay in place.
|
||||
// The style fixes the layout unless editing is allowed.
|
||||
const visual = fixedStyle && !p.presetScaleUnlocked
|
||||
? { ...p, ...fixedStyle.preferences }
|
||||
: p;
|
||||
@@ -487,9 +483,7 @@ function contrastRatio(first, second) {
|
||||
return (a + 0.05) / (b + 0.05);
|
||||
}
|
||||
|
||||
// Warns while the user's own colours make text hard to read (WCAG AA, 4.5:1
|
||||
// for text, 3:1 for the accent against panels). Predefined palettes are
|
||||
// checked at release time, so the hint is only shown for custom colours.
|
||||
// Live WCAG contrast hint for custom colours.
|
||||
function updateContrastWarning(usingPredefinedPalette) {
|
||||
const warning = $("#pref-contrast-warning");
|
||||
if (!warning) return;
|
||||
@@ -535,6 +529,7 @@ async function loadPreferences() {
|
||||
catch (error) { console.warn("Could not load display preferences", error); }
|
||||
applyPreferences();
|
||||
await loadTranslations(state.prefs.language);
|
||||
applyPreferences();
|
||||
}
|
||||
|
||||
async function loadMailPreferences() {
|
||||
@@ -724,9 +719,7 @@ function schedulePromotedAppearanceSave() {
|
||||
|
||||
const presetLayoutPreferenceKeys = [...presetEditablePreferenceKeys].filter((key) => !presetColorPreferenceKeys.has(key));
|
||||
|
||||
// "Allow editing predefined style": seed the user's own layout values from the
|
||||
// style, so editing starts from exactly what is on screen and never mixes in
|
||||
// values saved earlier for another style.
|
||||
// Seeds the user's layout from the style so editing starts from what is on screen.
|
||||
async function setPresetEditing(enabled) {
|
||||
const layoutStyle = layoutInterfaceStyle(state.prefs);
|
||||
const payload = { preset_scale_unlocked: enabled };
|
||||
@@ -746,10 +739,7 @@ async function setPresetEditing(enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
// Colours of a predefined style come from its light and dark palettes and
|
||||
// cannot be overridden one by one without mixing two palettes. Changing one
|
||||
// therefore turns the style into a RustBox custom copy of the palette that is
|
||||
// on screen now, pinned to the current light/dark mode so it stays coherent.
|
||||
// A colour change on a predefined style creates a RustBox custom copy of the current palette.
|
||||
async function promotePresetToCustom(key, value) {
|
||||
const paletteStyle = activeInterfaceStyle(state.prefs);
|
||||
if (!paletteStyle || !presetColorPreferenceKeys.has(key) || !state.prefs.presetScaleUnlocked) return false;
|
||||
@@ -815,7 +805,7 @@ function savePreference(key, value) {
|
||||
}
|
||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
||||
applyPreferences();
|
||||
if (key === "language") loadTranslations(value);
|
||||
if (key === "language") loadTranslations(value).then(applyPreferences);
|
||||
const previousTimer = preferenceTimers.get(key);
|
||||
if (previousTimer) clearTimeout(previousTimer);
|
||||
const timer = setTimeout(async () => {
|
||||
|
||||
@@ -11,12 +11,31 @@ function folderIconName(folder) {
|
||||
return "folder";
|
||||
}
|
||||
|
||||
function folderPathParts(folder) {
|
||||
const delimiter = folder.delimiter || "/";
|
||||
return delimiter ? folder.name.split(delimiter) : [folder.name];
|
||||
}
|
||||
|
||||
// Tree order: children under their parent, INBOX first, siblings alphabetical.
|
||||
function compareFolderPaths(a, b) {
|
||||
const left = folderPathParts(a);
|
||||
const right = folderPathParts(b);
|
||||
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
||||
if (left[index] === undefined) return -1;
|
||||
if (right[index] === undefined) return 1;
|
||||
if (left[index] === right[index]) continue;
|
||||
if (index === 0) {
|
||||
const leftInbox = left[0].toUpperCase() === "INBOX";
|
||||
const rightInbox = right[0].toUpperCase() === "INBOX";
|
||||
if (leftInbox !== rightInbox) return leftInbox ? -1 : 1;
|
||||
}
|
||||
return left[index].localeCompare(right[index]) || (left[index] < right[index] ? -1 : 1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
return [...state.folders].sort(compareFolderPaths);
|
||||
}
|
||||
|
||||
function folderDepth(folder) {
|
||||
|
||||
@@ -312,11 +312,7 @@ async function quickUpdateMessageFlag(message, flag, add, successMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
// Hides rows for messages that are currently snoozed in this folder. This is
|
||||
// a purely visual, best-effort filter on top of the already-rendered list:
|
||||
// it never changes state.messages/state.total, so pagination/counts/caching
|
||||
// above are completely unaffected. A message simply stops being hidden once
|
||||
// its wake time passes and the folder is reloaded or reopened.
|
||||
// Hides rows of snoozed messages (visual only; counts and paging unchanged).
|
||||
async function applySnoozeHiding() {
|
||||
if (state.searchContext) return;
|
||||
const folder = state.folder;
|
||||
@@ -508,9 +504,7 @@ function readerFullscreenUrl(detail = state.currentDetail) {
|
||||
return new URL(location.href);
|
||||
}
|
||||
|
||||
// Gmail-style modifier clicks on a message row, active only outside selection
|
||||
// mode (inside selection mode the same modifiers keep doing range/toggle
|
||||
// selection, see the row click handler below).
|
||||
// Ctrl/Cmd/Shift-click on a row outside selection mode.
|
||||
function openMessageInNewTab(folder, uid) {
|
||||
window.open(messageDeepLinkUrl(folder, uid), "_blank", "noopener");
|
||||
}
|
||||
|
||||
@@ -1788,10 +1788,7 @@ function buildReferences(detail) {
|
||||
return values.length ? values.join(" ") : null;
|
||||
}
|
||||
|
||||
// "Undo Send": the server held the just-sent message back for a few seconds
|
||||
// instead of dispatching it immediately. Show a toast with an Undo action
|
||||
// for the whole window, then poll once for the final result so a send that
|
||||
// actually failed in the background is still reported.
|
||||
// Undo Send: offer Undo during the hold window, then report the final result.
|
||||
function notifyUndoSendStaged(pendingId, windowSecs) {
|
||||
const seconds = Math.max(0, windowSecs);
|
||||
toast("Sending\u2026", "info", seconds * 1000 || 4000, {
|
||||
|
||||
@@ -181,9 +181,7 @@ async function copyCurrentTo(targetFolder) {
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
// Snoozing hides a message from its folder listing until a chosen wake time.
|
||||
// It is purely local client/server cache state: nothing moves on the real
|
||||
// IMAP server, so a snooze never risks the actual mailbox.
|
||||
// Snooze hides a message until its wake time (local only, nothing moves on IMAP).
|
||||
function snoozePresetOptions() {
|
||||
const now = new Date();
|
||||
const laterToday = new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||
|
||||
@@ -409,9 +409,7 @@ function calendarReminderStorageKey(event, minutes) {
|
||||
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}.${minutes}`;
|
||||
}
|
||||
|
||||
// Timed events: start minus the reminder. All-day events: the reminder counts
|
||||
// from the start of the working day (calendar settings, default 09:00) of the
|
||||
// event's date, so "1 day before" fires the previous morning, not at midnight.
|
||||
// All-day events are reminded relative to the start of the working day.
|
||||
function calendarReminderTrigger(event, minutes) {
|
||||
const offset = Math.max(0, Number(minutes) || 0) * 60000;
|
||||
if (event.all_day) {
|
||||
@@ -476,10 +474,7 @@ function isDuplicateRealtimeEvent(payload) {
|
||||
const now = Date.now();
|
||||
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
|
||||
|
||||
// Server websocket envelopes carry a unique eventId. When present it is the
|
||||
// only safe deduplication key: state can legitimately cycle A -> B -> A in
|
||||
// under a second (for example unread 1 -> 0 -> 1). Fingerprint-based
|
||||
// deduplication would incorrectly drop the second A and leave stale UI.
|
||||
// Deduplicate by eventId when present: the same state can legitimately repeat.
|
||||
if (payload.eventId) {
|
||||
const idKey = `id:${payload.eventId}`;
|
||||
const duplicate = state.realtimeSeenEvents.has(idKey);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// Filter conveniences on top of the visual Sieve editor: templates, creating
|
||||
// missing target folders, and "Apply now" — running a rule's actions on mail
|
||||
// that is already in a folder (Sieve itself only sees newly delivered mail).
|
||||
// Filter templates, missing-folder creation and "Apply now" for existing mail.
|
||||
|
||||
function fmtText(template, values = {}) {
|
||||
return trText(template).replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ""));
|
||||
|
||||
@@ -507,9 +507,7 @@ function bindEvents() {
|
||||
$$("[data-signature-command]").forEach((button) => button.addEventListener("click", () => {
|
||||
const range = selectedSignatureRange();
|
||||
if (!range) {
|
||||
// execCommand on a collapsed caret enables a sticky typing style (for
|
||||
// example every subsequently typed character becomes bold). Signature
|
||||
// formatting is intentionally selection-only.
|
||||
// Formatting applies to the selection only (a collapsed caret would make it sticky).
|
||||
signatureEditor.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user