# GIF picker

The SDK has no GIF picker, but it renders a `giphy` attachment, the kind the `/giphy` command produces, and the composer sends one that the app adds. This UI cookbook builds a picker on top of that: a GIF button in the attachment picker's bar opens a sheet with search, trending GIFs and a scrolling grid, the picked GIF shows above the input, and the sent message renders like one sent with `/giphy`. The attachment and its rendering are explained in [Commands and Giphy](https://getstream.io/chat/docs/sdk/react-native/features/rich-content/commands-and-giphy/), and the attachment picker in [Message composer UI](https://getstream.io/chat/docs/sdk/react-native/ui/components/message-composer-ui/).

>
> **Note:** **What you'll build**
>
> - A Giphy search source with trending and search results
> - A GIF sheet with a search field and a grid
> - A GIF button in the attachment picker's bar
> - A GIF card above the input
>

---

**Approach:** Replace one part and Compose. [Choosing where to start](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/overview/#choosing-where-to-start) explains the approaches.

## Best practices

- Put the picker in a sheet of its own, not in the attachment picker's content. The attachment picker closes when the keyboard opens, so a search field inside it would close it as the user taps the field.
- Add the button to the attachment picker's bar through the `AttachmentPickerSelectionBar` key, so it sits with the other attachment types.
- Search with a search source. `BaseSearchSource` already debounces the text, cancels a request that a newer one replaces, and pages by offset, so the sheet only renders its state.
- Send the GIF by URL as a `giphy` attachment with `giphy`, `thumb_url` and `title`. Nothing is uploaded, and the message list renders it with its `Giphy` component.
- Let a GIF replace what the composer holds. A message renders a `giphy` attachment only while it has no image, video or file, so a GIF next to a photo isn't shown.
- Keep the API key out of the code. Read it from your app's configuration, and in production proxy the requests through your own server. Giphy also asks apps to show its attribution next to results, so the sheet shows "Powered by GIPHY".

## A GIF search source

A GIF is an id, a title and its renditions. `attachment.giphy` holds seven renditions, typed as `Images` in `stream-chat`, and the message list reads the one that the `giphyVersion` prop of `Channel` names, `fixed_height` by default. `toImages()` turns whatever the source returns into that shape, and `toGiphyAttachment()` makes the attachment the composer adds:

```ts title="src/gif.ts"
import type {
  GiphyVersions,
  ImageData,
  Images,
  LocalAttachment,
} from "stream-chat";

/** One GIF the picker offers: its id, a title for screen readers, and its renditions. */
export type Gif = {
  id: string;
  images: Images;
  title: string;
};

/**
 * The renditions in the shape `attachment.giphy` takes: the seven versions the SDK knows, with
 * every field a string. A missing field becomes an empty string.
 */
export const toImages = (
  versions: Partial<Record<GiphyVersions, Partial<ImageData>>>,
): Images => {
  const imageData = (version: GiphyVersions): ImageData => {
    const {
      frames = "",
      height = "",
      size = "",
      url = "",
      width = "",
    } = versions[version] ?? {};
    return { frames, height, size, url, width };
  };
  return {
    fixed_height: imageData("fixed_height"),
    fixed_height_downsampled: imageData("fixed_height_downsampled"),
    fixed_height_still: imageData("fixed_height_still"),
    fixed_width: imageData("fixed_width"),
    fixed_width_downsampled: imageData("fixed_width_downsampled"),
    fixed_width_still: imageData("fixed_width_still"),
    original: imageData("original"),
  };
};

/**
 * The attachment the composer adds for a GIF. It has no file and no upload state, so the composer
 * sends it as it is. `thumb_url` is the image the composer shows; `giphy` holds the renditions the
 * message list draws.
 */
export const toGiphyAttachment = (gif: Gif): LocalAttachment => ({
  custom: {},
  giphy: gif.images,
  localMetadata: { id: `gif-${gif.id}` },
  thumb_url: gif.images.fixed_height.url,
  title: gif.title,
  type: "giphy",
});
```

`fetchGiphyPage()` asks Giphy's API for one page: its search for a search text, its trending GIFs for an empty one. The `signal` lets the source cancel the request:

```ts title="src/giphy.ts"
import type { GiphyVersions, ImageData } from "stream-chat";
import type { Gif } from "./gif";
import { toImages } from "./gif";

/** The part of a Giphy API GIF object the picker reads. */
type GiphyGif = {
  id: string;
  images: Partial<Record<GiphyVersions, Partial<ImageData>>>;
  title: string;
};

export type GifPage = { limit: number; offset: number };

/**
 * One page of Giphy's search results, or of its trending GIFs when `searchQuery` is empty.
 * `signal` cancels the request when a newer query starts.
 */
export const fetchGiphyPage = async ({
  apiKey,
  page,
  searchQuery,
  signal,
}: {
  apiKey: string;
  page: GifPage;
  searchQuery: string;
  signal?: AbortSignal;
}): Promise<Gif[]> => {
  const params = new URLSearchParams({
    api_key: apiKey,
    limit: String(page.limit),
    offset: String(page.offset),
    rating: "g",
  });
  if (searchQuery) params.set("q", searchQuery);
  const endpoint = searchQuery ? "search" : "trending";

  const response = await fetch(
    `https://api.giphy.com/v1/gifs/${endpoint}?${params.toString()}`,
    { signal },
  );
  if (!response.ok) throw new Error(`Giphy answered ${response.status}`);
  const { data } = (await response.json()) as { data: GiphyGif[] };

  return data.map(({ id, images, title }) => ({
    id,
    images: toImages(images),
    title,
  }));
};
```

`GifSearchSource` extends `BaseSearchSource`. Its `query()` returns one page as `{ items }`, so the source pages by offset: it moves `offset` forward by the number of items, and `hasNext` stays `true` while a page comes back full. `allowEmptySearchString` makes an empty search text a query too, the one for trending GIFs:

```ts title="src/GifSearchSource.ts"
import { BaseSearchSource } from "stream-chat";
import type { SearchQueryOptions, SearchSourceOptions } from "stream-chat";
import type { Gif } from "./gif";
import { fetchGiphyPage } from "./giphy";

