Channel invites

A channel can invite a user before they join it. This recipe gives those invitations a section of their own in the channel list.

Note:

What you'll build

  • An Invitations section above the channels
  • Each invitation with its channel and who sent it
  • Accept and Decline buttons for each invitation
  • New invitations shown as they arrive

Preview

A channel list with an Invitations section holding Chess club, which Cara invited the user to, and Bird walk, which Bob invited the user to, each with Accept and Decline buttons, above the user's channels

Approach: Replace one part and Change behaviour in the state layer. Choosing where to start explains the approaches.

Starts from: the base app. The page shows only what the recipe adds.

The SDK has no invitation UI. Another member invites the user with Channel.inviteMembers(), and the user answers with Channel.acceptInvite() or Channel.rejectInvite(). Channel invites explains the invite itself.

Best practices

  • Keep invitations apart from the channels. A channel the user hasn't joined yet doesn't belong among the conversations they're in.
  • Say who sent each invitation, so the user knows whether to trust it.
  • Name the channel in each button's accessible name, such as "Accept invitation to Bird walk". Several rows each have an Accept button, and a screen reader user who tabs to one hears which channel it answers.
  • Keep the buttons out of the channel list's role="listbox", which may hold only its rows. The invitations are a plain list in a section of their own, named by its heading.
  • Keep the focus on the pressed button while the request runs. The button reads "Joining…" and sets aria-busy and aria-disabled, not disabled, which would take the focus away.
  • Load more invitations when the user asks, or once they've answered every loaded one. The section sits above the channel list and takes the height its rows need, so it loads a few at a time, with the SDK's "Load more" button at its end.

Create the paginators

A query for the user's channels, members: { $in: [userId] }, also returns the channels the user is invited to, and the ones they declined. Two paginators split them:

Paginator Filters added to yours Holds
Channels joined: true The channels the user joined, by latest message.
Invites invite: "pending" The invitations the user hasn't answered yet.

Query Channels explains both filters.

The server reads joined from the user's membership. When an event asks whether a channel still belongs in a paginator, ChannelPaginator.matchesFilter() checks the filters on the client, and reads a field it has no rule for from the channel's data, which has no joined. Every channel would fail the check, and the first event ChannelManager.ingestChannel() routes for a channel, such as message.new or notification.channel_mutes_updated, would drop it from the list. JoinedChannelPaginator adds that rule: a member who wasn't invited, or accepted the invite, has joined, read from channel.state.membership.invited and channel.state.membership.invite_accepted_at. It goes first with ChannelPaginator.setFilterResolvers(), because the last default rule takes every field it reaches, so a rule added after it never runs.

useChannelInvites() registers the channels paginator on client.channelManager with ChannelManager.insertPaginator(), which lets the manager's events keep it current. The invites paginator stays off the manager: ChannelNavigation renders a ChannelList for every registered paginator, and the invitations need buttons that a channel list's rows can't hold. So it follows two events from client.on() itself:

  • notification.invited: a new invite. The paginator loads its first page again with ChannelPaginator.toTail(), with reset: "yes" and keepPreviousItems: true, so the rows stay on screen until the page arrives. A paginator runs one query at a time, so an invite that arrives while it loads sets reloadWhenIdle, and the hook reloads once that load ends. A reload already under way when the user answers can bring the row back until the next reload, because the server answers from before the change.
  • member.updated for the user, with event.member.invite_accepted_at or event.member.invite_rejected_at set: the user answered an invite, on this device or another. ChannelPaginator.removeItem() removes the channel's row by its cid.

The invites query watches its channels, as client.queryChannels() does by default. That is why an answer arrives as member.updated: the server sends notification.invite_accepted and notification.invite_rejected only to clients that aren't watching the channel, and member.updated only to clients that are, as Events lists. Don't query the invites with watch: false: the client would then receive no member.updated for the channel, and an accepted channel would never join the channel list.

On unmount, the hook disposes both paginators with ChannelPaginator.dispose(); a remount creates new ones:

src/useChannelInvites.ts
import { useEffect, useState } from "react";
import { ChannelPaginator } from "stream-chat";
import type {
  Channel,
  ChannelFilters,
  ChannelMemberResponse,
  ChannelPaginatorOptions,
  SortParamRequest,
} from "stream-chat";
import { useChatContext } from "stream-chat-react";

