| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { DownloadIcon, RefreshCwIcon, RotateCwIcon, TriangleAlertIcon } from "lucide-react"; | 1 | import { TriangleAlertIcon } from "lucide-react"; |
| 2 | import { useCallback, useState } from "react"; | 2 | import { useCallback, useEffect, useState } from "react"; |
| 3 | import { isElectron } from "../../env"; | 3 | import { isElectron } from "../../env"; |
| | 4 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 4 | import { cn } from "../../lib/utils"; | 5 | import { cn } from "../../lib/utils"; |
| 5 | import { ensureLocalApi } from "../../localApi"; | 6 | import { ensureLocalApi } from "../../localApi"; |
| 6 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; | 7 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| ... | @@ -21,6 +22,38 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | ... | @@ -21,6 +22,38 @@ import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 21 | import { Separator } from "../ui/separator"; | 22 | import { Separator } from "../ui/separator"; |
| 22 | import { SidebarMenuItem } from "../ui/sidebar"; | 23 | import { SidebarMenuItem } from "../ui/sidebar"; |
| 23 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 24 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| | 25 | import { |
| | 26 | DesktopUpdateStatusIcon, |
| | 27 | shouldContinueDesktopUpdateCheckAnimation, |
| | 28 | shouldShowDesktopUpdateCheckIcon, |
| | 29 | } from "./DesktopUpdateStatusIcon"; |
| | 30 | |
| | 31 | function resolveSidebarUpdatePresentation({ |
| | 32 | action, |
| | 33 | isDownloading, |
| | 34 | showCheckIcon, |
| | 35 | }: { |
| | 36 | readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>; |
| | 37 | readonly isDownloading: boolean; |
| | 38 | readonly showCheckIcon: boolean; |
| | 39 | }) { |
| | 40 | const showUpdateDetails = action !== "none" || isDownloading; |
| | 41 | const iconStatus = showCheckIcon |
| | 42 | ? "checking" |
| | 43 | : action === "install" |
| | 44 | ? "downloaded" |
| | 45 | : isDownloading |
| | 46 | ? "downloading" |
| | 47 | : action === "download" |
| | 48 | ? "available" |
| | 49 | : "idle"; |
| | 50 | |
| | 51 | return { |
| | 52 | iconStatus, |
| | 53 | showUpdateDetails, |
| | 54 | showUpdateIconState: showUpdateDetails && !showCheckIcon, |
| | 55 | } as const; |
| | 56 | } |
| 24 | | 57 | |
| 25 | function keyReleaseNoteItems(items: ReadonlyArray<string>) { | 58 | function keyReleaseNoteItems(items: ReadonlyArray<string>) { |
| 26 | const occurrences = new Map<string, number>(); | 59 | const occurrences = new Map<string, number>(); |
| ... | @@ -110,18 +143,42 @@ export function SidebarUpdatePill() { | ... | @@ -110,18 +143,42 @@ export function SidebarUpdatePill() { |
| 110 | function SidebarUpdateControl() { | 143 | function SidebarUpdateControl() { |
| 111 | const state = useDesktopUpdateState(); | 144 | const state = useDesktopUpdateState(); |
| 112 | const [isActionPending, setIsActionPending] = useState(false); | 145 | const [isActionPending, setIsActionPending] = useState(false); |
| | 146 | const [checkAnimationKey, setCheckAnimationKey] = useState(0); |
| | 147 | const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false); |
| | 148 | const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); |
| | 149 | |
| | 150 | useEffect(() => { |
| | 151 | if (prefersReducedMotion) { |
| | 152 | setIsCheckAnimationLatched(false); |
| | 153 | } else if (state?.status === "checking") { |
| | 154 | setIsCheckAnimationLatched(true); |
| | 155 | } |
| | 156 | }, [prefersReducedMotion, state?.status]); |
| 113 | | 157 | |
| 114 | const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; | 158 | const action = state ? resolveDesktopUpdateButtonAction(state) : "none"; |
| 115 | const isDownloading = state?.status === "downloading"; | 159 | const isDownloading = state?.status === "downloading"; |
| 116 | const isUpdateState = action !== "none" || isDownloading; | 160 | const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ |
| 117 | const tooltip = isUpdateState | 161 | isAnimationLatched: isCheckAnimationLatched, |
| | 162 | isChecking: state?.status === "checking", |
| | 163 | prefersReducedMotion, |
| | 164 | }); |
| | 165 | const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ |
| | 166 | action, |
| | 167 | isDownloading, |
| | 168 | showCheckIcon, |
| | 169 | }); |
| | 170 | const tooltip = showUpdateDetails |
| 118 | ? state | 171 | ? state |
| 119 | ? getDesktopUpdateButtonTooltip(state) | 172 | ? getDesktopUpdateButtonTooltip(state) |
| 120 | : "Update available" | 173 | : "Update available" |
| 121 | : state?.status === "checking" | 174 | : showCheckIcon |
| 122 | ? "Checking for updates…" | 175 | ? "Checking for updates…" |
| 123 | : "Check for updates"; | 176 | : "Check for updates"; |
| 124 | const disabled = isUpdateState ? isDesktopUpdateButtonDisabled(state) : !canCheckForUpdate(state); | 177 | const disabled = showCheckIcon |
| | 178 | ? true |
| | 179 | : showUpdateDetails |
| | 180 | ? isDesktopUpdateButtonDisabled(state) |
| | 181 | : !canCheckForUpdate(state); |
| 125 | | 182 | |
| 126 | const handleAction = useCallback(async () => { | 183 | const handleAction = useCallback(async () => { |
| 127 | const bridge = window.desktopBridge; | 184 | const bridge = window.desktopBridge; |
| ... | @@ -209,6 +266,10 @@ function SidebarUpdateControl() { | ... | @@ -209,6 +266,10 @@ function SidebarUpdateControl() { |
| 209 | return; | 266 | return; |
| 210 | } | 267 | } |
| 211 | | 268 | |
| | 269 | if (!prefersReducedMotion) { |
| | 270 | setIsCheckAnimationLatched(true); |
| | 271 | setCheckAnimationKey((key) => key + 1); |
| | 272 | } |
| 212 | void bridge | 273 | void bridge |
| 213 | .checkForUpdate() | 274 | .checkForUpdate() |
| 214 | .then((result) => { | 275 | .then((result) => { |
| ... | @@ -232,7 +293,16 @@ function SidebarUpdateControl() { | ... | @@ -232,7 +293,16 @@ function SidebarUpdateControl() { |
| 232 | ); | 293 | ); |
| 233 | }) | 294 | }) |
| 234 | .finally(() => setIsActionPending(false)); | 295 | .finally(() => setIsActionPending(false)); |
| 235 | }, [action, disabled, isActionPending, state]); | 296 | }, [action, disabled, isActionPending, prefersReducedMotion, state]); |
| | 297 | |
| | 298 | const handleCheckAnimationIteration = useCallback(() => { |
| | 299 | setIsCheckAnimationLatched( |
| | 300 | shouldContinueDesktopUpdateCheckAnimation({ |
| | 301 | isChecking: state?.status === "checking", |
| | 302 | prefersReducedMotion, |
| | 303 | }), |
| | 304 | ); |
| | 305 | }, [prefersReducedMotion, state?.status]); |
| 236 | | 306 | |
| 237 | return ( | 307 | return ( |
| 238 | <SidebarMenuItem className="ml-auto shrink-0"> | 308 | <SidebarMenuItem className="ml-auto shrink-0"> |
| ... | @@ -245,29 +315,28 @@ function SidebarUpdateControl() { | ... | @@ -245,29 +315,28 @@ function SidebarUpdateControl() { |
| 245 | aria-disabled={disabled || isActionPending || undefined} | 315 | aria-disabled={disabled || isActionPending || undefined} |
| 246 | disabled={disabled || isActionPending} | 316 | disabled={disabled || isActionPending} |
| 247 | className={cn( | 317 | className={cn( |
| 248 | "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", | 318 | "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", |
| 249 | isUpdateState | 319 | showUpdateIconState |
| 250 | ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12" | 320 | ? "bg-update-surface text-update-foreground enabled:hover:bg-update/12" |
| 251 | : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground", | 321 | : "text-[var(--sidebar-icon-color)] enabled:hover:bg-sidebar-row-hover enabled:hover:text-sidebar-foreground", |
| | 322 | disabled && !showUpdateIconState && "opacity-60", |
| 252 | )} | 323 | )} |
| 253 | onClick={handleAction} | 324 | onClick={handleAction} |
| 254 | > | 325 | > |
| 255 | {action === "install" ? ( | 326 | <DesktopUpdateStatusIcon |
| 256 | <RotateCwIcon className="size-4" /> | 327 | key={showCheckIcon ? checkAnimationKey : iconStatus} |
| 257 | ) : isUpdateState ? ( | 328 | downloadPercent={state?.downloadPercent ?? null} |
| 258 | <DownloadIcon className="size-4" /> | 329 | isCheckAnimating={showCheckIcon && !prefersReducedMotion} |
| 259 | ) : ( | 330 | onCheckAnimationIteration={handleCheckAnimationIteration} |
| 260 | <RefreshCwIcon | 331 | status={iconStatus} |
| 261 | className={cn("size-4", state?.status === "checking" && "animate-spin")} | 332 | /> |
| 262 | /> | | |
| 263 | )} | | |
| 264 | </button> | 333 | </button> |
| 265 | } | 334 | } |
| 266 | /> | 335 | /> |
| 267 | <TooltipPopup | 336 | <TooltipPopup |
| 268 | align="center" | 337 | align="center" |
| 269 | className={ | 338 | className={ |
| 270 | isUpdateState && state?.channel === "nightly" && state.releaseNotes.length > 0 | 339 | showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0 |
| 271 | ? // pointer-events-auto overrides the positioner's pointer-events-none so the | 340 | ? // pointer-events-auto overrides the positioner's pointer-events-none so the |
| 272 | // release notes stay open (and scrollable) when the cursor moves into them. | 341 | // release notes stay open (and scrollable) when the cursor moves into them. |
| 273 | "pointer-events-auto max-w-none text-balance" | 342 | "pointer-events-auto max-w-none text-balance" |
| ... | @@ -275,7 +344,7 @@ function SidebarUpdateControl() { | ... | @@ -275,7 +344,7 @@ function SidebarUpdateControl() { |
| 275 | } | 344 | } |
| 276 | side="top" | 345 | side="top" |
| 277 | style={ | 346 | style={ |
| 278 | isUpdateState | 347 | showUpdateDetails |
| 279 | ? { | 348 | ? { |
| 280 | background: | 349 | background: |
| 281 | "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", | 350 | "color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))", |
| ... | @@ -283,9 +352,9 @@ function SidebarUpdateControl() { | ... | @@ -283,9 +352,9 @@ function SidebarUpdateControl() { |
| 283 | } | 352 | } |
| 284 | : undefined | 353 | : undefined |
| 285 | } | 354 | } |
| 286 | variant={isUpdateState ? "glass" : "default"} | 355 | variant={showUpdateDetails ? "glass" : "default"} |
| 287 | > | 356 | > |
| 288 | {isUpdateState && state ? ( | 357 | {showUpdateDetails && state ? ( |
| 289 | <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} /> | 358 | <SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} /> |
| 290 | ) : ( | 359 | ) : ( |
| 291 | tooltip | 360 | tooltip |