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:
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);
|
||||
Reference in new issue
Block a user