import { MessageInput, useChannelStateContext } from 'stream-chat-react';
import { useGeoLocContext } from './geo-loc-context';
import './message-input-with-location-button.css';
export const MessageInputWithLocationButton = () => {
const { channel } = useChannelStateContext();
const { registerMessageIds } = useGeoLocContext();
return (
<div className='message-input-container'>
<MessageInput focus />
<button
onClick={() => {
const shouldShareLocation = window.confirm('Would you like to share your location?');
if (!shouldShareLocation) return;
navigator.geolocation.getCurrentPosition(
({ coords: { latitude, longitude } }) => {
channel
.sendMessage({
attachments: [
{
type: 'location',
latitude,
longitude,
},
],
})
.then((response) => {
registerMessageIds([response.message.id]);
});
},
console.log,
{ maximumAge: 0, timeout: 500 },
);
}}
className='location-share-button'
>
🗺️
</button>
</div>
);
};
Geolocation Attachment & Live Location Sharing
In this comprehensive example, we demonstrate how to build a live location sharing feature. Chat users will have the ability to send their location to a channel and display it through a custom Attachment
component that displays coordinates using the Google Maps API.
The feature flow has three distinct steps:
- Click
Share Location
button with confirmation dialog - Confirm, send and register a message for live location updates
- Render coordinates on a Google Maps overlay sent as a message attachment
Prerequisities
We’ve prepared an example geolocation context/controller that we’ll be referencing throughout the guide. This controller takes care of registering and updating messages with the current location. Note that this is an example controller that should be expanded on based on your needs and requirements. This controller is missing logic such as end-of-life of the messages with live location updates, manually stopping location updates, limiting the amount of messages with live location updates or proper error handling. Treat it as a baseline to get you started.
If you wish to use only one-time location sharing functionality, you don’t need GeoLocContext
controller/context and can safely omit it.
Custom Message Input With Location Sharing Button
The first step in our location sharing flow is to add a custom button beside message input that on click allows a chat user to begin the process of sending their coordinates to the channel.
In this example, our custom handler function will utilise window.confirm
dialog to get the user confirmation before requesting location data.
We’ll be using registerMessageIds
function from our pre-defined GeoLocContext
to save messages to a localStorage
to be later loaded in case of a page reload so that we can keep sharing our location.
.message-input-container {
display: flex;
}
.location-share-button {
border-radius: 9999px;
margin: 5px 5px 5px 0px;
line-height: 1;
padding: 5px 10px;
}
Custom Map Attachment
Now that we’re able to send a message with an attachment of type location
, we need to build a custom Attachment
component that can this new type. If a message attachment is not of type location
, meaning it’s a standard library type, we return the default Attachment
component.
When our custom component receives an attachment of type location
, we pass the geolocation coordinates to the Map
and Marker
components from @vis.gl/react-google-maps
. This library is a React-based wrapper around the Google Maps API and displays a map and geolocation as a React component. See the @vis.gl/react-google-maps
documentation for more information.
In order to interact with the Google Maps API, you must set up an account and generate an API key.
Since we’ve added new fields to our location
type message attachment, we must extend the default attachment type to support latitude
and longitude
through StreamChatGenerics
which we can then pass to AttachmentProps
type.
import { Attachment } from 'stream-chat-react';
import { APIProvider, Map, Marker } from '@vis.gl/react-google-maps';
import type { AttachmentProps } from 'stream-chat-react';
export type StreamChatGenerics = {
attachmentType: { latitude: number; longitude: number };
// ▽ defaults
commandType: string;
} & Record<
| 'channelType'
| 'eventType'
| 'messageType'
| 'pollOptionType'
| 'pollType'
| 'reactionType'
| 'userType',
Record<string, unknown>
>;
const GOOGLE_MAPS_API_KEY = 'key';
export const AttachmentWithMap = (props: AttachmentProps<StreamChatGenerics>) => {
const [locationAttachment] = props.attachments;
if (locationAttachment.type === 'location') {
const { latitude, longitude } = locationAttachment;
return (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
<Map
style={{ width: '300px', height: '300px' }}
defaultCenter={{ lat: latitude, lng: longitude }}
defaultZoom={16}
disableDefaultUI={true}
gestureHandling='greedy'
>
<Marker position={{ lat: latitude, lng: longitude }} />
</Map>
</APIProvider>
);
}
return <Attachment {...props} />;
};
Implementation
Now that each individual piece has been constructed, we can assemble all of the snippets into the final code example.
import { Chat, ChannelList, Channel, Window, Thread } from 'stream-chat-react';
import { AttachmentWithMap, MessageInputWithLocationButton } from './custom-components';
import { GeoLocContextProvider } from './geo-loc-context';
import type { StreamChatGenerics } from './custom-components';
const App = () => {
const chatClient = useCreateChatClient<StreamChatGenerics>(/*...*/);
if (!chatClient) return <div>Loading...</div>;
return (
<Chat client={chatClient}>
<ChannelList />
<Channel Attachment={AttachmentWithMap}>
<Window>
<ChannelHeader />
<MessageList />
<MessageInputWithLocationButton />
</Window>
<Thread />
</Channel>
</Chat>
);
};
GeoLocContext
import React, { useState, useEffect, useCallback, useRef, useContext } from 'react';
import { useChatContext } from 'stream-chat-react';
import type { PropsWithChildren } from 'react';
type GeoLocContextValue = {
registeredMessageIds: string[];
registerMessageIds: (messageIds: string[]) => void;
unregisterMessageIds: (messageIds: string[]) => void;
};
const GeoLocContext = React.createContext<GeoLocContextValue>({
registeredMessageIds: [],
registerMessageIds: () => {},
unregisterMessageIds: () => {},
});
export const useGeoLocContext = () => useContext(GeoLocContext);
const constructStorageKey = (userId: string) => {
return `$geoloc.registeredMessageIds-${userId}`;
};
export const GeoLocContextProvider = ({ children }: PropsWithChildren) => {
const { client } = useChatContext();
const [registeredMessageIds, setRegisteredMessageIds] = useState(() => {
const registeredMessageIdsString = window.localStorage.getItem(
constructStorageKey(client.userID!),
);
if (!registeredMessageIdsString) return [];
try {
const messageIds = JSON.parse(atob(registeredMessageIdsString));
return (messageIds as unknown) as string[];
} catch (error) {
console.log(error);
return [];
}
});
const registerMessageIds = useCallback((messageIds: string[]) => {
setRegisteredMessageIds((currentMessageIds) => currentMessageIds.concat(messageIds));
}, []);
const unregisterMessageIds = useCallback((messageIds: string[]) => {
setRegisteredMessageIds((currentMessageIds) =>
currentMessageIds.filter((messageId) => !messageIds.includes(messageId)),
);
}, []);
const handlePositionChangeRef = useRef<(position: GeolocationPosition) => Promise<void>>();
handlePositionChangeRef.current = async (position) => {
const settled = await Promise.allSettled(
registeredMessageIds.map((messageId) =>
client.partialUpdateMessage(messageId, {
set: {
attachments: [
{
type: 'location',
latitude: position.coords.latitude,
longitude: position.coords.longitude,
},
],
},
}),
),
);
const idsToDelete = [];
for (let index = 0; index < settled.length; index++) {
if (settled[index].status !== 'rejected') continue;
idsToDelete.push(registeredMessageIds[index]);
}
unregisterMessageIds(idsToDelete);
};
useEffect(() => {
// sync storage
const oldValue = window.localStorage.getItem(constructStorageKey(client.userID!));
const newValue = btoa(JSON.stringify(registeredMessageIds));
if (newValue === oldValue) return;
window.localStorage.setItem(constructStorageKey(client.userID!), newValue);
}, [client, registeredMessageIds]);
const shouldWatch = registeredMessageIds.length !== 0;
useEffect(() => {
let promise: Promise<void> | null = null;
if (!shouldWatch) return;
const position = navigator.geolocation.watchPosition(
(newPosition) => {
if (promise) return;
promise = handlePositionChangeRef.current!(newPosition).finally(() => {
promise = null;
});
},
console.log,
{ enableHighAccuracy: true },
);
return () => {
navigator.geolocation.clearWatch(position);
};
}, [shouldWatch]);
return (
<GeoLocContext.Provider
value={{ registeredMessageIds, registerMessageIds, unregisterMessageIds }}
>
{children}
</GeoLocContext.Provider>
);
};