Skip to content

Commit e990c06

Browse files
authored
feat: add new giphy and audio attachment widget designs and attachment grouping redesign (#2934)
1 parent 1d28279 commit e990c06

79 files changed

Lines changed: 1572 additions & 1107 deletions

File tree

Some content is hidden

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

src/components/Attachment/Attachment.tsx

Lines changed: 95 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -10,50 +10,39 @@ import {
1010
} from 'stream-chat';
1111

1212
import {
13-
AudioContainer,
1413
CardContainer,
1514
FileContainer,
16-
GalleryContainer,
1715
GeolocationContainer,
18-
ImageContainer,
16+
GiphyContainer,
1917
MediaContainer,
2018
UnsupportedAttachmentContainer,
21-
VoiceRecordingContainer,
2219
} from './AttachmentContainer';
2320
import { SUPPORTED_VIDEO_FORMATS } from './utils';
21+
import { defaultAttachmentActionsDefaultFocus } from './AttachmentActions';
2422

2523
import type { ReactPlayerProps } from 'react-player';
2624
import type { SharedLocationResponse, Attachment as StreamAttachment } from 'stream-chat';
27-
import type { AttachmentActionsProps } from './AttachmentActions';
25+
import type {
26+
AttachmentActionsDefaultFocusByType,
27+
AttachmentActionsProps,
28+
} from './AttachmentActions';
2829
import type { AudioProps } from './Audio';
2930
import type { VoiceRecordingProps } from './VoiceRecording';
30-
import type { CardProps } from './Card';
31+
import type { CardProps } from './LinkPreview/Card';
3132
import type { FileAttachmentProps } from './FileAttachment';
3233
import type { GalleryProps, ImageProps } from '../Gallery';
3334
import type { UnsupportedAttachmentProps } from './UnsupportedAttachment';
3435
import type { ActionHandlerReturnType } from '../Message/hooks/useActionHandler';
3536
import type { GroupedRenderedAttachment } from './utils';
3637
import type { GeolocationProps } from './Geolocation';
37-
38-
const CONTAINER_MAP = {
39-
audio: AudioContainer,
40-
// todo: rename to linkPreview
41-
card: CardContainer,
42-
file: FileContainer,
43-
media: MediaContainer,
44-
unsupported: UnsupportedAttachmentContainer,
45-
voiceRecording: VoiceRecordingContainer,
46-
} as const;
38+
import type { GiphyAttachmentProps } from './Giphy';
4739

4840
export const ATTACHMENT_GROUPS_ORDER = [
49-
'card',
50-
'gallery',
51-
'image',
5241
'media',
53-
'audio',
54-
'voiceRecording',
55-
'file',
42+
'giphy',
43+
'card',
5644
'geolocation',
45+
'file',
5746
'unsupported',
5847
] as const;
5948

@@ -62,6 +51,8 @@ export type AttachmentProps = {
6251
attachments: (StreamAttachment | SharedLocationResponse)[];
6352
/** The handler function to call when an action is performed on an attachment, examples include canceling a \/giphy command or shuffling the results. */
6453
actionHandler?: ActionHandlerReturnType;
54+
/** Which action should be focused on initial render, by attachment type (match by action.value) */
55+
attachmentActionsDefaultFocus?: AttachmentActionsDefaultFocusByType;
6556
/** 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) */
6657
AttachmentActions?: React.ComponentType<AttachmentActionsProps>;
6758
/** Custom UI component for displaying an audio type attachment, defaults to and accepts same props as: [Audio](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/Audio.tsx) */
@@ -73,6 +64,8 @@ export type AttachmentProps = {
7364
/** 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) */
7465
Gallery?: React.ComponentType<GalleryProps>;
7566
Geolocation?: React.ComponentType<GeolocationProps>;
67+
/** Custom UI component for displaying a Giphy image, defaults to and accepts same props as: [Giphy](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/Giphy.tsx) */
68+
Giphy?: React.ComponentType<GiphyAttachmentProps>;
7669
/** 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) */
7770
Image?: React.ComponentType<ImageProps>;
7871
/** Optional flag to signal that an attachment is a displayed as a part of a quoted message */
@@ -86,15 +79,24 @@ export type AttachmentProps = {
8679
};
8780

8881
/**
89-
* A component used for rendering message attachments. By default, the component supports: AttachmentActions, Audio, Card, File, Gallery, Image, and Video
82+
* A component used for rendering message attachments.
9083
*/
9184
export const Attachment = (props: AttachmentProps) => {
92-
const { attachments } = props;
85+
const {
86+
attachmentActionsDefaultFocus = defaultAttachmentActionsDefaultFocus,
87+
attachments,
88+
...rest
89+
} = props;
9390

9491
const groupedAttachments = useMemo(
95-
() => renderGroupedAttachments(props),
92+
() =>
93+
renderGroupedAttachments({
94+
attachmentActionsDefaultFocus,
95+
attachments,
96+
...rest,
97+
}),
9698
// eslint-disable-next-line react-hooks/exhaustive-deps
97-
[attachments],
99+
[attachments, attachmentActionsDefaultFocus],
98100
);
99101

100102
return (
@@ -111,87 +113,77 @@ const renderGroupedAttachments = ({
111113
attachments,
112114
...rest
113115
}: AttachmentProps): GroupedRenderedAttachment => {
114-
const uploadedImages: StreamAttachment[] = attachments.filter((attachment) =>
115-
isImageAttachment(attachment),
116+
const mediaAttachments: StreamAttachment[] = [];
117+
const containers = attachments.reduce<GroupedRenderedAttachment>(
118+
(typeMap, attachment) => {
119+
if (isSharedLocationResponse(attachment)) {
120+
typeMap.geolocation.push(
121+
<GeolocationContainer
122+
{...rest}
123+
key={`geolocation-${typeMap.geolocation.length}`}
124+
location={attachment}
125+
/>,
126+
);
127+
} else if (attachment.type === 'giphy') {
128+
typeMap.card.push(
129+
<GiphyContainer
130+
key={`giphy-${typeMap.giphy.length}`}
131+
{...rest}
132+
attachment={attachment}
133+
/>,
134+
);
135+
} else if (isScrapedContent(attachment)) {
136+
typeMap.card.push(
137+
<CardContainer
138+
key={`card-${typeMap.card.length}`}
139+
{...rest}
140+
attachment={attachment}
141+
/>,
142+
);
143+
} else if (
144+
isImageAttachment(attachment) ||
145+
isVideoAttachment(attachment, SUPPORTED_VIDEO_FORMATS)
146+
) {
147+
mediaAttachments.push(attachment);
148+
} else if (
149+
isAudioAttachment(attachment) ||
150+
isVoiceRecordingAttachment(attachment) ||
151+
isFileAttachment(attachment, SUPPORTED_VIDEO_FORMATS)
152+
) {
153+
typeMap.file.push(
154+
<FileContainer
155+
key={`file-${typeMap.file.length}`}
156+
{...rest}
157+
attachment={attachment}
158+
/>,
159+
);
160+
} else {
161+
typeMap.unsupported.push(
162+
<UnsupportedAttachmentContainer
163+
key={`unsupported-${typeMap.unsupported.length}`}
164+
{...rest}
165+
attachment={attachment}
166+
/>,
167+
);
168+
}
169+
170+
return typeMap;
171+
},
172+
{
173+
card: [],
174+
file: [],
175+
geolocation: [],
176+
giphy: [],
177+
media: [],
178+
unsupported: [],
179+
},
116180
);
117181

118-
const containers = attachments
119-
.filter((attachment) => !isImageAttachment(attachment))
120-
.reduce<GroupedRenderedAttachment>(
121-
(typeMap, attachment) => {
122-
if (isSharedLocationResponse(attachment)) {
123-
typeMap.geolocation.push(
124-
<GeolocationContainer
125-
{...rest}
126-
key='geolocation-container'
127-
location={attachment}
128-
/>,
129-
);
130-
} else {
131-
const attachmentType = getAttachmentType(attachment);
132-
133-
const Container = CONTAINER_MAP[attachmentType];
134-
typeMap[attachmentType].push(
135-
<Container
136-
key={`${attachmentType}-${typeMap[attachmentType].length}`}
137-
{...rest}
138-
attachment={attachment}
139-
/>,
140-
);
141-
}
142-
143-
return typeMap;
144-
},
145-
{
146-
audio: [],
147-
card: [],
148-
file: [],
149-
media: [],
150-
unsupported: [],
151-
// not used in reduce
152-
// eslint-disable-next-line sort-keys
153-
image: [],
154-
// eslint-disable-next-line sort-keys
155-
gallery: [],
156-
geolocation: [],
157-
voiceRecording: [],
158-
},
182+
if (mediaAttachments.length) {
183+
containers.media.push(
184+
<MediaContainer key='media-container' {...rest} attachments={mediaAttachments} />,
159185
);
160-
161-
if (uploadedImages.length > 1) {
162-
containers['gallery'] = [
163-
<GalleryContainer
164-
key='gallery-container'
165-
{...rest}
166-
attachment={{
167-
images: uploadedImages,
168-
type: 'gallery',
169-
}}
170-
/>,
171-
];
172-
} else if (uploadedImages.length === 1) {
173-
containers['image'] = [
174-
<ImageContainer key='image-container' {...rest} attachment={uploadedImages[0]} />,
175-
];
176186
}
177187

178188
return containers;
179189
};
180-
181-
export const getAttachmentType = (
182-
attachment: AttachmentProps['attachments'][number],
183-
): keyof typeof CONTAINER_MAP => {
184-
if (isScrapedContent(attachment)) {
185-
return 'card';
186-
} else if (isVideoAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) {
187-
return 'media';
188-
} else if (isAudioAttachment(attachment)) {
189-
return 'audio';
190-
} else if (isVoiceRecordingAttachment(attachment)) {
191-
return 'voiceRecording';
192-
} else if (isFileAttachment(attachment, SUPPORTED_VIDEO_FORMATS)) {
193-
return 'file';
194-
}
195-
196-
return 'unsupported';
197-
};

src/components/Attachment/AttachmentActions.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import React, { useMemo } from 'react';
1+
import React, { useEffect, useMemo, useRef } from 'react';
22
import type { Action, Attachment } from 'stream-chat';
33

44
import { useTranslationContext } from '../../context';
55

66
import type { ActionHandlerReturnType } from '../Message/hooks/useActionHandler';
7+
import { Button } from '../Button';
8+
import clsx from 'clsx';
79

810
export type AttachmentActionsProps = Attachment & {
911
/** A list of actions */
@@ -14,11 +16,22 @@ export type AttachmentActionsProps = Attachment & {
1416
text: string;
1517
/** Click event handler */
1618
actionHandler?: ActionHandlerReturnType;
19+
/** Which action should be focused on initial render (match by action.value) */
20+
defaultFocusedActionValue?: string;
21+
};
22+
23+
export type AttachmentActionsDefaultFocusByType = Partial<
24+
Record<NonNullable<Attachment['type']>, string>
25+
>;
26+
27+
export const defaultAttachmentActionsDefaultFocus: AttachmentActionsDefaultFocusByType = {
28+
giphy: 'send',
1729
};
1830

1931
const UnMemoizedAttachmentActions = (props: AttachmentActionsProps) => {
20-
const { actionHandler, actions, id, text } = props;
32+
const { actionHandler, actions, defaultFocusedActionValue, id, text } = props;
2133
const { t } = useTranslationContext('UnMemoizedAttachmentActions');
34+
const buttonRefs = useRef<Array<HTMLButtonElement | null>>([]);
2235

2336
const handleActionClick = (
2437
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
@@ -35,20 +48,43 @@ const UnMemoizedAttachmentActions = (props: AttachmentActionsProps) => {
3548
[t],
3649
);
3750

51+
const focusIndex = useMemo(() => {
52+
if (!defaultFocusedActionValue) return null;
53+
const index = actions.findIndex(
54+
(action) => action.value === defaultFocusedActionValue,
55+
);
56+
return index >= 0 ? index : null;
57+
}, [actions, defaultFocusedActionValue]);
58+
59+
useEffect(() => {
60+
if (focusIndex === null) return;
61+
const button = buttonRefs.current[focusIndex];
62+
if (button && document.activeElement !== button) {
63+
button.focus();
64+
}
65+
}, [focusIndex]);
66+
3867
return (
3968
<div className='str-chat__message-attachment-actions'>
4069
<div className='str-chat__message-attachment-actions-form'>
4170
<span>{text}</span>
42-
{actions.map((action) => (
43-
<button
44-
className={`str-chat__message-attachment-actions-button str-chat__message-attachment-actions-button--${action.style}`}
71+
{actions.map((action, index) => (
72+
<Button
73+
className={clsx(
74+
`str-chat__message-attachment-actions-button str-chat__message-attachment-actions-button--${action.style}`,
75+
'str-chat__button--ghost',
76+
'str-chat__button--secondary',
77+
)}
4578
data-testid={`${action.name}`}
4679
data-value={action.value}
4780
key={`${id}-${action.value}`}
4881
onClick={(event) => handleActionClick(event, action.name, action.value)}
82+
ref={(element) => {
83+
buttonRefs.current[index] = element;
84+
}}
4985
>
5086
{action.text ? (knownActionText[action.text] ?? t(action.text)) : null}
51-
</button>
87+
</Button>
5288
))}
5389
</div>
5490
</div>

0 commit comments

Comments
 (0)