Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/theming/lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace OCA\Theming;

use OCA\Theming\AppInfo\Application;
use OCA\Theming\Listener\BeforePreferenceListener;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\ThemesService;
use OCP\Capabilities\IPublicCapability;
Expand Down Expand Up @@ -65,6 +66,7 @@ public function __construct(
* inverted: bool,
* cacheBuster: string,
* enabledThemes: list<string>,
* toastTimeout: int,
* },
* }
*/
Expand Down Expand Up @@ -129,7 +131,26 @@ public function getCapabilities() {
'inverted' => $this->util->invertTextColor($color),
'cacheBuster' => $this->util->getCacheBuster(),
'enabledThemes' => $this->themesService->getEnabledThemes(),
'toastTimeout' => $this->getToastTimeout($user),
],
];
}

/**
* Resolve the effective toast timeout for the given user.
*
* Uses the config lexicon default and falls back when an invalid value is stored.
*/
private function getToastTimeout(?IUser $user): int {
if ($user instanceof IUser) {
// Config lexicon provides the default when the preference is unset.
$value = $this->userConfig->getValueInt($user->getUID(), Application::APP_ID, ConfigLexicon::TOAST_TIMEOUT);
if ($value === ConfigLexicon::TOAST_TIMEOUT_DEFAULT
|| in_array($value, BeforePreferenceListener::TOAST_TIMEOUT_VALUES, true)) {
return $value;
}
}

return ConfigLexicon::TOAST_TIMEOUT_DEFAULT;
}
}
11 changes: 10 additions & 1 deletion apps/theming/lib/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class ConfigLexicon implements ILexicon {
/** The cache buster index */
public const CACHE_BUSTER = 'cachebuster';
public const USER_THEMING_DISABLED = 'disable-user-theming';
public const TOAST_TIMEOUT = 'toast_timeout';
public const TOAST_TIMEOUT_DEFAULT = 7000;

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

#[\Override]
public function getUserConfigs(): array {
return [];
return [
new Entry(
self::TOAST_TIMEOUT,
ValueType::INT,
defaultRaw: self::TOAST_TIMEOUT_DEFAULT,
definition: 'How long toast notifications remain visible in milliseconds.',
),
];
}
}
15 changes: 14 additions & 1 deletion apps/theming/lib/Listener/BeforePreferenceListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OCA\Theming\Listener;

use OCA\Theming\AppInfo\Application;
use OCA\Theming\ConfigLexicon;
use OCP\App\IAppManager;
use OCP\Config\BeforePreferenceDeletedEvent;
use OCP\Config\BeforePreferenceSetEvent;
Expand All @@ -22,7 +23,15 @@ class BeforePreferenceListener implements IEventListener {
/**
* @var string[]
*/
private const ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color'];
private const ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color', ConfigLexicon::TOAST_TIMEOUT];

/**
* Allowed toast timeout values in milliseconds.
* Default (7000) is represented by deleting the preference.
*
* @var int[]
*/
public const array TOAST_TIMEOUT_VALUES = [15000, 30000, -1];

public function __construct(
private IAppManager $appManager,
Expand Down Expand Up @@ -62,6 +71,10 @@ private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDe
case 'primary_color':
$event->setValid(preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $event->getConfigValue()) === 1);
break;
case ConfigLexicon::TOAST_TIMEOUT:
$value = filter_var($event->getConfigValue(), FILTER_VALIDATE_INT);
$event->setValid($value !== false && in_array($value, self::TOAST_TIMEOUT_VALUES, true));
break;
default:
$event->setValid(false);
}
Expand Down
7 changes: 6 additions & 1 deletion apps/theming/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@
"defaultBackgroundColor",
"inverted",
"cacheBuster",
"enabledThemes"
"enabledThemes",
"toastTimeout"
],
"properties": {
"name": {
Expand Down Expand Up @@ -182,6 +183,10 @@
"items": {
"type": "string"
}
},
"toastTimeout": {
"type": "integer",
"description": "How long toast notifications remain visible in milliseconds. Use -1 to keep them until dismissed."
}
}
}
Expand Down
139 changes: 139 additions & 0 deletions apps/theming/src/components/UserSectionToastTimeout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts">
import axios from '@nextcloud/axios'
import { getCapabilities } from '@nextcloud/capabilities'
import {
showError,
TOAST_DEFAULT_TIMEOUT,
TOAST_PERMANENT_TIMEOUT,
} from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { computed, ref } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import { logger } from '../utils/logger.ts'

