diff --git a/client/src/App.jsx b/client/src/App.jsx index decad9392b..eda775bc71 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { RecoilRoot } from 'recoil'; import { DndProvider } from 'react-dnd'; import { RouterProvider } from 'react-router-dom'; @@ -8,6 +9,7 @@ import { Toast, ThemeProvider, ToastProvider } from '@librechat/client'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; import { getThemeFromEnv } from './utils/getThemeFromEnv'; +import { initializeFontSize } from '~/store/fontSize'; import { LiveAnnouncer } from '~/a11y'; import { router } from './routes'; @@ -24,6 +26,10 @@ const App = () => { }), }); + useEffect(() => { + initializeFontSize(); + }, []); + // Load theme from environment variables if available const envTheme = getThemeFromEnv(); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index a79f0985d9..a993009915 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -1,10 +1,12 @@ import React, { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import ContentParts from './Content/ContentParts'; +import { fontSizeAtom } from '~/store/fontSize'; import SiblingSwitch from './SiblingSwitch'; import MultiMessage from './MultiMessage'; import HoverButtons from './HoverButtons'; @@ -36,7 +38,7 @@ export default function Message(props: TMessageProps) { regenerateMessage, } = useMessageHelpers(props); - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const { children, messageId = null, isCreatedByUser } = message ?? {}; diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx index 01459203f0..bea6554ff1 100644 --- a/client/src/components/Chat/Messages/MessagesView.tsx +++ b/client/src/components/Chat/Messages/MessagesView.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { CSSTransition } from 'react-transition-group'; import type { TMessage } from 'librechat-data-provider'; import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks'; import ScrollToBottom from '~/components/Messages/ScrollToBottom'; import { MessagesViewProvider } from '~/Providers'; +import { fontSizeAtom } from '~/store/fontSize'; import MultiMessage from './MultiMessage'; import { cn } from '~/utils'; import store from '~/store'; @@ -15,7 +17,7 @@ function MessagesViewContent({ messagesTree?: TMessage[] | null; }) { const localize = useLocalize(); - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const { screenshotTargetRef } = useScreenshot(); const scrollButtonPreference = useRecoilValue(store.showScrollButton); const [currentEditId, setCurrentEditId] = useState(-1); diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx index c7ac2c69c3..982aee06ce 100644 --- a/client/src/components/Chat/Messages/SearchMessage.tsx +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { useAuthContext, useLocalize } from '~/hooks'; import type { TMessageProps, TMessageIcon } from '~/common'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import Icon from '~/components/Chat/Messages/MessageIcon'; import SearchContent from './Content/SearchContent'; +import { fontSizeAtom } from '~/store/fontSize'; import SearchButtons from './SearchButtons'; import SubRow from './SubRow'; import { cn } from '~/utils'; @@ -34,8 +36,8 @@ const MessageBody = ({ message, messageLabel, fontSize }) => ( ); export default function SearchMessage({ message }: Pick) { + const fontSize = useAtomValue(fontSizeAtom); const UsernameDisplay = useRecoilValue(store.UsernameDisplay); - const fontSize = useRecoilValue(store.fontSize); const { user } = useAuthContext(); const localize = useLocalize(); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index f056fccc98..179da5942d 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo, memo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { type TMessage } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; @@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import { Plugin } from '~/components/Messages/Content'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { MessageContext } from '~/Providers'; import { useMessageActions } from '~/hooks'; import { cn, logger } from '~/utils'; @@ -58,8 +60,8 @@ const MessageRender = memo( isMultiMessage, setCurrentEditId, }); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - const fontSize = useRecoilValue(store.fontSize); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const hasNoChildren = !(msg?.children?.length ?? 0); diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index ce88687d23..565dadde11 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -1,5 +1,6 @@ -import { useRecoilValue } from 'recoil'; import { useCallback, useMemo, memo } from 'react'; +import { useAtomValue } from 'jotai'; +import { useRecoilValue } from 'recoil'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; @@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import { useAttachments, useMessageActions } from '~/hooks'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { cn, logger } from '~/utils'; import store from '~/store'; @@ -60,8 +62,8 @@ const ContentRender = memo( isMultiMessage, setCurrentEditId, }); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - const fontSize = useRecoilValue(store.fontSize); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const isLast = useMemo( diff --git a/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx b/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx index 82fa2e746b..66b3f832ab 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx @@ -1,15 +1,14 @@ -import { useRecoilState } from 'recoil'; -import { Dropdown, applyFontSize } from '@librechat/client'; +import { useAtom } from 'jotai'; +import { Dropdown } from '@librechat/client'; +import { fontSizeAtom } from '~/store/fontSize'; import { useLocalize } from '~/hooks'; -import store from '~/store'; export default function FontSizeSelector() { - const [fontSize, setFontSize] = useRecoilState(store.fontSize); const localize = useLocalize(); + const [fontSize, setFontSize] = useAtom(fontSizeAtom); const handleChange = (val: string) => { setFontSize(val); - applyFontSize(val); }; const options = [ diff --git a/client/src/components/Share/Message.tsx b/client/src/components/Share/Message.tsx index eddd5060e5..e556145481 100644 --- a/client/src/components/Share/Message.tsx +++ b/client/src/components/Share/Message.tsx @@ -1,4 +1,4 @@ -import { useRecoilValue } from 'recoil'; +import { useAtomValue } from 'jotai'; import type { TMessageProps } from '~/common'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; @@ -6,16 +6,16 @@ import SearchContent from '~/components/Chat/Messages/Content/SearchContent'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import { Plugin } from '~/components/Messages/Content'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { MessageContext } from '~/Providers'; import { useAttachments } from '~/hooks'; import MultiMessage from './MultiMessage'; import { cn } from '~/utils'; -import store from '~/store'; import Icon from './MessageIcon'; export default function Message(props: TMessageProps) { - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const { message, siblingIdx, diff --git a/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx b/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx index a3d3b8d67d..e0dbac5a1e 100644 --- a/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx @@ -21,8 +21,8 @@ describe('useFocusChatEffect', () => { (useNavigate as jest.Mock).mockReturnValue(mockNavigate); // Mock window.matchMedia - window.matchMedia = jest.fn().mockImplementation(() => ({ - matches: false, + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query === '(hover: hover)', // Desktop has hover capability media: '', onchange: null, addListener: jest.fn(), @@ -83,8 +83,8 @@ describe('useFocusChatEffect', () => { }); test('should not focus textarea on touchscreen devices', () => { - window.matchMedia = jest.fn().mockImplementation(() => ({ - matches: true, // This indicates a touchscreen + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query === '(pointer: coarse)', // Touchscreen has coarse pointer media: '', onchange: null, addListener: jest.fn(), diff --git a/client/src/store/fontSize.ts b/client/src/store/fontSize.ts new file mode 100644 index 0000000000..4b1a0666f3 --- /dev/null +++ b/client/src/store/fontSize.ts @@ -0,0 +1,54 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { applyFontSize } from '@librechat/client'; + +const DEFAULT_FONT_SIZE = 'text-base'; + +/** + * Base storage atom for font size + */ +const fontSizeStorageAtom = atomWithStorage('fontSize', DEFAULT_FONT_SIZE, undefined, { + getOnInit: true, +}); + +/** + * Derived atom that applies font size changes to the DOM + * Read: returns the current font size + * Write: updates storage and applies the font size to the DOM + */ +export const fontSizeAtom = atom( + (get) => get(fontSizeStorageAtom), + (get, set, newValue: string) => { + set(fontSizeStorageAtom, newValue); + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + applyFontSize(newValue); + } + }, +); + +/** + * Initialize font size on app load + */ +export const initializeFontSize = () => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + const savedValue = localStorage.getItem('fontSize'); + + if (savedValue !== null) { + try { + const parsedValue = JSON.parse(savedValue); + applyFontSize(parsedValue); + } catch (error) { + console.error( + 'Error parsing localStorage key "fontSize", resetting to default. Error:', + error, + ); + localStorage.setItem('fontSize', JSON.stringify(DEFAULT_FONT_SIZE)); + applyFontSize(DEFAULT_FONT_SIZE); + } + } else { + applyFontSize(DEFAULT_FONT_SIZE); + } +}; diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index 0fe4dccd2c..4e9c2f5cad 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -21,7 +21,6 @@ const localStorageAtoms = { // General settings autoScroll: atomWithLocalStorage('autoScroll', false), hideSidePanel: atomWithLocalStorage('hideSidePanel', false), - fontSize: atomWithLocalStorage('fontSize', 'text-base'), enableUserMsgMarkdown: atomWithLocalStorage( LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN, true,