Search dialog

This recipe replaces the search bar above the channel list with a search dialog that opens over the app.

Note:

What you'll build

  • A search button that opens the dialog, also on ⌘K
  • One search across channels, people and messages
  • Filters and sorts for each kind of result
  • Results grouped by kind or mixed in one list
  • Opening the picked channel, message or person

Preview

A search dialog over the app with the text "Cara" and the results grouped into channels, people and messages

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.

Best practices

  • Give the dialog its filters and sorts when you create the sources, as filter generators that are on or off. Switching one at runtime is then a call on the source's filter builder.
  • After changing a source's filters or sort, search again. A source applies the change from its next query, and doesn't query on its own.
  • Empty the sources when the dialog closes with SearchController.exit(), so a closed dialog holds no results. The sources the user turned on stay on for the next time it opens.
  • Label the dialog with its heading, give every control a label, and render the results as lists of buttons, so a screen reader announces each result by its text.

Create the search controller

The controller holds three sources. Each one starts with the filter generators and sort the dialog offers, and searches at most three results per page:

src/searchController.ts
import {
  ChannelSearchSource,
  MessageSearchSource,
  SearchController,
  UserSearchSource,
} from "stream-chat";
import type { SearchSource, SortParamRequest, StreamChat } from "stream-chat";

/**
 * The values the message filters read: the author's id, and the first and last day, `YYYY-MM-DD`, as
 * days in the user's time zone.
 */
export type MessageFilterContext = {
  authorId?: string;
  sentFrom?: string;
  sentTo?: string;
};

export type DialogMessageSearchSource = MessageSearchSource<{
  messageSearchContext: MessageFilterContext;
}>;

export const channelSorts = {
  activity: [{ direction: -1, field: "last_message_at" }],
  members: [{ direction: -1, field: "member_count" }],
  newest: [{ direction: -1, field: "created_at" }],
} satisfies Record<string, SortParamRequest[]>;

export const userSorts = {
  active: [{ direction: -1, field: "last_active" }],
  name: [{ direction: 1, field: "name" }],
} satisfies Record<string, SortParamRequest[]>;

export const messageSorts = {
  newest: [{ direction: -1, field: "created_at" }],
  oldest: [{ direction: 1, field: "created_at" }],
} satisfies Record<string, SortParamRequest[]>;

/** The first moment of a day in the user's time zone, `YYYY-MM-DD`, as an ISO date. */
const startOfLocalDay = (day: string) => new Date(`${day}T00:00`).toISOString();

/** The last moment of a day in the user's time zone, `YYYY-MM-DD`, as an ISO date. */
const endOfLocalDay = (day: string) => {
  const nextDay = new Date(`${day}T00:00`);
  nextDay.setDate(nextDay.getDate() + 1);
  return new Date(nextDay.getTime() - 1).toISOString();
};

// A few results per source; Load more fetches the next few.
const pageSize = 3;

export const createSearchController = (client: StreamChat) =>
  new SearchController({
    client,
    // Every source the user turns on searches at once.
    config: { keepSingleActiveSource: false },
    sources: [
      new ChannelSearchSource(
        client,
        { pageSize, sort: channelSorts.activity },
        {
          initialFilterConfig: {
            // Replaces the built-in `name` generator: a channel matches by its name or a member's.
            name: {
              enabled: true,
              generate: ({ searchQuery }) =>
                searchQuery
                  ? {
                      $or: [
                        { name: { $autocomplete: searchQuery } },
                        { "member.user.name": { $autocomplete: searchQuery } },
                      ],
                    }
                  : null,
            },
            hideArchived: {
              enabled: true,
              generate: () => ({ archived: false }),
            },
            groupsOnly: {
              enabled: false,
              generate: () => ({ member_count: { $gt: 2 } }),
            },
          },
        },
      ),
      new UserSearchSource(client, { pageSize, sort: userSorts.name }),
      new MessageSearchSource<{ messageSearchContext: MessageFilterContext }>(
        client,
        { messageSearchSort: messageSorts.newest, pageSize },
        {
          messageSearch: {
            initialFilterConfig: {
              // Replaces the built-in `text` generator: a message matches when a word in it starts
              // with the search text.
              text: {
                enabled: true,
                generate: ({ searchQuery }) =>
                  searchQuery ? { text: { $autocomplete: searchQuery } } : null,
              },
              author: {
                enabled: true,
                generate: ({ authorId }) =>
                  authorId ? { "user.id": authorId } : null,
              },
              withAttachments: {
                enabled: false,
                generate: () => ({ attachments: { $exists: true } }),
              },
              // A field takes one operator per condition, so the two ends of the range are two
              // conditions under `$and`.
              sentBetween: {
                enabled: true,
                generate: ({ sentFrom, sentTo }) => {
                  const range = [
                    ...(sentFrom
                      ? [{ created_at: { $gte: startOfLocalDay(sentFrom) } }]
                      : []),
                    ...(sentTo
                      ? [{ created_at: { $lte: endOfLocalDay(sentTo) } }]
                      : []),
                  ];
                  return range.length ? { $and: range } : null;
                },
              },
            },
          },
        },
      ),
    ],
  });

