Merge pull request #1807 (resolved conflicts w/ #1813/#1818: SettingSwitch + i18n + admin locks)

Claude-Session: https://claude.ai/code/session_013LfzaWNuhA68eFgfMcAER4
This commit is contained in:
Fredrik Burmester
2026-07-19 10:23:01 +02:00
11 changed files with 235 additions and 192 deletions

View File

@@ -1,11 +1,8 @@
import { ScrollView } from "react-native";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
import { useSettings } from "@/utils/atoms/settings";
export default function JellyseerrPluginPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();
return (
@@ -16,12 +13,9 @@ export default function JellyseerrPluginPage() {
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
className='p-4'
>
<View className='p-4'>
<JellyseerrSettings />
</DisabledSetting>
</View>
</ScrollView>
);
}

View File

@@ -1,11 +1,8 @@
import { ScrollView } from "react-native";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { KefinTweaksSettings } from "@/components/settings/KefinTweaks";
import { useSettings } from "@/utils/atoms/settings";
export default function KefinTweaksPage() {
const { pluginSettings } = useSettings();
const insets = useSafeAreaInsets();
return (
@@ -16,12 +13,9 @@ export default function KefinTweaksPage() {
paddingRight: insets.right,
}}
>
<DisabledSetting
disabled={pluginSettings?.useKefinTweaks?.locked === true}
className='p-4'
>
<View className='px-4'>
<KefinTweaksSettings />
</DisabledSetting>
</View>
</ScrollView>
);
}

View File

