Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="relative grid size-4 place-items-center">
<DownloadIcon className="size-4" />
<span
aria-hidden="true"
className="absolute -top-0.5 -right-0.5 size-1.5 rounded-full bg-update-foreground ring-2 ring-update-surface"
/>
</span>
);
}

function DesktopUpdateDownloadingIcon({ percent }: { readonly percent: number | null }) {
const normalizedPercent = normalizeDesktopUpdateDownloadPercent(percent);
const progressOffset = DOWNLOAD_PROGRESS_CIRCUMFERENCE * (1 - normalizedPercent / 100);

return (
<span className="relative grid size-8 place-items-center">
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 size-full -rotate-90"
viewBox="0 0 32 32"
>
<circle
cx="16"
cy="16"
r={DOWNLOAD_PROGRESS_RADIUS}
fill="none"
stroke="color-mix(in srgb, currentColor 22%, transparent)"
strokeWidth="1.5"
/>
<circle
cx="16"
cy="16"
r={DOWNLOAD_PROGRESS_RADIUS}
fill="none"
stroke="currentColor"
strokeDasharray={DOWNLOAD_PROGRESS_CIRCUMFERENCE}
strokeDashoffset={progressOffset}
strokeLinecap="round"
strokeWidth="1.5"
className="transition-[stroke-dashoffset] duration-300 ease-out motion-reduce:transition-none"
/>
</svg>
<DownloadIcon className="size-4" />
</span>
);
}

function DesktopUpdateDownloadedIcon() {
return (
<span className="relative grid size-4 place-items-center">
<RotateCwIcon className="size-4" />
<span className="absolute -right-1 -bottom-1 grid size-2.5 place-items-center rounded-full bg-update-foreground text-background ring-2 ring-background">
<CheckIcon className="size-2" strokeWidth={3} />
</span>
</span>
);
}

export function DesktopUpdateStatusIcon({
downloadPercent,
isCheckAnimating,
onCheckAnimationIteration,
status,
}: {
readonly downloadPercent?: number | null;
readonly isCheckAnimating?: boolean;
readonly onCheckAnimationIteration?: AnimationEventHandler<SVGSVGElement>;
readonly status: DesktopUpdateStatusIconState;
}) {
if (status === "available") return <DesktopUpdateAvailableIcon />;
if (status === "downloading") {
return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />;
}
if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;

return (
<RefreshCwIcon
className={cn("size-4", status === "checking" && isCheckAnimating && "animate-spin")}
onAnimationIteration={onCheckAnimationIteration}
/>
);
}
113 changes: 91 additions & 22 deletions apps/web/src/components/sidebar/SidebarUpdatePill.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<typeof resolveDesktopUpdateButtonAction>;
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<string>) {
const occurrences = new Map<string, number>();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -209,6 +266,10 @@ function SidebarUpdateControl() {
return;
}

if (!prefersReducedMotion) {
setIsCheckAnimationLatched(true);
setCheckAnimationKey((key) => key + 1);
}
void bridge
.checkForUpdate()
.then((result) => {
Expand All @@ -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 (
<SidebarMenuItem className="ml-auto shrink-0">
Expand All @@ -245,47 +315,46 @@ 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" ? (
<RotateCwIcon className="size-4" />
) : isUpdateState ? (
<DownloadIcon className="size-4" />
) : (
<RefreshCwIcon
className={cn("size-4", state?.status === "checking" && "animate-spin")}
/>
)}
<DesktopUpdateStatusIcon
key={showCheckIcon ? checkAnimationKey : iconStatus}
downloadPercent={state?.downloadPercent ?? null}
isCheckAnimating={showCheckIcon && !prefersReducedMotion}
onCheckAnimationIteration={handleCheckAnimationIteration}
status={iconStatus}
/>
</button>
}
/>
<TooltipPopup
align="center"
className={
isUpdateState && state?.channel === "nightly" && state.releaseNotes.length > 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"
: undefined
}
side="top"
style={
isUpdateState
showUpdateDetails
? {
background:
"color-mix(in srgb, var(--update) 18%, color-mix(in srgb, var(--popover) var(--glass-opacity), transparent))",
borderColor: "var(--update-foreground)",
}
: undefined
}
variant={isUpdateState ? "glass" : "default"}
variant={showUpdateDetails ? "glass" : "default"}
>
{isUpdateState && state ? (
{showUpdateDetails && state ? (
<SidebarUpdateReleaseNotesTooltip state={state} tooltip={tooltip} />
) : (
tooltip
Expand Down
Loading