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.
This commit is contained in:
schroda
2024-03-06 23:07:48 +01:00
committed by GitHub
parent 6cff75e257
commit 4aec3c94ef
14 changed files with 325 additions and 82 deletions

View File

@@ -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,8 +72,27 @@ 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 <Box key={activeDevice}>{memorizedChildren}</Box>;
};
export const App: React.FC = () => (
<AppContext>
<ActiveDeviceListener>
<ScrollToTop />
<ServerUpdateChecker />
<BackgroundSubscriptions />
@@ -102,6 +124,7 @@ export const App: React.FC = () => (
<Route path="server" element={<ServerSettings />} />
<Route path="webUI" element={<WebUISettings />} />
<Route path="browseSettings" element={<BrowseSettings />} />
<Route path="device" element={<DeviceSetting />} />
</Route>
{/* Manga Routes */}
@@ -131,5 +154,6 @@ export const App: React.FC = () => (
<Route path="manga/:mangaId/chapter/:chapterIndex" element={<Reader />} />
<Route path="*" element={null} />
</Routes>
</ActiveDeviceListener>
</AppContext>
);

View File

@@ -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 () => {

View File

@@ -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 extends MetadataServerSettingKeys>(
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 (
<List>
<MutableListSetting
settingName={t('settings.device.devices.label.title')}
description={t('settings.device.devices.label.description')}
handleChange={(deviceList) => {
updateMetadataSetting('devices', [...new Set([DEFAULT_DEVICE, ...deviceList])]);
}}
valueInfos={devices.map((device) => [device, { mutable: false, deletable: device !== DEFAULT_DEVICE }])}
addItemButtonTitle={t('global.button.create')}
/>
<ListItem>
<ListItemText
primary={t('settings.device.active_device.label.title')}
secondary={t('settings.device.active_device.label.description')}
/>
<Select
MenuProps={{ PaperProps: { style: { maxHeight: 150 } } }}
value={activeDevice}
onChange={({ target: { value: device } }) => updateMetadataSetting('activeDevice', device)}
>
{devices.map((device) => (
<MenuItem key={device} value={device}>
{device}
</MenuItem>
))}
</Select>
</ListItem>
</List>
);
};

View File

@@ -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<TextSettingProps, 'isPassword' | 'disabled'> & { handleDelete: () => void }) => {
}: Omit<TextSettingProps, 'isPassword' | 'disabled'> & {
handleDelete: () => void;
mutable?: boolean;
deletable?: boolean;
}) => {
const { t } = useTranslation();
return (
<Stack direction="row">
{mutable ? (
<TextSetting {...textSettingProps} dialogTitle="" />
) : (
<ListItem>
<ListItemText secondary={textSettingProps.value} />
</ListItem>
)}
<Tooltip title={t('chapter.action.download.delete.label.action')}>
<IconButton size="large" onClick={handleDelete}>
<IconButton disabled={!deletable} size="large" onClick={handleDelete}>
<DeleteIcon />
</IconButton>
</Tooltip>
@@ -39,7 +61,10 @@ const MutableListItem = ({
};
type MutableListSettingProps = Pick<TextSettingProps, 'settingName' | 'placeholder'> & {
values?: string[];
valueInfos?: (
| [value: string]
| [value: string, Pick<React.ComponentProps<typeof MutableListItem>, 'mutable' | 'deletable'>]
)[];
description?: string;
dialogDisclaimer?: JSX.Element | string;
addItemButtonTitle?: string;
@@ -49,11 +74,14 @@ type MutableListSettingProps = Pick<TextSettingProps, 'settingName' | 'placehold
invalidItemError?: string;
};
const getValues = (valueInfos: MutableListSettingProps['valueInfos']): string[] =>
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}
/>
))}
</List>

View File

@@ -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",

View File

@@ -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() {
</ListItemIcon>
<ListItemText primary={t('global.label.browse')} />
</ListItemLink>
<ListItemLink to="/settings/device">
<ListItemIcon>
<DevicesIcon />
</ListItemIcon>
<ListItemText primary={t('settings.device.title.device')} />
</ListItemLink>
<ListItemLink to="/settings/webUI">
<ListItemIcon>
<WebIcon />

View File

@@ -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) =>

View File

@@ -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 (

View File

@@ -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 (

View File

@@ -229,6 +229,10 @@ export type MetadataServerSettings = {
showAddToLibraryCategorySelectDialog: boolean;
ignoreFilters: boolean;
removeMangaFromCategories: boolean;
// client
devices: string[];
activeDevice: string;
};
export interface ISearchSettings {

View File

@@ -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 = <T = any>(...args: Parameters<typeof JSON.parse>): T | null => {
try {
return JSON.parse(...args);
} catch (e) {
return null;
}
};

15
src/util/device.ts Normal file
View File

@@ -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;
};

View File

@@ -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));

View File

@@ -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<MetadataServerSettings>,
): Metadata<string, AllowedMetadataValueTypes> => ({
...settings,
devices: JSON.stringify(settings.devices),
});
export const convertMetadataToSettings = (
metadata: Partial<Metadata<AppMetadataKeys, AllowedMetadataValueTypes>>,
): MetadataServerSettings =>
({
...getDefaultSettings(),
...(metadata as unknown as MetadataServerSettings),
devices: jsonSaveParse<string[]>((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;