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

Location sharing

This UI cookbook adds location sharing to a chat app: a location tab in the attachment picker that shares the user's position once or live for a while, and a full-screen map that opens when a location in the list is tapped and follows a live location as it moves. The SDK sends the locations and keeps live ones current, but it doesn't read the device's position or render a map. What it provides, and what your app adds, is explained in Location sharing.

Best practices

  • Ask for the location permission when the user wants to share, and say why. Handle a refusal with a message and a way to the system settings.
  • Offer a few fixed durations for a live location, and show until when it runs. A live location is valid only for at least a minute.
  • Give the sender a way to stop a live location early, on the map and in the message.
  • Render LiveLocationManagerProvider once the user is connected. Without it, a live location is created but its position never changes.
  • Leave location sharing out of threads. The composer of a thread doesn't send locations.

Prerequisites

You need a working client and a channel screen with MessageList and MessageComposer, as in the Quick start. The examples use React Navigation; the Sample App and the Expo Messaging app have a full implementation.

Setup

Installing the packages

yarn add @react-native-community/geolocation react-native-maps
  • @react-native-community/geolocation reads the device's position, once and as it changes.
  • react-native-maps renders maps: Apple Maps on iOS and Google Maps on Android.

Permissions and the Google Maps key

On iOS, add NSLocationWhenInUseUsageDescription to Info.plist, with the reason the app shows the user. Add NSLocationAlwaysAndWhenInUseUsageDescription too if live locations should keep updating in the background.

On Android, add the permissions to android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

Google Maps on Android needs an API key for the Maps SDK for Android. Add it inside <application> in the same file:

<meta-data
  android:name="com.google.android.geo.API_KEY"
  android:value="YOUR_GOOGLE_MAPS_API_KEY" />

iOS uses Apple Maps and needs no key.

Keeping live locations current

Render LiveLocationManagerProvider inside Chat, once the user is connected, with a watchLocation function that reports the device's position. Live locations need the manager has the provider for both packages. While the user shares a live location, the manager sends this device's new positions to it, even when the share was started on another of the user's devices.

A location tab in the attachment picker

The attachment picker has a bar of buttons at its top and, below it, the content of the selected button. The AttachmentPickerSelectionBar key replaces the bar, and the AttachmentPickerContent key replaces the content. This sample adds a location button to the bar, and a content for it with three ways to share:

src/LocationPicker.tsx
import { useRef, useState } from "react";
import { Linking, Pressable, StyleSheet, Text, View } from "react-native";
import Geolocation from "@react-native-community/geolocation";
import {
  AttachmentPickerContent,
  AttachmentTypePickerButton,
  CameraPickerButton,
  CommandsPickerButton,
  FilePickerButton,
  MediaPickerButton,
  PollPickerButton,
  useAttachmentPickerContext,
  useAttachmentPickerState,
  useComponentsContext,
  useMessageComposer,
  useMessageInputContext,
  useTheme,
  useThreadContext,
} from "stream-chat-react-native";
import type {
  AttachmentPickerContentProps,
  ComponentOverrides,
} from "stream-chat-react-native";

const LOCATION_PICKER = "location";

type Coords = { latitude: number; longitude: number };

// Asks for the permission the first time, then reads the position once.
const getCurrentCoords = () =>
  new Promise<Coords>((resolve, reject) =>
    Geolocation.getCurrentPosition(
      ({ coords }) =>
        resolve({ latitude: coords.latitude, longitude: coords.longitude }),
      reject,
      { enableHighAccuracy: true, timeout: 15000 },
    ),
  );

const LIVE_DURATIONS = [
  { label: "15 minutes", ms: 15 * 60 * 1000 },
  { label: "1 hour", ms: 60 * 60 * 1000 },
  { label: "8 hours", ms: 8 * 60 * 60 * 1000 },
];

const LocationPickerButton = () => {
  const { attachmentPickerStore } = useAttachmentPickerContext();
  const { selectedPicker } = useAttachmentPickerState();
  const { icons } = useComponentsContext();
  const { thread } = useThreadContext();

  // A thread's composer doesn't send locations.
  if (thread) return null;

  return (
    <AttachmentTypePickerButton
      Icon={icons.MapPin}
      onPress={() => attachmentPickerStore.setSelectedPicker(LOCATION_PICKER)}
      selected={selectedPicker === LOCATION_PICKER}
    />
  );
};

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

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

