Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

Link previews

A link preview is the card with a title, a description and an image that shows for a link in a message. This page covers how the composer loads a preview while the user types, what's sent with the message, and how the message list shows it.

What users get

The user types "Look at https://example.com/launch". When they stop typing, a card appears above the input with the page's image, title and description, and the URL. They can dismiss it with its remove button. When they send, the message in the list shows a card for the link above the text, and tapping the card opens the page.

A link preview card above the composer input with the page title and URL and a remove button on iOS
A link preview card above the composer input with the page title and URL and a remove button on Android

How it works across the layers

  • State. The composer's linkPreviewsManager finds the links in the text, loads a preview for each, and keeps them in its store. Sending adds the loaded previews to the message as attachments.
  • UI. In the composer, MessageInputHeaderView renders the previews above the input as LinkPreviewCards. In the message list, Attachment renders each preview with UrlPreview, or with URLPreviewCompact.
  • Layout. Not involved.

From typing to sending

  1. Each text change starts a wait of linkPreviews.debounceURLEnrichmentMs, 1500 ms by default. When the user stops typing for that long, linkPreviews.findURLFn finds the URLs in the text.
  2. Each new URL gets a preview with status loading, and the manager asks the server for the page's data with client.getOG({ url }). The preview becomes loaded, or failed when the request fails. When the text has no URL left, all previews are cleared.
  3. Dismissing a preview calls LinkPreviewsManager.dismissPreview(url). Its status becomes dismissed, and linkPreviews.onLinkPreviewDismissed is called.
  4. On send, the composition middleware adds every loaded preview to the message's attachments, and tells the server not to add previews of its own (skip_enrich_url). It also sets skip_enrich_url when the user dismissed a preview, but only when the message carries attachments. A text-only message whose preview was dismissed goes out without the flag, and the server adds the card.
  5. If a preview is still loading when the user sends, no previews are sent, and the server adds them while it saves the message.

When the user lacks the send-links capability in the channel and the text contains a link, sending shows an alert, "Links are disabled", and nothing is sent. Message composer lists the statuses.

In the composer and the message list

The composer shows only loaded previews, and only the first of them. The others are still sent. The card shows the image, the title, the description and the URL, and its remove button dismisses the preview. A preview that is loading or failed shows nothing.

In the message list, an attachment with og_scrape_url or title_link is a link preview. Attachment renders it with UrlPreview: the image, with a play indicator when the link is a video, then the title, the description and the URL. With urlPreviewType="compact" on Channel or Thread, it renders URLPreviewCompact instead, a row with a small thumbnail. Tapping a card opens og_scrape_url, or the image's URL when there is none.

A message that also carries files, images or videos shows no link cards, and the link stays a link in the text.

Turning it on or off

Link previews are on by default. Two sides decide:

  • The client: messageComposer.linkPreviews.enabled, true by default. It decides whether the composer loads and shows previews.
  • The server: the channel type's url_enrichment setting. It decides whether the server adds previews to messages. With it off, the composer doesn't load previews either.

To stop the composer from loading previews:

client.config.set({
  messageComposer: { linkPreviews: { enabled: false } },
});

The server still adds previews to sent messages while url_enrichment is on, so messages keep their cards. Only the composer stops showing them. To send messages without cards, turn url_enrichment off for the channel type. Configuration lists every setting both sides decide.

Customizing

import { StreamChat } from "stream-chat";
import type { LinkPreview } from "stream-chat";

const client = StreamChat.getInstance("YOUR_API_KEY");

client.config.set({
  messageComposer: {
    linkPreviews: {
      // Only links to your own domain get a preview.
      findURLFn: (text: string) =>
        Array.from(
          text.matchAll(/https:\/\/example\.com\/\S*/g),
          ([url]) => url,
        ),
      onLinkPreviewDismissed: (linkPreview: LinkPreview) => {
        console.log(`Dismissed ${linkPreview.og_scrape_url}`);
      },
    },
  },
});

debounceURLEnrichmentMs is read only when a composer is built, so set it before the client creates composers, for example in the options you pass to useCreateChatClient.

