Skip to content

Commit b9a8191

Browse files
committed
feat: add message content slots
1 parent 914d101 commit b9a8191

File tree

5 files changed

+196
-75
lines changed

5 files changed

+196
-75
lines changed

package/src/components/Channel/Channel.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,11 @@ export type ChannelPropsWithContext = Pick<ChannelContextValue, 'channel'> &
371371
| 'MessageBounce'
372372
| 'MessageBlocked'
373373
| 'MessageContent'
374+
| 'MessageContentBottomView'
375+
| 'MessageContentLeadingView'
374376
| 'messageContentOrder'
377+
| 'MessageContentTrailingView'
378+
| 'MessageContentTopView'
375379
| 'MessageDeleted'
376380
| 'MessageError'
377381
| 'MessageFooter'
@@ -690,6 +694,8 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
690694
MessageBlocked = MessageBlockedDefault,
691695
MessageBounce = MessageBounceDefault,
692696
MessageContent = MessageContentDefault,
697+
MessageContentBottomView,
698+
MessageContentLeadingView,
693699
messageContentOrder = [
694700
'quoted_reply',
695701
'gallery',
@@ -700,6 +706,8 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
700706
'text',
701707
'location',
702708
],
709+
MessageContentTrailingView,
710+
MessageContentTopView,
703711
MessageDeleted = MessageDeletedDefault,
704712
MessageError = MessageErrorDefault,
705713
messageInputFloating = false,
@@ -1974,7 +1982,11 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
19741982
MessageBlocked,
19751983
MessageBounce,
19761984
MessageContent,
1985+
MessageContentBottomView,
1986+
MessageContentLeadingView,
19771987
messageContentOrder,
1988+
MessageContentTrailingView,
1989+
MessageContentTopView,
19781990
MessageDeleted,
19791991
MessageError,
19801992
MessageFooter,

