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; 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/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; 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