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.

custom-components.tsx
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>
  );
};

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.

custom-components.tsx
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.

app.tsx
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

geo-loc-context.tsx
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>
  );
};
© Getstream.io, Inc. All Rights Reserved.