diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 9c4f16c80..53a7cd08f 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -542,7 +542,7 @@ export const userRouter = createTRPCRouter({ .set({ pingIconsEnabled: input.pingIconsEnabled, }) - .where(eq(users.id, ctx.session.user.id)); + .where(eq(users.id, input.id)); }), changeDdgBangs: protectedProcedure .input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema))) @@ -603,7 +603,7 @@ export const userRouter = createTRPCRouter({ .set({ firstDayOfWeek: input.firstDayOfWeek, }) - .where(eq(users.id, ctx.session.user.id)); + .where(eq(users.id, input.id)); }), }); diff --git a/packages/settings/src/preferences/definitions.ts b/packages/settings/src/preferences/definitions.ts index c9de2a91f..6575cb74f 100644 --- a/packages/settings/src/preferences/definitions.ts +++ b/packages/settings/src/preferences/definitions.ts @@ -1,14 +1,14 @@ export const userPreferenceDefinitions = [ - { key: "colorScheme", kind: "select", guest: true }, - { key: "locale", kind: "select", guest: true }, - { key: "defaultSearchEngineId", kind: "searchEngine", guest: false }, - { key: "openSearchInNewTab", kind: "boolean", guest: false }, - { key: "ddgBangs", kind: "boolean", guest: false }, - { key: "firstDayOfWeek", kind: "select", guest: false }, - { key: "homeBoardId", kind: "board", guest: false }, - { key: "mobileHomeBoardId", kind: "board", guest: false }, - { key: "pingIconsEnabled", kind: "boolean", guest: false }, - { key: "fullPreferencesPage", kind: "link", guest: false }, + { key: "colorScheme", kind: "select", guest: true, aliases: ["theme", "dark", "light", "auto"] }, + { key: "locale", kind: "select", guest: true, aliases: ["language", "translation"] }, + { key: "defaultSearchEngineId", kind: "searchEngine", guest: false, aliases: ["search", "search engine"] }, + { key: "openSearchInNewTab", kind: "boolean", guest: false, aliases: ["search", "new tab", "external"] }, + { key: "ddgBangs", kind: "boolean", guest: false, aliases: ["duckduckgo", "bangs", "search"] }, + { key: "firstDayOfWeek", kind: "select", guest: false, aliases: ["calendar", "week", "weekday"] }, + { key: "homeBoardId", kind: "board", guest: false, aliases: ["home", "board", "dashboard"] }, + { key: "mobileHomeBoardId", kind: "board", guest: false, aliases: ["mobile", "home", "board", "dashboard"] }, + { key: "pingIconsEnabled", kind: "boolean", guest: false, aliases: ["ping", "icon", "status"] }, + { key: "fullPreferencesPage", kind: "link", guest: false, aliases: ["settings", "manage", "profile"] }, ] as const; export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number]; diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json index a3a36a3da..8dc8f6e01 100644 --- a/packages/spotlight/package.json +++ b/packages/spotlight/package.json @@ -29,6 +29,7 @@ "@homarr/integrations": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0", + "@homarr/notifications": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0", diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index 8e744c0a2..1ea06198e 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -4,7 +4,7 @@ import type { Dispatch, SetStateAction } from "react"; import { useCallback, useMemo, useRef, useState } from "react"; import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; -import { IconSearch, IconX } from "@tabler/icons-react"; +import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react"; import { hotkeys } from "@homarr/definitions"; import type { TranslationObject } from "@homarr/translation"; @@ -58,11 +58,14 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh setChildrenStack([]); }, []); - const pushChildrenOptions = useCallback((options: ChildrenOptions) => { - setChildrenStack((currentStack) => [...currentStack, options]); - setQuery(""); - setTimeout(() => selectAction(0, spotlightStore)); - }, [setQuery]); + const pushChildrenOptions = useCallback( + (options: ChildrenOptions) => { + setChildrenStack((currentStack) => [...currentStack, options]); + setQuery(""); + setTimeout(() => selectAction(0, spotlightStore)); + }, + [setQuery], + ); const popChildrenOptions = useCallback(() => { setChildrenStack((currentStack) => currentStack.slice(0, -1)); @@ -117,6 +120,11 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh rightSection={ mode === defaultMode && childrenStack.length === 0 ? null : ( 0 + ? t("search.mode.command.group.preferences.children.detail.backAction") + : t("common.action.close") + } onClick={() => { if (childrenStack.length > 0) { popChildrenOptions(); @@ -129,9 +137,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh inputRef.current?.focus(); }} variant="subtle" - aria-label={t("common.action.close")} + aria-label={ + childrenStack.length > 0 + ? t("search.mode.command.group.preferences.children.detail.backAction") + : t("common.action.close") + } > - + {childrenStack.length > 0 ? : } ) } diff --git a/packages/spotlight/src/modes/command/children/language.tsx b/packages/spotlight/src/modes/command/children/language.tsx index 1876b3930..e73f91d84 100644 --- a/packages/spotlight/src/modes/command/children/language.tsx +++ b/packages/spotlight/src/modes/command/children/language.tsx @@ -19,7 +19,7 @@ export const languageChildrenOptions = createChildrenOptions Math.min( languageA.configuration.name.toLowerCase().indexOf(normalizedQuery), diff --git a/packages/spotlight/src/modes/command/children/preferences/registry.ts b/packages/spotlight/src/modes/command/children/preferences/registry.ts index 70839c2bd..9baf13397 100644 --- a/packages/spotlight/src/modes/command/children/preferences/registry.ts +++ b/packages/spotlight/src/modes/command/children/preferences/registry.ts @@ -15,14 +15,20 @@ import { userPreferenceDefinitions } from "@homarr/settings"; import type { UserPreferenceKey } from "@homarr/settings"; import type { TablerIcon } from "@homarr/ui"; -import { createChildrenOptions } from "../../../../lib/children"; +import type { createChildrenOptions } from "../../../../lib/children"; import { languageChildrenOptions } from "../language"; import { createBoardChildrenOptions } from "./board"; import { colorSchemeChildrenOptions } from "./color-scheme"; import { firstDayOfWeekChildrenOptions } from "./first-day-of-week"; import { searchEngineChildrenOptions } from "./search-engine"; -type ChildrenPreferenceKey = "colorScheme" | "locale" | "defaultSearchEngineId" | "firstDayOfWeek" | "homeBoardId" | "mobileHomeBoardId"; +type ChildrenPreferenceKey = + | "colorScheme" + | "locale" + | "defaultSearchEngineId" + | "firstDayOfWeek" + | "homeBoardId" + | "mobileHomeBoardId"; type PreferenceChildrenOptionsFactory = ReturnType>>; diff --git a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx index 01d57e1c9..07c46efd5 100644 --- a/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx +++ b/packages/spotlight/src/modes/command/children/preferences/settings-children.tsx @@ -16,7 +16,7 @@ import type { TablerIcon } from "@homarr/ui"; import { createChildrenOptions } from "../../../../lib/children"; import type { ChildrenAction } from "../../../../lib/children"; import { interaction } from "../../../../lib/interaction"; -import { useUserPreference } from "../../../../preferences/use-user-preference"; +import { useUserPreferences } from "../../../../preferences/use-user-preference"; import { preferenceChildrenOptionsByKey, preferenceIcons, visiblePreferenceDefinitions } from "./registry"; dayjs.extend(localeData); @@ -25,7 +25,17 @@ type SettingsAction = ChildrenAction>; const weekDays = dayjs.weekdays(false); -const BooleanRow = ({ label, Icon, checked, isPending }: { label: string; Icon: TablerIcon; checked: boolean; isPending: boolean }) => ( +const BooleanRow = ({ + label, + Icon, + checked, + isPending, +}: { + label: string; + Icon: TablerIcon; + checked: boolean; + isPending: boolean; +}) => ( @@ -41,7 +51,9 @@ const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIco {label} - {valueLabel} + + {valueLabel} + ); @@ -58,24 +70,28 @@ const useSettingsActions = (_: Record, query: string): Settings const { data: session } = useSession(); const isAuthenticated = Boolean(session?.user); const normalizedQuery = query.trim().toLowerCase(); + const preferences = useUserPreferences(); - const colorScheme = useUserPreference("colorScheme"); - const locale = useUserPreference("locale"); - const defaultSearchEngineId = useUserPreference("defaultSearchEngineId"); - const openSearchInNewTab = useUserPreference("openSearchInNewTab"); - const ddgBangs = useUserPreference("ddgBangs"); - const firstDayOfWeek = useUserPreference("firstDayOfWeek"); - const homeBoardId = useUserPreference("homeBoardId"); - const mobileHomeBoardId = useUserPreference("mobileHomeBoardId"); - const pingIconsEnabled = useUserPreference("pingIconsEnabled"); + const colorScheme = preferences.getPreference("colorScheme"); + const locale = preferences.getPreference("locale"); + const defaultSearchEngineId = preferences.getPreference("defaultSearchEngineId"); + const openSearchInNewTab = preferences.getPreference("openSearchInNewTab"); + const ddgBangs = preferences.getPreference("ddgBangs"); + const firstDayOfWeek = preferences.getPreference("firstDayOfWeek"); + const homeBoardId = preferences.getPreference("homeBoardId"); + const mobileHomeBoardId = preferences.getPreference("mobileHomeBoardId"); + const pingIconsEnabled = preferences.getPreference("pingIconsEnabled"); - const boardsQuery = clientApi.board.getAllBoards.useQuery(); - const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(); + const boardsQuery = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isAuthenticated }); + const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated }); const boards = boardsQuery.data ?? []; const engines = searchEnginesQuery.data ?? []; - const prefState: Record, { value: unknown; setValue: (v: never) => void; isPending: boolean }> = { + const prefState: Record< + Exclude, + { value: unknown; setValue: (v: never) => void; isPending: boolean } + > = { colorScheme, locale, defaultSearchEngineId, @@ -90,7 +106,8 @@ const useSettingsActions = (_: Record, query: string): Settings const valueLabels: Record, string> = { colorScheme: tColorScheme(colorScheme.value as ColorScheme), locale: localeConfigurations[locale.value as keyof typeof localeConfigurations]?.name ?? String(locale.value), - defaultSearchEngineId: engines.find((e) => e.value === defaultSearchEngineId.value)?.label ?? t("searchEngine.none"), + defaultSearchEngineId: + engines.find((e) => e.value === defaultSearchEngineId.value)?.label ?? t("searchEngine.none"), openSearchInNewTab: "", ddgBangs: "", firstDayOfWeek: weekDays[firstDayOfWeek.value as DayOfWeek] ?? String(firstDayOfWeek.value), @@ -105,37 +122,46 @@ const useSettingsActions = (_: Record, query: string): Settings const label = t(`${definition.key}.label` as never); const Icon = preferenceIcons[definition.key]; - if (!label.toLowerCase().includes(normalizedQuery)) return []; + const matchesSearch = [label, ...definition.aliases].some((value) => value.toLowerCase().includes(normalizedQuery)); + if (!matchesSearch) return []; if (definition.kind === "boolean") { const state = prefState[definition.key as keyof typeof prefState]; const checked = state.value as boolean; - return [{ - key: definition.key, - Component: () => , - useInteraction: interaction.javaScript(() => ({ - onSelect: () => state.setValue(!checked as never), - closeSpotlightOnTrigger: false, - })), - }]; + return [ + { + key: definition.key, + Component: () => , + useInteraction: interaction.javaScript(() => ({ + onSelect: state.isPending ? () => undefined : () => state.setValue(!checked as never), + closeSpotlightOnTrigger: false, + })), + }, + ]; } if (definition.kind === "link") { - return [{ - key: definition.key, - Component: () => , - useInteraction: interaction.link(() => ({ - href: `/manage/users/${session?.user.id ?? ""}/general`, - })), - }]; + return [ + { + key: definition.key, + Component: () => , + useInteraction: interaction.link(() => ({ + href: `/manage/users/${session?.user.id ?? ""}/general`, + })), + }, + ]; } const childrenKey = definition.key as keyof typeof preferenceChildrenOptionsByKey; - return [{ - key: definition.key, - Component: () => , - useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]), - }]; + return [ + { + key: definition.key, + Component: () => ( + + ), + useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]), + }, + ]; }); }; @@ -146,9 +172,10 @@ export const settingsChildrenOptions = createChildrenOptions {t("title")} - {t("children.detail.backHint")} + + {t("children.detail.backHint")} + ); }, }); - diff --git a/packages/spotlight/src/modes/preferences/groups.tsx b/packages/spotlight/src/modes/preferences/groups.tsx index 4a0ccd08f..4b3db8dc4 100644 --- a/packages/spotlight/src/modes/preferences/groups.tsx +++ b/packages/spotlight/src/modes/preferences/groups.tsx @@ -1,25 +1,84 @@ import { Group, Text } from "@mantine/core"; import { IconSettings } from "@tabler/icons-react"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useSession } from "@homarr/auth/client"; +import { colorSchemes } from "@homarr/definitions"; +import { userPreferenceDefinitions } from "@homarr/settings"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { createGroup } from "../../lib/group"; -import { interaction } from "../../lib/interaction"; +import { preferenceChildrenOptionsByKey, preferenceIcons } from "../command/children/preferences/registry"; import { settingsChildrenOptions } from "../command/children/preferences/settings-children"; -export const preferencesGroup = createGroup({ +const normalizeSearchValue = (value: string) => value.trim().toLowerCase(); + +type PreferenceChildKey = Extract; + +type PreferencesGroupOption = + | { + key: "settings"; + preferenceKey: null; + } + | { + key: PreferenceChildKey; + preferenceKey: PreferenceChildKey; + }; + +const settingsOption = { key: "settings", preferenceKey: null } satisfies PreferencesGroupOption; + +export const preferencesGroup = createGroup({ keyPath: "key", title: (t) => t("search.mode.command.group.preferences.title"), - options: [{ key: "settings" }], - Component: () => { + useOptions(query) { + const t = useI18n(); + const tColorScheme = useScopedI18n("common.colorScheme.options"); + const { data: session } = useSession(); + const isAuthenticated = Boolean(session?.user); + const visibleDefinitions = userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated); + const normalizedQuery = normalizeSearchValue(query); + if (normalizedQuery.length === 0) return [settingsOption]; + + const settingsSearchValues = [ + t("search.mode.command.group.preferences.title"), + ...visibleDefinitions.flatMap((definition) => definition.aliases), + ]; + + const matchingPreferenceOptions = visibleDefinitions.flatMap((definition): PreferencesGroupOption[] => { + const label = t(`search.mode.command.group.preferences.option.${definition.key}.label` as never); + const searchValues = [ + label, + ...definition.aliases, + ...(definition.key === "colorScheme" ? colorSchemes.map((scheme) => tColorScheme(scheme)) : []), + ]; + if (!searchValues.some((value) => normalizeSearchValue(value).includes(normalizedQuery))) return []; + if (!(definition.key in preferenceChildrenOptionsByKey)) return []; + const preferenceKey = definition.key as PreferenceChildKey; + + return [{ key: preferenceKey, preferenceKey }]; + }); + + if (matchingPreferenceOptions.length > 0) return matchingPreferenceOptions; + + return settingsSearchValues.some((value) => normalizeSearchValue(value).includes(normalizedQuery)) + ? [settingsOption] + : []; + }, + Component: (option) => { const t = useScopedI18n("search.mode.command.group.preferences"); + const tOption = useScopedI18n("search.mode.command.group.preferences.option"); + const Icon = option.preferenceKey ? preferenceIcons[option.preferenceKey] : IconSettings; + const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title"); + return ( - - {t("title")} + + {label} ); }, - filter: (_query, _option) => true, - useInteraction: interaction.children(settingsChildrenOptions), + filter: () => true, + useInteraction: (option) => ({ + type: "children", + ...(option.preferenceKey ? preferenceChildrenOptionsByKey[option.preferenceKey]({}) : settingsChildrenOptions({})), + }), }); diff --git a/packages/spotlight/src/preferences/use-user-preference.ts b/packages/spotlight/src/preferences/use-user-preference.ts index 214df50b6..8cf47c7ea 100644 --- a/packages/spotlight/src/preferences/use-user-preference.ts +++ b/packages/spotlight/src/preferences/use-user-preference.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { useRouter } from "next/navigation"; import { useMantineColorScheme } from "@mantine/core"; import type { DayOfWeek } from "@mantine/dates"; @@ -8,10 +8,11 @@ import type { DayOfWeek } from "@mantine/dates"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import type { ColorScheme } from "@homarr/definitions"; +import { showErrorNotification } from "@homarr/notifications"; import type { SettingsContextProps } from "@homarr/settings/creator"; import type { UserPreferenceKey } from "@homarr/settings"; import { useSettings } from "@homarr/settings"; -import { useChangeLocale, useCurrentLocale } from "@homarr/translation/client"; +import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client"; const optimisticPreferenceKeys = new Set([ "defaultSearchEngineId", @@ -24,14 +25,36 @@ const optimisticPreferenceKeys = new Set([ ]); const optimisticPreferencesRef: { current: Partial> } = { current: {} }; +let optimisticPreferencesVersion = 0; +const optimisticPreferenceListeners = new Set<() => void>(); const searchPreferenceKeys = ["defaultSearchEngineId", "openSearchInNewTab", "ddgBangs"] as const; const homeBoardPreferenceKeys = ["homeBoardId", "mobileHomeBoardId"] as const; +const notifyOptimisticPreferenceListeners = () => { + optimisticPreferencesVersion += 1; + optimisticPreferenceListeners.forEach((listener) => listener()); +}; + +const subscribeToOptimisticPreferenceChanges = (listener: () => void) => { + optimisticPreferenceListeners.add(listener); + return () => optimisticPreferenceListeners.delete(listener); +}; + +const setOptimisticPreference = (preferenceKey: UserPreferenceKey, value: unknown) => { + optimisticPreferencesRef.current[preferenceKey] = value; + notifyOptimisticPreferenceListeners(); +}; + const clearOptimisticPreferences = (keys: readonly UserPreferenceKey[]) => { + let changed = false; keys.forEach((preferenceKey) => { + changed ||= preferenceKey in optimisticPreferencesRef.current; delete optimisticPreferencesRef.current[preferenceKey]; }); + if (!changed) return; + + notifyOptimisticPreferenceListeners(); }; const getEffectivePreferenceValue = (preferenceKey: UserPreferenceKey, settings: SettingsContextProps): unknown => { @@ -57,7 +80,7 @@ const refreshAfterMutation = (router: ReturnType) => { router.refresh(); }; -export const useUserPreference = (key: UserPreferenceKey) => { +export const useUserPreferences = () => { const router = useRouter(); const settings = useSettings(); const settingsRef = useRef(settings); @@ -67,141 +90,258 @@ export const useUserPreference = (key: UserPreferenceKey) => { const { colorScheme, setColorScheme } = useMantineColorScheme(); const currentLocale = useCurrentLocale(); const { changeLocale } = useChangeLocale(); - const [optimisticValue, setOptimisticValue] = useState(); - - const { mutate: mutateSearchPreferences, isPending: isSearchPreferencesPending } = - clientApi.user.changeSearchPreferences.useMutation({ - onSuccess: () => refreshAfterMutation(router), - onError: () => clearOptimisticPreferences(searchPreferenceKeys), - }); - - const { mutate: mutateHomeBoards, isPending: isHomeBoardsPending } = clientApi.user.changeHomeBoards.useMutation({ - onSuccess: () => refreshAfterMutation(router), - onError: () => clearOptimisticPreferences(homeBoardPreferenceKeys), + const t = useI18n(); + const pendingGroupsRef = useRef({ + search: false, + homeBoards: false, + firstDayOfWeek: false, + pingIcons: false, }); - const { mutate: mutateFirstDayOfWeek, isPending: isFirstDayOfWeekPending } = - clientApi.user.changeFirstDayOfWeek.useMutation({ + useSyncExternalStore( + subscribeToOptimisticPreferenceChanges, + () => optimisticPreferencesVersion, + () => optimisticPreferencesVersion, + ); + + const showPreferenceErrorNotification = () => { + showErrorNotification({ + message: t("search.mode.command.group.preferences.notification.error.message"), + }); + }; + + const { mutateAsync: mutateSearchPreferencesAsync, isPending: isSearchPreferencesPending } = + clientApi.user.changeSearchPreferences.useMutation({ onSuccess: () => refreshAfterMutation(router), - onError: () => clearOptimisticPreferences(["firstDayOfWeek"]), }); - const { mutate: mutatePingIconsEnabled, isPending: isPingIconsPending } = + const { mutateAsync: mutateHomeBoardsAsync, isPending: isHomeBoardsPending } = + clientApi.user.changeHomeBoards.useMutation({ + onSuccess: () => refreshAfterMutation(router), + }); + + const { mutateAsync: mutateFirstDayOfWeekAsync, isPending: isFirstDayOfWeekPending } = + clientApi.user.changeFirstDayOfWeek.useMutation({ + onSuccess: () => refreshAfterMutation(router), + }); + + const { mutateAsync: mutatePingIconsEnabledAsync, isPending: isPingIconsPending } = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: () => refreshAfterMutation(router), - onError: () => clearOptimisticPreferences(["pingIconsEnabled"]), }); const userId = session?.user.id; - const persistedValues: Record = { - colorScheme: colorScheme as ColorScheme, - locale: currentLocale, - defaultSearchEngineId: settings.defaultSearchEngineId, - openSearchInNewTab: settings.openSearchInNewTab, - ddgBangs: settings.ddgBangs, - firstDayOfWeek: settings.firstDayOfWeek, - homeBoardId: settings.homeBoardId, - mobileHomeBoardId: settings.mobileHomeBoardId, - pingIconsEnabled: settings.pingIconsEnabled, - fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null, - }; + const persistedValues = useMemo>( + () => ({ + colorScheme: colorScheme as ColorScheme, + locale: currentLocale, + defaultSearchEngineId: settings.defaultSearchEngineId, + openSearchInNewTab: settings.openSearchInNewTab, + ddgBangs: settings.ddgBangs, + firstDayOfWeek: settings.firstDayOfWeek, + homeBoardId: settings.homeBoardId, + mobileHomeBoardId: settings.mobileHomeBoardId, + pingIconsEnabled: settings.pingIconsEnabled, + fullPreferencesPage: userId ? `/manage/users/${userId}/general` : null, + }), + [ + colorScheme, + currentLocale, + settings.defaultSearchEngineId, + settings.openSearchInNewTab, + settings.ddgBangs, + settings.firstDayOfWeek, + settings.homeBoardId, + settings.mobileHomeBoardId, + settings.pingIconsEnabled, + userId, + ], + ); - const persistedValue = persistedValues[key]; + const getValue = (preferenceKey: UserPreferenceKey) => + preferenceKey in optimisticPreferencesRef.current + ? optimisticPreferencesRef.current[preferenceKey] + : persistedValues[preferenceKey]; useEffect(() => { - delete optimisticPreferencesRef.current[key]; - setOptimisticValue(undefined); - }, [persistedValue, key]); + clearOptimisticPreferences( + Object.entries(persistedValues) + .filter( + ([preferenceKey, value]) => optimisticPreferencesRef.current[preferenceKey as UserPreferenceKey] === value, + ) + .map(([preferenceKey]) => preferenceKey as UserPreferenceKey), + ); + }, [persistedValues]); - const setPersistedValue = (value: never) => { + const setPersistedValueAsync = async (key: UserPreferenceKey, value: never) => { const currentSettings = settingsRef.current; - const setters: Record void> = { + const setters: Record Promise | void> = { colorScheme: (nextValue) => setColorScheme(nextValue as ColorScheme), locale: (nextValue) => changeLocale(nextValue as typeof currentLocale), - defaultSearchEngineId: (nextValue) => { + defaultSearchEngineId: async (nextValue) => { if (!userId) return; - mutateSearchPreferences({ - userId, - defaultSearchEngineId: nextValue as string | null, - openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, - ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, - }); + if (pendingGroupsRef.current.search) return; + pendingGroupsRef.current.search = true; + try { + await mutateSearchPreferencesAsync({ + userId, + defaultSearchEngineId: nextValue as string | null, + openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, + ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, + }); + } finally { + pendingGroupsRef.current.search = false; + } }, - openSearchInNewTab: (nextValue) => { + openSearchInNewTab: async (nextValue) => { if (!userId) return; - mutateSearchPreferences({ - userId, - defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as string | null, - openInNewTab: nextValue as boolean, - ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, - }); + if (pendingGroupsRef.current.search) return; + pendingGroupsRef.current.search = true; + try { + await mutateSearchPreferencesAsync({ + userId, + defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as + | string + | null, + openInNewTab: nextValue as boolean, + ddgBangsEnabled: getEffectivePreferenceValue("ddgBangs", currentSettings) as boolean, + }); + } finally { + pendingGroupsRef.current.search = false; + } }, - ddgBangs: (nextValue) => { + ddgBangs: async (nextValue) => { if (!userId) return; - mutateSearchPreferences({ - userId, - defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as string | null, - openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, - ddgBangsEnabled: nextValue as boolean, - }); + if (pendingGroupsRef.current.search) return; + pendingGroupsRef.current.search = true; + try { + await mutateSearchPreferencesAsync({ + userId, + defaultSearchEngineId: getEffectivePreferenceValue("defaultSearchEngineId", currentSettings) as + | string + | null, + openInNewTab: getEffectivePreferenceValue("openSearchInNewTab", currentSettings) as boolean, + ddgBangsEnabled: nextValue as boolean, + }); + } finally { + pendingGroupsRef.current.search = false; + } }, - firstDayOfWeek: (nextValue) => { + firstDayOfWeek: async (nextValue) => { if (!userId) return; - mutateFirstDayOfWeek({ id: userId, firstDayOfWeek: nextValue as DayOfWeek }); + if (pendingGroupsRef.current.firstDayOfWeek) return; + pendingGroupsRef.current.firstDayOfWeek = true; + try { + await mutateFirstDayOfWeekAsync({ id: userId, firstDayOfWeek: nextValue as DayOfWeek }); + } finally { + pendingGroupsRef.current.firstDayOfWeek = false; + } }, - homeBoardId: (nextValue) => { + homeBoardId: async (nextValue) => { if (!userId) return; - mutateHomeBoards({ - userId, - homeBoardId: nextValue as string | null, - mobileHomeBoardId: getEffectivePreferenceValue("mobileHomeBoardId", currentSettings) as string | null, - }); + if (pendingGroupsRef.current.homeBoards) return; + pendingGroupsRef.current.homeBoards = true; + try { + await mutateHomeBoardsAsync({ + userId, + homeBoardId: nextValue as string | null, + mobileHomeBoardId: getEffectivePreferenceValue("mobileHomeBoardId", currentSettings) as string | null, + }); + } finally { + pendingGroupsRef.current.homeBoards = false; + } }, - mobileHomeBoardId: (nextValue) => { + mobileHomeBoardId: async (nextValue) => { if (!userId) return; - mutateHomeBoards({ - userId, - homeBoardId: getEffectivePreferenceValue("homeBoardId", currentSettings) as string | null, - mobileHomeBoardId: nextValue as string | null, - }); + if (pendingGroupsRef.current.homeBoards) return; + pendingGroupsRef.current.homeBoards = true; + try { + await mutateHomeBoardsAsync({ + userId, + homeBoardId: getEffectivePreferenceValue("homeBoardId", currentSettings) as string | null, + mobileHomeBoardId: nextValue as string | null, + }); + } finally { + pendingGroupsRef.current.homeBoards = false; + } }, - pingIconsEnabled: (nextValue) => { + pingIconsEnabled: async (nextValue) => { if (!userId) return; - mutatePingIconsEnabled({ id: userId, pingIconsEnabled: nextValue as boolean }); + if (pendingGroupsRef.current.pingIcons) return; + pendingGroupsRef.current.pingIcons = true; + try { + await mutatePingIconsEnabledAsync({ id: userId, pingIconsEnabled: nextValue as boolean }); + } finally { + pendingGroupsRef.current.pingIcons = false; + } }, fullPreferencesPage: () => undefined, }; - setters[key](value); + await setters[key](value); }; - const setValue = (value: never) => { + const isPreferencePending = (key: UserPreferenceKey) => { + if (searchPreferenceKeys.includes(key as never)) + return isSearchPreferencesPending || pendingGroupsRef.current.search; + if (homeBoardPreferenceKeys.includes(key as never)) + return isHomeBoardsPending || pendingGroupsRef.current.homeBoards; + if (key === "firstDayOfWeek") return isFirstDayOfWeekPending || pendingGroupsRef.current.firstDayOfWeek; + if (key === "pingIconsEnabled") return isPingIconsPending || pendingGroupsRef.current.pingIcons; + + return false; + }; + + const setValueAsync = async (key: UserPreferenceKey, value: never) => { + if (optimisticPreferenceKeys.has(key) && !userId) return; + if (isPreferencePending(key)) return; + if (optimisticPreferenceKeys.has(key)) { - optimisticPreferencesRef.current[key] = value; - setOptimisticValue(value); + setOptimisticPreference(key, value); } - setPersistedValue(value); + try { + await setPersistedValueAsync(key, value); + } catch { + if (searchPreferenceKeys.includes(key as never)) { + clearOptimisticPreferences(searchPreferenceKeys); + } else if (homeBoardPreferenceKeys.includes(key as never)) { + clearOptimisticPreferences(homeBoardPreferenceKeys); + } else { + clearOptimisticPreferences([key]); + } + showPreferenceErrorNotification(); + } }; const pendingStates: Record = { colorScheme: false, locale: false, - defaultSearchEngineId: isSearchPreferencesPending, - openSearchInNewTab: isSearchPreferencesPending, - ddgBangs: isSearchPreferencesPending, - firstDayOfWeek: isFirstDayOfWeekPending, - homeBoardId: isHomeBoardsPending, - mobileHomeBoardId: isHomeBoardsPending, - pingIconsEnabled: isPingIconsPending, + defaultSearchEngineId: isPreferencePending("defaultSearchEngineId"), + openSearchInNewTab: isPreferencePending("openSearchInNewTab"), + ddgBangs: isPreferencePending("ddgBangs"), + firstDayOfWeek: isPreferencePending("firstDayOfWeek"), + homeBoardId: isPreferencePending("homeBoardId"), + mobileHomeBoardId: isPreferencePending("mobileHomeBoardId"), + pingIconsEnabled: isPreferencePending("pingIconsEnabled"), fullPreferencesPage: false, }; return { - value: optimisticPreferencesRef.current[key] ?? optimisticValue ?? persistedValue, - setValue, - isPending: pendingStates[key], + getPreference(preferenceKey: UserPreferenceKey) { + return { + value: getValue(preferenceKey), + setValue: (value: never) => setValueAsync(preferenceKey, value), + isPending: pendingStates[preferenceKey], + }; + }, }; }; + +export const useUserPreference = (key: UserPreferenceKey) => { + const preferences = useUserPreferences(); + + return preferences.getPreference(key); +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index c7387d24b..c45101639 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4553,7 +4553,13 @@ "children": { "detail": { "title": "Select a preference to change", - "backHint": "Press Backspace to go back" + "backHint": "Press Backspace or use the back button to go back", + "backAction": "Go back" + } + }, + "notification": { + "error": { + "message": "Unable to update preference" } }, "option": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2a6bb7bb..1cf63c6c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2144,6 +2144,9 @@ importers: '@homarr/modals-collection': specifier: workspace:^0.1.0 version: link:../modals-collection + '@homarr/notifications': + specifier: workspace:^0.1.0 + version: link:../notifications '@homarr/settings': specifier: workspace:^0.1.0 version: link:../settings