/** The dialog's three sources, typed, from the controller `Chat` holds. */
export const getDialogSources = (searchController: SearchController) => {
  const find = <T extends SearchSource>(
    type: string,
    isType: (source: SearchSource) => source is T,
  ) => {
    const source = searchController.getSource(type);
    return source && isType(source) ? source : undefined;
  };
  return {
    channels: find(
      "channels",
      (source): source is ChannelSearchSource =>
        source instanceof ChannelSearchSource,
    ),
    messages: find(
      "messages",
      (source): source is DialogMessageSearchSource =>
        source instanceof MessageSearchSource,
    ),
    users: find(
      "users",
      (source): source is UserSearchSource =>
        source instanceof UserSearchSource,
    ),
  };
};

/**
 * Runs the current search text again in one source, from the first page. A new `sort` or filter
 * applies from the next query, and the source doesn't query on its own when they change.
 */
export const searchAgain = (
  searchController: SearchController,
  source: SearchSource,
) => {
  if (searchController.searchQuery)
    void source.search(searchController.searchQuery);
};

Pass it to Chat as searchController, once per client. Chat registers and disposes only a controller it creates itself, so the app releases its own with SearchController.dispose() on unmount, and takes its subscriptions again with SearchController.registerSubscriptions() on mount:

src/App.tsx
import { useEffect, useMemo } from "react";
import { Chat } from "stream-chat-react";
import { createSearchController } from "./searchController";

export const App = () => {
  const searchController = useMemo(
    () => createSearchController(client),
    [client],
  );

  // A controller passed to `Chat` belongs to the app, so the app releases it. Registering again on
  // mount takes back what an earlier dispose() released, as after StrictMode's remount.
  useEffect(() => {
    searchController.registerSubscriptions();
    return () => searchController.dispose();
  }, [searchController]);

  return (
    <Chat client={client} searchController={searchController}>
      {/* ChatView, as below */}
    </Chat>
  );
};
  • config: { keepSingleActiveSource: false } lets several sources be active at once. By default, SearchController.activateSource() turns the other sources off.
  • Each entry of initialFilterConfig is a filter generator. enabled sets whether its part of the filter is sent, and generate returns that part from the search text and the filter builder's context. A generator named like a built-in one replaces it, so the channel source's name generator also finds channels by a member's name.
  • The message source's text generator matches words that start with the search text, with $autocomplete, as the channel and user sources do.
  • sentBetween reads sentFrom and sentTo from the context. They're days in the user's time zone, so the range runs from the start of the first local day to the end of the last one, converted to UTC for the query. A field takes one operator per condition, so the two ends of the range are two conditions under $and.
  • The sort maps hold the orders the dialog offers. The filters panel sets one of them as the source's sort, or as messageSearchSort on the message source. Messages always sort by created_at first, so for messages only its direction changes.
  • getDialogSources() finds the three sources on the controller Chat holds, typed, so the dialog can reach their filter builders.
Info:

Example

The user types "Cara". The channel source finds Design review, General, Weekend trip and Book club, whose members include Cara, and loads the first three. The user source finds Cara, and the message source finds "Thanks Cara, looking forward to it!".

Open the dialog from the channel list

ChannelNavigation renders the search under the Search key. This button takes its place: it looks like a search field, opens the dialog, and opens it on ⌘K or Ctrl+K from anywhere in the app:

src/SearchDialogButton.tsx
import { useEffect, useState } from "react";
import type { PropsWithChildren } from "react";
import { WithComponents } from "stream-chat-react";
import { SearchDialog } from "./SearchDialog";
import "./SearchDialogButton.css";

const isMac = /Mac|iPhone|iPad/.test(navigator.userAgent);

const SearchDialogButton = () => {
  const [open, setOpen] = useState(false);

  // ⌘K on a Mac, Ctrl+K elsewhere, opens the dialog from anywhere in the app.
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      // `key` is missing on the keydown Chrome fires when it autofills a field.
      if (
        (isMac ? event.metaKey : event.ctrlKey) &&
        event.key?.toLowerCase() === "k"
      ) {
        event.preventDefault();
        setOpen(true);
      }
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, []);

  return (
    <div className="cookbook-search-trigger">
      <button
        aria-haspopup="dialog"
        aria-keyshortcuts={isMac ? "Meta+K" : "Control+K"}
        className="cookbook-search-trigger__button"
        onClick={() => setOpen(true)}
        type="button"
      >
        <span>Search</span>
        <kbd className="cookbook-search-trigger__shortcut">
          {isMac ? "⌘K" : "Ctrl K"}
        </kbd>
      </button>
      <SearchDialog onClose={() => setOpen(false)} open={open} />
    </div>
  );
};

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

/** Replaces the search above the channel list with a button that opens the search dialog. */
export const WithSearchDialog = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={overrides}>{children}</WithComponents>
);

The search button with ⌘K

The channel list with a search button and the ⌘K shortcut where the search bar was

Wrap ChannelNavigation, or the ChatView that renders it, in WithSearchDialog:

<WithSearchDialog>
  <ChatView layouts={layouts} views={views} />
</WithSearchDialog>

