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["onOpenChange"]> >[1]; type SidebarUpdatePopoverHandle = ReturnType; 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, ): 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, 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; 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 ? : null; } function SidebarUpdateArchitectureWarningContent() { const state = useDesktopUpdateState(); const visible = shouldShowArm64IntelBuildWarning(state); const description = state && visible ? getArm64IntelBuildWarningDescription(state) : null; if (!visible || !description) return null; return ( Intel build on Apple Silicon {description} ); } export function SidebarUpdatePill() { return isElectron ? : 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(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 = ( ); return ( { if (open && !showReleaseNotesPopover) { details.cancel(); return; } handleSidebarUpdateReleaseNotesPopoverOpenChange(open, details); }} > } /> {!showReleaseNotesPopover ? ( {tooltip} ) : null} {showReleaseNotesPopover && state ? ( { if ( event.key === "Escape" && releaseNotesPopupRef.current?.contains(document.activeElement) ) { suppressReleaseNotesFocusOpen.current = true; } }} ref={releaseNotesPopupRef} side="top" tooltipStyle > ) : null} ); }