From 4aec3c94ef2085639ee2c10b9b6683719327356c Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Wed, 6 Mar 2024 23:07:48 +0100 Subject: [PATCH] Feature/support settings per device (#640) * Optionally prevent deletion and mutation of "MutableListSetting" items * Save server metadata per device This makes it possible to e.g. have different settings on a desktop and a smartphone. --- src/App.tsx | 144 ++++++++++-------- src/components/navbar/ReaderNavBar.tsx | 1 + src/components/settings/DeviceSetting.tsx | 94 ++++++++++++ .../settings/MutableListSetting.tsx | 52 +++++-- src/i18n/locale/en.json | 18 +++ src/screens/Settings.tsx | 7 + src/screens/settings/BrowseSettings.tsx | 2 +- src/screens/settings/DownloadSettings.tsx | 8 +- src/screens/settings/LibrarySettings.tsx | 8 +- src/typings.ts | 4 + src/util/HelperFunctions.ts | 15 ++ src/util/device.ts | 15 ++ src/util/metadata.ts | 10 +- src/util/metadataServerSettings.ts | 29 +++- 14 files changed, 325 insertions(+), 82 deletions(-) create mode 100644 src/components/settings/DeviceSetting.tsx create mode 100644 src/util/HelperFunctions.ts create mode 100644 src/util/device.ts diff --git a/src/App.tsx b/src/App.tsx index 8c75487f..da8af5f4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,9 +6,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Container } from '@mui/material'; +import { Box, Container } from '@mui/material'; import CssBaseline from '@mui/material/CssBaseline'; -import React, { useLayoutEffect } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo } from 'react'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import { loadErrorMessages, loadDevMessages } from '@apollo/client/dev'; import { AppContext } from '@/components/context/AppContext'; @@ -38,6 +38,9 @@ import { requestManager } from '@/lib/requests/RequestManager.ts'; import { BrowseSettings } from '@/screens/settings/BrowseSettings.tsx'; import { WebUISettings } from '@/screens/settings/WebUISettings.tsx'; import { Migrate } from '@/screens/Migrate.tsx'; +import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; +import { getActiveDevice, setActiveDevice } from '@/util/device.ts'; +import { DeviceSetting } from '@/components/settings/DeviceSetting.tsx'; if (process.env.NODE_ENV !== 'production') { // Adds messages only in a dev environment @@ -69,67 +72,88 @@ const BackgroundSubscriptions = () => { return null; }; +const ActiveDeviceListener = ({ children }: { children?: React.ReactNode }) => { + const { + settings: { devices, activeDevice }, + } = useMetadataServerSettings(); + + useEffect(() => { + if (activeDevice === getActiveDevice()) { + return; + } + + setActiveDevice(activeDevice); + }, [devices, activeDevice]); + + const memorizedChildren = useMemo(() => children, [activeDevice]); + + return {memorizedChildren}; +}; + export const App: React.FC = () => ( - - - - - - + + + + + + + + + {/* General Routes */} + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Manga Routes */} + + + } /> + } /> + } /> + } /> + + } /> + + + } /> + + } /> + } /> + } /> + } /> + + } /> + } /> + + + - {/* General Routes */} - } /> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Manga Routes */} - - - } /> - } /> - } /> - } /> - - } /> - - - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - + } /> + - - - } /> - - + ); diff --git a/src/components/navbar/ReaderNavBar.tsx b/src/components/navbar/ReaderNavBar.tsx index c43ecc1f..e0a1043d 100644 --- a/src/components/navbar/ReaderNavBar.tsx +++ b/src/components/navbar/ReaderNavBar.tsx @@ -184,6 +184,7 @@ export function ReaderNavBar(props: IProps) { // main container and root div need to change styles... rootEl.style.display = 'flex'; + rootEl.style.flexDirection = 'column'; mainContainer.style.display = 'none'; return () => { diff --git a/src/components/settings/DeviceSetting.tsx b/src/components/settings/DeviceSetting.tsx new file mode 100644 index 00000000..094fe401 --- /dev/null +++ b/src/components/settings/DeviceSetting.tsx @@ -0,0 +1,94 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { List, ListItem, ListItemText, MenuItem, Select } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useContext, useEffect } from 'react'; +import { convertSettingsToMetadata, useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; +import { MetadataServerSettingKeys, MetadataServerSettings } from '@/typings.ts'; +import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.ts'; +import { makeToast } from '@/components/util/Toast.tsx'; +import { MutableListSetting } from '@/components/settings/MutableListSetting.tsx'; +import { NavBarContext } from '@/components/context/NavbarContext.tsx'; +import { DEFAULT_DEVICE } from '@/util/device.ts'; + +export const DeviceSetting = () => { + const { t } = useTranslation(); + const { setTitle, setAction } = useContext(NavBarContext); + + useEffect(() => { + setTitle(t('settings.device.title.settings')); + setAction(null); + + return () => { + setTitle(''); + setAction(null); + }; + }, [t]); + + const { + metadata, + settings: { devices, activeDevice }, + } = useMetadataServerSettings(); + + const updateMetadataSetting = async ( + setting: Setting, + value: MetadataServerSettings[Setting], + ) => { + if (!metadata) { + return; + } + + const wasActiveDeviceDeleted = setting === 'devices' && !(value as string[]).includes(activeDevice); + if (wasActiveDeviceDeleted) { + try { + await requestUpdateServerMetadata(convertToGqlMeta(metadata) ?? [], [ + ['activeDevice', convertSettingsToMetadata({ activeDevice: DEFAULT_DEVICE }).activeDevice], + ]); + } catch (e) { + makeToast(t('global.error.label.failed_to_save_changes'), 'error'); + return; + } + } + + requestUpdateServerMetadata(convertToGqlMeta(metadata) ?? [], [ + [setting, convertSettingsToMetadata({ [setting]: value })[setting]], + ]).catch(() => makeToast(t('global.error.label.failed_to_save_changes'), 'error')); + }; + + return ( + + { + updateMetadataSetting('devices', [...new Set([DEFAULT_DEVICE, ...deviceList])]); + }} + valueInfos={devices.map((device) => [device, { mutable: false, deletable: device !== DEFAULT_DEVICE }])} + addItemButtonTitle={t('global.button.create')} + /> + + + + + + ); +}; diff --git a/src/components/settings/MutableListSetting.tsx b/src/components/settings/MutableListSetting.tsx index 8359b7c2..7c4bf1fa 100644 --- a/src/components/settings/MutableListSetting.tsx +++ b/src/components/settings/MutableListSetting.tsx @@ -6,7 +6,17 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Button, Dialog, DialogTitle, ListItemButton, ListItemText, Stack, Tooltip, Typography } from '@mui/material'; +import { + Button, + Dialog, + DialogTitle, + ListItem, + ListItemButton, + ListItemText, + Stack, + Tooltip, + Typography, +} from '@mui/material'; import { useEffect, useState } from 'react'; import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; @@ -22,15 +32,27 @@ import { makeToast } from '@/components/util/Toast.tsx'; const MutableListItem = ({ handleDelete, + mutable = true, + deletable = true, ...textSettingProps -}: Omit & { handleDelete: () => void }) => { +}: Omit & { + handleDelete: () => void; + mutable?: boolean; + deletable?: boolean; +}) => { const { t } = useTranslation(); return ( - + {mutable ? ( + + ) : ( + + + + )} - + @@ -39,7 +61,10 @@ const MutableListItem = ({ }; type MutableListSettingProps = Pick & { - values?: string[]; + valueInfos?: ( + | [value: string] + | [value: string, Pick, 'mutable' | 'deletable'>] + )[]; description?: string; dialogDisclaimer?: JSX.Element | string; addItemButtonTitle?: string; @@ -49,11 +74,14 @@ type MutableListSettingProps = Pick + valueInfos?.map((valueInfo) => valueInfo[0]) ?? []; + export const MutableListSetting = ({ settingName, description, dialogDisclaimer, - values, + valueInfos, handleChange, addItemButtonTitle, placeholder, @@ -63,22 +91,24 @@ export const MutableListSetting = ({ }: MutableListSettingProps) => { const { t } = useTranslation(); + const values = getValues(valueInfos); + const [isDialogOpen, setIsDialogOpen] = useState(false); - const [dialogValues, setDialogValues] = useState(values ?? []); + const [dialogValues, setDialogValues] = useState(values); const [isAddItemDialogOpen, setIsAddItemDialogOpen] = useState(false); useEffect(() => { - if (!values) { + if (!valueInfos) { return; } setDialogValues(values); - }, [values]); + }, [valueInfos]); const closeDialog = (resetValue: boolean = true) => { if (resetValue) { - setDialogValues(values ?? []); + setDialogValues(values); } setIsDialogOpen(false); @@ -165,6 +195,8 @@ export const MutableListSetting = ({ handleChange={(newValue: string) => updateSetting(index, newValue)} handleDelete={() => updateSetting(index, undefined)} value={dialogValue} + mutable={valueInfos?.find(([value]) => value === dialogValue)?.[1]?.mutable} + deletable={valueInfos?.find(([value]) => value === dialogValue)?.[1]?.deletable} /> ))} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 41dd6023..992868f2 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -785,6 +785,24 @@ "title": "Clear server cache" } }, + "device": { + "active_device": { + "label": { + "description": "Select a device to use its server stored UI settings", + "title": "Active device" + } + }, + "devices": { + "label": { + "description": "Manage your existing devices.\nUI specific settings that are stored on the server are per device.\nThis makes it possible to have e.g. different settings on a desktop and a smartphone", + "title": "Devices" + } + }, + "title": { + "device": "Device", + "settings": "Device settings" + } + }, "label": { "dark_theme": "Dark theme", "language_description": "Feel free to translate the project on", diff --git a/src/screens/Settings.tsx b/src/screens/Settings.tsx index 6ec6eece..135eb062 100644 --- a/src/screens/Settings.tsx +++ b/src/screens/Settings.tsx @@ -27,6 +27,7 @@ import DnsIcon from '@mui/icons-material/Dns'; import WebIcon from '@mui/icons-material/Web'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import ExploreOutlinedIcon from '@mui/icons-material/ExploreOutlined'; +import DevicesIcon from '@mui/icons-material/Devices'; import { langCodeToName } from '@/util/language'; import { useLocalStorage } from '@/util/useLocalStorage'; import { ListItemLink } from '@/components/util/ListItemLink'; @@ -161,6 +162,12 @@ export function Settings() { + + + + + + diff --git a/src/screens/settings/BrowseSettings.tsx b/src/screens/settings/BrowseSettings.tsx index 707cb7f7..879b1068 100644 --- a/src/screens/settings/BrowseSettings.tsx +++ b/src/screens/settings/BrowseSettings.tsx @@ -92,7 +92,7 @@ export const BrowseSettings = () => { updateSetting('extensionRepos', repos); requestManager.clearExtensionCache(); }} - values={serverSettings?.extensionRepos} + valueInfos={serverSettings?.extensionRepos.map((extensionRepo) => [extensionRepo]) as [string][]} addItemButtonTitle={t('extension.settings.repositories.custom.dialog.action.button.add')} placeholder="https://github.com/MY_ACCOUNT/MY_REPO/tree/repo" validateItem={(repo) => diff --git a/src/screens/settings/DownloadSettings.tsx b/src/screens/settings/DownloadSettings.tsx index 2e253d3c..bfe3ede0 100644 --- a/src/screens/settings/DownloadSettings.tsx +++ b/src/screens/settings/DownloadSettings.tsx @@ -16,7 +16,7 @@ import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarC import { MetadataServerSettingKeys, MetadataServerSettings, ServerSettings } from '@/typings.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { DownloadAheadSetting } from '@/components/settings/downloads/DownloadAheadSetting.tsx'; -import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; +import { convertSettingsToMetadata, useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.ts'; import { makeToast } from '@/components/util/Toast.tsx'; import { DeleteChaptersWhileReadingSetting } from '@/components/settings/downloads/DeleteChaptersWhileReadingSetting.tsx'; @@ -78,9 +78,9 @@ export const DownloadSettings = () => { return; } - requestUpdateServerMetadata(convertToGqlMeta(metadata) ?? [], [[setting, value]]).catch(() => - makeToast(t('global.error.label.failed_to_save_changes'), 'error'), - ); + requestUpdateServerMetadata(convertToGqlMeta(metadata) ?? [], [ + [setting, convertSettingsToMetadata({ [setting]: value })[setting]], + ]).catch(() => makeToast(t('global.error.label.failed_to_save_changes'), 'error')); }; return ( diff --git a/src/screens/settings/LibrarySettings.tsx b/src/screens/settings/LibrarySettings.tsx index 10e12c13..3d6e5325 100644 --- a/src/screens/settings/LibrarySettings.tsx +++ b/src/screens/settings/LibrarySettings.tsx @@ -16,7 +16,7 @@ import { GlobalUpdateSettings } from '@/components/settings/globalUpdate/GlobalU import { MetadataServerSettingKeys, MetadataServerSettings } from '@/typings.ts'; import { convertToGqlMeta, requestUpdateServerMetadata } from '@/util/metadata.ts'; import { makeToast } from '@/components/util/Toast.tsx'; -import { useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; +import { convertSettingsToMetadata, useMetadataServerSettings } from '@/util/metadataServerSettings.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { Mangas } from '@/lib/data/Mangas.ts'; @@ -61,9 +61,9 @@ export function LibrarySettings() { setting: Setting, value: MetadataServerSettings[Setting], ) => { - requestUpdateServerMetadata(convertToGqlMeta(metadata)! ?? {}, [[setting, value]]).catch(() => - makeToast(t('search.error.label.failed_to_save_settings'), 'warning'), - ); + requestUpdateServerMetadata(convertToGqlMeta(metadata)! ?? {}, [ + [setting, convertSettingsToMetadata({ [setting]: value })[setting]], + ]).catch(() => makeToast(t('search.error.label.failed_to_save_settings'), 'warning')); }; return ( diff --git a/src/typings.ts b/src/typings.ts index a02ae941..208b6305 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -229,6 +229,10 @@ export type MetadataServerSettings = { showAddToLibraryCategorySelectDialog: boolean; ignoreFilters: boolean; removeMangaFromCategories: boolean; + + // client + devices: string[]; + activeDevice: string; }; export interface ISearchSettings { diff --git a/src/util/HelperFunctions.ts b/src/util/HelperFunctions.ts new file mode 100644 index 00000000..24f20dd6 --- /dev/null +++ b/src/util/HelperFunctions.ts @@ -0,0 +1,15 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +export const jsonSaveParse = (...args: Parameters): T | null => { + try { + return JSON.parse(...args); + } catch (e) { + return null; + } +}; diff --git a/src/util/device.ts b/src/util/device.ts new file mode 100644 index 00000000..1fd8bbd1 --- /dev/null +++ b/src/util/device.ts @@ -0,0 +1,15 @@ +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +export const DEFAULT_DEVICE = 'default'; + +let activeDevice = DEFAULT_DEVICE; +export const getActiveDevice = (): string => activeDevice; +export const setActiveDevice = (device: string) => { + activeDevice = device; +}; diff --git a/src/util/metadata.ts b/src/util/metadata.ts index 786b324c..3abff760 100644 --- a/src/util/metadata.ts +++ b/src/util/metadata.ts @@ -20,9 +20,12 @@ import { } from '@/typings'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { MetaType } from '@/lib/graphql/generated/graphql.ts'; +import { DEFAULT_DEVICE, getActiveDevice } from '@/util/device.ts'; const APP_METADATA_KEY_PREFIX = 'webUI_'; +const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = ['devices', 'activeDevice']; + /** * Once all changes have been done in the current branch, a new migration for all changes should be * created. @@ -97,7 +100,12 @@ const getAppKeyPrefixForMigration = (migrationId: number): string => { return appKeyPrefix?.appKeyPrefix?.newPrefix ?? APP_METADATA_KEY_PREFIX; }; -const getMetadataKey = (key: string, appPrefix: string = APP_METADATA_KEY_PREFIX) => `${appPrefix}${key}`; +const getMetadataKey = (key: string, appPrefix: string = APP_METADATA_KEY_PREFIX) => { + const isGlobalMetadataKey = GLOBAL_METADATA_KEYS.includes(key as AppMetadataKeys); + const addActiveDevicePrefix = !isGlobalMetadataKey && getActiveDevice() !== DEFAULT_DEVICE; + + return `${appPrefix}${addActiveDevicePrefix ? `${getActiveDevice()}_` : ''}${key}`; +}; const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string, appPrefix?: string): boolean => Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, appPrefix)); diff --git a/src/util/metadataServerSettings.ts b/src/util/metadataServerSettings.ts index 28f8f497..84479888 100644 --- a/src/util/metadataServerSettings.ts +++ b/src/util/metadataServerSettings.ts @@ -6,9 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Metadata, MetadataServerSettings } from '@/typings'; +import { AllowedMetadataValueTypes, AppMetadataKeys, Metadata, MetadataServerSettings } from '@/typings'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { convertFromGqlMeta, getMetadataFrom } from '@/util/metadata'; +import { jsonSaveParse } from '@/util/HelperFunctions.ts'; +import { DEFAULT_DEVICE } from '@/util/device.ts'; export const getDefaultSettings = (): MetadataServerSettings => ({ // downloads @@ -21,13 +23,36 @@ export const getDefaultSettings = (): MetadataServerSettings => ({ showAddToLibraryCategorySelectDialog: true, ignoreFilters: false, removeMangaFromCategories: false, + + // client + devices: [DEFAULT_DEVICE], + activeDevice: DEFAULT_DEVICE, }); +export const convertSettingsToMetadata = ( + settings: Partial, +): Metadata => ({ + ...settings, + devices: JSON.stringify(settings.devices), +}); + +export const convertMetadataToSettings = ( + metadata: Partial>, +): MetadataServerSettings => + ({ + ...getDefaultSettings(), + ...(metadata as unknown as MetadataServerSettings), + devices: jsonSaveParse((metadata.devices as string) ?? '') ?? getDefaultSettings().devices, + }) satisfies MetadataServerSettings; + const getMetadataServerSettingsWithDefaultFallback = ( meta?: Metadata, defaultSettings: MetadataServerSettings = getDefaultSettings(), applyMetadataMigration: boolean = true, -): MetadataServerSettings => getMetadataFrom({ meta }, defaultSettings, applyMetadataMigration); +): MetadataServerSettings => + convertMetadataToSettings( + getMetadataFrom({ meta }, convertSettingsToMetadata(defaultSettings), applyMetadataMigration), + ); export const useMetadataServerSettings = (): { metadata?: Metadata; settings: MetadataServerSettings;