yarn add @react-native-community/geolocation
yarn add react-native-maps
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:
@react-native-community/geolocation
library is used to watch the current location of the user and then send it in the messagereact-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:
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:
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:
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:
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:
Sample code
This cookbook is available as a full fledged app sample at our react-native-samples repository.