Live location Sharing Message

In this cookbook, we will build a simple live location sharing message feature using custom attachments.

The goal is to create two screens:

  • Chat screen with the live location shown on map in a message
  • A detailed map screen that is shown when the live location is tapped

Setup

Install Dependencies

Run the following commands in your terminal of choice:

yarn add @react-native-community/geolocation
yarn add react-native-maps
  • @react-native-community/geolocation library is used to watch the current location of the user and then send it in the message
  • react-native-maps is used to display the location of the user using the native maps present on iOS and Android

Configure location permissions

iOS

You need to include NSLocationWhenInUseUsageDescription and NSLocationAlwaysAndWhenInUseUsageDescription in Info.plist to enable geolocation when using the app.

Android

To request access to location, you need to add the following line to your app’s AndroidManifest.xml:

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

Android: Add API Key

On Android, one has to use Google Maps, which in turn requires you to obtain an API key for the Android SDK. On iOS, the native Apple Maps implementation is used and API keys are not necessary.

Add your API key to your manifest file (android/app/src/main/AndroidManifest.xml):

<application>
   <!-- You will only need to add this meta-data tag, but make sure it's a child of application -->
   <meta-data
     android:name="com.google.android.geo.API_KEY"
     android:value="Your Google maps API Key Here"/>
</application>

Implementation outline

We will use a custom attachment with a type set to location. This custom attachment will be part of the message the app sends to the channel. This attachment will carry the latest longitude, latitude, and timestamp when the location sharing has been stopped.

On the sharer side, the application itself will poll for location updates and whenever a new location is emitted, it will update the appropriate message/attachment through our channel.updateMessage() APIs.

On the recipient side, the application will listen for message.updated events and will update the interactive maps accordingly.

The attachment object

We need to know the latitude and longitude data in a message to show the location. Additionally, we need the time when the live location sharing was stopped. To have this lets have our message to have the following structure:

const messageWithLocation = {
  attachments: [
    {
      type: "location",
      latitude: 50.212312,
      longitude: -71.212659,
      ended_at: "2012-07-14T01:00:00+01:00",
    },
  ],
};

In Typescript, we can define this in type using generics,

import { DefaultStreamChatGenerics } from "stream-chat-react-native";
import { StreamChat } from "stream-chat";

type LocalAttachmentType = DefaultStreamChatGenerics["attachmentType"] & {
  latitude?: number;
  longitude?: number;
  ended_at?: string;
};

export type StreamChatGenerics = DefaultStreamChatGenerics & {
  attachmentType: LocalAttachmentType;
};

// and use the generics when creating the client
const client = StreamChat.getInstance<StreamChatGenerics>(
  "<ADD_YOUR_STREAM_API_KEY_HERE>",
);

Step 1: Create Live Location Sharing Context

In order for the location to be started or stopped in different screens. Lets add the start or stop methods in a React Context.

  • The Geolocation.watchPosition method is used to watch the location of the user.
  • The client.updateMessage method is used to update an existing message with the new location.

Below is an implementation of the context:

InputButtons.tsx
import React, {createContext, useContext} from 'react';
import {useChatContext} from 'stream-chat-react-native';
import Geolocation, {
  GeolocationResponse,
} from '@react-native-community/geolocation';

Geolocation.setRNConfiguration({
  skipPermissionRequests: false,
  authorizationLevel: 'always',
  enableBackgroundLocationUpdates: true,
});

interface LiveLocationContextValue {
  startLiveLocation: (messageId: string) => void;
  stopLiveLocation: (messageId: string) => void;
  isWatching: (messageId: string) => boolean;
}

const LiveLocationContext = createContext<LiveLocationContextValue>({
  startLiveLocation: () => {},
  stopLiveLocation: () => {},
  isWatching: () => false,
});

export const useLiveLocationContext = () => {
  return useContext(LiveLocationContext);
};

// a map of message IDs to live location watch IDs
const messageIdToLiveWatchMap = new Map<string, number>();

const isWatching = (id: string) => {
  return messageIdToLiveWatchMap.has(id);
};

