Skip to content

Commit b470bef

Browse files
authored
fix: put unread separator above system messages and stop scrolling the host page on scroll to a message (#3305)
1 parent 3bd71aa commit b470bef

13 files changed

Lines changed: 378 additions & 79 deletions

File tree

‎examples/vite/src/App.tsx‎

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { appSettingsStore, useAppSettingsSelector } from './AppSettings';
4444
import { DESKTOP_LAYOUT_BREAKPOINT } from './ChatLayout/constants.ts';
4545
import { ChatSkipNavigation } from './AccessibilityNavigation/ChatSkipNavigation.tsx';
4646
import { ChannelsPanels, ThreadsPanels } from './ChatLayout/Panels.tsx';
47+
import { EmbeddedHostPage } from './ChatLayout/EmbeddedHostPage.tsx';
4748
import { SidebarProvider } from './ChatLayout/SidebarContext.tsx';
4849
import {
4950
ChatViewSelectorWidthSync,
@@ -508,39 +509,41 @@ const App = () => {
508509
<ChatSkipNavigation />
509510
{/* Publishes window.streamDebug — see src/Debug/StreamDebugHandles.tsx */}
510511
<StreamDebugHandles />
511-
<div
512-
className='app-chat-layout'
513-
data-variant={messageUiVariant ?? undefined}
514-
ref={appLayoutRef}
515-
style={initialAppLayoutStyle}
516-
>
517-
<SystemNotification />
518-
<div className='app-chat-layout__body'>
519-
<PanelLayoutStyleSync layoutRef={appLayoutRef} />
520-
<ChatViewSelectorWidthSync
521-
iconOnly={chatView.iconOnly}
522-
layoutRef={appLayoutRef}
523-
/>
524-
<ChatView>
525-
<DialogManagerProvider id={globalDialogManager}>
526-
<ChatStateSync initialChatView={initialChatView} />
527-
<SidebarLayoutSync />
528-
<ChannelsPanels
529-
filters={filters}
530-
iconOnly={chatView.iconOnly}
531-
initialChannelId={initialChannelId ?? undefined}
532-
itemSet={chatViewSelectorItemSet}
533-
options={options}
534-
sort={sort}
535-
/>
536-
<ThreadsPanels
537-
iconOnly={chatView.iconOnly}
538-
itemSet={chatViewSelectorItemSet}
539-
/>
540-
</DialogManagerProvider>
541-
</ChatView>
512+
<EmbeddedHostPage>
513+
<div
514+
className='app-chat-layout'
515+
data-variant={messageUiVariant ?? undefined}
516+
ref={appLayoutRef}
517+
style={initialAppLayoutStyle}
518+
>
519+
<SystemNotification />
520+
<div className='app-chat-layout__body'>
521+
<PanelLayoutStyleSync layoutRef={appLayoutRef} />
522+
<ChatViewSelectorWidthSync
523+
iconOnly={chatView.iconOnly}
524+
layoutRef={appLayoutRef}
525+
/>
526+
<ChatView>
527+
<DialogManagerProvider id={globalDialogManager}>
528+
<ChatStateSync initialChatView={initialChatView} />
529+
<SidebarLayoutSync />
530+
<ChannelsPanels
531+
filters={filters}
532+
iconOnly={chatView.iconOnly}
533+
initialChannelId={initialChannelId ?? undefined}
534+
itemSet={chatViewSelectorItemSet}
535+
options={options}
536+
sort={sort}
537+
/>
538+
<ThreadsPanels
539+
iconOnly={chatView.iconOnly}
540+
itemSet={chatViewSelectorItemSet}
541+
/>
542+
</DialogManagerProvider>
543+
</ChatView>
544+
</div>
542545
</div>
543-
</div>
546+
</EmbeddedHostPage>
544547
</Chat>
545548
</SidebarProvider>
546549
</WithComponents>

‎examples/vite/src/AppSettings/state.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ export type MessageListSettingsState = {
7272
type: 'standard' | 'virtualized';
7373
};
7474

75+
export type PageLayoutSettingsState = {
76+
/**
77+
* Dev harness: renders the chat as a fixed-height widget between host-page content that
78+
* overflows the viewport, so the window itself scrolls. Reproduces integrations where
79+
* scrolling inside the chat must not move the surrounding page.
80+
*/
81+
embedded: boolean;
82+
};
83+
7584
export type ComposerSettingsState = {
7685
/**
7786
* POC: allow sending a message while its attachments are still uploading.
@@ -102,6 +111,7 @@ export type AppSettingsState = {
102111
messageActions: MessageActionsSettingsState;
103112
messageList: MessageListSettingsState;
104113
notifications: NotificationsSettingsState;
114+
pageLayout: PageLayoutSettingsState;
105115
panelLayout: PanelLayoutSettingsState;
106116
reactions: ReactionsSettingsState;
107117
theme: ThemeSettingsState;
@@ -165,6 +175,9 @@ const defaultAppSettingsState: AppSettingsState = {
165175
notifications: {
166176
verticalAlignment: 'bottom',
167177
},
178+
pageLayout: {
179+
embedded: false,
180+
},
168181
panelLayout: {
169182
leftPanel: {
170183
collapsed: false,
@@ -300,6 +313,45 @@ const getSendMessagesWithPendingUploadsFromUrl = (): boolean | undefined => {
300313
return raw !== '0' && raw !== 'false';
301314
};
302315

316+
const embeddedLayoutUrlParam = 'embedded_layout';
317+
318+
/** Seeded from `?embedded_layout=1`; kept in the URL (not localStorage) so a reload keeps it. */
319+
const getEmbeddedLayoutFromUrl = (): boolean | undefined => {
320+
if (typeof window === 'undefined') return;
321+
322+
const raw = new URLSearchParams(window.location.search).get(embeddedLayoutUrlParam);
323+
324+
if (raw === null) return;
325+
326+
return raw !== '0' && raw !== 'false';
327+
};
328+
329+
const persistEmbeddedLayoutInUrl = (embedded: boolean) => {
330+
if (typeof window === 'undefined') return;
331+
332+
const url = new URL(window.location.href);
333+
const hasParam = url.searchParams.get(embeddedLayoutUrlParam) === '1';
334+
335+
if (hasParam === embedded) return;
336+
337+
if (embedded) url.searchParams.set(embeddedLayoutUrlParam, '1');
338+
else url.searchParams.delete(embeddedLayoutUrlParam);
339+
340+
window.history.replaceState(
341+
window.history.state,
342+
'',
343+
`${url.pathname}${url.search}${url.hash}`,
344+
);
345+
};
346+
347+
/** The root stylesheet switches #root from viewport height to page flow off this attribute. */
348+
const applyEmbeddedLayout = (embedded: boolean) => {
349+
if (typeof document === 'undefined') return;
350+
351+
if (embedded) document.documentElement.setAttribute('data-embedded-layout', '');
352+
else document.documentElement.removeAttribute('data-embedded-layout');
353+
};
354+
303355
const getThemeModeFromUrl = (): ThemeSettingsState['mode'] | undefined => {
304356
if (typeof window === 'undefined') return;
305357

@@ -373,6 +425,9 @@ const initialAppSettingsState: AppSettingsState = {
373425
// A delay in the URL means the harness is wanted, so it arms the switch too.
374426
slowUploads: (getSlowUploadMsFromUrl() ?? 0) > 0,
375427
},
428+
pageLayout: {
429+
embedded: getEmbeddedLayoutFromUrl() ?? defaultAppSettingsState.pageLayout.embedded,
430+
},
376431
panelLayout: getStoredPanelLayoutSettings() ?? defaultAppSettingsState.panelLayout,
377432
theme: {
378433
...defaultAppSettingsState.theme,
@@ -403,6 +458,14 @@ appSettingsStore.subscribeWithSelector(
403458
// Apply initial direction on load
404459
applyDirection(initialAppSettingsState.theme.direction);
405460

461+
appSettingsStore.subscribeWithSelector(
462+
({ pageLayout }) => ({ embedded: pageLayout.embedded }),
463+
({ embedded }) => {
464+
persistEmbeddedLayoutInUrl(embedded);
465+
applyEmbeddedLayout(embedded);
466+
},
467+
);
468+
406469
appSettingsStore.subscribeWithSelector(
407470
({ panelLayout }) => panelLayout,
408471
(panelLayout) => {

‎examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type GeneralTabProps = {
1212
export const GeneralTab = ({ close }: GeneralTabProps) => {
1313
const {
1414
messageList,
15+
pageLayout,
1516
theme,
1617
theme: { direction },
1718
} = useAppSettingsState();
@@ -79,6 +80,34 @@ export const GeneralTab = ({ close }: GeneralTabProps) => {
7980
</Button>
8081
</div>
8182
</div>
83+
<div className='app__settings-modal__field'>
84+
<div className='app__settings-modal__field-label'>Page layout</div>
85+
<div className='app__settings-modal__options-row'>
86+
<Button
87+
aria-pressed={!pageLayout.embedded}
88+
className='app__settings-modal__option-button str-chat__button--outline str-chat__button--secondary str-chat__button--size-sm'
89+
onClick={() =>
90+
appSettingsStore.partialNext({ pageLayout: { embedded: false } })
91+
}
92+
>
93+
Full viewport
94+
</Button>
95+
<Button
96+
aria-pressed={pageLayout.embedded}
97+
className='app__settings-modal__option-button str-chat__button--outline str-chat__button--secondary str-chat__button--size-sm'
98+
onClick={() =>
99+
appSettingsStore.partialNext({ pageLayout: { embedded: true } })
100+
}
101+
>
102+
Embedded in page
103+
</Button>
104+
</div>
105+
<div className='app__settings-modal__field-comment'>
106+
Embedded places the chat between host-page content taller than the viewport,
107+
so the window scrolls too. Use it to check that scrolling inside the chat
108+
(e.g. jumping to unread or quoted messages) leaves the page in place.
109+
</div>
110+
</div>
82111
</SettingsTabBody>
83112
</div>
84113
);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { ReactNode } from 'react';
2+
3+
import { type AppSettingsState, useAppSettingsSelector } from '../AppSettings';
4+
5+
const pageLayoutSelector = ({ pageLayout }: AppSettingsState) => ({
6+
embedded: pageLayout.embedded,
7+
});
8+
9+
/**
10+
* With the "Embedded in page" layout setting, renders the chat as a fixed-height widget
11+
* between host-page content taller than the viewport, so the window scrolls as well.
12+
* Any scroll the chat performs on an ancestor (e.g. Element.scrollIntoView) then shows up
13+
* as the host page moving.
14+
*
15+
* The chat wrapper stays mounted in both layouts (it is `display: contents` in the full
16+
* viewport one), so toggling the setting does not remount the chat.
17+
*/
18+
export const EmbeddedHostPage = ({ children }: { children: ReactNode }) => {
19+
const { embedded } = useAppSettingsSelector(pageLayoutSelector);
20+
21+
return (
22+
<>
23+
{embedded && (
24+
<header className='app-embedded-host__content'>
25+
<strong>Host page header</strong>
26+
<p>
27+
The chat below is embedded in a page that scrolls. Scrolling inside the chat
28+
should never move this page. Scroll the page so the chat is only partly in
29+
view, then e.g. jump to the first unread message and check that the page stays
30+
where it was.
31+
</p>
32+
</header>
33+
)}
34+
<div className='app-embedded-host__chat'>{children}</div>
35+
{embedded && (
36+
<footer className='app-embedded-host__content'>
37+
<strong>Host page footer</strong>
38+
</footer>
39+
)}
40+
</>
41+
);
42+
};

‎examples/vite/src/index.scss‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,41 @@ body {
3939
min-height: 0;
4040
}
4141

42+
/* "Embedded in page" layout: #root flows with its content so the window scrolls. */
43+
html[data-embedded-layout] #root {
44+
height: auto;
45+
}
46+
47+
.app-embedded-host__content {
48+
box-sizing: border-box;
49+
min-height: 60vh;
50+
padding: 24px;
51+
font-family: system-ui, sans-serif;
52+
color: #333;
53+
background: repeating-linear-gradient(-45deg, #fdecec 0 16px, #fbe0e0 16px 32px);
54+
55+
p {
56+
max-width: 60ch;
57+
}
58+
}
59+
60+
/* Layout-neutral in the full viewport layout; stays mounted so toggling keeps the chat. */
61+
.app-embedded-host__chat {
62+
display: contents;
63+
}
64+
65+
html[data-embedded-layout] .app-embedded-host__chat {
66+
display: flex;
67+
flex-direction: column;
68+
flex: none;
69+
/* Shorter than the viewport, so the page around the chat stays visible when scrolled to. */
70+
height: 85vh;
71+
height: 85dvh;
72+
min-height: 0;
73+
margin: 0 24px;
74+
outline: 2px solid #e58a8a;
75+
}
76+
4277
@layer stream-overrides {
4378
.app-chat-layout {
4479
--app-left-panel-width: 360px;

‎src/components/MessageComposer/__tests__/MessageInput.test.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,23 @@ describe(`MessageInputFlat`, () => {
482482
expect(results).toHaveNoViolations();
483483
});
484484

485+
it('should not scroll ancestors when focusing the textarea because of the `focus` prop', async () => {
486+
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus');
487+
await renderComponent({
488+
messageInputProps: {
489+
focus: true,
490+
},
491+
});
492+
await waitFor(() => {
493+
expect(screen.getByPlaceholderText(inputPlaceholder)).toHaveFocus();
494+
});
495+
expect(focusSpy).toHaveBeenCalled();
496+
focusSpy.mock.calls.forEach(([options]) => {
497+
expect(options).toEqual({ preventScroll: true });
498+
});
499+
focusSpy.mockRestore();
500+
});
501+
485502
it('should render default file upload icon', async () => {
486503
const { container } = await renderComponent();
487504
const fileUploadIcon = await screen.findByTestId('invoke-attachment-selector-button');

‎src/components/MessageComposer/hooks/useTextareaRef.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import type { MessageComposerProps } from '../MessageComposer';
44
export const useTextareaRef = (props: MessageComposerProps) => {
55
const { focus } = props;
66
const textareaRef = useRef<HTMLTextAreaElement>(undefined);
7-
// Focus
7+
// Focus on mount without scrolling the host page to the composer
88
useEffect(() => {
99
if (focus && textareaRef.current) {
10-
textareaRef.current.focus();
10+
textareaRef.current.focus({ preventScroll: true });
1111
}
1212
}, [focus]);
1313

0 commit comments

Comments
 (0)