Skip to content

Commit 7068222

Browse files
authored
chore: customizability of context menu (#3558)
## 🎯 Goal This PR introduces a `MessageOverlayWrapper` component so that integrations can pick which part of the `Message` they want to move to the contextual menu. The component handles all bookkeeping on its own and makes sure that a single measurement source (target) is active at a time. It comes paired with a `messageOverlayTargetId` for the simple purpose of performance (self registration would work as well here, however I did notice that sometimes 2 layout passes had to be done before registration could be finished, in order for the other portals to unmount - causing a nasty remount on the first render and so this guards against that). Usage: ``` import { MessageContent as DefaultMessageContent } from 'stream-chat-react-native'; // ... const OverlayTargetedMessageContent = ( props: React.ComponentProps<typeof DefaultMessageContent>, ) => ( <MessageOverlayWrapper targetId='message-content'> <DefaultMessageContent {...props} /> </MessageOverlayWrapper> ); // ... <WithComponents overrides={{ MessageContent: OverlayTargetedMessageContent }}> <Channel {...otherProps} messageOverlayTargetId='message-content' > {/* channel children */} </Channel> </WithComponents> ``` ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 4e97097 commit 7068222

File tree

16 files changed

+393
-102
lines changed

16 files changed

+393
-102
lines changed

examples/SampleApp/ios/Podfile.lock

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3327,7 +3327,7 @@ PODS:
33273327
- ReactCommon/turbomodule/core
33283328
- SocketRocket
33293329
- Yoga
3330-
- Teleport (0.5.4):
3330+
- Teleport (1.1.2):
33313331
- boost
33323332
- DoubleConversion
33333333
- fast_float
@@ -3354,9 +3354,9 @@ PODS:
33543354
- ReactCommon/turbomodule/bridging
33553355
- ReactCommon/turbomodule/core
33563356
- SocketRocket
3357-
- Teleport/common (= 0.5.4)
3357+
- Teleport/common (= 1.1.2)
33583358
- Yoga
3359-
- Teleport/common (0.5.4):
3359+
- Teleport/common (1.1.2):
33603360
- boost
33613361
- DoubleConversion
33623362
- fast_float
@@ -3827,7 +3827,7 @@ SPEC CHECKSUMS:
38273827
RNGestureHandler: 6bc8f2f56c8a68f3380cd159f3a1ae06defcfabb
38283828
RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168
38293829
RNReactNativeHapticFeedback: 5f1542065f0b24c9252bd8cf3e83bc9c548182e4
3830-
RNReanimated: 0e779d4d219b01331bf5ad620d30c5b993d18856
3830+
RNReanimated: a1e0ce339c1d8f9164b7499920d8787d6a7f7a23
38313831
RNScreens: e902eba58a27d3ad399a495d578e8aba3ea0f490
38323832
RNShare: c0f25f3d0ec275239c35cadbc98c94053118bee7
38333833
RNSVG: b1cb00d54dbc3066a3e98732e5418c8361335124
@@ -3836,7 +3836,7 @@ SPEC CHECKSUMS:
38363836
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
38373837
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
38383838
stream-chat-react-native: d15df89b47c1a08bc7db90c316d34b8ac4e13900
3839-
Teleport: c5c5d9ac843d3024fd5776a7fcba22d37080f86b
3839+
Teleport: ed828b19e62ca8b9ec101d991bf0594b1c1c8812
38403840
Yoga: ff16d80456ce825ffc9400eeccc645a0dfcccdf5
38413841

38423842
PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d

examples/SampleApp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"react-native-screens": "^4.24.0",
6767
"react-native-share": "^12.0.11",
6868
"react-native-svg": "^15.15.4",
69-
"react-native-teleport": "^0.5.4",
69+
"react-native-teleport": "^1.1.2",
7070
"react-native-video": "^6.16.1",
7171
"react-native-worklets": "^0.8.1",
7272
"stream-chat-react-native": "link:../../package/native-package",

examples/SampleApp/yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7674,10 +7674,10 @@ react-native-svg@^15.15.4:
76747674
css-tree "^1.1.3"
76757675
warn-once "0.1.1"
76767676

7677-
react-native-teleport@^0.5.4:
7678-
version "0.5.4"
7679-
resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.4.tgz#6af16e3984ee0fc002e5d6c6dae0ad40f22699c2"
7680-
integrity sha512-kiNbB27lJHAO7DdG7LlPi6DaFnYusVQV9rqp0q5WoRpnqKNckIvzXULox6QpZBstaTF/jkO+xmlkU+pnQqKSAQ==
7677+
react-native-teleport@^1.1.2:
7678+
version "1.1.2"
7679+
resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-1.1.2.tgz#23deea2a34f6b1bb378e0305d44deeb93d51d490"
7680+
integrity sha512-64dcEkxlVKzxIts2FAVhzI2tDExcD23T13c2yDC/E+1dA1vP9UlDwPYUEkHvnoTOFtMDGrKLH03RJahIWfQC1g==
76817681

76827682
react-native-url-polyfill@^2.0.0:
76837683
version "2.0.0"

package/src/components/Channel/Channel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export type ChannelPropsWithContext = Pick<ChannelContextValue, 'channel'> &
261261
| 'markdownRules'
262262
| 'messageActions'
263263
| 'messageContentOrder'
264+
| 'messageOverlayTargetId'
264265
| 'messageTextNumberOfLines'
265266
| 'messageSwipeToReplyHitSlop'
266267
| 'myMessageTheme'
@@ -464,6 +465,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
464465
'text',
465466
'location',
466467
],
468+
messageOverlayTargetId,
467469
messageInputFloating = false,
468470
messageId,
469471
messageSwipeToReplyHitSlop,
@@ -1665,6 +1667,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
16651667
markdownRules,
16661668
messageActions,
16671669
messageContentOrder,
1670+
messageOverlayTargetId,
16681671
messageSwipeToReplyHitSlop,
16691672
messageTextNumberOfLines,
16701673
myMessageTheme,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const useCreateMessagesContext = ({
3737
markdownRules,
3838
messageActions,
3939
messageContentOrder,
40+
messageOverlayTargetId,
4041
messageSwipeToReplyHitSlop,
4142
messageTextNumberOfLines,
4243
myMessageTheme,
@@ -100,6 +101,7 @@ export const useCreateMessagesContext = ({
100101
markdownRules,
101102
messageActions,
102103
messageContentOrder,
104+
messageOverlayTargetId,
103105
messageSwipeToReplyHitSlop,
104106
messageTextNumberOfLines,
105107
myMessageTheme,
@@ -127,6 +129,7 @@ export const useCreateMessagesContext = ({
127129
initialScrollToFirstUnreadMessage,
128130
markdownRulesLength,
129131
messageContentOrderValue,
132+
messageOverlayTargetId,
130133
supportedReactionsLength,
131134
myMessageTheme,
132135
targetedMessage,

package/src/components/Message/Message.tsx

Lines changed: 109 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { useMessageActions } from './hooks/useMessageActions';
1919
import { useMessageDeliveredData } from './hooks/useMessageDeliveryData';
2020
import { useMessageReadData } from './hooks/useMessageReadData';
2121
import { useProcessReactions } from './hooks/useProcessReactions';
22+
import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from './messageOverlayConstants';
23+
import { MessageOverlayWrapper } from './MessageOverlayWrapper';
2224
import { measureInWindow } from './utils/measureInWindow';
2325
import { messageActions as defaultMessageActions } from './utils/messageActions';
2426

@@ -36,7 +38,11 @@ import {
3638
MessageComposerAPIContextValue,
3739
useMessageComposerAPIContext,
3840
} from '../../contexts/messageComposerContext/MessageComposerAPIContext';
39-
import { MessageContextValue, MessageProvider } from '../../contexts/messageContext/MessageContext';
41+
import {
42+
MessageContextValue,
43+
MessageOverlayRuntimeProvider,
44+
MessageProvider,
45+
} from '../../contexts/messageContext/MessageContext';
4046
import { useMessageListItemContext } from '../../contexts/messageListItemContext/MessageListItemContext';
4147
import {
4248
MessagesContextValue,
@@ -207,6 +213,7 @@ export type MessagePropsWithContext = Pick<
207213
| 'handleBlockUser'
208214
| 'isAttachmentEqual'
209215
| 'messageActions'
216+
| 'messageOverlayTargetId'
210217
| 'messageContentOrder'
211218
| 'onLongPressMessage'
212219
| 'onPressInMessage'
@@ -278,6 +285,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
278285
members,
279286
message,
280287
messageActions: messageActionsProp = defaultMessageActions,
288+
messageOverlayTargetId = DEFAULT_MESSAGE_OVERLAY_TARGET_ID,
281289
messageContentOrder: messageContentOrderProp,
282290
messagesContext,
283291
onLongPressMessage: onLongPressMessageProp,
@@ -323,11 +331,28 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
323331
const rectRef = useRef<Rect>(undefined);
324332
const bubbleRect = useRef<Rect>(undefined);
325333
const contextMenuAnchorRef = useRef<View>(null);
334+
const messageOverlayTargetsRef = useRef<Record<string, View | null>>({});
335+
const registerMessageOverlayTarget = useStableCallback(
336+
({ id, view }: { id: string; view: View | null }) => {
337+
messageOverlayTargetsRef.current[id] = view;
338+
},
339+
);
340+
const unregisterMessageOverlayTarget = useStableCallback((id: string) => {
341+
delete messageOverlayTargetsRef.current[id];
342+
});
326343

327344
const showMessageOverlay = useStableCallback(async () => {
328345
dismissKeyboard();
329346
try {
330-
const layout = await measureInWindow(messageWrapperRef, insets);
347+
const activeTargetView = messageOverlayTargetsRef.current[messageOverlayTargetId];
348+
349+
if (!activeTargetView) {
350+
throw new Error(
351+
`No message overlay target is registered for target id "${messageOverlayTargetId}".`,
352+
);
353+
}
354+
355+
const layout = await measureInWindow({ current: activeTargetView }, insets);
331356
const bubbleLayout = await measureInWindow(contextMenuAnchorRef, insets).catch(() => layout);
332357

333358
rectRef.current = layout;
@@ -655,8 +680,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
655680
unpinMessage: handleTogglePinMessage,
656681
};
657682

658-
const messageWrapperRef = useRef<View>(null);
659-
660683
const onLongPress = () => {
661684
setNativeScrollability(false);
662685
if (hasAttachmentActions || isBlockedMessage(message) || !enableLongPress) {
@@ -771,6 +794,8 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
771794
onThreadSelect,
772795
otherAttachments: attachments.other,
773796
preventPress: overlayActive ? true : preventPress,
797+
registerMessageOverlayTarget,
798+
unregisterMessageOverlayTarget,
774799
reactions,
775800
readBy,
776801
setQuotedMessage,
@@ -781,6 +806,14 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
781806
threadList,
782807
videos: attachments.videos,
783808
});
809+
const messageOverlayRuntimeContext = useMemo(
810+
() => ({
811+
overlayTargetRectRef: rectRef,
812+
messageOverlayTargetId,
813+
overlayActive,
814+
}),
815+
[messageOverlayTargetId, overlayActive],
816+
);
784817

785818
const prevActive = useRef<boolean>(overlayActive);
786819

@@ -817,82 +850,74 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
817850

818851
return (
819852
<MessageProvider value={messageContext}>
820-
<View style={[style, styles.wrapper]} testID='message-wrapper'>
821-
{overlayActive && rect ? (
822-
<View
823-
style={{
824-
height: rect.h,
825-
width: rect.w,
826-
}}
827-
/>
828-
) : null}
829-
{/*TODO: V9: Find a way to separate these in a dedicated file*/}
830-
<Portal hostName={overlayActive && rect ? 'top-item' : undefined}>
831-
{overlayActive && rect && overlayItemsAnchorRect ? (
832-
<View
833-
onLayout={(e) => {
834-
const { width: w, height: h } = e.nativeEvent.layout;
835-
836-
setOverlayTopH({
837-
h,
838-
w,
839-
x:
840-
overlayItemAlignment === 'right'
841-
? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w
842-
: overlayItemsAnchorRect.x,
843-
y: rect.y - h,
844-
});
845-
}}
846-
>
847-
<MessageReactionPicker
848-
dismissOverlay={dismissOverlay}
849-
handleReaction={ownCapabilities.sendReaction ? handleReaction : undefined}
850-
/>
851-
</View>
852-
) : null}
853-
</Portal>
854-
<Portal
855-
hostName={overlayActive ? 'message-overlay' : undefined}
856-
style={overlayActive && rect ? { width: rect.w } : undefined}
857-
>
858-
<View ref={messageWrapperRef}>
853+
<MessageOverlayRuntimeProvider value={messageOverlayRuntimeContext}>
854+
<View style={[style, styles.wrapper]} testID='message-wrapper'>
855+
{/*TODO: V9: Find a way to separate these in a dedicated file*/}
856+
<Portal hostName={overlayActive && rect ? 'top-item' : undefined}>
857+
{overlayActive && rect && overlayItemsAnchorRect ? (
858+
<View
859+
onLayout={(e) => {
860+
const { width: w, height: h } = e.nativeEvent.layout;
861+
862+
setOverlayTopH({
863+
h,
864+
w,
865+
x:
866+
overlayItemAlignment === 'right'
867+
? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w
868+
: overlayItemsAnchorRect.x,
869+
y: rect.y - h,
870+
});
871+
}}
872+
>
873+
<MessageReactionPicker
874+
dismissOverlay={dismissOverlay}
875+
handleReaction={ownCapabilities.sendReaction ? handleReaction : undefined}
876+
/>
877+
</View>
878+
) : null}
879+
</Portal>
880+
<MessageOverlayWrapper targetId={DEFAULT_MESSAGE_OVERLAY_TARGET_ID}>
859881
<MessageItemView />
860-
</View>
861-
</Portal>
862-
{showMessageReactions ? (
863-
<BottomSheetModal
864-
lazy={true}
865-
onClose={() => setShowMessageReactions(false)}
866-
visible={showMessageReactions}
867-
height={424}
868-
>
869-
<MessageUserReactions message={message} selectedReaction={selectedReaction} />
870-
</BottomSheetModal>
871-
) : null}
872-
<Portal hostName={overlayActive && rect ? 'bottom-item' : undefined}>
873-
{overlayActive && rect && overlayItemsAnchorRect ? (
874-
<View
875-
onLayout={(e) => {
876-
const { width: w, height: h } = e.nativeEvent.layout;
877-
setOverlayBottomH({
878-
h,
879-
w,
880-
x:
881-
overlayItemAlignment === 'right'
882-
? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w
883-
: overlayItemsAnchorRect.x,
884-
y: rect.y + rect.h,
885-
});
886-
}}
882+
</MessageOverlayWrapper>
883+
{showMessageReactions ? (
884+
<BottomSheetModal
885+
lazy={true}
886+
onClose={() => setShowMessageReactions(false)}
887+
visible={showMessageReactions}
888+
height={424}
887889
>
888-
<MessageActionList dismissOverlay={dismissOverlay} messageActions={messageActions} />
889-
</View>
890+
<MessageUserReactions message={message} selectedReaction={selectedReaction} />
891+
</BottomSheetModal>
890892
) : null}
891-
</Portal>
892-
{isBounceDialogOpen ? (
893-
<MessageBounce setIsBounceDialogOpen={setIsBounceDialogOpen} />
894-
) : null}
895-
</View>
893+
<Portal hostName={overlayActive && rect ? 'bottom-item' : undefined}>
894+
{overlayActive && rect && overlayItemsAnchorRect ? (
895+
<View
896+
onLayout={(e) => {
897+
const { width: w, height: h } = e.nativeEvent.layout;
898+
setOverlayBottomH({
899+
h,
900+
w,
901+
x:
902+
overlayItemAlignment === 'right'
903+
? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w
904+
: overlayItemsAnchorRect.x,
905+
y: rect.y + rect.h,
906+
});
907+
}}
908+
>
909+
<MessageActionList
910+
dismissOverlay={dismissOverlay}
911+
messageActions={messageActions}
912+
/>
913+
</View>
914+
) : null}
915+
</Portal>
916+
{isBounceDialogOpen ? (
917+
<MessageBounce setIsBounceDialogOpen={setIsBounceDialogOpen} />
918+
) : null}
919+
</View>
920+
</MessageOverlayRuntimeProvider>
896921
</MessageProvider>
897922
);
898923
};
@@ -905,6 +930,7 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit
905930
groupStyles: prevGroupStyles,
906931
isAttachmentEqual,
907932
isTargetedMessage: prevIsTargetedMessage,
933+
messageOverlayTargetId: prevMessageOverlayTargetId,
908934
members: prevMembers,
909935
message: prevMessage,
910936
messagesContext: prevMessagesContext,
@@ -918,6 +944,7 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit
918944
goToMessage: nextGoToMessage,
919945
groupStyles: nextGroupStyles,
920946
isTargetedMessage: nextIsTargetedMessage,
947+
messageOverlayTargetId: nextMessageOverlayTargetId,
921948
members: nextMembers,
922949
message: nextMessage,
923950
messagesContext: nextMessagesContext,
@@ -958,6 +985,11 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit
958985
return false;
959986
}
960987

988+
const messageOverlayTargetIdEqual = prevMessageOverlayTargetId === nextMessageOverlayTargetId;
989+
if (!messageOverlayTargetIdEqual) {
990+
return false;
991+
}
992+
961993
const messageEqual = checkMessageEquality(prevMessage, nextMessage);
962994

963995
if (!messageEqual) {

0 commit comments

Comments
 (0)