export const LiveLocationContextProvider = (
  props: React.PropsWithChildren<{}>,
) => {
  const {client} = useChatContext();

  const lastLocationRef = React.useRef<GeolocationResponse>();

  // watch live location and update message
  const startLiveLocation = React.useCallback(
    (id: string) => {
      const watchId = Geolocation.watchPosition(
        position => {
          client.updateMessage({
            id,
            attachments: [
              {
                type: 'location',
                latitude: position.coords.latitude,
                longitude: position.coords.longitude,
              },
            ],
          });
          lastLocationRef.current = position;
        },
        error => {
          console.error('watchPosition', error);
        },
        {
          enableHighAccuracy: true,
          timeout: 20000,
          maximumAge: 1000,
          interval: 5000, // android only
        },
      );
      messageIdToLiveWatchMap.set(id, watchId);
    },
    [client],
  );

  // stop watching live location and send message with ended time
  const stopLiveLocation = React.useCallback(
    (id: string) => {
      const watchId = messageIdToLiveWatchMap.get(id);
      if (watchId !== undefined) {
        messageIdToLiveWatchMap.delete(id);
        Geolocation.clearWatch(watchId);
        if (lastLocationRef.current) {
          client.updateMessage({
            id,
            attachments: [
              {
                type: 'location',
                latitude: lastLocationRef.current.coords.latitude,
                longitude: lastLocationRef.current.coords.longitude,
                ended_at: new Date().toISOString(),
              },
            ],
          });
        }
      }
    },
    [client],
  );

  const contextValue: LiveLocationContextValue = {
    startLiveLocation,
    stopLiveLocation,
    isWatching,
  };

  return (
    <LiveLocationContext.Provider value={contextValue}>
      {props.children}
    </LiveLocationContext.Provider>
  );
};

Since the context needs the instance of the Stream Chat Client. Lets make sure that this component is added below the Chat context component.

    <Chat client={chatClient}>
      <LiveLocationContextProvider>
        // ...add your screens here
      </LiveLocationContextProvider>
    </Chat>

Step 2: Add Live Location Sharing Button

Lets add a “Share Live Location” button next to input box. Channel component accepts a prop InputButtons, to add some custom buttons next to input box. When user presses this button, it should fetch the current location coordinates of user, and send a message on channel and then starting watching for live location.

Below is an implementation of this button:

InputButtons.tsx
import React from 'react';
import {Pressable, StyleSheet} from 'react-native';
import {
  Channel,
  useChannelContext,
  InputButtons as DefaultInputButtons,
  useTheme,
} from 'stream-chat-react-native';
import Svg, {Path} from 'react-native-svg';
import Geolocation from '@react-native-community/geolocation';
import {useLiveLocationContext} from './LiveLocationContext';

// Icon for "Share Location" button, next to input box.
const ShareLocationIcon = () => {
  const {
    theme: {
      colors: {grey},
    },
  } = useTheme();
  return (
    <Svg width={28} height={28} viewBox="0 0 24 24" fill="none">
      <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={grey}
      />
      <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={grey}
      />
    </Svg>
  );
};

const InputButtons: NonNullable<
  React.ComponentProps<typeof Channel>['InputButtons']
> = props => {
  const {channel: currentChannel} = useChannelContext();
  const {startLiveLocation} = useLiveLocationContext();

  const sendLiveLocation = async () => {
    Geolocation.getCurrentPosition(
      async position => {
        // create message with initial location
        const response = await currentChannel.sendMessage({
          attachments: [
            {
              type: 'location',
              latitude: position.coords.latitude,
              longitude: position.coords.longitude,
            },
          ],
        });
        // then start watching for live location
        startLiveLocation(response.message.id);
      },
      error => {
        console.error('getCurrentPosition', error);
      },
      {
        enableHighAccuracy: true,
        timeout: 20000,
        maximumAge: 1000,
      },
    );
  };

  return (
    <>
      <DefaultInputButtons {...props} hasCommands={false} />
      <Pressable style={styles.liveLocationButton} onPress={sendLiveLocation}>
        <ShareLocationIcon />
      </Pressable>
    </>
  );
};

const styles = StyleSheet.create({
  liveLocationButton: {
    paddingLeft: 5,
  },
});

export default InputButtons;

Step 3: Create Message Card with map showing location

Lets add a component to show the location in a map on a message with a button to stop sharing the location. Channel component accepts a prop called Card to render any type of custom attachment.

Below is an implementation of this card:

