{props.customTestId}<
const Gallery = (props) => (
{props.customTestId}
);
+const Geolocation = (props) => (
+
{props.customTestId}
+);
const ATTACHMENTS = {
scraped: {
@@ -58,6 +63,7 @@ const renderComponent = (props) =>
Card={Card}
File={File}
Gallery={Gallery}
+ Geolocation={Geolocation}
Image={Image}
Media={Media}
{...props}
@@ -248,6 +254,17 @@ describe('attachment', () => {
});
});
+ it('renders shared location with Geolocation attachment', () => {
+ renderComponent({ attachments: [generateLiveLocationResponse()] });
+ waitFor(() => {
+ expect(screen.getByTestId(testId)).toBeInTheDocument();
+ });
+ renderComponent({ attachments: [generateStaticLocationResponse()] });
+ waitFor(() => {
+ expect(screen.getByTestId(testId)).toBeInTheDocument();
+ });
+ });
+
it('should render AttachmentActions component if attachment has actions', async () => {
const action = generateAttachmentAction();
const attachment = generateImageAttachment({
diff --git a/src/components/Attachment/__tests__/Geolocation.test.js b/src/components/Attachment/__tests__/Geolocation.test.js
new file mode 100644
index 0000000000..2327e91f25
--- /dev/null
+++ b/src/components/Attachment/__tests__/Geolocation.test.js
@@ -0,0 +1,207 @@
+import React from 'react';
+import { act, render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { Channel } from '../../Channel';
+import { Chat } from '../../Chat';
+import { Geolocation } from '../Geolocation';
+import {
+ generateLiveLocationResponse,
+ generateStaticLocationResponse,
+ initClientWithChannels,
+} from '../../../mock-builders';
+
+const GeolocationMapComponent = (props) => (
+
+);
+const GeolocationAttachmentMapPlaceholderComponent = (props) => (
+
+);
+
+const getStopSharingButton = () =>
+ screen.queryByRole('button', { name: /stop sharing/i });
+const getLocationSharingEndedLabel = () => screen.queryByText('Location sharing ended');
+const getStaticLocationLabel = () => screen.queryByText('Current location');
+const getOtherUsersLiveLocationLabel = () => screen.queryByText('Live location');
+const getGeolocationAttachmentMapPlaceholder = () =>
+ screen.queryByTestId('geolocation-attachment-map-placeholder');
+const getCustomGeolocationAttachmentMapPlaceholder = () =>
+ screen.queryByTestId('geolocation-attachment-map-placeholder-custom');
+const getGeolocationMap = () => screen.queryByTestId('geolocation-map');
+
+const ownUser = { id: 'user-id' };
+const otherUser = { id: 'other-user-id' };
+
+const renderComponent = async ({ channel, client, props } = {}) => {
+ const {
+ channels: [defaultChannel],
+ client: defaultClient,
+ } = await initClientWithChannels({ customUser: ownUser });
+ let result;
+ await act(() => {
+ result = render(
+
+
+
+
+ ,
+ );
+ });
+ return { channel: defaultChannel, client: defaultClient, result };
+};
+
+describe.each([
+ ['with', 'with', GeolocationMapComponent, GeolocationAttachmentMapPlaceholderComponent],
+ ['with', 'without', GeolocationMapComponent, undefined],
+ ['without', 'with', undefined, GeolocationAttachmentMapPlaceholderComponent],
+ ['without', 'without', undefined, undefined],
+])(
+ 'Geolocation attachment %s GeolocationMap and %s GeolocationAttachmentMapPlaceholder',
+ (_, __, GeolocationMap, GeolocationAttachmentMapPlaceholder) => {
+ it('renders own static location', async () => {
+ const location = generateStaticLocationResponse({ user_id: ownUser.id });
+ await renderComponent({
+ props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location },
+ });
+ expect(getStopSharingButton()).not.toBeInTheDocument();
+ expect(getLocationSharingEndedLabel()).not.toBeInTheDocument();
+ expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument();
+ expect(getStaticLocationLabel()).toBeInTheDocument();
+ if (GeolocationMap) {
+ expect(getGeolocationMap()).toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ } else if (GeolocationAttachmentMapPlaceholder) {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ } else {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ }
+ });
+
+ it("renders other user's static location", async () => {
+ const location = generateStaticLocationResponse({ user_id: otherUser.id });
+ await renderComponent({
+ props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location },
+ });
+ expect(getStopSharingButton()).not.toBeInTheDocument();
+ expect(getLocationSharingEndedLabel()).not.toBeInTheDocument();
+ expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument();
+ expect(getStaticLocationLabel()).toBeInTheDocument();
+ if (GeolocationMap) {
+ expect(getGeolocationMap()).toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ } else if (GeolocationAttachmentMapPlaceholder) {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ } else {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ }
+ });
+
+ it('renders own live location', async () => {
+ const location = generateLiveLocationResponse({ user_id: ownUser.id });
+ await renderComponent({
+ props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location },
+ });
+ expect(getStopSharingButton()).toBeInTheDocument();
+ expect(getLocationSharingEndedLabel()).not.toBeInTheDocument();
+ expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument();
+ expect(getStaticLocationLabel()).not.toBeInTheDocument();
+ if (GeolocationMap) {
+ expect(getGeolocationMap()).toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ } else if (GeolocationAttachmentMapPlaceholder) {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ } else {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ }
+ });
+ it("other user's live location", async () => {
+ const location = generateLiveLocationResponse({ user_id: otherUser.id });
+ await renderComponent({
+ props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location },
+ });
+ expect(getStopSharingButton()).not.toBeInTheDocument();
+ expect(getLocationSharingEndedLabel()).not.toBeInTheDocument();
+ expect(getOtherUsersLiveLocationLabel()).toBeInTheDocument();
+ expect(getStaticLocationLabel()).not.toBeInTheDocument();
+ if (GeolocationMap) {
+ expect(getGeolocationMap()).toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ } else if (GeolocationAttachmentMapPlaceholder) {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ } else {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ }
+ });
+ it("own user's stopped live location", async () => {
+ const location = generateLiveLocationResponse({
+ end_at: '1980-01-01T00:00:00.000Z',
+ user_id: ownUser.id,
+ });
+ await renderComponent({
+ props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location },
+ });
+ expect(getStopSharingButton()).not.toBeInTheDocument();
+ expect(getLocationSharingEndedLabel()).toBeInTheDocument();
+ expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument();
+ expect(getStaticLocationLabel()).not.toBeInTheDocument();
+ if (GeolocationMap) {
+ expect(getGeolocationMap()).toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ } else if (GeolocationAttachmentMapPlaceholder) {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ } else {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ }
+ });
+ it("other user's stopped live location", async () => {
+ const location = generateLiveLocationResponse({
+ end_at: '1980-01-01T00:00:00.000Z',
+ user_id: otherUser.id,
+ });
+ await renderComponent({
+ props: { GeolocationAttachmentMapPlaceholder, GeolocationMap, location },
+ });
+ expect(getStopSharingButton()).not.toBeInTheDocument();
+ expect(getLocationSharingEndedLabel()).toBeInTheDocument();
+ expect(getOtherUsersLiveLocationLabel()).not.toBeInTheDocument();
+ expect(getStaticLocationLabel()).not.toBeInTheDocument();
+ if (GeolocationMap) {
+ expect(getGeolocationMap()).toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ } else if (GeolocationAttachmentMapPlaceholder) {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ } else {
+ expect(getGeolocationMap()).not.toBeInTheDocument();
+ expect(getGeolocationAttachmentMapPlaceholder()).toBeInTheDocument();
+ expect(getCustomGeolocationAttachmentMapPlaceholder()).not.toBeInTheDocument();
+ }
+ });
+ },
+);
diff --git a/src/components/Attachment/icons.tsx b/src/components/Attachment/icons.tsx
index 6e26b2c396..75cc86bbba 100644
--- a/src/components/Attachment/icons.tsx
+++ b/src/components/Attachment/icons.tsx
@@ -29,3 +29,24 @@ export const PauseIcon = () => (
);
+
+export const GeolocationIcon = () => (
+
+);
+
+export const ExternalLinkIcon = () => (
+
+);
diff --git a/src/components/Attachment/index.ts b/src/components/Attachment/index.ts
index 3086b6dd4a..377cbe2468 100644
--- a/src/components/Attachment/index.ts
+++ b/src/components/Attachment/index.ts
@@ -5,7 +5,9 @@ export * from './Audio';
export * from './audioSampling';
export * from './Card';
export * from './components';
-export * from './UnsupportedAttachment';
export * from './FileAttachment';
+export * from './Geolocation';
+export * from './UnsupportedAttachment';
export * from './utils';
export { useAudioController } from './hooks/useAudioController';
+export * from '../Location/hooks/useLiveLocationSharingManager';
diff --git a/src/components/Attachment/utils.tsx b/src/components/Attachment/utils.tsx
index 3dd9f83a75..a6ec70b1ef 100644
--- a/src/components/Attachment/utils.tsx
+++ b/src/components/Attachment/utils.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
-import type { Attachment } from 'stream-chat';
+import type { Attachment, SharedLocationResponse } from 'stream-chat';
import type { ATTACHMENT_GROUPS_ORDER, AttachmentProps } from './Attachment';
export const SUPPORTED_VIDEO_FORMATS = [
@@ -26,6 +26,10 @@ export type RenderGalleryProps = Omit
& {
attachment: GalleryAttachment;
};
+export type GeolocationContainerProps = Omit & {
+ location: SharedLocationResponse;
+};
+
// This identity function determines attachment type specific to React.
// Once made sure other SDKs support the same logic, move to stream-chat-js
export const isGalleryAttachmentType = (
diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx
index 844274cb52..8746dda216 100644
--- a/src/components/Channel/Channel.tsx
+++ b/src/components/Channel/Channel.tsx
@@ -113,6 +113,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
| 'Input'
| 'LinkPreviewList'
| 'LoadingIndicator'
+ | 'ShareLocationDialog'
| 'Message'
| 'MessageActions'
| 'MessageBouncePrompt'
@@ -1237,6 +1238,7 @@ const ChannelInner = (
ReminderNotification: props.ReminderNotification,
SendButton: props.SendButton,
SendToChannelCheckbox: props.SendToChannelCheckbox,
+ ShareLocationDialog: props.ShareLocationDialog,
StartRecordingAudioButton: props.StartRecordingAudioButton,
StopAIGenerationButton: props.StopAIGenerationButton,
StreamedMessageText: props.StreamedMessageText,
@@ -1303,6 +1305,7 @@ const ChannelInner = (
props.ReminderNotification,
props.SendButton,
props.SendToChannelCheckbox,
+ props.ShareLocationDialog,
props.StartRecordingAudioButton,
props.StopAIGenerationButton,
props.StreamedMessageText,
diff --git a/src/components/ChannelPreview/__tests__/utils.test.js b/src/components/ChannelPreview/__tests__/utils.test.js
index fb2722d33e..d46c8ddf2f 100644
--- a/src/components/ChannelPreview/__tests__/utils.test.js
+++ b/src/components/ChannelPreview/__tests__/utils.test.js
@@ -13,6 +13,7 @@ import {
} from 'mock-builders';
import { getDisplayImage, getDisplayTitle, getLatestMessagePreview } from '../utils';
+import { generateStaticLocationResponse } from '../../../mock-builders';
describe('ChannelPreview utils', () => {
const clientUser = generateUser();
@@ -37,6 +38,15 @@ describe('ChannelPreview utils', () => {
const channelWithDeletedMessage = generateChannel({
messages: [generateMessage({ deleted_at: new Date() })],
});
+ const channelWithLocationMessage = generateChannel({
+ messages: [
+ generateMessage({
+ attachments: [],
+ shared_location: generateStaticLocationResponse(),
+ text: '',
+ }),
+ ],
+ });
const channelWithAttachmentMessage = generateChannel({
messages: [
generateMessage({
@@ -50,6 +60,7 @@ describe('ChannelPreview utils', () => {
['Nothing yet...', 'channelWithEmptyMessage', channelWithEmptyMessage],
['Message deleted', 'channelWithDeletedMessage', channelWithDeletedMessage],
['🏙 Attachment...', 'channelWithAttachmentMessage', channelWithAttachmentMessage],
+ ['📍Shared location', 'channelWithLocationMessage', channelWithLocationMessage],
])('should return %s for %s', async (expectedValue, testCaseName, c) => {
const t = (text) => text;
const channel = await getQueriedChannelInstance(c);
diff --git a/src/components/ChannelPreview/utils.tsx b/src/components/ChannelPreview/utils.tsx
index 5ae0353d5c..bf39b94528 100644
--- a/src/components/ChannelPreview/utils.tsx
+++ b/src/components/ChannelPreview/utils.tsx
@@ -88,6 +88,10 @@ export const getLatestMessagePreview = (
return t('🏙 Attachment...');
}
+ if (latestMessage.shared_location) {
+ return t('📍Shared location');
+ }
+
return t('Empty message...');
};
diff --git a/src/components/Dialog/DialogAnchor.tsx b/src/components/Dialog/DialogAnchor.tsx
index de33b6ff71..5442256e61 100644
--- a/src/components/Dialog/DialogAnchor.tsx
+++ b/src/components/Dialog/DialogAnchor.tsx
@@ -11,9 +11,11 @@ export interface DialogAnchorOptions {
open: boolean;
placement: Placement;
referenceElement: HTMLElement | null;
+ allowFlip?: boolean;
}
export function useDialogAnchor({
+ allowFlip,
open,
placement,
referenceElement,
@@ -21,6 +23,10 @@ export function useDialogAnchor({
const [popperElement, setPopperElement] = useState(null);
const { attributes, styles, update } = usePopper(referenceElement, popperElement, {
modifiers: [
+ {
+ enabled: !!allowFlip, // Prevent flipping
+ name: 'flip',
+ },
{
name: 'eventListeners',
options: {
@@ -61,6 +67,7 @@ export type DialogAnchorProps = PropsWithChildren>
} & ComponentProps<'div'>;
export const DialogAnchor = ({
+ allowFlip = true,
children,
className,
focus = true,
@@ -74,6 +81,7 @@ export const DialogAnchor = ({
const dialog = useDialog({ id });
const open = useDialogIsOpen(id);
const { attributes, setPopperElement, styles } = useDialogAnchor({
+ allowFlip,
open,
placement,
referenceElement,
diff --git a/src/components/Form/Dropdown.tsx b/src/components/Form/Dropdown.tsx
new file mode 100644
index 0000000000..749549103c
--- /dev/null
+++ b/src/components/Form/Dropdown.tsx
@@ -0,0 +1,110 @@
+import type { PropsWithChildren } from 'react';
+import { useRef } from 'react';
+import { useEffect } from 'react';
+import React, { useState } from 'react';
+import { DialogAnchor, useDialog, useDialogIsOpen } from '../Dialog';
+import { DialogManagerProvider, useTranslationContext } from '../../context';
+import type { Placement } from '@popperjs/core';
+
+type DropdownContextValue = {
+ close(): void;
+};
+
+const DropdownContext = React.createContext({
+ close: () => null,
+});
+
+type DropdownContextProviderProps = DropdownContextValue;
+
+const DropdownContextProvider = ({
+ children,
+ ...props
+}: PropsWithChildren) => (
+ {children}
+);
+
+export const useDropdownContext = () => React.useContext(DropdownContext);
+
+export type DropdownProps = PropsWithChildren<{
+ className?: string;
+ openButtonProps?: React.HTMLAttributes;
+ placement?: Placement;
+}>;
+
+export const Dropdown = (props: DropdownProps) => {
+ const dropdownDialogId = `dropdown`;
+
+ return (
+
+
+
+
+
+ );
+};
+
+const DropdownInner = ({
+ children,
+ dialogId,
+ openButtonProps,
+ placement = 'bottom',
+}: DropdownProps & { dialogId: string }) => {
+ const { t } = useTranslationContext();
+ const [openButton, setOpenButton] = useState(null);
+ const [dropdownWidth, setDropdownWidth] = useState('');
+ const dropdownRef = useRef(null);
+ const dialog = useDialog({ id: dialogId });
+ const dropdownDialogIsOpen = useDialogIsOpen(dialogId);
+
+ useEffect(() => {
+ if (!openButton || typeof ResizeObserver === 'undefined') return;
+ let timeout: ReturnType;
+ const observer = new ResizeObserver(([button]) => {
+ if (timeout) clearTimeout(timeout);
+ timeout = setTimeout(() => {
+ const width = button.target.getBoundingClientRect().width + 'px';
+ if (!dropdownRef.current) {
+ setDropdownWidth(width);
+ return;
+ }
+ dropdownRef.current.style.width = width;
+ }, 100);
+ });
+ observer.observe(openButton);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, [openButton]);
+
+ return (
+
+
+ );
+};
diff --git a/src/components/Form/SwitchField.tsx b/src/components/Form/SwitchField.tsx
index 9d59631a79..5641bc30f9 100644
--- a/src/components/Form/SwitchField.tsx
+++ b/src/components/Form/SwitchField.tsx
@@ -18,7 +18,11 @@ export const SwitchField = ({ children, ...props }: SwitchFieldProps) => {
};
return (
-
+