Replace the previews in the composer

The composer's preview list has no override key of its own. MessageInputHeaderView renders it, together with the quoted or edited message and the attachment previews, so a replacement renders those too. This header shows every loaded preview as a line with its title:

import { Pressable, StyleSheet, Text, View } from "react-native";
import { LinkPreviewsManager } from "stream-chat";
import type {
  LinkPreviewsManagerState,
  MessageComposerState,
} from "stream-chat";
import {
  useComponentsContext,
  useMessageComposer,
  useMessageComposerAPIContext,
  useStateStore,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";

const loadedPreviewsSelector = ({ previews }: LinkPreviewsManagerState) => ({
  previews: Array.from(previews.values()).filter(
    LinkPreviewsManager.previewIsLoaded,
  ),
});

const quotedMessageSelector = ({ quotedMessage }: MessageComposerState) => ({
  quotedMessage,
});

const LinkPreviewLines = () => {
  const { linkPreviewsManager } = useMessageComposer();
  const { previews } = useStateStore(
    linkPreviewsManager.state,
    loadedPreviewsSelector,
  );

  return (
    <>
      {previews.map((preview) => (
        <View key={preview.og_scrape_url} style={styles.line}>
          <Text numberOfLines={1} style={styles.title}>
            {preview.title ?? preview.og_scrape_url}
          </Text>
          <Pressable
            onPress={() =>
              linkPreviewsManager.dismissPreview(preview.og_scrape_url)
            }
          >
            <Text>Remove</Text>
          </Pressable>
        </View>
      ))}
    </>
  );
};

const ComposerHeader = () => {
  const { AttachmentUploadPreviewList, Reply } = useComponentsContext();
  const messageComposer = useMessageComposer();
  const { quotedMessage } = useStateStore(
    messageComposer.state,
    quotedMessageSelector,
  );
  const { clearEditingState } = useMessageComposerAPIContext();
  const { editedMessage } = messageComposer;

  return (
    <View style={styles.header}>
      {editedMessage ? (
        <Reply
          mode="edit"
          onDismiss={clearEditingState}
          quotedMessage={editedMessage}
        />
      ) : quotedMessage ? (
        <Reply
          mode="reply"
          onDismiss={() => messageComposer.setQuotedMessage(null)}
        />
      ) : null}
      <AttachmentUploadPreviewList />
      <LinkPreviewLines />
    </View>
  );
};

// Pass to `WithComponents`, declared once at module scope.
export const overrides: ComponentOverrides = {
  MessageInputHeaderView: ComposerHeader,
};

const styles = StyleSheet.create({
  header: { gap: 4, paddingHorizontal: 8 },
  line: { alignItems: "center", flexDirection: "row", gap: 8 },
  title: { flex: 1 },
});

It works in channel, thread and edit composers alike, because useMessageComposer() returns the composer it's rendered in. The SDK's header also hides itself while a voice recording runs, and animates its parts in and out.

Change the card in the message list

UrlPreview and URLPreviewCompact are override keys, and receive the attachment. This one renders the link as a line of text:

import { Linking, Pressable, StyleSheet, Text } from "react-native";
import type {
  ComponentOverrides,
  URLPreviewProps,
} from "stream-chat-react-native";

const LinkLine = ({ attachment }: URLPreviewProps) => {
  const url = attachment.og_scrape_url ?? attachment.title_link;

  return (
    <Pressable onPress={() => url && Linking.openURL(url)}>
      <Text style={styles.link}>{attachment.title ?? url}</Text>
    </Pressable>
  );
};

// Pass to `WithComponents`, declared once at module scope.
export const overrides: ComponentOverrides = { UrlPreview: LinkLine };

const styles = StyleSheet.create({
  link: { padding: 8, textDecorationLine: "underline" },
});

The SDK's cards report a tap to onPressMessage on Channel, with the emitter urlPreview and the URL in additionalInfo, and open the page as their default. A card of your own that should keep that reads onPress from useMessageContext() and calls it with the same payload.

Reference