From 5b7ee5b4733eeb2c0b563235fd1714a8f163b02c Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 30 Sep 2026 15:39:36 +0200 Subject: [PATCH 1/3] fix(MessageList): align unread separator with jump target and keep page scroll System messages count as unread (the server includes them in unread_messages) and jumpToFirstUnreadMessage can target one, but the unread separator skipped them and was rendered below the system message. The separator is now placed before the first unread message regardless of its type, in both MessageList and VirtualizedMessageList, so it always matches the jump target. Jumping to a highlighted message (first unread, quoted message) used Element.scrollIntoView, which also scrolls every scrollable ancestor. When the chat is embedded in a scrollable host page, the page moved. The list is now scrolled directly with scrollTo, centering the target. Co-Authored-By: Claude Opus 5.5 --- src/components/MessageList/MessageList.tsx | 34 ++++++++---- .../VirtualizedMessageListComponents.tsx | 28 +++++++--- .../__tests__/MessageList.test.tsx | 55 ++++++++++++++++++- .../VirtualizedMessageListComponents.test.tsx | 32 +++++++++++ src/components/MessageList/renderMessages.tsx | 47 +++++++++------- 5 files changed, 152 insertions(+), 44 deletions(-) diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index b04a690f21..7f24eb928d 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -65,6 +65,21 @@ const getMessageSetSignature = (messages: LocalMessage[]) => const getMessageTimestamp = (message?: LocalMessage) => message?.created_at?.getTime?.() ?? null; +/** + * The list scrollTop that centers the element inside the list. Scrolling the list directly, + * rather than via Element.scrollIntoView, keeps the host page and other ancestors in place. + */ +const getCenteredScrollTop = (listElement: HTMLElement, element: Element) => { + const elementRect = element.getBoundingClientRect(); + const listRect = listElement.getBoundingClientRect(); + return Math.max( + listElement.scrollTop + + (elementRect.top - listRect.top) - + (listElement.clientHeight - elementRect.height) / 2, + 0, + ); +}; + const MessageListWithContext = (props: MessageListWithContextProps) => { const { channel, @@ -339,7 +354,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { const element = listElement?.querySelector( `[data-message-id='${highlightedMessageId}']`, ); - if (!element) { + if (!listElement || !element) { setHighlightedJumpPhase('waiting-for-render'); return; } @@ -350,25 +365,20 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { let settleTimeoutId: ReturnType | undefined; const animationFrameId = requestAnimationFrame(() => { - element.scrollIntoView({ + listElement.scrollTo?.({ behavior: scrollBehavior, - block: 'center', + top: getCenteredScrollTop(listElement, element), }); - if (!messageSetChanged || !listElement?.scrollTo) { + if (!messageSetChanged || !listElement.scrollTo) { setHighlightedJumpPhase('idle'); return; } + // Re-center once the smooth scroll settles, in case layout shifted while it ran + // (e.g. images of the freshly loaded page). settleTimeoutId = setTimeout(() => { - const elementRect = element.getBoundingClientRect(); - const listRect = listElement.getBoundingClientRect(); - const targetTop = - listElement.scrollTop + - (elementRect.top - listRect.top) - - (listElement.clientHeight - elementRect.height) / 2; - - listElement.scrollTo({ top: Math.max(targetTop, 0) }); + listElement.scrollTo({ top: getCenteredScrollTop(listElement, element) }); setHighlightedJumpPhase('idle'); }, 500); }); diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index 0e273dac8f..8e3809ef38 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -153,10 +153,8 @@ export const messageRenderer = ( ) : null; } - if (message.type === 'system') { - return MessageSystem ? : null; - } - + // System messages count as unread (the server includes them in unread_messages), so the + // separator can precede one - the same message jumpToFirstUnreadMessage scrolls to. const isFirstUnreadMessage = getIsFirstUnreadMessage({ firstUnreadMessageId, isFirstMessage: streamMessageIndex === 0, @@ -167,13 +165,25 @@ export const messageRenderer = ( unreadMessageCount, }); + const unreadMessagesSeparator = isFirstUnreadMessage && ( +
+ +
+ ); + + if (message.type === 'system') { + if (!MessageSystem) return unreadMessagesSeparator || null; + return ( + <> + {unreadMessagesSeparator} + + + ); + } + return ( <> - {isFirstUnreadMessage && ( -
- -
- )} + {unreadMessagesSeparator} { markReadSpy.mockRestore(); }); + it('should display unread messages separator before a system message that follows the last read message', async () => { + const user = generateUser(); + const messages = Array.from({ length: 5 }).map((_, i) => + generateMessage({ + created_at: new Date(i + 1000).toISOString(), + ...(i === 3 ? { text: 'member was added', type: 'system' } : {}), + }), + ); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [ + { + messages, + read: [ + { + last_read: new Date(messages[2].created_at).toISOString(), + last_read_message_id: messages[2].id, + unread_messages: 2, + user, + }, + ], + }, + ], + customUser: user, + }); + + // @ts-expect-error - mock implementation has simplified signature + const markReadSpy = vi.spyOn(channel, 'markRead').mockResolvedValue(false); + + await act(() => { + renderComponent({ + channelProps: { channel }, + chatClient: client, + msgListProps: { disableDateSeparator: true, messages }, + }); + }); + + const separators = screen.queryAllByTestId(UNREAD_MESSAGES_SEPARATOR_TEST_ID); + expect(separators).toHaveLength(1); + const separatorListItem = separators[0].closest('li'); + expect(separatorListItem?.nextElementSibling).toHaveAttribute( + 'data-message-id', + messages[3].id, + ); + markReadSpy.mockRestore(); + }); + it('should not display unread messages separator in read main msg list', async () => { const user = generateUser(); const messages = Array.from({ length: 5 }).map((_, i) => @@ -1245,9 +1294,11 @@ describe('MessageList', () => { expect(screen.getByText('target-2')).toBeInTheDocument(); }); - expect(scrollIntoViewMock).toHaveBeenCalledWith({ + // Only the list scrolls; scrollIntoView would also scroll the host page. + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'smooth', - block: 'center', + top: expect.any(Number), }); requestAnimationFrameSpy.mockRestore(); diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx index 4db910c2fe..3134733d45 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.tsx @@ -509,6 +509,38 @@ describe('VirtualizedMessageComponents', () => { expect(container.querySelector('.message-component')).toBeInTheDocument(); }); + it('should be rendered above a system message that follows the last read message', async () => { + const systemMessage = generateMessage({ + created_at: new Date(4).toISOString(), + id: '3', + type: 'system', + }); + const MessageSystem = () =>
; + const { container } = await renderMarkUnread({ + virtuosoContext: { + lastReadDate: new Date(messages[1].created_at), + lastReadMessageId: messages[1].id, + lastReceivedMessageId: systemMessage.id, + Message, + messageGroupStyles: {}, + MessageSystem, + numItemsPrepended: 1, + ownMessagesDeliveredToOthers: {}, + ownMessagesReadByOthers: {}, + processedMessages: [...messages, systemMessage], + unreadMessageCount: 1, + UnreadMessagesSeparator, + virtuosoRef: fromPartial({ current: {} }), + }, + virtuosoIndex: PREPEND_OFFSET + 1, + }); + const separator = container.querySelector( + '.str-chat__unread-messages-separator-wrapper', + ); + expect(separator).toBeInTheDocument(); + expect(separator?.nextElementSibling).toHaveClass('message-system-component'); + }); + it('should not be rendered below the last read message if the message is the newest in the channel', async () => { const { container } = await renderMarkUnread({ virtuosoContext: { diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx index 5b1a41036b..61b6f764b5 100644 --- a/src/components/MessageList/renderMessages.tsx +++ b/src/components/MessageList/renderMessages.tsx @@ -92,24 +92,13 @@ export function defaultRenderMessages({ , ); } - } else if (message.type === 'system') { - renderedMessages.push( - - - , - ); } else { if (!firstMessage) { firstMessage = message; } - const groupStyles: GroupStyle = messageGroupStyles[message.id] || ''; - const messageClass = - customClasses?.message || `str-chat__li str-chat__li--${groupStyles}`; + // System messages count as unread (the server includes them in unread_messages), so the + // separator can precede one - the same message jumpToFirstUnreadMessage scrolls to. const isFirstUnreadMessage = getIsFirstUnreadMessage({ firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, isFirstMessage: !!firstMessage?.id && firstMessage.id === message.id, @@ -119,16 +108,33 @@ export function defaultRenderMessages({ previousMessage, unreadMessageCount: channelUnreadUiState?.unread_messages, }); + previousMessage = message; + + const unreadMessagesSeparator = isFirstUnreadMessage && UnreadMessagesSeparator && ( + + + + ); + + if (message.type === 'system') { + renderedMessages.push( + + {unreadMessagesSeparator} + + + + , + ); + continue; + } + + const groupStyles: GroupStyle = messageGroupStyles[message.id] || ''; + const messageClass = + customClasses?.message || `str-chat__li str-chat__li--${groupStyles}`; renderedMessages.push( - {isFirstUnreadMessage && UnreadMessagesSeparator && ( - - - - )} + {unreadMessagesSeparator} , ); - previousMessage = message; } } return renderedMessages; From 42cd9b48124b4081fad9343567894d5027c07312 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 30 Sep 2026 15:39:53 +0200 Subject: [PATCH 2/3] chore(examples): add embedded page layout setting to the vite example Settings > General > Page layout > "Embedded in page" (or ?embedded_layout=1) renders the chat as a fixed-height widget between host-page content taller than the viewport, so the window scrolls too. Any scroll the SDK performs on an ancestor then shows up as the host page moving, which is how customers embedding the chat experience it. Co-Authored-By: Claude Opus 5.5 --- examples/vite/src/App.tsx | 67 ++++++++++--------- examples/vite/src/AppSettings/state.ts | 63 +++++++++++++++++ .../AppSettings/tabs/General/GeneralTab.tsx | 29 ++++++++ .../vite/src/ChatLayout/EmbeddedHostPage.tsx | 42 ++++++++++++ examples/vite/src/index.scss | 35 ++++++++++ 5 files changed, 204 insertions(+), 32 deletions(-) create mode 100644 examples/vite/src/ChatLayout/EmbeddedHostPage.tsx diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 60c98944a4..e8b8b43643 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -44,6 +44,7 @@ import { appSettingsStore, useAppSettingsSelector } from './AppSettings'; import { DESKTOP_LAYOUT_BREAKPOINT } from './ChatLayout/constants.ts'; import { ChatSkipNavigation } from './AccessibilityNavigation/ChatSkipNavigation.tsx'; import { ChannelsPanels, ThreadsPanels } from './ChatLayout/Panels.tsx'; +import { EmbeddedHostPage } from './ChatLayout/EmbeddedHostPage.tsx'; import { SidebarProvider } from './ChatLayout/SidebarContext.tsx'; import { ChatViewSelectorWidthSync, @@ -508,39 +509,41 @@ const App = () => { {/* Publishes window.streamDebug — see src/Debug/StreamDebugHandles.tsx */} -
- -
- - - - - - - - - - + +
+ +
+ + + + + + + + + + +
-
+ diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index 5696759edd..494faa784b 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -72,6 +72,15 @@ export type MessageListSettingsState = { type: 'standard' | 'virtualized'; }; +export type PageLayoutSettingsState = { + /** + * Dev harness: renders the chat as a fixed-height widget between host-page content that + * overflows the viewport, so the window itself scrolls. Reproduces integrations where + * scrolling inside the chat must not move the surrounding page. + */ + embedded: boolean; +}; + export type ComposerSettingsState = { /** * POC: allow sending a message while its attachments are still uploading. @@ -102,6 +111,7 @@ export type AppSettingsState = { messageActions: MessageActionsSettingsState; messageList: MessageListSettingsState; notifications: NotificationsSettingsState; + pageLayout: PageLayoutSettingsState; panelLayout: PanelLayoutSettingsState; reactions: ReactionsSettingsState; theme: ThemeSettingsState; @@ -165,6 +175,9 @@ const defaultAppSettingsState: AppSettingsState = { notifications: { verticalAlignment: 'bottom', }, + pageLayout: { + embedded: false, + }, panelLayout: { leftPanel: { collapsed: false, @@ -300,6 +313,45 @@ const getSendMessagesWithPendingUploadsFromUrl = (): boolean | undefined => { return raw !== '0' && raw !== 'false'; }; +const embeddedLayoutUrlParam = 'embedded_layout'; + +/** Seeded from `?embedded_layout=1`; kept in the URL (not localStorage) so a reload keeps it. */ +const getEmbeddedLayoutFromUrl = (): boolean | undefined => { + if (typeof window === 'undefined') return; + + const raw = new URLSearchParams(window.location.search).get(embeddedLayoutUrlParam); + + if (raw === null) return; + + return raw !== '0' && raw !== 'false'; +}; + +const persistEmbeddedLayoutInUrl = (embedded: boolean) => { + if (typeof window === 'undefined') return; + + const url = new URL(window.location.href); + const hasParam = url.searchParams.get(embeddedLayoutUrlParam) === '1'; + + if (hasParam === embedded) return; + + if (embedded) url.searchParams.set(embeddedLayoutUrlParam, '1'); + else url.searchParams.delete(embeddedLayoutUrlParam); + + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${url.search}${url.hash}`, + ); +}; + +/** The root stylesheet switches #root from viewport height to page flow off this attribute. */ +const applyEmbeddedLayout = (embedded: boolean) => { + if (typeof document === 'undefined') return; + + if (embedded) document.documentElement.setAttribute('data-embedded-layout', ''); + else document.documentElement.removeAttribute('data-embedded-layout'); +}; + const getThemeModeFromUrl = (): ThemeSettingsState['mode'] | undefined => { if (typeof window === 'undefined') return; @@ -373,6 +425,9 @@ const initialAppSettingsState: AppSettingsState = { // A delay in the URL means the harness is wanted, so it arms the switch too. slowUploads: (getSlowUploadMsFromUrl() ?? 0) > 0, }, + pageLayout: { + embedded: getEmbeddedLayoutFromUrl() ?? defaultAppSettingsState.pageLayout.embedded, + }, panelLayout: getStoredPanelLayoutSettings() ?? defaultAppSettingsState.panelLayout, theme: { ...defaultAppSettingsState.theme, @@ -403,6 +458,14 @@ appSettingsStore.subscribeWithSelector( // Apply initial direction on load applyDirection(initialAppSettingsState.theme.direction); +appSettingsStore.subscribeWithSelector( + ({ pageLayout }) => ({ embedded: pageLayout.embedded }), + ({ embedded }) => { + persistEmbeddedLayoutInUrl(embedded); + applyEmbeddedLayout(embedded); + }, +); + appSettingsStore.subscribeWithSelector( ({ panelLayout }) => panelLayout, (panelLayout) => { diff --git a/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx b/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx index 7b91cc709e..5a2a32e5c4 100644 --- a/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx +++ b/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx @@ -12,6 +12,7 @@ type GeneralTabProps = { export const GeneralTab = ({ close }: GeneralTabProps) => { const { messageList, + pageLayout, theme, theme: { direction }, } = useAppSettingsState(); @@ -79,6 +80,34 @@ export const GeneralTab = ({ close }: GeneralTabProps) => {
+
+
Page layout
+
+ + +
+
+ Embedded places the chat between host-page content taller than the viewport, + so the window scrolls too. Use it to check that scrolling inside the chat + (e.g. jumping to unread or quoted messages) leaves the page in place. +
+
); diff --git a/examples/vite/src/ChatLayout/EmbeddedHostPage.tsx b/examples/vite/src/ChatLayout/EmbeddedHostPage.tsx new file mode 100644 index 0000000000..978d1e62a4 --- /dev/null +++ b/examples/vite/src/ChatLayout/EmbeddedHostPage.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react'; + +import { type AppSettingsState, useAppSettingsSelector } from '../AppSettings'; + +const pageLayoutSelector = ({ pageLayout }: AppSettingsState) => ({ + embedded: pageLayout.embedded, +}); + +/** + * With the "Embedded in page" layout setting, renders the chat as a fixed-height widget + * between host-page content taller than the viewport, so the window scrolls as well. + * Any scroll the chat performs on an ancestor (e.g. Element.scrollIntoView) then shows up + * as the host page moving. + * + * The chat wrapper stays mounted in both layouts (it is `display: contents` in the full + * viewport one), so toggling the setting does not remount the chat. + */ +export const EmbeddedHostPage = ({ children }: { children: ReactNode }) => { + const { embedded } = useAppSettingsSelector(pageLayoutSelector); + + return ( + <> + {embedded && ( +
+ Host page header +

+ The chat below is embedded in a page that scrolls. Scrolling inside the chat + should never move this page. Scroll the page so the chat is only partly in + view, then e.g. jump to the first unread message and check that the page stays + where it was. +

+
+ )} +
{children}
+ {embedded && ( +
+ Host page footer +
+ )} + + ); +}; diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index f67fd5bfef..2db9220b86 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -39,6 +39,41 @@ body { min-height: 0; } +/* "Embedded in page" layout: #root flows with its content so the window scrolls. */ +html[data-embedded-layout] #root { + height: auto; +} + +.app-embedded-host__content { + box-sizing: border-box; + min-height: 60vh; + padding: 24px; + font-family: system-ui, sans-serif; + color: #333; + background: repeating-linear-gradient(-45deg, #fdecec 0 16px, #fbe0e0 16px 32px); + + p { + max-width: 60ch; + } +} + +/* Layout-neutral in the full viewport layout; stays mounted so toggling keeps the chat. */ +.app-embedded-host__chat { + display: contents; +} + +html[data-embedded-layout] .app-embedded-host__chat { + display: flex; + flex-direction: column; + flex: none; + /* Shorter than the viewport, so the page around the chat stays visible when scrolled to. */ + height: 85vh; + height: 85dvh; + min-height: 0; + margin: 0 24px; + outline: 2px solid #e58a8a; +} + @layer stream-overrides { .app-chat-layout { --app-left-panel-width: 360px; From 99d73dc8ea8338fa38a8d0982624c84f2ea75155 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 30 Sep 2026 15:58:34 +0200 Subject: [PATCH 3/3] fix(MessageComposer): focus the textarea without scrolling the host page The composer focuses its textarea on mount (the `focus` prop) and when attachments or the quoted message change. These focuses are not triggered by the user, and a plain focus() scrolls every scrollable ancestor to bring the textarea into view, so a chat embedded in a scrollable page moved the page to the composer when a channel opened. Both focus calls now pass { preventScroll: true }. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/MessageInput.test.tsx | 17 +++++++++++++++++ .../MessageComposer/hooks/useTextareaRef.ts | 4 ++-- .../TextareaComposer/TextareaComposer.tsx | 4 +++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/MessageComposer/__tests__/MessageInput.test.tsx b/src/components/MessageComposer/__tests__/MessageInput.test.tsx index fcdd28b44b..c1f8891c5f 100644 --- a/src/components/MessageComposer/__tests__/MessageInput.test.tsx +++ b/src/components/MessageComposer/__tests__/MessageInput.test.tsx @@ -482,6 +482,23 @@ describe(`MessageInputFlat`, () => { expect(results).toHaveNoViolations(); }); + it('should not scroll ancestors when focusing the textarea because of the `focus` prop', async () => { + const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus'); + await renderComponent({ + messageInputProps: { + focus: true, + }, + }); + await waitFor(() => { + expect(screen.getByPlaceholderText(inputPlaceholder)).toHaveFocus(); + }); + expect(focusSpy).toHaveBeenCalled(); + focusSpy.mock.calls.forEach(([options]) => { + expect(options).toEqual({ preventScroll: true }); + }); + focusSpy.mockRestore(); + }); + it('should render default file upload icon', async () => { const { container } = await renderComponent(); const fileUploadIcon = await screen.findByTestId('invoke-attachment-selector-button'); diff --git a/src/components/MessageComposer/hooks/useTextareaRef.ts b/src/components/MessageComposer/hooks/useTextareaRef.ts index 08a9479b0d..bfad641758 100644 --- a/src/components/MessageComposer/hooks/useTextareaRef.ts +++ b/src/components/MessageComposer/hooks/useTextareaRef.ts @@ -4,10 +4,10 @@ import type { MessageComposerProps } from '../MessageComposer'; export const useTextareaRef = (props: MessageComposerProps) => { const { focus } = props; const textareaRef = useRef(undefined); - // Focus + // Focus on mount without scrolling the host page to the composer useEffect(() => { if (focus && textareaRef.current) { - textareaRef.current.focus(); + textareaRef.current.focus({ preventScroll: true }); } }, [focus]); diff --git a/src/components/TextareaComposer/TextareaComposer.tsx b/src/components/TextareaComposer/TextareaComposer.tsx index 096c08f973..c11ab11d5a 100644 --- a/src/components/TextareaComposer/TextareaComposer.tsx +++ b/src/components/TextareaComposer/TextareaComposer.tsx @@ -361,7 +361,9 @@ const TextareaComposerWithLiveAnnouncements = ({ useEffect(() => { const textareaIsFocused = textareaRef.current?.matches(':focus'); if (!textareaRef.current || textareaIsFocused || !focus) return; - textareaRef.current.focus(); + // This focus is not triggered by the user, so it must not scroll the host page to the + // composer when the chat is embedded in a page that scrolls. + textareaRef.current.focus({ preventScroll: true }); }, [attachments, focus, quotedMessage, textareaRef]); // Announce textarea-mode changes (e.g. activating the `/giphy` command) over