From 17a24147c942730c388233599bff25d715d8b745 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Sat, 14 Mar 2026 18:56:12 +0100 Subject: [PATCH 1/4] feat(vite-example): add resizable chat panels --- examples/vite/src/App.tsx | 341 +++------------------- examples/vite/src/AppSettings/state.ts | 120 ++++++++ examples/vite/src/ChatLayout/Panels.tsx | 111 +++++++ examples/vite/src/ChatLayout/Resize.tsx | 258 ++++++++++++++++ examples/vite/src/ChatLayout/Sync.tsx | 220 ++++++++++++++ examples/vite/src/ChatLayout/constants.ts | 2 + examples/vite/src/index.scss | 215 +++++++++++++- 7 files changed, 959 insertions(+), 308 deletions(-) create mode 100644 examples/vite/src/ChatLayout/Panels.tsx create mode 100644 examples/vite/src/ChatLayout/Resize.tsx create mode 100644 examples/vite/src/ChatLayout/Sync.tsx create mode 100644 examples/vite/src/ChatLayout/constants.ts diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index b33815c43a..79564356f7 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,55 +1,43 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { type CSSProperties, useCallback, useEffect, useMemo } from 'react'; import { ChannelFilters, ChannelOptions, ChannelSort, LocalMessage, TextComposerMiddleware, - type ThreadManagerState, - createCommandInjectionMiddleware, - createDraftCommandInjectionMiddleware, - createActiveCommandGuardMiddleware, - createCommandStringExtractionMiddleware, SearchController, ChannelSearchSource, UserSearchSource, + createActiveCommandGuardMiddleware, + createCommandInjectionMiddleware, + createCommandStringExtractionMiddleware, + createDraftCommandInjectionMiddleware, } from 'stream-chat'; import { - AIStateIndicator, - Channel, - ChannelAvatar, - ChannelHeader, - type ChatView as ChatViewType, - ChannelList, Chat, ChatView, - MessageInput, - Thread, - ThreadList, - useCreateChatClient, - // VirtualizedMessageList as MessageList, - MessageList, - Window, - WithComponents, ReactionsList, - WithDragAndDropUpload, - useChatContext, - useChatViewContext, - useThreadsViewContext, + WithComponents, defaultReactionOptions, - ReactionOptions, + type ReactionOptions, mapEmojiMartData, - useStateStore, - TypingIndicator, + useCreateChatClient, } from 'stream-chat-react'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; 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 { Search } from 'stream-chat-react/experimental'; import { useAppSettingsState } from './AppSettings/state.ts'; +import { DESKTOP_LAYOUT_BREAKPOINT } from './ChatLayout/constants.ts'; +import { ChannelsPanels, ThreadsPanels } from './ChatLayout/Panels.tsx'; +import { SidebarLayoutSync } from './ChatLayout/Resize.tsx'; +import { + ChatStateSync, + getSelectedChannelIdFromUrl, + getSelectedChatViewFromUrl, +} from './ChatLayout/Sync.tsx'; +import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx'; init({ data }); @@ -62,89 +50,19 @@ const parseUserIdFromToken = (token: string) => { }; const apiKey = import.meta.env.VITE_STREAM_API_KEY; -const selectedChannelUrlParam = 'channel'; -const selectedChatViewUrlParam = 'view'; -const selectedThreadUrlParam = 'thread'; const token = new URLSearchParams(window.location.search).get('token') || import.meta.env.VITE_USER_TOKEN; -const getSelectedChannelIdFromUrl = () => - new URLSearchParams(window.location.search).get(selectedChannelUrlParam); - -const getSelectedChatViewFromUrl = (): ChatViewType | undefined => { - const selectedChatView = new URLSearchParams(window.location.search).get( - selectedChatViewUrlParam, - ); - - if (selectedChatView === 'threads') return 'threads'; - if (selectedChatView === 'channels' || selectedChatView === 'chat') return 'channels'; - - return undefined; -}; - -const getSelectedThreadIdFromUrl = () => - new URLSearchParams(window.location.search).get(selectedThreadUrlParam); - -const updateSelectedChannelIdInUrl = (channelId?: string) => { - const url = new URL(window.location.href); - - if (channelId) { - url.searchParams.set(selectedChannelUrlParam, channelId); - } else { - url.searchParams.delete(selectedChannelUrlParam); - } - - window.history.replaceState( - window.history.state, - '', - `${url.pathname}${url.search}${url.hash}`, - ); -}; - -const updateSelectedChatViewInUrl = (chatView: ChatViewType) => { - const url = new URL(window.location.href); - - url.searchParams.set( - selectedChatViewUrlParam, - chatView === 'threads' ? 'threads' : 'chat', - ); - - window.history.replaceState( - window.history.state, - '', - `${url.pathname}${url.search}${url.hash}`, - ); -}; - -const updateSelectedThreadIdInUrl = (threadId?: string) => { - const url = new URL(window.location.href); - - if (threadId) { - url.searchParams.set(selectedThreadUrlParam, threadId); - } else { - url.searchParams.delete(selectedThreadUrlParam); - } - - window.history.replaceState( - window.history.state, - '', - `${url.pathname}${url.search}${url.hash}`, - ); -}; - if (!apiKey) { throw new Error('VITE_STREAM_API_KEY is not defined'); } const options: ChannelOptions = { - // limit: 10, - // message_limit: 10, - // member_limit: 10, presence: true, state: true, }; -// pinned_at param leads to BE not returning (empty) channels + const sort: ChannelSort = { last_message_at: -1, updated_at: -1 }; // @ts-ignore @@ -183,7 +101,7 @@ const useUser = () => { .then((data) => data.token as string); }, [userId]); - return { userId: userId, tokenProvider }; + return { tokenProvider, userId }; }; const CustomMessageReactions = (props: React.ComponentProps) => { @@ -203,14 +121,16 @@ const CustomMessageReactions = (props: React.ComponentProps, ) => { - const state = useAppSettingsState(); + const { + theme: { mode }, + } = useAppSettingsState(); - return ; + return ; }; const App = () => { - const { userId, tokenProvider } = useUser(); - const { chatView, theme } = useAppSettingsState(); + const { tokenProvider, userId } = useUser(); + const { chatView, panelLayout, theme } = useAppSettingsState(); const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []); const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []); @@ -219,6 +139,8 @@ const App = () => { tokenOrProvider: tokenProvider, userData: { id: userId }, }); + const useResponsiveInitialNav = + typeof window === 'undefined' || window.innerWidth < DESKTOP_LAYOUT_BREAKPOINT; const searchController = useMemo(() => { if (!chatClient) return undefined; @@ -295,9 +217,13 @@ const App = () => { }); }, [chatClient]); - if (!chatClient) return <>Loading...; - const chatTheme = theme.mode === 'dark' ? 'str-chat__theme-dark' : 'messaging light'; + const appLayoutStyle = { + '--app-left-panel-width': `${panelLayout.leftPanel.width}px`, + '--app-thread-panel-width': `${panelLayout.threadPanel.width}px`, + } as CSSProperties; + + if (!chatClient) return <>Loading...; return ( { - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + ); }; -const ChatStateSync = ({ initialChatView }: { initialChatView?: ChatViewType }) => { - const { activeChatView, setActiveChatView } = useChatViewContext(); - const { channel, client } = useChatContext(); - const previousSyncedChatView = useRef(undefined); - const previousChannelId = useRef(undefined); - - useEffect(() => { - if ( - initialChatView && - previousSyncedChatView.current === undefined && - activeChatView !== initialChatView - ) { - setActiveChatView(initialChatView); - return; - } - - if (previousSyncedChatView.current === activeChatView) return; - - previousSyncedChatView.current = activeChatView; - updateSelectedChatViewInUrl(activeChatView); - }, [activeChatView, initialChatView, setActiveChatView]); - - useEffect(() => { - if (channel?.id) { - previousChannelId.current = channel.id; - updateSelectedChannelIdInUrl(channel.id); - return; - } - - if (!previousChannelId.current) return; - - previousChannelId.current = undefined; - updateSelectedChannelIdInUrl(); - }, [channel?.id]); - - // @ts-expect-error expose client and channel for debugging purposes - window.client = client; - // @ts-expect-error expose client and channel for debugging purposes - window.channel = channel; - return null; -}; - -const threadManagerSelector = (nextValue: ThreadManagerState) => ({ - isLoading: nextValue.pagination.isLoading, - ready: nextValue.ready, - threads: nextValue.threads, -}); - -const ThreadStateSync = () => { - const selectedThreadId = useRef( - getSelectedThreadIdFromUrl() ?? undefined, - ); - const { client } = useChatContext(); - const { activeThread, setActiveThread } = useThreadsViewContext(); - const { isLoading, ready, threads } = useStateStore( - client.threads.state, - threadManagerSelector, - ) ?? { - isLoading: false, - ready: false, - threads: [], - }; - const isRestoringThread = useRef(false); - const previousThreadId = useRef(undefined); - const attemptedThreadLookup = useRef(false); - - useEffect(() => { - if (activeThread?.id) { - selectedThreadId.current = activeThread.id; - previousThreadId.current = activeThread.id; - attemptedThreadLookup.current = false; - updateSelectedThreadIdInUrl(activeThread.id); - return; - } - - if (!previousThreadId.current) return; - - selectedThreadId.current = undefined; - previousThreadId.current = undefined; - attemptedThreadLookup.current = false; - updateSelectedThreadIdInUrl(); - }, [activeThread?.id]); - - useEffect(() => { - const threadIdToRestore = selectedThreadId.current; - - if (!threadIdToRestore) return; - - // If the user just picked another thread, let that selection win and let the - // URL-sync effect above update the restore target before we try to restore again. - if (activeThread?.id && activeThread.id !== threadIdToRestore) { - return; - } - - const matchingThreadFromList = threads.find( - (thread) => thread.id === threadIdToRestore, - ); - - if (matchingThreadFromList && activeThread !== matchingThreadFromList) { - setActiveThread(matchingThreadFromList); - return; - } - - if ( - matchingThreadFromList || - activeThread?.id === threadIdToRestore || - isRestoringThread.current || - attemptedThreadLookup.current || - isLoading || - !ready - ) { - return; - } - - let cancelled = false; - - attemptedThreadLookup.current = true; - isRestoringThread.current = true; - - client - .getThread(threadIdToRestore) - .then((thread) => { - if (!thread || cancelled) return; - - setActiveThread(thread); - }) - .catch(() => undefined) - .finally(() => { - if (cancelled) return; - - isRestoringThread.current = false; - }); - - return () => { - cancelled = true; - }; - }, [activeThread, client, isLoading, ready, setActiveThread, threads]); - - return null; -}; - export default App; diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 830903edff..7a1329f216 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -15,19 +15,60 @@ export type ThemeSettingsState = { mode: 'dark' | 'light'; }; +export const LEFT_PANEL_MIN_WIDTH = 360; +export const THREAD_PANEL_MIN_WIDTH = 360; + +export type LeftPanelLayoutSettingsState = { + collapsed: boolean; + previousWidth: number; + width: number; +}; + +export type ThreadPanelLayoutSettingsState = { + width: number; +}; + +export type PanelLayoutSettingsState = { + leftPanel: LeftPanelLayoutSettingsState; + threadPanel: ThreadPanelLayoutSettingsState; +}; + export type AppSettingsState = { chatView: ChatViewSettingsState; + panelLayout: PanelLayoutSettingsState; reactions: ReactionsSettingsState; theme: ThemeSettingsState; }; +const panelLayoutStorageKey = 'stream-chat-react:example-panel-layout'; const themeStorageKey = 'stream-chat-react:example-theme-mode'; const themeUrlParam = 'theme'; +const clamp = (value: number, min: number, max?: number) => { + const minClampedValue = Math.max(min, value); + + if (typeof max !== 'number') return minClampedValue; + + return Math.min(max, minClampedValue); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + const defaultAppSettingsState: AppSettingsState = { chatView: { iconOnly: true, }, + panelLayout: { + leftPanel: { + collapsed: false, + previousWidth: LEFT_PANEL_MIN_WIDTH, + width: LEFT_PANEL_MIN_WIDTH, + }, + threadPanel: { + width: THREAD_PANEL_MIN_WIDTH, + }, + }, reactions: { flipHorizontalPosition: false, verticalPosition: 'top', @@ -54,6 +95,59 @@ const getStoredThemeMode = (): ThemeSettingsState['mode'] | undefined => { } }; +const normalizePanelLayoutSettings = ( + value: unknown, +): PanelLayoutSettingsState | undefined => { + if (!isRecord(value)) return; + + const leftPanel = isRecord(value.leftPanel) ? value.leftPanel : undefined; + const threadPanel = isRecord(value.threadPanel) ? value.threadPanel : undefined; + + const leftPanelWidth = clamp( + typeof leftPanel?.width === 'number' + ? leftPanel.width + : defaultAppSettingsState.panelLayout.leftPanel.width, + LEFT_PANEL_MIN_WIDTH, + ); + const leftPanelPreviousWidth = clamp( + typeof leftPanel?.previousWidth === 'number' + ? leftPanel.previousWidth + : leftPanelWidth, + LEFT_PANEL_MIN_WIDTH, + ); + const threadPanelWidth = clamp( + typeof threadPanel?.width === 'number' + ? threadPanel.width + : defaultAppSettingsState.panelLayout.threadPanel.width, + THREAD_PANEL_MIN_WIDTH, + ); + + return { + leftPanel: { + collapsed: leftPanel?.collapsed === true, + previousWidth: leftPanelPreviousWidth, + width: leftPanelWidth, + }, + threadPanel: { + width: threadPanelWidth, + }, + }; +}; + +const getStoredPanelLayoutSettings = (): PanelLayoutSettingsState | undefined => { + if (typeof window === 'undefined') return; + + try { + const storedPanelLayout = window.localStorage.getItem(panelLayoutStorageKey); + + if (!storedPanelLayout) return; + + return normalizePanelLayoutSettings(JSON.parse(storedPanelLayout)); + } catch { + return; + } +}; + const getThemeModeFromUrl = (): ThemeSettingsState['mode'] | undefined => { if (typeof window === 'undefined') return; @@ -74,6 +168,16 @@ const persistThemeMode = (themeMode: ThemeSettingsState['mode']) => { } }; +const persistPanelLayoutSettings = (panelLayout: PanelLayoutSettingsState) => { + if (typeof window === 'undefined') return; + + try { + window.localStorage.setItem(panelLayoutStorageKey, JSON.stringify(panelLayout)); + } catch { + // ignore persistence failures in environments where localStorage is unavailable + } +}; + const persistThemeModeInUrl = (themeMode: ThemeSettingsState['mode']) => { if (typeof window === 'undefined') return; @@ -92,6 +196,7 @@ const persistThemeModeInUrl = (themeMode: ThemeSettingsState['mode']) => { const initialAppSettingsState: AppSettingsState = { ...defaultAppSettingsState, + panelLayout: getStoredPanelLayoutSettings() ?? defaultAppSettingsState.panelLayout, theme: { ...defaultAppSettingsState.theme, mode: @@ -109,6 +214,21 @@ appSettingsStore.subscribeWithSelector( }, ); +appSettingsStore.subscribeWithSelector( + ({ panelLayout }) => panelLayout, + (panelLayout) => { + persistPanelLayoutSettings(panelLayout); + }, +); + +export const updatePanelLayoutSettings = ( + updater: (panelLayout: PanelLayoutSettingsState) => PanelLayoutSettingsState, +) => { + appSettingsStore.partialNext({ + panelLayout: updater(appSettingsStore.getLatestValue().panelLayout), + }); +}; + export const useAppSettingsState = () => useStateStore(appSettingsStore, (nextValue: AppSettingsState) => nextValue) ?? initialAppSettingsState; diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx new file mode 100644 index 0000000000..5a5bb6b5ee --- /dev/null +++ b/examples/vite/src/ChatLayout/Panels.tsx @@ -0,0 +1,111 @@ +import clsx from 'clsx'; +import type { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat'; +import { useRef } from 'react'; +import { + AIStateIndicator, + Channel, + ChannelAvatar, + ChannelHeader, + ChannelList, + ChatView, + MessageInput, + MessageList, + Thread, + ThreadList, + TypingIndicator, + Window, + WithComponents, + WithDragAndDropUpload, + useChatContext, +} from 'stream-chat-react'; +import { Search } from 'stream-chat-react/experimental'; + +import { SidebarResizeHandle, ThreadResizeHandle } from './Resize.tsx'; +import { ThreadStateSync } from './Sync.tsx'; + +export const ChannelsPanels = ({ + filters, + initialChannelId, + options, + sort, +}: { + filters: ChannelFilters; + initialChannelId?: string; + options: ChannelOptions; + sort: ChannelSort; +}) => { + const { navOpen = true } = useChatContext('ChannelsPanels'); + const channelsLayoutRef = useRef(null); + + return ( + +
+ + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export const ThreadsPanels = () => { + const { navOpen = true } = useChatContext('ThreadsPanels'); + const threadsLayoutRef = useRef(null); + + return ( + + +
+ + +
+ + + + + + + +
+
+
+ ); +}; diff --git a/examples/vite/src/ChatLayout/Resize.tsx b/examples/vite/src/ChatLayout/Resize.tsx new file mode 100644 index 0000000000..c9676a49f7 --- /dev/null +++ b/examples/vite/src/ChatLayout/Resize.tsx @@ -0,0 +1,258 @@ +import clsx from 'clsx'; +import { + type PointerEvent as ReactPointerEvent, + type RefObject, + useCallback, + useEffect, + useRef, +} from 'react'; +import { useChannelStateContext, useChatContext } from 'stream-chat-react'; + +import { + LEFT_PANEL_MIN_WIDTH, + THREAD_PANEL_MIN_WIDTH, + updatePanelLayoutSettings, + useAppSettingsState, +} from '../AppSettings/state.ts'; +import { DESKTOP_LAYOUT_BREAKPOINT, MESSAGE_VIEW_MIN_WIDTH } from './constants.ts'; + +const clamp = (value: number, min: number, max?: number) => { + const safeMax = typeof max === 'number' ? Math.max(min, max) : undefined; + const minClampedValue = Math.max(value, min); + + if (typeof safeMax !== 'number') return minClampedValue; + + return Math.min(minClampedValue, safeMax); +}; + +const beginHorizontalResize = ({ + bodyClassName, + handle, + onMove, + onStop, + pointerId, +}: { + bodyClassName?: string; + handle: HTMLDivElement; + onMove: (event: PointerEvent) => void; + onStop?: () => void; + pointerId: number; +}) => { + const originalCursor = document.body.style.cursor; + const originalUserSelect = document.body.style.userSelect; + + const stopResize = () => { + document.body.style.cursor = originalCursor; + document.body.style.userSelect = originalUserSelect; + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', stopResize); + window.removeEventListener('pointercancel', stopResize); + + if (handle.hasPointerCapture(pointerId)) { + handle.releasePointerCapture(pointerId); + } + + if (bodyClassName) { + document.body.classList.remove(bodyClassName); + } + + onStop?.(); + }; + + const handlePointerMove = (event: PointerEvent) => { + onMove(event); + }; + + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + if (bodyClassName) { + document.body.classList.add(bodyClassName); + } + handle.setPointerCapture(pointerId); + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', stopResize); + window.addEventListener('pointercancel', stopResize); +}; + +const PanelResizeHandle = ({ + className, + onPointerDown, +}: { + className?: string; + onPointerDown: (event: ReactPointerEvent) => void; +}) => ( +
+
+
+
+
+); + +export const SidebarLayoutSync = () => { + const { navOpen = true } = useChatContext(); + const { + panelLayout: { leftPanel }, + } = useAppSettingsState(); + + useEffect(() => { + if (typeof window === 'undefined' || window.innerWidth < DESKTOP_LAYOUT_BREAKPOINT) { + return; + } + + const shouldBeCollapsed = !navOpen; + + if (shouldBeCollapsed === leftPanel.collapsed) return; + + updatePanelLayoutSettings((panelLayout) => ({ + ...panelLayout, + leftPanel: { + ...panelLayout.leftPanel, + collapsed: shouldBeCollapsed, + }, + })); + }, [leftPanel.collapsed, navOpen]); + + return null; +}; + +export const SidebarResizeHandle = ({ + layoutRef, +}: { + layoutRef: RefObject; +}) => { + const { closeMobileNav, openMobileNav } = useChatContext('SidebarResizeHandle'); + const { + panelLayout: { leftPanel }, + } = useAppSettingsState(); + const isSidebarCollapsedRef = useRef(leftPanel.collapsed); + + useEffect(() => { + isSidebarCollapsedRef.current = leftPanel.collapsed; + }, [leftPanel.collapsed]); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + + event.preventDefault(); + document.body.dataset.appSidebarResizeState = leftPanel.collapsed + ? 'collapsed' + : 'expanded'; + + beginHorizontalResize({ + bodyClassName: 'app-chat-resizing-sidebar', + handle: event.currentTarget, + onMove: (pointerEvent) => { + const layoutBounds = layoutRef.current?.getBoundingClientRect(); + + if (!layoutBounds) return; + + const nextWidth = pointerEvent.clientX - layoutBounds.left; + const maxWidth = layoutBounds.width - MESSAGE_VIEW_MIN_WIDTH; + const shouldCollapse = nextWidth < LEFT_PANEL_MIN_WIDTH; + + document.body.dataset.appSidebarResizeState = shouldCollapse + ? 'collapsed' + : 'expanded'; + + if (shouldCollapse !== isSidebarCollapsedRef.current) { + isSidebarCollapsedRef.current = shouldCollapse; + + if (shouldCollapse) { + closeMobileNav(); + } else { + openMobileNav(); + } + } + + updatePanelLayoutSettings((panelLayout) => { + if (shouldCollapse) { + return { + ...panelLayout, + leftPanel: { + ...panelLayout.leftPanel, + collapsed: true, + previousWidth: panelLayout.leftPanel.collapsed + ? panelLayout.leftPanel.previousWidth + : panelLayout.leftPanel.width, + }, + }; + } + + const clampedWidth = clamp(nextWidth, LEFT_PANEL_MIN_WIDTH, maxWidth); + const expandedWidth = panelLayout.leftPanel.collapsed + ? Math.max(panelLayout.leftPanel.previousWidth, clampedWidth) + : clampedWidth; + + return { + ...panelLayout, + leftPanel: { + collapsed: false, + previousWidth: expandedWidth, + width: expandedWidth, + }, + }; + }); + }, + onStop: () => { + delete document.body.dataset.appSidebarResizeState; + }, + pointerId: event.pointerId, + }); + }, + [closeMobileNav, layoutRef, leftPanel.collapsed, openMobileNav], + ); + + return ( + + ); +}; + +export const ThreadResizeHandle = () => { + const { thread } = useChannelStateContext('ThreadResizeHandle'); + + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) return; + + const container = event.currentTarget.parentElement; + + if (!container) return; + + event.preventDefault(); + + beginHorizontalResize({ + bodyClassName: 'app-chat-resizing-thread', + handle: event.currentTarget, + onMove: (pointerEvent) => { + const containerBounds = container.getBoundingClientRect(); + const nextWidth = containerBounds.right - pointerEvent.clientX; + const maxWidth = containerBounds.width - MESSAGE_VIEW_MIN_WIDTH; + + updatePanelLayoutSettings((panelLayout) => ({ + ...panelLayout, + threadPanel: { + width: clamp(nextWidth, THREAD_PANEL_MIN_WIDTH, maxWidth), + }, + })); + }, + pointerId: event.pointerId, + }); + }, []); + + if (!thread) return null; + + return ( + + ); +}; diff --git a/examples/vite/src/ChatLayout/Sync.tsx b/examples/vite/src/ChatLayout/Sync.tsx new file mode 100644 index 0000000000..823ea8b184 --- /dev/null +++ b/examples/vite/src/ChatLayout/Sync.tsx @@ -0,0 +1,220 @@ +import { useEffect, useRef } from 'react'; +import type { ThreadManagerState } from 'stream-chat'; +import { + type ChatView as ChatViewType, + useChatContext, + useChatViewContext, + useThreadsViewContext, + useStateStore, +} from 'stream-chat-react'; + +const selectedChannelUrlParam = 'channel'; +const selectedChatViewUrlParam = 'view'; +const selectedThreadUrlParam = 'thread'; + +export const getSelectedChannelIdFromUrl = () => + new URLSearchParams(window.location.search).get(selectedChannelUrlParam); + +export const getSelectedChatViewFromUrl = (): ChatViewType | undefined => { + const selectedChatView = new URLSearchParams(window.location.search).get( + selectedChatViewUrlParam, + ); + + if (selectedChatView === 'threads') return 'threads'; + if (selectedChatView === 'channels' || selectedChatView === 'chat') return 'channels'; + + return undefined; +}; + +const getSelectedThreadIdFromUrl = () => + new URLSearchParams(window.location.search).get(selectedThreadUrlParam); + +const updateSelectedChannelIdInUrl = (channelId?: string) => { + const url = new URL(window.location.href); + + if (channelId) { + url.searchParams.set(selectedChannelUrlParam, channelId); + } else { + url.searchParams.delete(selectedChannelUrlParam); + } + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}; + +const updateSelectedChatViewInUrl = (chatView: ChatViewType) => { + const url = new URL(window.location.href); + + url.searchParams.set( + selectedChatViewUrlParam, + chatView === 'threads' ? 'threads' : 'chat', + ); + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}; + +const updateSelectedThreadIdInUrl = (threadId?: string) => { + const url = new URL(window.location.href); + + if (threadId) { + url.searchParams.set(selectedThreadUrlParam, threadId); + } else { + url.searchParams.delete(selectedThreadUrlParam); + } + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}; + +export const ChatStateSync = ({ + initialChatView, +}: { + initialChatView?: ChatViewType; +}) => { + const { activeChatView, setActiveChatView } = useChatViewContext(); + const { channel, client } = useChatContext(); + const previousSyncedChatView = useRef(undefined); + const previousChannelId = useRef(undefined); + + useEffect(() => { + if ( + initialChatView && + previousSyncedChatView.current === undefined && + activeChatView !== initialChatView + ) { + setActiveChatView(initialChatView); + return; + } + + if (previousSyncedChatView.current === activeChatView) return; + + previousSyncedChatView.current = activeChatView; + updateSelectedChatViewInUrl(activeChatView); + }, [activeChatView, initialChatView, setActiveChatView]); + + useEffect(() => { + if (channel?.id) { + previousChannelId.current = channel.id; + updateSelectedChannelIdInUrl(channel.id); + return; + } + + if (!previousChannelId.current) return; + + previousChannelId.current = undefined; + updateSelectedChannelIdInUrl(); + }, [channel?.id]); + + // @ts-expect-error expose client and channel for debugging purposes + window.client = client; + // @ts-expect-error expose client and channel for debugging purposes + window.channel = channel; + return null; +}; + +const threadManagerSelector = (nextValue: ThreadManagerState) => ({ + isLoading: nextValue.pagination.isLoading, + ready: nextValue.ready, + threads: nextValue.threads, +}); + +export const ThreadStateSync = () => { + const selectedThreadId = useRef( + getSelectedThreadIdFromUrl() ?? undefined, + ); + const { client } = useChatContext(); + const { activeThread, setActiveThread } = useThreadsViewContext(); + const { isLoading, ready, threads } = useStateStore( + client.threads.state, + threadManagerSelector, + ) ?? { + isLoading: false, + ready: false, + threads: [], + }; + const isRestoringThread = useRef(false); + const previousThreadId = useRef(undefined); + const attemptedThreadLookup = useRef(false); + + useEffect(() => { + if (activeThread?.id) { + selectedThreadId.current = activeThread.id; + previousThreadId.current = activeThread.id; + attemptedThreadLookup.current = false; + updateSelectedThreadIdInUrl(activeThread.id); + return; + } + + if (!previousThreadId.current) return; + + selectedThreadId.current = undefined; + previousThreadId.current = undefined; + attemptedThreadLookup.current = false; + updateSelectedThreadIdInUrl(); + }, [activeThread?.id]); + + useEffect(() => { + const threadIdToRestore = selectedThreadId.current; + + if (!threadIdToRestore) return; + + if (activeThread?.id && activeThread.id !== threadIdToRestore) { + return; + } + + const matchingThreadFromList = threads.find( + (thread) => thread.id === threadIdToRestore, + ); + + if (matchingThreadFromList && activeThread !== matchingThreadFromList) { + setActiveThread(matchingThreadFromList); + return; + } + + if ( + matchingThreadFromList || + activeThread?.id === threadIdToRestore || + isRestoringThread.current || + attemptedThreadLookup.current || + isLoading || + !ready + ) { + return; + } + + let cancelled = false; + + attemptedThreadLookup.current = true; + isRestoringThread.current = true; + + client + .getThread(threadIdToRestore) + .then((thread) => { + if (!thread || cancelled) return; + + setActiveThread(thread); + }) + .catch(() => undefined) + .finally(() => { + if (cancelled) return; + + isRestoringThread.current = false; + }); + + return () => { + cancelled = true; + }; + }, [activeThread, client, isLoading, ready, setActiveThread, threads]); + + return null; +}; diff --git a/examples/vite/src/ChatLayout/constants.ts b/examples/vite/src/ChatLayout/constants.ts new file mode 100644 index 0000000000..682295db10 --- /dev/null +++ b/examples/vite/src/ChatLayout/constants.ts @@ -0,0 +1,2 @@ +export const DESKTOP_LAYOUT_BREAKPOINT = 768; +export const MESSAGE_VIEW_MIN_WIDTH = 360; diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 413518adec..f44406ffbd 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -24,15 +24,44 @@ body { } @layer stream-overrides { - .str-chat { - --app-chat-view-sidebar-width: 350px; - --str-chat__channel-list-width: var(--app-chat-view-sidebar-width); - --str-chat__thread-list-width: var(--app-chat-view-sidebar-width); + .app-chat-layout { + --app-left-panel-width: 360px; + --app-thread-panel-width: 360px; height: 100%; width: 100%; } + .app-chat-layout .str-chat { + --str-chat__channel-list-width: var(--app-left-panel-width); + --str-chat__thread-list-width: var(--app-left-panel-width); + } + + body.app-chat-resizing-sidebar[data-app-sidebar-resize-state='expanded'] + .str-chat__channel-list, + body.app-chat-resizing-sidebar[data-app-sidebar-resize-state='expanded'] + .str-chat__thread-list-container, + body.app-chat-resizing-thread + .str-chat__chat-view__channels + .str-chat__container + > .str-chat__dropzone-root--thread, + body.app-chat-resizing-thread + .str-chat__chat-view__channels + .str-chat__container + > .str-chat__thread-container, + body.app-chat-resizing-thread + .str-chat__chat-view__channels + .str-chat__container + > .str-chat__dropzone-root--thread + .str-chat__thread-container { + transition: none !important; + } + + .str-chat { + height: 100%; + width: 100%; + } + .str-chat__chat-view, .str-chat__chat-view-channels, .str-chat__channel, @@ -45,6 +74,61 @@ body { container-type: inline-size; } + .app-chat-view__channels-layout, + .app-chat-view__threads-layout { + display: flex; + flex: 1 1 auto; + min-width: 0; + height: 100%; + position: relative; + } + + .app-chat-view__channels-layout > .str-chat__channel, + .app-chat-view__threads-main { + display: flex; + flex: 1 1 auto; + min-width: 0; + height: 100%; + } + + .app-chat-view__threads-main > * { + flex: 1 1 auto; + min-width: 0; + height: 100%; + } + + .app-chat-resize-handle { + position: relative; + flex: 0 0 1px; + width: 1px; + min-width: 1px; + height: 100%; + z-index: 2; + } + + .app-chat-resize-handle__hitbox { + position: absolute; + inset-block: 0; + inset-inline-start: 50%; + transform: translateX(-50%); + width: 12px; + display: flex; + justify-content: center; + cursor: col-resize; + touch-action: none; + transition: background-color 0.15s ease; + + &:hover { + background: rgb(0 0 0 / 0.06); + } + } + + .app-chat-resize-handle__line { + width: 1px; + height: 100%; + background: var(--str-chat__surface-color); + } + .str-chat__chat-view-channels { height: 100%; gap: 0; @@ -109,34 +193,135 @@ body { max-width: none; } - /* Desktop (≥768px): in channel view only, thread fixed 360px next to channel. */ + @media (max-width: 767px) { + .app-chat-resize-handle { + display: none; + } + } + + /* Desktop (≥768px): persisted sidebar widths and resizable channel thread. */ @media (min-width: 768px) { - .str-chat__chat-view__channels .str-chat__container:has(.str-chat__thread-container) { + .str-chat__chat-view + > .str-chat__chat-view__selector.str-chat__chat-view__selector--nav-closed { + flex: 0 0 auto; + width: auto; + min-width: auto; + overflow: visible; + padding: var(--spacing-md); + gap: var(--spacing-xs); + border-inline-end: 1px solid var(--str-chat-selector-border-color); + } + + body[data-app-sidebar-resize-state='expanded'] + .app-chat-view__channels-layout + .str-chat__channel-list { + flex: 0 0 var(--str-chat__channel-list-width); + width: var(--str-chat__channel-list-width); + min-width: var(--str-chat__channel-list-width); + max-width: var(--str-chat__channel-list-width); + opacity: 1; + overflow: hidden; + pointer-events: auto; + transform: translateX(0); + } + + body[data-app-sidebar-resize-state='expanded'] + .app-chat-view__threads-layout + .str-chat__thread-list-container { + flex: 0 0 var(--str-chat__thread-list-width); + width: var(--str-chat__thread-list-width); + min-width: var(--str-chat__thread-list-width); + max-width: var(--str-chat__thread-list-width); + opacity: 1; + overflow: hidden; + pointer-events: auto; + transform: translateX(0); + } + + .app-chat-view__channels-layout:not( + .app-chat-view__channels-layout--sidebar-collapsed + ) + .str-chat__channel-list { + flex: 0 0 var(--str-chat__channel-list-width); + width: var(--str-chat__channel-list-width); + min-width: var(--str-chat__channel-list-width); + max-width: var(--str-chat__channel-list-width); + opacity: 1; + overflow: hidden; + pointer-events: auto; + transform: translateX(0); + } + + .app-chat-view__threads-layout:not(.app-chat-view__threads-layout--sidebar-collapsed) + .str-chat__thread-list-container { + flex: 0 0 var(--str-chat__thread-list-width); + width: var(--str-chat__thread-list-width); + min-width: var(--str-chat__thread-list-width); + max-width: var(--str-chat__thread-list-width); + opacity: 1; + overflow: hidden; + pointer-events: auto; + transform: translateX(0); + } + + .app-chat-view__channels-layout--sidebar-collapsed .str-chat__channel-list, + .app-chat-view__threads-layout--sidebar-collapsed .str-chat__thread-list-container { + flex: 0 0 0; + width: 0; + min-width: 0; + max-width: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + transform: translateX( + calc(0px - var(--str-chat__channel-list-transition-offset, 8px)) + ); + } + + .str-chat__chat-view__channels + .str-chat__container:has(.app-chat-resize-handle--thread) { > .str-chat__main-panel, > .str-chat__dropzone-root:not(.str-chat__dropzone-root--thread) { flex: 1 1 auto; min-width: 0; } - > .str-chat__thread-container, - > .str-chat__dropzone-root--thread { - flex: 0 0 360px; - width: 360px; - max-width: 360px; + > .app-chat-resize-handle--thread + .str-chat__thread-container, + > .app-chat-resize-handle--thread + .str-chat__dropzone-root--thread:not(:empty) { + flex: 0 0 var(--app-thread-panel-width); + width: var(--app-thread-panel-width); + max-width: var(--app-thread-panel-width); + min-width: 0; } } - .str-chat__chat-view__channels .str-chat__container .str-chat__dropzone-root--thread, - .str-chat__chat-view__channels .str-chat__container .str-chat__thread-container { + .str-chat__chat-view__channels + .str-chat__container:has(.app-chat-resize-handle--thread) + > .app-chat-resize-handle--thread + + .str-chat__dropzone-root--thread + .str-chat__thread-container, + .str-chat__chat-view__channels + .str-chat__container:has(.app-chat-resize-handle--thread) + > .app-chat-resize-handle--thread + + .str-chat__thread-container { width: 100%; - max-width: 360px; + max-width: none; } } @container (max-width: 860px) { + .app-chat-resize-handle--thread { + display: none; + } + + .str-chat__chat-view__channels + .str-chat__container:has(.app-chat-resize-handle--thread) + > .app-chat-resize-handle--thread + + .str-chat__dropzone-root--thread:not(:empty), .str-chat__thread-container { + flex: 1 1 auto; width: 100%; - max-width: initial; + max-width: none; } } } From efea27fdb21f74d5400253b501f3bf9950919fea Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Sat, 14 Mar 2026 19:25:57 +0100 Subject: [PATCH 2/4] fix(vite-example): animate thread panel via layout shell --- examples/vite/src/ChatLayout/Panels.tsx | 24 ++++++-- examples/vite/src/ChatLayout/Resize.tsx | 61 +++++++++---------- examples/vite/src/index.scss | 78 +++++++++++++++++++++++-- 3 files changed, 124 insertions(+), 39 deletions(-) diff --git a/examples/vite/src/ChatLayout/Panels.tsx b/examples/vite/src/ChatLayout/Panels.tsx index 5a5bb6b5ee..491d62c3a9 100644 --- a/examples/vite/src/ChatLayout/Panels.tsx +++ b/examples/vite/src/ChatLayout/Panels.tsx @@ -16,6 +16,7 @@ import { Window, WithComponents, WithDragAndDropUpload, + useChannelStateContext, useChatContext, } from 'stream-chat-react'; import { Search } from 'stream-chat-react/experimental'; @@ -23,6 +24,24 @@ import { Search } from 'stream-chat-react/experimental'; import { SidebarResizeHandle, ThreadResizeHandle } from './Resize.tsx'; import { ThreadStateSync } from './Sync.tsx'; +const ChannelThreadPanel = () => { + const { thread } = useChannelStateContext('ChannelThreadPanel'); + const isOpen = !!thread; + + return ( + <> + + + + + + ); +}; + export const ChannelsPanels = ({ filters, initialChannelId, @@ -70,10 +89,7 @@ export const ChannelsPanels = ({ /> - - - - +
diff --git a/examples/vite/src/ChatLayout/Resize.tsx b/examples/vite/src/ChatLayout/Resize.tsx index c9676a49f7..d3d8720a18 100644 --- a/examples/vite/src/ChatLayout/Resize.tsx +++ b/examples/vite/src/ChatLayout/Resize.tsx @@ -6,7 +6,7 @@ import { useEffect, useRef, } from 'react'; -import { useChannelStateContext, useChatContext } from 'stream-chat-react'; +import { useChatContext } from 'stream-chat-react'; import { LEFT_PANEL_MIN_WIDTH, @@ -216,42 +216,43 @@ export const SidebarResizeHandle = ({ ); }; -export const ThreadResizeHandle = () => { - const { thread } = useChannelStateContext('ThreadResizeHandle'); - - const handlePointerDown = useCallback((event: ReactPointerEvent) => { - if (event.button !== 0) return; - - const container = event.currentTarget.parentElement; - - if (!container) return; +export const ThreadResizeHandle = ({ isOpen }: { isOpen: boolean }) => { + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0 || !isOpen) return; - event.preventDefault(); + const container = event.currentTarget.parentElement; - beginHorizontalResize({ - bodyClassName: 'app-chat-resizing-thread', - handle: event.currentTarget, - onMove: (pointerEvent) => { - const containerBounds = container.getBoundingClientRect(); - const nextWidth = containerBounds.right - pointerEvent.clientX; - const maxWidth = containerBounds.width - MESSAGE_VIEW_MIN_WIDTH; + if (!container) return; - updatePanelLayoutSettings((panelLayout) => ({ - ...panelLayout, - threadPanel: { - width: clamp(nextWidth, THREAD_PANEL_MIN_WIDTH, maxWidth), - }, - })); - }, - pointerId: event.pointerId, - }); - }, []); + event.preventDefault(); - if (!thread) return null; + beginHorizontalResize({ + bodyClassName: 'app-chat-resizing-thread', + handle: event.currentTarget, + onMove: (pointerEvent) => { + const containerBounds = container.getBoundingClientRect(); + const nextWidth = containerBounds.right - pointerEvent.clientX; + const maxWidth = containerBounds.width - MESSAGE_VIEW_MIN_WIDTH; + + updatePanelLayoutSettings((panelLayout) => ({ + ...panelLayout, + threadPanel: { + width: clamp(nextWidth, THREAD_PANEL_MIN_WIDTH, maxWidth), + }, + })); + }, + pointerId: event.pointerId, + }); + }, + [isOpen], + ); return ( ); diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index f44406ffbd..281af3a046 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -41,6 +41,8 @@ body { .str-chat__channel-list, body.app-chat-resizing-sidebar[data-app-sidebar-resize-state='expanded'] .str-chat__thread-list-container, + body.app-chat-resizing-thread .app-chat-resize-handle--thread, + body.app-chat-resizing-thread .app-chat-thread-panel, body.app-chat-resizing-thread .str-chat__chat-view__channels .str-chat__container @@ -129,6 +131,35 @@ body { background: var(--str-chat__surface-color); } + .app-chat-resize-handle--thread { + transition: + flex-basis var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease), + width var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease), + min-width var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease), + opacity var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease); + } + + .app-chat-resize-handle--thread-hidden { + flex: 0 0 0; + width: 0; + min-width: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + } + + .app-chat-thread-panel { + background: var(--str-chat__secondary-background-color); + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + } + .str-chat__chat-view-channels { height: 100%; gap: 0; @@ -280,31 +311,68 @@ body { .str-chat__chat-view__channels .str-chat__container:has(.app-chat-resize-handle--thread) { + overflow-x: hidden; + > .str-chat__main-panel, > .str-chat__dropzone-root:not(.str-chat__dropzone-root--thread) { flex: 1 1 auto; min-width: 0; } - > .app-chat-resize-handle--thread + .str-chat__thread-container, - > .app-chat-resize-handle--thread + .str-chat__dropzone-root--thread:not(:empty) { + > .app-chat-resize-handle--thread + .app-chat-thread-panel { + flex: 0 0 0; + width: 0; + min-width: 0; + max-width: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; + transform: translateX(var(--str-chat__channel-list-transition-offset, 8px)); + transition: + flex-basis var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease), + min-width var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease), + width var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease), + max-width var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease), + opacity var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease), + transform var(--str-chat__channel-list-transition-duration, 180ms) + var(--str-chat__channel-list-transition-easing, ease); + } + + > .app-chat-resize-handle--thread + + .app-chat-thread-panel.app-chat-thread-panel--open { flex: 0 0 var(--app-thread-panel-width); width: var(--app-thread-panel-width); max-width: var(--app-thread-panel-width); min-width: 0; + opacity: 1; + pointer-events: auto; + transform: translateX(0); } } .str-chat__chat-view__channels .str-chat__container:has(.app-chat-resize-handle--thread) > .app-chat-resize-handle--thread - + .str-chat__dropzone-root--thread + + .app-chat-thread-panel + .str-chat__dropzone-root--thread, + .str-chat__chat-view__channels + .str-chat__container:has(.app-chat-resize-handle--thread) + > .app-chat-resize-handle--thread + + .app-chat-thread-panel .str-chat__thread-container, .str-chat__chat-view__channels .str-chat__container:has(.app-chat-resize-handle--thread) > .app-chat-resize-handle--thread - + .str-chat__thread-container { + + .app-chat-thread-panel + .str-chat__dropzone-root--thread + .str-chat__thread-container { width: 100%; + height: 100%; max-width: none; } } @@ -317,7 +385,7 @@ body { .str-chat__chat-view__channels .str-chat__container:has(.app-chat-resize-handle--thread) > .app-chat-resize-handle--thread - + .str-chat__dropzone-root--thread:not(:empty), + + .app-chat-thread-panel.app-chat-thread-panel--open, .str-chat__thread-container { flex: 1 1 auto; width: 100%; From fe71893289317ddcc48f719dd819b3a75e3217a4 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Sat, 14 Mar 2026 19:44:10 +0100 Subject: [PATCH 3/4] perf(vite-example): optimize panel resize updates --- examples/vite/src/App.tsx | 45 +++--- examples/vite/src/AppSettings/state.ts | 6 +- examples/vite/src/ChatLayout/Resize.tsx | 180 +++++++++++++++++------- 3 files changed, 165 insertions(+), 66 deletions(-) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 79564356f7..a1208dc8bd 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, useCallback, useEffect, useMemo } from 'react'; +import { type CSSProperties, useCallback, useEffect, useMemo, useRef } from 'react'; import { ChannelFilters, ChannelOptions, @@ -28,10 +28,10 @@ import { init, SearchIndex } from 'emoji-mart'; import data from '@emoji-mart/data/sets/14/native.json'; import { humanId } from 'human-id'; -import { useAppSettingsState } from './AppSettings/state.ts'; +import { appSettingsStore, useAppSettingsSelector } from './AppSettings/state.ts'; import { DESKTOP_LAYOUT_BREAKPOINT } from './ChatLayout/constants.ts'; import { ChannelsPanels, ThreadsPanels } from './ChatLayout/Panels.tsx'; -import { SidebarLayoutSync } from './ChatLayout/Resize.tsx'; +import { PanelLayoutStyleSync, SidebarLayoutSync } from './ChatLayout/Resize.tsx'; import { ChatStateSync, getSelectedChannelIdFromUrl, @@ -105,8 +105,8 @@ const useUser = () => { }; const CustomMessageReactions = (props: React.ComponentProps) => { - const { reactions } = useAppSettingsState(); - const { visualStyle, verticalPosition, flipHorizontalPosition } = reactions; + const { visualStyle, verticalPosition, flipHorizontalPosition } = + useAppSettingsSelector((state) => state.reactions); return ( , ) => { - const { - theme: { mode }, - } = useAppSettingsState(); + const mode = useAppSettingsSelector((state) => state.theme.mode); return ; }; const App = () => { const { tokenProvider, userId } = useUser(); - const { chatView, panelLayout, theme } = useAppSettingsState(); + const chatView = useAppSettingsSelector((state) => state.chatView); + const themeMode = useAppSettingsSelector((state) => state.theme.mode); const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []); const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []); + const initialPanelLayout = useMemo( + () => appSettingsStore.getLatestValue().panelLayout, + [], + ); + const initialNavOpen = useMemo( + () => !initialPanelLayout.leftPanel.collapsed, + [initialPanelLayout.leftPanel.collapsed], + ); + const appLayoutRef = useRef(null); const chatClient = useCreateChatClient({ apiKey, @@ -217,11 +225,15 @@ const App = () => { }); }, [chatClient]); - const chatTheme = theme.mode === 'dark' ? 'str-chat__theme-dark' : 'messaging light'; - const appLayoutStyle = { - '--app-left-panel-width': `${panelLayout.leftPanel.width}px`, - '--app-thread-panel-width': `${panelLayout.threadPanel.width}px`, - } as CSSProperties; + const chatTheme = themeMode === 'dark' ? 'str-chat__theme-dark' : 'messaging light'; + const initialAppLayoutStyle = useMemo( + () => + ({ + '--app-left-panel-width': `${initialPanelLayout.leftPanel.width}px`, + '--app-thread-panel-width': `${initialPanelLayout.threadPanel.width}px`, + }) as CSSProperties, + [initialPanelLayout.leftPanel.width, initialPanelLayout.threadPanel.width], + ); if (!chatClient) return <>Loading...; @@ -237,12 +249,13 @@ const App = () => { -
+
+ diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 7a1329f216..7205a771d2 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -229,6 +229,8 @@ export const updatePanelLayoutSettings = ( }); }; +export const useAppSettingsSelector = (selector: (state: AppSettingsState) => T): T => + useStateStore(appSettingsStore, selector) ?? selector(initialAppSettingsState); + export const useAppSettingsState = () => - useStateStore(appSettingsStore, (nextValue: AppSettingsState) => nextValue) ?? - initialAppSettingsState; + useAppSettingsSelector((nextValue: AppSettingsState) => nextValue); diff --git a/examples/vite/src/ChatLayout/Resize.tsx b/examples/vite/src/ChatLayout/Resize.tsx index d3d8720a18..0ca82a24b0 100644 --- a/examples/vite/src/ChatLayout/Resize.tsx +++ b/examples/vite/src/ChatLayout/Resize.tsx @@ -9,10 +9,11 @@ import { import { useChatContext } from 'stream-chat-react'; import { + type LeftPanelLayoutSettingsState, LEFT_PANEL_MIN_WIDTH, THREAD_PANEL_MIN_WIDTH, updatePanelLayoutSettings, - useAppSettingsState, + useAppSettingsSelector, } from '../AppSettings/state.ts'; import { DESKTOP_LAYOUT_BREAKPOINT, MESSAGE_VIEW_MIN_WIDTH } from './constants.ts'; @@ -74,6 +75,47 @@ const beginHorizontalResize = ({ window.addEventListener('pointercancel', stopResize); }; +const getAppLayoutElement = (element?: HTMLElement | null) => { + const appLayout = element?.closest('.app-chat-layout'); + + return appLayout instanceof HTMLDivElement ? appLayout : null; +}; + +const setPanelWidthCssVariable = ( + appLayoutElement: HTMLDivElement, + cssVariableName: '--app-left-panel-width' | '--app-thread-panel-width', + width: number, +) => { + appLayoutElement.style.setProperty(cssVariableName, `${width}px`); +}; + +export const PanelLayoutStyleSync = ({ + layoutRef, +}: { + layoutRef: RefObject; +}) => { + const panelLayout = useAppSettingsSelector((state) => state.panelLayout); + + useEffect(() => { + const layoutElement = layoutRef.current; + + if (!layoutElement) return; + + setPanelWidthCssVariable( + layoutElement, + '--app-left-panel-width', + panelLayout.leftPanel.width, + ); + setPanelWidthCssVariable( + layoutElement, + '--app-thread-panel-width', + panelLayout.threadPanel.width, + ); + }, [layoutRef, panelLayout]); + + return null; +}; + const PanelResizeHandle = ({ className, onPointerDown, @@ -95,18 +137,20 @@ const PanelResizeHandle = ({ export const SidebarLayoutSync = () => { const { navOpen = true } = useChatContext(); - const { - panelLayout: { leftPanel }, - } = useAppSettingsState(); + const leftPanelCollapsed = useAppSettingsSelector( + (state) => state.panelLayout.leftPanel.collapsed, + ); useEffect(() => { if (typeof window === 'undefined' || window.innerWidth < DESKTOP_LAYOUT_BREAKPOINT) { return; } + if (document.body.classList.contains('app-chat-resizing-sidebar')) return; + const shouldBeCollapsed = !navOpen; - if (shouldBeCollapsed === leftPanel.collapsed) return; + if (shouldBeCollapsed === leftPanelCollapsed) return; updatePanelLayoutSettings((panelLayout) => ({ ...panelLayout, @@ -115,7 +159,7 @@ export const SidebarLayoutSync = () => { collapsed: shouldBeCollapsed, }, })); - }, [leftPanel.collapsed, navOpen]); + }, [leftPanelCollapsed, navOpen]); return null; }; @@ -126,21 +170,31 @@ export const SidebarResizeHandle = ({ layoutRef: RefObject; }) => { const { closeMobileNav, openMobileNav } = useChatContext('SidebarResizeHandle'); - const { - panelLayout: { leftPanel }, - } = useAppSettingsState(); + const leftPanel = useAppSettingsSelector((state) => state.panelLayout.leftPanel); const isSidebarCollapsedRef = useRef(leftPanel.collapsed); + const leftPanelStateRef = useRef(leftPanel); useEffect(() => { isSidebarCollapsedRef.current = leftPanel.collapsed; - }, [leftPanel.collapsed]); + leftPanelStateRef.current = leftPanel; + }, [leftPanel]); const handlePointerDown = useCallback( (event: ReactPointerEvent) => { if (event.button !== 0) return; + const layoutElement = layoutRef.current; + const appLayoutElement = getAppLayoutElement(layoutElement); + const layoutBounds = layoutElement?.getBoundingClientRect(); + + if (!layoutBounds || !appLayoutElement) return; + event.preventDefault(); - document.body.dataset.appSidebarResizeState = leftPanel.collapsed + const dragState: LeftPanelLayoutSettingsState = { + ...leftPanelStateRef.current, + }; + + document.body.dataset.appSidebarResizeState = dragState.collapsed ? 'collapsed' : 'expanded'; @@ -148,10 +202,6 @@ export const SidebarResizeHandle = ({ bodyClassName: 'app-chat-resizing-sidebar', handle: event.currentTarget, onMove: (pointerEvent) => { - const layoutBounds = layoutRef.current?.getBoundingClientRect(); - - if (!layoutBounds) return; - const nextWidth = pointerEvent.clientX - layoutBounds.left; const maxWidth = layoutBounds.width - MESSAGE_VIEW_MIN_WIDTH; const shouldCollapse = nextWidth < LEFT_PANEL_MIN_WIDTH; @@ -160,6 +210,28 @@ export const SidebarResizeHandle = ({ ? 'collapsed' : 'expanded'; + if (shouldCollapse) { + if (!dragState.collapsed) { + dragState.previousWidth = dragState.width; + dragState.collapsed = true; + } + } else { + const clampedWidth = clamp(nextWidth, LEFT_PANEL_MIN_WIDTH, maxWidth); + const expandedWidth = dragState.collapsed + ? Math.max(dragState.previousWidth, clampedWidth) + : clampedWidth; + + dragState.collapsed = false; + dragState.previousWidth = expandedWidth; + dragState.width = expandedWidth; + + setPanelWidthCssVariable( + appLayoutElement, + '--app-left-panel-width', + expandedWidth, + ); + } + if (shouldCollapse !== isSidebarCollapsedRef.current) { isSidebarCollapsedRef.current = shouldCollapse; @@ -169,43 +241,31 @@ export const SidebarResizeHandle = ({ openMobileNav(); } } - - updatePanelLayoutSettings((panelLayout) => { - if (shouldCollapse) { - return { - ...panelLayout, - leftPanel: { - ...panelLayout.leftPanel, - collapsed: true, - previousWidth: panelLayout.leftPanel.collapsed - ? panelLayout.leftPanel.previousWidth - : panelLayout.leftPanel.width, - }, - }; - } - - const clampedWidth = clamp(nextWidth, LEFT_PANEL_MIN_WIDTH, maxWidth); - const expandedWidth = panelLayout.leftPanel.collapsed - ? Math.max(panelLayout.leftPanel.previousWidth, clampedWidth) - : clampedWidth; - - return { - ...panelLayout, - leftPanel: { - collapsed: false, - previousWidth: expandedWidth, - width: expandedWidth, - }, - }; - }); }, onStop: () => { delete document.body.dataset.appSidebarResizeState; + + const previousPanelState = leftPanelStateRef.current; + + leftPanelStateRef.current = dragState; + + if ( + previousPanelState.collapsed === dragState.collapsed && + previousPanelState.previousWidth === dragState.previousWidth && + previousPanelState.width === dragState.width + ) { + return; + } + + updatePanelLayoutSettings((panelLayout) => ({ + ...panelLayout, + leftPanel: dragState, + })); }, pointerId: event.pointerId, }); }, - [closeMobileNav, layoutRef, leftPanel.collapsed, openMobileNav], + [closeMobileNav, layoutRef, openMobileNav], ); return ( @@ -217,28 +277,52 @@ export const SidebarResizeHandle = ({ }; export const ThreadResizeHandle = ({ isOpen }: { isOpen: boolean }) => { + const threadPanelWidth = useAppSettingsSelector( + (state) => state.panelLayout.threadPanel.width, + ); + const threadPanelWidthRef = useRef(threadPanelWidth); + + useEffect(() => { + threadPanelWidthRef.current = threadPanelWidth; + }, [threadPanelWidth]); + const handlePointerDown = useCallback( (event: ReactPointerEvent) => { if (event.button !== 0 || !isOpen) return; const container = event.currentTarget.parentElement; + const appLayoutElement = getAppLayoutElement(container); + const containerBounds = container?.getBoundingClientRect(); - if (!container) return; + if (!container || !containerBounds || !appLayoutElement) return; event.preventDefault(); + const dragState = { + width: threadPanelWidthRef.current, + }; beginHorizontalResize({ bodyClassName: 'app-chat-resizing-thread', handle: event.currentTarget, onMove: (pointerEvent) => { - const containerBounds = container.getBoundingClientRect(); const nextWidth = containerBounds.right - pointerEvent.clientX; const maxWidth = containerBounds.width - MESSAGE_VIEW_MIN_WIDTH; + const width = clamp(nextWidth, THREAD_PANEL_MIN_WIDTH, maxWidth); + + dragState.width = width; + setPanelWidthCssVariable(appLayoutElement, '--app-thread-panel-width', width); + }, + onStop: () => { + const previousWidth = threadPanelWidthRef.current; + + threadPanelWidthRef.current = dragState.width; + + if (previousWidth === dragState.width) return; updatePanelLayoutSettings((panelLayout) => ({ ...panelLayout, threadPanel: { - width: clamp(nextWidth, THREAD_PANEL_MIN_WIDTH, maxWidth), + width: dragState.width, }, })); }, From 27f18e5e20c54760abcb987b8ebdd75428288d59 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 16 Mar 2026 12:56:03 +0100 Subject: [PATCH 4/4] fix(vite-example): resolve example type mismatches --- examples/vite/src/App.tsx | 4 ++-- examples/vite/src/AppSettings/state.ts | 7 +++++-- examples/vite/src/ChatLayout/Resize.tsx | 8 ++++---- examples/vite/tsconfig.json | 5 +++++ examples/vite/vite.config.ts | 7 +++++++ 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index a1208dc8bd..64557acd5d 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -121,7 +121,7 @@ const CustomMessageReactions = (props: React.ComponentProps, ) => { - const mode = useAppSettingsSelector((state) => state.theme.mode); + const { mode } = useAppSettingsSelector((state) => state.theme); return ; }; @@ -129,7 +129,7 @@ const EmojiPickerWithCustomOptions = ( const App = () => { const { tokenProvider, userId } = useUser(); const chatView = useAppSettingsSelector((state) => state.chatView); - const themeMode = useAppSettingsSelector((state) => state.theme.mode); + const { mode: themeMode } = useAppSettingsSelector((state) => state.theme); const initialChannelId = useMemo(() => getSelectedChannelIdFromUrl(), []); const initialChatView = useMemo(() => getSelectedChatViewFromUrl(), []); const initialPanelLayout = useMemo( diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 7205a771d2..4f0ab4c07a 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -229,8 +229,11 @@ export const updatePanelLayoutSettings = ( }); }; -export const useAppSettingsSelector = (selector: (state: AppSettingsState) => T): T => - useStateStore(appSettingsStore, selector) ?? selector(initialAppSettingsState); +export const useAppSettingsSelector = < + T extends Readonly | Readonly>, +>( + selector: (state: AppSettingsState) => T, +): T => useStateStore(appSettingsStore, selector) ?? selector(initialAppSettingsState); export const useAppSettingsState = () => useAppSettingsSelector((nextValue: AppSettingsState) => nextValue); diff --git a/examples/vite/src/ChatLayout/Resize.tsx b/examples/vite/src/ChatLayout/Resize.tsx index 0ca82a24b0..7b7a347f90 100644 --- a/examples/vite/src/ChatLayout/Resize.tsx +++ b/examples/vite/src/ChatLayout/Resize.tsx @@ -137,8 +137,8 @@ const PanelResizeHandle = ({ export const SidebarLayoutSync = () => { const { navOpen = true } = useChatContext(); - const leftPanelCollapsed = useAppSettingsSelector( - (state) => state.panelLayout.leftPanel.collapsed, + const { collapsed: leftPanelCollapsed } = useAppSettingsSelector( + (state) => state.panelLayout.leftPanel, ); useEffect(() => { @@ -277,8 +277,8 @@ export const SidebarResizeHandle = ({ }; export const ThreadResizeHandle = ({ isOpen }: { isOpen: boolean }) => { - const threadPanelWidth = useAppSettingsSelector( - (state) => state.panelLayout.threadPanel.width, + const { width: threadPanelWidth } = useAppSettingsSelector( + (state) => state.panelLayout.threadPanel, ); const threadPanelWidthRef = useRef(threadPanelWidth); diff --git a/examples/vite/tsconfig.json b/examples/vite/tsconfig.json index c4431280c3..55b9fbade8 100644 --- a/examples/vite/tsconfig.json +++ b/examples/vite/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "baseUrl": ".", "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], @@ -12,6 +13,10 @@ "noEmit": true, "jsx": "react-jsx", "allowImportingTsExtensions": true, + "paths": { + "stream-chat": ["../../node_modules/stream-chat"], + "stream-chat/*": ["../../node_modules/stream-chat/*"] + }, "noFallthroughCasesInSwitch": true, "noUnusedLocals": false, "noUnusedParameters": false diff --git a/examples/vite/vite.config.ts b/examples/vite/vite.config.ts index 55d4f8167d..cd86a5e913 100644 --- a/examples/vite/vite.config.ts +++ b/examples/vite/vite.config.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import { defineConfig, loadEnv } from 'vite'; import babel from 'vite-plugin-babel'; import react from '@vitejs/plugin-react'; @@ -5,6 +6,7 @@ import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig(() => { const rootDir = process.cwd(); + const streamChatPath = path.resolve(rootDir, '../../node_modules/stream-chat'); // Load shared .env file const env = loadEnv('', rootDir, ''); @@ -20,5 +22,10 @@ export default defineConfig(() => { define: { 'process.env': env, // need `process.env` access }, + resolve: { + alias: { + 'stream-chat': streamChatPath, + }, + }, }; });