const TOAST_TIMEOUT_15S = 15_000
const TOAST_TIMEOUT_30S = 30_000

type ThemingCapabilities = {
theming?: {
toastTimeout?: number
}
}

/**
* Read the effective toast timeout from theming capabilities.
*/
function readToastTimeout(): number {
const timeout = (getCapabilities() as ThemingCapabilities)?.theming?.toastTimeout
if (typeof timeout === 'number' && (timeout === TOAST_PERMANENT_TIMEOUT || timeout > 0)) {
return timeout
}
return TOAST_DEFAULT_TIMEOUT
}

/**
* Update the in-memory theming capability so subsequent toasts use the new timeout
* without requiring a page reload.
*
* @param timeout - Timeout in milliseconds
*/
function applyToastTimeoutCapability(timeout: number): void {
const capabilities = getCapabilities() as ThemingCapabilities
if (capabilities.theming) {
capabilities.theming.toastTimeout = timeout
}
}

const toastTimeout = ref(readToastTimeout())

const options = computed(() => [
{
value: TOAST_DEFAULT_TIMEOUT,
label: t('theming', 'Default ({time} seconds)', { time: TOAST_DEFAULT_TIMEOUT / 1000 }),
},
{
value: TOAST_TIMEOUT_15S,
label: t('theming', '15 seconds'),
},
{
value: TOAST_TIMEOUT_30S,
label: t('theming', '30 seconds'),
},
{
value: TOAST_PERMANENT_TIMEOUT,
label: t('theming', 'Never dismiss'),
},
])

/**
* Persist and apply the selected toast timeout
*
* @param value - Selected timeout preference value
*/
async function updateToastTimeout(value: string | number | boolean) {
const nextValue = Number(value)
const previous = toastTimeout.value
toastTimeout.value = nextValue
applyToastTimeoutCapability(nextValue)

const url = generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
appId: 'theming',
configKey: 'toast_timeout',
})

try {
if (nextValue === TOAST_DEFAULT_TIMEOUT) {
await axios.delete(url)
} else {
await axios.post(url, {
configValue: String(nextValue),
})
}
} catch (error) {
toastTimeout.value = previous
applyToastTimeoutCapability(previous)
logger.error('Could not update toast timeout', { error })
showError(t('theming', 'Could not update toast timeout'))
}
}
</script>

<template>
<NcSettingsSection
:name="t('theming', 'Toast notifications')"
:description="t('theming', 'Set how long toast messages stay visible. Choose a longer duration if you need more time to read them.')">
<fieldset class="toast-timeout">
<legend class="hidden-visually">
{{ t('theming', 'Toast timeout') }}
</legend>
<NcCheckboxRadioSwitch
v-for="option in options"
:key="option.value"
:modelValue="toastTimeout"
type="radio"
name="toast_timeout"
:value="option.value"
@update:modelValue="updateToastTimeout">
{{ option.label }}
</NcCheckboxRadioSwitch>
</fieldset>
</NcSettingsSection>
</template>

<style scoped lang="scss">
.toast-timeout {
display: flex;
flex-direction: column;
gap: 4px;
border: 0;
margin: 0;
padding: 0;
}
</style>
2 changes: 2 additions & 0 deletions apps/theming/src/views/UserTheming.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
</template>

<UserSectionHotkeys />
<UserSectionToastTimeout />
<UserSectionAppMenu />
</template>

Expand All @@ -68,6 +69,7 @@ import UserSectionAppMenu from '../components/UserSectionAppMenu.vue'
import UserSectionBackground from '../components/UserSectionBackground.vue'
import UserSectionHotkeys from '../components/UserSectionHotkeys.vue'
import UserSectionPrimaryColor from '../components/UserSectionPrimaryColor.vue'
import UserSectionToastTimeout from '../components/UserSectionToastTimeout.vue'
import { refreshStyles } from '../utils/refreshStyles.js'

