diff --git a/apps/web/src/components/NewVersionToastCoordinator.tsx b/apps/web/src/components/NewVersionToastCoordinator.tsx new file mode 100644 index 0000000000..dd4800fa0e --- /dev/null +++ b/apps/web/src/components/NewVersionToastCoordinator.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useDesktopUpdateState } from "../lib/desktopUpdateReactQuery"; +import { toastManager } from "./ui/toast"; +import { + acknowledgeCurrentVersion, + getNewVersionReleaseNotesUrl, + readLastAcknowledgedVersion, + shouldShowNewVersionToast, +} from "./desktopUpdate.logic"; + +export function NewVersionToastCoordinator() { + const { data: updateState } = useDesktopUpdateState(); + const toastIdRef = useRef | null>(null); + + useEffect(() => { + if (!updateState?.enabled) return; + const currentVersion = updateState.currentVersion; + if (!currentVersion) return; + + if (readLastAcknowledgedVersion() === null) { + acknowledgeCurrentVersion(updateState); + return; + } + + if (!shouldShowNewVersionToast(updateState)) return; + + acknowledgeCurrentVersion(updateState); + + if (toastIdRef.current) { + toastManager.close(toastIdRef.current); + } + + toastIdRef.current = toastManager.add({ + type: "success", + title: `Updated to v${currentVersion}`, + description: "View what's new in this release", + timeout: 0, + actionProps: { + children: "View release notes", + onClick: async () => { + const bridge = window.desktopBridge; + const url = getNewVersionReleaseNotesUrl(currentVersion); + if (bridge?.openExternal) { + await bridge.openExternal(url); + } else { + window.open(url, "_blank", "noopener,noreferrer"); + } + }, + }, + }); + }, [updateState]); + + useEffect(() => { + return () => { + if (toastIdRef.current) { + toastManager.close(toastIdRef.current); + toastIdRef.current = null; + } + }; + }, []); + + return null; +} diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 454ecdfe0e..91534cd92f 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -1,17 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; import { + acknowledgeCurrentVersion, canCheckForUpdate, getArm64IntelBuildWarningDescription, getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getNewVersionReleaseNotesUrl, isDesktopUpdateButtonDisabled, + readLastAcknowledgedVersion, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, + shouldShowNewVersionToast, } from "./desktopUpdate.logic"; const baseState: DesktopUpdateState = { @@ -290,3 +294,76 @@ describe("getDesktopUpdateButtonTooltip", () => { ); }); }); + +describe("new version toast", () => { + beforeEach(() => { + const store: Record = {}; + vi.stubGlobal("localStorage", { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + for (const k of Object.keys(store)) delete store[k]; + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("constructs the correct release notes URL", () => { + expect(getNewVersionReleaseNotesUrl("1.2.3")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v1.2.3", + ); + }); + + it("does not show toast when update state is null", () => { + expect(shouldShowNewVersionToast(null)).toBe(false); + expect(shouldShowNewVersionToast(undefined)).toBe(false); + }); + + it("does not show toast when updates are disabled", () => { + expect(shouldShowNewVersionToast({ ...baseState, enabled: false })).toBe(false); + }); + + it("does not show toast when no acknowledged version is stored and has no side effects", () => { + expect(readLastAcknowledgedVersion()).toBeNull(); + expect(shouldShowNewVersionToast(baseState)).toBe(false); + expect(readLastAcknowledgedVersion()).toBeNull(); + }); + + it("shows toast when version changes from acknowledged version", () => { + acknowledgeCurrentVersion(baseState); + expect(readLastAcknowledgedVersion()).toBe("1.0.0"); + + const updatedState: DesktopUpdateState = { ...baseState, currentVersion: "1.1.0" }; + expect(shouldShowNewVersionToast(updatedState)).toBe(true); + }); + + it("does not show toast when version matches acknowledged version", () => { + acknowledgeCurrentVersion(baseState); + expect(shouldShowNewVersionToast(baseState)).toBe(false); + }); + + it("acknowledgeCurrentVersion persists the current version", () => { + const state: DesktopUpdateState = { ...baseState, currentVersion: "2.0.0" }; + acknowledgeCurrentVersion(state); + expect(readLastAcknowledgedVersion()).toBe("2.0.0"); + }); + + it("acknowledgeCurrentVersion does nothing for disabled updates", () => { + acknowledgeCurrentVersion({ ...baseState, enabled: false }); + expect(readLastAcknowledgedVersion()).toBeNull(); + }); + + it("does not show toast when acknowledged version matches after first launch", () => { + acknowledgeCurrentVersion(baseState); + expect(shouldShowNewVersionToast(baseState)).toBe(false); + expect(shouldShowNewVersionToast(baseState)).toBe(false); + }); +}); diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 38983c810b..b8d32f294b 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -1,5 +1,51 @@ import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +const LAST_ACKNOWLEDGED_VERSION_KEY = "t3code:lastAcknowledgedDesktopVersion"; + +export const GITHUB_RELEASES_URL = "https://github.com/pingdotgg/t3code/releases/tag"; + +export function getNewVersionReleaseNotesUrl(version: string): string { + return `${GITHUB_RELEASES_URL}/v${version}`; +} + +export function shouldShowNewVersionToast( + updateState: DesktopUpdateState | null | undefined, +): boolean { + if (!updateState?.enabled) return false; + const currentVersion = updateState.currentVersion; + if (!currentVersion) return false; + + const lastAcknowledged = readLastAcknowledgedVersion(); + if (lastAcknowledged === null) return false; + return currentVersion !== lastAcknowledged; +} + +export function acknowledgeCurrentVersion( + updateState: DesktopUpdateState | null | undefined, +): void { + if (!updateState?.enabled) return; + const currentVersion = updateState.currentVersion; + if (currentVersion) { + persistLastAcknowledgedVersion(currentVersion); + } +} + +export function readLastAcknowledgedVersion(): string | null { + try { + return localStorage.getItem(LAST_ACKNOWLEDGED_VERSION_KEY); + } catch { + return null; + } +} + +function persistLastAcknowledgedVersion(version: string): void { + try { + localStorage.setItem(LAST_ACKNOWLEDGED_VERSION_KEY, version); + } catch { + // localStorage may be unavailable (incognito, SSR, etc.) + } +} + export type DesktopUpdateButtonAction = "download" | "install" | "none"; export function resolveDesktopUpdateButtonAction( diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 87e8667901..673defa963 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { NewVersionToastCoordinator } from "../components/NewVersionToastCoordinator"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, @@ -102,6 +103,7 @@ function RootRouteView() { +