Custom Attachments

How to build a custom attachment

Attachments are a property on the message object:

const messageObject = {
  id: "12312jh3b1jh2b312",
  text: "This is my test message!",
  attachments: [
    {
      type: "image",
      thumb_url: "",
    },
    {
      type: "file",
      asset_url: "",
    },
  ],
};

Attachment rendering depends on message.attachments[index].type. Built-in views:

  • attachment type image - Gallery (single or multiple images)
  • attachment type giphy - Giphy
  • attachment type file - FileAttachment
  • attachment with URL - Card

Best Practices

  • Keep custom attachment type values unique and stable so renderers stay deterministic.
  • Pass custom attachment renderers via Channel props to avoid forking the message list.
  • Include enough metadata on the attachment object to render without extra network calls.
  • Fall back to the default Card renderer for unknown types to avoid blank messages.
  • Validate attachment payloads before sending to prevent malformed renders for other clients.

117021214 6ec16600 Acf7 11eb 8cf8 5c9ae7c4da76

117021211 6e28cf80 Acf7 11eb 95f9 F0103fac1945

117021206 6cf7a280 Acf7 11eb 8769 3d8dd5a51946

117021199 6bc67580 Acf7 11eb 9ee8 66192ccc4c2a

GalleryGiphyUrlPreviewFileAttachment

Default components handle these types in MessageList. You can override them, for example:

const CustomGiphy = ({ attachment, onPressIn }) => {
  console.log(attachment.type);
  console.log(attachment.actions)
  console.log(attachment.image_url)
  console.log(attachment.thumb_url)
  console.log(attachment.title)
  console.log(attachment.type)

  return (/** Your custom UI */)
}

const CustomGallery = ({ images, onPressIn }) => {
  console.log(images);

  return (/** Your custom UI */)
}

const CustomFileAttachment = ({ attachment }) => {
  console.log(attachment.mime_type);
  console.log(attachment.title);
  console.log(attachment.file_size);
  console.log(attachment.actions);

  return (/** Your custom UI */)
}

const CustomUrlPreview = () => {
  console.log(text);
  console.log(thumb_url);
  console.log(title);

  return (/** Your custom UI */)
}

// Provide these custom components to Channel, as props.
<Channel
  Gallery={CustomGallery}
  Giphy={CustomGiphy}
  FileAttachment={CustomFileAttachment}
  UrlPreview={CustomUrlPreview}
>

You can also use a custom type. Custom attachments render in the Card view by default, which you can override.

const CustomCardComponent = ({ type, ...otherProperties }) => {
  console.log(type);
  console.log(otherProperties);

  return (/** Your custom UI */)
}

<Channel Card={CustomCardComponent} />

Next, build location sharing with a custom attachment:

Location sharing example

Let's build a location sharing example:

  • Add a "Share Location" button next to the input. Use the InputButtons prop on Channel to insert custom buttons.

  • On press, fetch the user's location and send a message like:

    const messageWithLocation = {
      text: "This is my location",
      attachments: [
        {
          type: "location",
          latitude: "50.212312",
          longitude: "-71.212659",
          // You can add more custom properties if needed.
        },
      ],
    };

    For this example, use react-native-geolocation. Follow their setup instructions.

    NOTE If you test on the iOS simulator, set dummy coordinates as described here. Also enable "location update" in background mode in Xcode.

  • On the receiver end, render the location attachment as a map in the message list. This example uses the Google Static Maps API. You can also use react-native-maps.

NOTE: Before using the Maps Static API, you need a billing-enabled project with the API enabled. See Set up in Cloud Console.

  • When the user taps the attachment, open Google Maps at the coordinates.

Here is the full implementation of share location example:

import React, { useEffect, useState } from "react";
import {
  Image,
  Linking,
  StyleSheet,
  TouchableOpacity,
  View,
} from "react-native";
import { StreamChat } from "stream-chat";
import {
  Channel,
  Chat,
  MessageInput,
  MessageList,
  OverlayProvider as ChatOverlayProvider,
  useChannelContext,
} from "stream-chat-react-native";
import {
  SafeAreaProvider,
  SafeAreaView,
  useSafeAreaInsets,
} from "react-native-safe-area-context";
import Geolocation from "@react-native-community/geolocation";
import Svg, { Path } from "react-native-svg";

// ============================================================
//   Fill in following values
// ============================================================
const API_KEY = "";
const USER_ID = "";
const USER_TOKEN = "";
const CHANNEL_ID = "";
// Reference: https://developers.google.com/maps/documentation/maps-static/get-api-key
const MAPS_API_KEY = "";

const chatClient = StreamChat.getInstance(API_KEY);
const user = { id: USER_ID };

// We are going to `await` following two calls, before rendering any UI component.
// Please check the App component at bottom.
const connectUserPromise = chatClient.connectUser(user, USER_TOKEN);
const channel = chatClient.channel("messaging", CHANNEL_ID);

// Basic utilities required for location sharing

// Given the location coordinates, this function generates URL for google map,
// and opens this Url using Linking module of react-native.
// Please check documentation of `Linking` module from react-native, for details:
// https://reactnative.dev/docs/linking
//
// Generally this URL will be opened in google maps application.
// https://developers.google.com/maps/documentation/urls/get-started
const goToGoogleMaps = (lat, long) => {
  const url = `https://www.google.com/maps/search/?api=1&query=${lat},${long}`;

  Linking.canOpenURL(url).then((supported) => {
    if (supported) {
      Linking.openURL(url);
    } else {
      console.log(`Don't know how to open URI: ${url}`);
    }
  });
};