const ShareLocationContent = ({ height }: AttachmentPickerContentProps) => {
  const messageComposer = useMessageComposer();
  const { closeAttachmentPicker } = useMessageInputContext();
  const {
    theme: { semantics },
  } = useTheme();
  const [sharing, setSharing] = useState(false);
  const [failed, setFailed] = useState(false);

  // Without `durationMs` the location is shared once; with it, it's live for that long.
  const share = async (durationMs?: number) => {
    setSharing(true);
    setFailed(false);
    try {
      const coords = await getCurrentCoords();
      messageComposer.locationComposer.setData({ ...coords, durationMs });
      await messageComposer.sendLocation();
      closeAttachmentPicker();
    } catch {
      setFailed(true);
    } finally {
      setSharing(false);
    }
  };

  const textColor = { color: semantics.textPrimary };

  return (
    <View
      style={[
        styles.content,
        { backgroundColor: semantics.backgroundCoreElevation1, height },
      ]}
    >
      <Pressable
        accessibilityRole="button"
        disabled={sharing}
        onPress={() => void share()}
        style={[styles.option, { borderColor: semantics.borderCoreDefault }]}
      >
        <Text style={[styles.optionTitle, textColor]}>
          Share current location
        </Text>
      </Pressable>
      <Text style={[styles.heading, { color: semantics.textSecondary }]}>
        Share live location for
      </Text>
      <View style={styles.durations}>
        {LIVE_DURATIONS.map(({ label, ms }) => (
          <Pressable
            accessibilityRole="button"
            disabled={sharing}
            key={label}
            onPress={() => void share(ms)}
            style={[
              styles.option,
              styles.duration,
              { borderColor: semantics.borderCoreDefault },
            ]}
          >
            <Text style={textColor}>{label}</Text>
          </Pressable>
        ))}
      </View>
      {failed ? (
        <Pressable
          accessibilityRole="button"
          onPress={() => void Linking.openSettings()}
        >
          <Text style={{ color: semantics.accentError }}>
            Your location couldn't be shared. Check that the app may use it in
            Settings.
          </Text>
        </Pressable>
      ) : null}
    </View>
  );
};

const PickerContentWithLocation = (props: AttachmentPickerContentProps) => {
  const { selectedPicker } = useAttachmentPickerState();

  // While the picker closes, no picker is selected: keep showing the last one.
  const lastPickerRef = useRef(selectedPicker);
  if (selectedPicker) lastPickerRef.current = selectedPicker;

  if (lastPickerRef.current === LOCATION_PICKER) {
    return <ShareLocationContent {...props} />;
  }

  return <AttachmentPickerContent {...props} />;
};

// Pass to WithComponents, declared once at module scope.
export const locationPickerOverrides: ComponentOverrides = {
  AttachmentPickerContent: PickerContentWithLocation,
  AttachmentPickerSelectionBar: SelectionBarWithLocation,
};

const styles = StyleSheet.create({
  bar: {
    alignItems: "center",
    flexDirection: "row",
    paddingBottom: 8,
    paddingHorizontal: 16,
  },
  content: { gap: 12, padding: 16 },
  duration: { flex: 1 },
  durations: { flexDirection: "row", gap: 8 },
  heading: { fontSize: 13 },
  option: {
    alignItems: "center",
    borderRadius: 12,
    borderWidth: 1,
    padding: 12,
  },
  optionTitle: { fontWeight: "600" },
});
The attachment picker with a location tab offering to share the current location or a live location on iOS
The attachment picker with a location tab offering to share the current location or a live location on Android
Info:

Example

What happens when the user shares their location for an hour:

  1. They open the attachment picker and tap the pin button. It sets the picker's selected type to "location", a type of your own; setSelectedPicker takes any string.
  2. PickerContentWithLocation sees the type and renders ShareLocationContent. For every other type it renders the SDK's content.
  3. They tap "1 hour". The first time, getCurrentPosition asks for the permission. Then it reads their position.
  4. LocationComposer.setData() stores the position and the duration in the channel's composer, and MessageComposer.sendLocation() sends a message that carries only the location, through Channel.sendSharedLocation(). Text they typed in the composer stays there.
  5. The picker closes. The message appears in the channel, and the LiveLocationManager starts sending their new positions to it.

When the permission is refused, the position can't be read or the request fails, the content says so, and tapping the text opens the app's settings. A failed request also publishes a "Failed to share location" notification, which the channel's snackbar shows.

The location button works only in the attachment picker. With disableAttachmentPicker on Channel, the bar's buttons open the system pickers directly, and there is no content area for the location tab.

With Expo, read the position with expo-location instead, and keep the rest of the file:

src/getCurrentCoords.ts
import * as Location from "expo-location";

export const getCurrentCoords = async () => {
  const { status } = await Location.requestForegroundPermissionsAsync();
  if (status !== "granted") throw new Error("Location permission not granted");

  const { coords } = await Location.getCurrentPositionAsync({
    accuracy: Location.Accuracy.High,
  });
  return { latitude: coords.latitude, longitude: coords.longitude };
};

The location in a message

A message with a location renders the MessageLocation key, which has no default. Without one, the message shows nothing for the location. Show a location in a message has a map with react-native-maps, with "Live until" and a Stop sharing button for a live location. Register it with the overrides above:

<WithComponents overrides={{ ...locationPickerOverrides, ...messageLocationOverrides }}>
A message showing a map with a marker on iOS
A message showing a map with a marker on Android

