Skip to content

Commit 73e9931

Browse files
feat: configure toast timeout
Signed-off-by: kristian-zendato <kristian.zendato@nextcloud.com>
1 parent 262972d commit 73e9931

7 files changed

Lines changed: 243 additions & 3 deletions

File tree

apps/theming/lib/Capabilities.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
namespace OCA\Theming;
99

1010
use OCA\Theming\AppInfo\Application;
11+
use OCA\Theming\Listener\BeforePreferenceListener;
1112
use OCA\Theming\Service\BackgroundService;
1213
use OCA\Theming\Service\ThemesService;
1314
use OCP\Capabilities\IPublicCapability;
@@ -65,6 +66,7 @@ public function __construct(
6566
* inverted: bool,
6667
* cacheBuster: string,
6768
* enabledThemes: list<string>,
69+
* toastTimeout: int,
6870
* },
6971
* }
7072
*/
@@ -129,7 +131,26 @@ public function getCapabilities() {
129131
'inverted' => $this->util->invertTextColor($color),
130132
'cacheBuster' => $this->util->getCacheBuster(),
131133
'enabledThemes' => $this->themesService->getEnabledThemes(),
134+
'toastTimeout' => $this->getToastTimeout($user),
132135
],
133136
];
134137
}
138+
139+
/**
140+
* Resolve the effective toast timeout for the given user.
141+
*
142+
* Uses the config lexicon default and falls back when an invalid value is stored.
143+
*/
144+
private function getToastTimeout(?IUser $user): int {
145+
if ($user instanceof IUser) {
146+
// Config lexicon provides the default when the preference is unset.
147+
$value = $this->userConfig->getValueInt($user->getUID(), Application::APP_ID);
148+
if ($value === ConfigLexicon::TOAST_TIMEOUT_DEFAULT
149+
|| in_array($value, BeforePreferenceListener::TOAST_TIMEOUT_VALUES, true)) {
150+
return $value;
151+
}
152+
}
153+
154+
return ConfigLexicon::TOAST_TIMEOUT_DEFAULT;
155+
}
135156
}

