Compare commits

...
Author SHA1 Message Date
Thomas Camlong 0ffeeed91b fix: open user menu instantly 2026-08-13 11:11:19 +02:00
2 changed files with 13 additions and 90 deletions

No files matched your search

@@ -1,38 +1,17 @@
"use client"; "use client";
import type { ComponentProps, ReactNode } from "react"; import type { ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core"; import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update"; import { UpdateIndicator } from "./update";
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
const loadUserAvatarMenu = () => {
if (userAvatarMenuPromise) return userAvatarMenuPromise;
const promise = import("~/components/user-avatar-menu");
userAvatarMenuPromise = promise;
void promise.catch(() => {
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
});
return promise;
};
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
const { UserAvatarMenu } = use(loadUserAvatarMenu());
return <UserAvatarMenu {...props} />;
};
interface UserButtonClientProps { interface UserButtonClientProps {
avatar: ReactNode; avatar: ReactNode;
isAdmin: boolean; isAdmin: boolean;
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => { export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false); const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession(); const session = useSession();
const { toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]); useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
staleTime: 60 * 60 * 1_000, staleTime: 60 * 60 * 1_000,
}); });
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined; const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return ( return (
<ErrorBoundary <UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
fallbackRender={({ resetErrorBoundary }) => <UnstyledButton>
renderButton(() => { <UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
resetErrorBoundary(); {avatar}
openMenu(); </UpdateIndicator>
}) </UnstyledButton>
} </UserAvatarMenu>
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
); );
}; };
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
children: ReactNode; children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"]; availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean; isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
} }
export const UserAvatarMenu = ({ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
children,
availableUpdates,
isDockerEnabled,
opened,
onOpenChange,
}: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu"); const t = useScopedI18n("common.userAvatar.menu");
const session = useSession(); const session = useSession();
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
return ( return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board // We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}> <Menu width={300} withinPortal keepMounted>
<Menu.Dropdown> <Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} /> <AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}> <Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>