Browse Source

Extract colour from custom background

Signed-off-by: John Molakvoæ <skjnldsv@protonmail.com>
John Molakvoæ 1 year ago
parent
commit
064fa10ecf

+ 98 - 0
.github/workflows/cypress.yml

@@ -0,0 +1,98 @@
+name: Cypress
+
+on:
+  pull_request:
+  push:
+    branches:
+      - master
+      - stable*
+
+env:
+  APP_NAME: viewer
+  BRANCH: ${{ github.base_ref }}
+  TESTING: true
+
+jobs:
+  init:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout server
+        uses: actions/checkout@v3
+
+      - name: Read package.json node and npm engines version
+        uses: skjnldsv/read-package-engines-version-actions@v1.2
+        id: versions
+        with:
+          fallbackNode: "^12"
+          fallbackNpm: "^6"
+
+      - name: Set up node ${{ steps.versions.outputs.nodeVersion }}
+        uses: actions/setup-node@v3
+        with:
+          cache: 'npm'
+          node-version: ${{ steps.versions.outputs.nodeVersion }}
+
+      - name: Set up npm ${{ steps.versions.outputs.npmVersion }}
+        run: npm i -g npm@"${{ steps.versions.outputs.npmVersion }}"
+
+      - name: Install dependencies & build app
+        run: |
+          npm ci
+          TESTING=true npm run build --if-present
+
+      - name: Save context
+        uses: actions/cache@v3
+        with:
+          key: cypress-context-${{ github.run_id }}
+          path: /home/runner/work/server
+
+  cypress:
+    runs-on: ubuntu-latest
+    needs: init
+
+    strategy:
+      fail-fast: false
+      matrix:
+        # run multiple copies of the current job in parallel
+        containers: [1]
+
+    name: runner ${{ matrix.containers }}
+
+    steps:
+      - name: Restore context
+        uses: actions/cache@v3
+        with:
+          key: cypress-context-${{ github.run_id }}
+          path: /home/runner/work/server
+
+      - name: Run E2E cypress tests
+        uses: cypress-io/github-action@v4
+        with:
+          record: true
+          parallel: true
+          # cypress env
+          ci-build-id: ${{ github.sha }}-${{ github.run_number }}
+          tag: ${{ github.event_name }}
+        env:
+          # Needs to be prefixed with CYPRESS_
+          CYPRESS_BRANCH: ${{ env.BRANCH }}
+          CYPRESS_GH: true
+          # https://github.com/cypress-io/github-action/issues/124
+          COMMIT_INFO_MESSAGE: ${{ github.event.pull_request.title }}
+          # Needed for some specific code workarounds
+          TESTING: true
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
+
+  summary:
+    runs-on: ubuntu-latest
+    needs: [init, cypress]
+
+    if: always()
+
+    name: cypress-summary
+
+    steps:
+      - name: Summary status
+        run: if ${{ needs.init.result != 'success' || ( needs.cypress.result != 'success' && needs.cypress.result != 'skipped' ) }}; then exit 1; fi

+ 4 - 0
.gitignore

@@ -163,3 +163,7 @@ composer.phar
 
 ./.htaccess
 core/js/mimetypelist.js
+
+# Tests - cypress
+cypress/snapshots
+cypress/videos

+ 2 - 3
apps/theming/css/default.css

@@ -54,9 +54,6 @@
   --background-invert-if-dark: no;
   --background-invert-if-bright: invert(100%);
   --background-image-invert-if-bright: no;
-  --image-background: url('/core/img/app-background.jpg');
-  --image-background-default: url('/core/img/app-background.jpg');
-  --color-background-plain: #0082c9;
   --primary-invert-if-bright: no;
   --color-primary: #006aa3;
   --color-primary-default: #0082c9;
@@ -75,4 +72,6 @@
   --color-primary-element-light-hover: #dbe5ea;
   --color-primary-element-text-dark: #ededed;
   --gradient-primary-background: linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
+  --image-background-default: url('/apps/theming/img/background/kamil-porembinski-clouds.jpg');
+  --color-background-plain: #0082c9;
 }

+ 0 - 148
apps/theming/css/settings-admin.css

@@ -1,148 +0,0 @@
-#theming input {
-  width: 230px;
-}
-#theming input:focus,
-#theming input:active {
-  padding-right: 30px;
-}
-#theming .fileupload {
-  display: none;
-}
-#theming div > label {
-  position: relative;
-}
-#theming .theme-undo {
-  position: absolute;
-  top: -7px;
-  right: 4px;
-  cursor: pointer;
-  opacity: 0.3;
-  padding: 7px;
-  vertical-align: top;
-  display: inline-block;
-  visibility: hidden;
-  height: 32px;
-  width: 32px;
-}
-#theming form.uploadButton {
-  width: 411px;
-  display: flex;
-  align-items: center;
-}
-#theming form .theme-undo,
-#theming .theme-remove-bg {
-  cursor: pointer;
-  opacity: 0.3;
-  padding: 7px;
-  vertical-align: top;
-  display: inline-block;
-  float: right;
-  position: relative;
-  top: 4px;
-  right: 0px;
-  visibility: visible;
-  height: 32px;
-  width: 32px;
-  margin-left: auto;
-}
-#theming form .theme-undo:not([style*="display:"]) ~ .theme-remove-bg {
-  margin-left: 0;
-}
-#theming input[type=text]:hover + .theme-undo,
-#theming input[type=text] + .theme-undo:hover,
-#theming input[type=text]:focus + .theme-undo,
-#theming input[type=text]:active + .theme-undo,
-#theming input[type=url]:hover + .theme-undo,
-#theming input[type=url] + .theme-undo:hover,
-#theming input[type=url]:focus + .theme-undo,
-#theming input[type=url]:active + .theme-undo {
-  visibility: visible;
-}
-#theming label span {
-  display: inline-block;
-  min-width: 175px;
-  max-width: 175px;
-  white-space: wrap;
-  padding: 8px 0px;
-  vertical-align: top;
-}
-#theming .icon-upload,
-#theming .uploadButton .icon-loading-small {
-  padding: 8px 20px;
-  width: 20px;
-  margin: 2px 0px;
-  min-height: 32px;
-  display: inline-block;
-}
-#theming #theming_settings_status {
-  height: 26px;
-  margin: 10px;
-}
-#theming #theming_settings_loading {
-  display: inline-block;
-  vertical-align: middle;
-  margin-right: 10px;
-}
-#theming #theming_settings_msg {
-  vertical-align: middle;
-  border-radius: 3px;
-}
-#theming #theming-preview {
-  width: 230px;
-  height: 140px;
-  background-size: cover;
-  background-position: center center;
-  text-align: center;
-  margin-left: 178px;
-  margin-top: 10px;
-  margin-bottom: 20px;
-  cursor: pointer;
-  background-color: var(--color-primary-default);
-  background-image: var(--image-background-default, var(--image-background-plain, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
-}
-#theming #theming-preview #theming-preview-logo {
-  cursor: pointer;
-  width: 20%;
-  height: 20%;
-  margin-top: 20px;
-  display: inline-block;
-  background-position: center;
-  background-repeat: no-repeat;
-  background-size: contain;
-  background-image: var(--image-logo, url("../../../core/img/logo/logo.svg"));
-}
-#theming .theming-hints {
-  margin-top: 20px;
-}
-#theming .image-preview {
-  display: inline-block;
-  width: 80px;
-  height: 36px;
-  background-position: center;
-  background-repeat: no-repeat;
-  background-size: contain;
-}
-#theming #theming-preview-logoheader {
-  background-image: var(--image-logoheader);
-}
-#theming #theming-preview-favicon {
-  background-image: var(--image-favicon);
-}
-#theming #user-theming {
-  margin-top: 44px;
-  display: flex;
-}
-#theming #user-theming > div {
-  max-width: 400px;
-  margin-bottom: 44px;
-}
-
-/* transition effects for theming value changes */
-#header {
-  transition: background-color 500ms linear;
-}
-#header svg, #header img {
-  transition: 500ms filter linear;
-}
-
-/*# sourceMappingURL=settings-admin.css.map */