Declare the merged object once, at module scope, as Overriding components explains.

A full-screen map

Tapping a location opens a screen with a large map. onPressMessage on Channel gets every press on a message, and a press on its content has the emitter messageContent. The map screen follows a live location with useHandleLiveLocationEvents:

src/MapScreens.tsx
import { useHeaderHeight } from "@react-navigation/elements";
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
import { Pressable, StyleSheet, Text, View } from "react-native";
import MapView, { Marker } from "react-native-maps";
import { convertTimestampToDate, nowNs } from "stream-chat";
import type {
  Channel as StreamChannel,
  SharedLocationResponse,
  SharedLocationResponseData,
} from "stream-chat";
import {
  Channel,
  MessageComposer,
  MessageList,
  useChatContext,
  useHandleLiveLocationEvents,
} from "stream-chat-react-native";

export type StackParamList = {
  Channel: { cid: string };
  Map: { cid: string; messageId: string };
};

export const ChannelScreen = ({
  navigation,
  route,
}: NativeStackScreenProps<StackParamList, "Channel">) => {
  const { client } = useChatContext();
  const headerHeight = useHeaderHeight();
  const channel = client.channelManager.get(route.params.cid);

  if (!channel) return null;

  return (
    <Channel
      channel={channel}
      keyboardVerticalOffset={headerHeight}
      onPressMessage={({ defaultHandler, emitter, message }) => {
        if (emitter === "messageContent" && message?.shared_location) {
          navigation.navigate("Map", {
            cid: channel.cid,
            messageId: message.id,
          });
          return;
        }
        defaultHandler?.();
      }}
      topInset={headerHeight}
    >
      <MessageList />
      <MessageComposer />
    </Channel>
  );
};

type SharedLocation = SharedLocationResponse | SharedLocationResponseData;

const LiveMap = ({
  channel,
  initialLocation,
}: {
  channel: StreamChannel;
  initialLocation: SharedLocation;
}) => {
  const { client } = useChatContext();
  // Each message.updated event of the message brings its new position.
  const { isLiveLocationStopped, locationResponse } =
    useHandleLiveLocationEvents({
      channel,
      messageId: initialLocation.message_id,
    });

  const location: SharedLocation = locationResponse ?? initialLocation;
  const { end_at, latitude, longitude, message_id, user_id } = location;
  const isLive = !isLiveLocationStopped && end_at != null && end_at > nowNs();
  const isOwn = user_id === client.userId;
  const endTime = convertTimestampToDate(end_at)?.toLocaleTimeString();

  return (
    <View style={styles.screen}>
      <MapView
        region={{
          latitude,
          latitudeDelta: 0.01,
          longitude,
          longitudeDelta: 0.01,
        }}
        style={styles.screen}
      >
        <Marker coordinate={{ latitude, longitude }} />
      </MapView>
      {end_at == null ? null : (
        <View style={styles.footer}>
          <Text>
            {isLive ? `Live until ${endTime}` : "Location sharing has ended"}
          </Text>
          {isLive && isOwn ? (
            <Pressable
              accessibilityRole="button"
              onPress={() =>
                void channel.stopLiveLocationSharing({ message_id })
              }
            >
              <Text style={styles.stop}>Stop sharing</Text>
            </Pressable>
          ) : null}
        </View>
      )}
    </View>
  );
};

export const MapScreen = ({
  route,
}: NativeStackScreenProps<StackParamList, "Map">) => {
  const { client } = useChatContext();
  const channel = client.channelManager.get(route.params.cid);
  const message = channel?.messagePaginator.getItem(route.params.messageId);
  const initialLocation = message?.shared_location;

  if (!channel || !initialLocation) return null;

  return <LiveMap channel={channel} initialLocation={initialLocation} />;
};

const styles = StyleSheet.create({
  footer: { alignItems: "center", gap: 8, padding: 16 },
  screen: { flex: 1 },
  stop: { color: "#d92f26", fontWeight: "600" },
});

Register MapScreen as Map in the same stack as the channel screen.

  • onPressMessage replaces what a press on a message does. The payload carries the message, so a location message navigates with its id, and every other press runs defaultHandler, the SDK's own behavior.
  • The map screen looks the message up in the channel's messagePaginator. The message list has it loaded, because the user just tapped it.
  • useHandleLiveLocationEvents listens to the channel's message.updated events for that message. locationResponse is the latest location, and isLiveLocationStopped turns true when an update arrives whose end time has passed. A share that ended before the screen opened sends no update, so the screen also compares end_at with nowNs() itself.
  • end_at is a timestamp in nanoseconds. convertTimestampToDate() turns it into a Date for display.
  • Channel.stopLiveLocationSharing({ message_id }) ends the share now. The server's message.updated event brings the end time, and the screen and the message both show that sharing has ended.
  • MapView is controlled through region, so it centers on each new position. Pass initialRegion instead to let the user move the map freely.