diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9bec14b..daf0901 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { useConversationActions, useConversationNavigation, useRealtimeAppState, + useBrowserNotifications, } from './hooks'; import { AppShell } from './components/AppShell'; import type { MessageInputHandle } from './components/MessageInput'; @@ -22,6 +23,13 @@ import type { Conversation, RawPacket } from './types'; export function App() { const messageInputRef = useRef(null); const [rawPackets, setRawPackets] = useState([]); + const { + notificationsSupported, + notificationsPermission, + isConversationNotificationsEnabled, + toggleConversationNotifications, + notifyIncomingMessage, + } = useBrowserNotifications(); const { showNewMessage, showSettings, @@ -202,6 +210,7 @@ export function App() { pendingDeleteFallbackRef, setActiveConversation, updateMessageAck, + notifyIncomingMessage, }); const { handleSendMessage, @@ -237,7 +246,10 @@ export function App() { [fetchUndecryptedCount, setChannels] ); - const statusProps = { health, config }; + const statusProps = { + health, + config, + }; const sidebarProps = { contacts, channels, @@ -258,6 +270,7 @@ export function App() { onSortOrderChange: (sortOrder: 'recent' | 'alpha') => { void handleSortOrderChange(sortOrder); }, + isConversationNotificationsEnabled, }; const conversationPaneProps = { activeConversation, @@ -289,6 +302,21 @@ export function App() { onLoadNewer: fetchNewerMessages, onJumpToBottom: jumpToBottom, onSendMessage: handleSendMessage, + notificationsSupported, + notificationsPermission, + notificationsEnabled: + activeConversation?.type === 'contact' || activeConversation?.type === 'channel' + ? isConversationNotificationsEnabled(activeConversation.type, activeConversation.id) + : false, + onToggleNotifications: () => { + if (activeConversation?.type === 'contact' || activeConversation?.type === 'channel') { + void toggleConversationNotifications( + activeConversation.type, + activeConversation.id, + activeConversation.name + ); + } + }, }; const searchProps = { contacts, diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 1537bec..3cba0b2 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; +import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; import { toast } from './ui/sonner'; import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; @@ -14,7 +14,11 @@ interface ChatHeaderProps { channels: Channel[]; config: RadioConfig | null; favorites: Favorite[]; + notificationsSupported: boolean; + notificationsEnabled: boolean; + notificationsPermission: NotificationPermission | 'unsupported'; onTrace: () => void; + onToggleNotifications: () => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void; onDeleteChannel: (key: string) => void; @@ -29,7 +33,11 @@ export function ChatHeader({ channels, config, favorites, + notificationsSupported, + notificationsEnabled, + notificationsPermission, onTrace, + onToggleNotifications, onToggleFavorite, onSetChannelFloodScopeOverride, onDeleteChannel, @@ -47,6 +55,12 @@ export function ChatHeader({ conversation.type === 'channel' ? channels.find((channel) => channel.key === conversation.id) : undefined; + const activeFloodScopeOverride = + conversation.type === 'channel' ? (activeChannel?.flood_scope_override ?? null) : null; + const activeFloodScopeLabel = activeFloodScopeOverride + ? stripRegionScopePrefix(activeFloodScopeOverride) + : null; + const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null; const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag; const titleClickable = @@ -65,7 +79,7 @@ export function ChatHeader({ if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return; const nextValue = window.prompt( 'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.', - stripRegionScopePrefix(activeChannel?.flood_scope_override) + activeFloodScopeLabel ?? '' ); if (nextValue === null) return; onSetChannelFloodScopeOverride(conversation.id, nextValue); @@ -164,12 +178,6 @@ export function ChatHeader({ )} - {conversation.type === 'channel' && activeChannel?.flood_scope_override && ( - - Regional override active:{' '} - {stripRegionScopePrefix(activeChannel.flood_scope_override)} - - )} {conversation.type === 'contact' && (() => { const contact = contacts.find((c) => c.public_key === conversation.id); @@ -185,9 +193,25 @@ export function ChatHeader({ ); })()} + {conversation.type === 'channel' && activeFloodScopeDisplay && ( + + )} -
+
{conversation.type === 'contact' && ( )} + {notificationsSupported && ( + + )} {conversation.type === 'channel' && onSetChannelFloodScopeOverride && ( )} {(conversation.type === 'channel' || conversation.type === 'contact') && ( diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 5def504..69dfafc 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -31,6 +31,9 @@ interface ConversationPaneProps { rawPackets: RawPacket[]; config: RadioConfig | null; health: HealthStatus | null; + notificationsSupported: boolean; + notificationsEnabled: boolean; + notificationsPermission: NotificationPermission | 'unsupported'; favorites: Favorite[]; messages: Message[]; messagesLoading: boolean; @@ -54,6 +57,7 @@ interface ConversationPaneProps { onLoadNewer: () => Promise; onJumpToBottom: () => void; onSendMessage: (text: string) => Promise; + onToggleNotifications: () => void; } function LoadingPane({ label }: { label: string }) { @@ -69,6 +73,9 @@ export function ConversationPane({ rawPackets, config, health, + notificationsSupported, + notificationsEnabled, + notificationsPermission, favorites, messages, messagesLoading, @@ -92,6 +99,7 @@ export function ConversationPane({ onLoadNewer, onJumpToBottom, onSendMessage, + onToggleNotifications, }: ConversationPaneProps) { const activeContactIsRepeater = useMemo(() => { if (!activeConversation || activeConversation.type !== 'contact') return false; @@ -155,10 +163,14 @@ export function ConversationPane({ conversation={activeConversation} contacts={contacts} favorites={favorites} + notificationsSupported={notificationsSupported} + notificationsEnabled={notificationsEnabled} + notificationsPermission={notificationsPermission} radioLat={config?.lat ?? null} radioLon={config?.lon ?? null} radioName={config?.name ?? null} onTrace={onTrace} + onToggleNotifications={onToggleNotifications} onToggleFavorite={onToggleFavorite} onDeleteContact={onDeleteContact} /> @@ -174,7 +186,11 @@ export function ConversationPane({ channels={channels} config={config} favorites={favorites} + notificationsSupported={notificationsSupported} + notificationsEnabled={notificationsEnabled} + notificationsPermission={notificationsPermission} onTrace={onTrace} + onToggleNotifications={onToggleNotifications} onToggleFavorite={onToggleFavorite} onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride} onDeleteChannel={onDeleteChannel} diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index 1982b68..8db61ae 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -1,6 +1,6 @@ import { toast } from './ui/sonner'; import { Button } from './ui/button'; -import { Route, Star, Trash2 } from 'lucide-react'; +import { Bell, Route, Star, Trash2 } from 'lucide-react'; import { RepeaterLogin } from './RepeaterLogin'; import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard'; import { isFavorite } from '../utils/favorites'; @@ -25,10 +25,14 @@ interface RepeaterDashboardProps { conversation: Conversation; contacts: Contact[]; favorites: Favorite[]; + notificationsSupported: boolean; + notificationsEnabled: boolean; + notificationsPermission: NotificationPermission | 'unsupported'; radioLat: number | null; radioLon: number | null; radioName: string | null; onTrace: () => void; + onToggleNotifications: () => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onDeleteContact: (publicKey: string) => void; } @@ -37,10 +41,14 @@ export function RepeaterDashboard({ conversation, contacts, favorites, + notificationsSupported, + notificationsEnabled, + notificationsPermission, radioLat, radioLon, radioName, onTrace, + onToggleNotifications, onToggleFavorite, onDeleteContact, }: RepeaterDashboardProps) { @@ -120,6 +128,35 @@ export function RepeaterDashboard({ >
); @@ -487,10 +501,10 @@ export function Sidebar({ onKeyDown={handleKeyboardActivate} onClick={onClick} > -
); @@ -653,44 +667,39 @@ export function Sidebar({ aria-label="Conversations" > {/* Header */} -
-

- Conversations -

+
+
+ setSearchQuery(e.target.value)} + className="h-7 text-[13px] pr-8 bg-background/50" + /> + {searchQuery && ( + + )} +
- {/* Search */} -
- setSearchQuery(e.target.value)} - className="h-7 text-[13px] pr-8 bg-background/50" - /> - {searchQuery && ( - - )} -
- {/* List */}
{/* Tools */} diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 1b5c97e..ec3fd1f 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; -import { Menu } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Menu, Moon, Sun } from 'lucide-react'; import type { HealthStatus, RadioConfig } from '../types'; import { api } from '../api'; import { toast } from './ui/sonner'; import { handleKeyboardActivate } from '../utils/a11y'; +import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme'; import { cn } from '@/lib/utils'; interface StatusBarProps { @@ -29,6 +30,19 @@ export function StatusBar({ ? 'Radio OK' : 'Radio Disconnected'; const [reconnecting, setReconnecting] = useState(false); + const [currentTheme, setCurrentTheme] = useState(getSavedTheme); + + useEffect(() => { + const handleThemeChange = (event: Event) => { + const themeId = (event as CustomEvent).detail; + setCurrentTheme(typeof themeId === 'string' && themeId ? themeId : getSavedTheme()); + }; + + window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener); + return () => { + window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener); + }; + }, []); const handleReconnect = async () => { setReconnecting(true); @@ -46,6 +60,12 @@ export function StatusBar({ } }; + const handleThemeToggle = () => { + const nextTheme = currentTheme === 'light' ? 'original' : 'light'; + applyTheme(nextTheme); + setCurrentTheme(nextTheme); + }; + return (
{/* Mobile menu button - only visible on small screens */} @@ -128,6 +148,18 @@ export function StatusBar({ > {settingsMode ? 'Back to Chat' : 'Settings'} +
); } diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index d6d1c94..571e07f 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels'; export { useRealtimeAppState } from './useRealtimeAppState'; export { useConversationActions } from './useConversationActions'; export { useConversationNavigation } from './useConversationNavigation'; +export { useBrowserNotifications } from './useBrowserNotifications'; diff --git a/frontend/src/hooks/useBrowserNotifications.ts b/frontend/src/hooks/useBrowserNotifications.ts new file mode 100644 index 0000000..395be43 --- /dev/null +++ b/frontend/src/hooks/useBrowserNotifications.ts @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useState } from 'react'; +import { toast } from '../components/ui/sonner'; +import type { Message } from '../types'; +import { getStateKey } from '../utils/conversationState'; + +const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation'; +const NOTIFICATION_ICON_PATH = '/favicon-256x256.png'; + +type NotificationPermissionState = NotificationPermission | 'unsupported'; +type ConversationNotificationMap = Record; + +function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string { + return getStateKey(type, id); +} + +function readStoredEnabledMap(): ConversationNotificationMap { + if (typeof window === 'undefined') { + return {}; + } + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') { + return {}; + } + return Object.fromEntries( + Object.entries(parsed).filter(([key, value]) => typeof key === 'string' && value === true) + ); + } catch { + return {}; + } +} + +function writeStoredEnabledMap(enabledByConversation: ConversationNotificationMap) { + if (typeof window === 'undefined') { + return; + } + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(enabledByConversation)); +} + +function getInitialPermission(): NotificationPermissionState { + if (typeof window === 'undefined' || !('Notification' in window)) { + return 'unsupported'; + } + return window.Notification.permission; +} + +function shouldShowDesktopNotification(): boolean { + if (typeof document === 'undefined') { + return false; + } + return document.visibilityState !== 'visible' || !document.hasFocus(); +} + +function getMessageConversationNotificationKey(message: Message): string | null { + if (message.type === 'PRIV' && message.conversation_key) { + return getConversationNotificationKey('contact', message.conversation_key); + } + if (message.type === 'CHAN' && message.conversation_key) { + return getConversationNotificationKey('channel', message.conversation_key); + } + return null; +} + +function buildNotificationTitle(message: Message): string { + if (message.type === 'PRIV') { + return message.sender_name + ? `New message from ${message.sender_name}` + : `New message from ${message.conversation_key.slice(0, 12)}`; + } + + const roomName = message.channel_name || message.conversation_key.slice(0, 8); + return `New message in ${roomName}`; +} + +function buildPreviewNotificationTitle(type: 'channel' | 'contact', label: string): string { + return type === 'contact' ? `New message from ${label}` : `New message in ${label}`; +} + +function buildMessageNotificationHash(message: Message): string | null { + if (message.type === 'PRIV' && message.conversation_key) { + const label = message.sender_name || message.conversation_key.slice(0, 12); + return `#contact/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`; + } + if (message.type === 'CHAN' && message.conversation_key) { + const label = message.channel_name || message.conversation_key.slice(0, 8); + return `#channel/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`; + } + return null; +} + +export function useBrowserNotifications() { + const [permission, setPermission] = useState(getInitialPermission); + const [enabledByConversation, setEnabledByConversation] = + useState(readStoredEnabledMap); + + useEffect(() => { + setPermission(getInitialPermission()); + }, []); + + const isConversationNotificationsEnabled = useCallback( + (type: 'channel' | 'contact', id: string) => + permission === 'granted' && + enabledByConversation[getConversationNotificationKey(type, id)] === true, + [enabledByConversation, permission] + ); + + const toggleConversationNotifications = useCallback( + async (type: 'channel' | 'contact', id: string, label: string) => { + const conversationKey = getConversationNotificationKey(type, id); + if (enabledByConversation[conversationKey]) { + setEnabledByConversation((prev) => { + const next = { ...prev }; + delete next[conversationKey]; + writeStoredEnabledMap(next); + return next; + }); + toast.success(`${label} notifications disabled`); + return; + } + + if (permission === 'unsupported') { + toast.error('Browser notifications unavailable', { + description: 'This browser does not support desktop notifications.', + }); + return; + } + + if (permission === 'denied') { + toast.error('Browser notifications blocked', { + description: 'Allow notifications in your browser settings, then try again.', + }); + return; + } + + const nextPermission = await window.Notification.requestPermission(); + setPermission(nextPermission); + + if (nextPermission === 'granted') { + setEnabledByConversation((prev) => { + const next = { + ...prev, + [conversationKey]: true, + }; + writeStoredEnabledMap(next); + return next; + }); + new window.Notification(buildPreviewNotificationTitle(type, label), { + body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.', + icon: NOTIFICATION_ICON_PATH, + tag: `meshcore-notification-preview-${conversationKey}`, + }); + toast.success(`${label} notifications enabled`); + return; + } + + toast.error('Browser notifications not enabled', { + description: + nextPermission === 'denied' + ? 'Permission was denied by the browser.' + : 'Permission request was dismissed.', + }); + }, + [enabledByConversation, permission] + ); + + const notifyIncomingMessage = useCallback( + (message: Message) => { + const conversationKey = getMessageConversationNotificationKey(message); + if ( + permission !== 'granted' || + !conversationKey || + enabledByConversation[conversationKey] !== true || + !shouldShowDesktopNotification() + ) { + return; + } + + const notification = new window.Notification(buildNotificationTitle(message), { + body: message.text, + icon: NOTIFICATION_ICON_PATH, + tag: `meshcore-message-${message.id}`, + }); + + notification.onclick = () => { + const hash = buildMessageNotificationHash(message); + if (hash) { + window.open(`${window.location.origin}${window.location.pathname}${hash}`, '_self'); + } + window.focus(); + notification.close(); + }; + }, + [enabledByConversation, permission] + ); + + return { + notificationsSupported: permission !== 'unsupported', + notificationsPermission: permission, + isConversationNotificationsEnabled, + toggleConversationNotifications, + notifyIncomingMessage, + }; +} diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts index 122da47..eb61835 100644 --- a/frontend/src/hooks/useRealtimeAppState.ts +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -44,6 +44,7 @@ interface UseRealtimeAppStateArgs { pendingDeleteFallbackRef: MutableRefObject; setActiveConversation: (conv: Conversation | null) => void; updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; + notifyIncomingMessage?: (msg: Message) => void; maxRawPackets?: number; } @@ -103,6 +104,7 @@ export function useRealtimeAppState({ pendingDeleteFallbackRef, setActiveConversation, updateMessageAck, + notifyIncomingMessage, maxRawPackets = 500, }: UseRealtimeAppStateArgs): UseWebSocketOptions { const mergeChannelIntoList = useCallback( @@ -180,18 +182,19 @@ export function useRealtimeAppState({ activeConversationRef.current, msg ); + let isNewMessage = false; if (isForActiveConversation && !hasNewerMessagesRef.current) { - addMessageIfNew(msg); + isNewMessage = addMessageIfNew(msg); } trackNewMessage(msg); const contentKey = getMessageContentKey(msg); if (!isForActiveConversation) { - const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey); + isNewMessage = messageCache.addMessage(msg.conversation_key, msg, contentKey); - if (!msg.outgoing && isNew) { + if (!msg.outgoing && isNewMessage) { let stateKey: string | null = null; if (msg.type === 'CHAN' && msg.conversation_key) { stateKey = getStateKey('channel', msg.conversation_key); @@ -203,6 +206,10 @@ export function useRealtimeAppState({ } } } + + if (!msg.outgoing && isNewMessage) { + notifyIncomingMessage?.(msg); + } }, onContact: (contact: Contact) => { setContacts((prev) => mergeContactIntoList(prev, contact)); @@ -259,6 +266,7 @@ export function useRealtimeAppState({ trackNewMessage, triggerReconcile, updateMessageAck, + notifyIncomingMessage, ] ); } diff --git a/frontend/src/index.css b/frontend/src/index.css index ac6f493..4a1ad44 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -40,6 +40,7 @@ --success-foreground: 0 0% 100%; --info: 217 91% 60%; --info-foreground: 0 0% 100%; + --region-override: 270 80% 74%; /* Favorites */ --favorite: 43 96% 56%; diff --git a/frontend/src/test/chatHeaderKeyVisibility.test.tsx b/frontend/src/test/chatHeaderKeyVisibility.test.tsx index 04952dc..a4e9179 100644 --- a/frontend/src/test/chatHeaderKeyVisibility.test.tsx +++ b/frontend/src/test/chatHeaderKeyVisibility.test.tsx @@ -14,7 +14,11 @@ const baseProps = { contacts: [], config: null, favorites: [] as Favorite[], + notificationsSupported: true, + notificationsEnabled: false, + notificationsPermission: 'granted' as const, onTrace: noop, + onToggleNotifications: noop, onToggleFavorite: noop, onSetChannelFloodScopeOverride: noop, onDeleteChannel: noop, @@ -107,7 +111,7 @@ describe('ChatHeader key visibility', () => { expect(writeText).toHaveBeenCalledWith(key); }); - it('shows active regional override banner for channels', () => { + it('shows active regional override badge for channels', () => { const key = 'AB'.repeat(16); const channel = { ...makeChannel(key, '#flightless', true), @@ -117,7 +121,27 @@ describe('ChatHeader key visibility', () => { render(); - expect(screen.getByText('Regional override active: Esperance')).toBeInTheDocument(); + expect(screen.getAllByText('#Esperance')).toHaveLength(2); + }); + + it('shows enabled notification state and toggles when clicked', () => { + const conversation: Conversation = { type: 'contact', id: '11'.repeat(32), name: 'Alice' }; + const onToggleNotifications = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('Notifications On')); + + expect(screen.getByText('Notifications On')).toBeInTheDocument(); + expect(onToggleNotifications).toHaveBeenCalledTimes(1); }); it('prompts for regional override when globe button is clicked', () => { diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 9f35335..5e62899 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -99,6 +99,9 @@ function createProps(overrides: Partial {}), onJumpToBottom: vi.fn(), onSendMessage: vi.fn(async () => {}), + onToggleNotifications: vi.fn(), ...overrides, }; } diff --git a/frontend/src/test/repeaterDashboard.test.tsx b/frontend/src/test/repeaterDashboard.test.tsx index 797b070..5cb5b1f 100644 --- a/frontend/src/test/repeaterDashboard.test.tsx +++ b/frontend/src/test/repeaterDashboard.test.tsx @@ -99,10 +99,14 @@ const defaultProps = { conversation, contacts, favorites, + notificationsSupported: true, + notificationsEnabled: false, + notificationsPermission: 'granted' as const, radioLat: null, radioLon: null, radioName: null, onTrace: vi.fn(), + onToggleNotifications: vi.fn(), onToggleFavorite: vi.fn(), onDeleteContact: vi.fn(), }; @@ -190,6 +194,21 @@ describe('RepeaterDashboard', () => { expect(mockHook.loadAll).toHaveBeenCalledTimes(1); }); + it('shows enabled notification state and toggles when clicked', () => { + render( + + ); + + fireEvent.click(screen.getByText('Notifications On')); + + expect(screen.getByText('Notifications On')).toBeInTheDocument(); + expect(defaultProps.onToggleNotifications).toHaveBeenCalledTimes(1); + }); + it('shows login error when present', () => { mockHook.loginError = 'Invalid password'; diff --git a/frontend/src/test/sidebar.test.tsx b/frontend/src/test/sidebar.test.tsx index ab01c57..b8422d0 100644 --- a/frontend/src/test/sidebar.test.tsx +++ b/frontend/src/test/sidebar.test.tsx @@ -41,6 +41,7 @@ function renderSidebar(overrides?: { favorites?: Favorite[]; lastMessageTimes?: ConversationTimes; channels?: Channel[]; + isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean; }) { const aliceName = 'Alice'; const publicChannel = makeChannel('AA'.repeat(16), 'Public'); @@ -76,6 +77,7 @@ function renderSidebar(overrides?: { favorites={favorites} sortOrder="recent" onSortOrderChange={vi.fn()} + isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled} /> ); @@ -218,4 +220,37 @@ describe('Sidebar section summaries', () => { const selectedIds = onSelectConversation.mock.calls.map(([conv]) => conv.id); expect(new Set(selectedIds)).toEqual(new Set([channelA.key, channelB.key])); }); + + it('shows a notification bell for conversations with notifications enabled', () => { + const { aliceName } = renderSidebar({ + unreadCounts: {}, + isConversationNotificationsEnabled: (type, id) => + (type === 'contact' && id === '11'.repeat(32)) || + (type === 'channel' && id === 'BB'.repeat(16)), + }); + + const aliceRow = screen.getByText(aliceName).closest('div'); + const flightRow = screen.getByText('#flight').closest('div'); + if (!aliceRow || !flightRow) throw new Error('Missing sidebar rows'); + + expect(within(aliceRow).getByLabelText('Notifications enabled')).toBeInTheDocument(); + expect(within(flightRow).getByLabelText('Notifications enabled')).toBeInTheDocument(); + }); + + it('keeps the notification bell to the left of the unread pill when both are present', () => { + const { aliceName } = renderSidebar({ + unreadCounts: { + [getStateKey('contact', '11'.repeat(32))]: 3, + }, + isConversationNotificationsEnabled: (type, id) => + type === 'contact' && id === '11'.repeat(32), + }); + + const aliceRow = screen.getByText(aliceName).closest('div'); + if (!aliceRow) throw new Error('Missing Alice row'); + + const bell = within(aliceRow).getByLabelText('Notifications enabled'); + const unread = within(aliceRow).getByText('3'); + expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); }); diff --git a/frontend/src/test/statusBar.test.tsx b/frontend/src/test/statusBar.test.tsx index 0ee8446..c664af6 100644 --- a/frontend/src/test/statusBar.test.tsx +++ b/frontend/src/test/statusBar.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { StatusBar } from '../components/StatusBar'; @@ -47,4 +47,21 @@ describe('StatusBar', () => { expect(screen.getByRole('status', { name: 'Radio Disconnected' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument(); }); + + it('toggles between classic and light themes from the shortcut button', () => { + localStorage.setItem('remoteterm-theme', 'cyberpunk'); + + render(); + + const themeToggle = screen.getByRole('button', { name: 'Switch to light theme' }); + fireEvent.click(themeToggle); + + expect(localStorage.getItem('remoteterm-theme')).toBe('light'); + expect(document.documentElement.dataset.theme).toBe('light'); + + fireEvent.click(screen.getByRole('button', { name: 'Switch to classic theme' })); + + expect(localStorage.getItem('remoteterm-theme')).toBe('original'); + expect(document.documentElement.dataset.theme).toBeUndefined(); + }); }); diff --git a/frontend/src/test/useBrowserNotifications.test.ts b/frontend/src/test/useBrowserNotifications.test.ts new file mode 100644 index 0000000..530ce21 --- /dev/null +++ b/frontend/src/test/useBrowserNotifications.test.ts @@ -0,0 +1,151 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useBrowserNotifications } from '../hooks/useBrowserNotifications'; +import type { Message } from '../types'; + +const mocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../components/ui/sonner', () => ({ + toast: mocks.toast, +})); + +const incomingChannelMessage: Message = { + id: 42, + type: 'CHAN', + conversation_key: 'ab'.repeat(16), + text: 'hello room', + sender_timestamp: 1700000000, + received_at: 1700000001, + paths: null, + txt_type: 0, + signature: null, + sender_key: 'cd'.repeat(32), + outgoing: false, + acked: 0, + sender_name: 'Alice', + channel_name: '#flightless', +}; + +describe('useBrowserNotifications', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + window.location.hash = ''; + vi.spyOn(window, 'open').mockReturnValue(null); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }); + vi.spyOn(document, 'hasFocus').mockReturnValue(false); + + const NotificationMock = vi.fn().mockImplementation(function (this: Record) { + this.close = vi.fn(); + this.onclick = null; + }); + Object.assign(NotificationMock, { + permission: 'granted', + requestPermission: vi.fn(async () => 'granted'), + }); + Object.defineProperty(window, 'Notification', { + configurable: true, + value: NotificationMock, + }); + }); + + it('stores notification opt-in per conversation', async () => { + const { result } = renderHook(() => useBrowserNotifications()); + + await act(async () => { + await result.current.toggleConversationNotifications( + 'channel', + incomingChannelMessage.conversation_key, + '#flightless' + ); + }); + + expect( + result.current.isConversationNotificationsEnabled( + 'channel', + incomingChannelMessage.conversation_key + ) + ).toBe(true); + expect(result.current.isConversationNotificationsEnabled('contact', 'ef'.repeat(32))).toBe( + false + ); + expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', { + body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.', + icon: '/favicon-256x256.png', + tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`, + }); + }); + + it('only sends desktop notifications for opted-in conversations', async () => { + const { result } = renderHook(() => useBrowserNotifications()); + + await act(async () => { + await result.current.toggleConversationNotifications( + 'channel', + incomingChannelMessage.conversation_key, + '#flightless' + ); + }); + + act(() => { + result.current.notifyIncomingMessage(incomingChannelMessage); + result.current.notifyIncomingMessage({ + ...incomingChannelMessage, + id: 43, + conversation_key: '34'.repeat(16), + channel_name: '#elsewhere', + }); + }); + + expect(window.Notification).toHaveBeenCalledTimes(2); + expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', { + body: 'hello room', + icon: '/favicon-256x256.png', + tag: 'meshcore-message-42', + }); + }); + + it('notification click deep-links to the conversation hash', async () => { + const focusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {}); + const { result } = renderHook(() => useBrowserNotifications()); + + await act(async () => { + await result.current.toggleConversationNotifications( + 'channel', + incomingChannelMessage.conversation_key, + '#flightless' + ); + }); + + act(() => { + result.current.notifyIncomingMessage(incomingChannelMessage); + }); + + const notificationInstance = (window.Notification as unknown as ReturnType).mock + .instances[1] as { + onclick: (() => void) | null; + close: ReturnType; + }; + + act(() => { + notificationInstance.onclick?.(); + }); + + expect(window.open).toHaveBeenCalledWith( + `${window.location.origin}${window.location.pathname}#channel/${incomingChannelMessage.conversation_key}/%23flightless`, + '_self' + ); + expect(focusSpy).toHaveBeenCalledTimes(1); + expect(notificationInstance.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/test/useRealtimeAppState.test.ts b/frontend/src/test/useRealtimeAppState.test.ts index 63beaf8..f723f08 100644 --- a/frontend/src/test/useRealtimeAppState.test.ts +++ b/frontend/src/test/useRealtimeAppState.test.ts @@ -81,6 +81,7 @@ function createRealtimeArgs(overrides: Partial { `contact-${incomingDm.conversation_key}`, true ); + expect(args.notifyIncomingMessage).toHaveBeenCalledWith(incomingDm); }); it('deleting the active contact clears it and marks fallback recovery pending', () => { diff --git a/frontend/src/themes.css b/frontend/src/themes.css index 667386a..1e7253c 100644 --- a/frontend/src/themes.css +++ b/frontend/src/themes.css @@ -35,6 +35,7 @@ --success-foreground: 0 0% 100%; --info: 217 91% 48%; --info-foreground: 0 0% 100%; + --region-override: 274 78% 24%; --favorite: 43 96% 50%; --console: 153 50% 22%; --console-command: 153 55% 18%; @@ -48,6 +49,10 @@ --overlay: 220 20% 10%; } +:root[data-theme='light'] .sidebar-tool-label { + color: hsl(var(--foreground)); +} + /* ── Cyberpunk ("Neon Bleed") ──────────────────────────────── */ :root[data-theme='cyberpunk'] { --background: 210 18% 3%; @@ -80,6 +85,7 @@ --success-foreground: 135 100% 6%; --info: 185 100% 42%; --info-foreground: 185 100% 6%; + --region-override: 292 100% 68%; --favorite: 62 100% 52%; --console: 135 100% 50%; --console-command: 135 100% 62%; @@ -126,6 +132,7 @@ --success-foreground: 0 0% 100%; --info: 212 100% 58%; --info-foreground: 0 0% 100%; + --region-override: 286 100% 76%; --favorite: 43 100% 54%; --console: 212 100% 62%; --console-command: 212 100% 74%; @@ -172,6 +179,7 @@ --success-foreground: 0 0% 100%; --info: 210 50% 56%; --info-foreground: 0 0% 100%; + --region-override: 273 72% 72%; --favorite: 38 70% 56%; --console: 30 40% 58%; --console-command: 30 40% 70%; @@ -267,6 +275,7 @@ --success-foreground: 0 0% 100%; --info: 198 80% 54%; --info-foreground: 0 0% 100%; + --region-override: 282 100% 72%; --favorite: 46 100% 54%; --console: 338 100% 54%; --console-command: 338 100% 68%; @@ -373,3 +382,317 @@ [data-theme='solar-flare'] ::-webkit-scrollbar-thumb:hover { background: linear-gradient(180deg, hsl(338 90% 48%), hsl(24 90% 48%)); } + +/* ── Lagoon Pop ("Tidal Candy") ───────────────────────────── */ +:root[data-theme='lagoon-pop'] { + --background: 197 62% 9%; + --foreground: 42 33% 92%; + --card: 197 46% 13%; + --card-foreground: 42 33% 92%; + --popover: 197 46% 14%; + --popover-foreground: 42 33% 92%; + --primary: 175 72% 49%; + --primary-foreground: 196 60% 9%; + --secondary: 197 34% 18%; + --secondary-foreground: 42 22% 84%; + --muted: 197 30% 16%; + --muted-foreground: 195 16% 64%; + --accent: 205 46% 22%; + --accent-foreground: 42 33% 92%; + --destructive: 8 88% 61%; + --destructive-foreground: 0 0% 100%; + --border: 191 34% 24%; + --input: 191 34% 24%; + --ring: 175 72% 49%; + --radius: 1rem; + --msg-outgoing: 184 46% 16%; + --msg-incoming: 204 34% 14%; + --status-connected: 167 76% 46%; + --status-disconnected: 204 12% 46%; + --warning: 41 100% 58%; + --warning-foreground: 38 100% 10%; + --success: 167 76% 42%; + --success-foreground: 196 60% 9%; + --info: 229 90% 72%; + --info-foreground: 232 56% 14%; + --region-override: 277 88% 76%; + --favorite: 49 100% 63%; + --console: 175 72% 54%; + --console-command: 175 78% 68%; + --console-bg: 198 68% 7%; + --toast-error: 8 38% 14%; + --toast-error-foreground: 10 86% 77%; + --toast-error-border: 8 30% 24%; + --code-editor-bg: 198 44% 11%; + --font-sans: 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif; + --scrollbar: 191 34% 22%; + --scrollbar-hover: 191 40% 30%; + --overlay: 198 80% 4%; +} + +[data-theme='lagoon-pop'] body { + background: + radial-gradient(circle at top left, hsl(175 72% 49% / 0.1), transparent 28%), + radial-gradient(circle at top right, hsl(229 90% 72% / 0.1), transparent 24%), + radial-gradient(circle at bottom center, hsl(8 88% 61% / 0.08), transparent 26%), + hsl(197 62% 9%); +} + +[data-theme='lagoon-pop'] .bg-card { + background: linear-gradient(145deg, hsl(197 46% 14%), hsl(205 40% 16%)); +} + +[data-theme='lagoon-pop'] .bg-popover { + background: linear-gradient(145deg, hsl(197 46% 15%), hsl(205 40% 17%)); +} + +[data-theme='lagoon-pop'] .bg-msg-outgoing { + background: linear-gradient(135deg, hsl(184 48% 16%), hsl(175 38% 19%)); + border-left: 2px solid hsl(175 72% 49% / 0.45); +} + +[data-theme='lagoon-pop'] .bg-msg-incoming { + background: linear-gradient(135deg, hsl(204 34% 14%), hsl(214 30% 16%)); + border-left: 2px solid hsl(229 90% 72% / 0.35); +} + +[data-theme='lagoon-pop'] .bg-primary { + background: linear-gradient(135deg, hsl(175 72% 49%), hsl(191 78% 56%)); +} + +[data-theme='lagoon-pop'] button { + transition: + transform 0.12s ease, + filter 0.2s ease, + background-color 0.15s ease, + color 0.15s ease; +} + +[data-theme='lagoon-pop'] button:hover { + filter: drop-shadow(0 0 10px hsl(175 72% 49% / 0.18)); +} + +[data-theme='lagoon-pop'] button:active { + transform: translateY(1px); +} + +[data-theme='lagoon-pop'] ::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, hsl(175 40% 32%), hsl(229 38% 40%)); +} + +[data-theme='lagoon-pop'] ::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, hsl(175 52% 42%), hsl(229 52% 54%)); +} + +/* ── Candy Dusk ("Dream Arcade") ──────────────────────────── */ +:root[data-theme='candy-dusk'] { + --background: 258 38% 10%; + --foreground: 302 30% 93%; + --card: 258 30% 15%; + --card-foreground: 302 30% 93%; + --popover: 258 30% 16%; + --popover-foreground: 302 30% 93%; + --primary: 325 100% 74%; + --primary-foreground: 258 38% 12%; + --secondary: 255 24% 20%; + --secondary-foreground: 291 20% 85%; + --muted: 255 20% 18%; + --muted-foreground: 265 12% 66%; + --accent: 251 28% 24%; + --accent-foreground: 302 30% 93%; + --destructive: 9 88% 66%; + --destructive-foreground: 0 0% 100%; + --border: 256 24% 28%; + --input: 256 24% 28%; + --ring: 325 100% 74%; + --radius: 1.25rem; + --msg-outgoing: 307 32% 20%; + --msg-incoming: 250 24% 18%; + --status-connected: 164 78% 58%; + --status-disconnected: 255 10% 48%; + --warning: 43 100% 63%; + --warning-foreground: 36 100% 12%; + --success: 164 78% 54%; + --success-foreground: 258 38% 12%; + --info: 191 90% 76%; + --info-foreground: 242 32% 18%; + --region-override: 278 100% 82%; + --favorite: 43 100% 66%; + --console: 191 90% 76%; + --console-command: 325 100% 82%; + --console-bg: 252 42% 8%; + --toast-error: 352 34% 16%; + --toast-error-foreground: 8 92% 82%; + --toast-error-border: 352 24% 26%; + --code-editor-bg: 255 28% 13%; + --font-sans: 'Nunito', 'Trebuchet MS', 'Segoe UI', sans-serif; + --scrollbar: 256 28% 24%; + --scrollbar-hover: 256 34% 32%; + --overlay: 258 40% 6%; +} + +[data-theme='candy-dusk'] body { + background: + radial-gradient(circle at 20% 10%, hsl(325 100% 74% / 0.16), transparent 22%), + radial-gradient(circle at 85% 12%, hsl(191 90% 76% / 0.12), transparent 18%), + radial-gradient(circle at 50% 100%, hsl(43 100% 63% / 0.08), transparent 24%), hsl(258 38% 10%); +} + +[data-theme='candy-dusk'] .bg-card { + background: linear-gradient(160deg, hsl(258 30% 16%), hsl(248 28% 18%)); + box-shadow: inset 0 1px 0 hsl(302 50% 96% / 0.04); +} + +[data-theme='candy-dusk'] .bg-popover { + background: linear-gradient(160deg, hsl(258 30% 17%), hsl(248 28% 19%)); +} + +[data-theme='candy-dusk'] .bg-msg-outgoing { + background: linear-gradient(135deg, hsl(307 34% 21%), hsl(325 28% 24%)); + border-left: 2px solid hsl(325 100% 74% / 0.55); +} + +[data-theme='candy-dusk'] .bg-msg-incoming { + background: linear-gradient(135deg, hsl(250 24% 18%), hsl(258 20% 20%)); + border-left: 2px solid hsl(191 90% 76% / 0.38); +} + +[data-theme='candy-dusk'] .bg-primary { + background: linear-gradient(135deg, hsl(325 100% 74%), hsl(289 84% 74%)); +} + +[data-theme='candy-dusk'] button { + border-radius: 999px; + transition: + transform 0.12s ease, + filter 0.2s ease, + background-color 0.15s ease, + color 0.15s ease; +} + +[data-theme='candy-dusk'] button:hover { + filter: drop-shadow(0 0 10px hsl(325 100% 74% / 0.22)) + drop-shadow(0 0 18px hsl(191 90% 76% / 0.08)); +} + +[data-theme='candy-dusk'] button:active { + transform: scale(0.98); +} + +[data-theme='candy-dusk'] ::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, hsl(325 48% 44%), hsl(256 46% 42%)); +} + +[data-theme='candy-dusk'] ::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, hsl(325 66% 58%), hsl(191 58% 56%)); +} + +/* ── Paper Grove ("Field Notes") ──────────────────────────── */ +:root[data-theme='paper-grove'] { + --background: 41 43% 93%; + --foreground: 148 16% 18%; + --card: 43 52% 97%; + --card-foreground: 148 16% 18%; + --popover: 43 52% 98%; + --popover-foreground: 148 16% 18%; + --primary: 157 54% 40%; + --primary-foreground: 45 60% 98%; + --secondary: 42 26% 87%; + --secondary-foreground: 148 14% 26%; + --muted: 42 22% 89%; + --muted-foreground: 148 10% 44%; + --accent: 36 42% 83%; + --accent-foreground: 148 16% 18%; + --destructive: 12 76% 58%; + --destructive-foreground: 0 0% 100%; + --border: 38 22% 76%; + --input: 38 22% 76%; + --ring: 157 54% 40%; + --radius: 0.9rem; + --msg-outgoing: 151 32% 90%; + --msg-incoming: 40 30% 94%; + --status-connected: 157 54% 38%; + --status-disconnected: 148 8% 58%; + --warning: 39 92% 46%; + --warning-foreground: 39 100% 12%; + --success: 157 54% 34%; + --success-foreground: 45 60% 98%; + --info: 227 78% 64%; + --info-foreground: 228 40% 20%; + --region-override: 273 56% 44%; + --favorite: 43 92% 48%; + --console: 157 54% 34%; + --console-command: 224 48% 42%; + --console-bg: 45 24% 89%; + --toast-error: 8 52% 94%; + --toast-error-foreground: 9 58% 40%; + --toast-error-border: 10 34% 78%; + --code-editor-bg: 42 30% 90%; + --font-sans: 'Avenir Next', 'Segoe UI', sans-serif; + --scrollbar: 36 18% 68%; + --scrollbar-hover: 36 22% 58%; + --overlay: 148 20% 12%; +} + +[data-theme='paper-grove'] body { + background: + linear-gradient(hsl(157 20% 50% / 0.04) 1px, transparent 1px), + linear-gradient(90deg, hsl(157 20% 50% / 0.04) 1px, transparent 1px), hsl(41 43% 93%); + background-size: + 32px 32px, + 32px 32px, + auto; +} + +[data-theme='paper-grove'] .bg-card { + background: linear-gradient(180deg, hsl(43 52% 98%), hsl(40 42% 95%)); + box-shadow: + 0 1px 0 hsl(0 0% 100% / 0.8), + 0 8px 22px hsl(148 18% 20% / 0.06); +} + +[data-theme='paper-grove'] .bg-popover { + background: linear-gradient(180deg, hsl(43 52% 98%), hsl(40 38% 96%)); +} + +[data-theme='paper-grove'] .bg-msg-outgoing { + background: linear-gradient(135deg, hsl(151 34% 90%), hsl(157 30% 87%)); + border-left: 2px solid hsl(157 54% 40% / 0.45); +} + +[data-theme='paper-grove'] .bg-msg-incoming { + background: linear-gradient(135deg, hsl(40 30% 95%), hsl(38 26% 92%)); + border-left: 2px solid hsl(227 78% 64% / 0.28); +} + +[data-theme='paper-grove'] .bg-primary { + background: linear-gradient(135deg, hsl(157 54% 40%), hsl(180 42% 42%)); +} + +[data-theme='paper-grove'] button { + box-shadow: 0 1px 0 hsl(0 0% 100% / 0.7); + transition: + transform 0.12s ease, + box-shadow 0.18s ease, + background-color 0.15s ease, + color 0.15s ease; +} + +[data-theme='paper-grove'] button:hover { + transform: translateY(-1px); + box-shadow: + 0 1px 0 hsl(0 0% 100% / 0.8), + 0 6px 14px hsl(148 20% 20% / 0.08); +} + +[data-theme='paper-grove'] button:active { + transform: translateY(0); +} + +[data-theme='paper-grove'] ::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, hsl(157 26% 54%), hsl(227 26% 60%)); +} + +[data-theme='paper-grove'] ::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, hsl(157 34% 46%), hsl(227 34% 52%)); +} diff --git a/frontend/src/utils/theme.ts b/frontend/src/utils/theme.ts index 9324d2b..c5320e6 100644 --- a/frontend/src/utils/theme.ts +++ b/frontend/src/utils/theme.ts @@ -7,6 +7,8 @@ export interface Theme { metaThemeColor: string; } +export const THEME_CHANGE_EVENT = 'remoteterm-theme-change'; + export const THEMES: Theme[] = [ { id: 'original', @@ -44,6 +46,24 @@ export const THEMES: Theme[] = [ swatches: ['#0D0607', '#151012', '#FF0066', '#2D1D22', '#FF8C1A', '#30ACD4'], metaThemeColor: '#0D0607', }, + { + id: 'lagoon-pop', + name: 'Lagoon Pop', + swatches: ['#081A22', '#0F2630', '#23D7C6', '#173844', '#FF7A66', '#7C83FF'], + metaThemeColor: '#081A22', + }, + { + id: 'candy-dusk', + name: 'Candy Dusk', + swatches: ['#140F24', '#201736', '#FF79C9', '#2A2144', '#FFC857', '#8BE9FD'], + metaThemeColor: '#140F24', + }, + { + id: 'paper-grove', + name: 'Paper Grove', + swatches: ['#F7F1E4', '#FFF9EE', '#2F9E74', '#E7DEC8', '#E76F51', '#5C7CFA'], + metaThemeColor: '#F7F1E4', + }, ]; const THEME_KEY = 'remoteterm-theme'; @@ -77,4 +97,8 @@ export function applyTheme(themeId: string): void { meta.setAttribute('content', theme.metaThemeColor); } } + + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: themeId })); + } }