diff --git a/src/app/AppConfig.ts b/src/app/AppConfig.ts index 3839b44f4..0ba99140f 100644 --- a/src/app/AppConfig.ts +++ b/src/app/AppConfig.ts @@ -11,6 +11,13 @@ import { isMac } from './system.utils.ts' const APP_CONFIG_FILE_NAME = 'config.json' +type WindowBounds = { + x: number + y: number + width: number + height: number +} + // Memoize the path to the application config file let APP_CONFIG_FILE_PATH: string | null = null @@ -41,6 +48,11 @@ export type AppConfig = { */ lastAppVersion?: string + /** + * Last position and size of the main Talk window. + */ + talkWindowBounds?: WindowBounds + /** * List of the accounts in the app in the {user}@{server} or {server} format, * E.g. nextcloud.tld, alice@nextcloud.tld, bob@company.tld@company.tld/nextcloud. diff --git a/src/talk/talk.window.js b/src/talk/talk.window.js index a514cb434..1d5927c21 100644 --- a/src/talk/talk.window.js +++ b/src/talk/talk.window.js @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -const { BrowserWindow } = require('electron') +const { BrowserWindow, screen } = require('electron') const { setupTray } = require('../app/app.tray.js') -const { getAppConfig } = require('../app/AppConfig.ts') +const { getAppConfig, setAppConfig } = require('../app/AppConfig.ts') const { applyContextMenu } = require('../app/applyContextMenu.js') const { applyDownloadHandler } = require('../app/downloads.ts') const { applyExternalLinkHandler } = require('../app/externalLinkHandlers.ts') @@ -15,18 +15,115 @@ const { TITLE_BAR_HEIGHT } = require('../constants.js') const { BUILD_CONFIG } = require('../shared/build.config.ts') const { getBrowserWindowIcon } = require('../shared/icons.utils.js') +const SAVE_WINDOW_BOUNDS_DELAY = 500 + +/** + * Check whether a value can be used as Electron window bounds. + * + * @param {unknown} bounds - Candidate window bounds + * @return {boolean} Whether the value is a valid window bounds object + */ +function isValidWindowBounds(bounds) { + return typeof bounds === 'object' + && bounds !== null + && Number.isFinite(bounds.x) + && Number.isFinite(bounds.y) + && Number.isFinite(bounds.width) + && Number.isFinite(bounds.height) + && bounds.width > 0 + && bounds.height > 0 +} + +/** + * Check whether saved bounds still overlap with an available display. + * + * @param {import('electron').Rectangle} bounds - Window bounds to check + * @return {boolean} Whether the bounds are visible on any display + */ +function isVisibleOnAnyDisplay(bounds) { + return screen.getAllDisplays().some(({ workArea }) => ( + bounds.x < workArea.x + workArea.width + && bounds.x + bounds.width > workArea.x + && bounds.y < workArea.y + workArea.height + && bounds.y + bounds.height > workArea.y + )) +} + +/** + * Get saved Talk window bounds if they are still usable. + * + * @param {{ minWidth: number, minHeight: number }} minimumSize - Minimum Talk window size + * @return {import('electron').Rectangle | undefined} Restorable bounds, if available + */ +function getSavedTalkWindowBounds(minimumSize) { + const bounds = getAppConfig('talkWindowBounds') + if (!isValidWindowBounds(bounds)) { + return undefined + } + + const savedBounds = { + ...bounds, + width: Math.max(bounds.width, minimumSize.minWidth), + height: Math.max(bounds.height, minimumSize.minHeight), + } + + return isVisibleOnAnyDisplay(savedBounds) ? savedBounds : undefined +} + +/** + * Persist Talk window bounds as they change. + * + * @param {import('electron').BrowserWindow} window - Talk window to persist + */ +function applyTalkWindowBoundsPersistence(window) { + let saveWindowBoundsTimeout + + /** + * Save the current normal bounds immediately. + */ + function saveWindowBounds() { + clearTimeout(saveWindowBoundsTimeout) + if (window.isDestroyed()) { + return + } + + setAppConfig('talkWindowBounds', window.getNormalBounds()) + } + + /** + * Debounce repeated move and resize events. + */ + function scheduleSaveWindowBounds() { + clearTimeout(saveWindowBoundsTimeout) + saveWindowBoundsTimeout = setTimeout(saveWindowBounds, SAVE_WINDOW_BOUNDS_DELAY) + } + + window.on('move', scheduleSaveWindowBounds) + window.on('moved', scheduleSaveWindowBounds) + window.on('resize', scheduleSaveWindowBounds) + window.on('resized', scheduleSaveWindowBounds) + window.on('close', saveWindowBounds) + window.on('closed', () => clearTimeout(saveWindowBoundsTimeout)) +} + /** * @return {import('electron').BrowserWindow} */ function createTalkWindow() { const zoomFactor = getAppConfig('zoomFactor') + const minimumSize = getScaledWindowMinSize({ + minWidth: 600, + minHeight: 400, + }) + const defaultSize = getScaledWindowSize({ + width: 1400, + height: 900, + }) + const savedBounds = getSavedTalkWindowBounds(minimumSize) const talkWindowOptions = { title: buildTitle(), - ...getScaledWindowMinSize({ - minWidth: 600, - minHeight: 400, - }), + ...minimumSize, backgroundColor: BUILD_CONFIG.backgroundColor, autoHideMenuBar: true, webPreferences: { @@ -49,10 +146,8 @@ function createTalkWindow() { const window = new BrowserWindow({ ...talkWindowOptions, - ...getScaledWindowSize({ - width: 1400, - height: 900, - }), + ...defaultSize, + ...savedBounds, show: false, }) @@ -75,6 +170,7 @@ function createTalkWindow() { applyDownloadHandler(window) applyWheelZoom(window) applyZoom(window) + applyTalkWindowBoundsPersistence(window) setupTray(window)