From a8741924085b5cd792bf93192c179cd82e9e9d6c Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:45:34 +0200 Subject: [PATCH] feat(themes): surface installed-theme updates in the store (#1240) * feat(themes): show per-theme changelog and pin updatable themes Surface theme updates in the store: each card gains an expandable "What's new" built from the theme's optional manifest changelog (versions listed newest first), and installed themes with a pending update now float to the top of the list in both sort modes so they are easy to find. * i18n(settings): add themeStoreWhatsNew across locales New "What's new" label for the theme-store changelog disclosure, in all 13 locales. * docs(changelog): add theme store changelog and pinned-updates entry (#1240) --- CHANGELOG.md | 8 +++ src/config/settingsCredits.ts | 1 + .../components/ThemeStoreSection.test.tsx | 65 +++++++++++++++++ .../settings/components/ThemeStoreSection.tsx | 71 +++++++++++++++++-- src/lib/themes/themeRegistry.ts | 6 ++ src/locales/bg/settings.ts | 1 + src/locales/de/settings.ts | 1 + src/locales/en/settings.ts | 1 + src/locales/es/settings.ts | 1 + src/locales/fr/settings.ts | 1 + src/locales/hu/settings.ts | 1 + src/locales/ja/settings.ts | 1 + src/locales/nb/settings.ts | 1 + src/locales/nl/settings.ts | 1 + src/locales/pl/settings.ts | 1 + src/locales/ro/settings.ts | 1 + src/locales/ru/settings.ts | 1 + src/locales/zh/settings.ts | 1 + 18 files changed, 160 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc96bba..79679d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently. +### Theme Store — per-theme changelogs and pinned updates + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1240](https://github.com/Psychotoxical/psysonic/pull/1240)** + +* Each theme card now has an expandable **What's new** with per-version release notes, so you can see what a theme update changed — including non-visual fixes. Provided by theme authors; themes without notes just don't show the section. +* Installed themes with an available update now appear at the top of the store list instead of wherever the sort placed them, so you don't have to hunt for them. + + ## Changed ### Frontend restructure — feature-folder architecture and hardening diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 32e0f677..ca382ec2 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -403,6 +403,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Themed window title bar on macOS — follows the active theme instead of the grey system bar, with the native window buttons floating over it (PR #1199)', 'Frontend feature-folder restructure — CI-enforced layering guard, added unit/behavior-scenario/boot-smoke tests, and a compile-time frontend/backend IPC contract via tauri-specta (PR #1225)', 'Completed the tauri-specta typed-IPC cutover — CI guards for bindings freshness and full command registration (PR #1230)', + 'Theme Store: per-theme changelogs shown as an expandable "What\'s new" on each card, plus installed themes with an available update pinned to the top of the list (PR #1240)', ], }, { diff --git a/src/features/settings/components/ThemeStoreSection.test.tsx b/src/features/settings/components/ThemeStoreSection.test.tsx index 6d4cf076..4e477e5d 100644 --- a/src/features/settings/components/ThemeStoreSection.test.tsx +++ b/src/features/settings/components/ThemeStoreSection.test.tsx @@ -3,6 +3,7 @@ import { screen, waitFor, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '@/test/helpers/renderWithProviders'; import { ThemeStoreSection } from '@/features/settings/components/ThemeStoreSection'; +import { useInstalledThemesStore } from '@/store/installedThemesStore'; import type { FetchRegistryResult, Registry, RegistryTheme } from '@/lib/themes/themeRegistry'; // Control the registry the store browses so pagination/refresh are deterministic. @@ -75,6 +76,7 @@ async function selectSort( describe('ThemeStoreSection — pagination & refresh', () => { beforeEach(() => { vi.clearAllMocks(); + useInstalledThemesStore.setState({ themes: [] }); // jsdom has no layout engine; goToPage() scrolls the list back up. Element.prototype.scrollIntoView = vi.fn(); }); @@ -221,4 +223,67 @@ describe('ThemeStoreSection — pagination & refresh', () => { expect(screen.queryByText('Theme 01')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument(); }); + + it('shows an expandable changelog only for themes that ship one, newest version first', async () => { + const themes = [ + mkTheme('with', 'With Log', { + changelog: { '1.0.0': ['First release'], '1.2.0': ['Newer note', 'Another newer note'] }, + }), + mkTheme('without', 'No Log'), + ]; + fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false }); + const { container } = renderWithProviders(); + const user = userEvent.setup(); + + await screen.findByText('With Log'); + + // Exactly one row (the one shipping a changelog) exposes the toggle. + const toggles = screen.getAllByRole('button', { name: "What's new" }); + expect(toggles).toHaveLength(1); + + // Collapsed by default — entry text is not rendered yet. + const toggle = toggles[0]; + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Newer note')).not.toBeInTheDocument(); + + // Expanding lists every version, newest first (1.2.0 before 1.0.0). + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + const panel = container.querySelector('#theme-changelog-with') as HTMLElement; + expect(panel).not.toBeNull(); + expect(panel.textContent).toContain('Newer note'); + const i120 = panel.textContent!.indexOf('v1.2.0'); + const i100 = panel.textContent!.indexOf('v1.0.0'); + expect(i120).toBeGreaterThanOrEqual(0); + expect(i100).toBeGreaterThan(i120); + }); + + it('floats installed themes with a pending update to the top of the list', async () => { + // Alpha is the newest by date and would normally lead; Zulu is older but has + // an installed copy on an earlier version, so it should be pinned first. + const themes = [ + mkTheme('alpha', 'Alpha', { updatedAt: '2026-06-10T00:00:00Z', version: '1.0.0' }), + mkTheme('zulu', 'Zulu', { updatedAt: '2026-06-01T00:00:00Z', version: '2.0.0' }), + ]; + useInstalledThemesStore.setState({ + themes: [ + { + id: 'zulu', + name: 'Zulu', + author: 'Tester', + version: '1.0.0', // installed older than registry 2.0.0 → update available + description: '', + mode: 'dark', + css: 'x', + installedAt: 0, + }, + ], + }); + fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false }); + const { container } = renderWithProviders(); + + await screen.findByText('Zulu'); + // Without the pin, newest-first would order Alpha (06-10) before Zulu (06-01). + expect(rowNames(container)).toEqual(['Zulu', 'Alpha']); + }); }); diff --git a/src/features/settings/components/ThemeStoreSection.tsx b/src/features/settings/components/ThemeStoreSection.tsx index 059ac06f..1e132e1c 100644 --- a/src/features/settings/components/ThemeStoreSection.tsx +++ b/src/features/settings/components/ThemeStoreSection.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Check, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react'; +import { Check, ChevronDown, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react'; import { open as openUrl } from '@tauri-apps/plugin-shell'; import CoverLightbox from '@/ui/CoverLightbox'; import { useThemeAnimationRisk } from '@/features/settings/hooks/useThemeAnimationRisk'; @@ -27,6 +27,11 @@ const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes'; /** Themes shown per page — the catalogue is large enough to paginate. */ const PAGE_SIZE = 12; +/** A theme's changelog as `[version, lines]` pairs, newest version first. */ +function sortedChangelog(changelog: Record): [string, string[]][] { + return Object.entries(changelog).sort(([a], [b]) => (isNewer(a, b) ? -1 : isNewer(b, a) ? 1 : 0)); +} + /** Page numbers for the pager: all of them when there are few, otherwise the * first and last page plus a window around the current one, with gaps. */ function pageItemsList(current: number, total: number): (number | 'gap')[] { @@ -67,8 +72,17 @@ export function ThemeStoreSection() { const [busyId, setBusyId] = useState(null); const [failedId, setFailedId] = useState(null); const [lightbox, setLightbox] = useState<{ src: string; name: string } | null>(null); + const [openChangelogIds, setOpenChangelogIds] = useState>(() => new Set()); const topRef = useRef(null); + const toggleChangelog = (id: string) => + setOpenChangelogIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + // A manual refresh must not unmount the list: blanking it collapses the // scroll viewport's content height, which clamps scrollTop to 0 — i.e. the // page jumps to the top. So keep the existing list mounted (`refreshing`, @@ -119,9 +133,19 @@ export function ThemeStoreSection() { // Name is the stable tie-breaker — keeps ordering deterministic when many // themes share the same last-modified date. const byName = (a: RegistryTheme, b: RegistryTheme) => a.name.localeCompare(b.name); - if (sortMode === 'name') return matched.sort(byName); - return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b)); - }, [themes, query, mode, sortMode, animFilter]); + const bySort = + sortMode === 'name' + ? byName + : (a: RegistryTheme, b: RegistryTheme) => + (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b); + // Installed themes with a pending update float to the top (in either sort + // mode) so the user finds them instead of hunting through the catalogue. + const hasUpdate = (th: RegistryTheme) => { + const inst = installedMap.get(th.id); + return inst ? isNewer(th.version, inst.version) : false; + }; + return matched.sort((a, b) => Number(hasUpdate(b)) - Number(hasUpdate(a)) || bySort(a, b)); + }, [themes, query, mode, sortMode, animFilter, installedMap]); // A changed filter can shrink the result set below the current page; reset to // the first page whenever the query or mode filter changes. @@ -308,6 +332,8 @@ export function ThemeStoreSection() { const updateAvailable = isInstalled && isNewer(th.version, inst!.version); const isActive = activeTheme === th.id; const busy = busyId === th.id; + const changelogEntries = th.changelog ? sortedChangelog(th.changelog) : []; + const changelogOpen = openChangelogIds.has(th.id); return (
)} + {changelogEntries.length > 0 && ( +
+ + {changelogOpen && ( +
+ {changelogEntries.map(([version, lines]) => ( +
+
+ v{version} +
+
    + {lines.map((line, i) => ( +
  • {line}
  • + ))} +
+
+ ))} +
+ )} +
+ )}
{!isInstalled && (