Card.tsx
import React, {useMemo} from 'react';
import {Button, StyleSheet, useWindowDimensions} from 'react-native';
import MapView, {Marker} from 'react-native-maps';
import {
  Channel,
  Card as DefaultCard,
  useMessageContext,
  useMessageOverlayContext,
  useOverlayContext,
} from 'stream-chat-react-native';
import {useLiveLocationContext} from './LiveLocationContext';
import {StreamChatGenerics} from './types';

const MapCard = ({
  latitude,
  longitude,
  ended_at,
}: {
  latitude: number;
  longitude: number;
  ended_at?: string;
}) => {
  const {width, height} = useWindowDimensions();
  const aspect_ratio = width / height;
  const {stopLiveLocation} = useLiveLocationContext();

  const {isMyMessage, message} = useMessageContext();
  const {data} = useMessageOverlayContext();
  const {overlay} = useOverlayContext();
  const overlayId = data?.message?.id;
  // is this message shown on overlay? If yes, then don't show the button
  const isOverlayOpen = overlay === 'message' && overlayId === message.id;
  const showStopSharingButton = !ended_at && isMyMessage && !isOverlayOpen;

  // Convert ISO date string to Date object
  const endedAtDate = ended_at ? new Date(ended_at) : null;

  // Format the date to a readable string
  const formattedEndedAt = endedAtDate ? endedAtDate.toLocaleString() : '';

  // this is to compute the zoom level and centre for the map view
  const region = useMemo(() => {
    const latitudeDelta = 0.02;
    const longitudeDelta = latitudeDelta * aspect_ratio;

    // For reference, check -
    // https://github.com/react-native-maps/react-native-maps/blob/master/example/src/examples/DisplayLatLng.tsx
    return {
      latitude,
      longitude,
      latitudeDelta,
      longitudeDelta,
    };
  }, [aspect_ratio, latitude, longitude]);

  return (
    <>
      <MapView
        region={region}
        pitchEnabled={false}
        rotateEnabled={false}
        scrollEnabled={false}
        zoomTapEnabled={false}
        zoomEnabled={false}
        toolbarEnabled={false}
        style={styles.mapView}>
        <Marker
          coordinate={{
            latitude,
            longitude,
          }}
        />
      </MapView>
      {showStopSharingButton && (
        <Button
          title="Stop sharing"
          onPress={() => {
            stopLiveLocation(message.id);
          }}
        />
      )}
      {ended_at && (
        <Button title={`Ended at: ${formattedEndedAt}`} disabled={true} />
      )}
      {!ended_at && !showStopSharingButton && (
        <Button title={'Live location'} disabled={true} />
      )}
    </>
  );
};

const Card: NonNullable<
  React.ComponentProps<typeof Channel>['Card']
