From 761580699cb148f887b1087a47908075b232b39c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 12 Jun 2026 19:00:22 +0100 Subject: [PATCH] Add sidebar update card --- .../features/settings/SidebarUpdateCard.tsx | 57 +++++++++++++++++++ .../features/settings/hooks/use-updater.ts | 5 -- .../settings/sidebarUpdateCardVisibility.ts | 7 +++ .../src/features/sidebar/ui/AppSidebar.tsx | 29 +++++++++- desktop/src/testing/e2eBridge.ts | 50 ++++++++++++++++ desktop/src/vite-env.d.ts | 4 ++ desktop/tests/e2e/sidebar.spec.ts | 56 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 3 + desktop/tests/helpers/screenshot.mjs | 56 ++++++++++-------- 9 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 desktop/src/features/settings/SidebarUpdateCard.tsx create mode 100644 desktop/src/features/settings/sidebarUpdateCardVisibility.ts diff --git a/desktop/src/features/settings/SidebarUpdateCard.tsx b/desktop/src/features/settings/SidebarUpdateCard.tsx new file mode 100644 index 000000000..158636057 --- /dev/null +++ b/desktop/src/features/settings/SidebarUpdateCard.tsx @@ -0,0 +1,57 @@ +import { CircleArrowUp, X } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; + +import { useUpdaterContext } from "./hooks/UpdaterProvider"; +import { shouldShowSidebarUpdateCard } from "./sidebarUpdateCardVisibility"; + +type SidebarUpdateCardProps = { + onDismiss: () => void; +}; + +export function SidebarUpdateCard({ onDismiss }: SidebarUpdateCardProps) { + const { status, relaunch } = useUpdaterContext(); + + if (!shouldShowSidebarUpdateCard(status)) { + return null; + } + + return ( +
+
+
+ + +
+

Update ready

+

+ Restart to apply the update. +

+ +
+
+ ); +} diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts index 21c7bd46f..3b9c5e632 100644 --- a/desktop/src/features/settings/hooks/use-updater.ts +++ b/desktop/src/features/settings/hooks/use-updater.ts @@ -1,7 +1,6 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { check, type Update } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; -import { toast } from "sonner"; export type UpdateStatus = | { state: "idle" } @@ -87,10 +86,6 @@ export function useUpdater() { updateRef.current = null; setStatus({ state: "ready" }); - toast("Update ready", { - description: "Restart when you're ready to apply the update.", - duration: 8000, - }); } catch (err) { setStatus({ state: "error", message: toErrorMessage(err) }); } finally { diff --git a/desktop/src/features/settings/sidebarUpdateCardVisibility.ts b/desktop/src/features/settings/sidebarUpdateCardVisibility.ts new file mode 100644 index 000000000..72b6d991f --- /dev/null +++ b/desktop/src/features/settings/sidebarUpdateCardVisibility.ts @@ -0,0 +1,7 @@ +const SHOW_UPDATE_CARD_PREVIEW = + import.meta.env.DEV && + import.meta.env.VITE_SIDEBAR_UPDATE_CARD_PREVIEW === "1"; + +export function shouldShowSidebarUpdateCard(status: { state: string }) { + return status.state === "ready" || SHOW_UPDATE_CARD_PREVIEW; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 00e8af524..829892e4d 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -44,6 +44,9 @@ import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog"; import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard"; import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles"; +import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard"; +import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider"; +import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility"; import type { Channel, ChannelVisibility, @@ -222,6 +225,12 @@ export function AppSidebar({ onStarChannel, onUnstarChannel, }: AppSidebarProps) { + const { status: updateStatus } = useUpdaterContext(); + const canShowSidebarUpdateCard = shouldShowSidebarUpdateCard(updateStatus); + const [isSidebarUpdateCardDismissed, setIsSidebarUpdateCardDismissed] = + React.useState(false); + const showSidebarUpdateCard = + canShowSidebarUpdateCard && !isSidebarUpdateCardDismissed; const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"]; const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false); const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal; @@ -231,6 +240,12 @@ export function AppSidebar({ const [createDialogKind, setCreateDialogKind] = React.useState(null); + React.useEffect(() => { + if (!canShowSidebarUpdateCard) { + setIsSidebarUpdateCardDismissed(false); + } + }, [canShowSidebarUpdateCard]); + // Allow the create-channel dialog to be opened from outside (e.g. the // ⌘⇧N global shortcut in AppShell), mirroring the controlled new-DM lift. // When the external flag flips on, open the "stream" create dialog; the @@ -526,7 +541,10 @@ export function AppSidebar({ testId="sidebar-more-unread-above" /> ) : null} - + {isLoading ? ( Channels @@ -755,7 +773,7 @@ export function AppSidebar({ {unreadBelowCount > 0 ? ( } onClick={scrollToNextBelow} @@ -765,6 +783,13 @@ export function AppSidebar({ ) : null} + {showSidebarUpdateCard ? ( +
+ setIsSidebarUpdateCardDismissed(true)} + /> +
+ ) : null} void; +}; + +function notifyUpdaterFinished(payload: unknown) { + const channel = (payload as { onEvent?: MockUpdaterChannel } | null)?.onEvent; + channel?.onmessage?.({ event: "Finished" }); +} + +function handleUpdaterCheck(config: E2eConfig | undefined) { + if (!config?.mock?.updateAvailable) { + return null; + } + + const version = config.mock.updateVersion ?? "0.3.18"; + + return { + rid: 42, + currentVersion: "0.3.17", + version, + date: "2026-06-12T00:00:00Z", + body: `Mock update ${version}`, + rawJson: null, + }; +} + +async function handleUpdaterDownloadAndInstall( + payload: unknown, + config: E2eConfig | undefined, +) { + const delayMs = config?.mock?.updateDownloadDelayMs ?? 0; + + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } + + notifyUpdaterFinished(payload); + return null; +} + async function handleArchiveChannel( args: { channelId: string }, config: E2eConfig | undefined, @@ -6490,6 +6533,13 @@ export function maybeInstallE2eTauriMocks() { case "plugin:window|set_badge_count": case "plugin:window|set_badge_label": return null; + case "plugin:updater|check": + return handleUpdaterCheck(activeConfig); + case "plugin:updater|download_and_install": + return handleUpdaterDownloadAndInstall(payload, activeConfig); + case "plugin:resources|close": + case "plugin:process|restart": + return null; case "get_channel_workflows": return handleGetChannelWorkflows( payload as Parameters[0], diff --git a/desktop/src/vite-env.d.ts b/desktop/src/vite-env.d.ts index 11f02fe2a..98b739534 100644 --- a/desktop/src/vite-env.d.ts +++ b/desktop/src/vite-env.d.ts @@ -1 +1,5 @@ /// + +interface ImportMetaEnv { + readonly VITE_SIDEBAR_UPDATE_CARD_PREVIEW?: string; +} diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 18c2b1882..157f46bd9 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -66,3 +66,59 @@ test("resizes, persists, and snaps to the default sidebar width", async ({ .poll(() => storedSidebarWidth(page)) .toBe(String(DEFAULT_SIDEBAR_WIDTH)); }); + +test("shows a sidebar update card when an update is ready", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible(); + + await page.evaluate(() => { + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: { updateAvailable?: boolean } }; + }; + + testWindow.__BUZZ_E2E__ = { + ...(testWindow.__BUZZ_E2E__ ?? {}), + mock: { + ...(testWindow.__BUZZ_E2E__?.mock ?? {}), + updateAvailable: true, + }, + }; + }); + + await page.getByTestId("sidebar-profile-card").click(); + await page.getByTestId("profile-popover-settings").click(); + await page.getByTestId("settings-nav-updates").click(); + await page.getByRole("button", { name: "Check for Updates" }).click(); + await expect(page.getByTestId("settings-panel-updates")).toContainText( + "Update installed. Restart to apply.", + ); + + await page.getByTestId("settings-back-to-app").click(); + + const updateCard = page.getByTestId("sidebar-update-card"); + await expect(updateCard).toBeVisible(); + await expect(updateCard).toContainText("Update ready"); + await expect(updateCard).toContainText("Restart to apply the update."); + await expect(page.getByTestId("sidebar-update-restart")).toBeVisible(); + + await page.getByTestId("sidebar-update-restart").click(); + + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMANDS__?: string[]; + } + ).__BUZZ_E2E_COMMANDS__ ?? [], + ), + ) + .toContain("plugin:process|restart"); + + await updateCard.hover(); + await page.getByTestId("sidebar-update-dismiss").click(); + await expect(updateCard).toBeHidden(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 3ef71af37..99ac95aa0 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -99,7 +99,10 @@ type MockBridgeOptions = { profileReadError?: string; profileUpdateError?: string; searchProfiles?: MockSearchProfileSeed[]; + updateAvailable?: boolean; updateChannelDelayMs?: number; + updateDownloadDelayMs?: number; + updateVersion?: string; stallWebsocketSends?: boolean; userSearchDelayMs?: number; // NIP-IA gate inputs — drive the archive-button gate matrix in diff --git a/desktop/tests/helpers/screenshot.mjs b/desktop/tests/helpers/screenshot.mjs index 096a6d182..552247d81 100644 --- a/desktop/tests/helpers/screenshot.mjs +++ b/desktop/tests/helpers/screenshot.mjs @@ -21,6 +21,7 @@ // --viewport Viewport dimensions (default: 1280x720) // --outdir Output directory (default: test-results/screenshots) // --messages JSON file with messages to inject before capture +// --update-ready Mock an available update so the sidebar update card renders import { parseArgs } from "node:util"; import { existsSync, mkdirSync, readFileSync } from "node:fs"; @@ -40,6 +41,7 @@ const { values: args } = parseArgs({ viewport: { type: "string", default: "1280x720" }, outdir: { type: "string", default: "test-results/screenshots" }, messages: { type: "string" }, + "update-ready": { type: "boolean", default: false }, }, strict: true, }); @@ -107,31 +109,37 @@ await page.addInitScript( ); // Install E2E mock bridge config + MockNotification (mirrors installBridge in bridge.ts) -await page.addInitScript(() => { - class MockNotification extends EventTarget { - static permission = "granted"; - static async requestPermission() { - return "granted"; - } - body; - onclick = null; - title; - constructor(title, options) { - super(); - this.title = title; - this.body = options?.body ?? null; +await page.addInitScript( + ({ updateReady }) => { + class MockNotification extends EventTarget { + static permission = "granted"; + static async requestPermission() { + return "granted"; + } + body; + onclick = null; + title; + constructor(title, options) { + super(); + this.title = title; + this.body = options?.body ?? null; + } + close() {} } - close() {} - } - Object.defineProperty(window, "Notification", { - configurable: true, - value: MockNotification, - writable: true, - }); - - window.__BUZZ_E2E__ = { mode: "mock" }; - window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0; -}); + Object.defineProperty(window, "Notification", { + configurable: true, + value: MockNotification, + writable: true, + }); + + window.__BUZZ_E2E__ = { + mode: "mock", + ...(updateReady ? { mock: { updateAvailable: true } } : {}), + }; + window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0; + }, + { updateReady: args["update-ready"] }, +); try { if (args.messages) {