Skip to content
Open
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
65 changes: 65 additions & 0 deletions apps/web/src/components/NewVersionToastCoordinator.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof toastManager.add> | 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;
}
79 changes: 78 additions & 1 deletion apps/web/src/components/desktopUpdate.logic.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -290,3 +294,76 @@ describe("getDesktopUpdateButtonTooltip", () => {
);
});
});

describe("new version toast", () => {
beforeEach(() => {
const store: Record<string, string> = {};
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);
});
});
46 changes: 46 additions & 0 deletions apps/web/src/components/desktopUpdate.logic.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
cursor[bot] marked this conversation as resolved.

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(
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -102,6 +103,7 @@ function RootRouteView() {
<EnvironmentConnectionManagerBootstrap />
<EventRouter />
<WebSocketConnectionCoordinator />
<NewVersionToastCoordinator />
<SlowRpcAckToastCoordinator />
<WebSocketConnectionSurface>
<CommandPalette>
Expand Down
Loading