refactor(spotlight): registry-driven preferences with data-driven hook

Replace 5 per-preference children files with a single
preference-registry.tsx that auto-generates CMDK children via factory
functions. Rewrite useUserPreferences from ~347 lines to ~160 lines by
deriving mutation dispatch, pending state, and optimistic values from
preference definitions.

Simplify settings-children.tsx and groups.tsx to iterate over
visiblePreferenceDefinitions instead of maintaining manual mappings.

Deleted: board.tsx, color-scheme.tsx, first-day-of-week.tsx,
search-engine.tsx, registry.ts

Net reduction: ~261 lines across 9 files.
This commit is contained in:
Thomas Camlong committed 2026-05-29 17:26:27 +02:00
1 parent f6592b27a1
commit 5501ee6a7d
9 files changed
+336 -599

No files matched your search

@@ -1,47 +0,0 @@
import { useUserPreference } from "../../../../preferences/use-user-preference";
import type { UserPreferenceKey } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import { clientApi } from "@homarr/api/client";
import { createChildrenOptions } from "../../../../lib/children";
import { createLoadingPreferenceAction, PreferenceDetailHeader } from "./action-row";
import { createSelectablePreferenceActions } from "./selectable-actions";
interface BoardChildrenOptionsConfig {
preferenceKey: Extract<UserPreferenceKey, "homeBoardId" | "mobileHomeBoardId">;
}
const boardDetailTitleKeys = {
homeBoardId: "homeBoardId.children.detail.title",
mobileHomeBoardId: "mobileHomeBoardId.children.detail.title",
} as const;
export const createBoardChildrenOptions = (config: BoardChildrenOptionsConfig) =>
createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const { value, setValue, isPending } = useUserPreference(config.preferenceKey);
const currentValue = value as string | null;
const boardsQuery = clientApi.board.getAllBoards.useQuery();
const t = useScopedI18n("search.mode.command.group.preferences.option");
if (boardsQuery.isLoading) {
return [createLoadingPreferenceAction()];
}
return createSelectablePreferenceActions({
query,
currentValue,
noneLabel: t("board.none"),
unavailableLabel: t("board.unavailable"),
items: (boardsQuery.data ?? []).map((board) => ({
key: board.id,
label: board.name,
value: board.id,
})),
onSelect: (nextValue) => setValue(nextValue as never),
isPending,
});
},
DetailComponent: () => <PreferenceDetailHeader titleKey={boardDetailTitleKeys[config.preferenceKey]} />,
});
@@ -1,26 +0,0 @@
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import { useUserPreference } from "../../../../preferences/use-user-preference";
import { useScopedI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../../../lib/children";
import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./action-row";
export const colorSchemeChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: () => {
const tOptions = useScopedI18n("common.colorScheme.options");
const { value, setValue, isPending } = useUserPreference("colorScheme");
const currentValue = value as ColorScheme;
return colorSchemes.map((scheme) =>
createCheckmarkPreferenceAction({
key: scheme,
label: tOptions(scheme),
isSelected: currentValue === scheme,
onSelect: () => setValue(scheme as never),
isPending,
}),
);
},
DetailComponent: () => <PreferenceDetailHeader titleKey="colorScheme.children.detail.title" />,
});
@@ -1,31 +0,0 @@
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import { useUserPreference } from "../../../../preferences/use-user-preference";
import { createChildrenOptions } from "../../../../lib/children";
import { createCheckmarkPreferenceAction, PreferenceDetailHeader } from "./action-row";
dayjs.extend(localeData);
const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0];
const weekDays = dayjs.weekdays(false);
export const firstDayOfWeekChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: () => {
const { value, setValue, isPending } = useUserPreference("firstDayOfWeek");
const currentValue = value as DayOfWeek;
return firstDayOfWeekOptions.map((day) =>
createCheckmarkPreferenceAction({
key: day.toString(),
label: weekDays[day] ?? day.toString(),
isSelected: currentValue === day,
onSelect: () => setValue(day as never),
isPending,
}),
);
},
DetailComponent: () => <PreferenceDetailHeader titleKey="firstDayOfWeek.children.detail.title" />,
});
@@ -1,58 +0,0 @@
import {
IconActivity,
IconCalendarWeek,
IconDeviceDesktop,
IconExternalLink,
IconHome,
IconLanguage,
IconLayoutBoard,
IconSearch,
IconSettings,
IconWorldWww,
} from "@tabler/icons-react";
import { userPreferenceDefinitions } from "@homarr/settings";
import type { UserPreferenceKey } from "@homarr/settings";
import type { TablerIcon } from "@homarr/ui";
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 PreferenceChildrenOptionsFactory = ReturnType<typeof createChildrenOptions<Record<string, unknown>>>;
export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
colorScheme: IconDeviceDesktop,
locale: IconLanguage,
defaultSearchEngineId: IconSearch,
openSearchInNewTab: IconExternalLink,
ddgBangs: IconWorldWww,
firstDayOfWeek: IconCalendarWeek,
homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity,
fullPreferencesPage: IconSettings,
};
export const preferenceChildrenOptionsByKey: Record<ChildrenPreferenceKey, PreferenceChildrenOptionsFactory> = {
colorScheme: colorSchemeChildrenOptions,
locale: languageChildrenOptions,
defaultSearchEngineId: searchEngineChildrenOptions,
firstDayOfWeek: firstDayOfWeekChildrenOptions,
homeBoardId: createBoardChildrenOptions({ preferenceKey: "homeBoardId" }),
mobileHomeBoardId: createBoardChildrenOptions({ preferenceKey: "mobileHomeBoardId" }),
};
export const visiblePreferenceDefinitions = (isAuthenticated: boolean) =>
userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated);
@@ -1,35 +0,0 @@
import { clientApi } from "@homarr/api/client";
import { useUserPreference } from "../../../../preferences/use-user-preference";
import { useScopedI18n } from "@homarr/translation/client";
import { createChildrenOptions } from "../../../../lib/children";
import { createLoadingPreferenceAction, PreferenceDetailHeader } from "./action-row";
import { createSelectablePreferenceActions } from "./selectable-actions";
export const searchEngineChildrenOptions = createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const { value, setValue, isPending } = useUserPreference("defaultSearchEngineId");
const currentValue = value as string | null;
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery();
const t = useScopedI18n("search.mode.command.group.preferences.option");
if (searchEnginesQuery.isLoading) {
return [createLoadingPreferenceAction()];
}
return createSelectablePreferenceActions({
query,
currentValue,
noneLabel: t("searchEngine.none"),
unavailableLabel: t("searchEngine.unavailable"),
items: (searchEnginesQuery.data ?? []).map((engine) => ({
key: engine.value,
label: engine.label,
value: engine.value,
})),
onSelect: (nextValue) => setValue(nextValue as never),
isPending,
});
},
DetailComponent: () => <PreferenceDetailHeader titleKey="defaultSearchEngineId.children.detail.title" />,
});
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { ColorScheme } from "@homarr/definitions";
import type { UserPreferenceKey } from "@homarr/settings";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { localeConfigurations } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
@@ -17,7 +18,7 @@ import { createChildrenOptions } from "../../../../lib/children";
import type { ChildrenAction } from "../../../../lib/children";
import { interaction } from "../../../../lib/interaction";
import { useUserPreferences } from "../../../../preferences/use-user-preference";
import { preferenceChildrenOptionsByKey, preferenceIcons, visiblePreferenceDefinitions } from "./registry";
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../../../preferences/preference-registry";
dayjs.extend(localeData);
@@ -25,17 +26,7 @@ type SettingsAction = ChildrenAction<Record<string, unknown>>;
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 }) => (
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
<Group wrap="nowrap" gap="sm">
<Icon stroke={1.5} size={20} />
@@ -51,9 +42,7 @@ const SelectRow = ({ label, Icon, valueLabel }: { label: string; Icon: TablerIco
<Icon stroke={1.5} size={20} />
<Text>{label}</Text>
</Group>
<Text size="sm" c="dimmed">
{valueLabel}
</Text>
<Text size="sm" c="dimmed">{valueLabel}</Text>
</Group>
);
@@ -64,6 +53,8 @@ const LinkRow = ({ label, Icon }: { label: string; Icon: TablerIcon }) => (
</Group>
);
const SELECT_KINDS = new Set(["select", "searchEngine", "board"]);
const useSettingsActions = (_: Record<string, unknown>, query: string): SettingsAction[] => {
const t = useScopedI18n("search.mode.command.group.preferences.option");
const tColorScheme = useScopedI18n("common.colorScheme.options");
@@ -72,97 +63,65 @@ const useSettingsActions = (_: Record<string, unknown>, query: string): Settings
const normalizedQuery = query.trim().toLowerCase();
const preferences = useUserPreferences();
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(undefined, { enabled: isAuthenticated });
const searchEnginesQuery = clientApi.searchEngine.getSelectable.useQuery(undefined, { enabled: isAuthenticated });
const boards = boardsQuery.data ?? [];
const engines = searchEnginesQuery.data ?? [];
const prefState: Record<
Exclude<UserPreferenceKey, "fullPreferencesPage">,
{ value: unknown; setValue: (v: never) => void; isPending: boolean }
> = {
colorScheme,
locale,
defaultSearchEngineId,
openSearchInNewTab,
ddgBangs,
firstDayOfWeek,
homeBoardId,
mobileHomeBoardId,
pingIconsEnabled,
const valueLabelResolvers: Record<string, () => string> = {
colorScheme: () => tColorScheme(preferences.getPreference("colorScheme").value as ColorScheme),
locale: () => {
const val = preferences.getPreference("locale").value;
return localeConfigurations[val as keyof typeof localeConfigurations]?.name ?? String(val);
},
defaultSearchEngineId: () =>
searchEnginesQuery.data?.find((e) => e.value === preferences.getPreference("defaultSearchEngineId").value)?.label ?? t("searchEngine.none" as never),
firstDayOfWeek: () => weekDays[preferences.getPreference("firstDayOfWeek").value as DayOfWeek] ?? "",
homeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("homeBoardId").value)?.name ?? t("board.none" as never),
mobileHomeBoardId: () =>
boardsQuery.data?.find((b) => b.id === preferences.getPreference("mobileHomeBoardId").value)?.name ?? t("board.none" as never),
};
const valueLabels: Record<Exclude<UserPreferenceKey, "fullPreferencesPage">, 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"),
openSearchInNewTab: "",
ddgBangs: "",
firstDayOfWeek: weekDays[firstDayOfWeek.value as DayOfWeek] ?? String(firstDayOfWeek.value),
homeBoardId: boards.find((b) => b.id === homeBoardId.value)?.name ?? t("board.none"),
mobileHomeBoardId: boards.find((b) => b.id === mobileHomeBoardId.value)?.name ?? t("board.none"),
pingIconsEnabled: "",
};
return visiblePreferenceDefinitions(isAuthenticated)
.map((definition): SettingsAction | null => {
const label = t(`${definition.key}.label` as never);
const Icon = preferenceIcons[definition.key];
const definitions = visiblePreferenceDefinitions(isAuthenticated);
const matchesSearch = [label, ...definition.aliases].some((v) => v.toLowerCase().includes(normalizedQuery));
if (!matchesSearch) return null;
return definitions.flatMap((definition): SettingsAction[] => {
const label = t(`${definition.key}.label` as never);
const Icon = preferenceIcons[definition.key];
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 [
{
if (definition.kind === "boolean") {
const pref = preferences.getPreference(definition.key);
return {
key: definition.key,
Component: () => <BooleanRow label={label} Icon={Icon} checked={checked} isPending={state.isPending} />,
Component: () => <BooleanRow label={label} Icon={Icon} checked={pref.value as boolean} isPending={pref.isPending} />,
useInteraction: interaction.javaScript(() => ({
onSelect: state.isPending ? () => undefined : () => state.setValue(!checked as never),
onSelect: pref.isPending ? () => undefined : () => pref.setValue(!(pref.value as boolean) as never),
closeSpotlightOnTrigger: false,
})),
},
];
}
};
}
if (definition.kind === "link") {
return [
{
if (definition.kind === "link") {
return {
key: definition.key,
Component: () => <LinkRow label={label} Icon={Icon} />,
useInteraction: interaction.link(() => ({
href: `/manage/users/${session?.user.id ?? ""}/general`,
})),
},
];
}
useInteraction: interaction.link(() => ({ href: `/manage/users/${session?.user.id ?? ""}/general` })),
};
}
const childrenKey = definition.key as keyof typeof preferenceChildrenOptionsByKey;
return [
{
key: definition.key,
Component: () => (
<SelectRow label={label} Icon={Icon} valueLabel={valueLabels[definition.key as keyof typeof valueLabels]} />
),
useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]),
},
];
});
if (SELECT_KINDS.has(definition.kind)) {
const children = preferenceChildrenOptionsByKey[definition.key];
if (!children) return null;
return {
key: definition.key,
Component: () => <SelectRow label={label} Icon={Icon} valueLabel={valueLabelResolvers[definition.key]?.() ?? ""} />,
useInteraction: interaction.children(children),
};
}
return null;
})
.filter((action): action is SettingsAction => action !== null);
};
export const settingsChildrenOptions = createChildrenOptions<Record<string, unknown>>({
@@ -172,9 +131,7 @@ export const settingsChildrenOptions = createChildrenOptions<Record<string, unkn
return (
<Stack mx="md" my="sm" gap="xs">
<Text>{t("title")}</Text>
<Text size="xs" c="dimmed">
{t("children.detail.backHint")}
</Text>
<Text size="xs" c="dimmed">{t("children.detail.backHint")}</Text>
</Stack>
);
},
@@ -3,28 +3,18 @@ import { IconSettings } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { colorSchemes } from "@homarr/definitions";
import { userPreferenceDefinitions } from "@homarr/settings";
import { visiblePreferenceDefinitions } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { createGroup } from "../../lib/group";
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../command/children/preferences/registry";
import { preferenceChildrenOptionsByKey, preferenceIcons } from "../../preferences/preference-registry";
import { settingsChildrenOptions } from "../command/children/preferences/settings-children";
const normalizeSearchValue = (value: string) => value.trim().toLowerCase();
type PreferenceChildKey = Extract<keyof typeof preferenceChildrenOptionsByKey, string>;
type PreferencesGroupOption =
| {
key: "settings";
preferenceKey: null;
}
| {
key: PreferenceChildKey;
preferenceKey: PreferenceChildKey;
};
| { key: "settings"; preferenceKey: null }
| { key: string; preferenceKey: string };
const settingsOption = { key: "settings", preferenceKey: null } satisfies PreferencesGroupOption;
const settingsOption: PreferencesGroupOption = { key: "settings", preferenceKey: null };
export const preferencesGroup = createGroup<PreferencesGroupOption>({
keyPath: "key",
@@ -33,40 +23,37 @@ export const preferencesGroup = createGroup<PreferencesGroupOption>({
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 definitions = visiblePreferenceDefinitions(Boolean(session?.user));
const q = query.trim().toLowerCase();
const settingsSearchValues = [
t("search.mode.command.group.preferences.title"),
...visibleDefinitions.flatMap((definition) => definition.aliases),
];
if (q.length === 0) return [settingsOption];
const matchingPreferenceOptions = visibleDefinitions.flatMap((definition): PreferencesGroupOption[] => {
const label = t(`search.mode.command.group.preferences.option.${definition.key}.label` as never);
const matching = definitions.flatMap((def): PreferencesGroupOption[] => {
if (!preferenceChildrenOptionsByKey[def.key]) return [];
const label = t(`search.mode.command.group.preferences.option.${def.key}.label` as never);
const searchValues = [
label,
...definition.aliases,
...(definition.key === "colorScheme" ? colorSchemes.map((scheme) => tColorScheme(scheme)) : []),
...def.aliases,
...(def.key === "colorScheme" ? colorSchemes.map((s) => tColorScheme(s)) : []),
];
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 (!searchValues.some((v) => v.toLowerCase().includes(q))) return [];
return [{ key: def.key, preferenceKey: def.key }];
});
if (matchingPreferenceOptions.length > 0) return matchingPreferenceOptions;
if (matching.length > 0) return matching;
return settingsSearchValues.some((value) => normalizeSearchValue(value).includes(normalizedQuery))
? [settingsOption]
: [];
const allSearchValues = [
t("search.mode.command.group.preferences.title"),
...definitions.flatMap((d) => d.aliases),
];
return allSearchValues.some((v) => v.toLowerCase().includes(q)) ? [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 Icon = option.preferenceKey
? preferenceIcons[option.preferenceKey as keyof typeof preferenceIcons]
: IconSettings;
const label = option.preferenceKey ? tOption(`${option.preferenceKey}.label` as never) : t("title");
return (
@@ -79,6 +66,8 @@ export const preferencesGroup = createGroup<PreferencesGroupOption>({
filter: () => true,
useInteraction: (option) => ({
type: "children",
...(option.preferenceKey ? preferenceChildrenOptionsByKey[option.preferenceKey]({}) : settingsChildrenOptions({})),
...(option.preferenceKey
? preferenceChildrenOptionsByKey[option.preferenceKey as keyof typeof preferenceChildrenOptionsByKey]!({})
: settingsChildrenOptions({})),
}),
});
@@ -0,0 +1,137 @@
"use client";
import {
IconActivity,
IconCalendarWeek,
IconDeviceDesktop,
IconExternalLink,
IconHome,
IconLanguage,
IconLayoutBoard,
IconSearch,
IconSettings,
IconWorldWww,
} from "@tabler/icons-react";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import { clientApi } from "@homarr/api/client";
import { colorSchemes } from "@homarr/definitions";
import type { UserPreferenceKey } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { createChildrenOptions } from "../lib/children";
import {
createCheckmarkPreferenceAction,
createLoadingPreferenceAction,
PreferenceDetailHeader,
} from "../modes/command/children/preferences/action-row";
import { createSelectablePreferenceActions } from "../modes/command/children/preferences/selectable-actions";
import { languageChildrenOptions } from "../modes/command/children/language";
import { useUserPreference } from "./use-user-preference";
dayjs.extend(localeData);
type ChildrenFactory = ReturnType<typeof createChildrenOptions<Record<string, unknown>>>;
export const preferenceIcons: Record<UserPreferenceKey, TablerIcon> = {
colorScheme: IconDeviceDesktop,
locale: IconLanguage,
defaultSearchEngineId: IconSearch,
openSearchInNewTab: IconExternalLink,
ddgBangs: IconWorldWww,
firstDayOfWeek: IconCalendarWeek,
homeBoardId: IconHome,
mobileHomeBoardId: IconLayoutBoard,
pingIconsEnabled: IconActivity,
fullPreferencesPage: IconSettings,
};
const createStaticChildren = (
preferenceKey: UserPreferenceKey,
useOptions: () => Array<{ key: string; label: string; value: unknown }>,
): ChildrenFactory =>
createChildrenOptions<Record<string, unknown>>({
useActions: () => {
const { value, setValue, isPending } = useUserPreference(preferenceKey);
return useOptions().map((opt) =>
createCheckmarkPreferenceAction({
key: opt.key,
label: opt.label,
isSelected: value === opt.value,
onSelect: () => setValue(opt.value as never),
isPending,
}),
);
},
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
});
const createAsyncSelectableChildren = <TItem,>(
preferenceKey: UserPreferenceKey,
useQuery: () => { data: TItem[] | undefined; isLoading: boolean },
mapItem: (item: TItem) => { key: string; label: string; value: string },
noneKey: string,
unavailableKey: string,
): ChildrenFactory =>
createChildrenOptions<Record<string, unknown>>({
useActions: (_, query) => {
const { value, setValue, isPending } = useUserPreference(preferenceKey);
const result = useQuery();
const t = useScopedI18n("search.mode.command.group.preferences.option");
if (result.isLoading) return [createLoadingPreferenceAction()];
return createSelectablePreferenceActions({
query,
currentValue: value as string | null,
noneLabel: t(noneKey as never),
unavailableLabel: t(unavailableKey as never),
items: (result.data ?? []).map(mapItem),
onSelect: (v) => setValue(v as never),
isPending,
});
},
DetailComponent: () => <PreferenceDetailHeader titleKey={`${preferenceKey}.children.detail.title`} />,
});
const weekDays = dayjs.weekdays(false);
const firstDayOfWeekOptions: DayOfWeek[] = [1, 6, 0];
export const preferenceChildrenOptionsByKey: Partial<Record<UserPreferenceKey, ChildrenFactory>> = {
colorScheme: createStaticChildren("colorScheme", () => {
const t = useScopedI18n("common.colorScheme.options");
return colorSchemes.map((s) => ({ key: s, label: t(s), value: s }));
}),
locale: languageChildrenOptions,
defaultSearchEngineId: createAsyncSelectableChildren(
"defaultSearchEngineId",
() => clientApi.searchEngine.getSelectable.useQuery(),
(e) => ({ key: e.value, label: e.label, value: e.value }),
"searchEngine.none",
"searchEngine.unavailable",
),
firstDayOfWeek: createStaticChildren("firstDayOfWeek", () =>
firstDayOfWeekOptions.map((day) => ({
key: day.toString(),
label: weekDays[day] ?? day.toString(),
value: day,
})),
),
homeBoardId: createAsyncSelectableChildren(
"homeBoardId",
() => clientApi.board.getAllBoards.useQuery(),
(b) => ({ key: b.id, label: b.name, value: b.id }),
"board.none",
"board.unavailable",
),
mobileHomeBoardId: createAsyncSelectableChildren(
"mobileHomeBoardId",
() => clientApi.board.getAllBoards.useQuery(),
(b) => ({ key: b.id, label: b.name, value: b.id }),
"board.none",
"board.unavailable",
),
};
@@ -10,76 +10,59 @@ 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 {
userPreferenceDefinitions,
userPreferenceDefinitionByKey,
} from "@homarr/settings";
import type { UserPreferenceKey } from "@homarr/settings";
import { useSettings } from "@homarr/settings";
import { useChangeLocale, useCurrentLocale, useI18n } from "@homarr/translation/client";
const optimisticPreferenceKeys = new Set<UserPreferenceKey>([
"defaultSearchEngineId",
"openSearchInNewTab",
"ddgBangs",
"firstDayOfWeek",
"homeBoardId",
"mobileHomeBoardId",
"pingIconsEnabled",
]);
// --- Optimistic store (module-level, shared across all hook instances) ---
const optimisticPreferencesRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { current: {} };
let optimisticPreferencesVersion = 0;
const optimisticPreferenceListeners = new Set<() => void>();
const optimisticRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { current: {} };
let optimisticVersion = 0;
const optimisticListeners = 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 notifyListeners = () => {
optimisticVersion += 1;
optimisticListeners.forEach((fn) => fn());
};
const subscribeToOptimisticPreferenceChanges = (listener: () => void) => {
optimisticPreferenceListeners.add(listener);
return () => optimisticPreferenceListeners.delete(listener);
const subscribeOptimistic = (listener: () => void) => {
optimisticListeners.add(listener);
return () => optimisticListeners.delete(listener);
};
const setOptimisticPreference = (preferenceKey: UserPreferenceKey, value: unknown) => {
optimisticPreferencesRef.current[preferenceKey] = value;
notifyOptimisticPreferenceListeners();
const setOptimistic = (key: UserPreferenceKey, value: unknown) => {
optimisticRef.current[key] = value;
notifyListeners();
};
const clearOptimisticPreferences = (keys: readonly UserPreferenceKey[]) => {
const clearOptimistic = (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 => {
const optimisticValue = optimisticPreferencesRef.current[preferenceKey];
if (optimisticValue !== undefined) {
return optimisticValue;
for (const key of keys) {
changed ||= key in optimisticRef.current;
delete optimisticRef.current[key];
}
const settingsValues: Partial<Record<UserPreferenceKey, unknown>> = {
defaultSearchEngineId: settings.defaultSearchEngineId,
openSearchInNewTab: settings.openSearchInNewTab,
ddgBangs: settings.ddgBangs,
firstDayOfWeek: settings.firstDayOfWeek,
homeBoardId: settings.homeBoardId,
mobileHomeBoardId: settings.mobileHomeBoardId,
pingIconsEnabled: settings.pingIconsEnabled,
};
return settingsValues[preferenceKey];
if (changed) notifyListeners();
};
const refreshAfterMutation = (router: ReturnType<typeof useRouter>) => {
router.refresh();
const CLIENT_ONLY_GROUPS = new Set(["colorScheme", "locale"]);
const isDbBacked = (key: UserPreferenceKey) => {
const group = userPreferenceDefinitionByKey[key].mutationGroup;
return group !== null && !CLIENT_ONLY_GROUPS.has(group);
};
const getGroupKeys = (key: UserPreferenceKey): UserPreferenceKey[] => {
const group = userPreferenceDefinitionByKey[key].mutationGroup;
if (!group) return [key];
return userPreferenceDefinitions.filter((d) => d.mutationGroup === group).map((d) => d.key);
};
// --- Hook ---
export const useUserPreferences = () => {
const router = useRouter();
const settings = useSettings();
@@ -91,257 +74,125 @@ export const useUserPreferences = () => {
const currentLocale = useCurrentLocale();
const { changeLocale } = useChangeLocale();
const t = useI18n();
const pendingGroupsRef = useRef({
search: false,
homeBoards: false,
firstDayOfWeek: false,
pingIcons: false,
});
const userId = session?.user.id;
const pendingRef = useRef<Record<string, boolean>>({});
useSyncExternalStore(
subscribeToOptimisticPreferenceChanges,
() => optimisticPreferencesVersion,
() => optimisticPreferencesVersion,
);
useSyncExternalStore(subscribeOptimistic, () => optimisticVersion, () => optimisticVersion);
const showPreferenceErrorNotification = () => {
showErrorNotification({
message: t("search.mode.command.group.preferences.notification.error.message"),
});
const refresh = () => router.refresh();
const searchMutation = clientApi.user.changeSearchPreferences.useMutation({ onSuccess: refresh });
const homeBoardsMutation = clientApi.user.changeHomeBoards.useMutation({ onSuccess: refresh });
const firstDayMutation = clientApi.user.changeFirstDayOfWeek.useMutation({ onSuccess: refresh });
const pingMutation = clientApi.user.changePingIconsEnabled.useMutation({ onSuccess: refresh });
const getEffective = (key: UserPreferenceKey): unknown =>
key in optimisticRef.current
? optimisticRef.current[key]
: settingsRef.current[key as keyof SettingsContextProps];
const pick = (field: UserPreferenceKey, changedKey: UserPreferenceKey, value: unknown) =>
field === changedKey ? value : getEffective(field);
const groupDispatchers: Record<string, (key: UserPreferenceKey, value: unknown) => Promise<unknown>> = {
searchPreferences: (key, value) =>
searchMutation.mutateAsync({
userId: userId!,
defaultSearchEngineId: pick("defaultSearchEngineId", key, value) as string | null,
openInNewTab: pick("openSearchInNewTab", key, value) as boolean,
ddgBangsEnabled: pick("ddgBangs", key, value) as boolean,
}),
homeBoards: (key, value) =>
homeBoardsMutation.mutateAsync({
userId: userId!,
homeBoardId: pick("homeBoardId", key, value) as string | null,
mobileHomeBoardId: pick("mobileHomeBoardId", key, value) as string | null,
}),
firstDayOfWeek: (_key, value) =>
firstDayMutation.mutateAsync({ id: userId!, firstDayOfWeek: value as DayOfWeek }),
pingIconsEnabled: (_key, value) =>
pingMutation.mutateAsync({ id: userId!, pingIconsEnabled: value as boolean }),
};
const { mutateAsync: mutateSearchPreferencesAsync, isPending: isSearchPreferencesPending } =
clientApi.user.changeSearchPreferences.useMutation({
onSuccess: () => refreshAfterMutation(router),
});
const groupPending: Record<string, boolean> = {
searchPreferences: searchMutation.isPending,
homeBoards: homeBoardsMutation.isPending,
firstDayOfWeek: firstDayMutation.isPending,
pingIconsEnabled: pingMutation.isPending,
};
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),
});
const userId = session?.user.id;
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(
() => ({
const persistedValues = useMemo<Record<UserPreferenceKey, unknown>>(() => {
const values: Record<string, unknown> = {
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 getValue = (preferenceKey: UserPreferenceKey) =>
preferenceKey in optimisticPreferencesRef.current
? optimisticPreferencesRef.current[preferenceKey]
: persistedValues[preferenceKey];
};
for (const def of userPreferenceDefinitions) {
if (def.key in values) continue;
values[def.key] = settings[def.key as keyof SettingsContextProps];
}
return values as Record<UserPreferenceKey, unknown>;
}, [colorScheme, currentLocale, userId, settings]);
useEffect(() => {
clearOptimisticPreferences(
Object.entries(persistedValues)
.filter(
([preferenceKey, value]) => optimisticPreferencesRef.current[preferenceKey as UserPreferenceKey] === value,
)
.map(([preferenceKey]) => preferenceKey as UserPreferenceKey),
);
const matchedKeys = Object.entries(persistedValues)
.filter(([key, value]) => optimisticRef.current[key as UserPreferenceKey] === value)
.map(([key]) => key as UserPreferenceKey);
clearOptimistic(matchedKeys);
}, [persistedValues]);
const setPersistedValueAsync = async (key: UserPreferenceKey, value: never) => {
const currentSettings = settingsRef.current;
const getValue = (key: UserPreferenceKey) =>
key in optimisticRef.current ? optimisticRef.current[key] : persistedValues[key];
const setters: Record<UserPreferenceKey, (nextValue: never) => Promise<void> | void> = {
colorScheme: (nextValue) => setColorScheme(nextValue as ColorScheme),
locale: (nextValue) => changeLocale(nextValue as typeof currentLocale),
defaultSearchEngineId: async (nextValue) => {
if (!userId) return;
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: async (nextValue) => {
if (!userId) return;
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: async (nextValue) => {
if (!userId) return;
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: async (nextValue) => {
if (!userId) return;
if (pendingGroupsRef.current.firstDayOfWeek) return;
pendingGroupsRef.current.firstDayOfWeek = true;
try {
await mutateFirstDayOfWeekAsync({ id: userId, firstDayOfWeek: nextValue as DayOfWeek });
} finally {
pendingGroupsRef.current.firstDayOfWeek = false;
}
},
homeBoardId: async (nextValue) => {
if (!userId) return;
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: async (nextValue) => {
if (!userId) return;
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: async (nextValue) => {
if (!userId) return;
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,
};
await setters[key](value);
const isPreferencePending = (key: UserPreferenceKey): boolean => {
const group = userPreferenceDefinitionByKey[key].mutationGroup;
if (!group || CLIENT_ONLY_GROUPS.has(group)) return false;
return (groupPending[group] ?? false) || (pendingRef.current[group] ?? false);
};
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 clientSetters: Partial<Record<UserPreferenceKey, (value: unknown) => void>> = {
colorScheme: (value) => setColorScheme(value as ColorScheme),
locale: (value) => changeLocale(value as typeof currentLocale),
fullPreferencesPage: () => undefined,
};
const setValueAsync = async (key: UserPreferenceKey, value: never) => {
if (optimisticPreferenceKeys.has(key) && !userId) return;
const setValueAsync = async (key: UserPreferenceKey, value: unknown) => {
if (isDbBacked(key) && !userId) return;
if (isPreferencePending(key)) return;
if (optimisticPreferenceKeys.has(key)) {
setOptimisticPreference(key, value);
const clientSetter = clientSetters[key];
if (clientSetter) {
clientSetter(value);
return;
}
setOptimistic(key, value);
const group = userPreferenceDefinitionByKey[key].mutationGroup!;
const dispatcher = groupDispatchers[group];
if (!dispatcher) return;
pendingRef.current[group] = true;
try {
await setPersistedValueAsync(key, value);
await dispatcher(key, value);
} catch {
if (searchPreferenceKeys.includes(key as never)) {
clearOptimisticPreferences(searchPreferenceKeys);
} else if (homeBoardPreferenceKeys.includes(key as never)) {
clearOptimisticPreferences(homeBoardPreferenceKeys);
} else {
clearOptimisticPreferences([key]);
}
showPreferenceErrorNotification();
clearOptimistic(getGroupKeys(key));
showErrorNotification({
message: t("search.mode.command.group.preferences.notification.error.message"),
});
} finally {
pendingRef.current[group] = false;
}
};
const pendingStates: Record<UserPreferenceKey, boolean> = {
colorScheme: false,
locale: false,
defaultSearchEngineId: isPreferencePending("defaultSearchEngineId"),
openSearchInNewTab: isPreferencePending("openSearchInNewTab"),
ddgBangs: isPreferencePending("ddgBangs"),
firstDayOfWeek: isPreferencePending("firstDayOfWeek"),
homeBoardId: isPreferencePending("homeBoardId"),
mobileHomeBoardId: isPreferencePending("mobileHomeBoardId"),
pingIconsEnabled: isPreferencePending("pingIconsEnabled"),
fullPreferencesPage: false,
};
return {
getPreference(preferenceKey: UserPreferenceKey) {
return {
value: getValue(preferenceKey),
setValue: (value: never) => setValueAsync(preferenceKey, value),
isPending: pendingStates[preferenceKey],
isPending: isPreferencePending(preferenceKey),
};
},
};
};
export const useUserPreference = (key: UserPreferenceKey) => {
const preferences = useUserPreferences();
return preferences.getPreference(key);
};
export const useUserPreference = (key: UserPreferenceKey) => useUserPreferences().getPreference(key);