/** The user joined the channel: they weren't invited, or they accepted the invite. */
const hasJoined = (channel: Channel) => {
  const { invite_accepted_at, invited } = channel.state.membership;
  return !invited || !!invite_accepted_at;
};

/** The member answered their invite, on this device or another. */
const hasAnswered = ({
  invite_accepted_at,
  invite_rejected_at,
}: ChannelMemberResponse) => !!(invite_accepted_at || invite_rejected_at);

/**
 * A channel paginator that checks the `joined` filter on the channel's membership, as the server
 * does, when an event asks whether a channel belongs in the list.
 */
class JoinedChannelPaginator extends ChannelPaginator {
  constructor(options: ChannelPaginatorOptions) {
    super(options);
    // First in the list: the last default resolver takes every field it reaches.
    this.setFilterResolvers([
      { matchesField: (field) => field === "joined", resolve: hasJoined },
      ...this._filterFieldToDataResolvers,
    ]);
  }
}

// The channel with the latest message first.
const byLastMessage: SortParamRequest[] = [
  { direction: -1, field: "last_message_at" },
];
// The newest channel first.
const byCreatedAt: SortParamRequest[] = [
  { direction: -1, field: "created_at" },
];

/**
 * Registers the channel list's paginator on `client.channelManager`, limited to channels the user
 * joined, and returns a second paginator with the channels the user is invited to. `filters`
 * scope both to the user's channels; pass a stable object. Returns `undefined` until both exist.
 */
export const useChannelInvites = (filters: ChannelFilters) => {
  const { client } = useChatContext();
  const [invites, setInvites] = useState<ChannelPaginator>();

  useEffect(() => {
    const channels = new JoinedChannelPaginator({
      client,
      filters: { ...filters, joined: true },
      id: "channels",
      requestOptions: { presence: true, state: true },
      sort: byLastMessage,
    });
    const invites = new ChannelPaginator({
      client,
      filters: { ...filters, invite: "pending" },
      id: "invites",
      paginatorOptions: { pageSize: 5 },
      sort: byCreatedAt,
    });
    client.channelManager.insertPaginator({ paginator: channels });

    // A paginator runs one query at a time, so an invite that arrives during a load is noted, and
    // loaded once that load ends. The reload keeps the rows on screen until its page arrives.
    let reloadWhenIdle = false;
    const reload = () => {
      if (invites.isLoading) {
        reloadWhenIdle = true;
        return;
      }
      reloadWhenIdle = false;
      void invites.toTail({ keepPreviousItems: true, reset: "yes" });
    };
    const offLoadEnd = invites.state.subscribeWithSelector(
      ({ isLoading }) => ({ isLoading }),
      ({ isLoading }) => {
        if (!isLoading && reloadWhenIdle) reload();
      },
    );

    // `ChannelNavigation` renders a channel list for every paginator on the manager, so the
    // invites stay off it and follow the events themselves. The invites query watches its
    // channels, so a watched channel's answer arrives as `member.updated`, not as a
    // notification. A new invite arrives as `notification.invited`.
    const { unsubscribe } = client.on((event) => {
      if (event.type === "notification.invited") reload();
      if (
        event.type === "member.updated" &&
        event.member.user_id === client.userId &&
        hasAnswered(event.member)
      )
        invites.removeItem({ id: event.cid });
    });
    setInvites(invites);

    return () => {
      unsubscribe();
      offLoadEnd();
      client.channelManager.removePaginator(channels);
      channels.dispose();
      invites.dispose();
      setInvites(undefined);
    };
  }, [client, filters]);

  return invites;
};

The channels paginator takes the place of the one your app registers for ChannelNavigation; register no other.

Render the invitations

Invitations renders the invites paginator: a heading, one row per channel in paginator.state.items, and the SDK's LoadMoreButton while paginator.state.hasMoreTail is true. It loads the first page itself with ChannelPaginator.toTail(), and the next page when the user answered every loaded invitation while paginator.state.hasMoreTail is still true. It shows nothing until the first page arrives, or when there are no invitations, heading included. A reload after a new invite keeps the rows, so the section stays on screen and the focus stays where it is.

