From 78c8ecddbd9b4783680e7ee69ee2424f2f01559a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:21:32 +0000 Subject: [PATCH] Port Angular settings service to a React SettingsContext Co-Authored-By: Devanshi Gupta --- .../src/context/SettingsContext.test.tsx | 166 ++++++++++++++++++ react-app/src/context/SettingsContext.tsx | 104 +++++++++++ 2 files changed, 270 insertions(+) create mode 100644 react-app/src/context/SettingsContext.test.tsx create mode 100644 react-app/src/context/SettingsContext.tsx diff --git a/react-app/src/context/SettingsContext.test.tsx b/react-app/src/context/SettingsContext.test.tsx new file mode 100644 index 00000000..24e58d22 --- /dev/null +++ b/react-app/src/context/SettingsContext.test.tsx @@ -0,0 +1,166 @@ +import { act, render, renderHook, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SettingsProvider, useSettings } from './SettingsContext'; + +type ChangeListener = (event: MediaQueryListEvent) => void; + +function stubMatchMedia(matches: boolean) { + const listeners = new Set(); + const media: MediaQueryList = { + matches, + media: '(prefers-color-scheme: dark)', + onchange: null, + addEventListener: (_: string, listener: ChangeListener) => listeners.add(listener), + removeEventListener: (_: string, listener: ChangeListener) => listeners.delete(listener), + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => true, + } as unknown as MediaQueryList; + + vi.stubGlobal( + 'matchMedia', + vi.fn(() => media) + ); + + return { + listeners, + emit(nextMatches: boolean) { + const event = { matches: nextMatches } as MediaQueryListEvent; + listeners.forEach((listener) => listener(event)); + }, + }; +} + +function wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe('useSettings', () => { + it('throws when used outside the provider', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useSettings())).toThrow( + 'useSettings must be used within a SettingsProvider' + ); + consoleError.mockRestore(); + }); +}); + +describe('SettingsProvider', () => { + it('uses the Angular service defaults when localStorage is empty', () => { + stubMatchMedia(false); + + const { result } = renderHook(() => useSettings(), { wrapper }); + + expect(result.current.settings).toMatchObject({ + showSettings: false, + openLinkInNewTab: false, + titleFontSize: '16', + listSpacing: '0', + theme: 'default', + }); + }); + + it('reads saved values from localStorage', () => { + stubMatchMedia(true); + localStorage.setItem('openLinkInNewTab', 'true'); + localStorage.setItem('titleFontSize', '20'); + localStorage.setItem('listSpacing', '10'); + localStorage.setItem('theme', 'dark'); + + const { result } = renderHook(() => useSettings(), { wrapper }); + + expect(result.current.settings).toMatchObject({ + openLinkInNewTab: true, + titleFontSize: '20', + listSpacing: '10', + theme: 'dark', + }); + }); + + it('persists setter results to localStorage', () => { + stubMatchMedia(false); + + const { result } = renderHook(() => useSettings(), { wrapper }); + + act(() => result.current.toggleOpenLinksInNewTab()); + act(() => result.current.setFont('22')); + act(() => result.current.setSpacing('5')); + act(() => result.current.setTheme('black')); + + expect(localStorage.getItem('openLinkInNewTab')).toBe('true'); + expect(localStorage.getItem('titleFontSize')).toBe('22'); + expect(localStorage.getItem('listSpacing')).toBe('5'); + expect(localStorage.getItem('theme')).toBe('black'); + expect(result.current.settings).toMatchObject({ + openLinkInNewTab: true, + titleFontSize: '22', + listSpacing: '5', + theme: 'black', + }); + }); + + it('does not persist showSettings', () => { + stubMatchMedia(false); + + const { result } = renderHook(() => useSettings(), { wrapper }); + + act(() => result.current.toggleSettings()); + + expect(result.current.settings.showSettings).toBe(true); + expect(localStorage.getItem('showSettings')).toBeNull(); + }); + + it('applies the system dark preference when no theme is saved', () => { + stubMatchMedia(true); + + const { result } = renderHook(() => useSettings(), { wrapper }); + + expect(result.current.settings.theme).toBe('night'); + expect(localStorage.getItem('theme')).toBe('night'); + }); + + it('keeps the saved theme even when the system prefers dark', () => { + stubMatchMedia(true); + localStorage.setItem('theme', 'default'); + + const { result } = renderHook(() => useSettings(), { wrapper }); + + expect(result.current.settings.theme).toBe('default'); + }); + + it('reacts to prefers-color-scheme changes', () => { + const media = stubMatchMedia(false); + + const { result } = renderHook(() => useSettings(), { wrapper }); + + act(() => media.emit(true)); + expect(result.current.settings.theme).toBe('night'); + + act(() => media.emit(false)); + expect(result.current.settings.theme).toBe('default'); + }); + + it('removes the media listener on unmount', () => { + const media = stubMatchMedia(false); + + const { unmount } = render( + + child + + ); + + expect(screen.getByText('child')).toBeInTheDocument(); + expect(media.listeners.size).toBe(1); + + unmount(); + + expect(media.listeners.size).toBe(0); + }); +}); diff --git a/react-app/src/context/SettingsContext.tsx b/react-app/src/context/SettingsContext.tsx new file mode 100644 index 00000000..706fa9e1 --- /dev/null +++ b/react-app/src/context/SettingsContext.tsx @@ -0,0 +1,104 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +import type { Settings } from '../models/settings'; + +export interface SettingsContextValue { + settings: Settings; + toggleSettings: () => void; + toggleOpenLinksInNewTab: () => void; + setTheme: (theme: string) => void; + setFont: (fontSize: string) => void; + setSpacing: (listSpace: string) => void; +} + +const SettingsContext = createContext(null); + +const DARK_COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)'; + +function readInitialSettings(): Settings { + const savedOpenLinkInNewTab = localStorage.getItem('openLinkInNewTab'); + + return { + showSettings: false, + openLinkInNewTab: savedOpenLinkInNewTab ? JSON.parse(savedOpenLinkInNewTab) : false, + theme: localStorage.getItem('theme') ?? 'default', + titleFontSize: localStorage.getItem('titleFontSize') ?? '16', + listSpacing: localStorage.getItem('listSpacing') ?? '0', + }; +} + +export function SettingsProvider({ children }: { children: ReactNode }) { + const [settings, setSettings] = useState(readInitialSettings); + + const setTheme = useCallback((theme: string) => { + localStorage.setItem('theme', theme); + setSettings((current) => ({ ...current, theme })); + }, []); + + const toggleSettings = useCallback(() => { + setSettings((current) => ({ ...current, showSettings: !current.showSettings })); + }, []); + + const toggleOpenLinksInNewTab = useCallback(() => { + setSettings((current) => { + const openLinkInNewTab = !current.openLinkInNewTab; + localStorage.setItem('openLinkInNewTab', JSON.stringify(openLinkInNewTab)); + return { ...current, openLinkInNewTab }; + }); + }, []); + + const setFont = useCallback((titleFontSize: string) => { + localStorage.setItem('titleFontSize', titleFontSize); + setSettings((current) => ({ ...current, titleFontSize })); + }, []); + + const setSpacing = useCallback((listSpacing: string) => { + localStorage.setItem('listSpacing', listSpacing); + setSettings((current) => ({ ...current, listSpacing })); + }, []); + + useEffect(() => { + const darkColorSchemeMedia = window.matchMedia(DARK_COLOR_SCHEME_QUERY); + const handleChange = (event: MediaQueryListEvent | MediaQueryList) => { + setTheme(event.matches ? 'night' : 'default'); + }; + + darkColorSchemeMedia.addEventListener('change', handleChange); + + if (!localStorage.getItem('theme')) { + handleChange(darkColorSchemeMedia); + } + + return () => darkColorSchemeMedia.removeEventListener('change', handleChange); + }, [setTheme]); + + const value = useMemo( + () => ({ + settings, + toggleSettings, + toggleOpenLinksInNewTab, + setTheme, + setFont, + setSpacing, + }), + [settings, toggleSettings, toggleOpenLinksInNewTab, setTheme, setFont, setSpacing] + ); + + return {children}; +} + +export function useSettings(): SettingsContextValue { + const context = useContext(SettingsContext); + if (!context) { + throw new Error('useSettings must be used within a SettingsProvider'); + } + return context; +}