Responsive layouts
On a wide screen, the app from the Quick start shows a channel and its thread side by side. On a phone there's room for one, so an app lays the thread over the channel, at full width. ChatView doesn't know that: to the layout, both slots are open. The CSS decides what's on screen.
That matters when the SDK navigates to something the user can't see. In a thread, View on "Also sent in channel" jumps to the reply in the channel. On a phone, the channel is under the thread, so nothing seems to happen.
The SDK's stylesheet does one part itself. Below 768px wide, it hides the channel list and the thread list, and the app shows them, for example as an overlay: see Collapsible sidebar. For the rest, the app needs to know which slots the user can see.
Slot geometry
stream-chat-react/slot-geometry tells you. It measures the elements that show the slots, and reports which ones are obscured: collapsed to nothing, or covered by another slot.
It only reports. It never moves, hides or closes anything: the app decides what to do. It's opt-in, and it doesn't depend on ChatView. Slots are plain strings, so it works with any layout that has named panels.
Detecting covered and collapsed slots
There are three steps:
- Wrap the layout in
SlotGeometryProvider. - Register the element of each slot with
useRegisterSlotGeometry(slot), which returns a ref. - Read the result with
useSlotGeometry().
This panel registers its element:
import type { PropsWithChildren } from "react";
import { useRegisterSlotGeometry } from "stream-chat-react/slot-geometry";
export const Panel = ({
children,
slot,
}: PropsWithChildren<{ slot: string }>) => {
const register = useRegisterSlotGeometry(slot);
return (
<div className={`panel panel--${slot}`} ref={register}>
{children}
</div>
);
};And this CSS lays an open thread over the channel on a phone:
.panel {
display: flex;
flex: 1 1 0;
min-width: 0;
}
.panel:empty {
display: none;
}
@media (max-width: 767px) {
.panel--thread:not(:empty) {
background: var(--str-chat__background-core-app);
inset: 0;
position: absolute;
z-index: 1;
}
}A slot is obscured when either holds:
- It's collapsed. Its area is under 1px²: zero width or height, or
display: none. - It's covered. Another registered slot covers at least 60% of it. A panel beside it, touching only its edge, doesn't count.
So on a phone, with a thread open, the channel slot is obscured. On a wide screen, with the thread beside the channel, neither is.
The boxes are compared without regard to which one is drawn on top. On the phone, the channel's box also covers most of the thread's, so the thread slot counts as obscured too.
useSlotGeometry() gives two readings:
coverage.obscured[slot]is kept up to date, and re-renders the component when it changes. Use it to render something.isObscured(slot)measures the elements when you call it. Use it in an event handler, where the latest change of layout may not have reachedcoverageyet.
The slots are measured again when one of their elements changes size, and when the window is resized. Outside a SlotGeometryProvider, useSlotGeometry() reports nothing as obscured.
Deciding what to show
What to do about an obscured slot is up to the app. Some options:
- close the panel that covers a slot the user needs;
- render a "back to the channel" button in the covering panel, while
coverage.obscured.channelistrue; - show a list on top only on request, as the SDK's stylesheet does below 768px.
The rest of this page fixes the example from the start: View in a thread closes the thread when it covers the channel.
There's a timing problem. View in the threads view switches to the channels view first, so at click time the channel isn't on screen and can't be measured. requestReveal(slot) records the intent instead. Later, once the slot has been measured, resolveRevealAction(revealSlot, coverage) says what to do:
| Result | Meaning |
|---|---|
wait |
The slot hasn't been measured yet. Wait for the next update. |
act |
The slot is measured and obscured. Reveal it. |
clear |
There's no intent, or the slot is already visible. Do nothing. |
Here is the whole layout:
import { useEffect } from "react";
import {
ChannelHeader,
ChannelNavigation,
MessageAlsoSentInChannelIndicator,
MessageComposer,
MessageList,
ThreadHeader,
ThreadList,
useMessageAlsoSentInChannelNavigation,
useWorkspaceNavigation,
WithComponents,
} from "stream-chat-react";
import {
ChannelSlot,
ChatView,
ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
import {
resolveRevealAction,
SlotGeometryProvider,
useSlotGeometry,
} from "stream-chat-react/slot-geometry";
import { Panel } from "./Panel";
const layouts: LayoutDescriptor[] = [
{ id: "channels", slots: ["channel", "thread"] },
{ id: "threads", slots: ["thread"] },
];
// "View" in a thread asks for the channel to be revealed, then jumps to the reply.
const RevealingAlsoSentIndicator = () => {
const { isInThread, viewReference } = useMessageAlsoSentInChannelNavigation();
const { requestReveal } = useSlotGeometry();
return (
<MessageAlsoSentInChannelIndicator
onView={async () => {
if (isInThread) requestReveal("channel");
await viewReference();
}}
/>
);
};
// Closes the thread once the channel has been measured and is covered.
const RevealChannel = () => {
const { clearReveal, coverage, revealSlot } = useSlotGeometry();
const { closeThread } = useWorkspaceNavigation();
useEffect(() => {
const action = resolveRevealAction(revealSlot, coverage);
if (action === "wait") return;
if (action === "act") closeThread();
clearReveal();
}, [clearReveal, closeThread, coverage, revealSlot]);
return null;
};
const ThreadPanel = () => (
<Panel slot="thread">
<ThreadSlot slot="thread">
<ThreadHeader />
<MessageList />
<MessageComposer />
</ThreadSlot>
</Panel>
);
const ChannelsView = () => (
<>
<ChatView.Selector />
<ChannelNavigation />
<Panel slot="channel">
<ChannelSlot slot="channel">
<ChannelHeader />
<MessageList />
<MessageComposer />
</ChannelSlot>
</Panel>
<ThreadPanel />
<RevealChannel />
</>
);
const ThreadsView = () => (
<>
<ChatView.Selector />
<ThreadList />
<ThreadPanel />
</>
);
export const ResponsiveWorkspace = () => (
<SlotGeometryProvider>
<WithComponents
overrides={{
MessageAlsoSentInChannelIndicator: RevealingAlsoSentIndicator,
}}
>
<ChatView
layouts={layouts}
views={{ channels: <ChannelsView />, threads: <ThreadsView /> }}
/>
</WithComponents>
</SlotGeometryProvider>
);Here is what happens when the user clicks View in a thread:
RevealingAlsoSentIndicatorasks for thechannelslot to be revealed, then jumps to the reply in the channel.RevealChannelruns whenever the intent or the measurements change. Until thechannelslot has been measured,resolveRevealActionreturnswait.- In the channels view on a phone, the thread covers the channel, so it returns
act, andcloseThread()closes the thread. The channel shows, at the reply. - On a wide screen, the channel is visible beside the thread, so it returns
clear, and the thread stays open. - From the threads view, the jump opens the channel in the channels view first. Opening a channel closes the thread beside it there, so once the channel is measured, it returns
clear. - Either way,
clearReveal()drops the intent, so it acts once.
SlotGeometryProvider wraps ChatView, not one view, so the intent made in the threads view is still there when the channels view shows. RevealChannel sits in the channels view, where the channel slot is.
In the channels view, the channel is already on screen when the user clicks. There, isObscured("channel") in the click handler would also answer at once. requestReveal covers both cases with one code path.
Used in
The full API is in Slot geometry.