From 368c958e70e01a5fe51b613abcc66f31fc423410 Mon Sep 17 00:00:00 2001 From: Kristian Zendato Date: Thu, 30 Jul 2026 18:56:30 +0800 Subject: [PATCH 1/2] feat: toast timeout configuration Signed-off-by: Kristian Zendato --- README.md | 15 +++++++++++++++ lib/index.ts | 2 ++ lib/toast.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 09487302d..f5095935a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,21 @@ There are several options that can be passed in as a second parameter, like the showError('This is an error shown without a timeout', { timeout: -1 }) ``` +You can also configure the timeout used by ordinary toasts (success, error, warning, info, and bare messages). +This is stored on `window`, so separately bundled copies of the library share the same value: + +``` +import { setToastTimeout, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' + +// Keep toasts visible for 30 seconds +setToastTimeout(30000) + +// Or never auto-dismiss ordinary toasts +setToastTimeout(TOAST_PERMANENT_TIMEOUT) +``` + +Loading toasts stay permanent until hidden manually, and undo toasts keep their fixed undo duration. + A full list of available options can be found in the [documentation](https://nextcloud-libraries.github.io/nextcloud-dialogs/). ### FilePicker diff --git a/lib/index.ts b/lib/index.ts index df5bb346d..6a2129c7a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -23,6 +23,8 @@ export { } from './public-auth.ts' export { + getToastTimeout, + setToastTimeout, showError, showInfo, showLoading, diff --git a/lib/toast.ts b/lib/toast.ts index fead2c23a..89e36fd7d 100644 --- a/lib/toast.ts +++ b/lib/toast.ts @@ -41,6 +41,48 @@ export const TOAST_DEFAULT_TIMEOUT = 7000 /** Timeout value to show a toast permanently */ export const TOAST_PERMANENT_TIMEOUT = -1 +/** + * Shared browser-global key for the configured toast timeout. + * Using `window` ensures separately bundled copies of this package share the same value. + */ +const GLOBAL_TOAST_TIMEOUT_KEY = '__nextcloud_dialogs_toast_timeout__' + +declare global { + interface Window { + [GLOBAL_TOAST_TIMEOUT_KEY]?: number + } +} + +/** + * Get the configured toast timeout in milliseconds. + * Falls back to {@link TOAST_DEFAULT_TIMEOUT} when unset. + */ +export function getToastTimeout(): number { + if (typeof window !== 'undefined') { + const timeout = window[GLOBAL_TOAST_TIMEOUT_KEY] + if (typeof timeout === 'number' && (timeout === TOAST_PERMANENT_TIMEOUT || timeout > 0)) { + return timeout + } + } + return TOAST_DEFAULT_TIMEOUT +} + +/** + * Set the toast timeout used by ordinary toast helpers. + * Stored on `window` so independently bundled copies of `@nextcloud/dialogs` share it. + * + * @param timeout Timeout in milliseconds, or {@link TOAST_PERMANENT_TIMEOUT} for never dismiss + */ +export function setToastTimeout(timeout: number): void { + if (typeof window === 'undefined') { + return + } + if (timeout !== TOAST_PERMANENT_TIMEOUT && !(timeout > 0)) { + throw new Error('Toast timeout must be a positive number or TOAST_PERMANENT_TIMEOUT') + } + window[GLOBAL_TOAST_TIMEOUT_KEY] = timeout +} + /** * Type of a toast * @@ -104,7 +146,7 @@ export interface ToastOptions { */ export function showMessage(data: string | Node, options?: ToastOptions): Toast { options = { - timeout: TOAST_DEFAULT_TIMEOUT, + timeout: getToastTimeout(), isHTML: false, type: undefined, // An undefined selector defaults to the body element @@ -115,6 +157,16 @@ export function showMessage(data: string | Node, options?: ToastOptions): Toast ...options, } + // Accessibility preference overrides ordinary/app-specific timeouts. + // Loading and undo toasts keep their forced durations; permanent stays permanent. + if ( + options.type !== ToastType.LOADING + && options.type !== ToastType.UNDO + && options.timeout !== TOAST_PERMANENT_TIMEOUT + ) { + options.timeout = getToastTimeout() + } + if (typeof data === 'string' && !options.isHTML) { // fime mae sure that text is extracted const element = document.createElement('div') From 90e9b31222116377537e4fbec1277dd50e0e8917 Mon Sep 17 00:00:00 2001 From: Kristian Zendato Date: Tue, 4 Aug 2026 13:49:41 +0800 Subject: [PATCH 2/2] toast timeout configuration using capabilities Signed-off-by: Kristian Zendato --- README.md | 15 ++----- lib/index.ts | 2 - lib/toast.spec.ts | 103 ++++++++++++++++++++++++++++++++++++++++++++++ lib/toast.ts | 50 +++++++++------------- package-lock.json | 1 + package.json | 1 + 6 files changed, 128 insertions(+), 44 deletions(-) create mode 100644 lib/toast.spec.ts diff --git a/README.md b/README.md index f5095935a..3b994538a 100644 --- a/README.md +++ b/README.md @@ -66,18 +66,9 @@ There are several options that can be passed in as a second parameter, like the showError('This is an error shown without a timeout', { timeout: -1 }) ``` -You can also configure the timeout used by ordinary toasts (success, error, warning, info, and bare messages). -This is stored on `window`, so separately bundled copies of the library share the same value: - -``` -import { setToastTimeout, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' - -// Keep toasts visible for 30 seconds -setToastTimeout(30000) - -// Or never auto-dismiss ordinary toasts -setToastTimeout(TOAST_PERMANENT_TIMEOUT) -``` +Ordinary toasts (success, error, warning, info, and bare messages) use the user-configured toast timeout +from the theming capabilities (`theming.toastTimeout`) when available, and fall back to +`TOAST_DEFAULT_TIMEOUT` (7 seconds) otherwise. Loading toasts stay permanent until hidden manually, and undo toasts keep their fixed undo duration. diff --git a/lib/index.ts b/lib/index.ts index 6a2129c7a..df5bb346d 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -23,8 +23,6 @@ export { } from './public-auth.ts' export { - getToastTimeout, - setToastTimeout, showError, showInfo, showLoading, diff --git a/lib/toast.spec.ts b/lib/toast.spec.ts new file mode 100644 index 000000000..a7af0f8d9 --- /dev/null +++ b/lib/toast.spec.ts @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { afterEach, beforeEach, expect, test, vi } from 'vitest' +import { showMessage, TOAST_DEFAULT_TIMEOUT, TOAST_PERMANENT_TIMEOUT, ToastType } from './toast.ts' + +const getCapabilities = vi.hoisted(() => vi.fn()) + +vi.mock('@nextcloud/capabilities', () => ({ + getCapabilities, +})) + +vi.mock('toastify-js', () => ({ + default: vi.fn((options: { duration?: number }) => ({ + options, + showToast: vi.fn(), + hideToast: vi.fn(), + })), +})) + +import Toastify from 'toastify-js' + +beforeEach(() => { + getCapabilities.mockReset() + vi.mocked(Toastify).mockClear() +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +test('uses default timeout when capabilities are missing', () => { + getCapabilities.mockImplementation(() => { + throw new Error('no capabilities') + }) + + showMessage('hello') + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_DEFAULT_TIMEOUT, + })) +}) + +test('uses toastTimeout from theming capabilities', () => { + getCapabilities.mockReturnValue({ + theming: { + toastTimeout: 15_000, + }, + }) + + showMessage('hello') + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: 15_000, + })) +}) + +test('allows permanent timeout from capabilities', () => { + getCapabilities.mockReturnValue({ + theming: { + toastTimeout: TOAST_PERMANENT_TIMEOUT, + }, + }) + + showMessage('hello') + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_PERMANENT_TIMEOUT, + })) +}) + +test('falls back for invalid capability values', () => { + getCapabilities.mockReturnValue({ + theming: { + toastTimeout: 0, + }, + }) + + showMessage('hello') + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_DEFAULT_TIMEOUT, + })) +}) + +test('does not override loading toast duration', () => { + getCapabilities.mockReturnValue({ + theming: { + toastTimeout: 30_000, + }, + }) + + showMessage('loading', { + type: ToastType.LOADING, + timeout: TOAST_PERMANENT_TIMEOUT, + }) + + expect(Toastify).toHaveBeenCalledWith(expect.objectContaining({ + duration: TOAST_PERMANENT_TIMEOUT, + })) +}) diff --git a/lib/toast.ts b/lib/toast.ts index 89e36fd7d..8112693ac 100644 --- a/lib/toast.ts +++ b/lib/toast.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getCapabilities } from '@nextcloud/capabilities' import Toastify from 'toastify-js' import LoaderSvg from '../styles/loader.svg?raw' import { t } from './utils/l10n.js' @@ -41,46 +42,35 @@ export const TOAST_DEFAULT_TIMEOUT = 7000 /** Timeout value to show a toast permanently */ export const TOAST_PERMANENT_TIMEOUT = -1 -/** - * Shared browser-global key for the configured toast timeout. - * Using `window` ensures separately bundled copies of this package share the same value. - */ -const GLOBAL_TOAST_TIMEOUT_KEY = '__nextcloud_dialogs_toast_timeout__' - -declare global { - interface Window { - [GLOBAL_TOAST_TIMEOUT_KEY]?: number +type ThemingCapabilities = { + theming?: { + toastTimeout?: number } } /** - * Get the configured toast timeout in milliseconds. - * Falls back to {@link TOAST_DEFAULT_TIMEOUT} when unset. + * Whether a timeout value is valid for ordinary toasts. + * + * @param timeout Timeout in milliseconds */ -export function getToastTimeout(): number { - if (typeof window !== 'undefined') { - const timeout = window[GLOBAL_TOAST_TIMEOUT_KEY] - if (typeof timeout === 'number' && (timeout === TOAST_PERMANENT_TIMEOUT || timeout > 0)) { - return timeout - } - } - return TOAST_DEFAULT_TIMEOUT +function isValidToastTimeout(timeout: number): boolean { + return timeout === TOAST_PERMANENT_TIMEOUT || timeout > 0 } /** - * Set the toast timeout used by ordinary toast helpers. - * Stored on `window` so independently bundled copies of `@nextcloud/dialogs` share it. - * - * @param timeout Timeout in milliseconds, or {@link TOAST_PERMANENT_TIMEOUT} for never dismiss + * Resolve the user-configured toast timeout from theming capabilities. + * Falls back to {@link TOAST_DEFAULT_TIMEOUT} when unset or invalid. */ -export function setToastTimeout(timeout: number): void { - if (typeof window === 'undefined') { - return - } - if (timeout !== TOAST_PERMANENT_TIMEOUT && !(timeout > 0)) { - throw new Error('Toast timeout must be a positive number or TOAST_PERMANENT_TIMEOUT') +function getToastTimeout(): number { + try { + const timeout = (getCapabilities() as ThemingCapabilities)?.theming?.toastTimeout + if (typeof timeout === 'number' && isValidToastTimeout(timeout)) { + return timeout + } + } catch { + // Capabilities may be unavailable outside the browser (e.g. unit tests). } - window[GLOBAL_TOAST_TIMEOUT_KEY] = timeout + return TOAST_DEFAULT_TIMEOUT } /** diff --git a/package-lock.json b/package-lock.json index 050e1495f..c3ffa0520 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/capabilities": "^1.2.1", "@nextcloud/event-bus": "^3.3.3", "@nextcloud/files": "^4.0.0", "@nextcloud/initial-state": "^3.0.0", diff --git a/package.json b/package.json index 85a4d7e98..8edf24287 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.6.0", "@nextcloud/browser-storage": "^0.5.0", + "@nextcloud/capabilities": "^1.2.1", "@nextcloud/event-bus": "^3.3.3", "@nextcloud/files": "^4.0.0", "@nextcloud/initial-state": "^3.0.0",