The rules sit in the stream-overrides layer, so they win over the SDK's own rules once the SDK's stylesheet is in an earlier layer, as Theming → CSS layers sets up. They take their colors, spacing and type from the SDK's tokens, so they follow your theme. The same goes for the stylesheets below.

Build the dialog

The dialog is a GlobalModal. It holds the search input, a switch for each source, the choice between grouped and mixed results, and the button that opens the filters:

src/SearchDialog.tsx
import { useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import type {
  SearchControllerState,
  SearchSource,
  SearchSourceState,
} from "stream-chat";
import { GlobalModal, useChatContext, useStateStore } from "stream-chat-react";
import { searchAgain } from "./searchController";
import { SearchFilters } from "./SearchFilters";
import { SearchResults } from "./SearchResults";
import type { ResultsLayout } from "./SearchResults";
import "./SearchDialog.css";

const sourceLabels: Record<string, string> = {
  channels: "Channels",
  messages: "Messages",
  users: "People",
};

const controllerSelector = ({
  searchQuery,
  sources,
}: SearchControllerState) => ({
  searchQuery,
  sources,
});

const isActiveSelector = ({ isActive }: SearchSourceState) => ({ isActive });

const SourceToggle = ({ source }: { source: SearchSource }) => {
  const { searchController } = useChatContext();
  const { isActive } = useStateStore(source.state, isActiveSelector);
  // The last active source can't be turned off.
  const isLastActive = isActive && searchController.activeSources.length === 1;

  return (
    <label className="cookbook-search-dialog__chip">
      <input
        checked={isActive}
        disabled={isLastActive}
        onChange={(event) => {
          if (event.target.checked) {
            searchController.activateSource(source.type);
            // Activating a source doesn't search it, so run the current text in it.
            searchAgain(searchController, source);
          } else {
            searchController.deactivateSource(source.type);
          }
        }}
        type="checkbox"
      />
      {sourceLabels[source.type] ?? source.type}
    </label>
  );
};

/** Moves the focus between the search input and the results with the arrow keys. */
const moveFocus = (
  event: KeyboardEvent<HTMLDivElement>,
  input: HTMLInputElement | null,
) => {
  if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
  const results = [
    ...event.currentTarget.querySelectorAll<HTMLElement>(
      "[data-search-result]",
    ),
  ];
  const index = results.indexOf(event.target as HTMLElement);
  if (!results.length || (index === -1 && event.target !== input)) return;
  event.preventDefault();
  const next =
    event.key === "ArrowDown"
      ? Math.min(index + 1, results.length - 1)
      : index - 1;
  (next < 0 ? input : results[next])?.focus();
};

type SearchDialogProps = {
  onClose: () => void;
  open: boolean;
};

export const SearchDialog = ({ onClose, open }: SearchDialogProps) => {
  const { searchController } = useChatContext();
  const { searchQuery, sources } = useStateStore(
    searchController.state,
    controllerSelector,
  );
  const [layout, setLayout] = useState<ResultsLayout>("grouped");
  const [filtersOpen, setFiltersOpen] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
  const id = useId();

  // No source is active the first time the dialog opens, so it turns them all on. Later, the
  // sources stay as the user left them: SearchController.exit() doesn't turn them off. The dialog
  // doesn't call SearchController.activate(): that marks search as open, and ChannelNavigation
  // would hide the channel lists behind the dialog.
  useEffect(() => {
    if (!open || searchController.activeSources.length) return;
    searchController.sources.forEach((source) =>
      searchController.activateSource(source.type),
    );
  }, [open, searchController]);

  const close = () => {
    // Empties the text and every source's results, and cancels the queries in flight.
    searchController.exit();
    onClose();
  };

  return (
    <GlobalModal aria-labelledby={`${id}-title`} onClose={close} open={open}>
      <div
        className="cookbook-search-dialog"
        onKeyDown={(event) => moveFocus(event, inputRef.current)}
      >
        <div className="cookbook-search-dialog__header">
          <h2 className="cookbook-search-dialog__title" id={`${id}-title`}>
            Search
          </h2>
          <button
            aria-keyshortcuts="Escape"
            className="cookbook-search-dialog__close"
            onClick={close}
            type="button"
          >
            Close
          </button>
        </div>
        {/* The dialog's content mounts a render after the modal opens, so the input takes the
            focus itself. */}
        <input
          aria-label="Search channels, people and messages"
          autoFocus
          className="cookbook-search-dialog__input"
          onChange={(event) => {
            const text = event.target.value;
            if (text) void searchController.search(text);
            // An empty text doesn't search, so clear the results instead.
            else searchController.exit();
          }}
          placeholder="Search channels, people and messages"
          ref={inputRef}
          type="search"
          value={searchQuery ?? ""}
        />
        <div className="cookbook-search-dialog__toolbar">
          <fieldset className="cookbook-search-dialog__options">
            <legend className="cookbook-search-dialog__legend">
              Search in
            </legend>
            {sources.map((source) => (
              <SourceToggle key={source.type} source={source} />
            ))}
          </fieldset>
          <fieldset className="cookbook-search-dialog__options">
            <legend className="cookbook-search-dialog__legend">Show</legend>
            {(["grouped", "mixed"] as const).map((value) => (
              <label className="cookbook-search-dialog__chip" key={value}>
                <input
                  checked={layout === value}
                  name={`${id}-layout`}
                  onChange={() => setLayout(value)}
                  type="radio"
                  value={value}
                />
                {value === "grouped" ? "Grouped" : "Mixed"}
              </label>
            ))}
          </fieldset>
          <button
            aria-controls={`${id}-filters`}
            aria-expanded={filtersOpen}
            className="cookbook-search-dialog__filters-button"
            onClick={() => setFiltersOpen((isOpen) => !isOpen)}
            type="button"
          >
            Filters and sort
          </button>
        </div>
        {filtersOpen && <SearchFilters id={`${id}-filters`} />}
        {searchQuery ? (
          <SearchResults close={close} layout={layout} />
        ) : (
          <p className="cookbook-search-dialog__hint">
            Type to search. ↑ and ↓ move between results, Enter opens one.
          </p>
        )}
      </div>
    </GlobalModal>
  );
};
  • GlobalModal renders the dialog on the modal manager that Chat creates, with an overlay, and keeps the focus inside it. Escape and a click on the overlay call onClose. aria-labelledby names the dialog after its heading. The modal renders its content a render after it opens, so the input takes the focus with autoFocus.
  • The first time the dialog opens, no source is active yet, so the dialog turns every source on with SearchController.activateSource(). After that, the sources stay as the user left them. The dialog doesn't call SearchController.activate(): it marks search as open in searchController.state.isActive, and ChannelNavigation then hides the channel lists behind the dialog.
  • Typing calls SearchController.search(), which passes the text to every active source. Each source waits for the user to stop typing before it queries, and until then the results show that it's searching. An empty text doesn't search, so the input empties the results with SearchController.exit() instead.
  • A switch calls SearchController.activateSource() or SearchController.deactivateSource(). Activating a source doesn't search it, so searchAgain() runs the current text in it. The last active source can't be turned off, so its switch is disabled.
  • Closing calls SearchController.exit(), which empties the text and every source's results and cancels the queries in flight. The sources stay active, and their filters and sorts stay as the user set them, for the next time the dialog opens.
  • ↑ and ↓ move the focus from the input through the results and back. Tab reaches every control, and Enter or Space opens the focused result.

Show the results

Each source keeps its results in its state store. The results component subscribes to the three stores and shows the active sources' results in one of two layouts:

src/SearchResults.tsx
import { useId } from "react";
import type { MouseEvent, PropsWithChildren } from "react";
import { convertTimestampToDate } from "stream-chat";
import type {
  Channel,
  ChannelStateData,
  MessageResponse,
  SearchControllerState,
  SearchSource,
  SearchSourceState,
  TimestampNS,
  UserResponse,
} from "stream-chat";
import {
  Avatar,
  useChannelDisplayName,
  useChatContext,
  useStateStore,
  useWorkspaceNavigation,
} from "stream-chat-react";
import { getDialogSources } from "./searchController";
import "./SearchResults.css";

export type ResultsLayout = "grouped" | "mixed";

const sourceLabels: Record<string, string> = {
  channels: "Channels",
  messages: "Messages",
  users: "People",
};

const tagLabels: Record<string, string> = {
  channels: "Channel",
  messages: "Message",
  users: "Person",
};

type Result =
  | { item: Channel; type: "channels" }
  | { item: MessageResponse; type: "messages" }
  | { item: UserResponse; type: "users" };

const resultKey = ({ item, type }: Result) =>
  `${type}:${type === "channels" ? item.cid : item.id}`;

// The time a mixed list sorts by: a channel's last message, a person's last activity, a message's
// creation. The SDK keeps timestamps as nanosecond numbers.
const resultTime = (result: Result) => {
  const timestamp =
    result.type === "channels"
      ? result.item.data?.last_message_at
      : result.type === "users"
        ? result.item.last_active
        : result.item.created_at;
  return convertTimestampToDate(timestamp)?.getTime() ?? 0;
};

const formatDate = (timestamp?: TimestampNS | null) =>
  convertTimestampToDate(timestamp)?.toLocaleDateString(undefined, {
    day: "numeric",
    month: "short",
  });

// The avatar repeats the row's name, so it's hidden from assistive technology. Avatar renders
// role="button" by default, which a result row mustn't announce.
const decorative = { "aria-hidden": true, role: "presentation" } as const;

const memberCountSelector = ({ memberCount }: ChannelStateData) => ({
  memberCount,
});

const sourceSelector = ({
  hasNext,
  isActive,
  isLoading,
  items,
  lastQueryError,
  searchQuery,
}: SearchSourceState) => ({
  hasNext,
  isActive,
  isLoading,
  items,
  lastQueryError,
  searchQuery,
});

const searchQuerySelector = ({ searchQuery }: SearchControllerState) => ({
  searchQuery,
});

/** Opens what a result points to, then closes the dialog. */
const useOpenResult = (close: () => void) => {
  const { channelManager, client } = useChatContext();
  const { openChannel } = useWorkspaceNavigation();

  const reportError = (error: Error) =>
    client.notifications.addError({
      message: "Failed to load the channel",
      options: { originalError: error },
      origin: { emitter: "SearchDialog" },
    });

  return async ({ item, type }: Result, event: MouseEvent) => {
    close();
    if (type === "channels") {
      openChannel(item, { event });
      // Channel search doesn't watch its results, so watch the channel to receive its events.
      item.watch().catch(reportError);
      channelManager.ingestChannel(item);
      return;
    }
    if (type === "messages") {
      // Message search stores every result's channel.
      const channel = item.cid ? channelManager.get(item.cid) : undefined;
      if (!channel) return;
      openChannel(channel, { event });
      channelManager.ingestChannel(channel);
      // Loads the page around the message, scrolls to it and watches the channel.
      channel.messagePaginator
        .jumpToMessage(item.id, { watchChannel: true })
        .catch(reportError);
      return;
    }
    // A person opens the user's direct conversation with them, if there is one: a channel with only
    // the two of them and no name of its own.
    try {
      const channels = await client.queryChannelsAndHydrate(
        {
          filter_conditions: {
            $and: [
              { members: { $in: [client.userId ?? ""] } },
              { members: { $in: [item.id] } },
            ],
            member_count: 2,
            type: "messaging",
          },
        },
        { withResponse: false },
      );
      const directMessage = channels.find(
        (channel) => !("name" in (channel.data?.custom ?? {})),
      );
      if (!directMessage) {
        client.notifications.addInfo({
          message: `No conversation with ${item.name ?? item.id} yet`,
          origin: { emitter: "SearchDialog" },
        });
        return;
      }
      openChannel(directMessage, { event });
      channelManager.ingestChannel(directMessage);
    } catch (error) {
      reportError(error as Error);
    }
  };
};

type ResultRowProps = {
  onOpen: (result: Result, event: MouseEvent) => void;
  result: Result;
  showTag?: boolean;
};

/**
 * Opens the result. The avatar sits beside the button, since a button can't contain the SDK's
 * Avatar, a `div`; the button's `::after` covers the whole row, so a click on the avatar opens the
 * result too.
 */
const ResultButton = ({
  children,
  onOpen,
  result,
  showTag,
}: PropsWithChildren<ResultRowProps>) => (
  <button
    className="cookbook-search-result__button"
    data-search-result
    onClick={(event) => void onOpen(result, event)}
    type="button"
  >
    <span className="cookbook-search-result__text">{children}</span>
    {showTag && (
      <span className="cookbook-search-result__tag">
        {tagLabels[result.type]}
      </span>
    )}
  </button>
);

const ChannelResult = ({
  channel,
  ...row
}: ResultRowProps & { channel: Channel }) => {
  const name = useChannelDisplayName(channel) ?? channel.id ?? "";
  const { memberCount } = useStateStore(channel.state, memberCountSelector);
  return (
    <>
      <Avatar {...decorative} size="md" userName={name} />
      <ResultButton {...row}>
        <span className="cookbook-search-result__title">{name}</span>
        <span className="cookbook-search-result__detail">
          {memberCount} members
        </span>
      </ResultButton>
    </>
  );
};

const UserResult = ({
  user,
  ...row
}: ResultRowProps & { user: UserResponse }) => (
  <>
    <Avatar
      {...decorative}
      imageUrl={user.image}
      size="md"
      userName={user.name ?? user.id}
    />
    <ResultButton {...row}>
      <span className="cookbook-search-result__title">
        {user.name ?? user.id}
      </span>
      <span className="cookbook-search-result__detail">
        {user.last_active ? `Active ${formatDate(user.last_active)}` : "Person"}
      </span>
    </ResultButton>
  </>
);

const MessageResult = ({
  message,
  ...row
}: ResultRowProps & { message: MessageResponse }) => {
  const { channelManager } = useChatContext();
  const channel = message.cid ? channelManager.get(message.cid) : undefined;
  const channelName = useChannelDisplayName(channel);
  const author = message.user?.name ?? message.user?.id ?? "";
  return (
    <>
      <Avatar
        {...decorative}
        imageUrl={message.user?.image}
        size="md"
        userName={author}
      />
      <ResultButton {...row}>
        <span className="cookbook-search-result__title">{message.text}</span>
        <span className="cookbook-search-result__detail">
          {author}
          {channelName && ` in ${channelName}`} ·{" "}
          {formatDate(message.created_at)}
        </span>
      </ResultButton>
    </>
  );
};

const ResultRow = (props: ResultRowProps) => {
  const { result } = props;
  return (
    <li className="cookbook-search-result">
      {result.type === "channels" && (
        <ChannelResult {...props} channel={result.item} />
      )}
      {result.type === "users" && <UserResult {...props} user={result.item} />}
      {result.type === "messages" && (
        <MessageResult {...props} message={result.item} />
      )}
    </li>
  );
};

/**
 * What one source has loaded, as results. A source waits for a pause in typing before it queries,
 * so until its own `searchQuery` is the controller's, it's still searching.
 */
const useSourceResults = (
  source: SearchSource | undefined,
  searchQuery: string,
) => {
  const state = useStateStore(source?.state, sourceSelector);
  const results = (state?.items ?? []).map(
    (item) => ({ item, type: source?.type }) as Result,
  );
  const isSearching =
    !!state && (state.isLoading || state.searchQuery !== searchQuery);
  return { ...state, isSearching, results, source };
};

type SourceResults = ReturnType<typeof useSourceResults>;

// Hidden while a source is searching, so it never loads the next page of the previous text.
const hasMore = ({ hasNext, isSearching, items }: SourceResults) =>
  !!items?.length && hasNext && !isSearching;

const GroupedResults = ({
  groups,
  onOpen,
}: {
  groups: SourceResults[];
  onOpen: ResultRowProps["onOpen"];
}) => {
  const headingId = useId();
  return groups.map((group) => {
    const type = group.source?.type ?? "";
    return (
      <section
        aria-labelledby={`${headingId}-${type}`}
        className="cookbook-search-results__group"
        key={type}
      >
        <h3
          className="cookbook-search-results__heading"
          id={`${headingId}-${type}`}
        >
          {sourceLabels[type]}
        </h3>
        {group.lastQueryError ? (
          <p className="cookbook-search-results__note">This search failed.</p>
        ) : group.results.length ? (
          <ul className="cookbook-search-results__list">
            {group.results.map((result) => (
              <ResultRow
                key={resultKey(result)}
                onOpen={onOpen}
                result={result}
              />
            ))}
          </ul>
        ) : (
          <p className="cookbook-search-results__note">
            {group.isSearching ? "Searching…" : "No results"}
          </p>
        )}
        {hasMore(group) && (
          <button
            className="cookbook-search-results__more"
            onClick={() => void group.source?.search()}
            type="button"
          >
            Load more {sourceLabels[type].toLowerCase()}
          </button>
        )}
      </section>
    );
  });
};

const MixedResults = ({
  groups,
  onOpen,
}: {
  groups: SourceResults[];
  onOpen: ResultRowProps["onOpen"];
}) => {
  const results = groups
    .flatMap((group) => group.results)
    .sort((a, b) => resultTime(b) - resultTime(a));
  const loadable = groups.filter(hasMore);
  return (
    <>
      {results.length ? (
        <ul aria-label="Results" className="cookbook-search-results__list">
          {results.map((result) => (
            <ResultRow
              key={resultKey(result)}
              onOpen={onOpen}
              result={result}
              showTag
            />
          ))}
        </ul>
      ) : (
        <p className="cookbook-search-results__note">
          {groups.some((group) => group.isSearching)
            ? "Searching…"
            : "No results"}
        </p>
      )}
      {!!loadable.length && (
        <button
          className="cookbook-search-results__more"
          onClick={() =>
            loadable.forEach((group) => void group.source?.search())
          }
          type="button"
        >
          Load more
        </button>
      )}
    </>
  );
};

type SearchResultsProps = {
  close: () => void;
  layout: ResultsLayout;
};

export const SearchResults = ({ close, layout }: SearchResultsProps) => {
  const { searchController } = useChatContext();
  const { searchQuery = "" } = useStateStore(
    searchController.state,
    searchQuerySelector,
  );
  const sources = getDialogSources(searchController);
  const groups = [
    useSourceResults(sources.channels, searchQuery),
    useSourceResults(sources.users, searchQuery),
    useSourceResults(sources.messages, searchQuery),
  ].filter((group) => group.isActive);
  const onOpen = useOpenResult(close);
  const resultCount = groups.reduce(
    (count, group) => count + group.results.length,
    0,
  );
  const isSearching = groups.some((group) => group.isSearching);

  return (
    <div className="cookbook-search-results">
      <p className="cookbook-search-results__status" role="status">
        {isSearching ? "Searching…" : `${resultCount} results`}
      </p>
      {layout === "grouped" ? (
        <GroupedResults groups={groups} onOpen={onOpen} />
      ) : (
        <MixedResults groups={groups} onOpen={onOpen} />
      )}
    </div>
  );
};

Grouped shows a section per active source, with a heading and the source's own Load more. BaseSearchSource.search() with no text loads that source's next page, while hasNext in the source's state says there is one.

A source waits for a pause in typing before it queries, so for a moment its results are still for the previous text. Until searchQuery in the source's state is the controller's searchQuery, the source counts as searching: its section and the status line say "Searching…" instead of "No results" or a count, and Load more is hidden, so it never loads the next page of the previous text.

Mixed merges the results into one list, newest first, and tags each row with its kind at its end. A channel sorts by its last message, a person by their last activity, and a message by when it was sent. Load more loads the next page of every source that has one, and the new results take their place in the list.


Mixed results

The same search with the results mixed into one list, each row tagged Person, Channel or Message

Picking a result closes the dialog, then opens what the result points to with openChannel from useWorkspaceNavigation(). The click event is passed on, so ⌘/ctrl-click can open it beside the current channel:

  • A channel is watched with Channel.watch(), since channel search doesn't watch its results, and added to the channel lists it matches with ChannelManager.ingestChannel().
  • A message opens its channel, which the message source stored with the result, and MessagePaginator.jumpToMessage() loads the page around the message, scrolls to it and watches the channel.
  • A person opens the user's direct conversation with them: client.queryChannelsAndHydrate() looks for a channel with only the two of them and no name of its own. When there isn't one, the dialog says so with client.notifications.addInfo(). To start a conversation instead, create it with client.channel("messaging", { members }) and call Channel.watch(), as the SDK's default user result does.

The status line above the results has role="status", so a screen reader announces "Searching…" and then the number of results. The avatars repeat the row's text, so they're hidden from assistive technology. Avatar renders a div, which a button mustn't contain, so each avatar sits beside its row's button, and the button's ::after covers the whole row, so a click on the avatar opens the result too.

Filter and sort each source

The filters panel changes the sources' filter generators and sorts while the user searches:

src/SearchFilters.tsx
import { useMemo, useState } from "react";
import type { SearchSource, SortParamRequest } from "stream-chat";
import { useChatContext, useStateStore } from "stream-chat-react";
import {
  channelSorts,
  getDialogSources,
  messageSorts,
  searchAgain,
  userSorts,
} from "./searchController";
import type { MessageFilterContext } from "./searchController";
import "./SearchFilters.css";

type Generators = Record<string, { enabled: boolean }>;

const enabledSelector = (config: Generators) => ({
  groupsOnly: !!config.groupsOnly?.enabled,
  hideArchived: !!config.hideArchived?.enabled,
  withAttachments: !!config.withAttachments?.enabled,
});

const messageContextSelector = ({
  authorId,
  sentFrom,
  sentTo,
}: MessageFilterContext) => ({
  authorId,
  sentFrom,
  sentTo,
});

/** The key of the sort a source uses, from one of the sort maps. */
const sortKeyOf = <K extends string>(
  sorts: Record<K, SortParamRequest[]>,
  sort: SortParamRequest[] | undefined,
) => (Object.keys(sorts) as K[]).find((key) => sorts[key] === sort);

/** Everyone in the user's loaded channels, for the "From" select. */
const usePeople = () => {
  const { channelManager, client } = useChatContext();
  return useMemo(() => {
    const people = new Map<string, string>();
    for (const channel of channelManager.values()) {
      for (const member of Object.values(channel.state.members)) {
        if (member.user && member.user.id !== client.userId) {
          people.set(member.user.id, member.user.name ?? member.user.id);
        }
      }
    }
    return [...people].sort(([, a], [, b]) => a.localeCompare(b));
  }, [channelManager, client]);
};

export const SearchFilters = ({ id }: { id: string }) => {
  const { client, searchController } = useChatContext();
  const { channels, messages, users } = getDialogSources(searchController);
  const people = usePeople();

  const channelFilters = useStateStore(
    channels?.filterBuilder.filterConfig,
    enabledSelector,
  );
  const messageFilters = useStateStore(
    messages?.messageSearchFilterBuilder.filterConfig,
    enabledSelector,
  );
  const messageContext = useStateStore(
    messages?.messageSearchFilterBuilder.context,
    messageContextSelector,
  );

  // A source's sort isn't in a store, so the selects keep their own copy, read from the source.
  const [channelSort, setChannelSort] = useState<string>(
    () => sortKeyOf(channelSorts, channels?.sort) ?? "activity",
  );
  const [userSort, setUserSort] = useState<string>(
    () => sortKeyOf(userSorts, users?.sort) ?? "name",
  );
  const [messageSort, setMessageSort] = useState<string>(
    () => sortKeyOf(messageSorts, messages?.messageSearchSort) ?? "newest",
  );

  // Every change applies from the next query, so search again from the first page.
  const change = (source: SearchSource, apply: () => void) => {
    apply();
    searchAgain(searchController, source);
  };

  return (
    <div className="cookbook-search-filters" id={id}>
      {channels && (
        <fieldset className="cookbook-search-filters__group">
          <legend className="cookbook-search-filters__legend">Channels</legend>
          <div className="cookbook-search-filters__controls">
            <label className="cookbook-search-filters__field">
              Sort by
              <select
                className="cookbook-search-filters__select"
                onChange={(event) => {
                  const key = event.target.value as keyof typeof channelSorts;
                  setChannelSort(event.target.value);
                  change(channels, () => {
                    channels.sort = channelSorts[key];
                  });
                }}
                value={channelSort}
              >
                <option value="activity">Recent activity</option>
                <option value="newest">Newest</option>
                <option value="members">Most members</option>
              </select>
            </label>
            <label className="cookbook-search-filters__check">
              <input
                checked={!channelFilters?.hideArchived}
                onChange={(event) =>
                  change(channels, () =>
                    event.target.checked
                      ? channels.filterBuilder.disableFilter("hideArchived")
                      : channels.filterBuilder.enableFilter("hideArchived"),
                  )
                }
                type="checkbox"
              />
              Include archived
            </label>
            <label className="cookbook-search-filters__check">
              <input
                checked={!!channelFilters?.groupsOnly}
                onChange={(event) =>
                  change(channels, () =>
                    event.target.checked
                      ? channels.filterBuilder.enableFilter("groupsOnly")
                      : channels.filterBuilder.disableFilter("groupsOnly"),
                  )
                }
                type="checkbox"
              />
              Groups only
            </label>
          </div>
        </fieldset>
      )}

      {users && (
        <fieldset className="cookbook-search-filters__group">
          <legend className="cookbook-search-filters__legend">People</legend>
          <div className="cookbook-search-filters__controls">
            <label className="cookbook-search-filters__field">
              Sort by
              <select
                className="cookbook-search-filters__select"
                onChange={(event) => {
                  const key = event.target.value as keyof typeof userSorts;
                  setUserSort(event.target.value);
                  change(users, () => {
                    users.sort = userSorts[key];
                  });
                }}
                value={userSort}
              >
                <option value="name">Name</option>
                <option value="active">Recently active</option>
              </select>
            </label>
          </div>
        </fieldset>
      )}

      {messages && (
        <fieldset className="cookbook-search-filters__group">
          <legend className="cookbook-search-filters__legend">Messages</legend>
          <div className="cookbook-search-filters__controls">
            <label className="cookbook-search-filters__field">
              Order
              <select
                className="cookbook-search-filters__select"
                onChange={(event) => {
                  const key = event.target.value as keyof typeof messageSorts;
                  setMessageSort(event.target.value);
                  change(messages, () => {
                    messages.messageSearchSort = messageSorts[key];
                  });
                }}
                value={messageSort}
              >
                <option value="newest">Newest first</option>
                <option value="oldest">Oldest first</option>
              </select>
            </label>
            <label className="cookbook-search-filters__field">
              From
              <select
                className="cookbook-search-filters__select"
                onChange={(event) =>
                  change(messages, () =>
                    messages.messageSearchFilterBuilder.updateContext({
                      authorId: event.target.value || undefined,
                    }),
                  )
                }
                value={messageContext?.authorId ?? ""}
              >
                <option value="">Anyone</option>
                <option value={client.userId}>You</option>
                {people.map(([userId, name]) => (
                  <option key={userId} value={userId}>
                    {name}
                  </option>
                ))}
              </select>
            </label>
            <label className="cookbook-search-filters__field">
              Sent from
              <input
                className="cookbook-search-filters__date"
                onChange={(event) =>
                  change(messages, () =>
                    messages.messageSearchFilterBuilder.updateContext({
                      sentFrom: event.target.value || undefined,
                    }),
                  )
                }
                type="date"
                value={messageContext?.sentFrom ?? ""}
              />
            </label>
            <label className="cookbook-search-filters__field">
              Sent until
              <input
                className="cookbook-search-filters__date"
                onChange={(event) =>
                  change(messages, () =>
                    messages.messageSearchFilterBuilder.updateContext({
                      sentTo: event.target.value || undefined,
                    }),
                  )
                }
                type="date"
                value={messageContext?.sentTo ?? ""}
              />
            </label>
            <label className="cookbook-search-filters__check">
              <input
                checked={!!messageFilters?.withAttachments}
                onChange={(event) =>
                  change(messages, () =>
                    event.target.checked
                      ? messages.messageSearchFilterBuilder.enableFilter(
                          "withAttachments",
                        )
                      : messages.messageSearchFilterBuilder.disableFilter(
                          "withAttachments",
                        ),
                  )
                }
                type="checkbox"
              />
              With attachments
            </label>
          </div>
        </fieldset>
      )}
    </div>
  );
};

The filters and sort panel

The filters and sort panel open in the dialog, with a row each for channels, people and messages

  • Include archived and Groups only turn the channel source's hideArchived and groupsOnly generators off and on with FilterBuilder.disableFilter() and FilterBuilder.enableFilter(), on channelSource.filterBuilder. With attachments does the same with the message source's withAttachments generator, on messageSource.messageSearchFilterBuilder.
  • From, Sent from and Sent until set values in the message filter builder's context with FilterBuilder.updateContext(). The author and sentBetween generators read them on the next query. The dates are days in the user's time zone.
  • Sort by sets channelSource.sort or userSource.sort, and Order sets messageSource.messageSearchSort, to one of the sort maps.
  • The checkboxes and the context fields read their values with useStateStore(), from filterBuilder.filterConfig and filterBuilder.context, so they show the source's current settings. A sort isn't in a store, so the selects keep their own copy, read from the source when the panel opens.

A source applies a new filter or sort from its next query, and doesn't query when it changes. searchAgain() calls BaseSearchSource.search() with the current text, which counts as a new search: the source drops its results and loads the first page again with the new settings. Calling it with no text would load the next page with them instead, after results the old settings found.

How it works

  1. createSearchController() creates the channel, user and message sources with the dialog's filter generators and sorts, and lets them be active together. Chat holds the controller, and useChatContext() gives it to the dialog.
  2. The button above the channel list, or ⌘K, opens the dialog. The first time, the dialog activates every source.
  3. Each keystroke passes the text to the active sources through SearchController.search(). Each source waits for a pause in typing, queries, and publishes its results in its state store, which the results component renders grouped or mixed.
  4. A switch, a filter or a sort changes one source and runs the current text in it again, from the first page.
  5. Picking a result closes the dialog, empties the search with SearchController.exit(), and opens the channel, the message or the direct conversation in the layout.
  • Search sources explains the controller, the built-in sources, filter generators and the source's state.
  • Dialogs explains GlobalModal and the modal manager.
  • Search customization changes the SDK's own search bar and results instead of replacing them.
  • Message paginators explains MessagePaginator.jumpToMessage().
  • Navigation explains useWorkspaceNavigation() and where openChannel opens a channel.
  • Search explains how search works across the layers.