Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import useSWR from "swr";
import { useLocation } from "wouter";
import { useAutoUpdate } from "../hooks/useAutoUpdate";
import { useWhatsNew } from "../hooks/useWhatsNew";
import { useKeyboardShortcut } from "../hooks/useKeyboard";
import { useMutation } from "../hooks/useMutation";
import { useWorkspaceHierarchy } from "../hooks/useWorkspaceHierarchy";
Expand Down Expand Up @@ -1180,6 +1181,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
autoCheck: import.meta.env.MODE !== "test",
listenMenu: import.meta.env.MODE !== "test",
});
useWhatsNew({ enabled: import.meta.env.MODE !== "test" });
const handleReturnToDashboard = () => {
// Navigate to main repo ShowWorkspace > Code
setSelectedWorkspace(null);
Expand Down
103 changes: 103 additions & 0 deletions src/hooks/useWhatsNew.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { getVersion } from "@tauri-apps/api/app";
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SWRConfig } from "swr";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "../components/ui/toast";
import { resetAllStores } from "../stores/resetStores";
import { useWhatsNew } from "./useWhatsNew";

vi.mock("@tauri-apps/api/app", () => ({ getVersion: vi.fn() }));

/** In-memory stand-in for the app settings table behind get/set_setting. */
let settings: Map<string, string>;

function WhatsNew() {
useWhatsNew();
return null;
}

/** Simulates one app launch on `version`, then lets the hook settle. */
async function launch(version: string) {
vi.mocked(getVersion).mockResolvedValue(version);
// A fresh SWR cache per render, as each app launch starts with one.
const view = render(
<SWRConfig value={{ provider: () => new Map() }}>
<ToastProvider>
<WhatsNew />
</ToastProvider>
</SWRConfig>,
);
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith("get_setting", {
key: "last_seen_app_version",
}),
);
// Flush the rest of the hook's promise chain (write, then toast).
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
return view;
}

describe("useWhatsNew", () => {
beforeEach(() => {
vi.clearAllMocks();
settings = new Map();
vi.mocked(invoke).mockImplementation(async (cmd, args) => {
const { key, value } = (args ?? {}) as { key: string; value?: string };
if (cmd === "get_setting") return settings.get(key) ?? null;
if (cmd === "set_setting") settings.set(key, value ?? "");
return null;
});
});

it("records the version silently on a fresh install", async () => {
await launch("0.3.0");

expect(settings.get("last_seen_app_version")).toBe("0.3.0");
expect(screen.queryByText(/Updated to/)).toBeNull();
});

it("shows nothing when the version has not changed", async () => {
settings.set("last_seen_app_version", "0.3.0");

await launch("0.3.0");

expect(screen.queryByText(/Updated to/)).toBeNull();
expect(settings.get("last_seen_app_version")).toBe("0.3.0");
});

it("shows the toast once after an update", async () => {
settings.set("last_seen_app_version", "0.2.0");

const first = await launch("0.3.0");
expect(await screen.findByText("Updated to v0.3.0")).toBeInTheDocument();
expect(settings.get("last_seen_app_version")).toBe("0.3.0");
first.unmount();
resetAllStores();
vi.mocked(invoke).mockClear();

await launch("0.3.0");
expect(screen.queryByText(/Updated to/)).toBeNull();
});

it("shows nothing after a downgrade and keeps the newer version", async () => {
settings.set("last_seen_app_version", "0.10.0");

await launch("0.9.0");

expect(screen.queryByText(/Updated to/)).toBeNull();
expect(settings.get("last_seen_app_version")).toBe("0.10.0");
});

it("opens the changelog from the What's new action", async () => {
settings.set("last_seen_app_version", "0.2.0");
const user = userEvent.setup();

await launch("0.3.0");
await user.click(await screen.findByRole("button", { name: "What's new" }));

expect(openUrl).toHaveBeenCalledWith("https://treq.dev/changelog");
});
});
66 changes: 66 additions & 0 deletions src/hooks/useWhatsNew.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { getVersion } from "@tauri-apps/api/app";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useEffect } from "react";
import useSWR from "swr";
import { useToast } from "../components/ui/toast";
import { getSetting, setSetting } from "../lib/api";
import { useMutation } from "./useMutation";

// Kept in the app settings database rather than localStorage: it is the store
// the other app-wide settings use, and it outlives a cleared WebView cache.
const LAST_SEEN_VERSION_KEY = "last_seen_app_version";
const CHANGELOG_URL = "https://treq.dev/changelog";

type SeenVersions = { version: string; lastSeen: string | null };

/** True when dotted version `a` is newer than `b` ("0.10.0" > "0.9.0"). */
function isNewerVersion(a: string, b: string): boolean {
return a.localeCompare(b, undefined, { numeric: true }) > 0;
}

/**
* On the first launch after an update, shows "Updated to vX.Y.Z" with a link
* to the changelog. A fresh install records its version and shows nothing.
*/
export function useWhatsNew(options: { enabled?: boolean } = {}) {
const { enabled = true } = options;
const { addToast } = useToast();

const { data, mutate } = useSWR<SeenVersions>(
enabled ? ["whats-new", LAST_SEEN_VERSION_KEY] : null,
async () => {
const [version, lastSeen] = await Promise.all([
getVersion(),
getSetting(LAST_SEEN_VERSION_KEY),
]);
return { version, lastSeen };
},
{ revalidateOnFocus: false, revalidateOnReconnect: false },
);

const recordVersion = useMutation({
mutationFn: (version: string) => setSetting(LAST_SEEN_VERSION_KEY, version),
// Mark the cached read as seen so a remount cannot repeat the toast.
onSuccess: (_, version) => {
void mutate({ version, lastSeen: version }, { revalidate: false });
},
});

useEffect(() => {
if (!data) return;
const { version, lastSeen } = data;
if (lastSeen && !isNewerVersion(version, lastSeen)) return;
recordVersion.mutate(version);
if (!lastSeen) return;
addToast({
type: "info",
title: `Updated to v${version}`,
action: {
label: "What's new",
onClick: () => void openUrl(CHANGELOG_URL),
},
});
// React to each read only: recordVersion is a new object every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
}
Loading