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 && (
+
+ )}
+ >
+ );
+};
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 && (
+