mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(themes): sidebar notice when an installed theme has an update (#1041)
* feat(themes): sidebar notice when an installed theme has an update Adds a dismissible sidebar pill (sibling of the What's New banner) shown while an installed community theme has a newer version in the store. Clicking opens Settings -> Themes; dismiss hides it until a new update changes the set. The theme registry is now refreshed from source once per app launch instead of only when the Theme Store tab is opened, so newly published themes and updates surface without a manual refresh -- and feed this notice. * docs: add CHANGELOG entry for PR #1041 * feat(themes): in-place update control on installed theme cards Themes with a newer version in the store now show a centered update icon on their card in Settings -> Themes; clicking it fetches and reinstalls in place. Extracts the shared installThemeFromRegistry helper (fetch -> validate -> install) used by both the store list and the card control, and surfaces the full registry entry from useThemeUpdates so the card can update directly.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../utils/themes/themeRegistry', () => ({
|
||||
fetchRegistry: vi.fn(),
|
||||
getCachedRegistry: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { fetchRegistry, type Registry } from '../utils/themes/themeRegistry';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../store/installedThemesStore';
|
||||
import { useThemeUpdates, themeUpdateSignature } from './useThemeUpdates';
|
||||
|
||||
const fetchRegistryMock = vi.mocked(fetchRegistry);
|
||||
|
||||
function inst(id: string, version: string): InstalledTheme {
|
||||
return { id, name: id, author: 'x', version, description: '', mode: 'dark', css: '', installedAt: 0 };
|
||||
}
|
||||
|
||||
function registry(themes: { id: string; version: string }[]): Registry {
|
||||
return { themes: themes.map(t => ({ id: t.id, name: t.id, version: t.version })) } as unknown as Registry;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useInstalledThemesStore.setState({ themes: [] });
|
||||
fetchRegistryMock.mockReset();
|
||||
});
|
||||
|
||||
describe('useThemeUpdates', () => {
|
||||
it('lists only installed themes that have a newer registry version', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0'), inst('b', '2.0.0'), inst('c', '1.5.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({
|
||||
registry: registry([
|
||||
{ id: 'a', version: '1.1.0' }, // newer → update
|
||||
{ id: 'b', version: '2.0.0' }, // same → no
|
||||
{ id: 'c', version: '1.4.0' }, // older → no
|
||||
]),
|
||||
stale: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(result.current).toHaveLength(1));
|
||||
expect(result.current[0].id).toBe('a');
|
||||
expect(result.current[0].version).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('returns nothing when nothing is outdated', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'a', version: '1.0.0' }]), stale: false });
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores registry themes the user has not installed', async () => {
|
||||
useInstalledThemesStore.setState({ themes: [inst('a', '1.0.0')] });
|
||||
fetchRegistryMock.mockResolvedValue({ registry: registry([{ id: 'z', version: '9.0.0' }]), stale: false });
|
||||
|
||||
const { result } = renderHook(() => useThemeUpdates());
|
||||
await waitFor(() => expect(fetchRegistryMock).toHaveBeenCalled());
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('themeUpdateSignature', () => {
|
||||
it('is order-independent and encodes id@version', () => {
|
||||
const a = themeUpdateSignature([{ id: 'b', version: '2.0.0' }, { id: 'a', version: '1.1.0' }]);
|
||||
const b = themeUpdateSignature([{ id: 'a', version: '1.1.0' }, { id: 'b', version: '2.0.0' }]);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe('a@1.1.0,b@2.0.0');
|
||||
});
|
||||
|
||||
it('changes when a version bumps so a dismissed notice can reappear', () => {
|
||||
expect(themeUpdateSignature([{ id: 'a', version: '1.1.0' }]))
|
||||
.not.toBe(themeUpdateSignature([{ id: 'a', version: '1.2.0' }]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { isNewer } from '../utils/componentHelpers/appUpdaterHelpers';
|
||||
import { fetchRegistry, getCachedRegistry, type Registry, type RegistryTheme } from '../utils/themes/themeRegistry';
|
||||
import { useInstalledThemesStore } from '../store/installedThemesStore';
|
||||
|
||||
// Refresh the registry from source once per app launch (not just from the
|
||||
// cache). This surfaces newly published themes and updates without the user
|
||||
// having to hit the manual refresh in the Theme Store, and it feeds the
|
||||
// sidebar update notice. Subsequent reads this session use the cache.
|
||||
let sessionRefreshStarted = false;
|
||||
|
||||
/**
|
||||
* Registry entries for installed community themes that have a newer version
|
||||
* available. Returns the full registry theme (css path, version, metadata) so a
|
||||
* caller can update in place. Seeds from the last-cached registry synchronously,
|
||||
* then revalidates (forced on the first call this session). Recomputes when the
|
||||
* installed set changes, so the list shrinks as the user updates themes.
|
||||
*/
|
||||
export function useThemeUpdates(): RegistryTheme[] {
|
||||
const installed = useInstalledThemesStore(s => s.themes);
|
||||
const [registry, setRegistry] = useState<Registry | null>(() => getCachedRegistry());
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const opts = sessionRefreshStarted ? undefined : { force: true };
|
||||
sessionRefreshStarted = true;
|
||||
fetchRegistry(opts)
|
||||
.then(r => { if (alive) setRegistry(r.registry); })
|
||||
.catch(() => { /* offline: keep whatever the cache gave us */ });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!registry) return [];
|
||||
const installedVersionById = new Map(installed.map(t => [t.id, t.version]));
|
||||
return registry.themes.filter(rt => {
|
||||
const current = installedVersionById.get(rt.id);
|
||||
return current != null && isNewer(rt.version, current);
|
||||
});
|
||||
}, [registry, installed]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of an update set, used to remember a dismissal: the sidebar
|
||||
* notice stays hidden until a new or bumped update changes this string.
|
||||
*/
|
||||
export function themeUpdateSignature(updates: Array<{ id: string; version: string }>): string {
|
||||
return updates.map(u => `${u.id}@${u.version}`).sort().join(',');
|
||||
}
|
||||
Reference in New Issue
Block a user