const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled')
Expand Down
51 changes: 51 additions & 0 deletions apps/theming/tests/CapabilitiesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public static function dataGetCapabilities(): array {
'inverted' => true,
'cacheBuster' => 'v1',
'enabledThemes' => ['default'],
'toastTimeout' => 7000,
]],
['name1', 'url2', 'slogan3', '#01e4a0', '#ffffff', 'logo5', 'background6', '#fff', '#000', 'http://localhost/', false, '', '', '#0082c9', [
'name' => 'name1',
Expand Down Expand Up @@ -117,6 +118,7 @@ public static function dataGetCapabilities(): array {
'inverted' => false,
'cacheBuster' => 'v1',
'enabledThemes' => ['default'],
'toastTimeout' => 7000,
]],
['name1', 'url2', 'slogan3', '#000000', '#ffffff', 'logo5', 'backgroundColor', '#000000', '#ffffff', 'http://localhost/', true, '', '', '#0082c9', [
'name' => 'name1',
Expand Down Expand Up @@ -144,6 +146,7 @@ public static function dataGetCapabilities(): array {
'inverted' => false,
'cacheBuster' => 'v1',
'enabledThemes' => ['default'],
'toastTimeout' => 7000,
]],
['name1', 'url2', 'slogan3', '#000000', '#ffffff', 'logo5', 'backgroundColor', '#000000', '#ffffff', 'http://localhost/', false, '', '', '#0082c9', [
'name' => 'name1',
Expand Down Expand Up @@ -171,6 +174,7 @@ public static function dataGetCapabilities(): array {
'inverted' => false,
'cacheBuster' => 'v1',
'enabledThemes' => ['default'],
'toastTimeout' => 7000,
]],
];
}
Expand Down Expand Up @@ -339,5 +343,52 @@ public function testGetCapabilitiesWithUser(string $backgroundImage, bool $expec
// New fields are always present
$this->assertSame('v1', $theming['cacheBuster']);
$this->assertSame(['default'], $theming['enabledThemes']);
$this->assertSame(7000, $theming['toastTimeout']);
}

public static function dataGetCapabilitiesToastTimeout(): array {
return [
'default' => [7000, 7000],
'15 seconds' => [15000, 15000],
'30 seconds' => [30000, 30000],
'never dismiss' => [-1, -1],
'invalid falls back to default' => [1234, 7000],
];
}

#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataGetCapabilitiesToastTimeout')]
public function testGetCapabilitiesToastTimeout(int $storedTimeout, int $expectedTimeout): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('user1');
$this->userSession->method('getUser')->willReturn($user);

$this->theming->method('getDefaultColorPrimary')->willReturn('#0082c9');
$this->theming->method('getColorPrimary')->willReturn('#0082c9');
$this->theming->method('getTextColorPrimary')->willReturn('#ffffff');
$this->theming->method('getName')->willReturn('Name');
$this->theming->method('getProductName')->willReturn('Name');
$this->theming->method('getBaseUrl')->willReturn('http://example.com/');
$this->theming->method('getImprintUrl')->willReturn('');
$this->theming->method('getPrivacyUrl')->willReturn('');
$this->theming->method('getSlogan')->willReturn('Slogan');
$this->theming->method('getColorBackground')->willReturn(BackgroundService::DEFAULT_COLOR);
$this->theming->method('getTextColorBackground')->willReturn('#ffffff');
$this->theming->method('getDefaultColorBackground')->willReturn('#0082c9');
$this->theming->method('getLogo')->willReturn('/logo');
$this->theming->method('getBackground')->willReturn('/background');

$this->appConfig->method('getValueString')->willReturn('');
$this->userConfig->method('getValueString')->willReturn(BackgroundService::BACKGROUND_DEFAULT);
$this->userConfig->method('getValueInt')->willReturn($storedTimeout);

$this->util->method('invertTextColor')->willReturn(false);
$this->util->method('elementColor')->willReturn('#0082c9');
$this->util->method('isBackgroundThemed')->willReturn(false);
$this->util->method('getCacheBuster')->willReturn('v1');
$this->themesService->method('getEnabledThemes')->willReturn(['default']);
$this->url->method('getAbsoluteURL')->willReturnCallback(fn (string $url) => 'http://localhost' . $url);

$result = $this->capabilities->getCapabilities();
$this->assertSame($expectedTimeout, $result['theming']['toastTimeout']);
}
}
Loading