apps/theming/lib/ConfigLexicon.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class ConfigLexicon implements ILexicon {
2424
/** The cache buster index */
2525
public const CACHE_BUSTER = 'cachebuster';
2626
public const USER_THEMING_DISABLED = 'disable-user-theming';
27+
public const TOAST_TIMEOUT = 'toast_timeout';
28+
public const TOAST_TIMEOUT_DEFAULT = 7000;
2729

2830
/** Name of the software running on this instance (usually "Nextcloud") */
2931
public const PRODUCT_NAME = 'productName';
@@ -114,6 +116,13 @@ public function getAppConfigs(): array {
114116

115117
#[\Override]
116118
public function getUserConfigs(): array {
117-
return [];
119+
return [
120+
new Entry(
121+
self::TOAST_TIMEOUT,
122+
ValueType::INT,
123+
defaultRaw: self::TOAST_TIMEOUT_DEFAULT,
124+
definition: 'How long toast notifications remain visible in milliseconds.',
125+
),
126+
];
118127
}
119128
}

apps/theming/lib/Listener/BeforePreferenceListener.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace OCA\Theming\Listener;
1111

1212
use OCA\Theming\AppInfo\Application;
13+
use OCA\Theming\ConfigLexicon;
1314
use OCP\App\IAppManager;
1415
use OCP\Config\BeforePreferenceDeletedEvent;
1516
use OCP\Config\BeforePreferenceSetEvent;
@@ -22,7 +23,15 @@ class BeforePreferenceListener implements IEventListener {
2223
/**
2324
* @var string[]
2425
*/
25-
private const array ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color'];
26+
private const array ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color', ConfigLexicon::TOAST_TIMEOUT];
27+
28+
/**
29+
* Allowed toast timeout values in milliseconds.
30+
* Default (7000) is represented by deleting the preference.
31+
*
32+
* @var int[]
33+
*/
34+
public const array TOAST_TIMEOUT_VALUES = [15000, 30000, -1];
2635

2736
public function __construct(
2837
private IAppManager $appManager,
@@ -62,6 +71,10 @@ private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDe
6271
case 'primary_color':
6372
$event->setValid(preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $event->getConfigValue()) === 1);
6473
break;
74+
case ConfigLexicon::TOAST_TIMEOUT:
75+
$value = filter_var($event->getConfigValue(), FILTER_VALIDATE_INT);
76+
$event->setValid($value !== false && in_array($value, self::TOAST_TIMEOUT_VALUES, true));
77+
break;
6578
default:
6679
$event->setValid(false);
6780
}

apps/theming/openapi.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@
102102
"defaultBackgroundColor",
103103
"inverted",
104104
"cacheBuster",
105-
"enabledThemes"
105+
"enabledThemes",
106+
"toastTimeout"
106107
],
107108
"properties": {
108109
"name": {
@@ -182,6 +183,10 @@
182183
"items": {
183184
"type": "string"
184185
}
186+
},
187+
"toastTimeout": {
188+
"type": "integer",
189+
"description": "How long toast notifications remain visible in milliseconds. Use -1 to keep them until dismissed."
185190
}
186191
}
187192
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts">
7+
import axios from '@nextcloud/axios'
8+
import { getCapabilities } from '@nextcloud/capabilities'
9+
import {
10+
showError,
11+
TOAST_DEFAULT_TIMEOUT,
12+
TOAST_PERMANENT_TIMEOUT,
13+
} from '@nextcloud/dialogs'
14+
import { t } from '@nextcloud/l10n'
15+
import { generateOcsUrl } from '@nextcloud/router'
16+
import { computed, ref } from 'vue'
17+
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
18+
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
19+
import { logger } from '../utils/logger.ts'
20+
21+
const TOAST_TIMEOUT_15S = 15_000
22+
const TOAST_TIMEOUT_30S = 30_000
23+
24+
type ThemingCapabilities = {
25+
theming?: {
26+
toastTimeout?: number
27+
}
28+
}
29+
30+
/**
31+
* Read the effective toast timeout from theming capabilities.
32+
*/
33+
function readToastTimeout(): number {
34+
const timeout = (getCapabilities() as ThemingCapabilities)?.theming?.toastTimeout
35+
if (typeof timeout === 'number' && (timeout === TOAST_PERMANENT_TIMEOUT || timeout > 0)) {
36+
return timeout
37+
}
38+
return TOAST_DEFAULT_TIMEOUT
39+
}
40+
41+
/**
42+
* Update the in-memory theming capability so subsequent toasts use the new timeout
43+
* without requiring a page reload.
44+
*
45+
* @param timeout - Timeout in milliseconds
46+
*/
47+
function applyToastTimeoutCapability(timeout: number): void {
48+
const capabilities = getCapabilities() as ThemingCapabilities
49+
if (capabilities.theming) {
50+
capabilities.theming.toastTimeout = timeout
51+
}
52+
}
53+
54+
const toastTimeout = ref(readToastTimeout())
55+
56+
const options = computed(() => [
57+
{
58+
value: TOAST_DEFAULT_TIMEOUT,
59+
label: t('theming', 'Default ({time} seconds)', { time: TOAST_DEFAULT_TIMEOUT / 1000 }),
60+
},
61+
{
62+
value: TOAST_TIMEOUT_15S,
63+
label: t('theming', '15 seconds'),
64+
},
65+
{
66+
value: TOAST_TIMEOUT_30S,
67+
label: t('theming', '30 seconds'),
68+
},
69+
{
70+
value: TOAST_PERMANENT_TIMEOUT,
71+
label: t('theming', 'Never dismiss'),
72+
},
73+
])
74+
75+
/**
76+
* Persist and apply the selected toast timeout
77+
*
78+
* @param value - Selected timeout preference value
79+
*/
80+
async function updateToastTimeout(value: string | number | boolean) {
81+
const nextValue = Number(value)
82+
const previous = toastTimeout.value
83+
toastTimeout.value = nextValue
84+
applyToastTimeoutCapability(nextValue)
85+
86+
const url = generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
87+
appId: 'theming',
88+
configKey: 'toast_timeout',
89+
})
90+
91+
try {
92+
if (nextValue === TOAST_DEFAULT_TIMEOUT) {
93+
await axios.delete(url)
94+
} else {
95+
await axios.post(url, {
96+
configValue: String(nextValue),
97+
})
98+
}
99+
} catch (error) {
100+
toastTimeout.value = previous
101+
applyToastTimeoutCapability(previous)
102+
logger.error('Could not update toast timeout', { error })
103+
showError(t('theming', 'Could not update toast timeout'))
104+
}
105+
}
106+
</script>
107+
108+
<template>
109+
<NcSettingsSection
110+
:name="t('theming', 'Toast notifications')"
111+
:description="t('theming', 'Set how long toast messages stay visible. Choose a longer duration if you need more time to read them.')">
112+
<fieldset class="toast-timeout">
113+
<legend class="hidden-visually">
114+
{{ t('theming', 'Toast timeout') }}
115+
</legend>
116+
<NcCheckboxRadioSwitch
117+
v-for="option in options"
118+
:key="option.value"
119+
:modelValue="toastTimeout"
120+
type="radio"
121+
name="toast_timeout"
122+
:value="option.value"
123+
@update:modelValue="updateToastTimeout">
124+
{{ option.label }}
125+
</NcCheckboxRadioSwitch>
126+
</fieldset>
127+
</NcSettingsSection>
128+
</template>
129+
130+
<style scoped lang="scss">
131+
.toast-timeout {
132+
display: flex;
133+
flex-direction: column;
134+
gap: 4px;
135+
border: 0;
136+
margin: 0;
137+
padding: 0;
138+
}
139+
</style>

