Skip to content

Commit 40e1521

Browse files
feat: toast timeout configuration
Signed-off-by: kristian-zendato <kristian.zendato@nextcloud.com>
1 parent 19882e5 commit 40e1521

5 files changed

Lines changed: 139 additions & 1 deletion

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ There are several options that can be passed in as a second parameter, like the
6666
showError('This is an error shown without a timeout', { timeout: -1 })
6767
```
6868

69+
Ordinary toasts (success, error, warning, info, and bare messages) use the user-configured toast timeout
70+
from the theming capabilities (`theming.toastTimeout`) when available, and fall back to
71+
`TOAST_DEFAULT_TIMEOUT` (7 seconds) otherwise.
72+
73+
Loading toasts stay permanent until hidden manually, and undo toasts keep their fixed undo duration.
74+
6975
A full list of available options can be found in the [documentation](https://nextcloud-libraries.github.io/nextcloud-dialogs/).
7076

7177
### FilePicker

lib/toast.spec.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,18 @@ import {
1414
showSuccess,
1515
showUndo,
1616
showWarning,
17+
TOAST_DEFAULT_TIMEOUT,
18+
TOAST_PERMANENT_TIMEOUT,
1719
ToastAriaLive,
20+
ToastType,
1821
} from './toast.ts'
1922

23+
const getCapabilities = vi.hoisted(() => vi.fn())
24+
25+
vi.mock('@nextcloud/capabilities', () => ({
26+
getCapabilities,
27+
}))
28+
2029
/**
2130
* Wait for the 50 ms setTimeout used in announce() to fire.
2231
* We intentionally advance only 100 ms so the 7-second cleanup timers
@@ -445,3 +454,91 @@ describe('multiple toasts', () => {
445454
expect(messages).toEqual(['First', 'Second', 'Third'])
446455
})
447456
})
457+
458+
describe('timeout resolution', () => {
459+
beforeEach(() => {
460+
getCapabilities.mockReset()
461+
})
462+
463+
test('uses default timeout when capabilities are missing', async () => {
464+
getCapabilities.mockReturnValue(undefined)
465+
showMessage('hello')
466+
467+
await vi.advanceTimersByTimeAsync(TOAST_DEFAULT_TIMEOUT - 1)
468+
expect(document.querySelector('[role="status"]')).not.toBeNull()
469+
470+
await vi.advanceTimersByTimeAsync(1)
471+
expect(document.querySelector('[role="status"]')).toBeNull()
472+
})
473+
474+
test('uses default timeout when capabilities access throws', async () => {
475+
getCapabilities.mockImplementation(() => {
476+
throw new Error('window is not defined')
477+
})
478+
showMessage('hello')
479+
480+
await vi.advanceTimersByTimeAsync(TOAST_DEFAULT_TIMEOUT - 1)
481+
expect(document.querySelector('[role="status"]')).not.toBeNull()
482+
483+
await vi.advanceTimersByTimeAsync(1)
484+
expect(document.querySelector('[role="status"]')).toBeNull()
485+
})
486+
487+
test('uses toastTimeout from theming capabilities', async () => {
488+
getCapabilities.mockReturnValue({
489+
theming: {
490+
toastTimeout: 15_000,
491+
},
492+
})
493+
showMessage('hello')
494+
495+
await vi.advanceTimersByTimeAsync(14_999)
496+
expect(document.querySelector('[role="status"]')).not.toBeNull()
497+
498+
await vi.advanceTimersByTimeAsync(1)
499+
expect(document.querySelector('[role="status"]')).toBeNull()
500+
})
501+
502+
test('allows permanent timeout from capabilities', async () => {
503+
getCapabilities.mockReturnValue({
504+
theming: {
505+
toastTimeout: TOAST_PERMANENT_TIMEOUT,
506+
},
507+
})
508+
showMessage('hello')
509+
510+
await vi.advanceTimersByTimeAsync(60_000)
511+
expect(document.querySelector('[role="status"]')).not.toBeNull()
512+
})
513+
514+
test('falls back for invalid capability values', async () => {
515+
getCapabilities.mockReturnValue({
516+
theming: {
517+
toastTimeout: 0,
518+
},
519+
})
520+
showMessage('hello')
521+
522+
await vi.advanceTimersByTimeAsync(TOAST_DEFAULT_TIMEOUT - 1)
523+
expect(document.querySelector('[role="status"]')).not.toBeNull()
524+
525+
await vi.advanceTimersByTimeAsync(1)
526+
expect(document.querySelector('[role="status"]')).toBeNull()
527+
})
528+
529+
test('does not override loading toast duration', async () => {
530+
getCapabilities.mockReturnValue({
531+
theming: {
532+
toastTimeout: 30_000,
533+
},
534+
})
535+
536+
showMessage('loading', {
537+
type: ToastType.LOADING,
538+
timeout: TOAST_PERMANENT_TIMEOUT,
539+
})
540+
541+
await vi.advanceTimersByTimeAsync(60_000)
542+
expect(document.querySelector('[role="status"]')).not.toBeNull()
543+
})
544+
})

lib/toast.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
55

6+
import { getCapabilities } from '@nextcloud/capabilities'
67
import { createApp } from 'vue'
78
import ToastContainer from './components/ToastContainer.vue'
89
import ToastNotification from './components/ToastNotification.vue'
@@ -40,6 +41,38 @@ export const TOAST_DEFAULT_TIMEOUT = 7000
4041
/** Timeout value to show a toast permanently */
4142
export const TOAST_PERMANENT_TIMEOUT = -1
4243

44+
type ThemingCapabilities = {
45+
theming?: {
46+
toastTimeout?: number
47+
}
48+
}
49+
50+
/**
51+
* Whether a timeout value is valid for ordinary toasts.
52+
*
53+
* @param timeout Timeout in milliseconds
54+
*/
55+
function isValidToastTimeout(timeout: number): boolean {
56+
return timeout === TOAST_PERMANENT_TIMEOUT || timeout > 0
57+
}
58+
59+
/**
60+
* Resolve the user-configured toast timeout from theming capabilities.
61+
* Falls back to {@link TOAST_DEFAULT_TIMEOUT} when unset or invalid.
62+
*/
63+
function getToastTimeout(): number {
64+
try {
65+
const timeout = (getCapabilities() as ThemingCapabilities | undefined)?.theming?.toastTimeout
66+
if (typeof timeout === 'number' && isValidToastTimeout(timeout)) {
67+
return timeout
68+
}
69+
} catch (_error) {
70+
// Catch any exception from capability access and fallback to default timeout.
71+
console.error('Error getting toast timeout:', _error)
72+
}
73+
return TOAST_DEFAULT_TIMEOUT
74+
}
75+
4376
export interface ToastOptions {
4477
/**
4578
* Defines the timeout in milliseconds after which the toast is closed. Set to -1 to have a persistent toast.
@@ -246,7 +279,7 @@ function getAnnouncementText(data: string | Node, isHTML: boolean): string {
246279
*/
247280
export function showMessage(data: string | Node, options?: ToastOptions): ToastHandle {
248281
const opts = {
249-
timeout: TOAST_DEFAULT_TIMEOUT,
282+
timeout: getToastTimeout(),
250283
isHTML: false,
251284
type: undefined as ToastType | undefined,
252285
selector: undefined as string | undefined,

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@nextcloud/auth": "^2.6.0",
5252
"@nextcloud/axios": "^2.6.0",
5353
"@nextcloud/browser-storage": "^0.5.0",
54+
"@nextcloud/capabilities": "^1.2.1",
5455
"@nextcloud/event-bus": "^3.3.3",
5556
"@nextcloud/files": "^4.0.0",
5657
"@nextcloud/initial-state": "^3.0.0",

0 commit comments

Comments
 (0)