Channel list UI
The channel list is the screen of channels a user picks from. It is the UI of a ChannelPaginator: the paginator holds the query and the channels it loaded, and ChannelList renders them. This page covers what renders the list and its rows, what each row reads, the swipe actions, and which parts you can replace. The props are in the ChannelList reference.
The ChannelList component
<ChannelList paginator={paginator} onSelect={onSelect} /> renders the channels of one paginator:
- When the paginator has no channels loaded and a user is connected, it loads the first page. That happens when it mounts, when you pass it a different paginator, and after
client.disconnectUser()resets the paginator. A paginator that already holds channels is shown as it is, without a new query. - It subscribes to
paginator.stateand renders a row for each channel, in a React NativeFlatList. - When the user scrolls near the end, it loads the next page.
loadMoreThresholdsets how near, as theFlatList'sonEndReachedThreshold. - It starts the event subscriptions of
client.channelManager, so the list stays up to date as messages and other events arrive. Every mounted list shares one set of subscriptions, which stops when the last list unmounts. - Pulling the list down refreshes it: the first page is queried again while the loaded channels stay on screen. A pull within 5 seconds of the last refresh, or of the list mounting, is ignored, unless the last query failed.
- It renders a
NotificationListfor the notifications aimed at the channel list. See Displaying notifications.
The list holds no selection and doesn't navigate. Tapping a row calls onSelect(channel), and your navigator shows the channel; see Navigation. While a row's swipe actions are open, a tap closes them instead.
The paginator belongs to your app. Register it on client.channelManager so it receives events: an unregistered paginator shows its first page and then never changes. ChannelList never disposes the paginator. Channel list paginators covers creating, registering and disposing one.
channelRenderFilterFn filters the loaded channels before they render. It runs on every render of the list, so keep it a cheap pure function.
Several lists
The SDK has no component that renders every registered list. Render one ChannelList per paginator. On a phone, one list at a time usually fits, so this screen switches between an "Unread" list and the full list:
import { useEffect, useMemo, useState } from "react";
import { Button, StyleSheet, View } from "react-native";
import { ChannelPaginator } from "stream-chat";
import type { Channel, ChannelFilters, SortParamRequest } from "stream-chat";
import { ChannelList, useChatContext } from "stream-chat-react-native";
const userId = "YOUR_USER_ID";
const sort: SortParamRequest[] = [{ direction: -1, field: "last_message_at" }];
const allFilters: ChannelFilters = {
members: { $in: [userId] },
type: "messaging",
};
const unreadFilters: ChannelFilters = { ...allFilters, has_unread: true };
export const ChannelListScreen = ({
onSelect,
}: {
onSelect: (channel: Channel) => void;
}) => {
const { client } = useChatContext();
const [tab, setTab] = useState<"all" | "unread">("all");
const [unread, all] = useMemo(
() => [
new ChannelPaginator({
client,
filters: unreadFilters,
id: "unread",
sort,
}),
new ChannelPaginator({ client, filters: allFilters, id: "all", sort }),
],
[client],
);
// Registered lists are kept up to date by the channel manager.
useEffect(() => {
client.channelManager.insertPaginator({ paginator: unread });
client.channelManager.insertPaginator({ paginator: all });
return () => {
client.channelManager.removePaginator(unread);
client.channelManager.removePaginator(all);
unread.dispose();
all.dispose();
};
}, [all, client, unread]);
return (
<View style={styles.container}>
<View style={styles.tabs}>
<Button onPress={() => setTab("all")} title="All" />
<Button onPress={() => setTab("unread")} title="Unread" />
</View>
<ChannelList
onSelect={onSelect}
paginator={tab === "all" ? all : unread}
/>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
tabs: { flexDirection: "row", justifyContent: "center" },
});Both paginators are registered, so both stay up to date while the other one is on screen. Switching tabs shows the other paginator's channels without a new query once it has loaded. A channel can match both lists; the channel manager decides which lists keep it, as Channel list paginators explains.
Rows
For each channel, the list renders a row that reads the channel's own stores, so a row re-renders only when its channel changes. The row computes the channel's muted, pinned and unread values and its latest message, and renders the ChannelPreview override, by default ChannelPreviewView, with them as props.
| The row shows | Rendered by | Read from |
|---|---|---|
| Avatar: the channel image, or its members' avatars | ChannelPreviewAvatar |
channel.state: the channel data and members |
| Title | ChannelPreviewTitle |
channel.state: the channel's name, or else the other members' names |
| Muted and pinned icons | ChannelPreviewMutedStatus, ChannelPreviewPinnedStatus |
channel.state: the mute status, and the user's membership (pinned_at) |
| Time of the latest message | ChannelPreviewStatus |
channel.messagePaginator.aggregateState: the latest message |
| Unread badge | ChannelPreviewUnreadCount |
channel.state: the user's read state. Hidden while the channel is muted. |
| The message line | ChannelPreviewMessage |
See below |
The title is the channel's name when it has one. Without one, a direct message shows the other member's name, and a group shows the first names of two other members, followed by "and N others" when there are more.
The unread badge caps the count at maxUnreadCount, 255 by default.
ChannelPreviewMessage shows the first of these that applies:
- Who is typing, from
channel.state.typing, throughChannelPreviewTypingIndicator. - "Draft:" and what the channel's composer holds, when the user left text or attachments in it.
- "No messages yet", when the channel has no messages.
- The poll's label, when the latest message is a poll.
- "Message failed to send", when the user's latest message failed.
- Otherwise the latest message, through
ChannelPreviewLastMessage, after a sent, delivered or read mark on the user's own message,ChannelPreviewMessageDeliveryStatus. The mark readschannel.messageReceiptsTracker.snapshotStore.
The latest message is translated into the user's language when it has a translation for it.
mutedStatusPosition and pinnedStatusPosition place the muted and pinned icons next to the title ('inlineTitle', the default) or at the end of the message line ('trailingBottom').
The channel preview hooks the row uses, such as useChannelPreviewDisplayName, are exported for rows of your own. See Channel preview hooks.
Swipe actions
Swiping a row toward the start reveals its actions. By default there are two:
- A "more" button that opens a bottom sheet,
ChannelDetailsBottomSheet, with the channel's avatar, name and member count (online status in a direct message) at the top, and a list of actions. - Mute or unmute, as a button in the swipe row itself.
The sheet lists pin or unpin, and leave. In a direct message it also lists mute user and block user. The user who created the channel also gets delete. Leaving, deleting and blocking ask for confirmation first.
Each action has a placement: 'swipe' shows it in the swipe row, 'sheet' in the bottom sheet, and 'both' in both. To change the actions, pass getChannelActionItems to ChannelList: it gets the default items and returns the list to show. Actions shows how. swipeActionsEnabled={false} turns swiping off; the rows then have no actions.
Opening a row's actions closes the actions of any other open row.
Loading, empty and error states
| State | What the list renders | Override key |
|---|---|---|
| Loading the first page | Placeholder rows, numberOfSkeletons of them (8 by default) |
ChannelListLoadingIndicator, Skeleton |
| Loading the next page | A spinner after the last row | ChannelListFooterLoadingIndicator |
| Refreshing | The pull-to-refresh spinner above the rows | None |
| No channels | "No conversations yet". The component gets listType="channel". |
EmptyStateIndicator |
| The first page failed | "Error loading channel list...". Tapping it loads the first page again. | LoadingErrorIndicator |
| A query failed while channels are on screen | A banner over the top of the list. Tapping it refreshes the list. | ChannelListHeaderErrorIndicator |
| No network, or the connection is down | A "Reconnecting..." banner over the top of the list | ChannelListHeaderNetworkDownIndicator |
A channel-list paginator retries a failed query three times. When every attempt has failed, the error is recorded as lastQueryError in paginator.state, and the list shows one of the error states above. With offline support on, a failure while the list has channels isn't recorded, so the channels from the local database stay on screen without an error.
EmptyStateIndicator, LoadingErrorIndicator and the network banner are shared with other parts of the SDK. The message list renders EmptyStateIndicator too, with listType="message", so a replacement should check listType, as the sample below does.
Replacing parts
Register replacements with WithComponents. Overriding components explains how overrides apply.
| Override key | Replaces |
|---|---|
ChannelPreview |
The row's content. Gets channel, muted, pinned, unread and lastMessage. The default is ChannelPreviewView. |
ChannelPreviewAvatar |
The row's avatar. The default is ChannelAvatar. |
ChannelPreviewTitle |
The title |
ChannelPreviewStatus |
The time of the latest message |
ChannelPreviewUnreadCount |
The unread badge |
ChannelPreviewMutedStatus, ChannelPreviewPinnedStatus |
The muted and pinned icons |
ChannelPreviewMessage |
The whole message line |
ChannelPreviewLastMessage |
The latest message, or the draft, in the message line |
ChannelPreviewMessageDeliveryStatus |
The sent, delivered or read mark |
ChannelPreviewTypingIndicator |
Who is typing, in the message line |
ChannelDetailsBottomSheet, ChannelDetailsHeader |
The bottom sheet the "more" button opens, and the header at its top |
ListHeaderComponent |
Nothing by default. Rendered above the first row, and scrolls with the list. |
ChannelListLoadingIndicator, Skeleton |
The first-page placeholder, and each placeholder row in it |
ChannelListFooterLoadingIndicator |
The next-page spinner |
EmptyStateIndicator, LoadingErrorIndicator |
The empty and first-page error states, here and in the message list |
ChannelListHeaderErrorIndicator, ChannelListHeaderNetworkDownIndicator |
The error and connection banners |
ChannelList itself has no override key. To render the channels in a list of your own, read paginator.state with useStateStore and render rows from it; see Reading state in components.
This list keeps the default row, adds a label to frozen channels, and replaces the empty state of the channel list only:
import { StyleSheet, Text, View } from "react-native";
import type { Channel, ChannelPaginator, ChannelStateData } from "stream-chat";
import type {
ChannelPreviewViewProps,
EmptyStateProps,
} from "stream-chat-react-native";
import {
ChannelList,
ChannelPreviewView,
EmptyStateIndicator,
useStateStore,
WithComponents,
} from "stream-chat-react-native";
const frozenSelector = ({ data }: ChannelStateData) => ({
frozen: data?.frozen ?? false,
});
const RowWithFrozenLabel = (props: ChannelPreviewViewProps) => {
const { frozen } = useStateStore(props.channel.state, frozenSelector);
return (
<View>
<ChannelPreviewView {...props} />
{frozen ? <Text style={styles.label}>Frozen</Text> : null}
</View>
);
};
// The message list renders this override too, so only the channel list's state is replaced.
const EmptyState = (props: EmptyStateProps) =>
props.listType === "channel" ? (
<Text style={styles.empty}>You aren't in any channels yet.</Text>
) : (
<EmptyStateIndicator {...props} />
);
// Declared outside the component, so WithComponents gets the same object on every render.
const overrides = {
ChannelPreview: RowWithFrozenLabel,
EmptyStateIndicator: EmptyState,
};
export const ChannelListWithLabels = ({
onSelect,
paginator,
}: {
onSelect: (channel: Channel) => void;
paginator: ChannelPaginator;
}) => (
<WithComponents overrides={overrides}>
<ChannelList onSelect={onSelect} paginator={paginator} />
</WithComponents>
);
const styles = StyleSheet.create({
empty: { padding: 16, textAlign: "center" },
label: { fontSize: 12, paddingHorizontal: 16, paddingBottom: 8 },
});The label reads frozen from channel.state through useStateStore, so it updates when the channel is frozen or unfrozen. Reading channel.data.frozen during render would not: the getter isn't reactive. See Reading state in components.
The options sheet
The row renders the ChannelDetailsBottomSheet key inside the bottom sheet that the "more" button opens. It receives ChannelDetailsBottomSheetProps:
| Prop | Type | Description |
|---|---|---|
channel |
Channel |
The row's channel. |
items |
ChannelActionItem[] |
The actions whose placement is 'sheet' or 'both', from the list getChannelActionItems returned. Channel actions explains the items. |
additionalFlatListProps |
Partial<FlatListProps<ChannelActionItem>> |
Spread over the props of the list of actions, after its own. The row doesn't pass it, so only a replacement that renders the default sheet sets it. |
The default sheet renders the ChannelDetailsHeader key with the channel, then the items in a list. Each item is a ChannelActionItemView, which runs the item's action, closes the sheet and closes every open row's swipe actions when pressed.
ChannelDetailsHeader receives ChannelDetailsHeaderProps, which hold channel only. The default shows the channel's avatar, its name with the muted icon, and the member and online counts, or "Online" or "Offline" in a direct message.
This sheet keeps the default, and shows a line when getChannelActionItems leaves it no items:
import type { FlatListProps } from "react-native";
import { Text } from "react-native";
import { ChannelDetailsBottomSheet } from "stream-chat-react-native";
import type {
ChannelActionItem,
ChannelDetailsBottomSheetProps,
ComponentOverrides,
} from "stream-chat-react-native";
// Declared once, so the list gets the same props on every render.
const listProps: Partial<FlatListProps<ChannelActionItem>> = {
ListEmptyComponent: <Text style={{ padding: 16 }}>No actions</Text>,
};
const ChannelOptionsSheet = (props: ChannelDetailsBottomSheetProps) => (
<ChannelDetailsBottomSheet {...props} additionalFlatListProps={listProps} />
);
// Pass to WithComponents, declared once at module scope.
export const sheetOverrides: ComponentOverrides = {
ChannelDetailsBottomSheet: ChannelOptionsSheet,
};