+ 0 - 168
apps/theming/css/settings-admin.scss

@@ -1,168 +0,0 @@
-#theming {
-    input {
-        width: 230px;
-    }
-
-    input:focus,
-    input:active {
-        padding-right: 30px;
-    }
-
-    .fileupload {
-        display: none;
-    }
-
-    div > label {
-        position: relative;
-    }
-
-    .theme-undo {
-        position: absolute;
-        top: -7px; // input padding
-        right: 4px; // input right margin + border
-        cursor: pointer;
-        opacity: .3;
-        padding: 7px;
-        vertical-align: top;
-        display: inline-block;
-        visibility: hidden;
-        height: 32px; // height of input
-        width: 32px; // height of input
-    }
-    form.uploadButton {
-        width: 411px;
-        display: flex;
-        align-items: center;
-    }
-    form .theme-undo,
-    .theme-remove-bg {
-        cursor: pointer;
-        opacity: .3;
-        padding: 7px;
-        vertical-align: top;
-        display: inline-block;
-        float: right;
-        position: relative;
-        top: 4px;
-        right: 0px;
-        visibility: visible;
-        height: 32px;
-        width: 32px;
-        // right align
-        margin-left: auto;
-    }
-    form .theme-undo:not([style*="display:"]) ~ .theme-remove-bg {
-        // Only align the undo button if both are shown
-        margin-left: 0;
-    }
-
-    input[type='text']:hover + .theme-undo,
-    input[type='text'] + .theme-undo:hover,
-    input[type='text']:focus + .theme-undo,
-    input[type='text']:active + .theme-undo,
-    input[type='url']:hover + .theme-undo,
-    input[type='url'] + .theme-undo:hover,
-    input[type='url']:focus + .theme-undo,
-    input[type='url']:active + .theme-undo{
-        visibility: visible;
-    }
-
-    label span {
-        display: inline-block;
-        min-width: 175px;
-        max-width: 175px;
-        white-space: wrap;
-        padding: 8px 0px;
-        vertical-align: top;
-    }
-
-    .icon-upload,
-    .uploadButton .icon-loading-small {
-        padding: 8px 20px;
-        width: 20px;
-        margin: 2px 0px;
-        min-height: 32px;
-        display: inline-block;
-    }
-
-    #theming_settings_status {
-        height: 26px;
-        margin: 10px;
-    }
-
-    #theming_settings_loading {
-        display: inline-block;
-        vertical-align: middle;
-        margin-right: 10px;
-    }
-
-    #theming_settings_msg {
-        vertical-align: middle;
-        border-radius: 3px;
-    }
-
-    #theming-preview {
-        width: 230px;
-        height: 140px;
-        background-size: cover;
-        background-position: center center;
-        text-align: center;
-        margin-left: 178px;
-        margin-top: 10px;
-        margin-bottom: 20px;
-        cursor: pointer;
-        background-color: var(--color-primary-default);
-        background-image: var(--image-background-default, var(--image-background-plain, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
-
-        #theming-preview-logo {
-            cursor: pointer;
-            width: 20%;
-            height: 20%;
-            margin-top: 20px;
-            display: inline-block;
-            background-position: center;
-            background-repeat: no-repeat;
-            background-size: contain;
-            background-image: var(--image-logo, url('../../../core/img/logo/logo.svg'));
-        }
-    }
-
-    .theming-hints {
-        margin-top: 20px;
-    }
-
-    .image-preview {
-        display: inline-block;
-        width: 80px;
-        height: 36px;
-        background-position: center;
-        background-repeat: no-repeat;
-        background-size: contain;
-    }
-
-	#theming-preview-logoheader {
-        // Only using --image-logoheader to show the custom value only
-        background-image: var(--image-logoheader);
-    }
-
-	#theming-preview-favicon {
-        background-image: var(--image-favicon);
-    }
-
-    #user-theming {
-        margin-top: 44px;
-        display: flex;
-       & > div {
-            max-width: 400px;
-            margin-bottom: 44px;
-        }
-    }
-}
-
-/* transition effects for theming value changes */
-#header {
-    transition: background-color 500ms linear;
-    svg, img {
-        transition: 500ms filter linear;
-    }
-}

+ 10 - 5
apps/theming/lib/Controller/UserThemeController.php

