Pinning and archiving
Pinning keeps a channel at the top of the user's list, and archiving moves it out of the list into an archive. Both belong to the current user alone: other members don't see them. The SDK's swipe sheet already has Pin, and no Archive. This UI cookbook puts a pin and an archive button on every row, and shows them working with an inbox that keeps pinned channels on top and an archive the user can switch to. What the lists do when a channel is pinned or archived is explained in Channel lists.
Best practices
- Read pinned and archived from the user's membership,
pinned_atandarchived_at. The row'spinnedprop anduseChannelMembershipState(channel)both follow it. - Change them with the handlers of
useChannelActions(channel). They callChannel.pin(),Channel.archive()and the others, and publish "Channel pinned" or an error notification, as the swipe sheet does. - Show a pending state. Neither change is optimistic: the membership changes when the server's
member.updatedevent arrives, not when the button is pressed. - Put the order and the split in the lists' filters and sort, not in the rows. Sort on
pinned_atto keep pinned channels on top, and filter onarchivedto keep archived channels out of the inbox. - Make undoing easy. An archived channel leaves the list the user is looking at, so give them a way to reach the archive and unarchive it.
Pin and archive buttons on the row
The row renders the default content, ChannelPreviewView, and two buttons after it. The screen has an inbox and an archive, each a ChannelPaginator:
import { useEffect, useMemo, useState } from "react";
import { Button, Pressable, StyleSheet, View } from "react-native";
import { ChannelPaginator } from "stream-chat";
import type { Channel, ChannelFilters, SortParamRequest } from "stream-chat";
import {
ChannelList,
ChannelPreviewView,
useChannelActions,
useChannelMembershipState,
useChatContext,
useComponentsContext,
useTheme,
WithComponents,
} from "stream-chat-react-native";
import type {
ChannelActionHandler,
ChannelPreviewViewProps,
} from "stream-chat-react-native";
const userId = "YOUR_USER_ID";
// Pinned channels first, the most recently pinned on top, then the latest message first.
const sort: SortParamRequest[] = [
{ direction: -1, field: "pinned_at" },
{ direction: -1, field: "last_message_at" },
];
const inboxFilters: ChannelFilters = {
archived: false,
members: { $in: [userId] },
};
const archiveFilters: ChannelFilters = {
archived: true,
members: { $in: [userId] },
};
const RowWithPinAndArchive = (props: ChannelPreviewViewProps) => {
const { channel, pinned } = props;
const { icons } = useComponentsContext();
const {
theme: { semantics },
} = useTheme();
const { archive, pin, unarchive, unpin } = useChannelActions(channel);
const membership = useChannelMembershipState(channel);
const archived = !!membership?.archived_at;
const [pending, setPending] = useState(false);
// The membership changes when the server's member.updated event arrives.
useEffect(() => setPending(false), [archived, pinned]);
const run = (action: ChannelActionHandler) => {
setPending(true);
void action({ onFailure: () => setPending(false) });
};
const iconColor = pending ? semantics.textTertiary : semantics.textSecondary;
return (
<View style={styles.row}>
<View style={styles.preview}>
<ChannelPreviewView {...props} />
</View>
<Pressable
accessibilityLabel={pinned ? "Unpin" : "Pin"}
accessibilityRole="button"
disabled={pending}
hitSlop={8}
onPress={() => run(pinned ? unpin : pin)}
style={styles.button}
>
{pinned ? (
<icons.Unpin height={20} stroke={iconColor} width={20} />
) : (
<icons.Pin height={20} stroke={iconColor} width={20} />
)}
</Pressable>
<Pressable
accessibilityLabel={archived ? "Unarchive" : "Archive"}
accessibilityRole="button"
disabled={pending}
hitSlop={8}
onPress={() => run(archived ? unarchive : archive)}
style={styles.button}
>
<icons.Folder height={20} stroke={iconColor} width={20} />
</Pressable>
</View>
);
};
// Declared once, so WithComponents gets the same object on every render.
const overrides = { ChannelPreview: RowWithPinAndArchive };
export const ChannelsScreen = ({
onSelect,
}: {
onSelect: (channel: Channel) => void;
}) => {
const { client } = useChatContext();
const [showArchive, setShowArchive] = useState(false);
const [inbox, archiveList] = useMemo(
() => [
new ChannelPaginator({
client,
filters: inboxFilters,
id: "inbox",
sort,
}),
new ChannelPaginator({
client,
filters: archiveFilters,
id: "archive",
sort,
}),
],
[client],
);
// Both lists are registered, so a channel moves between them while either is on screen.
useEffect(() => {
client.channelManager.insertPaginator({ paginator: inbox });
client.channelManager.insertPaginator({ paginator: archiveList });
return () => {
client.channelManager.removePaginator(inbox);
client.channelManager.removePaginator(archiveList);
inbox.dispose();
archiveList.dispose();
};
}, [archiveList, client, inbox]);
return (
<WithComponents overrides={overrides}>
<View style={styles.screen}>
<Button
onPress={() => setShowArchive((value) => !value)}
title={showArchive ? "Show inbox" : "Show archive"}
/>
<ChannelList
onSelect={onSelect}
paginator={showArchive ? archiveList : inbox}
/>
</View>
</WithComponents>
);
};
const styles = StyleSheet.create({
button: { padding: 8 },
preview: { flex: 1 },
row: { alignItems: "center", flexDirection: "row", paddingRight: 8 },
screen: { flex: 1 },
});![]() | ![]() |
Example
What happens when the user taps the pin button on "Design":
run(pin)disables both buttons and grays their icons, then calls thepinhandler, which callsChannel.pin().- The handler publishes "Channel pinned" to the channel list's notification list when the request succeeds. When it fails, it publishes an error, calls
onFailure, and the buttons come back. - The server sends
member.updated. It setspinned_aton the user's membership inchannel.state. - The list passes the row
pinned, so the row shows the pin icon after the title and the unpin button. The effect that watchespinnedclears the pending state. - The channel manager routes "Design" again. The inbox sorts on
pinned_at, so the channel moves to the top.
Archiving runs the same way, and at step 5 the channel leaves the inbox, whose filters say archived: false, and joins the archive. Its row unmounts in the inbox and mounts in the archive, where the same button unarchives it. How events move channels between paginators explains the routing.
The swipe actions still work on these rows, because the list wraps the override in them. The sheet's Pin and the row's pin button call the same handler, so they can't disagree.
Pinning from anywhere else
The handlers and the membership aren't tied to a row. A pin button in a channel's header, or anywhere inside Chat, works the same way with the channel it shows:
import { Button } from "react-native";
import type { Channel } from "stream-chat";
import {
useChannelActions,
useIsChannelPinned,
} from "stream-chat-react-native";
export const PinChannelButton = ({ channel }: { channel: Channel }) => {
const pinned = useIsChannelPinned(channel);
const { pin, unpin } = useChannelActions(channel);
return (
<Button
onPress={() => void (pinned ? unpin() : pin())}
title={pinned ? "Unpin" : "Pin"}
/>
);
};Outside a channel list, the handlers' notifications go to the notification list that mounted most recently, or to the one of the surface the button sits in, such as the open channel. Routing notifications explains how they're placed.
To call the channel without notifications, use Channel.pin(), Channel.unpin(), Channel.archive() and Channel.unarchive() directly.
Going further
- An Archive item in the swipe sheet instead of a button on the row. Add it with
getChannelActionItems, as Which channels a list shows does. - Pin and unpin in channel details. The details screen leaves pinning out by default. In channel details shows how to add it, and the Custom channel actions UI cookbook builds it.
- Pinned channels in a list of their own, such as a "Pinned" section above the inbox. Give it a paginator filtered on
pinned: true, and an ownership rule so a pinned channel shows in that list only. See Channels that match several paginators. - A pending state outside React. Showing a pending state waits for the membership to change with
StateStore.subscribeWithSelector()onchannel.state.