// Generates static map url for given location coordinates.
// For reference, please check - https://developers.google.com/maps/documentation/maps-static/overview
const prepareStaticMapUrl = (lat, long) => {
  let baseURL = "https://maps.googleapis.com/maps/api/staticmap?";
  let url = new URL(baseURL);
  let params = url.searchParams;
  params.append("center", `${lat},${long}`);
  params.append("zoom", "15");
  params.append("size", "600x300");
  params.append("maptype", "roadmap");
  params.append("key", MAPS_API_KEY);
  params.append("markers", `color:red|${lat},${long}`);

  return url.toString();
};

// Send your current location attachment, as message, on current channel.
const sendCurrentLocation = () => {
  Geolocation.getCurrentPosition((info) => {
    channel?.sendMessage({
      text: "This is my location",
      attachments: [
        {
          type: "location",
          latitude: info.coords.latitude,
          longitude: info.coords.longitude,
        },
      ],
    });
  });
};

// UI Component for rendering `location` type attachment
const LocationCard = ({ type, latitude, longitude }) => {
  if (type === "location") {
    const mapApi = prepareStaticMapUrl(latitude, longitude);
    console.log(mapApi);
    return (
      <TouchableOpacity onPress={() => goToGoogleMaps(latitude, longitude)}>
        <Image source={{ uri: mapApi }} style={{ height: 200, width: 300 }} />
      </TouchableOpacity>
    );
  }
};

// Icon for "Share Location" button, next to input box.
const ShareLocationIcon = (props) => (
  <Svg width={24} height={24} viewBox="0 0 24 24" fill="none" {...props}>
    <Path
      d="M12 12c-1.654 0-3-1.345-3-3 0-1.654 1.346-3 3-3s3 1.346 3 3c0 1.655-1.346 3-3 3zm0-4a1.001 1.001 0 101 1c0-.551-.449-1-1-1z"
      fill="#000"
    />
    <Path
      fillRule="evenodd"
      clipRule="evenodd"
      d="M12 22s7-5.455 7-12.727C19 5.636 16.667 2 12 2S5 5.636 5 9.273C5 16.545 12 22 12 22zm1.915-4.857C15.541 15.032 17 12.277 17 9.273c0-1.412-.456-2.75-1.27-3.7C14.953 4.664 13.763 4 12 4s-2.953.664-3.73 1.573C7.456 6.523 7 7.86 7 9.273c0 3.004 1.459 5.759 3.085 7.87.678.88 1.358 1.614 1.915 2.166a21.689 21.689 0 001.915-2.166zm-.683 3.281s0 .001 0 0z"
      fill="#000"
    />
  </Svg>
);

// UI component to add Share Location button next to input box.
const InputButtons = () => {
  const { channel: currentChannel } = useChannelContext();

  return (
    <TouchableOpacity
      onPress={() => sendCurrentLocation(currentChannel)}
      style={{ marginRight: 10 }}
    >
      <ShareLocationIcon />
    </TouchableOpacity>
  );
};

const ChannelScreen = () => {
  const { bottom } = useSafeAreaInsets();

  return (
    <ChatOverlayProvider bottomInset={bottom}>
      <SafeAreaView>
        <Chat client={chatClient}>
          {/* Setting keyboardVerticalOffset as 0, since we don't have any header yet */}
          <Channel
            channel={channel}
            keyboardVerticalOffset={0}
            Card={LocationCard}
            InputButtons={InputButtons}
          >
            <View style={StyleSheet.absoluteFill}>
              <MessageList />
              <MessageInput />
            </View>
          </Channel>
        </Chat>
      </SafeAreaView>
    </ChatOverlayProvider>
  );
};

export default function App() {
  const [ready, setReady] = useState();

  useEffect(() => {
    const initChat = async () => {
      await connectUserPromise;
      await channel.watch();
      setReady(true);
    };

    initChat();
  }, []);

  if (!ready) {
    return null;
  }

  return (
    <SafeAreaProvider>
      <ChannelScreen channel={channel} />
    </SafeAreaProvider>
  );
}

Handling Custom Properties On Attachment

Default UI components and context providers from the SDK are memoized for performance purpose, and will not trigger re-renders upon updates to custom properties on attachment.

Eg: Suppose we add a customField property to the attachment object and use it the UI in custom Card component.

<Channel
  Card={(attachment) => {
    return (
      <View>
        <Text>{attachment.customField}</Text>
      </View>
    );
  }}
/>

In this example, if you try to update customField on particular attachment from backend (or anywhere), you will not see it updated on UI until you refresh the chat.

The reason being, the default memoization logic only checks for fixed set of properties on attachment, and doesn't check for custom properties.

This can be solved by providing a function which checks for changes in custom properties which you may have been defined on attachment.

import { Channel } from "stream-chat-react-native";

const isAttachmentEqualHandler = (prevAttachment, nextAttachment) => {
  const attachmentEqual =
    prevAttachment.customField === nextAttachment.customField;
  if (!attachmentEqual) return false;
  return true;
};

<Channel
  channel={channel}
  isAttachmentEqual={isAttachmentEqualHandler}
  Card={(attachment) => {
    return (
      <View>
        <Text>{attachment.customField}</Text>
      </View>
    );
  }}
>
  {/* The underlying components */}
</Channel>;