Each row shows the channel's avatar and name from useChannelPreviewInfo(), the name of the member who created the channel, channel.data.created_by.name, and the two buttons. Accept calls Channel.acceptInvite() and Decline Channel.rejectInvite(). When the request succeeds, the row leaves the section with ChannelPaginator.removeItem(), and useAriaLiveAnnouncer() tells screen readers "Joined Bird walk" or "Declined the invitation to Bird walk". If the focus is in a row as it leaves, whatever removed it, the focus moves to the next row's Accept button, or else the previous row's. When the last row leaves, the focus is lost, also while the next page loads. When the request fails, the buttons work again, and client.notifications.addError() says so in the channel list's notifications, which origin.context.panel: "channel-list" routes there:

src/Invitations.tsx
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type {
  Channel,
  ChannelPaginator,
  ChannelPaginatorState,
} from "stream-chat";
import {
  Button,
  ChannelAvatar,
  LoadMoreButton,
  useAriaLiveAnnouncer,
  useChannelPreviewInfo,
  useChatContext,
  useStateStore,
} from "stream-chat-react";
import "./Invitations.css";

type Answer = "accept" | "decline";

const invitesSelector = ({
  hasMoreTail,
  isLoading,
  items,
}: ChannelPaginatorState) => ({
  hasMoreTail,
  isLoading,
  items,
});

/**
 * The section has nothing to show but more to load: the first page was never loaded, or the user
 * answered every loaded invitation while more remain on the server.
 */
const needsPage = (items: Channel[] | undefined, hasMoreTail: boolean) =>
  items === undefined || (items.length === 0 && hasMoreTail);

/**
 * One invitation: the channel, who invited the user, and Accept and Decline. Each button's name
 * includes the channel's name, so a screen reader tells the rows apart. While the request runs,
 * both buttons stay focusable but do nothing, and the pressed one reads "Joining…" or
 * "Declining…". Once the server answers, the row leaves the section. If the focus is in the row
 * then, it moves to the next row's first button, or else the previous row's.
 */
const Invitation = ({
  channel,
  paginator,
}: {
  channel: Channel;
  paginator: ChannelPaginator;
}) => {
  const { client } = useChatContext();
  const announce = useAriaLiveAnnouncer();
  const { displayImage, displayTitle, groupChannelDisplayInfo } =
    useChannelPreviewInfo({
      channel,
    });
  const [answering, setAnswering] = useState<Answer>();
  const rowRef = useRef<HTMLLIElement>(null);
  const inviter = channel.data?.created_by?.name;

  // Runs as the row leaves, while it is still on the page, whatever removed it.
  useLayoutEffect(() => {
    const row = rowRef.current;
    return () => {
      if (!row?.contains(document.activeElement)) return;
      const neighbour = row.nextElementSibling ?? row.previousElementSibling;
      neighbour?.querySelector("button")?.focus();
    };
  }, []);

  const answer = async (choice: Answer) => {
    if (answering) return;
    setAnswering(choice);
    try {
      if (choice === "accept") await channel.acceptInvite();
      else await channel.rejectInvite();
      // An accepted channel joins the channel list through the `member.updated` event that
      // follows, which removes the row too if it arrives first.
      paginator.removeItem({ item: channel });
      announce(
        choice === "accept"
          ? `Joined ${displayTitle}`
          : `Declined the invitation to ${displayTitle}`,
      );
    } catch (error) {
      setAnswering(undefined);
      client.notifications.addError({
        message: `Couldn’t answer the invitation to ${displayTitle}`,
        origin: {
          context: { error, panel: "channel-list" },
          emitter: "Invitation",
        },
      });
    }
  };

  return (
    <li className="cookbook-invitation" ref={rowRef}>
      <ChannelAvatar
        aria-hidden="true"
        displayMembers={groupChannelDisplayInfo.members}
        imageUrl={displayImage}
        size="xl"
        userName={displayTitle}
      />
      <div className="cookbook-invitation__body">
        <p className="cookbook-invitation__title">{displayTitle}</p>
        {inviter && (
          <p className="cookbook-invitation__by">{inviter} invited you</p>
        )}
        <div className="cookbook-invitation__actions">
          {/* aria-disabled, not disabled: a disabled button would lose focus. */}
          <Button
            appearance="solid"
            aria-busy={answering === "accept"}
            aria-disabled={!!answering}
            aria-label={
              answering === "accept"
                ? `Joining ${displayTitle}…`
                : `Accept invitation to ${displayTitle}`
            }
            onClick={() => answer("accept")}
            size="sm"
            variant="primary"
          >
            {answering === "accept" ? "Joining…" : "Accept"}
          </Button>
          <Button
            appearance="outline"
            aria-busy={answering === "decline"}
            aria-disabled={!!answering}
            aria-label={
              answering === "decline"
                ? `Declining invitation to ${displayTitle}…`
                : `Decline invitation to ${displayTitle}`
            }
            onClick={() => answer("decline")}
            size="sm"
            variant="secondary"
          >
            {answering === "decline" ? "Declining…" : "Decline"}
          </Button>
        </div>
      </div>
    </li>
  );
};

