Skip to content

Commit cd7d9fe

Browse files
committed
feat: add location sharing component
1 parent 56757ee commit cd7d9fe

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+1752
-191
lines changed

i18next-parser.config.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ module.exports = {
99
namespaceSeparator: false,
1010
output: 'src/i18n/$LOCALE.json',
1111
sort(a, b) {
12-
return a < b ? -1 : 1; // alfabetical order
12+
return a < b ? -1 : 1; // alphabetical order
1313
},
14-
useKeysAsDefaultValue: true,
1514
verbose: true,
1615
};

src/components/Attachment/Attachment.tsx

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isFileAttachment,
55
isImageAttachment,
66
isScrapedContent,
7+
isSharedLocationResponse,
78
isVideoAttachment,
89
isVoiceRecordingAttachment,
910
} from 'stream-chat';
@@ -13,6 +14,7 @@ import {
1314
CardContainer,
1415
FileContainer,
1516
GalleryContainer,
17+
GeolocationContainer,
1618
ImageContainer,
1719
MediaContainer,
1820
UnsupportedAttachmentContainer,
@@ -21,7 +23,7 @@ import {
2123
import { SUPPORTED_VIDEO_FORMATS } from './utils';
2224

2325
import type { ReactPlayerProps } from 'react-player';
24-
import type { Attachment as StreamAttachment } from 'stream-chat';
26+
import type { SharedLocationResponse, Attachment as StreamAttachment } from 'stream-chat';
2527
import type { AttachmentActionsProps } from './AttachmentActions';
2628
import type { AudioProps } from './Audio';
2729
import type { VoiceRecordingProps } from './VoiceRecording';
@@ -31,6 +33,7 @@ import type { GalleryProps, ImageProps } from '../Gallery';
3133
import type { UnsupportedAttachmentProps } from './UnsupportedAttachment';
3234
import type { ActionHandlerReturnType } from '../Message/hooks/useActionHandler';
3335
import type { GroupedRenderedAttachment } from './utils';
36+
import type { GeolocationProps } from './Geolocation';
3437

3538
const CONTAINER_MAP = {
3639
audio: AudioContainer,
@@ -49,12 +52,13 @@ export const ATTACHMENT_GROUPS_ORDER = [
4952
'audio',
5053
'voiceRecording',
5154
'file',
55+
'geolocation',
5256
'unsupported',
5357
] as const;
5458

5559
export type AttachmentProps = {
5660
/** The message attachments to render, see [attachment structure](https://getstream.io/chat/docs/javascript/message_format/?language=javascript) **/
57-
attachments: StreamAttachment[];
61+
attachments: (StreamAttachment | SharedLocationResponse)[];
5862
/** The handler function to call when an action is performed on an attachment, examples include canceling a \/giphy command or shuffling the results. */
5963
actionHandler?: ActionHandlerReturnType;
6064
/** Custom UI component for displaying attachment actions, defaults to and accepts same props as: [AttachmentActions](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/AttachmentActions.tsx) */
@@ -67,6 +71,7 @@ export type AttachmentProps = {
6771
File?: React.ComponentType<FileAttachmentProps>;
6872
/** Custom UI component for displaying a gallery of image type attachments, defaults to and accepts same props as: [Gallery](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/Gallery.tsx) */
6973
Gallery?: React.ComponentType<GalleryProps>;
74+
Geolocation?: React.ComponentType<GeolocationProps>;
7075
/** Custom UI component for displaying an image type attachment, defaults to and accepts same props as: [Image](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/Image.tsx) */
7176
Image?: React.ComponentType<ImageProps>;
7277
/** Optional flag to signal that an attachment is a displayed as a part of a quoted message */
@@ -113,16 +118,26 @@ const renderGroupedAttachments = ({
113118
.filter((attachment) => !isImageAttachment(attachment))
114119
.reduce<GroupedRenderedAttachment>(
115120
(typeMap, attachment) => {
116-
const attachmentType = getAttachmentType(attachment);
117-
118-
const Container = CONTAINER_MAP[attachmentType];
119-
typeMap[attachmentType].push(
120-
<Container
121-
key={`${attachmentType}-${typeMap[attachmentType].length}`}
122-
{...rest}
123-
attachment={attachment}
124-
/>,
125-
);
121+
if (isSharedLocationResponse(attachment)) {
122+
typeMap.geolocation.push(
123+
<GeolocationContainer
124+
{...rest}
125+
key='geolocation-container'
126+
location={attachment}
127+
/>,
128+
);
129+
} else {
130+
const attachmentType = getAttachmentType(attachment);
131+
132+
const Container = CONTAINER_MAP[attachmentType];
133+
typeMap[attachmentType].push(
134+
<Container
135+
key={`${attachmentType}-${typeMap[attachmentType].length}`}
136+
{...rest}
137+
attachment={attachment}
138+
/>,
139+
);
140+
}
126141

127142
return typeMap;
128143
},
@@ -137,6 +152,7 @@ const renderGroupedAttachments = ({
137152
image: [],
138153
// eslint-disable-next-line sort-keys
139154
gallery: [],
155+
geolocation: [],
140156
voiceRecording: [],
141157
},
142158
);

src/components/Attachment/AttachmentContainer.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@ import React, { useLayoutEffect, useRef, useState } from 'react';
33
import ReactPlayer from 'react-player';
44
import clsx from 'clsx';
55
import * as linkify from 'linkifyjs';
6-
import type { Attachment, LocalAttachment } from 'stream-chat';
6+
import type { Attachment, LocalAttachment, SharedLocationResponse } from 'stream-chat';
7+
import { isSharedLocationResponse } from 'stream-chat';
78

89
import { AttachmentActions as DefaultAttachmentActions } from './AttachmentActions';
910
import { Audio as DefaultAudio } from './Audio';
1011
import { VoiceRecording as DefaultVoiceRecording } from './VoiceRecording';
1112
import { Gallery as DefaultGallery, ImageComponent as DefaultImage } from '../Gallery';
1213
import { Card as DefaultCard } from './Card';
1314
import { FileAttachment as DefaultFile } from './FileAttachment';
15+
import { Geolocation as DefaultGeolocation } from './Geolocation';
1416
import { UnsupportedAttachment as DefaultUnsupportedAttachment } from './UnsupportedAttachment';
1517
import type {
1618
AttachmentComponentType,
1719
GalleryAttachment,
20+
GeolocationContainerProps,
1821
RenderAttachmentProps,
1922
RenderGalleryProps,
2023
} from './utils';
@@ -26,7 +29,7 @@ import type {
2629
} from '../../types/types';
2730

2831
export type AttachmentContainerProps = {
29-
attachment: Attachment | GalleryAttachment;
32+
attachment: Attachment | GalleryAttachment | SharedLocationResponse;
3033
componentType: AttachmentComponentType;
3134
};
3235
export const AttachmentWithinContainer = ({
@@ -37,7 +40,7 @@ export const AttachmentWithinContainer = ({
3740
const isGAT = isGalleryAttachmentType(attachment);
3841
let extra = '';
3942

40-
if (!isGAT) {
43+
if (!isGAT && !isSharedLocationResponse(attachment)) {
4144
extra =
4245
componentType === 'card' && !attachment?.image_url && !attachment?.thumb_url
4346
? 'no-image'
@@ -50,7 +53,9 @@ export const AttachmentWithinContainer = ({
5053
'str-chat__message-attachment str-chat__message-attachment-dynamic-size',
5154
{
5255
[`str-chat__message-attachment--${componentType}`]: componentType,
53-
[`str-chat__message-attachment--${attachment?.type}`]: attachment?.type,
56+
[`str-chat__message-attachment--${(attachment as Attachment)?.type}`]: (
57+
attachment as Attachment
58+
)?.type,
5459
[`str-chat__message-attachment--${componentType}--${extra}`]:
5560
componentType && extra,
5661
'str-chat__message-attachment--svg-image': isSvgAttachment(attachment),
@@ -288,6 +293,15 @@ export const MediaContainer = (props: RenderAttachmentProps) => {
288293
);
289294
};
290295

296+
export const GeolocationContainer = ({
297+
Geolocation = DefaultGeolocation,
298+
location,
299+
}: GeolocationContainerProps) => (
300+
<AttachmentWithinContainer attachment={location} componentType='geolocation'>
301+
<Geolocation location={location} />
302+
</AttachmentWithinContainer>
303+
);
304+
291305
export const UnsupportedAttachmentContainer = ({
292306
attachment,
293307
UnsupportedAttachment = DefaultUnsupportedAttachment,
Lines changed: 104 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,113 @@
1+
import type { ComponentType } from 'react';
2+
import { useEffect } from 'react';
3+
import { useRef, useState } from 'react';
14
import React from 'react';
2-
import type { Attachment, DefaultGenerics, ExtendableGenerics } from 'stream-chat';
3-
import { useChatContext, useMessageContext } from '../../context';
4-
5-
export const Geolocation = <SCG extends ExtendableGenerics = DefaultGenerics>({
6-
attachment,
7-
}: {
8-
attachment: Attachment<SCG>;
9-
}) => {
10-
const { channel } = useChatContext();
11-
const { isMyMessage, message } = useMessageContext();
12-
13-
const stoppedSharing = !!attachment.stopped_sharing;
14-
const expired: boolean =
15-
typeof attachment.end_time === 'string' &&
16-
Date.now() > new Date(attachment.end_time).getTime();
5+
import type { Coords, SharedLocationResponse } from 'stream-chat';
6+
import { useChatContext, useTranslationContext } from '../../context';
7+
import { ExternalLinkIcon, GeolocationIcon } from './icons';
8+
9+
export type GeolocationMapProps = Coords;
10+
11+
export type GeolocationProps = {
12+
location: SharedLocationResponse;
13+
GeolocationAttachmentMapPlaceholder?: ComponentType<GeolocationAttachmentMapPlaceholderProps>;
14+
GeolocationMap?: ComponentType<GeolocationMapProps>;
15+
};
16+
17+
export const Geolocation = ({
18+
GeolocationAttachmentMapPlaceholder = DefaultGeolocationAttachmentMapPlaceholder,
19+
GeolocationMap,
20+
location,
21+
}: GeolocationProps) => {
22+
const { channel, client } = useChatContext();
23+
const { t } = useTranslationContext();
24+
25+
const [stoppedSharing, setStoppedSharing] = useState(
26+
!!location.end_at && new Date(location.end_at).getTime() < new Date().getTime(),
27+
);
28+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
29+
30+
const isMyLocation = location.user_id === client.userID;
31+
const isLiveLocation = !!location.end_at;
32+
33+
useEffect(() => {
34+
if (!location.end_at) return;
35+
clearTimeout(timeoutRef.current);
36+
timeoutRef.current = setTimeout(
37+
() => setStoppedSharing(true),
38+
new Date(location.end_at).getTime() - Date.now(),
39+
);
40+
}, [location.end_at]);
1741

1842
return (
1943
<div
20-
style={{
21-
display: 'flex',
22-
flexDirection: 'column',
23-
gap: 10,
24-
paddingBlock: 15,
25-
paddingInline: 10,
26-
width: 'auto',
27-
}}
44+
className='str-chat__message-attachment-geolocation'
45+
data-testid='attachment-geolocation'
2846
>
29-
{attachment.type === 'live_location' &&
30-
!stoppedSharing &&
31-
!expired &&
32-
isMyMessage() && (
33-
<button
34-
onClick={() =>
35-
channel?.stopLiveLocationSharing({
36-
attachments: message.attachments,
37-
id: message.id,
38-
type: message.type,
39-
})
40-
}
41-
>
42-
Stop sharing
43-
</button>
47+
<div className='str-chat__message-attachment-geolocation__location-preview'>
48+
{GeolocationMap ? (
49+
<GeolocationMap latitude={location.latitude} longitude={location.longitude} />
50+
) : (
51+
<GeolocationAttachmentMapPlaceholder location={location} />
52+
)}
53+
</div>
54+
<div className='str-chat__message-attachment-geolocation__status'>
55+
{isLiveLocation ? (
56+
stoppedSharing ? (
57+
t('Location sharing ended')
58+
) : isMyLocation ? (
59+
<div className='str-chat__message-attachment-geolocation__status--active'>
60+
<button
61+
className='str-chat__message-attachment-geolocation__stop-sharing-button'
62+
onClick={() => channel?.stopLiveLocationSharing(location)}
63+
>
64+
{t('Stop sharing')}
65+
</button>
66+
<div className='str-chat__message-attachment-geolocation__status--active-until'>
67+
{t('Live until {{ timestamp }}', {
68+
timestamp: t('timestamp/LiveLocation', { timestamp: location.end_at }),
69+
})}
70+
</div>
71+
</div>
72+
) : (
73+
<div className='str-chat__message-attachment-geolocation__status--active'>
74+
<div className='str-chat__message-attachment-geolocation__status--active-status'>
75+
{t('Live location')}
76+
</div>
77+
<div className='str-chat__message-attachment-geolocation__status--active-until'>
78+
{t('Live until {{ timestamp }}', {
79+
timestamp: t('timestamp/LiveLocation', { timestamp: location.end_at }),
80+
})}
81+
</div>
82+
</div>
83+
)
84+
) : (
85+
t('Current location')
4486
)}
45-
{/* TODO: {MAP} */}
46-
<span>
47-
lat: {attachment.latitude}, lng: {attachment.longitude}
48-
</span>
49-
{(stoppedSharing || expired) && (
50-
<span style={{ fontSize: 12 }}>Location sharing ended</span>
51-
)}
87+
</div>
5288
</div>
5389
);
5490
};
91+
92+
export type GeolocationAttachmentMapPlaceholderProps = {
93+
location: SharedLocationResponse;
94+
};
95+
96+
const DefaultGeolocationAttachmentMapPlaceholder = ({
97+
location,
98+
}: GeolocationAttachmentMapPlaceholderProps) => (
99+
<div
100+
className='str-chat__message-attachment-geolocation__placeholder'
101+
data-testid='geolocation-attachment-map-placeholder'
102+
>
103+
<GeolocationIcon />
104+
<a
105+
className='str-chat__message-attachment-geolocation__placeholder-link'
106+
href={`https://maps.google.com?q=${[location.latitude, location.longitude].join()}`}
107+
rel='noreferrer'
108+
target='_blank'
109+
>
110+
<ExternalLinkIcon />
111+
</a>
112+
</div>
113+
);

src/components/Attachment/__tests__/Attachment.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import {
99
generateFileAttachment,
1010
generateGiphyAttachment,
1111
generateImageAttachment,
12+
generateLiveLocationResponse,
1213
generateScrapedAudioAttachment,
1314
generateScrapedDataAttachment,
1415
generateScrapedImageAttachment,
16+
generateStaticLocationResponse,
1517
generateVideoAttachment,
1618
} from 'mock-builders';
1719

@@ -31,6 +33,9 @@ const File = (props) => <div data-testid='file-attachment'>{props.customTestId}<
3133
const Gallery = (props) => (
3234
<div data-testid='gallery-attachment'>{props.customTestId}</div>
3335
);
36+
const Geolocation = (props) => (
37+
<div data-testid={'geolocation-attachment'}>{props.customTestId}</div>
38+
);
3439

3540
const ATTACHMENTS = {
3641
scraped: {
@@ -58,6 +63,7 @@ const renderComponent = (props) =>
5863
Card={Card}
5964
File={File}
6065
Gallery={Gallery}
66+
Geolocation={Geolocation}
6167
Image={Image}
6268
Media={Media}
6369
{...props}
@@ -248,6 +254,17 @@ describe('attachment', () => {
248254
});
249255
});
250256

257+
it('renders shared location with Geolocation attachment', () => {
258+
renderComponent({ attachments: [generateLiveLocationResponse()] });
259+
waitFor(() => {
260+
expect(screen.getByTestId(testId)).toBeInTheDocument();
261+
});
262+
renderComponent({ attachments: [generateStaticLocationResponse()] });
263+
waitFor(() => {
264+
expect(screen.getByTestId(testId)).toBeInTheDocument();
265+
});
266+
});
267+
251268
it('should render AttachmentActions component if attachment has actions', async () => {
252269
const action = generateAttachmentAction();
253270
const attachment = generateImageAttachment({

0 commit comments

Comments
 (0)