apps/theming/src/views/UserTheming.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
</template>
5050

5151
<UserSectionHotkeys />
52+
<UserSectionToastTimeout />
5253
<UserSectionAppMenu />
5354
</template>
5455

@@ -68,6 +69,7 @@ import UserSectionAppMenu from '../components/UserSectionAppMenu.vue'
6869
import UserSectionBackground from '../components/UserSectionBackground.vue'
6970
import UserSectionHotkeys from '../components/UserSectionHotkeys.vue'
7071
import UserSectionPrimaryColor from '../components/UserSectionPrimaryColor.vue'
72+
import UserSectionToastTimeout from '../components/UserSectionToastTimeout.vue'
7173
import { refreshStyles } from '../utils/refreshStyles.js'
7274
7375
const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled')

apps/theming/tests/CapabilitiesTest.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ public static function dataGetCapabilities(): array {
9090
'inverted' => true,
9191
'cacheBuster' => 'v1',
9292
'enabledThemes' => ['default'],
93+
'toastTimeout' => 7000,
9394
]],
9495
['name1', 'url2', 'slogan3', '#01e4a0', '#ffffff', 'logo5', 'background6', '#fff', '#000', 'http://localhost/', false, '', '', '#0082c9', [
9596
'name' => 'name1',
@@ -117,6 +118,7 @@ public static function dataGetCapabilities(): array {
117118
'inverted' => false,
118119
'cacheBuster' => 'v1',
119120
'enabledThemes' => ['default'],
121+
'toastTimeout' => 7000,
120122
]],
121123
['name1', 'url2', 'slogan3', '#000000', '#ffffff', 'logo5', 'backgroundColor', '#000000', '#ffffff', 'http://localhost/', true, '', '', '#0082c9', [
122124
'name' => 'name1',
@@ -144,6 +146,7 @@ public static function dataGetCapabilities(): array {
144146
'inverted' => false,
145147
'cacheBuster' => 'v1',
146148
'enabledThemes' => ['default'],
149+
'toastTimeout' => 7000,
147150
]],
148151
['name1', 'url2', 'slogan3', '#000000', '#ffffff', 'logo5', 'backgroundColor', '#000000', '#ffffff', 'http://localhost/', false, '', '', '#0082c9', [
149152
'name' => 'name1',
@@ -171,6 +174,7 @@ public static function dataGetCapabilities(): array {
171174
'inverted' => false,
172175
'cacheBuster' => 'v1',
173176
'enabledThemes' => ['default'],
177+
'toastTimeout' => 7000,
174178
]],
175179
];
176180
}
@@ -339,5 +343,52 @@ public function testGetCapabilitiesWithUser(string $backgroundImage, bool $expec
339343
// New fields are always present
340344
$this->assertSame('v1', $theming['cacheBuster']);
341345
$this->assertSame(['default'], $theming['enabledThemes']);
346+
$this->assertSame(7000, $theming['toastTimeout']);
347+
}
348+
349+
public static function dataGetCapabilitiesToastTimeout(): array {
350+
return [
351+
'default' => [7000, 7000],
352+
'15 seconds' => [15000, 15000],
353+
'30 seconds' => [30000, 30000],
354+
'never dismiss' => [-1, -1],
355+
'invalid falls back to default' => [1234, 7000],
356+
];
357+
}
358+
359+
#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataGetCapabilitiesToastTimeout')]
360+
public function testGetCapabilitiesToastTimeout(int $storedTimeout, int $expectedTimeout): void {
361+
$user = $this->createMock(IUser::class);
362+
$user->method('getUID')->willReturn('user1');
363+
$this->userSession->method('getUser')->willReturn($user);
364+
365+
$this->theming->method('getDefaultColorPrimary')->willReturn('#0082c9');
366+
$this->theming->method('getColorPrimary')->willReturn('#0082c9');
367+
$this->theming->method('getTextColorPrimary')->willReturn('#ffffff');
368+
$this->theming->method('getName')->willReturn('Name');
369+
$this->theming->method('getProductName')->willReturn('Name');
370+
$this->theming->method('getBaseUrl')->willReturn('http://example.com/');
371+
$this->theming->method('getImprintUrl')->willReturn('');
372+
$this->theming->method('getPrivacyUrl')->willReturn('');
373+
$this->theming->method('getSlogan')->willReturn('Slogan');
374+
$this->theming->method('getColorBackground')->willReturn(BackgroundService::DEFAULT_COLOR);
375+
$this->theming->method('getTextColorBackground')->willReturn('#ffffff');
376+
$this->theming->method('getDefaultColorBackground')->willReturn('#0082c9');
377+
$this->theming->method('getLogo')->willReturn('/logo');
378+
$this->theming->method('getBackground')->willReturn('/background');
379+
380+
$this->appConfig->method('getValueString')->willReturn('');
381+
$this->userConfig->method('getValueString')->willReturn(BackgroundService::BACKGROUND_DEFAULT);
382+
$this->userConfig->method('getValueInt')->willReturn($storedTimeout);
383+
384+
$this->util->method('invertTextColor')->willReturn(false);
385+
$this->util->method('elementColor')->willReturn('#0082c9');
386+
$this->util->method('isBackgroundThemed')->willReturn(false);
387+
$this->util->method('getCacheBuster')->willReturn('v1');
388+
$this->themesService->method('getEnabledThemes')->willReturn(['default']);
389+
$this->url->method('getAbsoluteURL')->willReturnCallback(fn (string $url) => 'http://localhost' . $url);
390+
391+
$result = $this->capabilities->getCapabilities();
392+
$this->assertSame($expectedTimeout, $result['theming']['toastTimeout']);
342393
}
343394
}

0 commit comments

Comments
 (0)