diff --git a/apps/theming/lib/Listener/BeforePreferenceListener.php b/apps/theming/lib/Listener/BeforePreferenceListener.php index d823df1ab33c2..c61211262e732 100644 --- a/apps/theming/lib/Listener/BeforePreferenceListener.php +++ b/apps/theming/lib/Listener/BeforePreferenceListener.php @@ -24,6 +24,11 @@ class BeforePreferenceListener implements IEventListener { */ private const ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color']; + /** + * @var string[] + */ + private const ALLOWED_CORE_KEYS = ['apporder', 'apps_pinned']; + public function __construct( private IAppManager $appManager, ) { @@ -72,7 +77,7 @@ private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDe } private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void { - if ($event->getConfigKey() !== 'apporder') { + if (!in_array($event->getConfigKey(), self::ALLOWED_CORE_KEYS, true)) { // Not allowed config key return; } @@ -82,6 +87,17 @@ private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDelet return; } + switch ($event->getConfigKey()) { + case 'apporder': + $this->validateAppOrder($event); + break; + case 'apps_pinned': + $this->validatePinnedApps($event); + break; + } + } + + private function validateAppOrder(BeforePreferenceSetEvent $event): void { $value = json_decode($event->getConfigValue(), true, flags:JSON_THROW_ON_ERROR); if (!is_array(($value))) { // Must be an array @@ -97,4 +113,21 @@ private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDelet } $event->setValid(true); } + + private function validatePinnedApps(BeforePreferenceSetEvent $event): void { + $value = json_decode($event->getConfigValue(), true); + // required format: list of navigation entry ids [ id: string ] + if (!is_array($value) || $value !== array_values($value)) { + // Must be a list + return; + } + + foreach ($value as $id) { + if (!is_string($id)) { + // Invalid config value, refuse the change + return; + } + } + $event->setValid(true); + } } diff --git a/apps/theming/lib/Settings/Personal.php b/apps/theming/lib/Settings/Personal.php index 5e1779d937a5e..7f9d12ca80770 100644 --- a/apps/theming/lib/Settings/Personal.php +++ b/apps/theming/lib/Settings/Personal.php @@ -81,6 +81,7 @@ public function getForm(): TemplateResponse { $this->initialStateService->provideInitialState('enableBlurFilter', $this->config->getUserValue($this->userId, 'theming', 'force_enable_blur_filter', '')); $this->initialStateService->provideInitialState('navigationBar', [ 'userAppOrder' => json_decode($this->config->getUserValue($this->userId, 'core', 'apporder', '[]'), true, flags:JSON_THROW_ON_ERROR), + 'userPinnedApps' => json_decode($this->config->getUserValue($this->userId, 'core', 'apps_pinned', '[]'), true, flags:JSON_THROW_ON_ERROR), 'enforcedDefaultApp' => $forcedDefaultEntry ]); diff --git a/apps/theming/src/components/AppOrderSelector.vue b/apps/theming/src/components/AppOrderSelector.vue index 25b78a43dabc7..886860503389f 100644 --- a/apps/theming/src/components/AppOrderSelector.vue +++ b/apps/theming/src/components/AppOrderSelector.vue @@ -25,6 +25,15 @@ defineProps<{ * Details like status information that need to be forwarded to the interactive elements */ ariaDetails: string + + /** + * Show the toggle for pinning apps to the navigation bar + */ + showPin?: boolean +}>() + +defineEmits<{ + 'toggle:pinned': [app: IApp] }>() /** @@ -153,6 +162,8 @@ function updateStatusInfo(index: number) { :aria-describedby="statusInfoId" :isFirst="index === 0 || !!appList[index - 1]!.default" :isLast="index === appList.length - 1" + :showPin="showPin" + @toggle:pinned="$emit('toggle:pinned', app)" v-on="app.default ? {} : { diff --git a/apps/theming/src/components/AppOrderSelectorElement.vue b/apps/theming/src/components/AppOrderSelectorElement.vue index 5dd43cf574d65..db288131cec55 100644 --- a/apps/theming/src/components/AppOrderSelectorElement.vue +++ b/apps/theming/src/components/AppOrderSelectorElement.vue @@ -9,12 +9,15 @@ import { nextTick, useTemplateRef } from 'vue' import NcButton from '@nextcloud/vue/components/NcButton' import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue' import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue' +import IconPin from 'vue-material-design-icons/Pin.vue' +import IconPinOutline from 'vue-material-design-icons/PinOutline.vue' export interface IApp { id: string // app id icon: string // path to the icon svg label?: string // display name default?: boolean // for app as default app + pinned?: boolean // for app pinned to the navigation bar } const props = defineProps<{ @@ -40,11 +43,16 @@ const props = defineProps<{ * Is this the last element in the list */ isLast?: boolean + /** + * Show the toggle for pinning the app to the navigation bar + */ + showPin?: boolean }>() const emit = defineEmits<{ 'move:up': [] 'move:down': [] + 'toggle:pinned': [] /** * We need this as Sortable.js removes all native focus event listeners */ @@ -124,6 +132,19 @@ function keepFocus() {
{{ t('theming', 'You can configure the app order used for the navigation bar. The first entry will be the default app, opened after login or when clicking on the logo.') }}
++ {{ t('theming', 'Pinned apps are shown directly in the navigation bar, while all other apps stay available in the apps menu.') }} +