420 lines · 14.0 KB · TypeScript
RawDownload
import type { DesktopUpdateState } from "@t3tools/contracts";import { TriangleAlertIcon } from "lucide-react";import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react";import { flushSync } from "react-dom";import { isElectron } from "../../env";import { useMediaQuery } from "../../hooks/useMediaQuery";import { cn } from "../../lib/utils";import { ensureLocalApi } from "../../localApi";import { useDesktopUpdateState } from "../../state/desktopUpdate";import { stackedThreadToast, toastManager } from "../ui/toast";import {  canCheckForUpdate,  getArm64IntelBuildWarningDescription,  getDesktopUpdateActionError,  getDesktopUpdateButtonTooltip,  getDesktopUpdateInstallConfirmationMessage,  isDesktopUpdateButtonDisabled,  resolveDesktopUpdateButtonAction,  shouldShowArm64IntelBuildWarning,  shouldToastDesktopUpdateActionResult,} from "../desktopUpdate.logic";import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast";import { Alert, AlertDescription, AlertTitle } from "../ui/alert";import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover";import { SidebarMenuItem } from "../ui/sidebar";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import {  DesktopUpdateStatusIcon,  shouldContinueDesktopUpdateCheckAnimation,  shouldShowDesktopUpdateCheckIcon,} from "./DesktopUpdateStatusIcon";import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes"; type SidebarUpdatePopoverChangeDetails = Parameters<  NonNullable<ComponentProps<typeof Popover>["onOpenChange"]>>[1];type SidebarUpdatePopoverHandle = ReturnType<typeof PopoverCreateHandle>; export function shouldUseSidebarUpdateReleaseNotesPopover(  showUpdateDetails: boolean,  state: DesktopUpdateState | null,): boolean {  return showUpdateDetails && state?.channel === "nightly" && state.releaseNotes.length > 0;} export function handleSidebarUpdateReleaseNotesPopoverOpenChange(  _open: boolean,  details: Pick<SidebarUpdatePopoverChangeDetails, "reason" | "cancel">,): void {  // The trigger is the update action, so its presses must not also toggle the Popover.  if (details.reason === "trigger-press") details.cancel();} export function openSidebarUpdateReleaseNotesPopoverOnForwardTab(  event: { readonly key: string; readonly shiftKey: boolean },  handle: Pick<SidebarUpdatePopoverHandle, "open">,  triggerId: string,): void {  if (event.key !== "Tab" || event.shiftKey) return;  // Hover-open popovers do not manage focus. Promote this one before native Tab runs.  flushSync(() => handle.open(triggerId));} 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;} export function SidebarUpdateArchitectureWarning() {  return isElectron ? <SidebarUpdateArchitectureWarningContent /> : null;} function SidebarUpdateArchitectureWarningContent() {  const state = useDesktopUpdateState();  const visible = shouldShowArm64IntelBuildWarning(state);  const description = state && visible ? getArm64IntelBuildWarningDescription(state) : null;   if (!visible || !description) return null;   return (    <Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8 text-xs">      <TriangleAlertIcon />      <AlertTitle>Intel build on Apple Silicon</AlertTitle>      <AlertDescription>{description}</AlertDescription>    </Alert>  );} export function SidebarUpdatePill() {  return isElectron ? <SidebarUpdateControl /> : null;} function SidebarUpdateControl() {  const state = useDesktopUpdateState();  const [isActionPending, setIsActionPending] = useState(false);  const [checkAnimationKey, setCheckAnimationKey] = useState(0);  const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);  const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());  const suppressReleaseNotesFocusOpen = useRef(false);  const releaseNotesPopupRef = useRef<HTMLDivElement>(null);  const releaseNotesTriggerId = useId();  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 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"    : showCheckIcon      ? "Checking for updates…"      : "Check for updates";  const disabled = showCheckIcon    ? true    : showUpdateDetails      ? isDesktopUpdateButtonDisabled(state)      : !canCheckForUpdate(state);  const isInteractionDisabled = disabled || isActionPending;  const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover(    showUpdateDetails,    state,  );   useEffect(() => {    if (!showReleaseNotesPopover) {      releaseNotesPopoverHandle.close();      return;    }     const trigger = document.getElementById(releaseNotesTriggerId);    if (trigger?.matches(":focus-visible")) {      releaseNotesPopoverHandle.open(releaseNotesTriggerId);    }  }, [releaseNotesPopoverHandle, releaseNotesTriggerId, showReleaseNotesPopover]);   const handleAction = useCallback(async () => {    const bridge = window.desktopBridge;    if (!bridge || !state) return;    if (isInteractionDisabled) return;     setIsActionPending(true);     if (action === "download") {      void bridge        .downloadUpdate()        .then((result) => {          if (result.completed) {            showDesktopUpdateDownloadedToast(bridge, result.state);          }          if (!shouldToastDesktopUpdateActionResult(result)) return;          const actionError = getDesktopUpdateActionError(result);          if (!actionError) return;          toastManager.add(            stackedThreadToast({              type: "error",              title: "Could not download update",              description: actionError,            }),          );        })        .catch((error) => {          toastManager.add(            stackedThreadToast({              type: "error",              title: "Could not start update download",              description: error instanceof Error ? error.message : "An unexpected error occurred.",            }),          );        })        .finally(() => setIsActionPending(false));      return;    }     if (action === "install") {      let confirmed = false;      try {        confirmed = await ensureLocalApi().dialogs.confirm(          getDesktopUpdateInstallConfirmationMessage(state),        );      } catch (error) {        setIsActionPending(false);        toastManager.add(          stackedThreadToast({            type: "error",            title: "Could not confirm update",            description: error instanceof Error ? error.message : "Update confirmation failed.",          }),        );        return;      }      if (!confirmed) {        setIsActionPending(false);        return;      }      void bridge        .installUpdate()        .then((result) => {          if (!shouldToastDesktopUpdateActionResult(result)) return;          const actionError = getDesktopUpdateActionError(result);          if (!actionError) return;          toastManager.add(            stackedThreadToast({              type: "error",              title: "Could not install update",              description: actionError,            }),          );        })        .catch((error) => {          toastManager.add(            stackedThreadToast({              type: "error",              title: "Could not install update",              description: error instanceof Error ? error.message : "An unexpected error occurred.",            }),          );        })        .finally(() => setIsActionPending(false));      return;    }     if (!prefersReducedMotion) {      setIsCheckAnimationLatched(true);      setCheckAnimationKey((key) => key + 1);    }    void bridge      .checkForUpdate()      .then((result) => {        if (result.checked) return;        toastManager.add(          stackedThreadToast({            type: "error",            title: "Could not check for updates",            description:              result.state.message ?? "Automatic updates are not available in this build.",          }),        );      })      .catch((error) => {        toastManager.add(          stackedThreadToast({            type: "error",            title: "Could not check for updates",            description: error instanceof Error ? error.message : "Update check failed.",          }),        );      })      .finally(() => setIsActionPending(false));  }, [action, isInteractionDisabled, prefersReducedMotion, state]);   const handleCheckAnimationIteration = useCallback(() => {    setIsCheckAnimationLatched(      shouldContinueDesktopUpdateCheckAnimation({        isChecking: state?.status === "checking",        prefersReducedMotion,      }),    );  }, [prefersReducedMotion, state?.status]);   const updateButton = (    <button      type="button"      aria-label={tooltip}      aria-disabled={isInteractionDisabled || undefined}      className={cn(        "inline-flex size-8 items-center justify-center rounded-full outline-hidden ring-ring transition-colors focus-visible:ring-2",        isInteractionDisabled ? "cursor-not-allowed" : "cursor-pointer",        showUpdateIconState          ? cn(              "bg-sidebar-control-surface text-sidebar-foreground",              !isInteractionDisabled && "hover:bg-sidebar-row-hover",            )          : cn(              "text-[var(--sidebar-icon-color)]",              !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",            ),        disabled && !showUpdateIconState && "opacity-60",      )}      onClick={handleAction}      onBlur={() => {        suppressReleaseNotesFocusOpen.current = false;      }}      onFocus={(event) => {        if (!showReleaseNotesPopover || !event.currentTarget.matches(":focus-visible")) return;        if (suppressReleaseNotesFocusOpen.current) {          suppressReleaseNotesFocusOpen.current = false;          return;        }        flushSync(() => releaseNotesPopoverHandle.open(releaseNotesTriggerId));      }}      onKeyDown={(event) => {        if (!showReleaseNotesPopover) return;        openSidebarUpdateReleaseNotesPopoverOnForwardTab(          event,          releaseNotesPopoverHandle,          releaseNotesTriggerId,        );      }}    >      <DesktopUpdateStatusIcon        key={showCheckIcon ? checkAnimationKey : iconStatus}        downloadPercent={state?.downloadPercent ?? null}        isCheckAnimating={showCheckIcon && !prefersReducedMotion}        onCheckAnimationIteration={handleCheckAnimationIteration}        status={iconStatus}      />    </button>  );   return (    <SidebarMenuItem className="ml-auto shrink-0">      <Popover        handle={releaseNotesPopoverHandle}        onOpenChange={(open, details) => {          if (open && !showReleaseNotesPopover) {            details.cancel();            return;          }          handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details);        }}      >        <Tooltip disabled={showReleaseNotesPopover}>          <TooltipTrigger            id={releaseNotesTriggerId}            render={              <PopoverTrigger                {...(!showReleaseNotesPopover                  ? {                      "aria-controls": undefined,                      "aria-expanded": undefined,                      "aria-haspopup": undefined,                    }                  : {})}                closeDelay={150}                handle={releaseNotesPopoverHandle}                id={releaseNotesTriggerId}                openOnHover={showReleaseNotesPopover}                render={updateButton}              />            }          />          {!showReleaseNotesPopover ? (            <TooltipPopup              align="center"              side="top"              variant={showUpdateDetails ? "glass" : "default"}            >              {tooltip}            </TooltipPopup>          ) : null}        </Tooltip>        {showReleaseNotesPopover && state ? (          <PopoverPopup            align="center"            aria-label="Nightly update release notes"            className="max-w-none text-balance shadow-xl shadow-black/25"            initialFocus={false}            onKeyDownCapture={(event) => {              if (                event.key === "Escape" &&                releaseNotesPopupRef.current?.contains(document.activeElement)              ) {                suppressReleaseNotesFocusOpen.current = true;              }            }}            ref={releaseNotesPopupRef}            side="top"            tooltipStyle          >            <SidebarUpdateReleaseNotes              shell={window.desktopBridge}              state={state}              tooltip={tooltip}            />          </PopoverPopup>        ) : null}      </Popover>    </SidebarMenuItem>  );}