continue working on user prefernces in command palette

This commit is contained in:
Thomas Camlong committed 2026-05-29 16:23:29 +02:00
1 parent 19d86daa87
commit e182d28605
11 files changed
+415 -161

No files matched your search

+2 -2
View File
@@ -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));
}),
});
@@ -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];
+1
View File
@@ -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",
@@ -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 : (
<ActionIcon
title={
childrenStack.length > 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")
}
>
<IconX stroke={1.5} />
{childrenStack.length > 0 ? <IconArrowLeft stroke={1.5} /> : <IconX stroke={1.5} />}
</ActionIcon>
)
}
@@ -19,7 +19,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
configuration.name.toLowerCase().includes(normalizedQuery) ||
configuration.translatedName.toLowerCase().includes(normalizedQuery),
)
.sort(
.toSorted(
(languageA, languageB) =>
Math.min(
languageA.configuration.name.toLowerCase().indexOf(normalizedQuery),
@@ -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<typeof createChildrenOptions<Record<string, unknown>>>;
@@ -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<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} />
@@ -41,7 +51,9 @@ 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>
);
@@ -58,24 +70,28 @@ const useSettingsActions = (_: Record<string, unknown>, 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<Exclude<UserPreferenceKey, "fullPreferencesPage">, { value: unknown; setValue: (v: never) => void; isPending: boolean }> = {
const prefState: Record<
Exclude<UserPreferenceKey, "fullPreferencesPage">,
{ value: unknown; setValue: (v: never) => void; isPending: boolean }
> = {
colorScheme,
locale,
defaultSearchEngineId,
@@ -90,7 +106,8 @@ const useSettingsActions = (_: Record<string, unknown>, query: string): Settings
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"),
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<string, unknown>, 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: () => <BooleanRow label={label} Icon={Icon} checked={checked} isPending={state.isPending} />,
useInteraction: interaction.javaScript(() => ({
onSelect: () => state.setValue(!checked as never),
closeSpotlightOnTrigger: false,
})),
}];
return [
{
key: definition.key,
Component: () => <BooleanRow label={label} Icon={Icon} checked={checked} isPending={state.isPending} />,
useInteraction: interaction.javaScript(() => ({
onSelect: state.isPending ? () => undefined : () => state.setValue(!checked as never),
closeSpotlightOnTrigger: false,
})),
},
];
}
if (definition.kind === "link") {
return [{
key: definition.key,
Component: () => <LinkRow label={label} Icon={Icon} />,
useInteraction: interaction.link(() => ({
href: `/manage/users/${session?.user.id ?? ""}/general`,
})),
}];
return [
{
key: definition.key,
Component: () => <LinkRow label={label} Icon={Icon} />,
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]),
}];
return [
{
key: definition.key,
Component: () => (
<SelectRow label={label} Icon={Icon} valueLabel={valueLabels[definition.key as keyof typeof valueLabels]} />
),
useInteraction: interaction.children(preferenceChildrenOptionsByKey[childrenKey]),
},
];
});
};
@@ -146,9 +172,10 @@ 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>
);
},
});
@@ -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<keyof typeof preferenceChildrenOptionsByKey, string>;
type PreferencesGroupOption =
| {
key: "settings";
preferenceKey: null;
}
| {
key: PreferenceChildKey;
preferenceKey: PreferenceChildKey;
};
const settingsOption = { key: "settings", preferenceKey: null } satisfies PreferencesGroupOption;
export const preferencesGroup = createGroup<PreferencesGroupOption>({
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 (
<Group px="md" py="sm" wrap="nowrap" gap="sm">
<IconSettings stroke={1.5} />
<Text>{t("title")}</Text>
<Icon stroke={1.5} />
<Text>{label}</Text>
</Group>
);
},
filter: (_query, _option) => true,
useInteraction: interaction.children(settingsChildrenOptions),
filter: () => true,
useInteraction: (option) => ({
type: "children",
...(option.preferenceKey ? preferenceChildrenOptionsByKey[option.preferenceKey]({}) : settingsChildrenOptions({})),
}),
});
@@ -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<UserPreferenceKey>([
"defaultSearchEngineId",
@@ -24,14 +25,36 @@ const optimisticPreferenceKeys = new Set<UserPreferenceKey>([
]);
const optimisticPreferencesRef: { current: Partial<Record<UserPreferenceKey, unknown>> } = { 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<typeof useRouter>) => {
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<unknown>();
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<UserPreferenceKey, 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,
};
const persistedValues = useMemo<Record<UserPreferenceKey, 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 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<UserPreferenceKey, (nextValue: never) => void> = {
const setters: Record<UserPreferenceKey, (nextValue: never) => Promise<void> | 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<UserPreferenceKey, boolean> = {
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);
};
+7 -1
View File
@@ -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": {
+3
View File
@@ -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