/**
 * The channels the user is invited to, as a list of invitations under an "Invitations" heading.
 * Loads its first page on mount, and each further page when the user clicks "Load more", or when
 * the user answered every loaded invitation while more remain. Shows nothing until the first page
 * arrives, or when there are no invitations.
 */
export const Invitations = ({ paginator }: { paginator: ChannelPaginator }) => {
  const { hasMoreTail, isLoading, items } = useStateStore(
    paginator.state,
    invitesSelector,
  );
  const titleId = useId();

  useEffect(() => {
    if (needsPage(items, hasMoreTail)) void paginator.toTail();
  }, [hasMoreTail, items, paginator]);

  if (!items?.length) return null;

  return (
    <section aria-labelledby={titleId} className="cookbook-invitations">
      <h2 className="cookbook-invitations__title" id={titleId}>
        Invitations
      </h2>
      <ul className="cookbook-invitations__list">
        {items.map((channel) => (
          <Invitation
            channel={channel}
            key={channel.cid}
            paginator={paginator}
          />
        ))}
      </ul>
      {hasMoreTail && (
        <div className="cookbook-invitations__more">
          <LoadMoreButton
            isLoading={isLoading}
            onClick={() => void paginator.toTail()}
          />
        </div>
      )}
    </section>
  );
};

Put the section above the channel list

ChannelNavigation renders the channel list header, its Search slot, the channel lists, and the notifications, in that order. SearchWithInvitations takes the Search slot: it renders the app's Search, then the invitations while no search is open, so they sit right above the channel list and leave the rest of ChannelNavigation as it is. WithChannelInvites creates the paginators and registers the slot with WithComponents. It reads Search from useComponentContext() outside its own WithComponents, so an app that overrides Search keeps its own, and the SDK's is the fallback. The slot gets no props, so the invites paginator and that Search reach it through a context:

src/WithChannelInvites.tsx
import { createContext, useContext, useMemo } from "react";
import type { ComponentType, PropsWithChildren } from "react";
import type {
  ChannelFilters,
  ChannelPaginator,
  SearchControllerState,
} from "stream-chat";
import {
  Search as DefaultSearch,
  useChatContext,
  useComponentContext,
  useStateStore,
  WithComponents,
} from "stream-chat-react";
import { Invitations } from "./Invitations";
import { useChannelInvites } from "./useChannelInvites";

const InvitesContext = createContext<{
  invites?: ChannelPaginator;
  Search: ComponentType;
}>({ Search: DefaultSearch });

const searchSelector = ({ isActive }: SearchControllerState) => ({ isActive });

/**
 * The app's search, then the invitations while no search is open. `ChannelNavigation` renders its
 * `Search` slot right above the channel list, so the invitations sit there too.
 */
const SearchWithInvitations = () => {
  const { invites, Search } = useContext(InvitesContext);
  const { searchController } = useChatContext();
  const { isActive } = useStateStore(searchController.state, searchSelector);

  return (
    <>
      <Search />
      {!isActive && invites && <Invitations paginator={invites} />}
    </>
  );
};

// Declared once, so WithComponents gets the same object on every render.
const overrides = { Search: SearchWithInvitations };

/**
 * Shows the channels the user is invited to above the channel list, and keeps them out of the
 * list until the user accepts. `filters` scope both to the user's channels; pass a stable object.
 * Keeps the app's own `Search` override, if it has one.
 */