> = props => {
  const {type, ...otherProperties} = props;

  if (type === 'location') {
    // @ts-ignore
    return <MapCard {...otherProperties} />;
  }

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

const styles = StyleSheet.create({
  mapView: {
    height: 250,
    width: 250,
  },
});

export default Card;

Default UI components and context providers from the SDK are memoized for performance purposes adhering the FlatList performance optimization rules, and it will not trigger re-renders upon updates to custom properties on attachment. This can be solved by providing the isAttachmentEqual function prop in the Channel component which checks for changes in custom properties which you may have been defined on attachment.

const isAttachmentEqual: NonNullable<
  React.ComponentProps<typeof Channel<StreamChatGenerics>>["isAttachmentEqual"]
> = (prevAttachment, nextAttachment) => {
  if (
    prevAttachment.type === "location" &&
    nextAttachment.type === "location"
  ) {
    return (
      prevAttachment.latitude === nextAttachment.latitude &&
      prevAttachment.longitude === nextAttachment.longitude &&
      prevAttachment.ended_at === nextAttachment.ended_at
    );
  }
  return true;
};

On the tap of a message, we want to navigate to the detail map. This can be done by providing the onPressMessage function prop in the Channel component which is the callback executed when a message is tapped.

const onPressMessage: NonNullable<
  React.ComponentProps<typeof Channel<StreamChatGenerics>>["onPressMessage"]
> = (payload) => {
  const { message, defaultHandler, emitter } = payload;
  if (emitter === "messageContent") {
    if (message?.attachments?.[0]?.type === "location") {
      // here we use react-navigation to define screens
      // and we pass the initial data to the screen
      navigation.navigate("MapDetail", {
        messageId: message.id,
        latitude: message.attachments[0].latitude!,
        longitude: message.attachments[0].longitude!,
        ended_at: message.attachments[0].ended_at,
      });
    }
  }
  defaultHandler?.();
};

Step 4: Add the relevant props to the Channel component

Lets add the InputButtons, Card, isAttachmentEqual and onPressMessage to the Channel component.

import { Channel } from 'stream-chat-react-native';
import InputButtons from './InputButtons';
import Card from './Card';

<Channel
  channel={channel}
  InputButtons={InputButtons}
  Card={Card}
  keyboardVerticalOffset={headerHeight}
  onPressMessage={onPressMessage}
  isAttachmentEqual={isAttachmentEqual}
>
  {/* The underlying components */}
</Channel>;

You should now be able to see the following attachment on the message on tap of the “Share Live Location” button that we created in Step 2:

Preview of chat screen

Step 5: Add the map detail screen

In this screen, we display the coordinates of the map in a full screen map. Additionally we also show the button to stop sharing the location.

We use the channel.on method to listen to the following events:

  • The message.updated event is used to watch the location updates and then update the map.
  • The message.deleted method is used to exit the screen if the message was deleted.

Here is an example implementation of the screen:

import React, {useMemo} from 'react';
import {StackScreenProps} from '@react-navigation/stack';
import {useEffect} from 'react';
import {Alert, Button, StyleSheet, useWindowDimensions} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import MapView, {Marker} from 'react-native-maps';
import {NavigationParamsList} from './types';
import {useAppContext} from './AppContext';
import {useLiveLocationContext} from './LiveLocationContext';

type MapDetailScreenProps = StackScreenProps<NavigationParamsList, 'MapDetail'>;

const MapDetailScreen: React.FC<MapDetailScreenProps> = ({
  route,
  navigation,
}) => {
  // store channel in an app context like below to easily access the channel in this screen
  const {channel} = useAppContext();
  if (!channel) {
    throw new Error('MapDetailScreen - Channel is not defined');
  }
  const {isWatching, stopLiveLocation} = useLiveLocationContext();
  const {width, height} = useWindowDimensions();
  const aspect_ratio = width / height;

  // the parameters passed to the screen define the state
  const {messageId, latitude, longitude, ended_at} = route.params;
  const showStopSharingButton = !ended_at && isWatching(messageId);

  const endedAtDate = ended_at ? new Date(ended_at) : null;
  const formattedEndedAt = endedAtDate ? endedAtDate.toLocaleString() : '';

  const region = useMemo(() => {
    const latitudeDelta = 0.1;
    const longitudeDelta = latitudeDelta * aspect_ratio;
    return {
      latitude,
      longitude,
      latitudeDelta,
      longitudeDelta,
    };
  }, [aspect_ratio, latitude, longitude]);

  useEffect(() => {
    const listeners = [
      channel.on('message.updated', event => {
        if (
          event.message?.id === messageId &&
          event.message.attachments?.[0]?.type === 'location'
        ) {
          const attachment = event.message.attachments[0];
          if (attachment) {
            // update the navigation params of the screen which would in turn update the state
            navigation.setParams({
              latitude: attachment.latitude,
              longitude: attachment.longitude,
              ended_at: attachment.ended_at,
            });
          }
        }
      }),
      channel.on('message.deleted', event => {
        if (event.message?.id === messageId) {
          Alert.alert(
            'Message deleted',
            'The live location message has been deleted',
          );
          navigation.goBack();
        }
      }),
    ];

    return () => listeners.forEach(l => l.unsubscribe());
  }, [channel, messageId, navigation]);

  return (
    <SafeAreaView style={styles.container} edges={['bottom']}>
      <MapView region={region} style={styles.container}>
        <Marker
          coordinate={{
            latitude,
            longitude,
          }}
        />
      </MapView>
      {showStopSharingButton && (
        <Button
          title="Stop sharing"
          onPress={() => {
            stopLiveLocation(messageId);
          }}
        />
      )}
      {ended_at && (
        <Button title={`Ended at: ${formattedEndedAt}`} disabled={true} />
      )}
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
});

export default MapDetailScreen;

You should now be able to see the following screen with a detailed map:

Preview of chat screen

Sample code

This cookbook is available as a full fledged app sample at our react-native-samples repository.

© Getstream.io, Inc. All Rights Reserved.