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