export const WithChannelInvites = ({
  children,
  filters,
}: PropsWithChildren<{ filters: ChannelFilters }>) => {
  const invites = useChannelInvites(filters);
  // Read outside this component's own WithComponents, where `Search` is still the app's.
  const { Search = DefaultSearch } = useComponentContext();
  const value = useMemo(() => ({ invites, Search }), [invites, Search]);

  return (
    <InvitesContext.Provider value={value}>
      <WithComponents overrides={overrides}>{children}</WithComponents>
    </InvitesContext.Provider>
  );
};

Wrap your channels view in it, inside Chat. Declare the filters outside the component, so the paginators aren't created again on every render:

src/App.tsx
import type { ChannelFilters } from "stream-chat";
import {
  ChannelHeader,
  ChannelNavigation,
  Chat,
  MessageComposer,
  MessageList,
} from "stream-chat-react";
import { ChannelSlot, ChatView } from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
import { WithChannelInvites } from "./WithChannelInvites";

const filters: ChannelFilters = {
  members: { $in: [userId] },
  type: "messaging",
};

const layouts: LayoutDescriptor[] = [{ id: "channels", slots: ["channel"] }];

const channelsView = (
  <>
    <ChannelNavigation />
    <ChannelSlot slot="channel">
      <ChannelHeader />
      <MessageList />
      <MessageComposer />
    </ChannelSlot>
  </>
);

export const App = () => (
  <Chat client={client}>
    <WithChannelInvites filters={filters}>
      <ChatView layouts={layouts} views={{ channels: channelsView }} />
    </WithChannelInvites>
  </Chat>
);

Accept and decline

The user presses Accept on Bird walk. The button reads "Joining…" and keeps the focus, and both of the row's buttons do nothing until the server answers:


Joining after Accept

The Invitations section with Bird walk's Accept button reading Joining… and both of its buttons dimmed, while Chess club's buttons are unchanged

The screenshots on this page come from a read-only demo app: Accept and Decline send no request there, so the button keeps reading "Joining…". In your app the request goes out, the row leaves the section when it succeeds, and Bird walk joins the channel list when the server's member.updated event arrives. The client receives that event because the invites query watches Bird walk. A declined channel leaves the section the same way, and joins no list.

How it works

Info:

Example

Bob invites the user to Bird walk, and the user accepts:

  1. Bob calls Channel.inviteMembers() for the user. The user is now a member of Bird walk, with channel.state.membership.invited set to true. The user's client, which isn't watching Bird walk yet, receives notification.invited, and may also receive notification.added_to_channel.
  2. If notification.added_to_channel arrives, client.channelManager routes it to its paginators. Bird walk matches the channels paginator's members filter, but hasJoined() is false, so it stays out of the channel list.
  3. useChannelInvites() sees notification.invited and loads the invites paginator's first page again. Its query, with invite: "pending", returns and watches Bird walk, which shows under Invitations with "Bob invited you".
  4. The user presses Accept. Channel.acceptInvite() sends the request, and the button reads "Joining…".
  5. The request succeeds. Invitation removes Bird walk from the invites paginator, and screen readers hear "Joined Bird walk". The focus moves to the Accept button of the next invitation, or the previous one. With no other invitation, the section hides.
  6. The server sends member.updated to the clients watching Bird walk, as Events lists, with event.member.invite_accepted_at set. The client applies it to channel.state.membership, and client.channelManager routes the channel again. hasJoined() is now true, so Bird walk joins the channel list, at its place by last_message_at.
  7. useChannelInvites() sees the same member.updated and calls ChannelPaginator.removeItem() for Bird walk. The row is gone already, so nothing changes; had the event arrived first, it would have removed the row.

Declining runs the same way: Channel.rejectInvite() stores only the returned channel in channel.data. The member.updated event that follows sets channel.state.membership.invite_rejected_at, on a client that watches the channel, so hasJoined() stays false and the channel joins no list. The user is still a member of a declined channel, which is why the channel list needs joined: true: without it, the query for the user's channels returns the declined channels too.

An invite answered on another device reaches this one as member.updated only, since this client watches the channel and the server sends the notification.invite_* events only to clients that don't, as Events lists, and useChannelInvites() removes its row. A new invite reaches it as notification.invited, and the section loads again with it.