package/src/components/Channel/hooks/useCreateMessagesContext.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ export const useCreateMessagesContext = ({
5757
MessageBlocked,
5858
MessageBounce,
5959
MessageContent,
60+
MessageContentBottomView,
61+
MessageContentLeadingView,
6062
messageContentOrder,
63+
MessageContentTrailingView,
64+
MessageContentTopView,
6165
MessageDeleted,
6266
MessageError,
6367
MessageFooter,
@@ -182,7 +186,11 @@ export const useCreateMessagesContext = ({
182186
MessageBlocked,
183187
MessageBounce,
184188
MessageContent,
189+
MessageContentBottomView,
190+
MessageContentLeadingView,
185191
messageContentOrder,
192+
MessageContentTrailingView,
193+
MessageContentTopView,
186194
MessageDeleted,
187195
MessageError,
188196
MessageFooter,

package/src/components/Message/MessageItemView/MessageContent.tsx

Lines changed: 110 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ export type MessageContentPropsWithContext = Pick<
7878
| 'FileAttachmentGroup'
7979
| 'Gallery'
8080
| 'isAttachmentEqual'
81+
| 'MessageContentBottomView'
82+
| 'MessageContentLeadingView'
8183
| 'MessageLocation'
84+
| 'MessageContentTrailingView'
85+
| 'MessageContentTopView'
8286
| 'myMessageTheme'
8387
| 'Reply'
8488
| 'StreamingMessageView'
@@ -128,8 +132,12 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
128132
isVeryLastMessage,
129133
message,
130134
messageContentOrder,
135+
MessageContentBottomView,
136+
MessageContentLeadingView,
131137
messageGroupedSingleOrBottom = false,
132138
MessageLocation,
139+
MessageContentTrailingView,
140+
MessageContentTopView,
133141
noBorder,
134142
onLongPress,
135143
onPress,
@@ -227,6 +235,83 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
227235
};
228236

229237
const { setNativeScrollability } = useMessageListItemContext();
238+
const hasContentSideViews = !!(MessageContentLeadingView || MessageContentTrailingView);
239+
240+
const contentBody = (
241+
<>
242+
<View
243+
style={[
244+
{
245+
gap: primitives.spacingXs,
246+
paddingTop: hidePaddingTop ? 0 : primitives.spacingXs,
247+
paddingHorizontal: hidePaddingHorizontal ? 0 : primitives.spacingXs,
248+
paddingBottom: hidePaddingBottom ? 0 : primitives.spacingXs,
249+
},
250+
contentContainer,
251+
]}
252+
>
253+
{messageContentOrder.map((messageContentType, messageContentOrderIndex) => {
254+
switch (messageContentType) {
255+
case 'quoted_reply':
256+
return (
257+
message.quoted_message && (
258+
<View
259+
key={`quoted_reply_${messageContentOrderIndex}`}
260+
style={[styles.replyContainer, replyContainer]}
261+
>
262+
<Reply mode='reply' styles={replyStyles} />
263+
</View>
264+
)
265+
);
266+
case 'attachments':
267+
return otherAttachments.map((attachment, attachmentIndex) => (
268+
<Attachment attachment={attachment} key={`${message.id}-${attachmentIndex}`} />
269+
));
270+
case 'files':
271+
return (
272+
<FileAttachmentGroup key={`file_attachment_group_${messageContentOrderIndex}`} />
273+
);
274+
case 'gallery':
275+
return (
276+
<View key={`gallery_${messageContentOrderIndex}`} style={styles.galleryContainer}>
277+
<Gallery />
278+
</View>
279+
);
280+
case 'poll': {
281+
const pollId = message.poll_id;
282+
const poll = pollId && client.polls.fromState(pollId);
283+
return pollId && poll ? (
284+
<Poll
285+
key={`poll_${message.poll_id}`}
286+
message={message}
287+
poll={poll}
288+
PollContent={PollContentOverride}
289+
/>
290+
) : null;
291+
}
292+
case 'location':
293+
return MessageLocation ? (
294+
<MessageLocation
295+
key={`message_location_${messageContentOrderIndex}`}
296+
message={message}
297+
/>
298+
) : null;
299+
case 'ai_text':
300+
return isAIGenerated ? (
301+
<StreamingMessageView
302+
key={`ai_message_text_container_${messageContentOrderIndex}`}
303+
/>
304+
) : null;
305+
default:
306+
return null;
307+
}
308+
})}
309+
</View>
310+
{(otherAttachments.length && otherAttachments[0].actions) || isAIGenerated ? null : (
311+
<MessageTextContainer />
312+
)}
313+
</>
314+
);
230315

231316
return (
232317
<Pressable
@@ -284,82 +369,17 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
284369
]}
285370
testID='message-content-wrapper'
286371
>
287-
<View
288-
style={[
289-
{
290-
gap: primitives.spacingXs,
291-
paddingTop: hidePaddingTop ? 0 : primitives.spacingXs,
292-
paddingHorizontal: hidePaddingHorizontal ? 0 : primitives.spacingXs,
293-
paddingBottom: hidePaddingBottom ? 0 : primitives.spacingXs,
294-
},
295-
contentContainer,
296-
]}
297-
>
298-
{messageContentOrder.map((messageContentType, messageContentOrderIndex) => {
299-
switch (messageContentType) {
300-
case 'quoted_reply':
301-
return (
302-
message.quoted_message && (
303-
<View
304-
key={`quoted_reply_${messageContentOrderIndex}`}
305-
style={[styles.replyContainer, replyContainer]}
306-
>
307-
<Reply mode='reply' styles={replyStyles} />
308-
</View>
309-
)
310-
);
311-
case 'attachments':
312-
return otherAttachments.map((attachment, attachmentIndex) => (
313-
<Attachment attachment={attachment} key={`${message.id}-${attachmentIndex}`} />
314-
));
315-
case 'files':
316-
return (
317-
<FileAttachmentGroup
318-
key={`file_attachment_group_${messageContentOrderIndex}`}
319-
/>
320-
);
321-
case 'gallery':
322-
return (
323-
<View
324-
key={`gallery_${messageContentOrderIndex}`}
325-
style={styles.galleryContainer}
326-
>
327-
<Gallery />
328-
</View>
329-
);
330-
case 'poll': {
331-
const pollId = message.poll_id;
332-
const poll = pollId && client.polls.fromState(pollId);
333-
return pollId && poll ? (
334-
<Poll
335-
key={`poll_${message.poll_id}`}
336-
message={message}
337-
poll={poll}
338-
PollContent={PollContentOverride}
339-
/>
340-
) : null;
341-
}
342-
case 'location':
343-
return MessageLocation ? (
344-
<MessageLocation
345-
key={`message_location_${messageContentOrderIndex}`}
346-
message={message}
347-
/>
348-
) : null;
349-
case 'ai_text':
350-
return isAIGenerated ? (
351-
<StreamingMessageView
352-
key={`ai_message_text_container_${messageContentOrderIndex}`}
353-
/>
354-
) : null;
355-
default:
356-
return null;
357-
}
358-
})}
359-
</View>
360-
{(otherAttachments.length && otherAttachments[0].actions) || isAIGenerated ? null : (
361-
<MessageTextContainer />
372+
{MessageContentTopView ? <MessageContentTopView /> : null}
373+
{hasContentSideViews ? (
374+
<View style={styles.contentRow}>
375+
{MessageContentLeadingView ? <MessageContentLeadingView /> : null}
376+
<View style={styles.contentBody}>{contentBody}</View>
377+
{MessageContentTrailingView ? <MessageContentTrailingView /> : null}
378+
</View>
379+
) : (
380+
contentBody
362381
)}
382+
{MessageContentBottomView ? <MessageContentBottomView /> : null}
363383
</View>
364384
</View>
365385
</Pressable>
@@ -550,7 +570,11 @@ export const MessageContent = (props: MessageContentProps) => {
550570
FileAttachmentGroup,
551571
Gallery,
552572
isAttachmentEqual,
573+
MessageContentBottomView,
574+
MessageContentLeadingView,
553575
MessageLocation,
576+
MessageContentTrailingView,
577+
MessageContentTopView,
554578
myMessageTheme,
555579
Reply,
556580
StreamingMessageView,
@@ -604,7 +628,11 @@ export const MessageContent = (props: MessageContentProps) => {
604628
isMyMessage,
605629
message,
606630
messageContentOrder,
631+
MessageContentBottomView,
632+
MessageContentLeadingView,
607633
MessageLocation,
634+
MessageContentTrailingView,
635+
MessageContentTopView,
608636
myMessageTheme,
609637
onLongPress,
610638
onPress,
@@ -633,6 +661,13 @@ const styles = StyleSheet.create({
633661
borderTopRightRadius: components.messageBubbleRadiusGroupBottom,
634662
overflow: 'hidden',
635663
},
664+
contentBody: {
665+
flexShrink: 1,
666+
minWidth: 0,
667+
},
668+
contentRow: {
669+
flexDirection: 'row',
670+
},
636671
leftAlignContent: {
637672
justifyContent: 'flex-start',
638673
},

package/src/components/Message/MessageItemView/__tests__/MessageContent.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,56 @@ describe('MessageContent', () => {
149149
});
150150
});
151151

152+
it('renders MessageContentTopView and MessageContentBottomView when provided', async () => {
153+
const user = generateUser();
154+
const message = generateMessage({ user });
155+
156+
render(
157+
<ChannelsStateProvider>
158+
<Chat client={chatClient}>
159+
<Channel
160+
channel={channel}
161+
MessageContentBottomView={() => <View testID='message-content-bottom-view' />}
162+
MessageContentTopView={() => <View testID='message-content-top-view' />}
163+
>
164+
<Message groupStyles={['bottom']} message={message} />
165+
</Channel>
166+
</Chat>
167+
</ChannelsStateProvider>,
168+
);
169+
170+
await waitFor(() => {
171+
expect(screen.getByTestId('message-content-wrapper')).toBeTruthy();
172+
expect(screen.getByTestId('message-content-top-view')).toBeTruthy();
173+
expect(screen.getByTestId('message-content-bottom-view')).toBeTruthy();
174+
});
175+
});
176+
177+
it('renders MessageContentLeadingView and MessageContentTrailingView when provided', async () => {
178+
const user = generateUser();
179+
const message = generateMessage({ user });
180+
181+
render(
182+
<ChannelsStateProvider>
183+
<Chat client={chatClient}>
184+
<Channel
185+
channel={channel}
186+
MessageContentLeadingView={() => <View testID='message-content-leading-view' />}
187+
MessageContentTrailingView={() => <View testID='message-content-trailing-view' />}
188+
>
189+
<Message groupStyles={['bottom']} message={message} />
190+
</Channel>
191+
</Chat>
192+
</ChannelsStateProvider>,
193+
);
194+
195+
await waitFor(() => {
196+
expect(screen.getByTestId('message-content-wrapper')).toBeTruthy();
197+
expect(screen.getByTestId('message-content-leading-view')).toBeTruthy();
198+
expect(screen.getByTestId('message-content-trailing-view')).toBeTruthy();
199+
});
200+
});
201+
152202
it('renders a time component when MessageFooter does not exist', async () => {
153203
const user = generateUser();
154204
const message = generateMessage({ user });

package/src/contexts/messagesContext/MessagesContext.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,22 @@ export type MessagesContextValue = Pick<MessageContextValue, 'isMessageAIGenerat
241241
* Defaults to: [MessageContent](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/Message/MessageItemView/MessageContent.tsx)
242242
*/
243243
MessageContent: React.ComponentType<MessageContentProps>;
244+
/**
245+
* Optional UI component rendered above the message content body.
246+
*/
247+
MessageContentTopView?: React.ComponentType;
248+
/**
249+
* Optional UI component rendered to the left of the message content body.
250+
*/
251+
MessageContentLeadingView?: React.ComponentType;
252+
/**
253+
* Optional UI component rendered to the right of the message content body.
254+
*/
255+
MessageContentTrailingView?: React.ComponentType;
256+
/**
257+
* Optional UI component rendered below the message content body.
258+
*/
259+
MessageContentBottomView?: React.ComponentType;
244260
/** Order to render the message content */
245261
messageContentOrder: MessageContentType[];
246262
/**

0 commit comments

Comments
 (0)