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 list's filters and sort, and the channels it loaded, and ChannelList renders them. Each row of the list renders a channel preview: the channel's title, image, latest message and status. This page covers what renders the list and its channel previews, what each channel preview 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, or was never queried, and a user is connected, it loads the first page, even when events already put channels in the paginator. That happens when it mounts, when you pass it a different paginator, and after
client.disconnectUser()resets the paginator. A paginator that was queried and holds channels is shown as it is, without a new query. After a reconnect,client.connectionRecoveryre-queries the channels the list has loaded, up to 150 per list; see Channel list paginators. - It subscribes to
paginator.stateand renders a channel preview 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 Notifications UI.
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.
Channel previews
For each channel, the list renders a channel preview. It reads the channel's own stores, so a channel preview re-renders only when its channel changes. It 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 default is made of parts, each its own override key:
| The channel preview 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 who sent it,ChannelPreviewMessageDeliveryStatus. The user's own message gets "You:" with a sending, sent, delivered or read mark, read fromchannel.messageReceiptsTracker.snapshotStore. A message from another member gets that member's name, except in a direct message. A deleted message gets the same sender, and no mark. When the channel has read events turned off, only the sending mark shows.
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 default uses, such as useChannelPreviewDisplayName, are exported for channel previews 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
ChannelList shows a state of its own while the first page loads, when it fails, while the next page loads, and when it has no channels. List states UI lists each state's default and override key, and explains what happens when a query fails.
Replacing parts
Register replacements with WithComponents. Overriding components explains how overrides apply.
| Override key | Replaces |
|---|---|
ChannelPreview |
The channel preview. Gets channel, muted, pinned, unread and lastMessage. The default is ChannelPreviewView. |
ChannelPreviewAvatar |
The channel preview'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 channel preview, 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 channel preview renders the ChannelDetailsBottomSheet key inside the bottom sheet that the "more" button opens. It receives ChannelDetailsBottomSheetProps:
| Prop | Type | Description |
|---|---|---|
channel |
Channel |
The channel preview'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 channel preview 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,
};