diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx new file mode 100644 index 00000000000..9346a742a37 --- /dev/null +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -0,0 +1,126 @@ +import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react"; +import type { AnimationEventHandler } from "react"; + +import { cn } from "../../lib/utils"; + +const DOWNLOAD_PROGRESS_RADIUS = 14; +const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; + +export type DesktopUpdateStatusIconState = + | "idle" + | "checking" + | "available" + | "downloading" + | "downloaded"; + +function normalizeDesktopUpdateDownloadPercent(percent: number | null): number { + if (percent === null || !Number.isFinite(percent)) return 0; + return Math.min(100, Math.max(0, percent)); +} + +export function shouldShowDesktopUpdateCheckIcon({ + isAnimationLatched, + isChecking, + prefersReducedMotion, +}: { + readonly isAnimationLatched: boolean; + readonly isChecking: boolean; + readonly prefersReducedMotion: boolean; +}): boolean { + return isChecking || (isAnimationLatched && !prefersReducedMotion); +} + +export function shouldContinueDesktopUpdateCheckAnimation({ + isChecking, + prefersReducedMotion, +}: { + readonly isChecking: boolean; + readonly prefersReducedMotion: boolean; +}): boolean { + return isChecking && !prefersReducedMotion; +} + +function DesktopUpdateAvailableIcon() { + return ( + + + + ); +} + +function DesktopUpdateDownloadingIcon({ percent }: { readonly percent: number | null }) { + const normalizedPercent = normalizeDesktopUpdateDownloadPercent(percent); + const progressOffset = DOWNLOAD_PROGRESS_CIRCUMFERENCE * (1 - normalizedPercent / 100); + + return ( + + + + + ); +} + +function DesktopUpdateDownloadedIcon() { + return ( + + + + + + + ); +} + +export function DesktopUpdateStatusIcon({ + downloadPercent, + isCheckAnimating, + onCheckAnimationIteration, + status, +}: { + readonly downloadPercent?: number | null; + readonly isCheckAnimating?: boolean; + readonly onCheckAnimationIteration?: AnimationEventHandler; + readonly status: DesktopUpdateStatusIconState; +}) { + if (status === "available") return ; + if (status === "downloading") { + return ; + } + if (status === "downloaded") return ; + + return ( + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 191f30438dd..c5cffd8110b 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -1,6 +1,7 @@ -import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; -import { useCallback, useState } from "react"; +import { TriangleAlertIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; import { isElectron } from "../../env"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; import { cn } from "../../lib/utils"; import { ensureLocalApi } from "../../localApi"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; @@ -21,6 +22,38 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Separator } from "../ui/separator"; import { SidebarMenuItem } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + DesktopUpdateStatusIcon, + shouldContinueDesktopUpdateCheckAnimation, + shouldShowDesktopUpdateCheckIcon, +} from "./DesktopUpdateStatusIcon"; + +function resolveSidebarUpdatePresentation({ + action, + isDownloading, + showCheckIcon, +}: { + readonly action: ReturnType; + readonly isDownloading: boolean; + readonly showCheckIcon: boolean; +}) { + const showUpdateDetails = action !== "none" || isDownloading; + const iconStatus = showCheckIcon + ? "checking" + : action === "install" + ? "downloaded" + : isDownloading + ? "downloading" + : action === "download" + ? "available" + : "idle"; + + return { + iconStatus, + showUpdateDetails, + showUpdateIconState: showUpdateDetails && !showCheckIcon, + } as const; +} function keyReleaseNoteItems(items: ReadonlyArray) { const occurrences = new Map(); @@ -110,18 +143,42 @@ export function SidebarUpdatePill() { function SidebarUpdateControl() { const state = useDesktopUpdateState(); const [isActionPending, setIsActionPending] = useState(false); + const [checkAnimationKey, setCheckAnimationKey] = useState(0); + const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); + const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + + useEffect(() => { + if (prefersReducedMotion) { + setIsCheckAnimationLatched(false); + } else if (state?.status === "checking") { + setIsCheckAnimationLatched(true); + } + }, [prefersReducedMotion, state?.status]); const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; const isDownloading = state?.status === "downloading"; - const isUpdateState = action !== "none" || isDownloading; - const tooltip = isUpdateState + const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ + isAnimationLatched: isCheckAnimationLatched, + isChecking: state?.status === "checking", + prefersReducedMotion, + }); + const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ + action, + isDownloading, + showCheckIcon, + }); + const tooltip = showUpdateDetails ? state ? getDesktopUpdateButtonTooltip(state) : "Update available" - : state?.status === "checking" + : showCheckIcon ? "Checking for updates…" : "Check for updates"; - const disabled = isUpdateState ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state); + const disabled = showCheckIcon + ? true + : showUpdateDetails + ? isDesktopUpdateButtonDisabled(state) + : !canCheckForUpdate(state); const handleAction = useCallback(async () => { const bridge = window.desktopBridge; @@ -209,6 +266,10 @@ function SidebarUpdateControl() { return; } + if (!prefersReducedMotion) { + setIsCheckAnimationLatched(true); + setCheckAnimationKey((key) => key + 1); + } void bridge .checkForUpdate() .then((result) => { @@ -232,7 +293,16 @@ function SidebarUpdateControl() { ); }) .finally(() => setIsActionPending(false)); - }, [action, disabled, isActionPending, state]); + }, [action, disabled, isActionPending, prefersReducedMotion, state]); + + const handleCheckAnimationIteration = useCallback(() => { + setIsCheckAnimationLatched( + shouldContinueDesktopUpdateCheckAnimation({ + isChecking: state?.status === "checking", + prefersReducedMotion, + }), + ); + }, [prefersReducedMotion, state?.status]); return ( @@ -245,29 +315,28 @@ function SidebarUpdateControl() { aria-disabled={disabled || isActionPending || undefined} disabled={disabled || isActionPending} className={cn( - "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60", - isUpdateState + "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors enabled:cursor-pointer focus-visible:ring-2 disabled:cursor-not-allowed", + showUpdateIconState ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12" : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground", + disabled && !showUpdateIconState && "opacity-60", )} onClick={handleAction} > - {action === "install" ? ( - - ) : isUpdateState ? ( - - ) : ( - - )} + } /> 0 + showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0 ? // pointer-events-auto overrides the positioner's pointer-events-none so the // release notes stay open (and scrollable) when the cursor moves into them. "pointer-events-auto max-w-none text-balance" @@ -275,7 +344,7 @@ function SidebarUpdateControl() { } side="top" style={ - isUpdateState + showUpdateDetails ? { background: "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", @@ -283,9 +352,9 @@ function SidebarUpdateControl() { } : undefined } - variant={isUpdateState ? "glass" : "default"} + variant={showUpdateDetails ? "glass" : "default"} > - {isUpdateState && state ? ( + {showUpdateDetails && state ? ( ) : ( tooltip