diff --git a/apps/theming/lib/Capabilities.php b/apps/theming/lib/Capabilities.php index 9d4847f58b0ef..c1f14b6001826 100644 --- a/apps/theming/lib/Capabilities.php +++ b/apps/theming/lib/Capabilities.php @@ -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; @@ -65,6 +66,7 @@ public function __construct( * inverted: bool, * cacheBuster: string, * enabledThemes: list, + * toastTimeout: int, * }, * } */ @@ -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; + } } diff --git a/apps/theming/lib/ConfigLexicon.php b/apps/theming/lib/ConfigLexicon.php index 6c91d7dfad30b..f9b03b018aff2 100644 --- a/apps/theming/lib/ConfigLexicon.php +++ b/apps/theming/lib/ConfigLexicon.php @@ -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'; @@ -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.', + ), + ]; } } diff --git a/apps/theming/lib/Listener/BeforePreferenceListener.php b/apps/theming/lib/Listener/BeforePreferenceListener.php index d823df1ab33c2..d964a7f884b05 100644 --- a/apps/theming/lib/Listener/BeforePreferenceListener.php +++ b/apps/theming/lib/Listener/BeforePreferenceListener.php @@ -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; @@ -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, @@ -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); } diff --git a/apps/theming/openapi.json b/apps/theming/openapi.json index beaf92477cda4..81f8760fcc42b 100644 --- a/apps/theming/openapi.json +++ b/apps/theming/openapi.json @@ -102,7 +102,8 @@ "defaultBackgroundColor", "inverted", "cacheBuster", - "enabledThemes" + "enabledThemes", + "toastTimeout" ], "properties": { "name": { @@ -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." } } } diff --git a/apps/theming/src/components/UserSectionToastTimeout.vue b/apps/theming/src/components/UserSectionToastTimeout.vue new file mode 100644 index 0000000000000..d7b1206c63ae3 --- /dev/null +++ b/apps/theming/src/components/UserSectionToastTimeout.vue @@ -0,0 +1,139 @@ + + + + + + + diff --git a/apps/theming/src/views/UserTheming.vue b/apps/theming/src/views/UserTheming.vue index 6ecee83202e7a..7bb7128547e0d 100644 --- a/apps/theming/src/views/UserTheming.vue +++ b/apps/theming/src/views/UserTheming.vue @@ -49,6 +49,7 @@ + @@ -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') diff --git a/apps/theming/tests/CapabilitiesTest.php b/apps/theming/tests/CapabilitiesTest.php index 6101a833f36af..fad548e7dd5aa 100644 --- a/apps/theming/tests/CapabilitiesTest.php +++ b/apps/theming/tests/CapabilitiesTest.php @@ -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', @@ -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', @@ -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', @@ -171,6 +174,7 @@ public static function dataGetCapabilities(): array { 'inverted' => false, 'cacheBuster' => 'v1', 'enabledThemes' => ['default'], + 'toastTimeout' => 7000, ]], ]; } @@ -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']); } }