From c878970180e358fa4820b48aff191044e875aa6e Mon Sep 17 00:00:00 2001
From: LyAhn
Date: Tue, 23 Jun 2026 21:33:08 +0100
Subject: [PATCH] feat: What's New screen + updater progress, plus light-theme
fixes
Post-update UX for the next release (0.1.2):
- What's New: after a version change, greet the user with a toast that opens
an in-app release-notes screen (collapsible Added/Changed/Fixed sections)
sourced from the bundled CHANGELOG.md, reopenable from Settings -> Updates.
Backed by a last_seen_version settings file (get/set_last_seen_version
commands) that distinguishes upgrades from fresh installs.
- Updater: the download/install progress toast now reappears when an update is
started from the title-bar indicator or Settings after the prompt was
dismissed; Settings -> Updates gains a real progress bar with a percentage.
- Light theme: fix the recurring subtle-light breakage. Neutral surfaces now
rely on the CSS-variable remap instead of light-theme:bg-white (which forced
surfaces dark because --color-white is remapped dark); the green action
buttons drop the broken light-theme:hover:bg-emerald-200 (remapped dark,
unreadable on hover) for an override-free emerald-500 tint that auto-themes,
across the updater, What's New, and onboarding.
- Debug panel: add What's New triggers (toast / modal / reset).
---
CHANGELOG.md | 24 +++
src-tauri/src/commands.rs | 30 +++
src-tauri/src/lib.rs | 2 +
src/App.tsx | 9 +-
src/changelog.ts | 112 ++++++++++
src/components/DemoPanel.tsx | 31 +++
src/components/SettingsModal.tsx | 37 +++-
src/components/UpdateToast.tsx | 2 +-
src/components/WhatsNewModal.tsx | 197 ++++++++++++++++++
src/components/WhatsNewToast.tsx | 44 ++++
.../onboarding/OnboardingOverlay.tsx | 2 +-
src/components/onboarding/StepAddLibrary.tsx | 2 +-
src/components/onboarding/StepWelcome.tsx | 2 +-
src/store.ts | 51 ++++-
14 files changed, 537 insertions(+), 8 deletions(-)
create mode 100644 src/changelog.ts
create mode 100644 src/components/WhatsNewModal.tsx
create mode 100644 src/components/WhatsNewToast.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 953b710..0641712 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ All notable changes to Phokus are documented here. The format is based on
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(0.x: anything may change between minor versions).
+## [Unreleased]
+
+### Added
+
+- **What's New** — after updating, Phokus now greets you with a "What's new"
+ toast that opens an in-app release-notes screen for the new version, with the
+ changes grouped into collapsible Added / Changed / Fixed sections. It's
+ sourced from the bundled changelog (so it works offline) and can be reopened
+ any time from Settings → Updates → What's new.
+
+### Changed
+
+- The updater now shows a real download progress bar with a percentage in
+ Settings → Updates (previously it only said "Downloading").
+
+### Fixed
+
+- The update download/install progress toast now reappears when you start an
+ update from the title-bar indicator or Settings after dismissing the earlier
+ "Update available" prompt — previously progress only showed in Settings.
+- Subtle Light theme — fixed several surfaces and buttons that stayed dark or
+ became unreadable on hover, including new dialogs and the green action buttons
+ across the updater and onboarding.
+
## [0.1.1] — 2026-06-23
### Added
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index 06053ab..f6c0da1 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -2430,3 +2430,33 @@ pub async fn set_onboarding_completed(app: AppHandle, completed: bool) -> Result
)
.map_err(|e| e.to_string())
}
+
+const LAST_SEEN_VERSION_FILE: &str = "settings/last_seen_version.txt";
+
+/// The app version recorded on the previous launch. `None` when the file is
+/// absent (fresh install, or first launch since this was introduced) — the
+/// frontend uses that to decide whether to surface the "What's New" prompt.
+#[tauri::command]
+pub async fn get_last_seen_version(app: AppHandle) -> Result, String> {
+ let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
+ let path = app_dir.join(LAST_SEEN_VERSION_FILE);
+ if !path.exists() {
+ return Ok(None);
+ }
+ let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
+ let trimmed = content.trim();
+ Ok(if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_string())
+ })
+}
+
+#[tauri::command]
+pub async fn set_last_seen_version(app: AppHandle, version: String) -> Result<(), String> {
+ let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
+ let settings_dir = app_dir.join("settings");
+ std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?;
+ std::fs::write(settings_dir.join("last_seen_version.txt"), version.trim())
+ .map_err(|e| e.to_string())
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 9ffb038..dec5ccc 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -208,6 +208,8 @@ pub fn run() {
commands::retry_ffmpeg_download,
commands::get_onboarding_completed,
commands::set_onboarding_completed,
+ commands::get_last_seen_version,
+ commands::set_last_seen_version,
commands::get_notifications_paused,
commands::set_notifications_paused,
])
diff --git a/src/App.tsx b/src/App.tsx
index fa0adb3..45bc904 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -12,6 +12,8 @@ import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal";
import { FolderPickerModal } from "./components/FolderPickerModal";
import { UpdateToast } from "./components/UpdateToast";
+import { WhatsNewToast } from "./components/WhatsNewToast";
+import { WhatsNewModal } from "./components/WhatsNewModal";
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
import { DemoPanel } from "./components/DemoPanel";
import { initializeNotifications } from "./notifications";
@@ -29,15 +31,18 @@ export default function App() {
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus);
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted);
+ const initWhatsNew = useGalleryStore((state) => state.initWhatsNew);
const activeView = useGalleryStore((state) => state.activeView);
useEffect(() => {
void initializeNotifications();
void loadMutedFolderIds();
void loadNotificationsPaused();
- void loadAppVersion();
void loadFfmpegStatus();
void loadOnboardingCompleted();
+ // Load the app version first so the What's New toast/modal (which read
+ // appVersion from the store) have it before the greeting can appear.
+ void loadAppVersion().then(() => initWhatsNew());
// Quiet launch check — dev builds have no signed artifacts to update to.
if (import.meta.env.PROD) {
void checkForUpdates({ quiet: true });
@@ -96,6 +101,8 @@ export default function App() {
+
+
{import.meta.env.DEV && }
diff --git a/src/changelog.ts b/src/changelog.ts
new file mode 100644
index 0000000..1e3a112
--- /dev/null
+++ b/src/changelog.ts
@@ -0,0 +1,112 @@
+// Parses the project CHANGELOG.md (imported raw at build time) into structured
+// data so the "What's New" UI can render it nicely instead of dumping markdown.
+// Keeping the changelog as the single source of truth means there's no separate
+// per-release copy to maintain — whatever ships in CHANGELOG.md is what users see.
+import changelogRaw from "../CHANGELOG.md?raw";
+
+export interface ChangelogItem {
+ /** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */
+ lead: string | null;
+ /** The remaining descriptive text. May still contain inline `code` / **bold** markers. */
+ body: string;
+}
+
+export interface ChangelogSection {
+ /** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */
+ title: string;
+ items: ChangelogItem[];
+}
+
+export interface ChangelogEntry {
+ version: string;
+ date: string | null;
+ sections: ChangelogSection[];
+}
+
+// "## [0.1.1] — 2026-06-23" / "## [Unreleased]"
+const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/;
+// "### Added"
+const SECTION_HEADING = /^###\s+(.+?)\s*$/;
+// "- bullet text"
+const BULLET = /^[-*]\s+(.*)$/;
+// Leading "**Title**" optionally followed by an em dash, used as the item's lead-in.
+// (Item text is whitespace-collapsed before matching, so no dotAll flag needed.)
+const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/;
+
+function toItem(text: string): ChangelogItem {
+ const collapsed = text.replace(/\s+/g, " ").trim();
+ const match = collapsed.match(LEAD);
+ if (match) {
+ return { lead: match[1].trim(), body: match[2].trim() };
+ }
+ return { lead: null, body: collapsed };
+}
+
+function parseChangelog(raw: string): ChangelogEntry[] {
+ const lines = raw.split(/\r?\n/);
+ const entries: ChangelogEntry[] = [];
+
+ let entry: ChangelogEntry | null = null;
+ let section: ChangelogSection | null = null;
+ let buffer: string[] = [];
+
+ const flushItem = () => {
+ if (section && buffer.length > 0) {
+ section.items.push(toItem(buffer.join(" ")));
+ }
+ buffer = [];
+ };
+
+ for (const line of lines) {
+ const versionMatch = line.match(VERSION_HEADING);
+ if (versionMatch) {
+ flushItem();
+ section = null;
+ entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] };
+ entries.push(entry);
+ continue;
+ }
+
+ // Stop collecting once we leave the changelog body (e.g. link-reference defs at EOF).
+ if (!entry) continue;
+
+ const sectionMatch = line.match(SECTION_HEADING);
+ if (sectionMatch) {
+ flushItem();
+ section = { title: sectionMatch[1].trim(), items: [] };
+ entry.sections.push(section);
+ continue;
+ }
+
+ const bulletMatch = line.match(BULLET);
+ if (bulletMatch) {
+ flushItem();
+ buffer.push(bulletMatch[1]);
+ continue;
+ }
+
+ if (line.trim() === "") {
+ // A blank line ends a (possibly wrapped) multi-line bullet.
+ flushItem();
+ continue;
+ }
+
+ // Indented continuation of the current wrapped bullet.
+ if (buffer.length > 0) {
+ buffer.push(line.trim());
+ }
+ }
+
+ flushItem();
+ return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) }));
+}
+
+const ENTRIES = parseChangelog(changelogRaw);
+
+export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
+ if (!version) return null;
+ const normalized = version.replace(/^v/, "");
+ // Never surface the in-progress [Unreleased] section to users.
+ if (normalized.toLowerCase() === "unreleased") return null;
+ return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null;
+}
diff --git a/src/components/DemoPanel.tsx b/src/components/DemoPanel.tsx
index b62c9a8..e90463d 100644
--- a/src/components/DemoPanel.tsx
+++ b/src/components/DemoPanel.tsx
@@ -42,6 +42,7 @@ const DEMO_UPDATE_VERSION = "0.2.0";
export function DemoPanel() {
const folders = useGalleryStore((state) => state.folders);
+ const appVersion = useGalleryStore((state) => state.appVersion);
const [open, setOpen] = useState(false);
const downloadTimer = useRef(null);
@@ -160,6 +161,18 @@ export function DemoPanel() {
});
};
+ // --- What's New flow ------------------------------------------------------
+ // Drives the post-update greeting without needing a real version change. The
+ // toast + modal pull their content from the bundled changelog for the running
+ // app version, so the current release's notes are what render.
+
+ const showWhatsNewToast = () =>
+ useGalleryStore.setState({ whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION, whatsNewOpen: false });
+
+ const openWhatsNewModal = () => useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null });
+
+ const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null });
+
if (!open) return null;
const injectBtn =
@@ -215,6 +228,24 @@ export function DemoPanel() {
Reset updater state
+
+
+
+ What's new
+
+ Post-update greeting for v{appVersion ?? "—"}, sourced from the bundled changelog.
+
+
+
+ Show "What's new" toast
+
+
+ Open "What's new" modal
+
+
+ Reset What's New state
+
+
);
}
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index ba50dca..aaf2e88 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown";
+import { getChangelogForVersion } from "../changelog";
type SettingsSection = "workspace" | "general";
@@ -192,9 +193,11 @@ export function SettingsModal() {
const appVersion = useGalleryStore((state) => state.appVersion);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
+ const updateProgress = useGalleryStore((state) => state.updateProgress);
const updateError = useGalleryStore((state) => state.updateError);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const installUpdate = useGalleryStore((state) => state.installUpdate);
+ const openWhatsNew = useGalleryStore((state) => state.openWhatsNew);
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
const theme = useGalleryStore((state) => state.theme);
const setTheme = useGalleryStore((state) => state.setTheme);
@@ -670,7 +673,24 @@ export function SettingsModal() {
updateStatus === "error" ? (
Update check failed: {updateError}
) : updateStatus === "downloading" || updateStatus === "installing" ? (
- "Downloading update — the app will restart when it finishes."
+
+
+ {updateStatus === "installing"
+ ? "Installing update…"
+ : updateProgress !== null
+ ? `Downloading update — ${Math.round(updateProgress * 100)}%`
+ : "Downloading update…"}
+
+
+
+
+ The app will restart when it finishes.
+
) : (
"Updates are checked quietly at launch and installed only when you choose."
)
@@ -678,7 +698,7 @@ export function SettingsModal() {
>
{updateStatus === "available" ? (
void installUpdate()}
>
Install & restart
@@ -693,6 +713,19 @@ export function SettingsModal() {
)}
+ {getChangelogForVersion(appVersion) ? (
+
+
+ View changes
+
+
+ ) : null}
diff --git a/src/components/UpdateToast.tsx b/src/components/UpdateToast.tsx
index e569768..ad1bc0a 100644
--- a/src/components/UpdateToast.tsx
+++ b/src/components/UpdateToast.tsx
@@ -31,7 +31,7 @@ export function UpdateToast() {
void installUpdate()}
>
Install & restart
diff --git a/src/components/WhatsNewModal.tsx b/src/components/WhatsNewModal.tsx
new file mode 100644
index 0000000..5971ce9
--- /dev/null
+++ b/src/components/WhatsNewModal.tsx
@@ -0,0 +1,197 @@
+import { useEffect, useState, type ReactNode } from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { openUrl } from "@tauri-apps/plugin-opener";
+import { useGalleryStore } from "../store";
+import { getChangelogForVersion, type ChangelogSection } from "../changelog";
+
+const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md";
+
+// Per-section accent. These all use the standard colour scale, which the
+// subtle-light theme re-maps to readable dark shades via CSS variables — so no
+// `light-theme:` overrides are needed (or wanted) here. See the theme note in
+// index.css: `bg-white`/`text-white` deliberately become *dark* in light mode,
+// so neutral surfaces must use the gray scale and trust the remap.
+const SECTION_STYLES: Record = {
+ Added: { label: "text-emerald-300", dot: "bg-emerald-400/80" },
+ Changed: { label: "text-sky-300", dot: "bg-sky-300/80" },
+ Fixed: { label: "text-amber-300", dot: "bg-amber-400/80" },
+ Removed: { label: "text-rose-300", dot: "bg-rose-400/80" },
+ Security: { label: "text-violet-300", dot: "bg-violet-400/80" },
+};
+
+const NEUTRAL_STYLE = { label: "text-gray-300", dot: "bg-gray-400/70" };
+
+// Render the small amount of inline markdown the changelog uses: **bold** and
+// `code`. Anything else passes through as plain text.
+function renderInline(text: string): ReactNode[] {
+ const nodes: ReactNode[] = [];
+ const regex = /\*\*(.+?)\*\*|`([^`]+)`/g;
+ let lastIndex = 0;
+ let key = 0;
+ let match: RegExpExecArray | null;
+ while ((match = regex.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ nodes.push(text.slice(lastIndex, match.index));
+ }
+ if (match[1] !== undefined) {
+ nodes.push(
+
+ {match[1]}
+ ,
+ );
+ } else if (match[2] !== undefined) {
+ nodes.push(
+
+ {match[2]}
+ ,
+ );
+ }
+ lastIndex = regex.lastIndex;
+ }
+ if (lastIndex < text.length) {
+ nodes.push(text.slice(lastIndex));
+ }
+ return nodes;
+}
+
+function Section({ section }: { section: ChangelogSection }) {
+ const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE;
+ const [open, setOpen] = useState(true);
+
+ return (
+
+ setOpen((value) => !value)}
+ className="flex w-full items-center gap-2 text-left"
+ aria-expanded={open}
+ >
+
+
+
+ {section.title}
+ {section.items.length}
+
+
+ {open ? (
+
+
+
+ ) : null}
+
+
+ );
+}
+
+export function WhatsNewModal() {
+ const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen);
+ const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew);
+ const appVersion = useGalleryStore((s) => s.appVersion);
+
+ const entry = getChangelogForVersion(appVersion);
+
+ useEffect(() => {
+ if (!whatsNewOpen) return;
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") closeWhatsNew();
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [whatsNewOpen, closeWhatsNew]);
+
+ return (
+
+ {whatsNewOpen ? (
+
+ event.stopPropagation()}
+ initial={{ opacity: 0, scale: 0.97, y: 8 }}
+ animate={{ opacity: 1, scale: 1, y: 0 }}
+ exit={{ opacity: 0, scale: 0.98, y: 6 }}
+ transition={{ duration: 0.16 }}
+ >
+
+
+
What's new
+
+ Phokus v{entry?.version ?? appVersion ?? "—"}
+
+ {entry?.date ?
Released {entry.date}
: null}
+
+
+
+
+
+
+
+
+
+ {entry && entry.sections.length > 0 ? (
+ entry.sections.map((section) =>
)
+ ) : (
+
+ Release notes for this version aren't available in-app. See the full changelog on GitHub.
+
+ )}
+
+
+
+ void openUrl(CHANGELOG_URL)}
+ className="text-xs text-gray-500 transition-colors hover:text-gray-300"
+ >
+ Full changelog ↗
+
+
+ Got it
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/WhatsNewToast.tsx b/src/components/WhatsNewToast.tsx
new file mode 100644
index 0000000..578c7e8
--- /dev/null
+++ b/src/components/WhatsNewToast.tsx
@@ -0,0 +1,44 @@
+import { AnimatePresence, motion } from "framer-motion";
+import { useGalleryStore } from "../store";
+
+// Shown once on the first launch after an update, inviting the user to read the
+// changelog. Distinct from UpdateToast (which drives the download/install flow).
+export function WhatsNewToast() {
+ const whatsNewToast = useGalleryStore((s) => s.whatsNewToast);
+ const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen);
+ const openWhatsNew = useGalleryStore((s) => s.openWhatsNew);
+ const dismissWhatsNewToast = useGalleryStore((s) => s.dismissWhatsNewToast);
+
+ const visible = whatsNewToast !== null && !whatsNewOpen;
+
+ return (
+
+ {visible ? (
+
+ What's new in Phokus v{whatsNewToast}
+ See what's changed in this version.
+
+
+ What's new
+
+
+ Dismiss
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/onboarding/OnboardingOverlay.tsx b/src/components/onboarding/OnboardingOverlay.tsx
index 4c5992d..6ffd6ea 100644
--- a/src/components/onboarding/OnboardingOverlay.tsx
+++ b/src/components/onboarding/OnboardingOverlay.tsx
@@ -103,7 +103,7 @@ export function OnboardingOverlay() {
Back
(isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
>
{isLast ? "Finish" : "Next"}
diff --git a/src/components/onboarding/StepAddLibrary.tsx b/src/components/onboarding/StepAddLibrary.tsx
index 0b3411e..7d2a558 100644
--- a/src/components/onboarding/StepAddLibrary.tsx
+++ b/src/components/onboarding/StepAddLibrary.tsx
@@ -37,7 +37,7 @@ export function StepAddLibrary() {
)}
{folders.length > 0 ? "Add another folder" : "Choose a folder"}
diff --git a/src/components/onboarding/StepWelcome.tsx b/src/components/onboarding/StepWelcome.tsx
index 05703c7..23f22e6 100644
--- a/src/components/onboarding/StepWelcome.tsx
+++ b/src/components/onboarding/StepWelcome.tsx
@@ -142,7 +142,7 @@ export function StepWelcome() {
type="button"
className={`rounded-md border p-2 text-left transition-colors ${
selected
- ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/40 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700 light-theme:hover:bg-emerald-200"
+ ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/40"
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
}`}
onClick={() => setTheme(option.value)}
diff --git a/src/store.ts b/src/store.ts
index ba4f35d..24436de 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -6,6 +6,7 @@ import { getVersion } from "@tauri-apps/api/app";
import { check, Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { notifyTaskComplete } from "./notifications";
+import { getChangelogForVersion } from "./changelog";
// Per-folder debounce timers for batching notifications.
// Keyed as `${folderId}:embedding` or `${folderId}:tagging`.
@@ -378,6 +379,11 @@ interface GalleryState {
updateProgress: number | null; // 0..1 download progress, null while size unknown
updateError: string | null;
updateDismissed: boolean;
+ // "What's New" greeting after a version change. `whatsNewToast` holds the
+ // version to advertise in the corner toast (null = hidden); `whatsNewOpen`
+ // controls the full changelog modal.
+ whatsNewOpen: boolean;
+ whatsNewToast: string | null;
ffmpegStatus: FfmpegStatus;
ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null;
@@ -479,6 +485,10 @@ interface GalleryState {
checkForUpdates: (options?: { quiet?: boolean }) => Promise;
installUpdate: () => Promise;
dismissUpdate: () => void;
+ initWhatsNew: () => Promise;
+ openWhatsNew: () => void;
+ closeWhatsNew: () => void;
+ dismissWhatsNewToast: () => void;
loadFfmpegStatus: () => Promise;
retryFfmpegDownload: () => Promise;
loadOnboardingCompleted: () => Promise;
@@ -810,6 +820,8 @@ export const useGalleryStore = create((set, get) => ({
updateProgress: null,
updateError: null,
updateDismissed: false,
+ whatsNewOpen: false,
+ whatsNewToast: null,
ffmpegStatus: "unknown",
ffmpegProgress: null,
@@ -1801,7 +1813,10 @@ export const useGalleryStore = create((set, get) => ({
const update = pendingUpdate;
if (!update || get().updateStatus !== "available") return;
- set({ updateStatus: "downloading", updateProgress: null, updateError: null });
+ // Clearing the dismissed flag re-surfaces the progress toast: the user may
+ // have clicked "Later" on the prompt and then triggered the install from the
+ // title-bar button or Settings, and they should still see download progress.
+ set({ updateStatus: "downloading", updateProgress: null, updateError: null, updateDismissed: false });
try {
let contentLength: number | null = null;
let downloaded = 0;
@@ -1830,6 +1845,40 @@ export const useGalleryStore = create((set, get) => ({
dismissUpdate: () => set({ updateDismissed: true }),
+ initWhatsNew: async () => {
+ try {
+ const current = await getVersion();
+ const lastSeen = (await invoke("get_last_seen_version")) || null;
+ // Already greeted this version — nothing to do, and no need to rewrite.
+ if (lastSeen === current) return;
+
+ let shouldShow: boolean;
+ if (lastSeen) {
+ // A recorded earlier version means this launch is a genuine upgrade.
+ shouldShow = true;
+ } else {
+ // No record yet. Fresh installs are covered by the welcome tour, so only
+ // greet users who have already completed onboarding (i.e. upgraded into
+ // this feature) rather than someone opening the app for the first time.
+ shouldShow = await invoke("get_onboarding_completed").catch(() => false);
+ }
+
+ // Only surface the prompt if we actually have notes for this version.
+ if (shouldShow && getChangelogForVersion(current)) {
+ set({ whatsNewToast: current });
+ }
+ await invoke("set_last_seen_version", { version: current }).catch(() => {});
+ } catch {
+ // Non-fatal: the greeting is a nicety, never block startup on it.
+ }
+ },
+
+ openWhatsNew: () => set({ whatsNewOpen: true, whatsNewToast: null }),
+
+ closeWhatsNew: () => set({ whatsNewOpen: false }),
+
+ dismissWhatsNewToast: () => set({ whatsNewToast: null }),
+
loadFfmpegStatus: async () => {
try {
const status = await invoke<{ installed: boolean; downloading: boolean; failed: boolean }>(