/** Searches GIFs: Giphy's search, or its trending GIFs while the search text is empty. */
export class GifSearchSource extends BaseSearchSource<Gif> {
  readonly type = "gifs";
  private readonly apiKey: string;

  constructor({
    apiKey,
    ...options
  }: SearchSourceOptions & { apiKey: string }) {
    // An empty search text is a query too: it loads the trending GIFs.
    super({
      allowEmptySearchString: true,
      debounceMs: 300,
      pageSize: 24,
      ...options,
    });
    this.apiKey = apiKey;
  }

  protected async query(
    searchQuery: string,
    { signal }: SearchQueryOptions = {},
  ) {
    const page = { limit: this.pageSize, offset: this.offset ?? 0 };
    const items = await fetchGiphyPage({
      apiKey: this.apiKey,
      page,
      searchQuery,
      signal,
    });
    return { items };
  }

  protected filterQueryResults(items: Gif[]) {
    return items;
  }
}
```

[Search sources](https://getstream.io/chat/docs/sdk/react-native/state/loading-data/search-sources/) explains the contract the source implements.

## The GIF sheet

The sheet holds a search field and a grid. It starts a `GifSearchSource` when it opens, loads the trending GIFs at once with `executeQuery("")`, runs a search as the user types with `search(text)`, and loads the next page with `search()` when the grid reaches its end. Each tile calls `onPick` with its GIF:

```tsx title="src/GifSheet.tsx"
import { useEffect, useState } from "react";
import {
  FlatList,
  Image,
  Modal,
  Platform,
  Pressable,
  StyleSheet,
  Text,
  TextInput,
  View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { SearchSourceState } from "stream-chat";
import { useStateStore, useTheme } from "stream-chat-react-native";
import type { Gif } from "./gif";
import { GifSearchSource } from "./GifSearchSource";

const GIPHY_API_KEY = "YOUR_GIPHY_API_KEY";

const gifsSelector = ({ items, lastQueryError }: SearchSourceState<Gif>) => ({
  items,
  lastQueryError,
});

const GifSheetContent = ({
  onClose,
  onPick,
}: {
  onClose: () => void;
  onPick: (gif: Gif) => void;
}) => {
  const {
    theme: { semantics },
  } = useTheme();
  // On Android the modal draws under the system bars.
  const insets = useSafeAreaInsets();
  const [source] = useState(
    () => new GifSearchSource({ apiKey: GIPHY_API_KEY }),
  );
  const { items, lastQueryError } = useStateStore(source.state, gifsSelector);

  useEffect(() => {
    source.activate();
    // The trending GIFs, without the debounce of a typed search.
    void source.executeQuery("");
    return () => {
      source.cancelScheduledQuery();
      source.deactivate();
    };
  }, [source]);

  return (
    <View
      style={[
        styles.sheet,
        Platform.OS === "android" && {
          paddingBottom: insets.bottom,
          paddingTop: insets.top,
        },
        { backgroundColor: semantics.backgroundCoreElevation1 },
      ]}
    >
      <View style={styles.header}>
        <TextInput
          accessibilityLabel="Search GIFs"
          autoCapitalize="none"
          onChangeText={(text) => void source.search(text)}
          placeholder="Search GIFs"
          placeholderTextColor={semantics.textTertiary}
          returnKeyType="search"
          style={[
            styles.input,
            {
              backgroundColor: semantics.backgroundCoreSurfaceCard,
              color: semantics.textPrimary,
            },
          ]}
        />
        <Pressable accessibilityRole="button" onPress={onClose}>
          <Text style={{ color: semantics.accentPrimary }}>Close</Text>
        </Pressable>
      </View>
      <FlatList
        columnWrapperStyle={styles.row}
        contentContainerStyle={styles.grid}
        data={items ?? []}
        keyboardShouldPersistTaps="handled"
        keyExtractor={(gif) => gif.id}
        ListEmptyComponent={
          lastQueryError ? (
            <Text style={{ color: semantics.accentError }}>
              The GIFs couldn't be loaded.
            </Text>
          ) : null
        }
        numColumns={2}
        onEndReached={() => void source.search()}
        renderItem={({ item }) => {
          const { height, url, width } = item.images.fixed_width_downsampled;
          return (
            <Pressable
              accessibilityLabel={item.title || "GIF"}
              accessibilityRole="button"
              onPress={() => onPick(item)}
              style={styles.tile}
            >
              <Image
                source={{ uri: url }}
                style={{ aspectRatio: Number(width) / Number(height) || 1 }}
              />
            </Pressable>
          );
        }}
      />
      <Text style={[styles.attribution, { color: semantics.textTertiary }]}>
        Powered by GIPHY
      </Text>
    </View>
  );
};

export const GifSheet = ({
  onClose,
  onPick,
  visible,
}: {
  onClose: () => void;
  onPick: (gif: Gif) => void;
  visible: boolean;
}) => (
  <Modal
    animationType="slide"
    onRequestClose={onClose}
    presentationStyle="pageSheet"
    visible={visible}
  >
    {/* Mounted while the sheet is open, so every opening starts a new search. */}
    {visible ? <GifSheetContent onClose={onClose} onPick={onPick} /> : null}
  </Modal>
);

const styles = StyleSheet.create({
  attribution: { fontSize: 12, padding: 8, textAlign: "center" },
  grid: { gap: 8, padding: 8 },
  header: { alignItems: "center", flexDirection: "row", gap: 12, padding: 12 },
  input: {
    borderRadius: 12,
    flex: 1,
    paddingHorizontal: 12,
    paddingVertical: 10,
  },
  row: { gap: 8 },
  sheet: { flex: 1 },
  tile: { flex: 1, overflow: "hidden", borderRadius: 8 },
});
```

<table>
  <tr>
  <td align='center' width='50%'>

![The GIF sheet with a search field and a grid of GIFs, with Powered by GIPHY under it, on iOS](https://getstream.io/docs-assets/images/71039b3c69c1.png)

  </td>
  <td align='center' width='50%'>

![The GIF sheet with a search field and a grid of GIFs, with Powered by GIPHY under it, on Android](https://getstream.io/docs-assets/images/06258a87b7a1.png)

  </td>
  </tr>
</table>

The grid shows the `fixed_width_downsampled` rendition, which Giphy keeps small, and `aspectRatio` gives each tile the GIF's own proportions. `useStateStore()` reads the source's state with a selector declared at module scope. Closing the sheet unmounts its content, which cancels a search that hasn't started, and a new opening starts from the trending GIFs.

## Add the GIF to the composer

A message renders a `giphy` attachment only while it has no image, video or file. So picking a GIF removes what the composer holds, and adds the GIF in its place, with `AttachmentManager.upsertAttachments()`. The attachment has no file, so the composer sends it without an upload:

```ts title="src/addGifToComposer.ts"
import type { MessageComposer } from "stream-chat";
import type { Gif } from "./gif";
import { toGiphyAttachment } from "./gif";

/** Puts the GIF into the composer as its only attachment. */
export const addGifToComposer = (composer: MessageComposer, gif: Gif) => {
  const { attachmentManager } = composer;

  attachmentManager.removeAttachments(
    attachmentManager.attachments.map(({ localMetadata }) => localMetadata.id),
  );
  attachmentManager.upsertAttachments([toGiphyAttachment(gif)]);
};
```

`AttachmentManager.removeAttachments()` cancels an upload that is still running. The attachment's local id comes from the GIF's id, so picking the same GIF twice doesn't add it twice. The composer counts the attachment as content, so the send button is on without any text.

## The GIF button

The button lives in the attachment picker's bar. It closes the attachment picker and opens the sheet, which a provider above the channel and the thread renders once. The button hands the provider a function that adds the picked GIF to the composer the button was pressed in, a channel's or a thread's:

```tsx title="src/GifPicker.tsx"
import { createContext, useContext, useMemo, useState } from "react";
import type { PropsWithChildren } from "react";
import { StyleSheet, Text, View } from "react-native";
import {
  AttachmentTypePickerButton,
  CameraPickerButton,
  CommandsPickerButton,
  FilePickerButton,
  MediaPickerButton,
  PollPickerButton,
  useMessageComposer,
  useMessageInputContext,
  useTheme,
} from "stream-chat-react-native";
import type { IconProps } from "stream-chat-react-native";
import { addGifToComposer } from "./addGifToComposer";
import type { Gif } from "./gif";
import { GifSheet } from "./GifSheet";

type OnPick = (gif: Gif) => void;

const GifPickerContext = createContext<{
  openGifPicker: (onPick: OnPick) => void;
}>({ openGifPicker: () => undefined });

/** Renders the GIF sheet once, above the channel and the thread. */
export const GifPickerProvider = ({ children }: PropsWithChildren) => {
  // The function is wrapped, because a function given to `setState` is called.
  const [target, setTarget] = useState<{ onPick: OnPick }>();
  const value = useMemo(
    () => ({ openGifPicker: (onPick: OnPick) => setTarget({ onPick }) }),
    [],
  );

  return (
    <GifPickerContext.Provider value={value}>
      {children}
      <GifSheet
        onClose={() => setTarget(undefined)}
        onPick={(gif) => {
          target?.onPick(gif);
          setTarget(undefined);
        }}
        visible={!!target}
      />
    </GifPickerContext.Provider>
  );
};

// The button passes the color of its icons as `stroke`.
const GifIcon = ({ stroke }: IconProps) => (
  <View style={[styles.gifIcon, { borderColor: stroke }]}>
    <Text style={[styles.gifText, { color: stroke }]}>GIF</Text>
  </View>
);

const GifPickerButton = () => {
  const { closeAttachmentPicker } = useMessageInputContext();
  const messageComposer = useMessageComposer();
  const { openGifPicker } = useContext(GifPickerContext);

  return (
    <AttachmentTypePickerButton
      Icon={GifIcon}
      onPress={() => {
        closeAttachmentPicker();
        openGifPicker((gif) => addGifToComposer(messageComposer, gif));
      }}
    />
  );
};

export const SelectionBarWithGif = () => {
  const {
    theme: { semantics },
  } = useTheme();

  return (
    <View
      style={[
        styles.bar,
        { backgroundColor: semantics.backgroundCoreElevation1 },
      ]}
    >
      <MediaPickerButton />
      <CameraPickerButton />
      <FilePickerButton />
      <PollPickerButton />
      <CommandsPickerButton />
      <GifPickerButton />
    </View>
  );
};

const styles = StyleSheet.create({
  bar: {
    alignItems: "center",
    flexDirection: "row",
    paddingBottom: 8,
    paddingHorizontal: 16,
  },
  gifIcon: {
    alignItems: "center",
    borderRadius: 4,
    borderWidth: 1.5,
    height: 16,
    justifyContent: "center",
    width: 22,
  },
  gifText: { fontSize: 9, fontWeight: "700" },
});
```

<table>
  <tr>
  <td align='center' width='50%'>

![The attachment picker's bar with a GIF button after the other attachment types on iOS](https://getstream.io/docs-assets/images/7e9cc11b3476.png)

  </td>
  <td align='center' width='50%'>

![The attachment picker's bar with a GIF button after the other attachment types on Android](https://getstream.io/docs-assets/images/d709b1490bf1.png)

  </td>
  </tr>
</table>

The button draws a small `GIF` label in the color it passes as `stroke`, so it follows the theme. The SDK's `GiphyIcon` is Giphy's colored logo, which reads as a dark disc at the 20 points the bar draws icons at.

The bar lists the default buttons, and the GIF button comes last. Each default button shows itself only when it applies, such as the commands button in a channel type that has commands, so the bar behaves as the default one does. With `disableAttachmentPicker` on `Channel`, the sheet shows the bar only, the default buttons open the system pickers directly, and the GIF button still opens the GIF sheet. With `shouldRenderAttachmentPicker` set to `false`, the bar isn't rendered, so the button has no place to show.

## The GIF above the input

The attachment preview list has no preview for a `giphy` attachment, so the composer shows nothing for it. `MessageInputHeaderView` renders the previews above the input, and a replacement of it can render the GIF next to them. This one renders the default header, and a card with the GIF and a button that takes it out:

```tsx title="src/GifPreview.tsx"
import { Image, Pressable, StyleSheet, Text, View } from "react-native";
import type { AttachmentManagerState } from "stream-chat";
import {
  MessageInputHeaderView,
  useComponentsContext,
  useMessageComposer,
  useStateStore,
  useTheme,
} from "stream-chat-react-native";

const gifSelector = ({ attachments }: AttachmentManagerState) => ({
  gif: attachments.find(({ type }) => type === "giphy"),
});

const GifPreview = () => {
  const { attachmentManager } = useMessageComposer();
  const { gif } = useStateStore(attachmentManager.state, gifSelector);
  const { icons } = useComponentsContext();
  const {
    theme: { semantics },
  } = useTheme();

  if (!gif?.thumb_url) return null;

  return (
    <View
      style={[
        styles.card,
        {
          backgroundColor: semantics.backgroundCoreSurfaceCard,
          borderColor: semantics.borderCoreDefault,
        },
      ]}
    >
      <Image
        accessibilityLabel={gif.title ?? "The picked GIF"}
        resizeMode="contain"
        source={{ uri: gif.thumb_url }}
        style={styles.image}
      />
      <Text style={[styles.title, { color: semantics.textSecondary }]}>
        {gif.title}
      </Text>
      <Pressable
        accessibilityLabel="Remove GIF"
        accessibilityRole="button"
        hitSlop={8}
        onPress={() =>
          attachmentManager.removeAttachments([gif.localMetadata.id])
        }
      >
        <icons.Cross height={20} stroke={semantics.textSecondary} width={20} />
      </Pressable>
    </View>
  );
};

/** The composer's header, with the picked GIF under the other previews. */
export const MessageInputHeaderWithGif = () => (
  <>
    <MessageInputHeaderView />
    <GifPreview />
  </>
);

const styles = StyleSheet.create({
  card: {
    alignItems: "center",
    borderRadius: 12,
    borderWidth: 1,
    flexDirection: "row",
    gap: 8,
    marginHorizontal: 8,
    marginTop: 4,
    padding: 8,
  },
  image: { borderRadius: 8, height: 72, width: 96 },
  title: { flex: 1 },
});
```

<table>
  <tr>
  <td align='center' width='50%'>

![The picked GIF as a card above the input with a remove button on iOS](https://getstream.io/docs-assets/images/b079836c9251.png)

  </td>
  <td align='center' width='50%'>

![The picked GIF as a card above the input with a remove button on Android](https://getstream.io/docs-assets/images/d873bdafdc4f.png)

  </td>
  </tr>
</table>

The attachment's `thumb_url` is an animated GIF, so the card plays it.

## Register it

Render `GifPickerProvider` around the screens that use the picker, and pass the two overrides to `WithComponents`, declared once at module scope. A `Thread` on its own screen needs the same overrides, and the provider can sit above the navigator, so both screens share one sheet:

```tsx title="src/GifChannelScreen.tsx"
import type { Channel as StreamChannel } from "stream-chat";
import {
  Channel,
  MessageComposer,
  MessageList,
  WithComponents,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
import { GifPickerProvider, SelectionBarWithGif } from "./GifPicker";
import { MessageInputHeaderWithGif } from "./GifPreview";

const overrides: ComponentOverrides = {
  AttachmentPickerSelectionBar: SelectionBarWithGif,
  MessageInputHeaderView: MessageInputHeaderWithGif,
};

export const GifChannelScreen = ({ channel }: { channel: StreamChannel }) => (
  <GifPickerProvider>
    <WithComponents overrides={overrides}>
      <Channel channel={channel}>
        <MessageList />
        <MessageComposer />
      </Channel>
    </WithComponents>
  </GifPickerProvider>
);
```

>
> **Info:** **Example**
>
> The user wants to send a GIF of mountains:
>
> 1. They open the attachment picker and tap the GIF button. The picker closes, and the sheet slides up with Giphy's trending GIFs.
> 2. They type "mountain". After the debounce, `GifSearchSource` queries Giphy's search, and the grid shows the results. They scroll down, and `search()` loads the next page.
> 3. They tap a GIF. `addGifToComposer()` empties the composer's attachments and adds a `giphy` attachment, and the sheet closes. The card above the input shows the GIF, and the send button is on.
> 4. They send. The composer sends the attachment as it is, with no upload. The message renders the attachment with the `Giphy` component, with the Giphy badge, as it renders a GIF sent with `/giphy`.
>

## The GIF in the message

The message list shows the GIF through the `Giphy` component, at the rendition `giphyVersion` names on `Channel` and `Thread`, `fixed_height` by default. A tap and a long press on the GIF go through the message's handlers, so the message menu opens as for any message.

<table>
  <tr>
  <td align='center' width='50%'>

![A sent message showing the picked GIF with the Giphy badge on iOS](https://getstream.io/docs-assets/images/ec98d74ab587.png)

  </td>
  <td align='center' width='50%'>

![A sent message showing the picked GIF with the Giphy badge on Android](https://getstream.io/docs-assets/images/52dcc4b1a3bb.png)

  </td>
  </tr>
</table>

The default menu has no Edit Message action on a message that carries a `giphy` attachment, so a message with a GIF can't be edited. The text the user types with the GIF goes into the same message, and renders under it.

## Read more

- [Commands and Giphy](https://getstream.io/chat/docs/sdk/react-native/features/rich-content/commands-and-giphy/): the `/giphy` command and the Giphy preview.
- [Location sharing](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/custom-attachments/location-sharing/): another button in the attachment picker's bar.
- [Attachment previews](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/composer/attachment-previews/): the previews above the input.
- [Search sources](https://getstream.io/chat/docs/sdk/react-native/state/loading-data/search-sources/): the contract of `BaseSearchSource`.
- [Attachments and uploads](https://getstream.io/chat/docs/sdk/react-native/features/rich-content/attachments-and-uploads/): how the composer sends attachments.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/composer/gif-picker/](https://getstream.io/chat/docs/sdk/react-native/ui-cookbooks/composer/gif-picker/).