@@ -168,9 +168,15 @@ class UserThemeController extends OCSController {
 	/**
 	 * @NoAdminRequired
 	 */
-	public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = ''): JSONResponse {
+	public function setBackground(string $type = BackgroundService::BACKGROUND_DEFAULT, string $value = '', string $color = null): JSONResponse {
 		$currentVersion = (int)$this->config->getUserValue($this->userId, Application::APP_ID, 'userCacheBuster', '0');
 
+		// Set color if provided
+		if ($color) {
+			$this->backgroundService->setColorBackground($color);
+		}
+
+		// Set background image if provided
 		try {
 			switch ($type) {
 				case BackgroundService::BACKGROUND_SHIPPED:
@@ -179,14 +185,13 @@ class UserThemeController extends OCSController {
 				case BackgroundService::BACKGROUND_CUSTOM:
 					$this->backgroundService->setFileBackground($value);
 					break;
-				case 'color':
-					$this->backgroundService->setColorBackground($value);
-					break;
 				case BackgroundService::BACKGROUND_DEFAULT:
 					$this->backgroundService->setDefaultBackground();
 					break;
 				default:
-					return new JSONResponse(['error' => 'Invalid type provided'], Http::STATUS_BAD_REQUEST);
+					if (!$color) {
+						return new JSONResponse(['error' => 'Invalid type provided'], Http::STATUS_BAD_REQUEST);
+					}
 			}
 		} catch (\InvalidArgumentException $e) {
 			return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);

+ 1 - 1
apps/theming/lib/ImageManager.php

@@ -94,7 +94,7 @@ class ImageManager {
 			case 'favicon':
 				return $this->urlGenerator->imagePath('core', 'logo/logo.png') . '?v=' . $cacheBusterCounter;
 			case 'background':
-				return $this->urlGenerator->linkTo(Application::APP_ID, "img/background/" . BackgroundService::DEFAULT_BACKGROUND);
+				return $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND);
 		}
 		return '';
 	}

+ 6 - 5
apps/theming/lib/Service/BackgroundService.php

@@ -30,7 +30,7 @@ namespace OCA\Theming\Service;
 use InvalidArgumentException;
 use OC\User\NoUserException;
 use OCA\Theming\AppInfo\Application;
-use OCP\Files\AppData\IAppDataFactory;
+use OCA\Theming\ThemingDefaults;
 use OCP\Files\File;
 use OCP\Files\IAppData;
 use OCP\Files\IRootFolder;
@@ -140,13 +140,13 @@ class BackgroundService {
 	private IAppData $appData;
 	private IConfig $config;
 	private string $userId;
-	private IAppDataFactory $appDataFactory;
+	private ThemingDefaults $themingDefaults;
 
 	public function __construct(IRootFolder $rootFolder,
 								IAppData $appData,
 								IConfig $config,
 								?string $userId,
-								IAppDataFactory $appDataFactory) {
+								ThemingDefaults $themingDefaults) {
 		if ($userId === null) {
 			return;
 		}
@@ -155,11 +155,12 @@ class BackgroundService {
 		$this->config = $config;
 		$this->userId = $userId;
 		$this->appData = $appData;
-		$this->appDataFactory = $appDataFactory;
+		$this->themingDefaults = $themingDefaults;
 	}
 
 	public function setDefaultBackground(): void {
 		$this->config->deleteUserValue($this->userId, Application::APP_ID, 'background_image');
+		$this->config->setUserValue($this->userId, Application::APP_ID, 'background_color', $this->themingDefaults->getDefaultColorPrimary());
 	}
 
 	/**
@@ -171,7 +172,7 @@ class BackgroundService {
 	 * @throws NoUserException
 	 */
 	public function setFileBackground($path): void {
-		$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_DEFAULT);
+		$this->config->setUserValue($this->userId, Application::APP_ID, 'background_image', self::BACKGROUND_CUSTOM);
 		$userFolder = $this->rootFolder->getUserFolder($this->userId);
 
 		/** @var File $file */

+ 2 - 3
apps/theming/lib/Themes/CommonThemeTrait.php

@@ -97,7 +97,7 @@ trait CommonThemeTrait {
 		if ($backgroundDeleted) {
 			$variables['--color-background-plain'] = $this->themingDefaults->getColorPrimary();
 			if ($this->themingDefaults->isUserThemingDisabled() || $user === null) {
-				$variables['--image-background-plain'] = 'true';
+				$variables['--image-background-plain'] = 'yes';
 			}
 		}
 
@@ -108,13 +108,12 @@ trait CommonThemeTrait {
 				if ($image === 'background') {
 					// If background deleted is set, ignoring variable
 					if ($backgroundDeleted) {
-						$variables['--image-background-default'] = 'no';
 						continue;
 					}
 					$variables['--image-background-size'] = 'cover';
 					$variables['--image-background-default'] = "url('" . $imageUrl . "')";
 				}
-				// --image-background is overriden by user theming
+				// --image-background is overridden by user theming
 				$variables["--image-$image"] = "url('" . $imageUrl . "')";
 			}
 		}

+ 1 - 1
apps/theming/lib/ThemingDefaults.php

@@ -247,7 +247,7 @@ class ThemingDefaults extends \OC_Defaults {
 	 * Return the default color primary
 	 */
 	public function getDefaultColorPrimary(): string {
-		$color = $this->config->getAppValue(Application::APP_ID, 'color');
+		$color = $this->config->getAppValue(Application::APP_ID, 'color', '');
 		if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $color)) {
 			$color = '#0082c9';
 		}

+ 9 - 2
apps/theming/src/AdminTheming.vue

@@ -285,8 +285,15 @@ export default {
 		background-position: center;
 		text-align: center;
 		margin-top: 10px;
-		background-color: var(--color-primary-default);
-		background-image: var(--image-background-default, var(--image-background-plain, url('../../../core/img/app-background.jpg'), linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
+		/* This is basically https://github.com/nextcloud/server/blob/master/core/css/guest.css
+		   But without the user variables. That way the admin can preview the render as guest*/
+		/* As guest, there is no user color color-background-plain */
+		background-color: var(--color-primary-default, #0082c9);
+		/* As guest, there is no user background (--image-background)
+		1. Empty background if defined
+		2. Else default background
+		3. Finally default gradient (should not happened, the background is always defined anyway) */
+		background-image: var(--image-background-plain, var(--image-background-default, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
 
 		&-logo {
 			width: 20%;

+ 88 - 26
apps/theming/src/components/BackgroundSettings.vue

@@ -1,10 +1,10 @@
 <!--
   - @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
-  - @copyright Copyright (c) 2022 Greta Doci <gretadoci@gmail.com>
   -
-  - @author Julius Härtl <jus@bitgrid.net>
-  - @author Greta Doci <gretadoci@gmail.com>
   - @author Christopher Ng <chrng8@gmail.com>
+  - @author Greta Doci <gretadoci@gmail.com>
+  - @author John Molakvoæ <skjnldsv@protonmail.com>
+  - @author Julius Härtl <jus@bitgrid.net>
   -
   - @license GNU AGPL version 3 or any later version
   -
@@ -24,13 +24,16 @@
   -->
 
 <template>
-	<div class="background-selector">
+	<div class="background-selector" data-user-theming-background-settings>
 		<!-- Custom background -->
 		<button class="background background__filepicker"
-			:class="{ 'background--active': backgroundImage === 'custom' }"
+			:class="{ 'icon-loading': loading === 'custom', 'background--active': backgroundImage === 'custom' }"
+			:data-color-bright="invertTextColor(Theming.color)"
+			data-user-theming-background-custom
 			tabindex="0"
 			@click="pickFile">
 			{{ t('theming', 'Custom background') }}
+			<Check :size="44" />
 		</button>
 
 		<!-- Default background -->
@@ -38,6 +41,7 @@
 			:class="{ 'icon-loading': loading === 'default', 'background--active': backgroundImage === 'default' }"
 			:data-color-bright="invertTextColor(Theming.defaultColor)"
 			:style="{ '--border-color': Theming.defaultColor }"
+			data-user-theming-background-default
 			tabindex="0"
 			@click="setDefault">
 			{{ t('theming', 'Default background') }}
@@ -50,6 +54,7 @@
 				:data-color="Theming.color"
 				:data-color-bright="invertTextColor(Theming.color)"
 				:style="{ backgroundColor: Theming.color, '--border-color': Theming.color}"
+				data-user-theming-background-color
 				tabindex="0">
 				{{ t('theming', 'Change color') }}
 			</button>
@@ -61,6 +66,7 @@
 			v-tooltip="shippedBackground.details.attribution"
 			:class="{ 'icon-loading': loading === shippedBackground.name, 'background--active': backgroundImage === shippedBackground.name }"
 			:data-color-bright="shippedBackground.details.theming === 'dark'"
+			:data-user-theming-background-shipped="shippedBackground.name"
 			:style="{ backgroundImage: 'url(' + shippedBackground.preview + ')', '--border-color': shippedBackground.details.primary_color }"
 			class="background background__shipped"
 			tabindex="0"
@@ -70,16 +76,17 @@
 
 		<!-- Remove background -->
 		<button class="background background__delete"
+			data-user-theming-background-clear
 			tabindex="0"
 			@click="removeBackground">
 			{{ t('theming', 'Remove background') }}
-			<Close :size="24" />
+			<Close :size="32" />
 		</button>
 	</div>
 </template>
 
 <script>
-import { generateFilePath, generateUrl } from '@nextcloud/router'
+import { generateFilePath, generateRemoteUrl, generateUrl } from '@nextcloud/router'
 import { loadState } from '@nextcloud/initial-state'
 import axios from '@nextcloud/axios'
 import Check from 'vue-material-design-icons/Check.vue'
@@ -87,6 +94,10 @@ import Close from 'vue-material-design-icons/Close.vue'
 import debounce from 'debounce'
 import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker'
 import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip'
+import Vibrant from 'node-vibrant'
+import { Palette } from 'node-vibrant/lib/color'
+import { getFilePickerBuilder } from '@nextcloud/dialogs'
+import { getCurrentUser } from '@nextcloud/auth'
 
 const backgroundColor = loadState('theming', 'backgroundColor')
 const backgroundImage = loadState('theming', 'backgroundImage')
@@ -95,6 +106,12 @@ const themingDefaultBackground = loadState('theming', 'themingDefaultBackground'
 const defaultShippedBackground = loadState('theming', 'defaultShippedBackground')
 
 const prefixWithBaseUrl = (url) => generateFilePath('theming', '', 'img/background/') + url
+const picker = getFilePickerBuilder(t('theming', 'Select a background from your files'))
+	.setMultiSelect(false)
+	.setModal(true)
+	.setType(1)
+	.setMimeTypeFilter(['image/png', 'image/gif', 'image/jpeg', 'image/svg+xml', 'image/svg'])
+	.build()
 
 export default {
 	name: 'BackgroundSettings',
@@ -213,9 +230,9 @@ export default {
 			this.update(result.data)
 		},
 
-		async setFile(path) {
+		async setFile(path, color = null) {
 			this.loading = 'custom'
-			const result = await axios.post(generateUrl('/apps/theming/background/custom'), { value: path })
+			const result = await axios.post(generateUrl('/apps/theming/background/custom'), { value: path, color })
 			this.update(result.data)
 		},
 
@@ -228,19 +245,55 @@ export default {
 		async pickColor(event) {
 			this.loading = 'color'
 			const color = event?.target?.dataset?.color || this.Theming?.color || '#0082c9'
-			const result = await axios.post(generateUrl('/apps/theming/background/color'), { value: color })
+			const result = await axios.post(generateUrl('/apps/theming/background/color'), { color })
 			this.update(result.data)
 		},
 		debouncePickColor: debounce(function() {
 			this.pickColor(...arguments)
 		}, 200),
 
-		pickFile() {
-			window.OC.dialogs.filepicker(t('theming', 'Select a background from your files'), (path, type) => {
-				if (type === OC.dialogs.FILEPICKER_TYPE_CHOOSE) {
-					this.setFile(path)
-				}
-			}, false, ['image/png', 'image/gif', 'image/jpeg', 'image/svg'], true, OC.dialogs.FILEPICKER_TYPE_CHOOSE)
+		async pickFile() {
+			const path = await picker.pick()
+			this.loading = 'custom'
+
+			// Extract primary color from image
+			let response = null
+			let color = null
+			try {
+				const fileUrl = generateRemoteUrl('dav/files/' + getCurrentUser().uid + path)
+				response = await axios.get(fileUrl, { responseType: 'blob' })
+				const blobUrl = URL.createObjectURL(response.data)
+				const palette = await this.getColorPaletteFromBlob(blobUrl)
+
+				// DarkVibrant is accessible AND visually pleasing
+				// Vibrant is not accessible enough and others are boring
+				color = palette?.DarkVibrant?.hex
+				this.setFile(path, color)
+
+				// Log data
+				console.debug('Extracted colour', color, 'from custom image', path, palette)
+			} catch (error) {
+				this.setFile(path)
+				console.error('Unable to extract colour from custom image', { error, path, response, color })
+			}
+		},
+
+		/**
+		 * Extract a Vibrant color palette from a blob URL
+		 *
+		 * @param {string} blobUrl the blob URL
+		 * @return {Promise<Palette>}
+		 */
+		getColorPaletteFromBlob(blobUrl) {
+			return new Promise((resolve, reject) => {
+				const vibrant = new Vibrant(blobUrl)
+				vibrant.getPalette((error, palette) => {
+					if (error) {
+						reject(error)
+					}
+					resolve(palette)
+				})
+			})
 		},
 	},
 }
@@ -263,6 +316,13 @@ export default {
 		background-position: center center;
 		background-size: cover;
 
+		&__filepicker {
+			&.background--active {
+				color: white;
+				background-image: var(--image-background);
+			}
+		}
+
 		&__default {
 			background-color: var(--color-primary-default);
 			background-image: var(--image-background-default);
@@ -277,6 +337,12 @@ export default {
 			background-color: var(--color-primary-default);
 		}
 
+		// Over a background image
+		&__default,
+		&__shipped {
+			color: white;
+		}
+
 		// Text and svg icon dark on bright background
 		&[data-color-bright] {
 			color: black;
@@ -294,18 +360,14 @@ export default {
 			margin: 4px;
 		}
 
-		&__default,
-		&__shipped {
-			color: white;
-			span {
-				display: none;
-			}
+		&__filepicker span,
+		&__default span,
+		&__shipped span {
+			display: none;
 		}
 
-		&--active:not(.icon-loading) {
-			span {
-				display: block;
-			}
+		&--active:not(.icon-loading) span {
+			display: block !important;
 		}
 	}
 }

+ 1 - 1
apps/theming/tests/Controller/ThemingControllerTest.php

@@ -680,7 +680,7 @@ class ThemingControllerTest extends TestCase {
 
 	public function testGetLoginBackground() {
 		$file = $this->createMock(ISimpleFile::class);
-		$file->method('getName')->willReturn('app-background.jpg');
+		$file->method('getName')->willReturn('background.png');
 		$file->method('getMTime')->willReturn(42);
 		$this->imageManager->expects($this->once())
 			->method('getImage')

+ 7 - 0
apps/theming/tests/Themes/DefaultThemeTest.php

@@ -22,8 +22,10 @@
  */
 namespace OCA\Theming\Tests\Service;
 
+use OCA\Theming\AppInfo\Application;
 use OCA\Theming\ImageManager;
 use OCA\Theming\ITheme;
+use OCA\Theming\Service\BackgroundService;
 use OCA\Theming\Themes\DefaultTheme;
 use OCA\Theming\ThemingDefaults;
 use OCA\Theming\Util;
@@ -80,6 +82,11 @@ class DefaultThemeTest extends TestCase {
 			->method('getDefaultColorPrimary')
 			->willReturn('#0082c9');
 
+		$this->themingDefaults
+			->expects($this->any())
+			->method('getBackground')
+			->willReturn('/apps/' . Application::APP_ID . '/img/background/' . BackgroundService::DEFAULT_BACKGROUND);
+
 		$this->l10n
 			->expects($this->any())
 			->method('t')

+ 14 - 14
apps/theming/tests/ThemingDefaultsTest.php

@@ -473,6 +473,7 @@ class ThemingDefaultsTest extends TestCase {
 	public function testGetColorPrimaryWithCustomBackground() {
 		$backgroundIndex = 2;
 		$background = array_values(BackgroundService::SHIPPED_BACKGROUNDS)[$backgroundIndex];
+
 		$user = $this->createMock(IUser::class);
 		$this->userSession->expects($this->any())
 			->method('getUser')
@@ -484,14 +485,15 @@ class ThemingDefaultsTest extends TestCase {
 		$this->config
 			->expects($this->once())
 			->method('getUserValue')
-			->with('user', 'theming', 'background_image', '')
-			->willReturn(array_keys(BackgroundService::SHIPPED_BACKGROUNDS)[$backgroundIndex]);
+			->with('user', 'theming', 'background_color', '')
+			->willReturn($background['primary_color']);
+
 		$this->config
 			->expects($this->exactly(2))
 			->method('getAppValue')
 			->willReturnMap([
-				['theming', 'disable-user-theming', 'no', 'no'],
 				['theming', 'color', '', ''],
+				['theming', 'disable-user-theming', 'no', 'no'],
 			]);
 
 		$this->assertEquals($background['primary_color'], $this->template->getColorPrimary());
@@ -509,14 +511,14 @@ class ThemingDefaultsTest extends TestCase {
 		$this->config
 			->expects($this->once())
 			->method('getUserValue')
-			->with('user', 'theming', 'background_image', '')
+			->with('user', 'theming', 'background_color', '')
 			->willReturn('#fff');
 		$this->config
 			->expects($this->exactly(2))
 			->method('getAppValue')
 			->willReturnMap([
-				['theming', 'disable-user-theming', 'no', 'no'],
 				['theming', 'color', '', ''],
+				['theming', 'disable-user-theming', 'no', 'no'],
 			]);
 
 		$this->assertEquals('#fff', $this->template->getColorPrimary());
@@ -534,14 +536,14 @@ class ThemingDefaultsTest extends TestCase {
 		$this->config
 			->expects($this->once())
 			->method('getUserValue')
-			->with('user', 'theming', 'background_image', '')
+			->with('user', 'theming', 'background_color', '')
 			->willReturn('nextcloud');
 		$this->config
 			->expects($this->exactly(3))
 			->method('getAppValue')
 			->willReturnMap([
-				['theming', 'disable-user-theming', 'no', 'no'],
 				['theming', 'color', '', ''],
+				['theming', 'disable-user-theming', 'no', 'no'],
 			]);
 
 		$this->assertEquals($this->template->getDefaultColorPrimary(), $this->template->getColorPrimary());
@@ -650,16 +652,14 @@ class ThemingDefaultsTest extends TestCase {
 			->method('deleteAppValue')
 			->with('theming', 'color');
 		$this->config
-			->expects($this->exactly(3))
+			->expects($this->exactly(2))
 			->method('getAppValue')
 			->withConsecutive(
 				['theming', 'cachebuster', '0'],
 				['theming', 'color', null],
-				['theming', 'disable-user-theming', 'no'],
 			)->willReturnOnConsecutiveCalls(
 				'15',
 				$this->defaults->getColorPrimary(),
-				'no',
 			);
 		$this->config
 			->expects($this->once())
@@ -778,10 +778,10 @@ class ThemingDefaultsTest extends TestCase {
 		$this->imageManager->expects($this->exactly(4))
 			->method('getImageUrl')
 			->willReturnMap([
-				['logo', true, 'custom-logo?v=0'],
-				['logoheader', true, 'custom-logoheader?v=0'],
-				['favicon', true, 'custom-favicon?v=0'],
-				['background_image', true, 'custom-background?v=0'],
+				['logo', 'custom-logo?v=0'],
+				['logoheader', 'custom-logoheader?v=0'],
+				['favicon', 'custom-favicon?v=0'],
+				['background', 'custom-background?v=0'],
 			]);
 
 		$expected = [

+ 1 - 4
core/css/apps.css

@@ -90,14 +90,11 @@ html {
   height: 100%;
   position: absolute;
   background-color: var(--color-background-plain, var(--color-main-background));
-  background-image: var(--image-background);
-  background-size: cover;
-  background-position: center;
 }
 
 body {
   background-color: var(--color-background-plain, var(--color-main-background));
-  background-image: var(--image-background-plain, var(--image-background, var(--image-background-default)));
+  background-image: var(--image-background, var(--image-background-default));
   background-size: cover;
   background-position: center;
   position: fixed;

File diff suppressed because it is too large
+ 0 - 0
core/css/apps.css.map


+ 4 - 4
core/css/apps.scss

@@ -39,15 +39,15 @@ html {
 	width: 100%;
 	height: 100%;
 	position: absolute;
+	// color-background-plain should always be defined. It is the primary user colour
 	background-color: var(--color-background-plain, var(--color-main-background));
-	background-image: var(--image-background);
-	background-size: cover;
-	background-position: center;
 }
 
 body {
+	// color-background-plain should always be defined. It is the primary user colour
 	background-color: var(--color-background-plain, var(--color-main-background));
-	background-image: var(--image-background-plain, var(--image-background, var(--image-background-default)));
+	// color-background-plain should always be defined. It is the primary user colour
+	background-image: var(--image-background, var(--image-background-default));
 	background-size: cover;
 	background-position: center;
 	position: fixed;

+ 8 - 2
core/css/guest.css

@@ -23,8 +23,14 @@ body {
 	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
 	color: var(--color-text);
 	text-align: center;
-	background-color: var(--color-main-background-not-plain, var(--color-primary));
-	background-image: var(--image-background, var(--image-background-plain, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
+	/* As guest, there is no color-background-plain */
+	background-color: var(--color-background-plain, var(--color-primary-default, #0082c9));
+	/* As guest, there is no user background (--image-background)
+	1. User background if logged in ('no' if removed, that way the variable is _defined_)
+	2. Empty background if enabled ('yes' is used, that way the variable is _defined_)
+	3. Else default background
+	4. Finally default gradient (should not happened, the background is always defined anyway) */
+	background-image: var(--image-background, var(--image-background-plain, var(--image-background-default, linear-gradient(40deg, #0082c9 0%, #30b6ff 100%))));
     background-attachment: fixed;
 	min-height: 100%; /* fix sticky footer */
 	height: auto;

+ 1 - 4
core/css/server.css

@@ -2672,14 +2672,11 @@ html {
   height: 100%;
   position: absolute;
   background-color: var(--color-background-plain, var(--color-main-background));
-  background-image: var(--image-background);
-  background-size: cover;
-  background-position: center;
 }
 
 body {
   background-color: var(--color-background-plain, var(--color-main-background));
-  background-image: var(--image-background-plain, var(--image-background, var(--image-background-default)));
+  background-image: var(--image-background, var(--image-background-default));
   background-size: cover;
   background-position: center;
   position: fixed;

File diff suppressed because it is too large
+ 0 - 0
core/css/server.css.map


+ 85 - 0
cypress.config.ts

@@ -0,0 +1,85 @@
+/* eslint-disable node/no-unpublished-import */
+import { applyChangesToNextcloud, configureNextcloud, preppingNextcloud, startNextcloud, stopNextcloud, waitOnNextcloud } from './cypress/dockerNode'
+import { defineConfig } from 'cypress'
+
+import browserify from '@cypress/browserify-preprocessor'
+
+export default defineConfig({
+	projectId: '37xpdh',
+
+	// 16/9 screen ratio
+	viewportWidth: 1280,
+	viewportHeight: 720,
+
+	// Tries again 2 more times on failure
+	retries: {
+		runMode: 2,
+		// do not retry in `cypress open`
+		openMode: 0,
+	},
+
+	// Needed to trigger `after:run` events with cypress open
+	experimentalInteractiveRunEvents: true,
+
+	// faster video processing
+	videoCompression: false,
+
+	// Visual regression testing
+	env: {
+		failSilently: false,
+		type: 'actual',
+	},
+	screenshotsFolder: 'cypress/snapshots/actual',
+	trashAssetsBeforeRuns: true,
+
+	e2e: {
+		// Enable session management and disable isolation
+		experimentalSessionAndOrigin: true,
+		testIsolation: 'off',
+
+		// We've imported your old cypress plugins here.
+		// You may want to clean this up later by importing these.
+		async setupNodeEvents(on, config) {
+			// Fix browserslist extend https://github.com/cypress-io/cypress/issues/2983#issuecomment-570616682
+			on('file:preprocessor', browserify({ typescript: require.resolve('typescript') }))
+
+			// Disable spell checking to prevent rendering differences
+			on('before:browser:launch', (browser, launchOptions) => {
+				if (browser.family === 'chromium' && browser.name !== 'electron') {
+					launchOptions.preferences.default['browser.enable_spellchecking'] = false
+					return launchOptions
+				}
+
+				if (browser.family === 'firefox') {
+					launchOptions.preferences['layout.spellcheckDefault'] = 0
+					return launchOptions
+				}
+
+				if (browser.name === 'electron') {
+					launchOptions.preferences.spellcheck = false
+					return launchOptions
+				}
+			})
+
+			// Remove container after run
+			on('after:run', () => {
+				stopNextcloud()
+			})
+
+			// Before the browser launches
+			// starting Nextcloud testing container
+			return startNextcloud(process.env.BRANCH)
+				.then((ip) => {
+					// Setting container's IP as base Url
+					config.baseUrl = `http://${ip}/index.php`
+					return ip
+				})
+				.then(waitOnNextcloud)
+				.then(configureNextcloud)
+				.then(applyChangesToNextcloud)
+				.then(() => {
+					return config
+				})
+		},
+	},
+})

+ 243 - 0
cypress/dockerNode.ts

@@ -0,0 +1,243 @@
+/**
+ * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+/* eslint-disable no-console */
+/* eslint-disable node/no-unpublished-import */
+
+import Docker from 'dockerode'
+import waitOn from 'wait-on'
+import tar from 'tar'
+
+export const docker = new Docker()
+
+const CONTAINER_NAME = 'nextcloud-cypress-tests-server'
+const SERVER_IMAGE = 'ghcr.io/nextcloud/continuous-integration-shallow-server'
+
+/**
+ * Start the testing container
+ *
+ * @param {string} branch the branch of your current work
+ */
+export const startNextcloud = async function(branch: string = 'master'): Promise<any> {
+
+	try {
+		// Pulling images
+		console.log('\nPulling images... ⏳')
+		await new Promise((resolve, reject): any => docker.pull(SERVER_IMAGE, (err, stream) => {
+			if (err) {
+				reject(err)
+			}
+			// https://github.com/apocas/dockerode/issues/357
+			docker.modem.followProgress(stream, onFinished)
+
+			function onFinished(err) {
+				if (!err) {
+					resolve(true)
+					return
+				}
+				reject(err)
+			}
+		}))
+		console.log('└─ Done')
+
+		// Remove old container if exists
+		console.log('\nChecking running containers... 🔍')
+		try {
+			const oldContainer = docker.getContainer(CONTAINER_NAME)
+			const oldContainerData = await oldContainer.inspect()
+			if (oldContainerData) {
+				console.log('├─ Existing running container found')
+				console.log('├─ Removing... ⏳')
+				// Forcing any remnants to be removed just in case
+				await oldContainer.remove({ force: true })
+				console.log('└─ Done')
+			}
+		} catch (error) {
+			console.log('└─ None found!')
+		}
+
+		// Starting container
+		console.log('\nStarting Nextcloud container... 🚀')
+		console.log(`├─ Using branch '${branch}'`)
+		const container = await docker.createContainer({
+			Image: SERVER_IMAGE,
+			name: CONTAINER_NAME,
+			HostConfig: {
+				Binds: [],
+			},
+		})
+		await container.start()
+
+		// Get container's IP
+		const ip = await getContainerIP(container)
+
+		console.log(`├─ Nextcloud container's IP is ${ip} 🌏`)
+		return ip
+	} catch (err) {
+		console.log('└─ Unable to start the container 🛑')
+		console.log(err)
+		stopNextcloud()
+		throw new Error('Unable to start the container')
+	}
+}
+
+/**
+ * Configure Nextcloud
+ */
+export const configureNextcloud = async function() {
+	console.log('\nConfiguring nextcloud...')
+	const container = docker.getContainer(CONTAINER_NAME)
+	await runExec(container, ['php', 'occ', '--version'], true)
+
+	// Be consistent for screenshots
+	await runExec(container, ['php', 'occ', 'config:system:set', 'default_language', '--value', 'en'], true)
+	await runExec(container, ['php', 'occ', 'config:system:set', 'force_language', '--value', 'en'], true)
+	await runExec(container, ['php', 'occ', 'config:system:set', 'default_locale', '--value', 'en_US'], true)
+	await runExec(container, ['php', 'occ', 'config:system:set', 'force_locale', '--value', 'en_US'], true)
+	await runExec(container, ['php', 'occ', 'config:system:set', 'enforce_theme', '--value', 'light'], true)
+
+	// Enable the app and give status
+	await runExec(container, ['php', 'occ', 'app:enable', '--force', 'viewer'], true)
+	// await runExec(container, ['php', 'occ', 'app:list'], true)
+
+	console.log('└─ Nextcloud is now ready to use 🎉')
+}
+
+/**
+ * Applying local changes to the container
+ * Only triggered if we're not in CI. Otherwise the
+ * continuous-integration-shallow-server image will
+ * already fetch the proper branch.
+ */
+export const applyChangesToNextcloud = async function() {
+	console.log('\nApply local changes to nextcloud...')
+	const container = docker.getContainer(CONTAINER_NAME)
+
+	const htmlPath = '/var/www/html'
+	const folderPaths = [
+		'./apps',
+		'./core',
+		'./dist',
+		'./lib',
+		'./ocs',
+	]
+
+	// Tar-streaming the above folder sinto the container
+	const serverTar = tar.c({ gzip: false }, folderPaths)
+	await container.putArchive(serverTar, {
+		path: htmlPath,
+	})
+
+	// Making sure we have the proper permissions
+	await runExec(container, ['chown', '-R', 'www-data:www-data', htmlPath], false, 'root')
+
+	console.log('└─ Changes applied successfully 🎉')
+}
+
+/**
+ * Force stop the testing container
+ */
+export const stopNextcloud = async function() {
+	try {
+		const container = docker.getContainer(CONTAINER_NAME)
+		console.log('Stopping Nextcloud container...')
+		container.remove({ force: true })
+		console.log('└─ Nextcloud container removed 🥀')
+	} catch (err) {
+		console.log(err)
+	}
+}
+
+/**
+ * Get the testing container's IP
+ *
+ * @param {Docker.Container} container the container to get the IP from
+ */
+export const getContainerIP = async function(
+	container = docker.getContainer(CONTAINER_NAME)
+): Promise<string> {
+	let ip = ''
+	let tries = 0
+	while (ip === '' && tries < 10) {
+		tries++
+
+		await container.inspect(function(err, data) {
+			if (err) {
+				throw err
+			}
+			ip = data?.NetworkSettings?.IPAddress || ''
+		})
+
+		if (ip !== '') {
+			break
+		}
+
+		await sleep(1000 * tries)
+	}
+
+	return ip
+}
+
+// Would be simpler to start the container from cypress.config.ts,
+// but when checking out different branches, it can take a few seconds
+// Until we can properly configure the baseUrl retry intervals,
+// We need to make sure the server is already running before cypress
+// https://github.com/cypress-io/cypress/issues/22676
+export const waitOnNextcloud = async function(ip: string) {
+	console.log('├─ Waiting for Nextcloud to be ready... ⏳')
+	await waitOn({ resources: [`http://${ip}/index.php`] })
+	console.log('└─ Done')
+}
+
+const runExec = async function(
+	container: Docker.Container,
+	command: string[],
+	verbose = false,
+	user = 'www-data'
+) {
+	const exec = await container.exec({
+		Cmd: command,
+		AttachStdout: true,
+		AttachStderr: true,
+		User: user,
+	})
+
+	return new Promise((resolve, reject) => {
+		exec.start({}, (err, stream) => {
+			if (err) {
+				reject(err)
+			}
+			if (stream) {
+				stream.setEncoding('utf-8')
+				stream.on('data', str => {
+					if (verbose && str.trim() !== '') {
+						console.log(`├─ ${str.trim().replace(/\n/gi, '\n├─ ')}`)
+					}
+				})
+				stream.on('end', resolve)
+			}
+		})
+	})
+}
+
+const sleep = function(milliseconds: number) {
+	return new Promise((resolve) => setTimeout(resolve, milliseconds))
+}

+ 37 - 0
cypress/e2e/files.cy.ts

@@ -0,0 +1,37 @@
+/**
+ * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+describe('Login with a new user and open the files app', function() {
+	before(function() {
+		cy.createRandomUser().then((user) => {
+			cy.login(user)
+		})
+	})
+
+	after(function() {
+		cy.logout()
+	})
+
+	it('See the default file welcome.txt in the files list', function() {
+		cy.visit('/apps/files')
+		cy.get('.files-fileList tr').should('contain', 'welcome.txt')
+	})
+})

+ 164 - 0
cypress/e2e/theming/user-background.cy.ts

@@ -0,0 +1,164 @@
+/**
+ * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import type { User } from '@nextcloud/cypress'
+
+const defaultPrimary = '#006aa3'
+const defaultBackground = 'kamil-porembinski-clouds.jpg'
+
+const validateThemingCss = function(expectedPrimary = '#0082c9', expectedBackground = 'kamil-porembinski-clouds.jpg', bright = false) {
+	return cy.window().then((win) => {
+		const primary = getComputedStyle(win.document.body).getPropertyValue('--color-primary')
+		const background = getComputedStyle(win.document.body).getPropertyValue('--image-background')
+		const invertIfBright = getComputedStyle(win.document.body).getPropertyValue('--background-image-invert-if-bright')
+
+		// Returning boolean for cy.waitUntil usage
+		return primary === expectedPrimary
+			&& background.includes(expectedBackground)
+			&& invertIfBright === (bright ? 'invert(100%)' : 'no')
+	})
+}
+
+describe('User default background settings', function() {
+	before(function() {
+		cy.createRandomUser().then((user: User) => {
+			cy.login(user)
+		})
+	})
+
+	it('See the user background settings', function() {
+		cy.visit('/settings/user/theming')
+		cy.get('[data-user-theming-background-settings]').scrollIntoView().should('be.visible')
+	})
+
+	// Default cloud background is not rendered if admin theming background remains unchanged
+	it('Default cloud background is not rendered', function() {
+		cy.get(`[data-user-theming-background-shipped="${defaultBackground}"]`).should('not.exist')
+	})
+
+	it('Default is selected on new users', function() {
+		cy.get('[data-user-theming-background-default]').should('be.visible')
+		cy.get('[data-user-theming-background-default]').should('have.class', 'background--active')
+	})
+})
+
+describe('User select shipped backgrounds', function() {
+	before(function() {
+		cy.createRandomUser().then((user: User) => {
+			cy.login(user)
+		})
+	})
+
+	it('See the user background settings', function() {
+		cy.visit('/settings/user/theming')
+		cy.get('[data-user-theming-background-settings]').scrollIntoView().should('be.visible')
+	})
+
+	it('Select a shipped background', function() {
+		const background = 'anatoly-mikhaltsov-butterfly-wing-scale.jpg'
+		cy.intercept('*/apps/theming/background/shipped').as('setBackground')
+
+		// Select background
+		cy.get(`[data-user-theming-background-shipped="${background}"]`).click()
+
+		// Validate changed background and primary
+		cy.wait('@setBackground')
+		cy.waitUntil(() => validateThemingCss('#a53c17', background))
+	})
+
+	it('Select a bright shipped background', function() {
+		const background = 'bernie-cetonia-aurata-take-off-composition.jpg'
+		cy.intercept('*/apps/theming/background/shipped').as('setBackground')
+
+		// Select background
+		cy.get(`[data-user-theming-background-shipped="${background}"]`).click()
+
+		// Validate changed background and primary
+		cy.wait('@setBackground')
+		cy.waitUntil(() => validateThemingCss('#56633d', background, true))
+	})
+
+	it('Remove background', function() {
+		cy.intercept('*/apps/theming/background/custom').as('clearBackground')
+
+		// Clear background
+		cy.get('[data-user-theming-background-clear]').click()
+
+		// Validate clear background
+		cy.wait('@clearBackground')
+		cy.waitUntil(() => validateThemingCss('#56633d', ''))
+	})
+})
+
+describe('User select a custom color', function() {
+	before(function() {
+		cy.createRandomUser().then((user: User) => {
+			cy.login(user)
+		})
+	})
+
+	it('See the user background settings', function() {
+		cy.visit('/settings/user/theming')
+		cy.get('[data-user-theming-background-settings]').scrollIntoView().should('be.visible')
+	})
+
+	it('Select a custom color', function() {
+		cy.intercept('*/apps/theming/background/color').as('setColor')
+
+		cy.get('[data-user-theming-background-color]').click()
+		cy.get('.color-picker__simple-color-circle:eq(3)').click()
+
+		// Validate clear background
+		cy.wait('@setColor')
+		cy.waitUntil(() => cy.window().then((win) => {
+			const primary = getComputedStyle(win.document.body).getPropertyValue('--color-primary')
+			return primary !== defaultPrimary
+		}))
+	})
+})
+
+describe('User select a custom background', function() {
+	const image = 'image.jpg'
+	before(function() {
+		cy.createRandomUser().then((user: User) => {
+			cy.uploadFile(user, image, 'image/jpeg')
+			cy.login(user)
+		})
+	})
+
+	it('See the user background settings', function() {
+		cy.visit('/settings/user/theming')
+		cy.get('[data-user-theming-background-settings]').scrollIntoView().should('be.visible')
+	})
+
+	it('Select a custom background', function() {
+		cy.intercept('*/apps/theming/background/custom').as('setBackground')
+
+		// Pick background
+		cy.get('[data-user-theming-background-custom]').click()
+		cy.get(`#picker-filestable tr[data-entryname="${image}"]`).click()
+		cy.get('#oc-dialog-filepicker-content ~ .oc-dialog-buttonrow button.primary').click()
+
+		// Wait for background to be set
+		cy.wait('@setBackground')
+		cy.waitUntil(() => validateThemingCss('#4c0c04', 'apps/theming/background?v='))
+	})
+})

BIN
cypress/fixtures/image.jpg


+ 86 - 0
cypress/support/commands.ts

@@ -0,0 +1,86 @@
+/**
+ * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+/* eslint-disable node/no-unpublished-import */
+import axios from '@nextcloud/axios'
+import { addCommands, type User} from '@nextcloud/cypress'
+import { basename } from 'path'
+
+// Add custom commands
+import 'cypress-wait-until'
+addCommands()
+
+// Register this file's custom commands types
+declare global {
+	// eslint-disable-next-line @typescript-eslint/no-namespace
+	namespace Cypress {
+		interface Chainable<Subject = any> {
+			uploadFile(user: User, fixture: string, mimeType: string, target ?: string): Cypress.Chainable<void>
+		}
+	}
+}
+
+const url = (Cypress.config('baseUrl') || '').replace(/\/index.php\/?$/g, '')
+Cypress.env('baseUrl', url)
+
+/**
+ * cy.uploadedFile - uploads a file from the fixtures folder
+ * TODO: standardise in @nextcloud/cypress
+ *
+ * @param {User} user the owner of the file, e.g. admin
+ * @param {string} fixture the fixture file name, e.g. image1.jpg
+ * @param {string} mimeType e.g. image/png
+ * @param {string} [target] the target of the file relative to the user root
+ */
+Cypress.Commands.add('uploadFile', (user, fixture, mimeType, target = `/${fixture}`) => {
+	cy.clearCookies()
+	const fileName = basename(target)
+
+	// get fixture
+	return cy.fixture(fixture, 'base64').then(async file => {
+		// convert the base64 string to a blob
+		const blob = Cypress.Blob.base64StringToBlob(file, mimeType)
+
+		// Process paths
+		const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
+		const filePath = target.split('/').map(encodeURIComponent).join('/')
+		try {
+			const file = new File([blob], fileName, { type: mimeType })
+			await axios({
+				url: `${rootPath}${filePath}`,
+				method: 'PUT',
+				data: file,
+				headers: {
+					'Content-Type': mimeType,
+				},
+				auth: {
+					username: user.userId,
+					password: user.password,
+				},
+			}).then(response => {
+				cy.log(`Uploaded ${fixture} as ${fileName}`, response)
+			})
+		} catch (error) {
+			cy.log('error', error)
+			throw new Error(`Unable to process fixture ${fixture}`)
+		}
+	})
+})

+ 22 - 0
cypress/support/e2e.ts

@@ -0,0 +1,22 @@
+/**
+ * @copyright Copyright (c) 2022 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import './commands'

+ 7 - 0
cypress/tsconfig.json

@@ -0,0 +1,7 @@
+{
+	"extends": "../tsconfig.json",
+	"include": ["./**/*.ts"],
+	"compilerOptions": {
+		"types": ["cypress", "dockerode", "cypress-wait-until"],
+	}
+}

File diff suppressed because it is too large
+ 0 - 0
dist/theming-admin-theming.js


File diff suppressed because it is too large
+ 0 - 0
dist/theming-admin-theming.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/theming-personal-theming.js


File diff suppressed because it is too large
+ 0 - 0
dist/theming-personal-theming.js.map


File diff suppressed because it is too large
+ 381 - 100
package-lock.json


+ 19 - 1
package.json

@@ -18,7 +18,10 @@
     "test:jsunit": "karma start tests/karma.config.js --single-run",
     "sass": "sass --load-path core/css core/css/ apps/*/css",
     "sass:watch": "sass --watch --load-path core/css core/css/ apps/*/css",
-    "sass:icons": "babel-node core/src/icons.js"
+    "sass:icons": "babel-node core/src/icons.js",
+    "cypress": "npm run cypress:e2e",
+    "cypress:e2e": "cypress run --e2e",
+    "cypress:gui": "cypress open --e2e"
   },
   "repository": {
     "type": "git",
@@ -78,6 +81,7 @@
     "moment": "^2.29.4",
     "moment-timezone": "^0.5.38",
     "nextcloud-vue-collections": "^0.10.0",
+    "node-vibrant": "^3.1.6",
     "p-limit": "^4.0.0",
     "p-queue": "^7.3.0",
     "path": "^0.12.7",
@@ -107,18 +111,28 @@
   },
   "devDependencies": {
     "@babel/node": "^7.20.0",
+    "@cypress/browserify-preprocessor": "^3.0.2",
     "@nextcloud/babel-config": "^1.0.0",
+    "@nextcloud/cypress": "^1.0.0-beta.1",
     "@nextcloud/eslint-config": "^8.0.0",
     "@nextcloud/stylelint-config": "^2.1.2",
     "@testing-library/jest-dom": "^5.16.4",
     "@testing-library/user-event": "^14.4.3",
     "@testing-library/vue": "^5.8.3",
+    "@types/dockerode": "^3.3.14",
+    "@typescript-eslint/eslint-plugin": "^5.44.0",
+    "@typescript-eslint/parser": "^5.44.0",
     "@vue/test-utils": "^1.3.0",
+    "@vue/tsconfig": "^0.1.3",
     "@vue/vue2-jest": "^29.1.1",
     "babel-jest": "^29.0.3",
     "babel-loader": "^8.2.5",
     "babel-loader-exclude-node-modules-except": "^1.2.1",
     "css-loader": "^6.7.1",
+    "cypress": "^11.2.0",
+    "cypress-wait-until": "^1.7.2",
+    "dockerode": "^3.3.4",
+    "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-es": "^4.1.0",
     "exports-loader": "^4.0.0",
     "file-loader": "^6.2.0",
@@ -143,8 +157,12 @@
     "sass-loader": "^12.6.0",
     "sinon": "<= 5.0.7",
     "style-loader": "^3.3.1",
+    "ts-node": "^10.9.1",
+    "tslib": "^2.4.1",
+    "typescript": "^4.9.3",
     "vue-loader": "^15.9.8",
     "vue-template-compiler": "^2.7.13",
+    "wait-on": "^6.0.1",
     "webpack": "^5.75.0",
     "webpack-cli": "^4.9.2",
     "webpack-merge": "^5.8.0"

+ 22 - 0
tsconfig.json

@@ -0,0 +1,22 @@
+{
+	"extends": "@vue/tsconfig/tsconfig.json",
+	"include": ["./**/*.ts"],
+	"compilerOptions": {
+		"types": ["node"],
+		"allowSyntheticDefaultImports": true,
+		"moduleResolution": "node",
+		"target": "ESNext",
+		"module": "esnext",
+		"declaration": true,
+		"strict": true,
+		"noImplicitAny": false,
+		"resolveJsonModule": true
+	},
+	"ts-node": {
+		// these options are overrides used only by ts-node
+		// same as our --compilerOptions flag and our TS_NODE_COMPILER_OPTIONS environment variable
+		"compilerOptions": {
+			"module": "commonjs"
+		}
+	}
+}

+ 2 - 1
webpack.common.js

@@ -166,8 +166,9 @@ module.exports = {
 		extensions: ['*', '.js', '.vue'],
 		symlinks: true,
 		fallback: {
-			stream: require.resolve('stream-browserify'),
 			buffer: require.resolve('buffer'),
+			fs: false,
+			stream: require.resolve('stream-browserify'),
 		},
 	},
 }

Some files were not shown because too many files changed in this diff