From 02a7dfab163b0e5a5dd4e8a04e683526bd10d73f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:24:00 +0200 Subject: [PATCH] feat: tell plugins when the colour scheme or theme changes --- lib/__tests__/plugin-hook-catalog.test.ts | 4 +-- lib/__tests__/theme-store-hooks.test.ts | 30 +++++++++++++++++++++++ stores/theme-store.ts | 12 ++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 lib/__tests__/theme-store-hooks.test.ts diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index 5b5d56cac..f27f7c0b3 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -37,7 +37,7 @@ const sources = ['app', 'components', 'hooks', 'lib', 'stores'].flatMap((d) => s /** A call site that fires the hook: `xHooks.onFoo.emit(` and friends. */ function isFired(hookName: string, groupName: string): boolean { - const re = new RegExp(`\\b${groupName}\\.${hookName}\\.(emit|emitSync|emitTo|intercept|interceptOutcome|transform|takeOver)\\b`); + const re = new RegExp(`\\b${groupName}\\.${hookName}\\s*\\.(emit|emitSync|emitTo|intercept|interceptOutcome|transform|takeOver)\\b`); return re.test(sources); } @@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean { const NOT_YET_WIRED = new Set([ 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', 'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities', - 'filters', 'vacation', 'templates', 'auth', 'security', 'theme', 'notifications', 'ui', 'keyboard', + 'filters', 'vacation', 'templates', 'auth', 'security', 'notifications', 'ui', 'keyboard', ]); describe('hook catalogue', () => { diff --git a/lib/__tests__/theme-store-hooks.test.ts b/lib/__tests__/theme-store-hooks.test.ts new file mode 100644 index 000000000..803bf84b8 --- /dev/null +++ b/lib/__tests__/theme-store-hooks.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { clearAllHooks, themeHooks } from '@/lib/plugin-hooks'; +import { useThemeStore } from '@/stores/theme-store'; + +beforeEach(() => { + clearAllHooks(); + useThemeStore.setState({ theme: 'light', resolvedTheme: 'light', activeThemeId: null }); +}); + +describe('theme hooks', () => { + it('tells plugins when the colour scheme changes', () => { + const fn = vi.fn(); + themeHooks.onThemeChange.register('t', fn); + + useThemeStore.getState().setTheme('dark'); + + expect(fn).toHaveBeenCalledWith({ theme: 'dark', resolved: 'dark' }); + }); + + it('tells plugins when another theme is activated, and not when nothing changed', () => { + const fn = vi.fn(); + themeHooks.onCustomThemeChange.register('t', fn); + + useThemeStore.setState({ activeThemeId: 'ocean' }); + useThemeStore.setState({ activeThemeId: 'ocean' }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith({ themeId: 'ocean' }); + }); +}); diff --git a/stores/theme-store.ts b/stores/theme-store.ts index d06df2822..5e31ac3fa 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -644,4 +644,14 @@ async function downloadThemeAsset(theme: ServerThemeInfo, kind: 'css' | 'skin'): console.warn(`[theme-store] Failed to download ${kind} for theme "${theme.id}"`); return null; } -} \ No newline at end of file +} +// Plugins hear about every change of scheme or theme, whichever path made it +// (settings, the system switching to dark, a forced admin theme). +useThemeStore.subscribe((state, prev) => { + if (state.theme !== prev.theme || state.resolvedTheme !== prev.resolvedTheme) { + void themeHooks.onThemeChange.emit({ theme: state.theme, resolved: state.resolvedTheme }); + } + if (state.activeThemeId !== prev.activeThemeId) { + void themeHooks.onCustomThemeChange.emit({ themeId: state.activeThemeId }); + } +});