diff --git a/app/common/config-schemata.ts b/app/common/config-schemata.ts index e2db7b80f..ac172c9c3 100644 --- a/app/common/config-schemata.ts +++ b/app/common/config-schemata.ts @@ -8,6 +8,7 @@ export const dndSettingsSchemata = { export const configSchemata = { ...dndSettingsSchemata, + dndExpiration: z.number().nullable().default(null), appLanguage: z.string().nullable(), autoHideMenubar: z.boolean(), autoUpdate: z.boolean(), diff --git a/app/common/typed-ipc.ts b/app/common/typed-ipc.ts index 269f42984..0700b8eec 100644 --- a/app/common/typed-ipc.ts +++ b/app/common/typed-ipc.ts @@ -69,7 +69,12 @@ export type RendererMessage = { autoHideMenubar: boolean, updateMenu: boolean, ) => void; - "toggle-dnd": (state: boolean, newSettings: Partial) => void; + "toggle-dnd-request": (duration?: number) => void; + "toggle-dnd": ( + state: boolean, + newSettings: Partial, + duration?: number, + ) => void; "toggle-sidebar": (show: boolean) => void; "toggle-silent": (state: boolean) => void; "toggle-tray": (state: boolean) => void; diff --git a/app/main/index.ts b/app/main/index.ts index e04c59936..03373ff32 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -18,6 +18,7 @@ import * as remoteMain from "@electron/remote/main"; import windowStateKeeper from "electron-window-state"; import * as ConfigUtil from "../common/config-util.js"; +import * as DNDUtil from "../common/dnd-util.js"; import {bundlePath, bundleUrl, publicPath} from "../common/paths.js"; import * as t from "../common/translation-util.js"; import type {RendererMessage} from "../common/typed-ipc.js"; @@ -48,6 +49,7 @@ let badgeCount: number; let isQuitting = false; +let dndRevertTimeout: NodeJS.Timeout | null = null; // Load this file in main window const mainUrl = new URL("app/renderer/main.html", bundleUrl).href; @@ -68,6 +70,23 @@ const toggleApp = (): void => { } }; +function checkDndExpirationOnStartup(page: WebContents): void { + const expiration = ConfigUtil.getConfigItem("dndExpiration", null); + + if (expiration && Date.now() > expiration) { + const revert = DNDUtil.toggle(); + send(page, "toggle-dnd", revert.dnd, revert.newSettings); + ConfigUtil.removeConfigItem("dndExpiration"); + } else if (expiration) { + const timeLeft = expiration - Date.now(); + dndRevertTimeout = setTimeout(() => { + const revert = DNDUtil.toggle(); + send(page, "toggle-dnd", revert.dnd, revert.newSettings); + ConfigUtil.removeConfigItem("dndExpiration"); + }, timeLeft); + } +} + function createMainWindow(): BrowserWindow { // Load the previous state with fallback to defaults mainWindowState = windowStateKeeper({ @@ -270,7 +289,7 @@ function createMainWindow(): BrowserWindow { } const page = mainWindow.webContents; - + checkDndExpirationOnStartup(page); page.on("dom-ready", () => { if (ConfigUtil.getConfigItem("startMinimized", false)) { mainWindow.hide(); @@ -407,6 +426,34 @@ function createMainWindow(): BrowserWindow { listener: Channel, ...parameters: Parameters ) => { + if (listener === "toggle-dnd-request") { + const [duration] = parameters as [number?]; + const result = DNDUtil.toggle(); + send(_event.sender, "toggle-dnd", result.dnd, result.newSettings); + + if (result.dnd && duration && !Number.isNaN(duration)) { + if (dndRevertTimeout) clearTimeout(dndRevertTimeout); + + const expirationTime = Date.now() + duration * 60 * 1000; + ConfigUtil.setConfigItem("dndExpiration", expirationTime); + + dndRevertTimeout = setTimeout( + () => { + const revert = DNDUtil.toggle(); + send(_event.sender, "toggle-dnd", revert.dnd, revert.newSettings); + ConfigUtil.removeConfigItem("dndExpiration"); + }, + duration * 60 * 1000, + ); + } else if (dndRevertTimeout) { + clearTimeout(dndRevertTimeout); + dndRevertTimeout = null; + ConfigUtil.removeConfigItem("dndExpiration"); + } + + return; + } + send(page, listener, ...parameters); }, ); diff --git a/app/renderer/css/main.css b/app/renderer/css/main.css index a796921f8..a117dcebb 100644 --- a/app/renderer/css/main.css +++ b/app/renderer/css/main.css @@ -386,6 +386,31 @@ webview.focus { font-size: 14px; } +.dnd-dropdown { + position: absolute; + left: 60px; + background-color: rgb(32 33 36 / 100%); /* closer to Zulip's sidebars */ + border: 1px solid rgb(60 60 60 / 100%); + border-radius: 8px; + color: rgb(240 240 240 / 100%); + z-index: 1000; + padding: 4px 0; + font-size: 13px; + font-family: arial, sans-serif; + min-width: 150px; + box-shadow: 0 4px 12px rgb(0 0 0 / 40%); +} + +.dnd-dropdown-item { + padding: 8px 16px; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.dnd-dropdown-item:hover { + background-color: rgb(60 60 60 / 100%); +} + #loading-tooltip::after, #dnd-tooltip::after, #back-tooltip::after, diff --git a/app/renderer/js/main.ts b/app/renderer/js/main.ts index 861074738..66d4961f3 100644 --- a/app/renderer/js/main.ts +++ b/app/renderer/js/main.ts @@ -9,7 +9,6 @@ import * as Sentry from "@sentry/electron/renderer"; import type {Config} from "../../common/config-util.js"; import * as ConfigUtil from "../../common/config-util.js"; -import * as DNDUtil from "../../common/dnd-util.js"; import type {DndSettings} from "../../common/dnd-util.js"; import * as EnterpriseUtil from "../../common/enterprise-util.js"; import {html} from "../../common/html.js"; @@ -444,14 +443,34 @@ export class ServerManagerView { initLeftSidebarEvents(): void { this.$dndButton.addEventListener("click", () => { - const dndUtil = DNDUtil.toggle(); - ipcRenderer.send( - "forward-message", - "toggle-dnd", - dndUtil.dnd, - dndUtil.newSettings, - ); + const isDndOn = ConfigUtil.getConfigItem("dnd", false); + if (isDndOn) { + ipcRenderer.send("forward-message", "toggle-dnd-request", undefined); + return; + } + + const dropdown = document.querySelector("#dnd-dropdown"); + dropdown?.classList.toggle("hidden"); + this.$dndTooltip.classList.add("hidden"); + dropdown?.addEventListener("mouseleave", () => { + dropdown.classList.add("hidden"); + this.$dndTooltip.classList.remove("hidden"); + }); }); + const dropdownItems = document.querySelectorAll("#dnd-dropdown div"); + for (const item of dropdownItems) { + item.addEventListener("click", (event) => { + const target = event.target as HTMLElement; + const value = target.dataset.minutes; + const duration = value === "forever" ? undefined : Number(value); + + ipcRenderer.send("forward-message", "toggle-dnd-request", duration); + + document.querySelector("#dnd-dropdown")?.classList.add("hidden"); + this.$dndTooltip.classList.remove("hidden"); + }); + } + this.$reloadButton.addEventListener("click", async () => { const tab = this.tabs[this.activeTabIndex]; if (tab instanceof ServerTab) (await tab.webview).reload(); @@ -1215,6 +1234,23 @@ window.addEventListener("load", async () => { >${t.__("Do Not Disturb")} +