@@ -1,5 +1,5 @@
import { useNavigation } from "expo-router";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Linking,
@@ -14,22 +14,24 @@ import { SettingSwitch } from "@/components/common/SettingSwitch";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
import { useSettings } from "@/utils/atoms/settings";
export default function MarlinSearchPage() {
const navigation = useNavigation();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useNetworkAwareQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
const searchEngineLocked = pluginSettings?.searchEngine?.locked === true;
const marlinUrlLocked = pluginSettings?.marlinServerUrl?.locked === true;
// Effective (user/admin merged) URL, same source the search screen uses —
// the raw plugin value misses a user-configured Streamystats.
const hasStreamystats = !!settings?.streamyStatsServerUrl;
const onSave = (val: string) => {
updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
@@ -41,15 +43,8 @@ export default function MarlinSearchPage() {
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
};
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) {
if (!marlinUrlLocked) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity onPress={() => onSave(value)} className='px-2'>
@@ -60,7 +55,7 @@ export default function MarlinSearchPage() {
),
});
}
}, [navigation, value, pluginSettings?.marlinServerUrl?.locked, t]);
}, [navigation, value, marlinUrlLocked, t]);
if (!settings) return null;
@@ -72,52 +67,42 @@ export default function MarlinSearchPage() {
paddingRight: insets.right,
}}
>
<DisabledSetting disabled={disabled} className='px-4 pt-4'>
<View className='px-4 pt-4'>
<ListGroup>
<DisabledSetting
disabled={
pluginSettings?.searchEngine?.locked === true ||
!!pluginSettings?.streamyStatsServerUrl?.value
}
showText={!pluginSettings?.marlinServerUrl?.locked}
{/* disabledByAdmin renders the "Disabled by admin" notice as the row's
subtitle (same pattern as the Streamystats settings) — no clipping. */}
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
disabledByAdmin={searchEngineLocked}
// Streamystats owns the search engine while configured — block the
// row tap too, not just the Switch, so it can't force "Jellyfin".
disabled={hasStreamystats}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<ListItem
title={t(
"home.settings.plugins.marlin_search.enable_marlin_search",
)}
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
<SettingSwitch
value={settings.searchEngine === "Marlin"}
disabled={searchEngineLocked || hasStreamystats}
onValueChange={(val) => {
updateSettings({ searchEngine: val ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
>
<SettingSwitch
value={settings.searchEngine === "Marlin"}
disabled={!!pluginSettings?.streamyStatsServerUrl?.value}
onValueChange={(value) => {
updateSettings({
searchEngine: value ? "Marlin" : "Jellyfin",
});
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
/>
</ListItem>
</ListGroup>
<DisabledSetting
disabled={pluginSettings?.marlinServerUrl?.locked === true}
showText={!pluginSettings?.searchEngine?.locked}
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
>
<View
className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}
<ListGroup className='mt-2'>
<ListItem
title={t("home.settings.plugins.marlin_search.url")}
disabledByAdmin={marlinUrlLocked}
>
<Text className='mr-4'>
{t("home.settings.plugins.marlin_search.url")}
</Text>
<TextInput
editable={settings.searchEngine === "Marlin"}
className='text-white'
editable={!marlinUrlLocked && settings.searchEngine === "Marlin"}
className='text-white text-right flex-1'
placeholder={t(
"home.settings.plugins.marlin_search.server_url_placeholder",
)}
@@ -128,15 +113,16 @@ export default function MarlinSearchPage() {
textContentType='URL'
onChangeText={(text) => setValue(text)}
/>
</View>
</DisabledSetting>
</ListItem>
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
<Text className='text-blue-500' onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
</Text>
</Text>
</DisabledSetting>
</View>
</ScrollView>
);
}

View File

@@ -1,9 +1,28 @@
import { Platform, ScrollView, View } from "react-native";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native";
import { Text } from "@/components/common/Text";
import { PluginSettings } from "@/components/settings/PluginSettings";
import { useSettings } from "@/utils/atoms/settings";
export default function PluginsPage() {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const { refreshStreamyfinPluginSettings } = useSettings();
const handleRefreshFromServer = useCallback(async () => {
// Returns undefined when the API call fails (handled internally).
const refreshed = await refreshStreamyfinPluginSettings();
if (refreshed) {
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
} else {
toast.error(
t("home.settings.plugins.streamystats.toasts.refresh_failed"),
);
}
}, [refreshStreamyfinPluginSettings, t]);
return (
<ScrollView
@@ -18,6 +37,17 @@ export default function PluginsPage() {
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<PluginSettings />
{/* Pulls the centralised Streamyfin plugin settings for every plugin,
so it lives on the plugins index rather than inside Streamystats. */}
<TouchableOpacity
onPress={handleRefreshFromServer}
className='py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
);

View File

@@ -22,12 +22,7 @@ export default function StreamystatsPage() {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const {
settings,
updateSettings,
pluginSettings,
refreshStreamyfinPluginSettings,
} = useSettings();
const { settings, updateSettings, pluginSettings } = useSettings();
const queryClient = useNetworkAwareQueryClient();
// Local state for all editable fields
@@ -49,7 +44,21 @@ export default function StreamystatsPage() {
);
const isUrlLocked = pluginSettings?.streamyStatsServerUrl?.locked === true;
const isStreamystatsEnabled = !!url;
const searchLocked = pluginSettings?.searchEngine?.locked === true;
const movieRecsLocked =
pluginSettings?.streamyStatsMovieRecommendations?.locked === true;
const seriesRecsLocked =
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true;
const promotedWatchlistsLocked =
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true;
const hideWatchlistsTabLocked =
pluginSettings?.hideWatchlistsTab?.locked === true;
// The input renders the locked admin URL; enablement must follow the same
// effective value or every toggle stays disabled until local state syncs.
const effectiveUrl = isUrlLocked
? (settings?.streamyStatsServerUrl ?? "")
: url;
const isStreamystatsEnabled = !!effectiveUrl;
const onSave = useCallback(() => {
const cleanUrl = url.endsWith("/") ? url.slice(0, -1) : url;
@@ -113,17 +122,6 @@ export default function StreamystatsPage() {
Linking.openURL("https://github.com/fredrikburmester/streamystats");
};
const handleRefreshFromServer = useCallback(async () => {
const newPluginSettings = await refreshStreamyfinPluginSettings();
// Update local state with new values
const newUrl = newPluginSettings?.streamyStatsServerUrl?.value || "";
setUrl(newUrl);
if (newUrl) {
setUseForSearch(true);
}
toast.success(t("home.settings.plugins.streamystats.toasts.refreshed"));
}, [refreshStreamyfinPluginSettings, t]);
if (!settings) return null;
return (
@@ -146,7 +144,7 @@ export default function StreamystatsPage() {
placeholder={t(
"home.settings.plugins.streamystats.server_url_placeholder",
)}
value={url}
value={effectiveUrl}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
@@ -171,11 +169,18 @@ export default function StreamystatsPage() {
>
<ListItem
title={t("home.settings.plugins.streamystats.enable_search")}
disabledByAdmin={pluginSettings?.searchEngine?.locked === true}
disabledByAdmin={searchLocked}
>
{/* Locked controls show the live admin value and can't be toggled —
local form state would let the switch flip while the write guard
drops the change. */}
<SettingSwitch
value={useForSearch}
disabled={!isStreamystatsEnabled}
value={
searchLocked
? settings?.searchEngine === "Streamystats"
: useForSearch
}
disabled={!isStreamystatsEnabled || searchLocked}
onValueChange={setUseForSearch}
/>
</ListItem>
@@ -183,52 +188,62 @@ export default function StreamystatsPage() {
title={t(
"home.settings.plugins.streamystats.enable_movie_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsMovieRecommendations?.locked === true
}
disabledByAdmin={movieRecsLocked}
>
<SettingSwitch
value={movieRecs}
value={
movieRecsLocked
? (settings?.streamyStatsMovieRecommendations ?? false)
: movieRecs
}
onValueChange={setMovieRecs}
disabled={!isStreamystatsEnabled}
disabled={!isStreamystatsEnabled || movieRecsLocked}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_series_recommendations",
)}
disabledByAdmin={
pluginSettings?.streamyStatsSeriesRecommendations?.locked === true
}
disabledByAdmin={seriesRecsLocked}
>
<SettingSwitch
value={seriesRecs}
value={
seriesRecsLocked
? (settings?.streamyStatsSeriesRecommendations ?? false)
: seriesRecs
}
onValueChange={setSeriesRecs}
disabled={!isStreamystatsEnabled}
disabled={!isStreamystatsEnabled || seriesRecsLocked}
/>
</ListItem>
<ListItem
title={t(
"home.settings.plugins.streamystats.enable_promoted_watchlists",
)}
disabledByAdmin={
pluginSettings?.streamyStatsPromotedWatchlists?.locked === true
}
disabledByAdmin={promotedWatchlistsLocked}
>
<SettingSwitch
value={promotedWatchlists}
value={
promotedWatchlistsLocked
? (settings?.streamyStatsPromotedWatchlists ?? false)
: promotedWatchlists
}
onValueChange={setPromotedWatchlists}
disabled={!isStreamystatsEnabled}
disabled={!isStreamystatsEnabled || promotedWatchlistsLocked}
/>
</ListItem>
<ListItem
title={t("home.settings.plugins.streamystats.hide_watchlists_tab")}
disabledByAdmin={pluginSettings?.hideWatchlistsTab?.locked === true}
disabledByAdmin={hideWatchlistsTabLocked}
>
<SettingSwitch
value={hideWatchlistsTab}
value={
hideWatchlistsTabLocked
? (settings?.hideWatchlistsTab ?? false)
: hideWatchlistsTab
}
onValueChange={setHideWatchlistsTab}
disabled={!isStreamystatsEnabled}
disabled={!isStreamystatsEnabled || hideWatchlistsTabLocked}
/>
</ListItem>
</ListGroup>
@@ -236,15 +251,6 @@ export default function StreamystatsPage() {
{t("home.settings.plugins.streamystats.home_sections_hint")}
</Text>
<TouchableOpacity
onPress={handleRefreshFromServer}
className='mt-6 py-3 rounded-xl bg-neutral-800'
>
<Text className='text-center text-blue-500'>
{t("home.settings.plugins.streamystats.refresh_from_server")}
</Text>
</TouchableOpacity>
{/* Disable button - only show if URL is not locked and Streamystats is enabled */}
{!isUrlLocked && isStreamystatsEnabled && (
<TouchableOpacity

View File

@@ -1,5 +1,6 @@
import { Ionicons } from "@expo/vector-icons";
import type { PropsWithChildren, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View, type ViewProps } from "react-native";
import { Text } from "../common/Text";
@@ -32,7 +33,10 @@ export const ListItem: React.FC<PropsWithChildren<Props>> = ({
disabledByAdmin = false,
...viewProps
}) => {
const effectiveSubtitle = disabledByAdmin ? "Disabled by admin" : subtitle;
const { t } = useTranslation();
const effectiveSubtitle = disabledByAdmin
? t("home.settings.disabled_by_admin")
: subtitle;
const isDisabled = disabled || disabledByAdmin;
// Keep the row floor uniform; Android trims padding slightly (its native
// controls sit taller). Switch height is capped via SettingSwitch so toggle

View File

@@ -20,7 +20,10 @@ export const JellyseerrSettings = () => {
const { t } = useTranslation();
const [user] = useAtom(userAtom);
const { settings, updateSettings } = useSettings();
const { settings, updateSettings, pluginSettings } = useSettings();
// Only the server URL is admin-lockable — the password stays editable so
// the user can still sign in to the admin-pinned Jellyseerr server.
const urlLocked = pluginSettings?.jellyseerrServerUrl?.locked === true;
const [jellyseerrPassword, setJellyseerrPassword] = useState<
string | undefined
@@ -30,22 +33,25 @@ export const JellyseerrSettings = () => {
string | undefined
>(settings?.jellyseerrServerUrl || undefined);
// When the URL is admin-pinned, ignore any locally-typed value so the login
// targets the same server the (read-only) input displays.
const effectiveServerUrl = urlLocked
? settings?.jellyseerrServerUrl
: jellyseerrServerUrl || settings?.jellyseerrServerUrl;
const loginToJellyseerrMutation = useMutation({
mutationFn: async () => {
if (!jellyseerrServerUrl && !settings?.jellyseerrServerUrl)
throw new Error("Missing server url");
if (!effectiveServerUrl) throw new Error("Missing server url");
if (!user?.Name)
throw new Error("Missing required information for login");
const jellyseerrTempApi = new JellyseerrApi(
jellyseerrServerUrl || settings.jellyseerrServerUrl || "",
);
const jellyseerrTempApi = new JellyseerrApi(effectiveServerUrl);
const testResult = await jellyseerrTempApi.test();
if (!testResult.isValid) throw new Error("Invalid server url");
return jellyseerrTempApi.login(user.Name, jellyseerrPassword || "");
},
onSuccess: (user) => {
setJellyseerrUser(user);
updateSettings({ jellyseerrServerUrl });
updateSettings({ jellyseerrServerUrl: effectiveServerUrl });
},
onError: () => {
toast.error(t("jellyseerr.failed_to_login"));
@@ -115,30 +121,41 @@ export const JellyseerrSettings = () => {
</>
) : (
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
<View style={{ opacity: urlLocked ? 0.5 : 1 }}>
<Text className='font-bold mb-1'>
{t("home.settings.plugins.jellyseerr.server_url")}
</Text>
</View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
<View className='flex flex-col shrink mb-2'>
<Text className='text-xs text-gray-600'>
{t("home.settings.plugins.jellyseerr.server_url_hint")}
</Text>
</View>
<Input
className='border border-neutral-800 mb-2'
placeholder={t(
"home.settings.plugins.jellyseerr.server_url_placeholder",
)}
value={
urlLocked
? settings?.jellyseerrServerUrl
: (jellyseerrServerUrl ?? settings?.jellyseerrServerUrl)
}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setjellyseerrServerUrl}
editable={!urlLocked && !loginToJellyseerrMutation.isPending}
/>
{urlLocked && (
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.disabled_by_admin")}
</Text>
)}
value={jellyseerrServerUrl ?? settings?.jellyseerrServerUrl}
defaultValue={
settings?.jellyseerrServerUrl ?? jellyseerrServerUrl
}
keyboardType='url'
returnKeyType='done'
autoCapitalize='none'
textContentType='URL'
onChangeText={setjellyseerrServerUrl}
editable={!loginToJellyseerrMutation.isPending}
/>
</View>
<View>
<Text className='font-bold mb-2'>
{t("home.settings.plugins.jellyseerr.password")}

View File

@@ -1,34 +1,28 @@
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { SettingSwitch } from "@/components/common/SettingSwitch";
import { useSettings } from "@/utils/atoms/settings";
import { ListGroup } from "../list/ListGroup";
import { ListItem } from "../list/ListItem";
export const KefinTweaksSettings = () => {
const { settings, updateSettings } = useSettings();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
const isEnabled = settings?.useKefinTweaks ?? false;
const locked = pluginSettings?.useKefinTweaks?.locked === true;
return (
<View className=''>
<View className='flex flex-col rounded-xl overflow-hidden p-4 bg-neutral-900'>
<Text className='text-xs text-red-600 mb-2'>
{t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
</Text>
<View className='flex flex-row items-center justify-between mt-2'>
<Text className='text-white'>
{isEnabled ? t("Watchlist On") : t("Watchlist Off")}
</Text>
<SettingSwitch
value={isEnabled}
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
trackColor={{ false: "#555", true: "purple" }}
thumbColor={isEnabled ? "#fff" : "#ccc"}
/>
</View>
</View>
</View>
<ListGroup>
<ListItem
title={t("home.settings.plugins.kefinTweaks.watchlist_enabler")}
disabledByAdmin={locked}
>
<SettingSwitch
value={isEnabled}
disabled={locked}
onValueChange={(value) => updateSettings({ useKefinTweaks: value })}
/>
</ListItem>
</ListGroup>
);
};

View File

@@ -20,12 +20,7 @@ export const PluginSettings = () => {
>
<ListItem
onPress={() => router.push("/settings/plugins/jellyseerr/page")}
title={"Jellyseerr"}
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
title='Jellyseerr'
showArrow
/>
<ListItem
@@ -33,6 +28,11 @@ export const PluginSettings = () => {
title='Streamystats'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/marlin-search/page")}
title='Marlin Search'
showArrow
/>
<ListItem
onPress={() => router.push("/settings/plugins/kefinTweaks/page")}
title='KefinTweaks'

View File

@@ -122,6 +122,7 @@
},
"settings": {
"settings_title": "Settings",
"disabled_by_admin": "Disabled by admin",
"log_out_button": "Log out",
"switch_user": {
"title": "Switch user",
@@ -384,6 +385,7 @@
"toasts": {
"saved": "Saved",
"refreshed": "Settings refreshed from server",
"refresh_failed": "Couldn't refresh settings from server",
"disabled": "Streamystats disabled"
},
"refresh_from_server": "Refresh settings from server"

View File

@@ -453,11 +453,6 @@ export const pluginSettingsAtom = atom<PluginLockableSettings | undefined>(
const hasMeaningfulSettingValue = (value: unknown) =>
value !== undefined && value !== null && value !== "";
const getEffectiveSettingValue = <K extends keyof Settings>(
settings: Partial<Settings> | null | undefined,
settingsKey: K,
) => settings?.[settingsKey] ?? defaultValues[settingsKey];
export const useSettings = () => {
const api = useAtomValue(apiAtom);
const [_settings, setSettings] = useAtom(settingsAtom);
@@ -514,7 +509,17 @@ export const useSettings = () => {
if (!_settings) {
return;
}
const hasChanges = Object.entries(update).some(
// Admin-locked settings are enforced at write time too: a control that
// isn't disabled in the UI must not persist a value the admin pinned.
// The read memo already overrides locked keys, but without this guard the
// write would silently land in user storage and resurface once unlocked.
const sanitizedUpdate = Object.fromEntries(
Object.entries(update).filter(
([key]) => pluginSettings?.[key as keyof Settings]?.locked !== true,
),
) as Partial<Settings>;
const hasChanges = Object.entries(sanitizedUpdate).some(
([key, value]) => _settings[key as keyof Settings] !== value,
);
@@ -523,7 +528,7 @@ export const useSettings = () => {
const newSettings = {
...defaultValues,
..._settings,
...update,
...sanitizedUpdate,
} as Settings;
setSettings(newSettings);
saveSettings(newSettings);
@@ -546,13 +551,24 @@ export const useSettings = () => {
// Normalize object-typed settings from plugin (plain primitive → { key, value })
value = normalizePluginValue(settingsKey, value);
const effectiveValue = getEffectiveSettingValue(_settings, settingsKey);
// When unlocked, keep the user's value only if they explicitly diverged
// from the app default. Otherwise the plugin value is the admin's
// default and must win over the hardcoded app default — e.g. a toggle
// that was always locked then unlocked should reflect the plugin
// default, not the app's `false`. Object-typed settings compare by
// reference, so their behaviour is unchanged.
const userValue = _settings?.[settingsKey];
const userDiverged =
hasMeaningfulSettingValue(userValue) &&
userValue !== defaultValues[settingsKey];
(acc as any)[settingsKey] = locked
? value
: hasMeaningfulSettingValue(effectiveValue)
? effectiveValue
: value;
: userDiverged
? userValue
: hasMeaningfulSettingValue(value)
? value
: defaultValues[settingsKey];
}
return acc;
}, {});