diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 9f5a27d0e6..ef95e8d98d 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -46,9 +46,9 @@ import { init, SearchIndex } from 'emoji-mart'; import data from '@emoji-mart/data/sets/14/native.json'; import { humanId } from 'human-id'; import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx'; -import { useAppSettingsState } from './AppSettings'; import { Search } from 'stream-chat-react/experimental'; +import { useAppSettingsState } from './AppSettings/state.ts'; init({ data }); @@ -199,9 +199,17 @@ const CustomMessageReactions = (props: React.ComponentProps, +) => { + const state = useAppSettingsState(); + + return ; +}; + const App = () => { const { userId, tokenProvider } = useUser(); - const { chatView } = useAppSettingsState(); + const { chatView, theme } = useAppSettingsState(); const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []); const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []); @@ -288,11 +296,13 @@ const App = () => { if (!chatClient) return <>Loading...; + const chatTheme = theme.mode === 'dark' ? 'str-chat__theme-dark' : 'messaging light'; + return ( { searchController={searchController} client={chatClient} isMessageAIGenerated={isMessageAIGenerated} + theme={chatTheme} > diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index 333fa03052..64e8eb4d13 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -8,11 +8,6 @@ .app__settings-group_button { color: var(--text-secondary); - - svg { - height: 2rem; - width: 2rem; - } } } @@ -22,7 +17,9 @@ width: min(920px, 90vw); max-height: min(80vh, 760px); min-height: min(520px, 72vh); - background: #fff; + background: var(--background-elevation-elevation-2); + color: var(--text-primary); + border: 1px solid var(--border-core-default); border-radius: 14px; } @@ -33,7 +30,7 @@ padding: 16px 20px; font-size: 1.5rem; font-weight: 700; - border-bottom: 1px solid #dfe5ef; + border-bottom: 1px solid var(--border-core-default); svg.str-chat__icon--cog { height: 1.75rem; @@ -51,7 +48,7 @@ .app__settings-modal__tabs { overflow-y: auto; overscroll-behavior: contain; - border-right: 1px solid #dfe5ef; + border-right: 1px solid var(--border-core-default); padding: 10px; } @@ -61,12 +58,14 @@ justify-content: flex-start; font-weight: 500; margin-bottom: 6px; + color: var(--text-secondary); } .app__settings-modal__tab[aria-selected='true'], .app__settings-modal__tab.app__settings-modal__tab--active { - background: #e9f0ff; - border-color: #3167f6; + background: var(--background-core-selected); + border-color: var(--border-utility-selected); + color: var(--text-primary); font-weight: 600; } @@ -90,7 +89,7 @@ .app__settings-modal__field-label { font-weight: 600; - color: #2f3550; + color: var(--text-primary); } .app__settings-modal__options-row { @@ -100,8 +99,8 @@ } .app__settings-modal__option-button[aria-pressed='true'] { - border-color: #3167f6; - background: #e9f0ff; + border-color: var(--border-utility-selected); + background: var(--background-core-selected); font-weight: 600; } @@ -113,10 +112,10 @@ } .app__settings-modal__preview { - border: 1px solid var(--border); + border: 1px solid var(--border-core-default); border-radius: 12px; padding: 12px; - background: var(--bg-surface); + background: var(--background-core-surface); .str-chat__li--single { list-style: none; @@ -141,7 +140,7 @@ .app__settings-modal__tabs { border-right: 0; - border-bottom: 1px solid #dfe5ef; + border-bottom: 1px solid var(--border-core-default); display: flex; gap: 8px; padding: 10px 12px; diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx index eed3f3c06e..485d648246 100644 --- a/examples/vite/src/AppSettings/AppSettings.tsx +++ b/examples/vite/src/AppSettings/AppSettings.tsx @@ -4,11 +4,13 @@ import { GlobalModal, IconBubble3ChatMessage, IconEmojiSmile, + IconLightBulbSimple, IconSettingsGear2, } from 'stream-chat-react'; import { type ComponentType, useState } from 'react'; import { ReactionsTab } from './tabs/Reactions'; import { SidebarTab } from './tabs/Sidebar'; +import { appSettingsStore, useAppSettingsState } from './state'; type TabId = 'reactions' | 'sidebar'; @@ -17,13 +19,41 @@ const tabConfig: { Icon: ComponentType; id: TabId; title: string }[] = [ { Icon: IconEmojiSmile, id: 'reactions', title: 'Reactions' }, ]; +const SidebarThemeToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => { + const { + theme: { mode }, + } = useAppSettingsState(); + const nextMode = mode === 'dark' ? 'light' : 'dark'; + + return ( + + appSettingsStore.partialNext({ + theme: { mode: nextMode }, + }) + } + role='switch' + text={mode === 'dark' ? 'Dark mode' : 'Light mode'} + /> + ); +}; + export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { const [activeTab, setActiveTab] = useState('sidebar'); const [open, setOpen] = useState(false); return (
+ setOpen(true)} diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index f8b382e678..830903edff 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -11,11 +11,19 @@ export type ChatViewSettingsState = { iconOnly: boolean; }; +export type ThemeSettingsState = { + mode: 'dark' | 'light'; +}; + export type AppSettingsState = { chatView: ChatViewSettingsState; reactions: ReactionsSettingsState; + theme: ThemeSettingsState; }; +const themeStorageKey = 'stream-chat-react:example-theme-mode'; +const themeUrlParam = 'theme'; + const defaultAppSettingsState: AppSettingsState = { chatView: { iconOnly: true, @@ -25,10 +33,82 @@ const defaultAppSettingsState: AppSettingsState = { verticalPosition: 'top', visualStyle: 'clustered', }, + theme: { + mode: 'light', + }, +}; + +const getStoredThemeMode = (): ThemeSettingsState['mode'] | undefined => { + if (typeof window === 'undefined') return; + + let storedThemeMode: string | null = null; + + try { + storedThemeMode = window.localStorage.getItem(themeStorageKey); + } catch { + return; + } + + if (storedThemeMode === 'dark' || storedThemeMode === 'light') { + return storedThemeMode; + } }; -export const appSettingsStore = new StateStore(defaultAppSettingsState); +const getThemeModeFromUrl = (): ThemeSettingsState['mode'] | undefined => { + if (typeof window === 'undefined') return; + + const themeMode = new URLSearchParams(window.location.search).get(themeUrlParam); + + if (themeMode === 'dark' || themeMode === 'light') { + return themeMode; + } +}; + +const persistThemeMode = (themeMode: ThemeSettingsState['mode']) => { + if (typeof window === 'undefined') return; + + try { + window.localStorage.setItem(themeStorageKey, themeMode); + } catch { + // ignore persistence failures in environments where localStorage is unavailable + } +}; + +const persistThemeModeInUrl = (themeMode: ThemeSettingsState['mode']) => { + if (typeof window === 'undefined') return; + + const url = new URL(window.location.href); + + if (url.searchParams.get(themeUrlParam) === themeMode) return; + + url.searchParams.set(themeUrlParam, themeMode); + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}; + +const initialAppSettingsState: AppSettingsState = { + ...defaultAppSettingsState, + theme: { + ...defaultAppSettingsState.theme, + mode: + getThemeModeFromUrl() ?? getStoredThemeMode() ?? defaultAppSettingsState.theme.mode, + }, +}; + +export const appSettingsStore = new StateStore(initialAppSettingsState); + +appSettingsStore.subscribeWithSelector( + ({ theme }) => ({ mode: theme.mode }), + ({ mode }) => { + persistThemeMode(mode); + persistThemeModeInUrl(mode); + }, +); export const useAppSettingsState = () => useStateStore(appSettingsStore, (nextValue: AppSettingsState) => nextValue) ?? - defaultAppSettingsState; + initialAppSettingsState; diff --git a/src/components/Attachment/styling/ModalGallery.scss b/src/components/Attachment/styling/ModalGallery.scss index 45216303da..9f7205c783 100644 --- a/src/components/Attachment/styling/ModalGallery.scss +++ b/src/components/Attachment/styling/ModalGallery.scss @@ -4,7 +4,8 @@ --str-chat__modal-gallery-load-failed-indicator-background: var(--accent-error); --str-chat__modal-gallery-load-failed-indicator-color: var(--text-inverse); --str-chat__modal-gallery-loading-background: var(--chat-bg-incoming); - --str-chat__modal-gallery-loading-highlight: var(--base-white); + --str-chat__modal-gallery-loading-base: var(--skeleton-loading-base); + --str-chat__modal-gallery-loading-highlight: var(--skeleton-loading-highlight); } .str-chat__message--me { @@ -140,9 +141,9 @@ background-color: var(--str-chat__modal-gallery-loading-background); background-image: linear-gradient( 90deg, - rgba(255, 255, 255, 0) 0%, + var(--str-chat__modal-gallery-loading-base) 0%, var(--str-chat__modal-gallery-loading-highlight) 50%, - rgba(255, 255, 255, 0) 100% + var(--str-chat__modal-gallery-loading-base) 100% ); background-repeat: no-repeat; background-size: 200% 100%; diff --git a/src/components/Channel/styling/Channel.scss b/src/components/Channel/styling/Channel.scss index 8deae9d06c..e5b114bffb 100644 --- a/src/components/Channel/styling/Channel.scss +++ b/src/components/Channel/styling/Channel.scss @@ -206,8 +206,8 @@ /* The icon color used when no channel is selected */ --str-chat__channel-empty-indicator-color: var(--str-chat__disabled-color); - /* The color of the loading indicator */ - --str-chat__channel-loading-state-color: var(--slate-100); + /* The base surface color behind the loading shimmer */ + --str-chat__channel-loading-state-color: var(--background-core-surface); } .str-chat__channel { diff --git a/src/components/Chat/__tests__/Chat.test.js b/src/components/Chat/__tests__/Chat.test.js index 9fe2722f69..fcd3c355fc 100644 --- a/src/components/Chat/__tests__/Chat.test.js +++ b/src/components/Chat/__tests__/Chat.test.js @@ -70,7 +70,7 @@ describe('Chat', () => { }); it('props change should update the context', async () => { - const theme = 'team dark'; + const theme = 'str-chat__theme-dark'; let context; const { rerender } = render( @@ -86,7 +86,7 @@ describe('Chat', () => { expect(context.theme).toBe(theme); }); - const newTheme = 'messaging dark'; + const newTheme = 'str-chat__theme-dark custom-theme'; const newClient = getTestClient(); rerender( diff --git a/src/components/Icons/icons.tsx b/src/components/Icons/icons.tsx index 835cc0d94f..de59f9c66d 100644 --- a/src/components/Icons/icons.tsx +++ b/src/components/Icons/icons.tsx @@ -110,7 +110,7 @@ export const IconBookmark = createIcon( , @@ -364,14 +364,14 @@ export const IconCloseQuote2 = createIcon( diff --git a/src/components/Loading/LoadingIndicator.tsx b/src/components/Loading/LoadingIndicator.tsx index 92c9f85ced..976e858f5a 100644 --- a/src/components/Loading/LoadingIndicator.tsx +++ b/src/components/Loading/LoadingIndicator.tsx @@ -9,7 +9,9 @@ export type LoadingIndicatorProps = { }; const UnMemoizedLoadingIndicator = (props: LoadingIndicatorProps) => { - const { color = '#006CFF', size = 15 } = props; + const { color, size = 15 } = props; + const baseColor = 'var(--str-chat__loading-indicator-base-color)'; + const indicatorColor = color ?? 'var(--str-chat__loading-indicator-color)'; return (
{ > - + diff --git a/src/components/Loading/__tests__/__snapshots__/LoadingIndicator.test.js.snap b/src/components/Loading/__tests__/__snapshots__/LoadingIndicator.test.js.snap index 59793154ce..cc4f78d385 100644 --- a/src/components/Loading/__tests__/__snapshots__/LoadingIndicator.test.js.snap +++ b/src/components/Loading/__tests__/__snapshots__/LoadingIndicator.test.js.snap @@ -23,15 +23,14 @@ exports[`LoadingIndicator should render with default props 1`] = ` > diff --git a/src/components/Loading/styling/LoadingChannels.scss b/src/components/Loading/styling/LoadingChannels.scss index 4ae6bf967a..aa7a9de4bf 100644 --- a/src/components/Loading/styling/LoadingChannels.scss +++ b/src/components/Loading/styling/LoadingChannels.scss @@ -12,8 +12,8 @@ } .str-chat { - /* The color of the loading indicator while initializing the channel list */ - --str-chat__channel-preview-loading-state-color: var(--slate-100); + /* The base surface color behind the channel list loading shimmer */ + --str-chat__channel-preview-loading-state-color: var(--background-core-surface); } .str-chat__loading-channels { diff --git a/src/components/Loading/styling/LoadingIndicator.scss b/src/components/Loading/styling/LoadingIndicator.scss index 11597bdce5..10161a5ba2 100644 --- a/src/components/Loading/styling/LoadingIndicator.scss +++ b/src/components/Loading/styling/LoadingIndicator.scss @@ -1,8 +1,10 @@ .str-chat { /* The size of the loading indicator, only available in Angular v5+ */ --str-chat__loading-indicator-size: calc(var(--str-chat__spacing-px) * 15); - /* The color of the loading indicator */ - --str-chat__loading-indicator-color: var(--str-chat__primary-color); + /* The transparent edge of the loading indicator gradient */ + --str-chat__loading-indicator-base-color: var(--skeleton-loading-base); + /* The visible shimmer color of the loading indicator */ + --str-chat__loading-indicator-color: var(--skeleton-loading-highlight); } .str-chat__loading-indicator { @@ -14,11 +16,6 @@ svg { width: var(--str-chat__loading-indicator-size); height: var(--str-chat__loading-indicator-size); - linearGradient { - stop:last-child { - stop-color: var(--str-chat__loading-indicator-color); - } - } } @-webkit-keyframes rotate { diff --git a/src/components/MessageInput/MessageInputFlat.tsx b/src/components/MessageInput/MessageInputFlat.tsx index 7c3aeb7728..318cf9f019 100644 --- a/src/components/MessageInput/MessageInputFlat.tsx +++ b/src/components/MessageInput/MessageInputFlat.tsx @@ -135,21 +135,9 @@ export const MessageInputFlat = () => {
-
-
-
+
+
+
diff --git a/src/components/Modal/GlobalModal.tsx b/src/components/Modal/GlobalModal.tsx index 0c92f5ce00..ad10411865 100644 --- a/src/components/Modal/GlobalModal.tsx +++ b/src/components/Modal/GlobalModal.tsx @@ -10,7 +10,11 @@ import React, { } from 'react'; import { FocusScope } from '@react-aria/focus'; -import { ModalContextProvider, modalDialogManagerId } from '../../context'; +import { + ModalContextProvider, + modalDialogManagerId, + useChatContext, +} from '../../context'; import { DialogPortalEntry, modalDialogId, @@ -51,6 +55,7 @@ export const GlobalModal = ({ const overlayRef = useRef(null); const closeButtonRef = useRef(null); const closingRef = useRef(false); + const { theme } = useChatContext('GlobalModal'); const maybeClose = useCallback( (source: ModalCloseSource, event: ModalCloseEvent) => { @@ -109,7 +114,9 @@ export const GlobalModal = ({