From 1727b182889db3be9227b1f7fb69bbccbab53f4b Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Thu, 9 May 2024 17:26:41 +0900 Subject: [PATCH 01/46] Implement GroupChannel thread. --- .../src/assets/icon/icon-thread.png | Bin 0 -> 458 bytes .../src/assets/icon/icon-thread@2x.png | Bin 0 -> 756 bytes .../src/assets/icon/icon-thread@3x.png | Bin 0 -> 1090 bytes .../src/assets/icon/index.ts | 1 + .../GroupChannelMessage/MessageContainer.tsx | 63 +- .../src/ui/GroupChannelMessage/index.ts | 1 + .../src/components/ChannelInput/SendInput.tsx | 15 +- .../src/components/ChannelInput/index.tsx | 1 + .../components/ChannelMessageList/index.tsx | 34 +- .../GroupChannelMessageParentMessage.tsx | 6 +- .../GroupChannelMessageReplyInfo.tsx | 95 ++ .../GroupChannelMessageRenderer/index.tsx | 19 +- .../ReactionAddons/MessageReactionAddon.tsx | 23 +- .../ThreadParentMessage.file.image.tsx | 35 + .../ThreadParentMessage.file.tsx | 57 ++ .../ThreadParentMessage.file.video.tsx | 44 + .../ThreadParentMessage.file.voice.tsx | 104 +++ .../ThreadParentMessage.user.og.tsx | 120 +++ .../ThreadParentMessage.user.tsx | 65 ++ .../ThreadParentMessageRenderer/index.tsx | 179 ++++ .../src/containers/SendbirdUIKitContainer.tsx | 5 +- .../component/GroupChannelMessageList.tsx | 20 +- .../groupChannel/module/moduleContext.tsx | 14 +- .../src/domain/groupChannel/types.ts | 6 +- .../component/GroupChannelThreadHeader.tsx | 53 ++ .../component/GroupChannelThreadInput.tsx | 36 + .../GroupChannelThreadMessageList.tsx | 82 ++ .../GroupChannelThreadParentMessageInfo.tsx | 298 +++++++ .../GroupChannelThreadStatusEmpty.tsx | 18 + .../GroupChannelThreadStatusLoading.tsx | 18 + ...GroupChannelThreadSuggestedMentionList.tsx | 174 ++++ .../src/domain/groupChannelThread/index.ts | 8 + .../module/createGroupChannelThreadModule.tsx | 35 + .../module/moduleContext.tsx | 161 ++++ .../src/domain/groupChannelThread/types.ts | 202 +++++ .../fragments/createGroupChannelFragment.tsx | 10 +- .../createGroupChannelThreadFragment.tsx | 237 +++++ .../useChannelThreadMessagesReducer.ts | 247 ++++++ .../useGroupChannelThreadMessages.ts | 839 ++++++++++++++++++ packages/uikit-react-native/src/index.ts | 1 + .../src/localization/StringSet.type.ts | 24 + .../src/localization/createBaseStringSet.ts | 21 + packages/uikit-utils/src/ui-format/common.ts | 15 + sample/src/App.tsx | 8 +- sample/src/libs/navigation.ts | 9 + .../uikit/groupChannel/GroupChannelScreen.tsx | 11 + .../groupChannel/GroupChannelThreadScreen.tsx | 47 + .../src/screens/uikit/groupChannel/index.ts | 1 + 48 files changed, 3392 insertions(+), 70 deletions(-) create mode 100644 packages/uikit-react-native-foundation/src/assets/icon/icon-thread.png create mode 100644 packages/uikit-react-native-foundation/src/assets/icon/icon-thread@2x.png create mode 100644 packages/uikit-react-native-foundation/src/assets/icon/icon-thread@3x.png create mode 100644 packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx create mode 100644 packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.image.tsx create mode 100644 packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.tsx create mode 100644 packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.video.tsx create mode 100644 packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx create mode 100644 packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx create mode 100644 packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.tsx create mode 100644 packages/uikit-react-native/src/components/ThreadParentMessageRenderer/index.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadStatusEmpty.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadStatusLoading.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadSuggestedMentionList.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/index.ts create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/module/createGroupChannelThreadModule.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx create mode 100644 packages/uikit-react-native/src/domain/groupChannelThread/types.ts create mode 100644 packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx create mode 100644 packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useChannelThreadMessagesReducer.ts create mode 100644 packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useGroupChannelThreadMessages.ts create mode 100644 sample/src/screens/uikit/groupChannel/GroupChannelThreadScreen.tsx diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-thread.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-thread.png new file mode 100644 index 0000000000000000000000000000000000000000..09e62c8026457392c14175336b199c09c57fab3d GIT binary patch literal 458 zcmV;*0X6=KP)dx9Vg%b0s3%ZQP`yE*^hqT-LJpAKamS_+ zr1>C?sNnA*SxEN(o6UX%;ID)0#;6nYkhyPLIJoeg5Nd)_6RSP|ca>6Is1bs1=3nsh z0vjrF;s!1%-6M-;dCU59nF2;h!-X;^eWn41O6mEj8#D_AjPU$%XWE4_XquR7!~o4a zNh*kmZl!~4wsGfeSP&21((zH#J|cv9l2kDDoydHi$!a)oT~=aIz68H^8ns`ihPk<+CVO z>PEt4iP}Vao5F?4XORh?nd&05502~n#r{6t0a^u~#&$#6#sB~S07*qoM6N<$f-{N1 AegFUf literal 0 HcmV?d00001 diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-thread@2x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-thread@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..52cdd4ac2dfc27d49f1a2bc935d163e2efd3106d GIT binary patch literal 756 zcmVaS@#aPqb%tLaNU%t+eKTPIc1w)WWLUfWMas~Wh+v*$?!%Mb17jvV}_XG6AV_u0Rl!%?G z(V=X};dQe?K+kks!U53>R}&&N=bb~ygszdkN6`~R^!aK1h=mobnNY`mY7FhX$MOGK zHkPS2C<&aJ2b?#9q&>^|bvu-ReVx}hKT>8mFY}yVUk47!a^95lO*j)^!+DL95ZdNE zyFc5-`E?ICF#YyTmjDd~8eRP=>UQJccYqM<2J2qVYYr};rq9H&Hy`^uuW_=8*Iz>U mMS&pxjJqiJXWWX4vhx#)>mQhODJmWS0000DQQbj&~O5dkn{vZKsJSH(i>y} zQWrCJ{RXI_g6)|QPvXR%gfxncV}1UAo&Y!;4u`|B(A0x4s0HB;EFnadh+-gSXB?pT zka_5NwEU@G8p4u5m8oNL5PXhf)`cZPDySA*?~KcN1V5_=VG))D3KQT*PsJ@N6A4-- zoFENOkOn76gA=5|3DV#MX>fuxI6)emAPr8C1}8{^6Qscj(%=MXaDp^AK^lCpjM{ei zkpQcR#}VPsd+pI?qhA_%D2~t6SL>HE&F+z%vGZ92ZSsF^h>nIbFq=HVwxW=Qo-&dtS-q}rCl5P%?REh^C$Nh6)gNdu%LscFVl9Yltfgh5j!YxZyl3ZzDte%K@ zsHw^`ZAnT&5v3hjnpk2F^6o`Q`)a+BrzEAIq+0{;fus_m7gb>f*fE1xe)r;vh7f~sO!f5q529u_r)4n;MZ z*s_o_X(i!^NYjv+1R*$#VsSXsNqQ)JC^nnl2&KJNzm$1`oD`G<9y`<4klByE(&1l7=C0WpI;(!_M zvfGzy6T_m<21T?4jbaIe|xj4jLI&C?~LI_GWu5k)ET$~d#$U&?h zV*liTW+6a1SE~)kh}D8j%R#Ial!qL|N { MessageContainer.Incoming = function MessageContainerIncoming({ children, + replyInfo, groupedWithNext, groupedWithPrev, message, @@ -34,43 +35,48 @@ MessageContainer.Incoming = function MessageContainerIncoming({ const color = colors.ui.groupChannelMessage.incoming; return ( - - - {(message.isFileMessage() || message.isUserMessage()) && !groupedWithNext && ( - - - - )} - - - {parentMessage} - {!groupedWithPrev && !message.parentMessage && ( - - {(message.isFileMessage() || message.isUserMessage()) && ( - - {strings?.senderName ?? message.sender.nickname} - - )} - - )} - - - {children} - {!groupedWithNext && ( - - - {strings?.sentDate ?? getMessageTimeFormat(new Date(message.createdAt))} - + + + + {(message.isFileMessage() || message.isUserMessage()) && !groupedWithNext && ( + + + + )} + + + {parentMessage} + {!groupedWithPrev && !message.parentMessage && ( + + {(message.isFileMessage() || message.isUserMessage()) && ( + + {strings?.senderName ?? message.sender.nickname} + + )} )} + + {children} + {!groupedWithNext && ( + + + {strings?.sentDate ?? getMessageTimeFormat(new Date(message.createdAt))} + + + )} + + + {replyInfo} + ); }; MessageContainer.Outgoing = function MessageContainerOutgoing({ children, + replyInfo, message, groupedWithNext, strings, @@ -96,6 +102,9 @@ MessageContainer.Outgoing = function MessageContainerOutgoing({ {children} + + {replyInfo} + ); }; diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/index.ts b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/index.ts index bc46a214e..52f9fe016 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/index.ts +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/index.ts @@ -28,6 +28,7 @@ export type GroupChannelMessageProps(function SendInput( channel, messageToReply, setMessageToReply, + messageToThread, }, ref, ) { @@ -68,16 +69,21 @@ const SendInput = forwardRef(function SendInput( visible: voiceMessageInputVisible, setVisible: setVoiceMessageInputVisible, } = useDeferredModalState(); - + const messageReplyParams = useIIFE(() => { const { groupChannel } = sbOptions.uikit; - if (!channel.isGroupChannel() || groupChannel.channel.replyType === 'none' || !messageToReply) return {}; + if (!channel.isGroupChannel() || groupChannel.channel.replyType === 'none' + || (groupChannel.channel.replyType === 'quote_reply' && !messageToReply) + || (groupChannel.channel.replyType === 'thread' && !messageToThread)) { + return {}; + } + return { - parentMessageId: messageToReply.messageId, + parentMessageId: messageToReply?.messageId ?? messageToThread?.messageId, isReplyToChannel: true, }; }); - + const messageMentionParams = useIIFE(() => { const { groupChannel } = sbOptions.uikit; if (!channel.isGroupChannel() || !groupChannel.channel.enableMention) return {}; @@ -152,6 +158,7 @@ const SendInput = forwardRef(function SendInput( if (inputFrozen) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_DISABLED; if (inputDisabled) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_DISABLED; if (messageToReply) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_REPLY; + if (messageToThread) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_THREAD; return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_ACTIVE; }; diff --git a/packages/uikit-react-native/src/components/ChannelInput/index.tsx b/packages/uikit-react-native/src/components/ChannelInput/index.tsx index a044efb63..0d110a192 100644 --- a/packages/uikit-react-native/src/components/ChannelInput/index.tsx +++ b/packages/uikit-react-native/src/components/ChannelInput/index.tsx @@ -65,6 +65,7 @@ export type ChannelInputProps = { // reply - only available on group channel messageToReply?: undefined | SendbirdUserMessage | SendbirdFileMessage; setMessageToReply?: (message?: undefined | SendbirdUserMessage | SendbirdFileMessage) => void; + messageToThread?: undefined | SendbirdUserMessage | SendbirdFileMessage; // mention SuggestedMentionList?: CommonComponent; diff --git a/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx b/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx index c24e6219c..4c970b9b6 100644 --- a/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx +++ b/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx @@ -57,9 +57,10 @@ export type ChannelMessageListProps void; onReplyMessage?: (message: HandleableMessage) => void; // only available on group channel + onReplyInThreadMessage?: (message: HandleableMessage) => void; // only available on group channel onDeleteMessage: (message: HandleableMessage) => Promise; onResendFailedMessage: (failedMessage: HandleableMessage) => Promise; - onPressParentMessage?: (parentMessage: SendbirdMessage) => void; + onPressParentMessage?: (parentMessage: SendbirdMessage, childMessage: HandleableMessage) => void; onPressMediaMessage?: (message: SendbirdFileMessage, deleteMessage: () => Promise, uri: string) => void; renderMessage: (props: { @@ -70,12 +71,14 @@ export type ChannelMessageListProps void; onLongPress?: () => void; onPressParentMessage?: ChannelMessageListProps['onPressParentMessage']; + onReplyInThreadMessage?: ChannelMessageListProps['onReplyInThreadMessage']; onShowUserProfile?: UserProfileContextType['show']; channel: T; currentUserId?: ChannelMessageListProps['currentUserId']; enableMessageGrouping: ChannelMessageListProps['enableMessageGrouping']; bottomSheetItem?: BottomSheetItem; isFirstItem: boolean; + hideParentMessage?: boolean; }) => React.ReactElement | null; renderNewMessagesButton: | null @@ -93,6 +96,7 @@ const ChannelMessageList = onReplyMessage?.(message), }), + replyInThread: (message: HandleableMessage) => ({ + disabled: Boolean(message.parentMessageId), + icon: 'thread' as const, + title: STRINGS.LABELS.CHANNEL_MESSAGE_THREAD, + onPress: () => onReplyInThreadMessage?.(message), + }), download: (message: HandleableMessage) => ({ icon: 'download' as const, title: STRINGS.LABELS.CHANNEL_MESSAGE_SAVE, @@ -336,8 +350,12 @@ const useCreateMessagePressActions = void; + onPress?: (parentMessage: SendbirdMessage, childMessage: SendbirdUserMessage | SendbirdFileMessage) => void; }; const GroupChannelMessageParentMessage = ({ variant, channel, message, childMessage, onPress }: Props) => { @@ -135,7 +135,7 @@ const GroupChannelMessageParentMessage = ({ variant, channel, message, childMess paddingLeft={variant === 'outgoing' ? 0 : 12} paddingRight={variant === 'outgoing' ? 12 : 0} > - onPress?.(parentMessage)} style={styles.senderLabel}> + onPress?.(parentMessage, childMessage)} style={styles.senderLabel}> {STRINGS.LABELS.REPLY_FROM_SENDER_TO_RECEIVER(childMessage, parentMessage, currentUser?.userId)} @@ -147,7 +147,7 @@ const GroupChannelMessageParentMessage = ({ variant, channel, message, childMess justifyContent={variant === 'outgoing' ? 'flex-end' : 'flex-start'} style={styles.messageContainer} > - onPress?.(parentMessage)}>{parentMessageComponent} + onPress?.(parentMessage, childMessage)}>{parentMessageComponent} ); diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx new file mode 100644 index 000000000..0c51d666b --- /dev/null +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { User } from '@sendbird/chat'; +import { Avatar, Box, createStyleSheet, Icon, PressBox, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { SendbirdFileMessage, SendbirdGroupChannel, SendbirdMessage, SendbirdUserMessage } from '@sendbird/uikit-utils'; + +import { useLocalization } from '../../hooks/useContext'; + +const AVATAR_LIMIT = 5; + +type Props = { + channel: SendbirdGroupChannel; + message: SendbirdMessage; + onPress?: (message: SendbirdUserMessage | SendbirdFileMessage) => void; +}; + +const createRepliedUserAvatars = (mostRepliedUsers: User[]) => { + if (!mostRepliedUsers || mostRepliedUsers.length === 0) return null; + + const { palette } = useUIKitTheme(); + + return mostRepliedUsers.slice(0, AVATAR_LIMIT).map((user, index) => { + if (index < AVATAR_LIMIT - 1) { + return + + ; + } else { + return + + + + + ; + } + }); +}; + +const GroupChannelMessageReplyInfo = ({ channel, message, onPress }: Props) => { + const { STRINGS } = useLocalization(); + const { select, palette } = useUIKitTheme(); + + if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; + + const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_POSTFIX(message.threadInfo.replyCount || 0); + const onPressReply = () => { + onPress?.(message as SendbirdUserMessage | SendbirdFileMessage); + }; + + const renderAvatars = createRepliedUserAvatars(message.threadInfo.mostRepliedUsers); + + return + {renderAvatars} + + {replyCountText} + + ; +}; + +const styles = createStyleSheet({ + container: { + flexDirection: 'row', + }, + messageContainer: { + flexDirection: 'row', + alignItems: 'flex-end', + }, + message: { + marginHorizontal: 4, + }, + avatarContainer: { + marginRight: 4, + width: 20, + height: 20, + }, + avatar: { + width: '100%', + height: '100%', + }, + avatarOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 10, + }, + plusIcon: { + position: 'absolute', + top: 3, + left: 3, + right: 0, + bottom: 0, + }, +}); + +export default React.memo(GroupChannelMessageReplyInfo); diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index 854f158e5..580593af2 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -33,6 +33,7 @@ import GroupChannelMessageDateSeparator from './GroupChannelMessageDateSeparator import GroupChannelMessageFocusAnimation from './GroupChannelMessageFocusAnimation'; import GroupChannelMessageOutgoingStatus from './GroupChannelMessageOutgoingStatus'; import GroupChannelMessageParentMessage from './GroupChannelMessageParentMessage'; +import GroupChannelMessageReplyInfo from './GroupChannelMessageReplyInfo'; const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage'] = ({ channel, @@ -41,10 +42,12 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' onLongPress, onPressParentMessage, onShowUserProfile, + onReplyInThreadMessage, enableMessageGrouping, focused, prevMessage, nextMessage, + hideParentMessage, }) => { const playerUnsubscribes = useRef<(() => void)[]>([]); const { palette } = useUIKitTheme(); @@ -57,7 +60,8 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' prevMessage, nextMessage, ); - + const variant = isMyMessage(message, currentUser?.userId) ? 'outgoing' : 'incoming'; + const reactionChildren = useIIFE(() => { const configs = sbOptions.uikitWithAppInfo.groupChannel.channel; if ( @@ -69,7 +73,13 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' } return null; }); - + + const renderReplyInfo = useIIFE(() => { + if (sbOptions.uikit.groupChannel.channel.replyType !== 'thread') return null; + if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; + return ; + }); + const resetPlayer = async () => { playerUnsubscribes.current.forEach((unsubscribe) => { try { @@ -80,8 +90,6 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' await playerService.reset(); }; - const variant = isMyMessage(message, currentUser?.userId) ? 'outgoing' : 'incoming'; - const messageProps: Omit, 'message'> = { channel, variant, @@ -146,10 +154,11 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' groupedWithPrev: groupWithPrev, groupedWithNext: groupWithNext, children: reactionChildren, + replyInfo: renderReplyInfo, sendingStatus: isMyMessage(message, currentUser?.userId) ? ( ) : null, - parentMessage: shouldRenderParentMessage(message) ? ( + parentMessage: (!hideParentMessage && shouldRenderParentMessage(message)) ? ( { return reaction.userIds.indexOf(userId) > -1; @@ -74,13 +75,13 @@ const createReactionButtons = ( return buttons; }; -const MessageReactionAddon = ({ channel, message }: { channel: SendbirdBaseChannel; message: SendbirdBaseMessage }) => { +const MessageReactionAddon = ({ channel, message, reactionAddonType = 'default'}: { channel: SendbirdBaseChannel; message: SendbirdBaseMessage, reactionAddonType?: ReactionAddonType }) => { const { colors } = useUIKitTheme(); const { emojiManager, currentUser } = useSendbirdChat(); const { openReactionList, openReactionUserList } = useReaction(); - - if (!message.reactions?.length) return null; - + + if (reactionAddonType === 'default' && !message.reactions?.length) return null; + const reactionButtons = createReactionButtons( channel, message, @@ -90,11 +91,13 @@ const MessageReactionAddon = ({ channel, message }: { channel: SendbirdBaseChann (focusIndex) => openReactionUserList({ channel, message, focusIndex }), currentUser?.userId, ); - + + const containerStyle = reactionAddonType === 'default' ? styles.reactionContainer : styles.reactionThreadParentMessageContainer; + return ( @@ -112,6 +115,13 @@ const styles = createStyleSheet({ borderRadius: 16, borderWidth: 1, }, + reactionThreadParentMessageContainer: { + alignItems: 'stretch', + flexDirection: 'row', + flexWrap: 'wrap', + padding: 8, + borderRadius: 16, + }, marginRight: { marginRight: 4.5, }, @@ -121,3 +131,4 @@ const styles = createStyleSheet({ }); export default MessageReactionAddon; +export { ReactionAddonType }; diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.image.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.image.tsx new file mode 100644 index 000000000..8fee9eecb --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.image.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { getThumbnailUriFromFileMessage, SendbirdFileMessage } from '@sendbird/uikit-utils'; +import { Box, createStyleSheet, PressBox } from '@sendbird/uikit-react-native-foundation'; + +import { ThreadParentMessageRendererProps } from './index'; +import { ImageWithPlaceholder } from '@sendbird/uikit-react-native-foundation'; + +const ThreadParentMessageFileImage = (props: ThreadParentMessageRendererProps) => { + const fileMessage: SendbirdFileMessage = props.parentMessage as SendbirdFileMessage; + if (!fileMessage) return null; + + return ( + + + + + + ); +}; + +const styles = createStyleSheet({ + container: { + borderRadius: 16, + overflow: 'hidden', + }, + image: { + maxWidth: 240, + width: 240, + height: 160, + borderRadius: 16, + overflow: 'hidden', + }, +}); + +export default ThreadParentMessageFileImage; diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.tsx new file mode 100644 index 000000000..c7c2841f9 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { SendbirdFileMessage, getFileExtension, getFileType, truncate } from '@sendbird/uikit-utils'; +import { Box, createStyleSheet, PressBox, Icon, Text } from '@sendbird/uikit-react-native-foundation'; +import { useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import { ThreadParentMessageRendererProps } from './index'; +import { useLocalization } from './../../hooks/useContext'; + +const ThreadParentMessageFile = (props: ThreadParentMessageRendererProps) => { + const fileMessage: SendbirdFileMessage = props.parentMessage as SendbirdFileMessage; + if (!fileMessage) return null; + + const { STRINGS } = useLocalization(); + const { select, colors, palette } = useUIKitTheme(); + + const fileType = getFileType(fileMessage.type || getFileExtension(fileMessage.name)); + const fileName = STRINGS.GROUP_CHANNEL.MESSAGE_BUBBLE_FILE_TITLE(fileMessage) ?? fileMessage.name; + + return ( + + + + + + {truncate(fileName, { mode: 'mid', maxLen: 30 })} + + + + + ); +}; + +const styles = createStyleSheet({ + fileBubbleContainer: { + alignSelf: 'flex-start', + overflow: 'hidden', + flexDirection: 'row', + alignItems: 'center', + borderRadius: 16, + paddingHorizontal: 12, + paddingVertical: 8, + }, + iconBackground: { + flexDirection: 'row', + alignItems: 'center', + }, + name: { + flexShrink: 1, + marginLeft: 8, + }, +}); + +export default ThreadParentMessageFile; diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.video.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.video.tsx new file mode 100644 index 000000000..892f37a3a --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.video.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { getThumbnailUriFromFileMessage, SendbirdFileMessage } from '@sendbird/uikit-utils'; +import { Box, createStyleSheet, PressBox, VideoThumbnail } from '@sendbird/uikit-react-native-foundation'; + +import { ThreadParentMessageRendererProps } from './index'; + +type Props = ThreadParentMessageRendererProps< + { fetchThumbnailFromVideoSource: (uri: string) => Promise<{ path: string } | null> } +>; + +const ThreadParentMessageFileVideo = (props: Props) => { + const fileMessage: SendbirdFileMessage = props.parentMessage as SendbirdFileMessage; + if (!fileMessage) return null; + + const uri = getThumbnailUriFromFileMessage(fileMessage); + + return ( + + + + + + ); +}; + +const styles = createStyleSheet({ + container: { + borderRadius: 16, + overflow: 'hidden', + }, + image: { + maxWidth: 240, + width: 240, + height: 160, + borderRadius: 16, + overflow: 'hidden', + }, +}); + +export default ThreadParentMessageFileVideo; diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx new file mode 100644 index 000000000..5fe9daea6 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react'; +import { millsToMSS, SendbirdFileMessage } from '@sendbird/uikit-utils'; +import { Box, Icon, PressBox, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import { ThreadParentMessageRendererProps } from './index'; +import { ProgressBar, LoadingSpinner } from '@sendbird/uikit-react-native-foundation'; +import { createStyleSheet } from '@sendbird/uikit-react-native-foundation'; + +export type VoiceFileMessageState = { + status: 'preparing' | 'playing' | 'paused'; + currentTime: number; + duration: number; +}; + +type Props = ThreadParentMessageRendererProps< + { + durationMetaArrayKey?: string; + onUnmount: () => void; + } +>; + +const ThreadParentMessageFileVoice = (props: Props) => { + const { + onLongPress, + onToggleVoiceMessage, + parentMessage, + durationMetaArrayKey = 'KEY_VOICE_MESSAGE_DURATION', + onUnmount, + } = props; + + const fileMessage: SendbirdFileMessage = parentMessage as SendbirdFileMessage; + if (!fileMessage) return null; + + const { colors } = useUIKitTheme(); + + const [state, setState] = useState(() => { + const meta = fileMessage.metaArrays.find((it) => it.key === durationMetaArrayKey); + const value = meta?.value?.[0]; + const initialDuration = value ? parseInt(value, 10) : 0; + return { + status: 'paused', + currentTime: 0, + duration: initialDuration, + }; + }); + + useEffect(() => { + return () => { + onUnmount(); + }; + }, []); + + const uiColors = colors.ui.groupChannelMessage['incoming']; + const remainingTime = state.duration - state.currentTime; + + return ( + + onToggleVoiceMessage?.(state, setState)} onLongPress={onLongPress}> + + {state.status === 'preparing' ? ( + + ) : ( + + )} + + {millsToMSS(state.currentTime === 0 ? state.duration : remainingTime)} + + + } + /> + + + ); +}; + +const styles = createStyleSheet({ + container: { + borderRadius: 16, + overflow: 'hidden', + }, +}); + +export default ThreadParentMessageFileVoice; diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx new file mode 100644 index 000000000..5076724c6 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { Box, createStyleSheet, ImageWithPlaceholder, PressBox, RegexText, type RegexTextPattern, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { SendbirdUserMessage, urlRegexStrict, useFreshCallback } from '@sendbird/uikit-utils'; +import { useSendbirdChat } from './../../hooks/useContext'; +import { ThreadParentMessageRendererProps } from './index'; + +type Props = ThreadParentMessageRendererProps< + { + regexTextPatterns?: RegexTextPattern[]; + renderRegexTextChildren?: (message: SendbirdUserMessage) => string; + } +>; + +const ThreadParentMessageUserOg = (props: Props) => { + const userMessage: SendbirdUserMessage = props.parentMessage as SendbirdUserMessage; + if (!userMessage) return null; + + const { sbOptions } = useSendbirdChat(); + const { select, colors, palette } = useUIKitTheme(); + const enableOgtag = sbOptions.uikitWithAppInfo.groupChannel.channel.enableOgtag; + const onPressMessage = (userMessage: SendbirdUserMessage) => useFreshCallback(() => { + typeof userMessage.ogMetaData?.url === 'string' && props.onPressURL?.(userMessage.ogMetaData.url); + }); + + return ( + + + + props.onPressURL?.(match)} + style={[parentProps?.style, styles.urlText]} + > + {match} + + ); + }, + }, + ]} + > + {props.renderRegexTextChildren?.(userMessage)} + + {Boolean(userMessage.updatedAt) && ( + + {' (edited)'} + + )} + + + {userMessage.ogMetaData && enableOgtag && + + {!!userMessage.ogMetaData.defaultImage && ( + + )} + + + {userMessage.ogMetaData.title} + + {!!userMessage.ogMetaData.description && ( + + {userMessage.ogMetaData.description} + + )} + + {userMessage.ogMetaData.url} + + + + } + + ); +}; + +const styles = createStyleSheet({ + ogContainer: { + overflow: 'hidden', + paddingHorizontal: 12, + paddingTop: 8, + paddingBottom: 12, + maxWidth: 240, + }, + ogImage: { + width: 240, + height: 136, + }, + ogUrl: { + marginBottom: 4, + }, + ogTitle: { + marginBottom: 4, + }, + ogDesc: { + lineHeight: 14, + marginBottom: 8, + }, + urlText: { + textDecorationLine: 'underline', + }, +}); + +export default ThreadParentMessageUserOg; diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.tsx new file mode 100644 index 000000000..5a3a1a909 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { type RegexTextPattern, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { SendbirdUserMessage, urlRegexStrict } from '@sendbird/uikit-utils'; +import { ThreadParentMessageRendererProps } from './index'; +import { RegexText, createStyleSheet } from '@sendbird/uikit-react-native-foundation'; + +type Props = ThreadParentMessageRendererProps< + { + regexTextPatterns?: RegexTextPattern[]; + renderRegexTextChildren?: (message: SendbirdUserMessage) => string; + } +>; + +const ThreadParentMessageUser = (props: Props) => { + const userMessage: SendbirdUserMessage = props.parentMessage as SendbirdUserMessage; + if (!userMessage) return null; + + const { colors } = useUIKitTheme(); + + return ( + + props.onPressURL?.(match)} + style={[parentProps?.style, styles.urlText]} + > + {match} + + ); + }, + }, + ]} + > + {props.renderRegexTextChildren?.(userMessage)} + + {Boolean(userMessage.updatedAt) && ( + + {' (edited)'} + + )} + + ); +}; + +const styles = createStyleSheet({ + bubble: { + paddingHorizontal: 12, + paddingVertical: 6, + }, + urlText: { + textDecorationLine: 'underline', + }, +}); + +export default ThreadParentMessageUser; diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/index.tsx new file mode 100644 index 000000000..3499ce35c --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/index.tsx @@ -0,0 +1,179 @@ +import React, { useRef } from 'react'; +import { getMessageType, isMyMessage, isVoiceMessage, SendbirdFileMessage, type SendbirdUser, SendbirdUserMessage } from '@sendbird/uikit-utils'; +import { RegexTextPattern, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import { VOICE_MESSAGE_META_ARRAY_DURATION_KEY } from '../../constants'; +import { usePlatformService, useSendbirdChat } from './../../hooks/useContext'; +import SBUUtils from '../../libs/SBUUtils'; +import ThreadParentMessageUser from './ThreadParentMessage.user'; +import ThreadParentMessageFile from './ThreadParentMessage.file'; +import ThreadParentMessageFileVoice, { VoiceFileMessageState } from './ThreadParentMessage.file.voice'; +import ThreadParentMessageUserOg from './ThreadParentMessage.user.og'; +import ThreadParentMessageFileImage from './ThreadParentMessage.file.image'; +import ThreadParentMessageFileVideo from './ThreadParentMessage.file.video'; + +export type ThreadParentMessageRendererProps = { + parentMessage: SendbirdUserMessage | SendbirdFileMessage; + onPress?: () => void; + onLongPress?: () => void; + onPressURL?: (url: string) => void; + onPressMentionedUser?: (mentionedUser?: SendbirdUser) => void; + onToggleVoiceMessage?: ( + state: VoiceFileMessageState, + setState: React.Dispatch>, + ) => Promise; +} & AdditionalProps; + +const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => { + const playerUnsubscribes = useRef<(() => void)[]>([]); + const { sbOptions, currentUser, mentionManager } = useSendbirdChat(); + const { palette } = useUIKitTheme(); + const { mediaService, playerService } = usePlatformService(); + const parentMessage = props.parentMessage; + + const resetPlayer = async () => { + playerUnsubscribes.current.forEach((unsubscribe) => { + try { + unsubscribe(); + } catch {} + }); + playerUnsubscribes.current.length = 0; + await playerService.reset(); + }; + + const messageProps: ThreadParentMessageRendererProps = { + onPressURL: (url) => SBUUtils.openURL(url), + onToggleVoiceMessage: async (state, setState) => { + if (isVoiceMessage(parentMessage) && parentMessage.sendingStatus === 'succeeded') { + if (playerService.uri === parentMessage.url) { + if (playerService.state === 'playing') { + await playerService.pause(); + } else { + await playerService.play(parentMessage.url); + } + } else { + if (playerService.state !== 'idle') { + await resetPlayer(); + } + + const shouldSeekToTime = state.duration > state.currentTime && state.currentTime > 0; + let seekFinished = !shouldSeekToTime; + + const forPlayback = playerService.addPlaybackListener(({ stopped, currentTime, duration }) => { + if (seekFinished) { + setState((prevState) => ({ ...prevState, currentTime: stopped ? 0 : currentTime, duration })); + } + }); + const forState = playerService.addStateListener((state) => { + switch (state) { + case 'preparing': + setState((prevState) => ({ ...prevState, status: 'preparing' })); + break; + case 'playing': + setState((prevState) => ({ ...prevState, status: 'playing' })); + break; + case 'idle': + case 'paused': { + setState((prevState) => ({ ...prevState, status: 'paused' })); + break; + } + case 'stopped': + setState((prevState) => ({ ...prevState, status: 'paused' })); + break; + } + }); + playerUnsubscribes.current.push(forPlayback, forState); + + await playerService.play(parentMessage.url); + if (shouldSeekToTime) { + await playerService.seek(state.currentTime); + seekFinished = true; + } + } + } + }, + ...props, + }; + + const userMessageProps: { + renderRegexTextChildren: (message: SendbirdUserMessage) => string; + regexTextPatterns: RegexTextPattern[]; + } = { + renderRegexTextChildren: (message) => { + if ( + mentionManager.shouldUseMentionedMessageTemplate(message, sbOptions.uikit.groupChannel.channel.enableMention) + ) { + return message.mentionedMessageTemplate; + } else { + return message.message; + } + }, + regexTextPatterns: [ + { + regex: mentionManager.templateRegex, + replacer({ match, groups, parentProps, index, keyPrefix }) { + const user = parentMessage.mentionedUsers?.find((it) => it.userId === groups[2]); + if (user) { + const mentionColor = + !isMyMessage(parentMessage, currentUser?.userId) && user.userId === currentUser?.userId + ? palette.onBackgroundLight01 + : parentProps?.color; + + return ( + messageProps.onPressMentionedUser?.(user)} + onLongPress={messageProps.onLongPress} + style={[ + parentProps?.style, + { fontWeight: '700' }, + user.userId === currentUser?.userId && { backgroundColor: palette.highlight }, + ]} + > + {`${mentionManager.asMentionedMessageText(user)}`} + + ); + } + return match; + }, + }, + ], + }; + + switch (getMessageType(props.parentMessage)) { + case 'user': { + return ; + } + case 'user.opengraph': { + return ; + } + case 'file': + case 'file.audio': { + return ; + } + case 'file.video': { + return mediaService.getVideoThumbnail({ url: uri, timeMills: 1000 })} + {...messageProps} />; + } + case 'file.image': { + return ; + } + case 'file.voice': { + return { + if (isVoiceMessage(parentMessage) && playerService.uri === parentMessage.url) { + resetPlayer(); + } + }}{...messageProps} />; + } + default: { + return null; + } + } +}; + +export default React.memo(ThreadParentMessageRenderer); diff --git a/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx b/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx index f59a2c04f..fe93be207 100644 --- a/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx +++ b/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx @@ -64,7 +64,6 @@ export const SendbirdUIKit = Object.freeze({ }, }); -type UnimplementedFeatures = 'threadReplySelectType' | 'replyType'; export type ChatOmittedInitParams = Omit< SendbirdChatParams<[GroupChannelModule, OpenChannelModule]>, (typeof chatOmitKeys)[number] @@ -102,9 +101,7 @@ export type SendbirdUIKitContainerProps = React.PropsWithChildren<{ Partial; uikitOptions?: PartialDeep<{ common: SBUConfig['common']; - groupChannel: Omit & { - replyType: Extract; - }; + groupChannel: SBUConfig['groupChannel']['channel']; groupChannelList: SBUConfig['groupChannel']['channelList']; groupChannelSettings: SBUConfig['groupChannel']['setting']; openChannel: SBUConfig['openChannel']['channel']; diff --git a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx index b8502a325..58e1d068a 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx @@ -2,7 +2,7 @@ import React, { useContext, useEffect } from 'react'; import { useChannelHandler } from '@sendbird/uikit-chat-hooks'; import { useToast } from '@sendbird/uikit-react-native-foundation'; -import type { SendbirdMessage } from '@sendbird/uikit-utils'; +import { SendbirdMessage, SendbirdSendableMessage } from '@sendbird/uikit-utils'; import { isDifferentChannel, useFreshCallback, useIsFirstMount, useUniqHandlerId } from '@sendbird/uikit-utils'; import ChannelMessageList from '../../../components/ChannelMessageList'; @@ -14,10 +14,10 @@ import type { GroupChannelProps } from '../types'; const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { const toast = useToast(); const { STRINGS } = useLocalization(); - const { sdk } = useSendbirdChat(); + const { sdk, sbOptions } = useSendbirdChat(); const { setMessageToEdit, setMessageToReply } = useContext(GroupChannelContexts.Fragment); const { subscribe } = useContext(GroupChannelContexts.PubSub); - const { flatListRef, lazyScrollToBottom, lazyScrollToIndex } = useContext(GroupChannelContexts.MessageList); + const { flatListRef, lazyScrollToBottom, lazyScrollToIndex, onPressReplyMessageInThread } = useContext(GroupChannelContexts.MessageList); const id = useUniqHandlerId('GroupChannelMessageList'); const isFirstMount = useIsFirstMount(); @@ -98,10 +98,15 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { scrollToMessageWithCreatedAt(props.searchItem.startingPoint, false, MESSAGE_SEARCH_SAFE_SCROLL_DELAY); } }, [isFirstMount]); - - const onPressParentMessage = useFreshCallback((message: SendbirdMessage) => { - const canScrollToParent = scrollToMessageWithCreatedAt(message.createdAt, true, 0); - if (!canScrollToParent) toast.show(STRINGS.TOAST.FIND_PARENT_MSG_ERROR, 'error'); + + const onPressParentMessage = useFreshCallback((parentMessage: SendbirdMessage, childMessage: SendbirdSendableMessage) => { + if (onPressReplyMessageInThread && sbOptions.uikit.groupChannel.channel.replyType === 'thread' + && sbOptions.uikit.groupChannel.channel.threadReplySelectType === 'thread') { + onPressReplyMessageInThread(parentMessage as SendbirdSendableMessage, childMessage.createdAt); + } else { + const canScrollToParent = scrollToMessageWithCreatedAt(parentMessage.createdAt, true, 0); + if (!canScrollToParent) toast.show(STRINGS.TOAST.FIND_PARENT_MSG_ERROR, 'error'); + } }); return ( @@ -109,6 +114,7 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { {...props} ref={flatListRef} onReplyMessage={setMessageToReply} + onReplyInThreadMessage={setMessageToReply} onEditMessage={setMessageToEdit} onPressParentMessage={onPressParentMessage} onPressNewMessagesButton={scrollToBottom} diff --git a/packages/uikit-react-native/src/domain/groupChannel/module/moduleContext.tsx b/packages/uikit-react-native/src/domain/groupChannel/module/moduleContext.tsx index d0e0e947f..5155d83f9 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/module/moduleContext.tsx +++ b/packages/uikit-react-native/src/domain/groupChannel/module/moduleContext.tsx @@ -58,12 +58,13 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({ groupChannelPubSub, messages, onUpdateSearchItem, + onPressReplyMessageInThread, }) => { if (!channel) throw new Error('GroupChannel is not provided to GroupChannelModule'); const handlerId = useUniqHandlerId('GroupChannelContextsProvider'); const { STRINGS } = useLocalization(); - const { currentUser, sdk } = useSendbirdChat(); + const { currentUser, sdk, sbOptions } = useSendbirdChat(); const [typingUsers, setTypingUsers] = useState([]); const [messageToEdit, setMessageToEdit] = useState(); @@ -89,6 +90,14 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({ return; } }; + + const onPressMessageToReply = (parentMessage?: SendbirdUserMessage | SendbirdFileMessage) => { + if (sbOptions.uikit.groupChannel.channel.replyType === 'thread' && parentMessage) { + onPressReplyMessageInThread?.(parentMessage, Number.MAX_SAFE_INTEGER); + } else if (sbOptions.uikit.groupChannel.channel.replyType === 'quote_reply') { + updateInputMode('reply', parentMessage); + } + }; useChannelHandler(sdk, handlerId, { onMessageDeleted(_, messageId) { @@ -125,7 +134,7 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({ messageToEdit, setMessageToEdit: useCallback((message) => updateInputMode('edit', message), []), messageToReply, - setMessageToReply: useCallback((message) => updateInputMode('reply', message), []), + setMessageToReply: useCallback((message) => onPressMessageToReply(message), []), }} > @@ -136,6 +145,7 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({ scrollToMessage, lazyScrollToIndex, lazyScrollToBottom, + onPressReplyMessageInThread: onPressReplyMessageInThread, }} > {children} diff --git a/packages/uikit-react-native/src/domain/groupChannel/types.ts b/packages/uikit-react-native/src/domain/groupChannel/types.ts index 2bdcbabf4..c452e5e6b 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/types.ts +++ b/packages/uikit-react-native/src/domain/groupChannel/types.ts @@ -10,7 +10,7 @@ import type { SendbirdFileMessageCreateParams, SendbirdFileMessageUpdateParams, SendbirdGroupChannel, - SendbirdMessage, + SendbirdMessage, SendbirdSendableMessage, SendbirdUser, SendbirdUserMessage, SendbirdUserMessageCreateParams, @@ -30,6 +30,7 @@ export interface GroupChannelProps { onPressHeaderLeft: GroupChannelProps['Header']['onPressHeaderLeft']; onPressHeaderRight: GroupChannelProps['Header']['onPressHeaderRight']; onPressMediaMessage?: GroupChannelProps['MessageList']['onPressMediaMessage']; + onPressReplyMessageInThread?: GroupChannelProps['Provider']['onPressReplyMessageInThread']; onBeforeSendUserMessage?: OnBeforeHandler; onBeforeSendFileMessage?: OnBeforeHandler; @@ -114,6 +115,7 @@ export interface GroupChannelProps { messages: SendbirdMessage[]; // Changing the search item will trigger the focus animation on messages. onUpdateSearchItem: (searchItem?: GroupChannelProps['MessageList']['searchItem']) => void; + onPressReplyMessageInThread: (parentMessage: SendbirdSendableMessage, startingPoint?: number) => void; }; } @@ -171,6 +173,8 @@ export interface GroupChannelContextsType { timeout?: number; viewPosition?: number; }) => void; + + onPressReplyMessageInThread?: (parentMessage: SendbirdSendableMessage, startingPoint?: number) => void; }>; } export interface GroupChannelModule { diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx new file mode 100644 index 000000000..818c90f7a --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx @@ -0,0 +1,53 @@ +import React, { useContext } from 'react'; +import { View } from 'react-native'; + +import { createStyleSheet, Icon, Text, useHeaderStyle, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import { GroupChannelThreadContexts } from '../module/moduleContext'; +import type { GroupChannelThreadProps } from '../types'; +import { useLocalization, useSendbirdChat } from '../../../hooks/useContext'; + +const GroupChannelThreadHeader = ({ onPressHeaderLeft }: GroupChannelThreadProps['Header']) => { + const { headerTitle, channel } = useContext(GroupChannelThreadContexts.Fragment); + const { HeaderComponent } = useHeaderStyle(); + const { STRINGS } = useLocalization(); + const { select, colors, palette } = useUIKitTheme(); + const { currentUser } = useSendbirdChat(); + + const renderSubtitle = () => { + if (!currentUser) return null; + + return ( + {STRINGS.GROUP_CHANNEL_THREAD.HEADER_SUBTITLE(currentUser.userId, channel)} + ); + }; + + return ( + + + {headerTitle} + {renderSubtitle()} + + + } + left={} + onPressLeft={onPressHeaderLeft} + /> + ); +}; + +const styles = createStyleSheet({ + titleContainer: { + maxWidth: '100%', + flexDirection: 'row', + width: '100%', + }, + subtitle: { + marginTop: 2, + }, +}); + +export default GroupChannelThreadHeader; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx new file mode 100644 index 000000000..836fe7eaf --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx @@ -0,0 +1,36 @@ +import React, { useContext } from 'react'; + +import { getGroupChannelChatAvailableState } from '@sendbird/uikit-utils'; + +import ChannelInput from '../../../components/ChannelInput'; +import { GroupChannelThreadContexts } from '../module/moduleContext'; +import type { GroupChannelThreadProps } from '../types'; + +const GroupChannelThreadInput = ({ inputDisabled, ...props }: GroupChannelThreadProps['Input']) => { + const { + channel, + keyboardAvoidOffset = 0, + messageToEdit, + setMessageToEdit, + parentMessage, + } = useContext(GroupChannelThreadContexts.Fragment); + + const chatAvailableState = getGroupChannelChatAvailableState(channel); + + return ( + {return null;}} + {...props} + /> + ); +}; + +export default React.memo(GroupChannelThreadInput); diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx new file mode 100644 index 000000000..aa6cf3cf1 --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx @@ -0,0 +1,82 @@ +import { useChannelHandler } from '@sendbird/uikit-chat-hooks'; +import { isDifferentChannel, useFreshCallback, useUniqHandlerId } from '@sendbird/uikit-utils'; +import React, { useContext, useEffect, useLayoutEffect } from 'react'; + +import ChannelMessageList from '../../../components/ChannelMessageList'; +import { useSendbirdChat } from '../../../hooks/useContext'; +import { GroupChannelThreadContexts } from '../module/moduleContext'; +import type { GroupChannelThreadProps } from '../types'; + +const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageList']) => { + const { sdk } = useSendbirdChat(); + const { setMessageToEdit } = useContext(GroupChannelThreadContexts.Fragment); + const { subscribe } = useContext(GroupChannelThreadContexts.PubSub); + const { flatListRef, lazyScrollToBottom, lazyScrollToIndex } = useContext(GroupChannelThreadContexts.MessageList); + + const id = useUniqHandlerId('GroupChannelThreadMessageList'); + + const scrollToBottom = useFreshCallback(async (animated = false) => { + if (props.hasNext()) { + props.onScrolledAwayFromBottom(false); + + await props.onResetMessageList(); + props.onScrolledAwayFromBottom(false); + lazyScrollToBottom({ animated }); + } else { + lazyScrollToBottom({ animated }); + } + }); + + useLayoutEffect(() => { + if (props.startingPoint) { + const foundMessageIndex = props.messages.findIndex((it) => it.createdAt === props.startingPoint); + const isIncludedInList = foundMessageIndex > -1; + if (isIncludedInList) { + lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout: 100 }); + } + } + }, [props.startingPoint]); + + useChannelHandler(sdk, id, { + onReactionUpdated(channel, event) { + if (isDifferentChannel(channel, props.channel)) return; + const recentMessage = props.messages[0]; + const isRecentMessage = recentMessage && recentMessage.messageId === event.messageId; + const scrollReachedBottomAndCanScroll = !props.scrolledAwayFromBottom && !props.hasNext(); + if (isRecentMessage && scrollReachedBottomAndCanScroll) { + lazyScrollToBottom({ animated: true, timeout: 250 }); + } + }, + }); + + useEffect(() => { + return subscribe(({ type }) => { + switch (type) { + case 'TYPING_BUBBLE_RENDERED': + case 'MESSAGES_RECEIVED': { + if (!props.scrolledAwayFromBottom) { + scrollToBottom(true); + } + break; + } + case 'MESSAGE_SENT_SUCCESS': + case 'MESSAGE_SENT_PENDING': { + scrollToBottom(false); + break; + } + } + }); + }, [props.scrolledAwayFromBottom]); + + return ( + + ); +}; + +export default React.memo(GroupChannelThreadMessageList); diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx new file mode 100644 index 000000000..3df16e49b --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx @@ -0,0 +1,298 @@ +import React, { useContext } from 'react'; +import { TouchableOpacity, View } from 'react-native'; + +import { Avatar, BottomSheetItem, createStyleSheet, Divider, Icon, Text, useAlert, useBottomSheet, useToast, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { useLocalization, usePlatformService, useSendbirdChat } from '../../../hooks/useContext'; +import type { GroupChannelThreadProps } from '../types'; +import { GroupChannelThreadContexts } from '../module/moduleContext'; +import { format } from 'date-fns'; +import ThreadParentMessageRenderer, { ThreadParentMessageRendererProps } from '../../../components/ThreadParentMessageRenderer'; +import { getAvailableUriFromFileMessage, getFileExtension, getFileType, isMyMessage, isVoiceMessage, Logger, SendbirdFileMessage, SendbirdMessage, SendbirdUserMessage, shouldRenderReaction, toMegabyte } from '@sendbird/uikit-utils'; +import SBUUtils from '../../../libs/SBUUtils'; +import { ReactionAddons } from './../../../components/ReactionAddons'; + +type PressActions = { onPress?: () => void; onLongPress?: () => void; bottomSheetItem?: BottomSheetItem }; +type HandleableMessage = SendbirdUserMessage | SendbirdFileMessage; +type CreateMessagePressActions = (params: { message: SendbirdMessage }) => PressActions; + +const GroupChannelThreadParentMessageInfo = (props: GroupChannelThreadProps['ParentMessageInfo']) => { + const { channel, parentMessage, setMessageToEdit } = useContext(GroupChannelThreadContexts.Fragment); + const { STRINGS } = useLocalization(); + const { colors } = useUIKitTheme(); + const { sbOptions } = useSendbirdChat(); + + const nickName = parentMessage.sender?.nickname || STRINGS.LABELS.USER_NO_NAME; + const messageTimestamp = format(new Date(parentMessage.updatedAt), 'MMM dd \'at\' h:mm a'); + const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_POSTFIX(parentMessage.threadInfo?.replyCount || 0); + const createMessagePressActions = useCreateMessagePressActions({ + channel: props.channel, + currentUserId: props.currentUserId, + onDeleteMessage: props.onDeleteMessage, + onPressMediaMessage: props.onPressMediaMessage, + onEditMessage: setMessageToEdit, + }); + const { onPress, onLongPress, bottomSheetItem } = createMessagePressActions({ message: parentMessage }); + + const renderMessageInfoAndMenu = () => { + return ( + + + + {nickName} + {messageTimestamp} + + + + + + ); + }; + + const renderReplyCount = (replyCountText: string) => { + if (replyCountText) { + return ( + + + {replyCountText} + + + + ); + } else { + return null; + } + }; + + const renderReactionAddons = () => { + const configs = sbOptions.uikitWithAppInfo.groupChannel.channel; + if (shouldRenderReaction(channel, channel.isSuper ? configs.enableReactionsSupergroup : configs.enableReactions)) { + return + + ; + } else { + return null; + } + }; + + const messageProps: ThreadParentMessageRendererProps = { + parentMessage, + onPress, + onLongPress, + }; + + return ( + + + {renderMessageInfoAndMenu()} + + + + + {renderReactionAddons()} + + {renderReplyCount(replyCountText)} + + ); +}; + +const styles = createStyleSheet({ + container: { + flexDirection: 'column', + }, + infoAndMenuContainer: { + flexDirection: 'row', + height: 50, + padding: 16, + paddingBottom: 0, + }, + userNickAndTimeContainer: { + flexDirection: 'column', + flex: 1, + marginLeft: 8, + }, + userNickname: { + marginBottom: 2, + }, + messageTime: { + marginTop: 2, + }, + contextMenuButton: { + width: 34, + height: 34, + justifyContent: 'flex-end', + }, + messageContainer: { + paddingHorizontal: 16, + paddingVertical: 8, + }, + reactionButtonContainer: { + flexDirection: 'row', + height: 50, + }, + replyContainer: { + flexDirection: 'column', + }, + replyText: { + justifyContent: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + }, +}); + +const useCreateMessagePressActions = ({ + channel, + currentUserId, + onDeleteMessage, + onPressMediaMessage, + onEditMessage, + }: Pick< + GroupChannelThreadProps['ParentMessageInfo'], + | 'channel' + | 'currentUserId' + | 'onDeleteMessage' + | 'onPressMediaMessage' +> & { onEditMessage: (message: HandleableMessage) => void; }): CreateMessagePressActions => { + const { STRINGS } = useLocalization(); + const toast = useToast(); + const { openSheet } = useBottomSheet(); + const { alert } = useAlert(); + const { clipboardService, fileService } = usePlatformService(); + const { sbOptions } = useSendbirdChat(); + + const onDeleteFailure = (error: Error) => { + toast.show(STRINGS.TOAST.DELETE_MSG_ERROR, 'error'); + Logger.error(STRINGS.TOAST.DELETE_MSG_ERROR, error); + }; + + const onCopyText = (message: HandleableMessage) => { + if (message.isUserMessage()) { + clipboardService.setString(message.message || ''); + toast.show(STRINGS.TOAST.COPY_OK, 'success'); + } + }; + + const onDownloadFile = (message: HandleableMessage) => { + if (message.isFileMessage()) { + if (toMegabyte(message.size) > 4) { + toast.show(STRINGS.TOAST.DOWNLOAD_START, 'success'); + } + + fileService + .save({ fileUrl: message.url, fileName: message.name, fileType: message.type }) + .then((response) => { + toast.show(STRINGS.TOAST.DOWNLOAD_OK, 'success'); + Logger.log('File saved to', response); + }) + .catch((err) => { + toast.show(STRINGS.TOAST.DOWNLOAD_ERROR, 'error'); + Logger.log('File save failure', err); + }); + } + }; + + const onOpenFile = (message: HandleableMessage) => { + if (message.isFileMessage()) { + const fileType = getFileType(message.type || getFileExtension(message.name)); + if (['image', 'video', 'audio'].includes(fileType)) { + onPressMediaMessage?.(message, () => onDeleteMessage?.(message), getAvailableUriFromFileMessage(message)); + } else { + SBUUtils.openURL(message.url); + } + } + }; + + const alertForMessageDelete = (message: HandleableMessage) => { + alert({ + title: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_TITLE, + buttons: [ + { text: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_CANCEL }, + { + text: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_OK, + style: 'destructive', + onPress: () => { + onDeleteMessage?.(message).catch(onDeleteFailure); + }, + }, + ], + }); + }; + + return ({ message }) => { + if (!message.isUserMessage() && !message.isFileMessage()) return {}; + + const sheetItems: BottomSheetItem['sheetItems'] = []; + const menu = { + copy: (message: HandleableMessage) => ({ + icon: 'copy' as const, + title: STRINGS.LABELS.CHANNEL_MESSAGE_COPY, + onPress: () => onCopyText(message), + }), + edit: (message: HandleableMessage) => ({ + icon: 'edit' as const, + title: STRINGS.LABELS.CHANNEL_MESSAGE_EDIT, + onPress: () => onEditMessage?.(message), + }), + delete: (message: HandleableMessage) => ({ + disabled: message.threadInfo ? message.threadInfo.replyCount > 0 : undefined, + icon: 'delete' as const, + title: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE, + onPress: () => alertForMessageDelete(message), + }), + download: (message: HandleableMessage) => ({ + icon: 'download' as const, + title: STRINGS.LABELS.CHANNEL_MESSAGE_SAVE, + onPress: () => onDownloadFile(message), + }), + }; + + if (message.isUserMessage()) { + sheetItems.push(menu.copy(message)); + if (!channel.isEphemeral) { + if (isMyMessage(message, currentUserId) && message.sendingStatus === 'succeeded') { + sheetItems.push(menu.edit(message)); + sheetItems.push(menu.delete(message)); + } + } + } + + if (message.isFileMessage()) { + if (!isVoiceMessage(message)) { + sheetItems.push(menu.download(message)); + } + if (!channel.isEphemeral) { + if (isMyMessage(message, currentUserId) && message.sendingStatus === 'succeeded') { + sheetItems.push(menu.delete(message)); + } + } + } + + const configs = sbOptions.uikitWithAppInfo.groupChannel.channel; + const bottomSheetItem: BottomSheetItem = { + sheetItems, + HeaderComponent: shouldRenderReaction( + channel, + channel.isGroupChannel() && (channel.isSuper ? configs.enableReactionsSupergroup : configs.enableReactions), + ) + ? ({ onClose }) => + : undefined, + }; + + if (message.isFileMessage()) { + return { + onPress: () => onOpenFile(message), + onLongPress: () => openSheet(bottomSheetItem), + bottomSheetItem, + }; + } else { + return { + onPress: undefined, + onLongPress: () => openSheet(bottomSheetItem), + bottomSheetItem, + }; + } + }; +}; + +export default React.memo(GroupChannelThreadParentMessageInfo); diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadStatusEmpty.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadStatusEmpty.tsx new file mode 100644 index 000000000..9d01363b5 --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadStatusEmpty.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import TypedPlaceholder from '../../../components/TypedPlaceholder'; + +const GroupChannelThreadStatusEmpty = () => { + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, justifyContent: 'center', alignItems: 'center' }, +}); + +export default GroupChannelThreadStatusEmpty; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadStatusLoading.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadStatusLoading.tsx new file mode 100644 index 000000000..e8f775282 --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadStatusLoading.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import TypedPlaceholder from '../../../components/TypedPlaceholder'; + +const GroupChannelThreadStatusLoading = () => { + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, justifyContent: 'center', alignItems: 'center' }, +}); + +export default GroupChannelThreadStatusLoading; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadSuggestedMentionList.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadSuggestedMentionList.tsx new file mode 100644 index 000000000..ebffbe70c --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadSuggestedMentionList.tsx @@ -0,0 +1,174 @@ +import React, { useContext } from 'react'; +import { Pressable, ScrollView, View, useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { + Avatar, + Divider, + Icon, + Text, + createStyleSheet, + useHeaderStyle, + useUIKitTheme, +} from '@sendbird/uikit-react-native-foundation'; +import { conditionChaining } from '@sendbird/uikit-utils'; + +import { useLocalization, useSendbirdChat } from '../../../hooks/useContext'; +import useKeyboardStatus from '../../../hooks/useKeyboardStatus'; +import useMentionSuggestion from '../../../hooks/useMentionSuggestion'; +import { GroupChannelThreadContexts } from '../module/moduleContext'; +import type { GroupChannelThreadProps } from '../types'; + +const GroupChannelThreadSuggestedMentionList = ({ + text, + selection, + inputHeight, + bottomInset, + onPressToMention, + mentionedUsers, +}: GroupChannelThreadProps['SuggestedMentionList']) => { + const { width: screenWidth, height: screenHeight } = useWindowDimensions(); + const { channel } = useContext(GroupChannelThreadContexts.Fragment); + const { sdk, mentionManager } = useSendbirdChat(); + const { STRINGS } = useLocalization(); + const { colors } = useUIKitTheme(); + const { topInset } = useHeaderStyle(); + const { left, right } = useSafeAreaInsets(); + + const keyboard = useKeyboardStatus(); + + const { members, reset, searchStringRange, searchLimited } = useMentionSuggestion({ + sdk, + text, + selection, + channel, + mentionedUsers, + }); + + const isLandscape = screenWidth > screenHeight; + const isShortened = isLandscape && keyboard.visible; + const canRenderMembers = members.length > 0; + const maxHeight = isShortened ? screenHeight - (topInset + inputHeight + keyboard.height) : styles.suggestion.height; + + const renderLimitGuide = () => { + return ( + + + + {STRINGS.GROUP_CHANNEL_THREAD.MENTION_LIMITED(mentionManager.config.mentionLimit)} + + + ); + }; + + const renderMembers = () => { + return ( + + {members.map((member) => { + return ( + { + onPressToMention(member, searchStringRange); + reset(); + }} + key={member.userId} + style={styles.userContainer} + > + + + + {member.nickname || STRINGS.LABELS.USER_NO_NAME} + + + {member.userId} + + + + + ); + })} + + ); + }; + + return ( + + + {conditionChaining([searchLimited, canRenderMembers], [renderLimitGuide(), renderMembers(), null])} + + + ); +}; + +const styles = createStyleSheet({ + suggestion: { + height: 196, + }, + container: { + position: 'absolute', + top: 0, + right: 0, + left: 0, + }, + scrollView: { + position: 'absolute', + left: 0, + right: 0, + }, + userContainer: { + paddingLeft: 16, + flexDirection: 'row', + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + userAvatar: { + marginRight: 16, + }, + userInfo: { + flexDirection: 'row', + flex: 1, + }, + userNickname: { + flexShrink: 1, + lineHeight: 44, + textAlignVertical: 'center', + marginRight: 6, + }, + userId: { + lineHeight: 44, + textAlignVertical: 'center', + minWidth: 32, + flexShrink: 1, + marginRight: 16, + }, + searchLimited: { + borderTopWidth: 1, + paddingHorizontal: 16, + height: 44, + flexDirection: 'row', + alignItems: 'center', + }, +}); +export default GroupChannelThreadSuggestedMentionList; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/index.ts b/packages/uikit-react-native/src/domain/groupChannelThread/index.ts new file mode 100644 index 000000000..e7c6c05bd --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/index.ts @@ -0,0 +1,8 @@ +export { default as GroupChannelThreadHeader } from './component/GroupChannelThreadHeader'; +export { default as GroupChannelThreadMessageList } from './component/GroupChannelThreadMessageList'; +export { default as GroupChannelThreadInput } from './component/GroupChannelThreadInput'; +export { default as GroupChannelThreadSuggestedMentionList } from './component/GroupChannelThreadSuggestedMentionList'; +export { default as GroupChannelThreadStatusEmpty } from './component/GroupChannelThreadStatusEmpty'; +export { default as GroupChannelThreadStatusLoading } from './component/GroupChannelThreadStatusLoading'; +export { default as createGroupChannelThreadModule } from './module/createGroupChannelThreadModule'; +export { GroupChannelThreadContextsProvider, GroupChannelThreadContexts } from './module/moduleContext'; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/module/createGroupChannelThreadModule.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/module/createGroupChannelThreadModule.tsx new file mode 100644 index 000000000..211a230fe --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/module/createGroupChannelThreadModule.tsx @@ -0,0 +1,35 @@ +import GroupChannelThreadHeader from '../component/GroupChannelThreadHeader'; +import GroupChannelThreadInput from '../component/GroupChannelThreadInput'; +import GroupChannelThreadMessageList from '../component/GroupChannelThreadMessageList'; +import GroupChannelThreadStatusEmpty from '../component/GroupChannelThreadStatusEmpty'; +import GroupChannelThreadStatusLoading from '../component/GroupChannelThreadStatusLoading'; +import GroupChannelThreadSuggestedMentionList from '../component/GroupChannelThreadSuggestedMentionList'; +import type { GroupChannelThreadModule } from '../types'; +import { GroupChannelThreadContextsProvider } from './moduleContext'; +import GroupChannelThreadParentMessageInfo from '../component/GroupChannelThreadParentMessageInfo'; + +const createGroupChannelThreadModule = ({ + Header = GroupChannelThreadHeader, + ParentMessageInfo = GroupChannelThreadParentMessageInfo, + MessageList = GroupChannelThreadMessageList, + Input = GroupChannelThreadInput, + SuggestedMentionList = GroupChannelThreadSuggestedMentionList, + StatusLoading = GroupChannelThreadStatusLoading, + StatusEmpty = GroupChannelThreadStatusEmpty, + Provider = GroupChannelThreadContextsProvider, + ...module + }: Partial = {}): GroupChannelThreadModule => { + return { + Header, + ParentMessageInfo, + MessageList, + Input, + SuggestedMentionList, + StatusEmpty, + StatusLoading, + Provider, + ...module, + }; +}; + +export default createGroupChannelThreadModule; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx new file mode 100644 index 000000000..d5ba44636 --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx @@ -0,0 +1,161 @@ +import React, { createContext, useCallback, useRef, useState } from 'react'; +import type { FlatList } from 'react-native'; + +import { + ContextValue, + Logger, + NOOP, + SendbirdFileMessage, + SendbirdGroupChannel, + SendbirdMessage, + SendbirdUserMessage, + useFreshCallback, +} from '@sendbird/uikit-utils'; + +import ProviderLayout from '../../../components/ProviderLayout'; +import { useLocalization } from '../../../hooks/useContext'; +import type { PubSub } from '../../../utils/pubsub'; +import type { GroupChannelThreadContextsType, GroupChannelThreadModule, GroupChannelThreadPubSubContextPayload } from '../types'; +import { GroupChannelThreadProps } from '../types'; + +export const GroupChannelThreadContexts: GroupChannelThreadContextsType = { + Fragment: createContext({ + headerTitle: '', + channel: {} as SendbirdGroupChannel, + parentMessage: {} as SendbirdUserMessage | SendbirdFileMessage, + setMessageToEdit: NOOP, + }), + PubSub: createContext({ + publish: NOOP, + subscribe: () => NOOP, + } as PubSub), + MessageList: createContext({ + flatListRef: { current: null }, + scrollToMessage: () => false, + lazyScrollToBottom: () => { + // noop + }, + lazyScrollToIndex: () => { + // noop + }, + } as MessageListContextValue), +}; + +export const GroupChannelThreadContextsProvider: GroupChannelThreadModule['Provider'] = ({ + children, + channel, + parentMessage, + keyboardAvoidOffset = 0, + groupChannelThreadPubSub, + threadedMessages, + }) => { + if (!channel) throw new Error('GroupChannel is not provided to GroupChannelThreadModule'); + + const { STRINGS } = useLocalization(); + const [messageToEdit, setMessageToEdit] = useState(); + const { flatListRef, lazyScrollToIndex, lazyScrollToBottom, scrollToMessage } = useScrollActions({ + threadedMessages: threadedMessages, + }); + + return ( + + setMessageToEdit(message), []), + }} + > + + + {children} + + + + + ); +}; + +type MessageListContextValue = ContextValue; +const useScrollActions = (params: Pick) => { + const { threadedMessages } = params; + const flatListRef = useRef>(null); + + // FIXME: Workaround, should run after data has been applied to UI. + const lazyScrollToBottom = useFreshCallback((params) => { + if (!flatListRef.current) { + logFlatListRefWarning(); + return; + } + + setTimeout(() => { + if (flatListRef.current) { + flatListRef.current.scrollToOffset({ offset: 0, animated: params?.animated ?? false }); + } + }, params?.timeout ?? 0); + }); + + // FIXME: Workaround, should run after data has been applied to UI. + const lazyScrollToIndex = useFreshCallback((params) => { + if (!flatListRef.current) { + logFlatListRefWarning(); + return; + } + + setTimeout(() => { + if (flatListRef.current) { + flatListRef.current.scrollToIndex({ + index: params?.index ?? 0, + animated: params?.animated ?? false, + viewPosition: params?.viewPosition ?? 0.5, + }); + } + }, params?.timeout ?? 0); + }); + + const scrollToMessage = useFreshCallback((messageId, options) => { + if (!flatListRef.current) { + logFlatListRefWarning(); + return false; + } + + const foundMessageIndex = threadedMessages.findIndex((it) => it.messageId === messageId); + const isIncludedInList = foundMessageIndex > -1; + + if (isIncludedInList) { + lazyScrollToIndex({ + index: foundMessageIndex, + animated: true, + timeout: 0, + viewPosition: options?.viewPosition, + }); + return true; + } else { + return false; + } + }); + + return { + flatListRef, + lazyScrollToIndex, + lazyScrollToBottom, + scrollToMessage, + }; +}; + +const logFlatListRefWarning = () => { + Logger.warn( + 'Cannot find flatListRef.current, please render FlatList and pass the flatListRef' + + 'or please try again after FlatList has been rendered.', + ); +}; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts new file mode 100644 index 000000000..b61c9a2d6 --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts @@ -0,0 +1,202 @@ +import type React from 'react'; +import type { FlatList } from 'react-native'; + +import type { MessageCollectionParams, MessageFilterParams } from '@sendbird/chat/groupChannel'; +import type { UseGroupChannelMessagesOptions } from '@sendbird/uikit-chat-hooks'; +import type { + OnBeforeHandler, + PickPartial, + SendbirdFileMessage, + SendbirdFileMessageCreateParams, + SendbirdFileMessageUpdateParams, + SendbirdGroupChannel, + SendbirdMessage, + SendbirdUserMessage, + SendbirdUserMessageCreateParams, + SendbirdUserMessageUpdateParams, +} from '@sendbird/uikit-utils'; + +import type { ChannelInputProps, SuggestedMentionListProps } from '../../components/ChannelInput'; +import type { ChannelMessageListProps } from '../../components/ChannelMessageList'; +import type { CommonComponent } from '../../types'; +import type { PubSub } from '../../utils/pubsub'; + +export type MessageListQueryParamsType = Omit & MessageFilterParams; + +export interface GroupChannelThreadProps { + Fragment: { + channel: SendbirdGroupChannel; + parentMessage: SendbirdUserMessage | SendbirdFileMessage; + startingPoint?: number; + onParentMessageDeleted: () => void; + onChannelDeleted: () => void; + onPressHeaderLeft: GroupChannelThreadProps['Header']['onPressHeaderLeft']; + onPressMediaMessage?: GroupChannelThreadProps['MessageList']['onPressMediaMessage']; + + onBeforeSendUserMessage?: OnBeforeHandler; + onBeforeSendFileMessage?: OnBeforeHandler; + onBeforeUpdateUserMessage?: OnBeforeHandler; + onBeforeUpdateFileMessage?: OnBeforeHandler; + + renderMessage?: GroupChannelThreadProps['MessageList']['renderMessage']; + renderNewMessagesButton?: GroupChannelThreadProps['MessageList']['renderNewMessagesButton']; + renderScrollToBottomButton?: GroupChannelThreadProps['MessageList']['renderScrollToBottomButton']; + + enableMessageGrouping?: GroupChannelThreadProps['MessageList']['enableMessageGrouping']; + + keyboardAvoidOffset?: GroupChannelThreadProps['Provider']['keyboardAvoidOffset']; + flatListProps?: GroupChannelThreadProps['MessageList']['flatListProps']; + sortComparator?: UseGroupChannelMessagesOptions['sortComparator']; + searchItem?: GroupChannelThreadProps['MessageList']['searchItem']; + + /** + * @description You can specify the query parameters for the message list. + * @example + * ``` + * + * ``` + * */ + messageListQueryParams?: MessageListQueryParamsType; + /** @deprecated Please use `messageListQueryParams` instead */ + collectionCreator?: UseGroupChannelMessagesOptions['collectionCreator']; + }; + Header: { + onPressHeaderLeft: () => void; + }; + ParentMessageInfo: { + channel: SendbirdGroupChannel, + currentUserId?: string; + onPressContextMenu?: () => void; + onDeleteMessage: (message: SendbirdUserMessage | SendbirdFileMessage) => Promise; + onPressMediaMessage?: (message: SendbirdFileMessage, deleteMessage: () => Promise, uri: string) => void; + }; + MessageList: Pick< + ChannelMessageListProps, + | 'enableMessageGrouping' + | 'currentUserId' + | 'channel' + | 'messages' + | 'newMessages' + | 'scrolledAwayFromBottom' + | 'onScrolledAwayFromBottom' + | 'onTopReached' + | 'onBottomReached' + | 'onResendFailedMessage' + | 'onDeleteMessage' + | 'onPressMediaMessage' + | 'renderMessage' + | 'renderNewMessagesButton' + | 'renderScrollToBottomButton' + | 'flatListProps' + | 'hasNext' + | 'searchItem' + > & { + onResetMessageList: () => Promise; + onResetMessageListWithStartingPoint: (startingPoint: number) => Promise; + startingPoint?: number; + }; + Input: PickPartial< + ChannelInputProps, + | 'shouldRenderInput' + | 'onPressSendUserMessage' + | 'onPressSendFileMessage' + | 'onPressUpdateUserMessage' + | 'onPressUpdateFileMessage' + | 'SuggestedMentionList' + | 'AttachmentsButton', + 'inputDisabled' + >; + + SuggestedMentionList: SuggestedMentionListProps; + Provider: { + channel: SendbirdGroupChannel; + keyboardAvoidOffset?: number; + groupChannelThreadPubSub: PubSub; + parentMessage: SendbirdUserMessage | SendbirdFileMessage; + threadedMessages: SendbirdMessage[]; + }; +} + +/** + * Internal context for GroupChannelThread + * For example, the developer can create a custom header + * with getting data from the domain context + * */ +export interface GroupChannelThreadContextsType { + Fragment: React.Context<{ + headerTitle: string; + keyboardAvoidOffset?: number; + channel: SendbirdGroupChannel; + parentMessage: SendbirdUserMessage | SendbirdFileMessage; + messageToEdit?: SendbirdUserMessage | SendbirdFileMessage; + setMessageToEdit: (msg?: SendbirdUserMessage | SendbirdFileMessage) => void; + }>; + PubSub: React.Context>; + MessageList: React.Context<{ + /** + * ref object for FlatList of MessageList + * */ + flatListRef: React.MutableRefObject; + /** + * Function that scrolls to a message within a group channel. + * @param messageId {number} - The id of the message to scroll. + * @param options {object} - Scroll options (optional). + * @param options.focusAnimated {boolean} - Enable a shake animation on the message component upon completion of scrolling. + * @param options.viewPosition {number} - Position information to adjust the visible area during scrolling. bottom(0) ~ top(1.0) + * + * @example + * ``` + * const { scrollToMessage } = useContext(GroupChannelThreadContexts.MessageList); + * const messageIncludedInMessageList = scrollToMessage(lastMessage.messageId, { focusAnimated: true, viewPosition: 1 }); + * if (!messageIncludedInMessageList) console.warn('Message not found in the message list.'); + * ``` + * */ + scrollToMessage: (messageId: number, options?: { focusAnimated?: boolean; viewPosition?: number }) => boolean; + /** + * Call the FlatList function asynchronously to scroll to bottom lazily + * to avoid scrolling before data rendering has been committed. + * */ + lazyScrollToBottom: (params?: { animated?: boolean; timeout?: number }) => void; + /** + * Call the FlatList function asynchronously to scroll to index lazily. + * to avoid scrolling before data rendering has been committed. + * */ + lazyScrollToIndex: (params?: { + index?: number; + animated?: boolean; + timeout?: number; + viewPosition?: number; + }) => void; + }>; +} + +export interface GroupChannelThreadModule { + Provider: CommonComponent; + Header: CommonComponent; + ParentMessageInfo: CommonComponent; + MessageList: CommonComponent; + Input: CommonComponent; + SuggestedMentionList: CommonComponent; + StatusEmpty: CommonComponent; + StatusLoading: CommonComponent; +} + +export type GroupChannelThreadFragment = React.FC; + +export type GroupChannelThreadPubSubContextPayload = + | { + type: 'MESSAGE_SENT_PENDING' | 'MESSAGE_SENT_SUCCESS'; + data: { + message: SendbirdUserMessage | SendbirdFileMessage; + }; +} + | { + type: 'MESSAGES_RECEIVED' | 'MESSAGES_UPDATED'; + data: { + messages: SendbirdMessage[]; + }; +} + | { + type: 'TYPING_BUBBLE_RENDERED'; + data?: undefined; +}; diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index c48ced2c8..fc8204b8a 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -4,7 +4,7 @@ import { MessageCollection, MessageFilter } from '@sendbird/chat/groupChannel'; import { ReplyType } from '@sendbird/chat/message'; import { Box } from '@sendbird/uikit-react-native-foundation'; import { useGroupChannelMessages } from '@sendbird/uikit-tools'; -import type { SendbirdFileMessage, SendbirdGroupChannel, SendbirdUserMessage } from '@sendbird/uikit-utils'; +import type { SendbirdFileMessage, SendbirdGroupChannel, SendbirdSendableMessage, SendbirdUserMessage } from '@sendbird/uikit-utils'; import { NOOP, PASS, @@ -45,6 +45,7 @@ const createGroupChannelFragment = (initModule?: Partial): G onPressHeaderRight = NOOP, onPressMediaMessage = NOOP, onChannelDeleted = NOOP, + onPressReplyMessageInThread = NOOP, onBeforeSendUserMessage = PASS, onBeforeSendFileMessage = PASS, onBeforeUpdateUserMessage = PASS, @@ -70,7 +71,7 @@ const createGroupChannelFragment = (initModule?: Partial): G if (sbOptions.uikit.groupChannel.channel.replyType === 'none') return ReplyType.NONE; else return ReplyType.ONLY_REPLY_TO_CHANNEL; }); - + const { loading, messages, @@ -120,6 +121,10 @@ const createGroupChannelFragment = (initModule?: Partial): G onPressMediaMessage(message, deleteMessage, uri); }, ); + const _onPressReplyMessageInThread = useFreshCallback(async (message: SendbirdSendableMessage, startingPoint?: number) => { + await onBlurFragment(); + onPressReplyMessageInThread(message, startingPoint); + }); useEffect(() => { return () => { @@ -208,6 +213,7 @@ const createGroupChannelFragment = (initModule?: Partial): G keyboardAvoidOffset={keyboardAvoidOffset} messages={messages} onUpdateSearchItem={onUpdateSearchItem} + onPressReplyMessageInThread={_onPressReplyMessageInThread} > ): GroupChannelThreadFragment => { + const GroupChannelThreadModule = createGroupChannelThreadModule(initModule); + + return ({ + renderNewMessagesButton = (props) => , + renderScrollToBottomButton = (props) => , + renderMessage, + enableMessageGrouping = true, + onPressHeaderLeft = NOOP, + onPressMediaMessage = NOOP, + onParentMessageDeleted = NOOP, + onChannelDeleted = NOOP, + onBeforeSendUserMessage = PASS, + onBeforeSendFileMessage = PASS, + onBeforeUpdateUserMessage = PASS, + onBeforeUpdateFileMessage = PASS, + channel, + parentMessage, + startingPoint, + keyboardAvoidOffset, + sortComparator = messageComparator, + flatListProps, + }) => { + const { playerService, recorderService } = usePlatformService(); + const { sdk, currentUser, sbOptions } = useSendbirdChat(); + + const [groupChannelThreadPubSub] = useState(() => pubsub()); + const [scrolledAwayFromBottom, setScrolledAwayFromBottom] = useState(false); + const scrolledAwayFromBottomRef = useRefTracker(scrolledAwayFromBottom); + + const toast = useToast(); + const { STRINGS } = useLocalization(); + const [_parentMessage, setParentMessage] = useState(parentMessage); + + const { + loading, + messages, + newMessages, + resetNewMessages, + loadNext, + loadPrevious, + hasNext, + sendFileMessage, + sendUserMessage, + updateFileMessage, + updateUserMessage, + resendMessage, + deleteMessage, + resetWithStartingPoint, + } = useGroupChannelThreadMessages(sdk, channel, _parentMessage, { + shouldCountNewMessages: () => scrolledAwayFromBottomRef.current, + onMessagesReceived(messages) { + groupChannelThreadPubSub.publish({ type: 'MESSAGES_RECEIVED', data: { messages } }); + }, + onMessagesUpdated(messages) { + groupChannelThreadPubSub.publish({ type: 'MESSAGES_UPDATED', data: { messages } }); + }, + onParentMessageUpdated(parentMessage) { + setParentMessage(parentMessage); + }, + onParentMessageDeleted: () => { + toast.show(STRINGS.TOAST.THREAD_PARENT_MESSAGE_DELETED_ERROR, 'error'); + onParentMessageDeleted?.(); + }, + onChannelDeleted, + onCurrentUserBanned: onChannelDeleted, + sortComparator, + markAsRead: confirmAndMarkAsRead, + isReactionEnabled: sbOptions.uikit.groupChannel.channel.enableReactions, + startingPoint, + }); + + const onBlurFragment = () => { + return Promise.allSettled([playerService.reset(), recorderService.reset()]); + }; + const _onPressHeaderLeft = useFreshCallback(async () => { + await onBlurFragment(); + onPressHeaderLeft(); + }); + const _onPressMediaMessage: NonNullable = useFreshCallback( + async (message, deleteMessage, uri) => { + await onBlurFragment(); + onPressMediaMessage(message, deleteMessage, uri); + }, + ); + + useEffect(() => { + return () => { + onBlurFragment(); + }; + }, []); + + const renderItem: GroupChannelThreadProps['MessageList']['renderMessage'] = useFreshCallback((props) => { + const content = renderMessage ? renderMessage(props) : ; + return ( + + {content} + + ); + }); + + const memoizedFlatListProps = useMemo( + () => ({ + ListEmptyComponent: , + contentContainerStyle: { flexGrow: 1 }, + ...flatListProps, + }), + [flatListProps], + ); + + const onResetMessageList = useCallback(async () => { + return await resetWithStartingPoint(Number.MAX_SAFE_INTEGER); + }, []); + + const onResetMessageListWithStartingPoint = useCallback(async (startingPoint: number) => { + return await resetWithStartingPoint(startingPoint); + }, []); + + + const onPending = (message: SendbirdFileMessage | SendbirdUserMessage) => { + groupChannelThreadPubSub.publish({ type: 'MESSAGE_SENT_PENDING', data: { message } }); + }; + + const onSent = (message: SendbirdFileMessage | SendbirdUserMessage) => { + groupChannelThreadPubSub.publish({ type: 'MESSAGE_SENT_SUCCESS', data: { message } }); + }; + + const onPressSendUserMessage: GroupChannelThreadProps['Input']['onPressSendUserMessage'] = useFreshCallback( + async (params) => { + const processedParams = await onBeforeSendUserMessage(params); + const message = await sendUserMessage(processedParams, onPending); + onSent(message); + }, + ); + const onPressSendFileMessage: GroupChannelThreadProps['Input']['onPressSendFileMessage'] = useFreshCallback( + async (params) => { + const processedParams = await onBeforeSendFileMessage(params); + const message = await sendFileMessage(processedParams, onPending); + onSent(message); + }, + ); + const onPressUpdateUserMessage: GroupChannelThreadProps['Input']['onPressUpdateUserMessage'] = useFreshCallback( + async (message, params) => { + const processedParams = await onBeforeUpdateUserMessage(params); + await updateUserMessage(message.messageId, processedParams); + }, + ); + const onPressUpdateFileMessage: GroupChannelThreadProps['Input']['onPressUpdateFileMessage'] = useFreshCallback( + async (message, params) => { + const processedParams = await onBeforeUpdateFileMessage(params); + await updateFileMessage(message.messageId, processedParams); + }, + ); + const onScrolledAwayFromBottom = useFreshCallback((value: boolean) => { + if (!value) resetNewMessages(); + setScrolledAwayFromBottom(value); + }); + + return ( + + + + }> + + + + + ); + }; +}; + +function shouldRenderInput(channel: SendbirdGroupChannel) { + if (channel.isBroadcast) { + return channel.myRole === 'operator'; + } + + return true; +} + +export default createGroupChannelThreadFragment; diff --git a/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useChannelThreadMessagesReducer.ts b/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useChannelThreadMessagesReducer.ts new file mode 100644 index 000000000..97dfd467a --- /dev/null +++ b/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useChannelThreadMessagesReducer.ts @@ -0,0 +1,247 @@ +import { useMemo, useReducer } from 'react'; + +import type { SendableMessage } from '@sendbird/chat/lib/__definition'; +import type { BaseMessage } from '@sendbird/chat/message'; +import { SendingStatus } from '@sendbird/chat/message'; + +import type { SendbirdMessage } from '@sendbird/uikit-tools/src/types'; +import { getMessageUniqId, isMyMessage, isNewMessage, isSendableMessage } from '@sendbird/uikit-tools'; + +export function arrayToMapWithGetter(arr: T[], getSelector: (item: T) => string) { + return arr.reduce((accum, curr) => { + const _key = getSelector(curr); + accum[_key] = curr; + return accum; + }, {} as Record); +} + +type Action = + | { + type: 'update_initialized' | 'update_loading' | 'update_refreshing' | 'update_has_previous' | 'update_has_next'; + value: { status: boolean }; + } + | { + type: 'update_messages' | 'update_new_messages'; + value: { messages: BaseMessage[]; clearBeforeAction: boolean; currentUserId?: string }; + } + | { + type: 'delete_messages' | 'delete_new_messages'; + value: { messageIds: number[]; reqIds: string[] }; + }; + +type State = { + initialized: boolean; + loading: boolean; + refreshing: boolean; + hasPreviousMessages: boolean, + hasNextMessages: boolean, + messageMap: Record; + newMessageMap: Record; +}; + +const defaultReducer = ({ ...draft }: State, action: Action) => { + switch (action.type) { + case 'update_initialized': { + draft['initialized'] = action.value.status; + return draft; + } + case 'update_refreshing': { + draft['refreshing'] = action.value.status; + return draft; + } + case 'update_loading': { + draft['loading'] = action.value.status; + return draft; + } + case 'update_has_previous': { + draft['hasPreviousMessages'] = action.value.status; + return draft; + } + case 'update_has_next': { + draft['hasNextMessages'] = action.value.status; + return draft; + } + case 'update_messages': { + const userId = action.value.currentUserId; + + if (action.value.clearBeforeAction) { + draft['messageMap'] = messagesToObject(action.value.messages); + } else { + // Filtering meaningless message updates + const nextMessages = action.value.messages.filter((next) => { + if (isMyMessage(next, userId)) { + const prev = draft['messageMap'][next.reqId] ?? draft['messageMap'][next.messageId]; + if (isMyMessage(prev, userId)) { + const shouldUpdate = shouldUpdateMessage(prev, next); + if (shouldUpdate) { + // Remove existing messages before update to prevent duplicate display + delete draft['messageMap'][prev.reqId]; + delete draft['messageMap'][prev.messageId]; + } + return shouldUpdate; + } + } + return true; + }); + + const obj = messagesToObject(nextMessages); + draft['messageMap'] = { ...draft['messageMap'], ...obj }; + } + + return draft; + } + case 'update_new_messages': { + const userId = action.value.currentUserId; + const newMessages = action.value.messages.filter((it) => isNewMessage(it, userId)); + + if (action.value.clearBeforeAction) { + draft['newMessageMap'] = arrayToMapWithGetter(newMessages, getMessageUniqId); + } else { + // Remove existing messages before update to prevent duplicate display + const messageKeys = newMessages.map((it) => it.messageId); + messageKeys.forEach((key) => delete draft['newMessageMap'][key]); + + draft['newMessageMap'] = { + ...draft['newMessageMap'], + ...arrayToMapWithGetter(newMessages, getMessageUniqId), + }; + } + + return draft; + } + case 'delete_messages': + case 'delete_new_messages': { + const key = action.type === 'delete_messages' ? 'messageMap' : 'newMessageMap'; + draft[key] = { ...draft[key] }; + action.value.messageIds.forEach((msgId) => { + const message = draft[key][msgId]; + if (message) { + if (isSendableMessage(message)) delete draft[key][message.reqId]; + delete draft[key][message.messageId]; + } + }); + action.value.reqIds.forEach((reqId) => { + const message = draft[key][reqId]; + if (message) { + if (isSendableMessage(message)) delete draft[key][message.reqId]; + delete draft[key][message.messageId]; + } + }); + + return draft; + } + } +}; + +const messagesToObject = (messages: BaseMessage[]) => { + return messages.reduce((accum, curr) => { + if (isSendableMessage(curr)) { + accum[curr.reqId] = curr; + if (curr.sendingStatus === SendingStatus.SUCCEEDED) { + accum[curr.messageId] = curr; + } + } else { + accum[curr.messageId] = curr; + } + return accum; + }, {} as Record); +}; + +const shouldUpdateMessage = (prev: SendableMessage, next: SendableMessage) => { + // message data update (e.g. reactions) + if (prev.sendingStatus === SendingStatus.SUCCEEDED) return next.sendingStatus === SendingStatus.SUCCEEDED; + + // message sending status update + return prev.sendingStatus !== next.sendingStatus; +}; + +const getOldestMessageTimeStamp = (messages: BaseMessage[]) => { + return messages.reduce((accum, curr) => { + return Math.min(accum, curr.createdAt); + }, Number.MAX_SAFE_INTEGER); +}; + +const getLatestMessageTimeStamp = (messages: BaseMessage[]) => { + return messages.reduce((accum, curr) => { + return Math.max(accum, curr.createdAt); + }, Number.MIN_SAFE_INTEGER); +}; + +export const useChannelThreadMessagesReducer = (sortComparator = defaultMessageComparator) => { + const [{ initialized, loading, refreshing, hasPreviousMessages, hasNextMessages, messageMap, newMessageMap }, dispatch] = useReducer(defaultReducer, { + initialized: false, + loading: true, + refreshing: false, + hasPreviousMessages: false, + hasNextMessages: false, + messageMap: {}, + newMessageMap: {}, + }); + + const updateMessages = (messages: BaseMessage[], clearBeforeAction: boolean, currentUserId?: string) => { + dispatch({ type: 'update_messages', value: { messages, clearBeforeAction, currentUserId } }); + }; + const deleteMessages = (messageIds: number[], reqIds: string[]) => { + dispatch({ type: 'delete_messages', value: { messageIds, reqIds } }); + }; + const updateNewMessages = (messages: BaseMessage[], clearBeforeAction: boolean, currentUserId?: string) => { + dispatch({ type: 'update_new_messages', value: { messages, clearBeforeAction, currentUserId } }); + }; + const deleteNewMessages = (messageIds: number[], reqIds: string[]) => { + dispatch({ type: 'delete_new_messages', value: { messageIds, reqIds } }); + }; + const updateInitialized = (status: boolean) => { + dispatch({ type: 'update_initialized', value: { status } }); + }; + const updateLoading = (status: boolean) => { + dispatch({ type: 'update_loading', value: { status } }); + }; + const updateRefreshing = (status: boolean) => { + dispatch({ type: 'update_refreshing', value: { status } }); + }; + const updateHasPreviousMessages = (status: boolean) => { + dispatch({ type: 'update_has_previous', value: { status } }); + }; + const updateHasNextMessages = (status: boolean) => { + dispatch({ type: 'update_has_next', value: { status } }); + }; + + const newMessages = Object.values(newMessageMap); + const messages = useMemo(() => Array.from(new Set(Object.values(messageMap))).sort(sortComparator), [messageMap]); + const oldestMessageTimeStamp = getOldestMessageTimeStamp(messages); + const latestMessageTimeStamp = getLatestMessageTimeStamp(messages); + + return { + updateInitialized, + updateLoading, + updateRefreshing, + updateMessages, + deleteMessages, + updateHasPreviousMessages, + updateHasNextMessages, + + initialized, + loading, + refreshing, + hasPreviousMessages, + hasNextMessages, + oldestMessageTimeStamp, + latestMessageTimeStamp, + messages, + + newMessages, + updateNewMessages, + deleteNewMessages, + }; +}; + +const LARGE_OFFSET = Math.floor(Number.MAX_SAFE_INTEGER / 10); +export function defaultMessageComparator(a: SendbirdMessage, b: SendbirdMessage) { + let aStatusOffset = 0; + let bStatusOffset = 0; + + if (isSendableMessage(a) && a.sendingStatus !== 'succeeded') aStatusOffset = LARGE_OFFSET; + if (isSendableMessage(b) && b.sendingStatus !== 'succeeded') bStatusOffset = LARGE_OFFSET; + + return a.createdAt + aStatusOffset - (b.createdAt + bStatusOffset); +} diff --git a/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useGroupChannelThreadMessages.ts b/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useGroupChannelThreadMessages.ts new file mode 100644 index 000000000..3c1c26ac2 --- /dev/null +++ b/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useGroupChannelThreadMessages.ts @@ -0,0 +1,839 @@ +import { useEffect, useLayoutEffect, useRef } from 'react'; + +import { CollectionEventSource, type SendbirdChatWith } from '@sendbird/chat'; +import type { + GroupChannel, + GroupChannelModule, + MessageCollection, +} from '@sendbird/chat/groupChannel'; +import { MessageCollectionInitPolicy, MessageFilter } from '@sendbird/chat/groupChannel'; +import { + BaseMessage, + FileMessage, + FileMessageCreateParams, + FileMessageUpdateParams, + MessageRequestHandler, + MultipleFilesMessage, + MultipleFilesMessageCreateParams, + MultipleFilesMessageRequestHandler, + UserMessage, + UserMessageCreateParams, + UserMessageUpdateParams, +} from '@sendbird/chat/message'; + +import { ReplyType } from '@sendbird/chat/message'; + +import { sbuConstants } from '@sendbird/uikit-tools'; +import type { SendbirdMessage } from '@sendbird/uikit-tools/src/types'; +import { isDifferentChannel } from '@sendbird/uikit-tools'; +import { isMyMessage, isSendableMessage } from '@sendbird/uikit-tools'; +import { isNotEmptyArray, useForceUpdate } from '@sendbird/uikit-tools'; +import { useGroupChannelHandler, usePreservedCallback } from '@sendbird/uikit-tools'; +import { useChannelThreadMessagesReducer } from './useChannelThreadMessagesReducer'; +import { ThreadedMessageListParams } from '@sendbird/chat/lib/__definition'; +import { SendbirdFileMessage, SendbirdUserMessage, useAsyncEffect } from '@sendbird/uikit-utils'; + +type Log = (...args: unknown[]) => void; +type UseGroupChannelThreadMessagesOptions = { + markAsRead?: (channels: GroupChannel[]) => void; + shouldCountNewMessages?: () => boolean; + sortComparator?: (a: SendbirdMessage, b: SendbirdMessage) => number; + isReactionEnabled?: boolean; + startingPoint?: number; + + onMessagesReceived?: (messages: SendbirdMessage[]) => void; + onMessagesUpdated?: (messages: SendbirdMessage[]) => void; + onParentMessageUpdated?: (messages: SendbirdUserMessage | SendbirdFileMessage) => void; + onParentMessageDeleted?: () => void; + onChannelDeleted?: (channelUrl: string) => void; + onChannelUpdated?: (channel: GroupChannel) => void; + onCurrentUserBanned?: () => void; + + logger?: { info?: Log; warn?: Log; error?: Log; log?: Log; debug?: Log }; +}; + +function isThreadedMessage(message: BaseMessage, parentMessage: BaseMessage) { + return message.parentMessageId === parentMessage.messageId; +} + +/** + * group channel thread messages hook + * - Receive new messages from other users & should count new messages -> append to state(newMessages) + * - onTopReached -> prev() -> fetch prev messages and append to state(messages) + * - onBottomReached -> next() -> fetch next messages and append to state(messages) + * */ +export const useGroupChannelThreadMessages = ( + sdk: SendbirdChatWith<[GroupChannelModule]>, + channel: GroupChannel, + parentMessage: FileMessage | UserMessage, + options: UseGroupChannelThreadMessagesOptions = {}, +) => { + const internalOptions = useRef(options); // to keep reference of options in event handler + internalOptions.current = options; + + const channelRef = useRef(channel); // to keep reference of channel in event handler + channelRef.current = channel; + + const parentMessageRef = useRef(parentMessage); // to keep reference of parent message in event handler + parentMessageRef.current = parentMessage; + + const startingPoint = useRef(internalOptions.current?.startingPoint || Number.MAX_SAFE_INTEGER); + startingPoint.current = internalOptions.current?.startingPoint || Number.MAX_SAFE_INTEGER; + + const prevFetchSize = sbuConstants.collection.message.defaultLimit.prev; + const nextFetchSize = sbuConstants.collection.message.defaultLimit.next; + + const logger = internalOptions.current.logger; + const isFetching = useRef({ prev: false, next: false }); + const forceUpdate = useForceUpdate(); + const collectionRef = useRef<{ initialized: boolean; apiInitialized: boolean; instance: MessageCollection | null }>({ + initialized: false, + apiInitialized: false, + instance: null, + }); + + const { + initialized, + loading, + refreshing, + hasPreviousMessages, + hasNextMessages, + oldestMessageTimeStamp, + latestMessageTimeStamp, + messages, + newMessages, + updateMessages, + updateNewMessages, + deleteNewMessages, + deleteMessages, + updateInitialized, + updateLoading, + updateRefreshing, + updateHasPreviousMessages, + updateHasNextMessages, + } = useChannelThreadMessagesReducer(options?.sortComparator); + + const markAsReadBySource = usePreservedCallback((source?: CollectionEventSource) => { + if (!channelRef.current || !channelRef.current.url) { return logger?.error?.('[useGroupChannelThreadMessages] channel is required'); } + + try { + switch (source) { + case CollectionEventSource.EVENT_MESSAGE_RECEIVED: + case CollectionEventSource.EVENT_MESSAGE_SENT_SUCCESS: + case CollectionEventSource.SYNC_MESSAGE_FILL: + case undefined: + internalOptions.current.markAsRead?.([channelRef.current]); + break; + } + } catch (e) { + logger?.warn?.('[useGroupChannelThreadMessages/markAsReadBySource]', e); + } + }); + + const updateNewMessagesReceived = usePreservedCallback((source: CollectionEventSource, messages: BaseMessage[]) => { + const incomingMessages = messages.filter( + (it) => isThreadedMessage(it, parentMessageRef.current) && !isMyMessage(it, sdk.currentUser?.userId), + ); + + if (incomingMessages.length > 0) { + switch (source) { + case CollectionEventSource.EVENT_MESSAGE_RECEIVED: + case CollectionEventSource.SYNC_MESSAGE_FILL: { + if (internalOptions.current.shouldCountNewMessages?.()) { + updateNewMessages(incomingMessages, false, sdk.currentUser?.userId); + } + internalOptions.current.onMessagesReceived?.(incomingMessages); + break; + } + } + } + }); + + useAsyncEffect(async () => { + const messages = await channelRef.current?.getMessagesByMessageId(parentMessageRef.current?.messageId, { + prevResultSize: 1, + nextResultSize: 1, + isInclusive: true, + includeThreadInfo: true, + includeMetaArray: true, + includeReactions: internalOptions.current.isReactionEnabled ?? false, + }); + + const parentMessage = messages?.find((message) => { + return message.messageId === parentMessageRef.current.messageId; + }) as FileMessage | UserMessage; + if (parentMessage) { + parentMessageRef.current = parentMessage; + internalOptions.current.onParentMessageUpdated?.(parentMessage); + } + }, []); + + const init = usePreservedCallback(async (startingPoint: number) => { + return new Promise((resolve) => { + + if (!channelRef.current || !channelRef.current.url) { + return logger?.error?.('[useGroupChannelThreadMessages] channel is required'); + } + + if (!parentMessageRef.current) { + return logger?.error?.('[useGroupChannelThreadMessages] parent message is required'); + } + + if (collectionRef.current.instance) collectionRef.current.instance.dispose(); + + markAsReadBySource(); + updateNewMessages([], true, sdk.currentUser?.userId); + + const updateUnsentMessages = () => { + const { pendingMessages, failedMessages } = collectionRef.current.instance ?? {}; + + let filteredMessages; + if (isNotEmptyArray(pendingMessages)) { + filteredMessages = pendingMessages.filter((message) => + isThreadedMessage(message, parentMessageRef.current), + ); + } + + if (isNotEmptyArray(failedMessages)) { + filteredMessages = failedMessages.filter((message) => + isThreadedMessage(message, parentMessageRef.current), + ); + } + + if (isNotEmptyArray(filteredMessages)) updateMessages(filteredMessages, false, sdk.currentUser?.userId); + }; + + setTimeout(async () => { + try { + if (parentMessageRef.current) { + const params: ThreadedMessageListParams = { + prevResultSize: prevFetchSize, + nextResultSize: nextFetchSize, + isInclusive: true, + includeReactions: internalOptions.current.isReactionEnabled ?? false, + }; + const { threadedMessages } = await parentMessageRef.current.getThreadedMessagesByTimestamp(startingPoint, params); + if (isNotEmptyArray(threadedMessages)) { + const prevMessagesCount = threadedMessages.filter((message) => message?.createdAt < startingPoint).length; + const nextMessagesCount = threadedMessages.filter((message) => message?.createdAt > startingPoint).length; + updateHasPreviousMessages(prevMessagesCount >= prevFetchSize); + updateHasNextMessages(nextMessagesCount >= nextFetchSize); + updateMessages(threadedMessages, true, sdk.currentUser?.userId); + } + } else { + logger?.warn?.('[useGroupChannelThreadMessages] parent message is required'); + } + resolve(); + } catch (error) { + logger?.error?.('[useGroupChannelThreadMessages] Initialize thread list failed.', error); + } + }); + + const collectionInstance = channelRef.current.createMessageCollection({ + prevResultLimit: prevFetchSize, + nextResultLimit: nextFetchSize, + startingPoint: startingPoint - 1, + filter: new MessageFilter({ replyType: ReplyType.ALL }), + }); + + collectionRef.current = { apiInitialized: false, initialized: false, instance: collectionInstance }; + + collectionInstance.setMessageCollectionHandler({ + onMessagesAdded: (ctx, __, messages) => { + const filteredMessages = messages.filter((message) => + isThreadedMessage(message, parentMessageRef.current), + ); + if (isNotEmptyArray(filteredMessages)) { + markAsReadBySource(ctx.source); + updateNewMessagesReceived(ctx.source, filteredMessages); + updateMessages(filteredMessages, false, sdk.currentUser?.userId); + } + }, + onMessagesUpdated: (ctx, __, messages) => { + const filteredMessages = messages.filter((message) => + isThreadedMessage(message, parentMessageRef.current), + ); + + const parentMessage = messages.find((message) => message.messageId === parentMessageRef.current.messageId) as FileMessage | UserMessage; + if (parentMessage) { + parentMessageRef.current = parentMessage; + internalOptions.current.onParentMessageUpdated?.(parentMessage); + } + if (isNotEmptyArray(filteredMessages)) { + markAsReadBySource(ctx.source); + updateNewMessagesReceived(ctx.source, filteredMessages); // NOTE: admin message is not added via onMessagesAdded handler, not checked yet is this a bug. + + updateMessages(filteredMessages, false, sdk.currentUser?.userId); + + if (ctx.source === CollectionEventSource.EVENT_MESSAGE_UPDATED) { + internalOptions.current.onMessagesUpdated?.(filteredMessages); + } + } + }, + onMessagesDeleted: (_, __, ___, messages) => { + const parentMessage = messages.find((message) => message.messageId === parentMessageRef.current.messageId) as FileMessage | UserMessage; + if (parentMessage) { + internalOptions.current.onParentMessageDeleted?.(); + } + + const filteredMessages = messages.filter((message) => + isThreadedMessage(message, parentMessageRef.current), + ); + if (isNotEmptyArray(filteredMessages)) { + const msgIds = filteredMessages.map((it) => it.messageId); + const reqIds = filteredMessages.filter(isSendableMessage).map((it) => it.reqId); + deleteMessages(msgIds, reqIds); + deleteNewMessages(msgIds, reqIds); + } + }, + onChannelDeleted: (_, channelUrl) => { + internalOptions.current.onChannelDeleted?.(channelUrl); + }, + onChannelUpdated: (_, channel) => { + forceUpdate(); + internalOptions.current.onChannelUpdated?.(channel); + }, + onHugeGapDetected: () => { + init(startingPoint); + }, + }); + + collectionInstance + .initialize(MessageCollectionInitPolicy.CACHE_AND_REPLACE_BY_API) + .onCacheResult((err, messages) => { + if (err) { + sdk.isCacheEnabled && logger?.error?.('[useGroupChannelThreadMessages/onCacheResult]', err); + } else if (messages) { + logger?.debug?.('[useGroupChannelThreadMessages/onCacheResult]', 'message length:', messages.length); + + const filteredMessages = messages.filter((message) => + isThreadedMessage(message, parentMessageRef.current), + ); + if (isNotEmptyArray(filteredMessages)) { + updateMessages(filteredMessages, false, sdk.currentUser?.userId); + updateUnsentMessages(); + } + } + }) + .onApiResult((err, messages) => { + if (err) { + logger?.warn?.('[useGroupChannelThreadMessages/onApiResult]', err); + } else if (messages) { + logger?.debug?.('[useGroupChannelThreadMessages/onApiResult]', 'message length:', messages.length); + + const filteredMessages = messages.filter((message) => + isThreadedMessage(message, parentMessageRef.current), + ); + if (isNotEmptyArray(filteredMessages)) { + updateMessages(filteredMessages, false, sdk.currentUser?.userId); + if (sdk.isCacheEnabled) updateUnsentMessages(); + } + } + + collectionRef.current.initialized = true; + collectionRef.current.apiInitialized = true; + }); + }); + }); + + useGroupChannelHandler(sdk, { + onUserBanned(eventChannel, bannedUser) { + if (eventChannel.isGroupChannel() && !isDifferentChannel(eventChannel, channelRef.current)) { + if (bannedUser.userId === sdk.currentUser?.userId) { + internalOptions.current.onCurrentUserBanned?.(); + } else { + forceUpdate(); + } + } + }, + }); + + useLayoutEffect(() => { + const timeout = setTimeout(async () => { + if (sdk.currentUser && channelRef.current) { + updateInitialized(false); + updateLoading(true); + await init(startingPoint.current); + updateLoading(false); + updateInitialized(true); + } + }); + return () => clearTimeout(timeout); + }, [sdk, sdk.currentUser?.userId, channelRef.current?.url, startingPoint.current]); + + useEffect(() => { + return () => { + if (collectionRef.current.instance) collectionRef.current.instance.dispose(); + }; + }, []); + + const refresh = usePreservedCallback(async () => { + if (sdk.currentUser && channelRef.current) { + updateRefreshing(true); + await init(startingPoint.current); + updateRefreshing(false); + } + }); + + const loadPrevious = usePreservedCallback(async () => { + if (!channelRef.current || !channelRef.current.url || !parentMessageRef.current) { + logger?.error?.('[useGroupChannelThreadMessages] channel or parent message is required'); + return; + } + + if (hasPreviousMessages && !isFetching.current.prev) { + try { + isFetching.current.prev = true; + const params: ThreadedMessageListParams = { + prevResultSize: prevFetchSize, + nextResultSize: 0, + isInclusive: false, + includeReactions: internalOptions.current.isReactionEnabled ?? false, + }; + const { threadedMessages } = await parentMessageRef.current.getThreadedMessagesByTimestamp(oldestMessageTimeStamp, params); + if (isNotEmptyArray(threadedMessages)) { + updateHasPreviousMessages(threadedMessages.length >= prevFetchSize); + updateMessages(threadedMessages, false, sdk.currentUser?.userId); + } + } catch (error) { + logger?.error?.('[useGroupChannelThreadMessages] loadPrevious thread list failed.', error); + } finally { + isFetching.current.prev = false; + } + } + }); + + const hasPrevious = usePreservedCallback(() => { return hasPreviousMessages; }); + + const loadNext = usePreservedCallback(async () => { + if (!channelRef.current || !channelRef.current.url || !parentMessageRef.current) { + logger?.error?.('[useGroupChannelThreadMessages] channel or parent message is required'); + return; + } + + if (hasNextMessages && !isFetching.current.next) { + try { + isFetching.current.prev = true; + const params: ThreadedMessageListParams = { + prevResultSize: 0, + nextResultSize: nextFetchSize, + isInclusive: false, + includeReactions: internalOptions.current.isReactionEnabled ?? false, + }; + const { threadedMessages } = await parentMessageRef.current.getThreadedMessagesByTimestamp(latestMessageTimeStamp, params); + updateHasNextMessages(threadedMessages.length >= nextFetchSize); + updateMessages(threadedMessages, false, sdk.currentUser?.userId); + } catch (error) { + logger?.error?.('[useGroupChannelThreadMessages] loadNext thread list failed.', error); + } finally { + isFetching.current.next = false; + } + } + }); + + const hasNext = usePreservedCallback(() => {return hasNextMessages;}); + + const sendUserMessage = usePreservedCallback( + (params: UserMessageCreateParams, onPending: (message: UserMessage) => void): Promise => { + if (!channelRef.current || !channelRef.current.url) { + logger?.error?.('[useGroupChannelThreadMessages] channel is required'); + throw new Error('Channel is required'); + } + + return new Promise((resolve, reject) => { + channelRef.current + .sendUserMessage(params) + .onPending((pendingMessage) => { + if (pendingMessage.channelUrl === channelRef.current.url) { + updateMessages([pendingMessage], false, sdk.currentUser?.userId); + } + onPending?.(pendingMessage as UserMessage); + }) + .onSucceeded((sentMessage) => { + if (sentMessage.channelUrl === channelRef.current.url) { + updateMessages([sentMessage], false, sdk.currentUser?.userId); + } + resolve(sentMessage as UserMessage); + }) + .onFailed((err, failedMessage) => { + if (failedMessage && failedMessage.channelUrl === channelRef.current.url) { + updateMessages([failedMessage], false, sdk.currentUser?.userId); + } + reject(err); + }); + }); + }, + ); + const sendFileMessage = usePreservedCallback( + (params: FileMessageCreateParams, onPending?: (message: FileMessage) => void): Promise => { + if (!channelRef.current || !channelRef.current.url) { + logger?.error?.('[useGroupChannelThreadMessages] channel is required'); + throw new Error('Channel is required'); + } + + return new Promise((resolve, reject) => { + channelRef.current + .sendFileMessage(params) + .onPending((pendingMessage) => { + if (pendingMessage.channelUrl === channelRef.current.url) { + updateMessages([pendingMessage], false, sdk.currentUser?.userId); + } + onPending?.(pendingMessage as FileMessage); + }) + .onSucceeded((sentMessage) => { + if (sentMessage.channelUrl === channelRef.current.url) { + updateMessages([sentMessage], false, sdk.currentUser?.userId); + } + resolve(sentMessage as FileMessage); + }) + .onFailed((err, failedMessage) => { + if (failedMessage && failedMessage.channelUrl === channelRef.current.url) { + updateMessages([failedMessage], false, sdk.currentUser?.userId); + } + reject(err); + }); + }); + }, + ); + const sendFileMessages = usePreservedCallback( + async ( + paramsList: FileMessageCreateParams[], + onPending?: (message: FileMessage) => void, + ): Promise => { + if (!channelRef.current || !channelRef.current.url) { + logger?.error?.('[useGroupChannelThreadMessages] channel is required'); + throw new Error('Channel is required'); + } + + return new Promise((resolve) => { + const messages: FileMessage[] = []; + + channelRef.current + .sendFileMessages(paramsList) + .onPending((pendingMessage) => { + if (pendingMessage.channelUrl === channelRef.current.url) { + updateMessages([pendingMessage], false, sdk.currentUser?.userId); + } + onPending?.(pendingMessage as FileMessage); + }) + .onSucceeded((sentMessage) => { + if (sentMessage.isFileMessage() && sentMessage.channelUrl === channelRef.current.url) { + updateMessages([sentMessage], false, sdk.currentUser?.userId); + messages.push(sentMessage); + } + + if (messages.length === paramsList.length) resolve(messages); + }) + .onFailed((_, failedMessage) => { + if (failedMessage && failedMessage.channelUrl === channelRef.current.url) { + updateMessages([failedMessage], false, sdk.currentUser?.userId); + messages.push(failedMessage as FileMessage); + } else { + // NOTE: Since failedMessage is nullable by type, to resolve the promise, handle pushing null even when there is no failedMessage. + messages.push(null as unknown as FileMessage); + } + + if (messages.length === paramsList.length) resolve(messages); + }); + }); + }, + ); + const sendMultipleFilesMessage = usePreservedCallback( + ( + params: MultipleFilesMessageCreateParams, + onPending?: (message: MultipleFilesMessage) => void, + ): Promise => { + if (!channelRef.current || !channelRef.current.url) { + logger?.error?.('[useGroupChannelThreadMessages] channel is required'); + throw new Error('Channel is required'); + } + + return new Promise((resolve, reject) => { + channelRef.current + .sendMultipleFilesMessage(params) + .onPending((pendingMessage) => { + if (pendingMessage.channelUrl === channelRef.current.url) { + updateMessages([pendingMessage], false, sdk.currentUser?.userId); + } + onPending?.(pendingMessage as MultipleFilesMessage); + }) + .onFileUploaded(() => { + // Just re-render to use updated message.messageParams + forceUpdate(); + }) + .onSucceeded((sentMessage) => { + if (sentMessage.channelUrl === channelRef.current.url) { + updateMessages([sentMessage], false, sdk.currentUser?.userId); + } + resolve(sentMessage as MultipleFilesMessage); + }) + .onFailed((err, failedMessage) => { + if (failedMessage && failedMessage.channelUrl === channelRef.current.url) { + updateMessages([failedMessage], false, sdk.currentUser?.userId); + } + reject(err); + }); + }); + }, + ); + + const updateUserMessage = usePreservedCallback( + async (messageId: number, params: UserMessageUpdateParams): Promise => { + if (!channelRef.current || !channelRef.current.url) { + logger?.error?.('[useGroupChannelThreadMessages] channel is required'); + throw new Error('Channel is required'); + } + + const updatedMessage = await channelRef.current.updateUserMessage(messageId, params); + if (updatedMessage.channelUrl === channelRef.current.url && isThreadedMessage(updatedMessage, parentMessageRef.current)) { + updateMessages([updatedMessage], false, sdk.currentUser?.userId); + } + return updatedMessage; + }, + ); + const updateFileMessage = usePreservedCallback( + async (messageId: number, params: FileMessageUpdateParams): Promise => { + if (!channelRef.current || !channelRef.current.url) { + logger?.error?.('[useGroupChannelThreadMessages] channel is required'); + throw new Error('Channel is required'); + } + + const updatedMessage = await channelRef.current.updateFileMessage(messageId, params); + if (updatedMessage.channelUrl === channelRef.current.url && isThreadedMessage(updatedMessage, parentMessageRef.current)) { + updateMessages([updatedMessage], false, sdk.currentUser?.userId); + } + return updatedMessage; + }, + ); + + const resendMessage = usePreservedCallback( + async (failedMessage: T): Promise => { + if (!channelRef.current || !channelRef.current.url) { + logger?.error?.('[useGroupChannelThreadMessages] channel is required'); + throw new Error('Channel is required'); + } + + return new Promise((resolve, reject) => { + let handler: + | MessageRequestHandler + | MessageRequestHandler + | MultipleFilesMessageRequestHandler + | undefined = undefined; + + if (failedMessage.isUserMessage()) handler = channelRef.current.resendMessage(failedMessage); + if (failedMessage.isFileMessage()) handler = channelRef.current.resendMessage(failedMessage); + if (failedMessage.isMultipleFilesMessage()) handler = channelRef.current.resendMessage(failedMessage); + + if (handler) { + if ('onPending' in handler) { + handler.onPending((message) => { + if (message.channelUrl === channelRef.current.url) { + updateMessages([message], false, sdk.currentUser?.userId); + } + }); + } + + if ('onFileUploaded' in handler) { + handler.onFileUploaded(() => { + // Just re-render to use updated message.messageParams + forceUpdate(); + }); + } + + if ('onSucceeded' in handler) { + handler.onSucceeded((message) => { + if (message.channelUrl === channelRef.current.url) { + updateMessages([message], false, sdk.currentUser?.userId); + } + resolve(message as T); + }); + } + + if ('onFailed' in handler) { + handler.onFailed((err, message) => { + if (message && message.channelUrl === channelRef.current.url) { + updateMessages([message], false, sdk.currentUser?.userId); + } + reject(err); + }); + } + } + }); + }, + ); + + const deleteMessage = usePreservedCallback( + async (message: T): Promise => { + if (!channelRef.current || !channelRef.current.url) { + logger?.error?.('[useGroupChannelThreadMessages] channel is required'); + throw new Error('Channel is required'); + } + + if (message.sendingStatus === 'succeeded') { + if (message.isUserMessage()) await channelRef.current.deleteMessage(message); + if (message.isFileMessage()) await channelRef.current.deleteMessage(message); + if (message.isMultipleFilesMessage()) await channelRef.current.deleteMessage(message); + } else { + try { + await collectionRef.current.instance?.removeFailedMessage(message.reqId); + } finally { + deleteMessages([message.messageId], [message.reqId]); + } + } + }, + ); + const resetNewMessages = usePreservedCallback(() => { + updateNewMessages([], true, sdk.currentUser?.userId); + }); + const resetWithStartingPoint = usePreservedCallback(async (startingPoint: number) => { + if (sdk.currentUser && channelRef.current) { + updateLoading(true); + updateMessages([], true, sdk.currentUser?.userId); + await init(startingPoint); + updateLoading(false); + } + }); + + return { + /** + * Initialized state, only available on first render + * */ + initialized, + + /** + * Loading state, status is changes on first mount or when the resetWithStartingPoint is called. + * */ + loading, + + /** + * Refreshing state, status is changes when the refresh is called. + * */ + refreshing, + + /** + * Get messages, this state is for render + * For example, if a user receives a new messages while searching for an old message + * for this case, new messages will be included here. + * */ + messages, + + /** + * If the `shouldCountNewMessages()` is true, only then push in the newMessages state. + * (Return false for the `shouldCountNewMessages()` if the message scroll is the most recent; otherwise, return true.) + * + * A new message means a message that meets the below conditions + * - Not admin message + * - Not updated message + * - Not current user's message + * */ + newMessages, + + /** + * Reset new message list + * @return {void} + * */ + resetNewMessages, + + /** + * Reset message list and create a new collection for latest messages + * @return {Promise} + * */ + refresh, + + /** + * Load previous messages to state + * @return {Promise} + * */ + loadPrevious, + + /** + * Check if there are more previous messages to fetch + * @return {boolean} + * */ + hasPrevious, + + /** + * Load next messages to state + * @return {Promise} + * */ + loadNext, + + /** + * Check if there are more next messages to fetch + * @return {boolean} + * */ + hasNext, + + /** + * Send user message + * @param {UserMessageCreateParams} params user message create params + * @param {function} [onPending] pending message callback + * @return {Promise} succeeded message + * */ + sendUserMessage, + + /** + * Send file message + * @param {FileMessageCreateParams} params file message create params + * @param {function} [onPending] pending message callback + * @return {Promise} succeeded message + * */ + sendFileMessage, + + /** + * Send file messages + * @param {FileMessageCreateParams[]} paramList file message create params + * @param {function} [onPending] pending message callback for each message request + * @return {Promise} succeeded or failed message + * */ + sendFileMessages, + + /** + * Send multiple files message + * @param {MultipleFilesMessageCreateParams} params multiple files message create params + * @param {function} [onPending] pending message callback + * @return {Promise} succeeded message + * */ + sendMultipleFilesMessage, + + /** + * Update user message + * @param {number} messageId + * @param {UserMessageUpdateParams} params user message update params + * @return {Promise} + * */ + updateUserMessage, + + /** + * Update file message + * @param {number} messageId + * @param {FileMessageUpdateParams} params file message update params + * @return {Promise} + * */ + updateFileMessage, + + /** + * Resend failed message + * @template {UserMessage | FileMessage | MultipleFilesMessage} T + * @param {T} failedMessage message to resend + * @return {Promise} + * */ + resendMessage, + + /** + * Delete a message + * @template {UserMessage | FileMessage | MultipleFilesMessage} T + * @param {T} message succeeded or failed message + * @return {Promise} + * */ + deleteMessage, + + /** + * Reset message list and create a new collection with starting point + * @param {number} startingPoint + * @param {function} callback + * @return {void} + * */ + resetWithStartingPoint, + }; +}; diff --git a/packages/uikit-react-native/src/index.ts b/packages/uikit-react-native/src/index.ts index 5af081e70..400b89d82 100644 --- a/packages/uikit-react-native/src/index.ts +++ b/packages/uikit-react-native/src/index.ts @@ -36,6 +36,7 @@ export { default as createGroupChannelRegisterOperatorFragment } from './fragmen export { default as createGroupChannelMutedMembersFragment } from './fragments/createGroupChannelMutedMembersFragment'; export { default as createGroupChannelBannedUsersFragment } from './fragments/createGroupChannelBannedUsersFragment'; export { default as createGroupChannelNotificationsFragment } from './fragments/createGroupChannelNotificationsFragment'; +export { default as createGroupChannelThreadFragment } from './fragments/createGroupChannelThreadFragment'; export { default as createMessageSearchFragment } from './fragments/createMessageSearchFragment'; /** Fragments - open channels **/ diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index d6256daed..7c078a3b7 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -127,6 +127,27 @@ export interface StringSet { /** GroupChannel > Suggested mention list */ MENTION_LIMITED: (mentionLimit: number) => string; }; + GROUP_CHANNEL_THREAD: { + /** GroupChannelThread > Header */ + HEADER_TITLE: string; + HEADER_SUBTITLE: (currentUserId: string, channel: SendbirdGroupChannel) => string; + + /** GroupChannelThread > List */ + LIST_DATE_SEPARATOR: (date: Date, locale?: Locale) => string; + LIST_BUTTON_NEW_MSG: (newMessages: SendbirdMessage[]) => string; + + /** GroupChannelThread > Message bubble */ + MESSAGE_BUBBLE_TIME: (message: SendbirdMessage, locale?: Locale) => string; + MESSAGE_BUBBLE_FILE_TITLE: (message: SendbirdFileMessage) => string; + MESSAGE_BUBBLE_EDITED_POSTFIX: string; + MESSAGE_BUBBLE_UNKNOWN_TITLE: (message: SendbirdMessage) => string; + MESSAGE_BUBBLE_UNKNOWN_DESC: (message: SendbirdMessage) => string; + + REPLAY_POSTFIX: (replyCount: number) => string; + + /** GroupChannelThread > Suggested mention list */ + MENTION_LIMITED: (mentionLimit: number) => string; + }; GROUP_CHANNEL_SETTINGS: { /** GroupChannelSettings > Header */ HEADER_TITLE: string; @@ -270,6 +291,7 @@ export interface StringSet { CHANNEL_INPUT_PLACEHOLDER_DISABLED: string; CHANNEL_INPUT_PLACEHOLDER_MUTED: string; CHANNEL_INPUT_PLACEHOLDER_REPLY: string; + CHANNEL_INPUT_PLACEHOLDER_THREAD: string; CHANNEL_INPUT_EDIT_OK: string; CHANNEL_INPUT_EDIT_CANCEL: string; /** ChannelInput > Attachments **/ @@ -286,6 +308,7 @@ export interface StringSet { CHANNEL_MESSAGE_SAVE: string; CHANNEL_MESSAGE_DELETE: string; CHANNEL_MESSAGE_REPLY: string; + CHANNEL_MESSAGE_THREAD: string; /** Channel > Message > Delete confirm **/ CHANNEL_MESSAGE_DELETE_CONFIRM_TITLE: string; CHANNEL_MESSAGE_DELETE_CONFIRM_OK: string; @@ -346,6 +369,7 @@ export interface StringSet { UNKNOWN_ERROR: string; GET_CHANNEL_ERROR: string; FIND_PARENT_MSG_ERROR: string; + THREAD_PARENT_MESSAGE_DELETED_ERROR: string; }; PROFILE_CARD: { BUTTON_MESSAGE: string; diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index d4a8553bc..9793ffde2 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -13,6 +13,7 @@ import { getOpenChannelParticipants, getOpenChannelTitle, isVoiceMessage, + getReplyCountFormat, } from '@sendbird/uikit-utils'; import { UNKNOWN_USER_ID } from '../constants'; @@ -127,6 +128,23 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp MENTION_LIMITED: (mentionLimit) => `You can have up to ${mentionLimit} mentions per message.`, ...overrides?.GROUP_CHANNEL, }, + GROUP_CHANNEL_THREAD: { + HEADER_TITLE: 'Thread', + HEADER_SUBTITLE: (uid, channel) => getGroupChannelTitle(uid, channel, USER_NO_NAME, CHANNEL_NO_MEMBERS), + LIST_DATE_SEPARATOR: (date, locale) => getDateSeparatorFormat(date, locale ?? dateLocale), + LIST_BUTTON_NEW_MSG: (newMessages) => `${newMessages.length} new messages`, + + MESSAGE_BUBBLE_TIME: (message, locale) => getMessageTimeFormat(new Date(message.createdAt), locale ?? dateLocale), + MESSAGE_BUBBLE_FILE_TITLE: (message) => message.name, + MESSAGE_BUBBLE_EDITED_POSTFIX: ' (edited)', + MESSAGE_BUBBLE_UNKNOWN_TITLE: () => '(Unknown message type)', + MESSAGE_BUBBLE_UNKNOWN_DESC: () => 'Cannot read this message.', + + REPLAY_POSTFIX: (replyCount:number) => getReplyCountFormat(replyCount), + + MENTION_LIMITED: (mentionLimit) => `You can have up to ${mentionLimit} mentions per message.`, + ...overrides?.GROUP_CHANNEL_THREAD, + }, GROUP_CHANNEL_SETTINGS: { HEADER_TITLE: 'Channel information', HEADER_RIGHT: 'Edit', @@ -280,6 +298,7 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp CHANNEL_MESSAGE_SAVE: 'Save', CHANNEL_MESSAGE_DELETE: 'Delete', CHANNEL_MESSAGE_REPLY: 'Reply', + CHANNEL_MESSAGE_THREAD: 'Reply in thread', CHANNEL_MESSAGE_DELETE_CONFIRM_TITLE: 'Delete message?', CHANNEL_MESSAGE_DELETE_CONFIRM_OK: 'Delete', CHANNEL_MESSAGE_DELETE_CONFIRM_CANCEL: 'Cancel', @@ -293,6 +312,7 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp CHANNEL_INPUT_PLACEHOLDER_DISABLED: 'Chat not available in this channel.', CHANNEL_INPUT_PLACEHOLDER_MUTED: "You're muted by the operator.", CHANNEL_INPUT_PLACEHOLDER_REPLY: 'Reply to message', + CHANNEL_INPUT_PLACEHOLDER_THREAD: 'Reply in thread', CHANNEL_INPUT_EDIT_OK: 'Save', CHANNEL_INPUT_EDIT_CANCEL: 'Cancel', CHANNEL_INPUT_REPLY_PREVIEW_TITLE: (user) => `Reply to ${user.nickname || USER_NO_NAME}`, @@ -371,6 +391,7 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp UNKNOWN_ERROR: 'Something went wrong.', GET_CHANNEL_ERROR: "Couldn't retrieve channel.", FIND_PARENT_MSG_ERROR: "Couldn't find the original message for this reply.", + THREAD_PARENT_MESSAGE_DELETED_ERROR: "The thread doesn't exist because the parent message was deleted.", ...overrides?.TOAST, }, PROFILE_CARD: { diff --git a/packages/uikit-utils/src/ui-format/common.ts b/packages/uikit-utils/src/ui-format/common.ts index ff34e9ace..eb6264717 100644 --- a/packages/uikit-utils/src/ui-format/common.ts +++ b/packages/uikit-utils/src/ui-format/common.ts @@ -152,3 +152,18 @@ export const millsToMSS = (mills: number) => { return `${minutes}:${ss}`; }; + +/** + * Message reply count format + * If reply count is 1: 1 'reply' + * If the reply count is greater than 1 : '{count} replies' + * */ +export const getReplyCountFormat = (replyCount: number) => { + if (replyCount === 1) { + return `${replyCount} reply`; + } else if (replyCount > 1) { + return `${replyCount} replies`; + } + + return ''; +}; diff --git a/sample/src/App.tsx b/sample/src/App.tsx index fbc348b71..290d43e12 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -27,6 +27,7 @@ import { GroupChannelScreen, GroupChannelSettingsScreen, GroupChannelTabs, + GroupChannelThreadScreen, HomeScreen, MessageSearchScreen, OpenChannelBannedUsersScreen, @@ -61,6 +62,7 @@ const App = () => { groupChannel: { enableMention: true, typingIndicatorTypes: new Set([TypingIndicatorType.Text, TypingIndicatorType.Bubble]), + replyType: 'thread', }, groupChannelList: { enableTypingIndicator: true, @@ -143,10 +145,8 @@ const Navigations = () => { - + + diff --git a/sample/src/libs/navigation.ts b/sample/src/libs/navigation.ts index f3fd00c2b..fa28d1ab9 100644 --- a/sample/src/libs/navigation.ts +++ b/sample/src/libs/navigation.ts @@ -40,6 +40,7 @@ export enum Routes { GroupChannelRegisterOperator = 'GroupChannelRegisterOperator', GroupChannelMutedMembers = 'GroupChannelMutedMembers', GroupChannelBannedUsers = 'GroupChannelBannedUsers', + GroupChannelThread = 'GroupChannelThread', GroupChannelCreate = 'GroupChannelCreate', GroupChannelInvite = 'GroupChannelInvite', @@ -145,6 +146,14 @@ export type RouteParamsUnion = route: Routes.MessageSearch; params: ChannelUrlParams; } + | { + route: Routes.GroupChannelThread; + params: { + channelUrl: string; + serializedMessage: object; + startingPoint?: number; + }; + } /** OpenChannel screens **/ | { route: Routes.OpenChannelTabs; diff --git a/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx b/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx index 6178db66a..6ddbe42e3 100644 --- a/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx +++ b/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx @@ -97,6 +97,17 @@ const GroupChannelScreen = () => { // Navigate to group channel settings navigation.push(Routes.GroupChannelSettings, params); }} + onPressReplyMessageInThread={(message,startingPoint) => { + // Navigate to thread + if (message) { + navigation.push(Routes.GroupChannelThread, { + channelUrl: params.channelUrl, + serializedMessage: message.serialize(), + startingPoint: startingPoint, + }); + } + } + } // messageListQueryParams={{ // prevResultLimit: 20, // customTypesFilter: ['filter'], diff --git a/sample/src/screens/uikit/groupChannel/GroupChannelThreadScreen.tsx b/sample/src/screens/uikit/groupChannel/GroupChannelThreadScreen.tsx new file mode 100644 index 000000000..8dc790c3d --- /dev/null +++ b/sample/src/screens/uikit/groupChannel/GroupChannelThreadScreen.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; + +import { useGroupChannel } from '@sendbird/uikit-chat-hooks'; +import { createGroupChannelThreadFragment, useSendbirdChat } from '@sendbird/uikit-react-native'; + +import { useAppNavigation } from '../../../hooks/useAppNavigation'; +import { Routes } from '../../../libs/navigation'; +import type { SendbirdFileMessage, SendbirdUserMessage } from '@sendbird/uikit-utils'; + +const GroupChannelThreadFragment = createGroupChannelThreadFragment(); + +const GroupChannelThreadScreen = () => { + const { navigation, params } = useAppNavigation(); + + const { sdk } = useSendbirdChat(); + const { channel } = useGroupChannel(sdk, params.channelUrl); + const [parentMessage] = useState(() => sdk.message.buildMessageFromSerializedData(params.serializedMessage) as SendbirdUserMessage | SendbirdFileMessage); + if (!channel || !parentMessage) return null; + + return ( + { + // Navigate to media viewer + navigation.navigate(Routes.FileViewer, { + serializedFileMessage: fileMessage.serialize(), + deleteMessage, + }); + }} + onParentMessageDeleted={() => { + navigation.goBack(); + }} + onChannelDeleted={() => { + // Should leave channel, navigate to channel list + navigation.navigate(Routes.GroupChannelList); + }} + onPressHeaderLeft={() => { + // Navigate back + navigation.goBack(); + }} + /> + ); +}; + +export default GroupChannelThreadScreen; diff --git a/sample/src/screens/uikit/groupChannel/index.ts b/sample/src/screens/uikit/groupChannel/index.ts index 9828d6b5f..df41af794 100644 --- a/sample/src/screens/uikit/groupChannel/index.ts +++ b/sample/src/screens/uikit/groupChannel/index.ts @@ -10,3 +10,4 @@ export { default as GroupChannelRegisterOperatorScreen } from './GroupChannelReg export { default as GroupChannelMutedMembersScreen } from './GroupChannelMutedMembersScreen'; export { default as GroupChannelBannedUsersScreen } from './GroupChannelBannedUsersScreen'; export { default as GroupChannelNotificationsScreen } from './GroupChannelNotificationsScreen'; +export { default as GroupChannelThreadScreen } from './GroupChannelThreadScreen'; From 0620e931fd7f4cd47464deb7b33213fe859cfaf1 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Thu, 30 May 2024 15:53:44 +0900 Subject: [PATCH 02/46] Clean up code --- .../createGroupChannelThreadFragment.tsx | 2 +- .../useChannelThreadMessagesReducer.ts | 247 ------ .../useGroupChannelThreadMessages.ts | 839 ------------------ 3 files changed, 1 insertion(+), 1087 deletions(-) delete mode 100644 packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useChannelThreadMessagesReducer.ts delete mode 100644 packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useGroupChannelThreadMessages.ts diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index ec373f96b..025e835d4 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, useToast } from '@sendbird/uikit-react-native-foundation'; -import { useGroupChannelThreadMessages } from '../hooks/useGroupChannelThreadMessages/useGroupChannelThreadMessages'; +import { useGroupChannelThreadMessages } from '@sendbird/uikit-tools'; import type { SendbirdFileMessage, SendbirdGroupChannel, SendbirdUserMessage } from '@sendbird/uikit-utils'; import { confirmAndMarkAsRead, messageComparator, NOOP, PASS, useFreshCallback, useRefTracker } from '@sendbird/uikit-utils'; diff --git a/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useChannelThreadMessagesReducer.ts b/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useChannelThreadMessagesReducer.ts deleted file mode 100644 index 97dfd467a..000000000 --- a/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useChannelThreadMessagesReducer.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { useMemo, useReducer } from 'react'; - -import type { SendableMessage } from '@sendbird/chat/lib/__definition'; -import type { BaseMessage } from '@sendbird/chat/message'; -import { SendingStatus } from '@sendbird/chat/message'; - -import type { SendbirdMessage } from '@sendbird/uikit-tools/src/types'; -import { getMessageUniqId, isMyMessage, isNewMessage, isSendableMessage } from '@sendbird/uikit-tools'; - -export function arrayToMapWithGetter(arr: T[], getSelector: (item: T) => string) { - return arr.reduce((accum, curr) => { - const _key = getSelector(curr); - accum[_key] = curr; - return accum; - }, {} as Record); -} - -type Action = - | { - type: 'update_initialized' | 'update_loading' | 'update_refreshing' | 'update_has_previous' | 'update_has_next'; - value: { status: boolean }; - } - | { - type: 'update_messages' | 'update_new_messages'; - value: { messages: BaseMessage[]; clearBeforeAction: boolean; currentUserId?: string }; - } - | { - type: 'delete_messages' | 'delete_new_messages'; - value: { messageIds: number[]; reqIds: string[] }; - }; - -type State = { - initialized: boolean; - loading: boolean; - refreshing: boolean; - hasPreviousMessages: boolean, - hasNextMessages: boolean, - messageMap: Record; - newMessageMap: Record; -}; - -const defaultReducer = ({ ...draft }: State, action: Action) => { - switch (action.type) { - case 'update_initialized': { - draft['initialized'] = action.value.status; - return draft; - } - case 'update_refreshing': { - draft['refreshing'] = action.value.status; - return draft; - } - case 'update_loading': { - draft['loading'] = action.value.status; - return draft; - } - case 'update_has_previous': { - draft['hasPreviousMessages'] = action.value.status; - return draft; - } - case 'update_has_next': { - draft['hasNextMessages'] = action.value.status; - return draft; - } - case 'update_messages': { - const userId = action.value.currentUserId; - - if (action.value.clearBeforeAction) { - draft['messageMap'] = messagesToObject(action.value.messages); - } else { - // Filtering meaningless message updates - const nextMessages = action.value.messages.filter((next) => { - if (isMyMessage(next, userId)) { - const prev = draft['messageMap'][next.reqId] ?? draft['messageMap'][next.messageId]; - if (isMyMessage(prev, userId)) { - const shouldUpdate = shouldUpdateMessage(prev, next); - if (shouldUpdate) { - // Remove existing messages before update to prevent duplicate display - delete draft['messageMap'][prev.reqId]; - delete draft['messageMap'][prev.messageId]; - } - return shouldUpdate; - } - } - return true; - }); - - const obj = messagesToObject(nextMessages); - draft['messageMap'] = { ...draft['messageMap'], ...obj }; - } - - return draft; - } - case 'update_new_messages': { - const userId = action.value.currentUserId; - const newMessages = action.value.messages.filter((it) => isNewMessage(it, userId)); - - if (action.value.clearBeforeAction) { - draft['newMessageMap'] = arrayToMapWithGetter(newMessages, getMessageUniqId); - } else { - // Remove existing messages before update to prevent duplicate display - const messageKeys = newMessages.map((it) => it.messageId); - messageKeys.forEach((key) => delete draft['newMessageMap'][key]); - - draft['newMessageMap'] = { - ...draft['newMessageMap'], - ...arrayToMapWithGetter(newMessages, getMessageUniqId), - }; - } - - return draft; - } - case 'delete_messages': - case 'delete_new_messages': { - const key = action.type === 'delete_messages' ? 'messageMap' : 'newMessageMap'; - draft[key] = { ...draft[key] }; - action.value.messageIds.forEach((msgId) => { - const message = draft[key][msgId]; - if (message) { - if (isSendableMessage(message)) delete draft[key][message.reqId]; - delete draft[key][message.messageId]; - } - }); - action.value.reqIds.forEach((reqId) => { - const message = draft[key][reqId]; - if (message) { - if (isSendableMessage(message)) delete draft[key][message.reqId]; - delete draft[key][message.messageId]; - } - }); - - return draft; - } - } -}; - -const messagesToObject = (messages: BaseMessage[]) => { - return messages.reduce((accum, curr) => { - if (isSendableMessage(curr)) { - accum[curr.reqId] = curr; - if (curr.sendingStatus === SendingStatus.SUCCEEDED) { - accum[curr.messageId] = curr; - } - } else { - accum[curr.messageId] = curr; - } - return accum; - }, {} as Record); -}; - -const shouldUpdateMessage = (prev: SendableMessage, next: SendableMessage) => { - // message data update (e.g. reactions) - if (prev.sendingStatus === SendingStatus.SUCCEEDED) return next.sendingStatus === SendingStatus.SUCCEEDED; - - // message sending status update - return prev.sendingStatus !== next.sendingStatus; -}; - -const getOldestMessageTimeStamp = (messages: BaseMessage[]) => { - return messages.reduce((accum, curr) => { - return Math.min(accum, curr.createdAt); - }, Number.MAX_SAFE_INTEGER); -}; - -const getLatestMessageTimeStamp = (messages: BaseMessage[]) => { - return messages.reduce((accum, curr) => { - return Math.max(accum, curr.createdAt); - }, Number.MIN_SAFE_INTEGER); -}; - -export const useChannelThreadMessagesReducer = (sortComparator = defaultMessageComparator) => { - const [{ initialized, loading, refreshing, hasPreviousMessages, hasNextMessages, messageMap, newMessageMap }, dispatch] = useReducer(defaultReducer, { - initialized: false, - loading: true, - refreshing: false, - hasPreviousMessages: false, - hasNextMessages: false, - messageMap: {}, - newMessageMap: {}, - }); - - const updateMessages = (messages: BaseMessage[], clearBeforeAction: boolean, currentUserId?: string) => { - dispatch({ type: 'update_messages', value: { messages, clearBeforeAction, currentUserId } }); - }; - const deleteMessages = (messageIds: number[], reqIds: string[]) => { - dispatch({ type: 'delete_messages', value: { messageIds, reqIds } }); - }; - const updateNewMessages = (messages: BaseMessage[], clearBeforeAction: boolean, currentUserId?: string) => { - dispatch({ type: 'update_new_messages', value: { messages, clearBeforeAction, currentUserId } }); - }; - const deleteNewMessages = (messageIds: number[], reqIds: string[]) => { - dispatch({ type: 'delete_new_messages', value: { messageIds, reqIds } }); - }; - const updateInitialized = (status: boolean) => { - dispatch({ type: 'update_initialized', value: { status } }); - }; - const updateLoading = (status: boolean) => { - dispatch({ type: 'update_loading', value: { status } }); - }; - const updateRefreshing = (status: boolean) => { - dispatch({ type: 'update_refreshing', value: { status } }); - }; - const updateHasPreviousMessages = (status: boolean) => { - dispatch({ type: 'update_has_previous', value: { status } }); - }; - const updateHasNextMessages = (status: boolean) => { - dispatch({ type: 'update_has_next', value: { status } }); - }; - - const newMessages = Object.values(newMessageMap); - const messages = useMemo(() => Array.from(new Set(Object.values(messageMap))).sort(sortComparator), [messageMap]); - const oldestMessageTimeStamp = getOldestMessageTimeStamp(messages); - const latestMessageTimeStamp = getLatestMessageTimeStamp(messages); - - return { - updateInitialized, - updateLoading, - updateRefreshing, - updateMessages, - deleteMessages, - updateHasPreviousMessages, - updateHasNextMessages, - - initialized, - loading, - refreshing, - hasPreviousMessages, - hasNextMessages, - oldestMessageTimeStamp, - latestMessageTimeStamp, - messages, - - newMessages, - updateNewMessages, - deleteNewMessages, - }; -}; - -const LARGE_OFFSET = Math.floor(Number.MAX_SAFE_INTEGER / 10); -export function defaultMessageComparator(a: SendbirdMessage, b: SendbirdMessage) { - let aStatusOffset = 0; - let bStatusOffset = 0; - - if (isSendableMessage(a) && a.sendingStatus !== 'succeeded') aStatusOffset = LARGE_OFFSET; - if (isSendableMessage(b) && b.sendingStatus !== 'succeeded') bStatusOffset = LARGE_OFFSET; - - return a.createdAt + aStatusOffset - (b.createdAt + bStatusOffset); -} diff --git a/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useGroupChannelThreadMessages.ts b/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useGroupChannelThreadMessages.ts deleted file mode 100644 index 3c1c26ac2..000000000 --- a/packages/uikit-react-native/src/hooks/useGroupChannelThreadMessages/useGroupChannelThreadMessages.ts +++ /dev/null @@ -1,839 +0,0 @@ -import { useEffect, useLayoutEffect, useRef } from 'react'; - -import { CollectionEventSource, type SendbirdChatWith } from '@sendbird/chat'; -import type { - GroupChannel, - GroupChannelModule, - MessageCollection, -} from '@sendbird/chat/groupChannel'; -import { MessageCollectionInitPolicy, MessageFilter } from '@sendbird/chat/groupChannel'; -import { - BaseMessage, - FileMessage, - FileMessageCreateParams, - FileMessageUpdateParams, - MessageRequestHandler, - MultipleFilesMessage, - MultipleFilesMessageCreateParams, - MultipleFilesMessageRequestHandler, - UserMessage, - UserMessageCreateParams, - UserMessageUpdateParams, -} from '@sendbird/chat/message'; - -import { ReplyType } from '@sendbird/chat/message'; - -import { sbuConstants } from '@sendbird/uikit-tools'; -import type { SendbirdMessage } from '@sendbird/uikit-tools/src/types'; -import { isDifferentChannel } from '@sendbird/uikit-tools'; -import { isMyMessage, isSendableMessage } from '@sendbird/uikit-tools'; -import { isNotEmptyArray, useForceUpdate } from '@sendbird/uikit-tools'; -import { useGroupChannelHandler, usePreservedCallback } from '@sendbird/uikit-tools'; -import { useChannelThreadMessagesReducer } from './useChannelThreadMessagesReducer'; -import { ThreadedMessageListParams } from '@sendbird/chat/lib/__definition'; -import { SendbirdFileMessage, SendbirdUserMessage, useAsyncEffect } from '@sendbird/uikit-utils'; - -type Log = (...args: unknown[]) => void; -type UseGroupChannelThreadMessagesOptions = { - markAsRead?: (channels: GroupChannel[]) => void; - shouldCountNewMessages?: () => boolean; - sortComparator?: (a: SendbirdMessage, b: SendbirdMessage) => number; - isReactionEnabled?: boolean; - startingPoint?: number; - - onMessagesReceived?: (messages: SendbirdMessage[]) => void; - onMessagesUpdated?: (messages: SendbirdMessage[]) => void; - onParentMessageUpdated?: (messages: SendbirdUserMessage | SendbirdFileMessage) => void; - onParentMessageDeleted?: () => void; - onChannelDeleted?: (channelUrl: string) => void; - onChannelUpdated?: (channel: GroupChannel) => void; - onCurrentUserBanned?: () => void; - - logger?: { info?: Log; warn?: Log; error?: Log; log?: Log; debug?: Log }; -}; - -function isThreadedMessage(message: BaseMessage, parentMessage: BaseMessage) { - return message.parentMessageId === parentMessage.messageId; -} - -/** - * group channel thread messages hook - * - Receive new messages from other users & should count new messages -> append to state(newMessages) - * - onTopReached -> prev() -> fetch prev messages and append to state(messages) - * - onBottomReached -> next() -> fetch next messages and append to state(messages) - * */ -export const useGroupChannelThreadMessages = ( - sdk: SendbirdChatWith<[GroupChannelModule]>, - channel: GroupChannel, - parentMessage: FileMessage | UserMessage, - options: UseGroupChannelThreadMessagesOptions = {}, -) => { - const internalOptions = useRef(options); // to keep reference of options in event handler - internalOptions.current = options; - - const channelRef = useRef(channel); // to keep reference of channel in event handler - channelRef.current = channel; - - const parentMessageRef = useRef(parentMessage); // to keep reference of parent message in event handler - parentMessageRef.current = parentMessage; - - const startingPoint = useRef(internalOptions.current?.startingPoint || Number.MAX_SAFE_INTEGER); - startingPoint.current = internalOptions.current?.startingPoint || Number.MAX_SAFE_INTEGER; - - const prevFetchSize = sbuConstants.collection.message.defaultLimit.prev; - const nextFetchSize = sbuConstants.collection.message.defaultLimit.next; - - const logger = internalOptions.current.logger; - const isFetching = useRef({ prev: false, next: false }); - const forceUpdate = useForceUpdate(); - const collectionRef = useRef<{ initialized: boolean; apiInitialized: boolean; instance: MessageCollection | null }>({ - initialized: false, - apiInitialized: false, - instance: null, - }); - - const { - initialized, - loading, - refreshing, - hasPreviousMessages, - hasNextMessages, - oldestMessageTimeStamp, - latestMessageTimeStamp, - messages, - newMessages, - updateMessages, - updateNewMessages, - deleteNewMessages, - deleteMessages, - updateInitialized, - updateLoading, - updateRefreshing, - updateHasPreviousMessages, - updateHasNextMessages, - } = useChannelThreadMessagesReducer(options?.sortComparator); - - const markAsReadBySource = usePreservedCallback((source?: CollectionEventSource) => { - if (!channelRef.current || !channelRef.current.url) { return logger?.error?.('[useGroupChannelThreadMessages] channel is required'); } - - try { - switch (source) { - case CollectionEventSource.EVENT_MESSAGE_RECEIVED: - case CollectionEventSource.EVENT_MESSAGE_SENT_SUCCESS: - case CollectionEventSource.SYNC_MESSAGE_FILL: - case undefined: - internalOptions.current.markAsRead?.([channelRef.current]); - break; - } - } catch (e) { - logger?.warn?.('[useGroupChannelThreadMessages/markAsReadBySource]', e); - } - }); - - const updateNewMessagesReceived = usePreservedCallback((source: CollectionEventSource, messages: BaseMessage[]) => { - const incomingMessages = messages.filter( - (it) => isThreadedMessage(it, parentMessageRef.current) && !isMyMessage(it, sdk.currentUser?.userId), - ); - - if (incomingMessages.length > 0) { - switch (source) { - case CollectionEventSource.EVENT_MESSAGE_RECEIVED: - case CollectionEventSource.SYNC_MESSAGE_FILL: { - if (internalOptions.current.shouldCountNewMessages?.()) { - updateNewMessages(incomingMessages, false, sdk.currentUser?.userId); - } - internalOptions.current.onMessagesReceived?.(incomingMessages); - break; - } - } - } - }); - - useAsyncEffect(async () => { - const messages = await channelRef.current?.getMessagesByMessageId(parentMessageRef.current?.messageId, { - prevResultSize: 1, - nextResultSize: 1, - isInclusive: true, - includeThreadInfo: true, - includeMetaArray: true, - includeReactions: internalOptions.current.isReactionEnabled ?? false, - }); - - const parentMessage = messages?.find((message) => { - return message.messageId === parentMessageRef.current.messageId; - }) as FileMessage | UserMessage; - if (parentMessage) { - parentMessageRef.current = parentMessage; - internalOptions.current.onParentMessageUpdated?.(parentMessage); - } - }, []); - - const init = usePreservedCallback(async (startingPoint: number) => { - return new Promise((resolve) => { - - if (!channelRef.current || !channelRef.current.url) { - return logger?.error?.('[useGroupChannelThreadMessages] channel is required'); - } - - if (!parentMessageRef.current) { - return logger?.error?.('[useGroupChannelThreadMessages] parent message is required'); - } - - if (collectionRef.current.instance) collectionRef.current.instance.dispose(); - - markAsReadBySource(); - updateNewMessages([], true, sdk.currentUser?.userId); - - const updateUnsentMessages = () => { - const { pendingMessages, failedMessages } = collectionRef.current.instance ?? {}; - - let filteredMessages; - if (isNotEmptyArray(pendingMessages)) { - filteredMessages = pendingMessages.filter((message) => - isThreadedMessage(message, parentMessageRef.current), - ); - } - - if (isNotEmptyArray(failedMessages)) { - filteredMessages = failedMessages.filter((message) => - isThreadedMessage(message, parentMessageRef.current), - ); - } - - if (isNotEmptyArray(filteredMessages)) updateMessages(filteredMessages, false, sdk.currentUser?.userId); - }; - - setTimeout(async () => { - try { - if (parentMessageRef.current) { - const params: ThreadedMessageListParams = { - prevResultSize: prevFetchSize, - nextResultSize: nextFetchSize, - isInclusive: true, - includeReactions: internalOptions.current.isReactionEnabled ?? false, - }; - const { threadedMessages } = await parentMessageRef.current.getThreadedMessagesByTimestamp(startingPoint, params); - if (isNotEmptyArray(threadedMessages)) { - const prevMessagesCount = threadedMessages.filter((message) => message?.createdAt < startingPoint).length; - const nextMessagesCount = threadedMessages.filter((message) => message?.createdAt > startingPoint).length; - updateHasPreviousMessages(prevMessagesCount >= prevFetchSize); - updateHasNextMessages(nextMessagesCount >= nextFetchSize); - updateMessages(threadedMessages, true, sdk.currentUser?.userId); - } - } else { - logger?.warn?.('[useGroupChannelThreadMessages] parent message is required'); - } - resolve(); - } catch (error) { - logger?.error?.('[useGroupChannelThreadMessages] Initialize thread list failed.', error); - } - }); - - const collectionInstance = channelRef.current.createMessageCollection({ - prevResultLimit: prevFetchSize, - nextResultLimit: nextFetchSize, - startingPoint: startingPoint - 1, - filter: new MessageFilter({ replyType: ReplyType.ALL }), - }); - - collectionRef.current = { apiInitialized: false, initialized: false, instance: collectionInstance }; - - collectionInstance.setMessageCollectionHandler({ - onMessagesAdded: (ctx, __, messages) => { - const filteredMessages = messages.filter((message) => - isThreadedMessage(message, parentMessageRef.current), - ); - if (isNotEmptyArray(filteredMessages)) { - markAsReadBySource(ctx.source); - updateNewMessagesReceived(ctx.source, filteredMessages); - updateMessages(filteredMessages, false, sdk.currentUser?.userId); - } - }, - onMessagesUpdated: (ctx, __, messages) => { - const filteredMessages = messages.filter((message) => - isThreadedMessage(message, parentMessageRef.current), - ); - - const parentMessage = messages.find((message) => message.messageId === parentMessageRef.current.messageId) as FileMessage | UserMessage; - if (parentMessage) { - parentMessageRef.current = parentMessage; - internalOptions.current.onParentMessageUpdated?.(parentMessage); - } - if (isNotEmptyArray(filteredMessages)) { - markAsReadBySource(ctx.source); - updateNewMessagesReceived(ctx.source, filteredMessages); // NOTE: admin message is not added via onMessagesAdded handler, not checked yet is this a bug. - - updateMessages(filteredMessages, false, sdk.currentUser?.userId); - - if (ctx.source === CollectionEventSource.EVENT_MESSAGE_UPDATED) { - internalOptions.current.onMessagesUpdated?.(filteredMessages); - } - } - }, - onMessagesDeleted: (_, __, ___, messages) => { - const parentMessage = messages.find((message) => message.messageId === parentMessageRef.current.messageId) as FileMessage | UserMessage; - if (parentMessage) { - internalOptions.current.onParentMessageDeleted?.(); - } - - const filteredMessages = messages.filter((message) => - isThreadedMessage(message, parentMessageRef.current), - ); - if (isNotEmptyArray(filteredMessages)) { - const msgIds = filteredMessages.map((it) => it.messageId); - const reqIds = filteredMessages.filter(isSendableMessage).map((it) => it.reqId); - deleteMessages(msgIds, reqIds); - deleteNewMessages(msgIds, reqIds); - } - }, - onChannelDeleted: (_, channelUrl) => { - internalOptions.current.onChannelDeleted?.(channelUrl); - }, - onChannelUpdated: (_, channel) => { - forceUpdate(); - internalOptions.current.onChannelUpdated?.(channel); - }, - onHugeGapDetected: () => { - init(startingPoint); - }, - }); - - collectionInstance - .initialize(MessageCollectionInitPolicy.CACHE_AND_REPLACE_BY_API) - .onCacheResult((err, messages) => { - if (err) { - sdk.isCacheEnabled && logger?.error?.('[useGroupChannelThreadMessages/onCacheResult]', err); - } else if (messages) { - logger?.debug?.('[useGroupChannelThreadMessages/onCacheResult]', 'message length:', messages.length); - - const filteredMessages = messages.filter((message) => - isThreadedMessage(message, parentMessageRef.current), - ); - if (isNotEmptyArray(filteredMessages)) { - updateMessages(filteredMessages, false, sdk.currentUser?.userId); - updateUnsentMessages(); - } - } - }) - .onApiResult((err, messages) => { - if (err) { - logger?.warn?.('[useGroupChannelThreadMessages/onApiResult]', err); - } else if (messages) { - logger?.debug?.('[useGroupChannelThreadMessages/onApiResult]', 'message length:', messages.length); - - const filteredMessages = messages.filter((message) => - isThreadedMessage(message, parentMessageRef.current), - ); - if (isNotEmptyArray(filteredMessages)) { - updateMessages(filteredMessages, false, sdk.currentUser?.userId); - if (sdk.isCacheEnabled) updateUnsentMessages(); - } - } - - collectionRef.current.initialized = true; - collectionRef.current.apiInitialized = true; - }); - }); - }); - - useGroupChannelHandler(sdk, { - onUserBanned(eventChannel, bannedUser) { - if (eventChannel.isGroupChannel() && !isDifferentChannel(eventChannel, channelRef.current)) { - if (bannedUser.userId === sdk.currentUser?.userId) { - internalOptions.current.onCurrentUserBanned?.(); - } else { - forceUpdate(); - } - } - }, - }); - - useLayoutEffect(() => { - const timeout = setTimeout(async () => { - if (sdk.currentUser && channelRef.current) { - updateInitialized(false); - updateLoading(true); - await init(startingPoint.current); - updateLoading(false); - updateInitialized(true); - } - }); - return () => clearTimeout(timeout); - }, [sdk, sdk.currentUser?.userId, channelRef.current?.url, startingPoint.current]); - - useEffect(() => { - return () => { - if (collectionRef.current.instance) collectionRef.current.instance.dispose(); - }; - }, []); - - const refresh = usePreservedCallback(async () => { - if (sdk.currentUser && channelRef.current) { - updateRefreshing(true); - await init(startingPoint.current); - updateRefreshing(false); - } - }); - - const loadPrevious = usePreservedCallback(async () => { - if (!channelRef.current || !channelRef.current.url || !parentMessageRef.current) { - logger?.error?.('[useGroupChannelThreadMessages] channel or parent message is required'); - return; - } - - if (hasPreviousMessages && !isFetching.current.prev) { - try { - isFetching.current.prev = true; - const params: ThreadedMessageListParams = { - prevResultSize: prevFetchSize, - nextResultSize: 0, - isInclusive: false, - includeReactions: internalOptions.current.isReactionEnabled ?? false, - }; - const { threadedMessages } = await parentMessageRef.current.getThreadedMessagesByTimestamp(oldestMessageTimeStamp, params); - if (isNotEmptyArray(threadedMessages)) { - updateHasPreviousMessages(threadedMessages.length >= prevFetchSize); - updateMessages(threadedMessages, false, sdk.currentUser?.userId); - } - } catch (error) { - logger?.error?.('[useGroupChannelThreadMessages] loadPrevious thread list failed.', error); - } finally { - isFetching.current.prev = false; - } - } - }); - - const hasPrevious = usePreservedCallback(() => { return hasPreviousMessages; }); - - const loadNext = usePreservedCallback(async () => { - if (!channelRef.current || !channelRef.current.url || !parentMessageRef.current) { - logger?.error?.('[useGroupChannelThreadMessages] channel or parent message is required'); - return; - } - - if (hasNextMessages && !isFetching.current.next) { - try { - isFetching.current.prev = true; - const params: ThreadedMessageListParams = { - prevResultSize: 0, - nextResultSize: nextFetchSize, - isInclusive: false, - includeReactions: internalOptions.current.isReactionEnabled ?? false, - }; - const { threadedMessages } = await parentMessageRef.current.getThreadedMessagesByTimestamp(latestMessageTimeStamp, params); - updateHasNextMessages(threadedMessages.length >= nextFetchSize); - updateMessages(threadedMessages, false, sdk.currentUser?.userId); - } catch (error) { - logger?.error?.('[useGroupChannelThreadMessages] loadNext thread list failed.', error); - } finally { - isFetching.current.next = false; - } - } - }); - - const hasNext = usePreservedCallback(() => {return hasNextMessages;}); - - const sendUserMessage = usePreservedCallback( - (params: UserMessageCreateParams, onPending: (message: UserMessage) => void): Promise => { - if (!channelRef.current || !channelRef.current.url) { - logger?.error?.('[useGroupChannelThreadMessages] channel is required'); - throw new Error('Channel is required'); - } - - return new Promise((resolve, reject) => { - channelRef.current - .sendUserMessage(params) - .onPending((pendingMessage) => { - if (pendingMessage.channelUrl === channelRef.current.url) { - updateMessages([pendingMessage], false, sdk.currentUser?.userId); - } - onPending?.(pendingMessage as UserMessage); - }) - .onSucceeded((sentMessage) => { - if (sentMessage.channelUrl === channelRef.current.url) { - updateMessages([sentMessage], false, sdk.currentUser?.userId); - } - resolve(sentMessage as UserMessage); - }) - .onFailed((err, failedMessage) => { - if (failedMessage && failedMessage.channelUrl === channelRef.current.url) { - updateMessages([failedMessage], false, sdk.currentUser?.userId); - } - reject(err); - }); - }); - }, - ); - const sendFileMessage = usePreservedCallback( - (params: FileMessageCreateParams, onPending?: (message: FileMessage) => void): Promise => { - if (!channelRef.current || !channelRef.current.url) { - logger?.error?.('[useGroupChannelThreadMessages] channel is required'); - throw new Error('Channel is required'); - } - - return new Promise((resolve, reject) => { - channelRef.current - .sendFileMessage(params) - .onPending((pendingMessage) => { - if (pendingMessage.channelUrl === channelRef.current.url) { - updateMessages([pendingMessage], false, sdk.currentUser?.userId); - } - onPending?.(pendingMessage as FileMessage); - }) - .onSucceeded((sentMessage) => { - if (sentMessage.channelUrl === channelRef.current.url) { - updateMessages([sentMessage], false, sdk.currentUser?.userId); - } - resolve(sentMessage as FileMessage); - }) - .onFailed((err, failedMessage) => { - if (failedMessage && failedMessage.channelUrl === channelRef.current.url) { - updateMessages([failedMessage], false, sdk.currentUser?.userId); - } - reject(err); - }); - }); - }, - ); - const sendFileMessages = usePreservedCallback( - async ( - paramsList: FileMessageCreateParams[], - onPending?: (message: FileMessage) => void, - ): Promise => { - if (!channelRef.current || !channelRef.current.url) { - logger?.error?.('[useGroupChannelThreadMessages] channel is required'); - throw new Error('Channel is required'); - } - - return new Promise((resolve) => { - const messages: FileMessage[] = []; - - channelRef.current - .sendFileMessages(paramsList) - .onPending((pendingMessage) => { - if (pendingMessage.channelUrl === channelRef.current.url) { - updateMessages([pendingMessage], false, sdk.currentUser?.userId); - } - onPending?.(pendingMessage as FileMessage); - }) - .onSucceeded((sentMessage) => { - if (sentMessage.isFileMessage() && sentMessage.channelUrl === channelRef.current.url) { - updateMessages([sentMessage], false, sdk.currentUser?.userId); - messages.push(sentMessage); - } - - if (messages.length === paramsList.length) resolve(messages); - }) - .onFailed((_, failedMessage) => { - if (failedMessage && failedMessage.channelUrl === channelRef.current.url) { - updateMessages([failedMessage], false, sdk.currentUser?.userId); - messages.push(failedMessage as FileMessage); - } else { - // NOTE: Since failedMessage is nullable by type, to resolve the promise, handle pushing null even when there is no failedMessage. - messages.push(null as unknown as FileMessage); - } - - if (messages.length === paramsList.length) resolve(messages); - }); - }); - }, - ); - const sendMultipleFilesMessage = usePreservedCallback( - ( - params: MultipleFilesMessageCreateParams, - onPending?: (message: MultipleFilesMessage) => void, - ): Promise => { - if (!channelRef.current || !channelRef.current.url) { - logger?.error?.('[useGroupChannelThreadMessages] channel is required'); - throw new Error('Channel is required'); - } - - return new Promise((resolve, reject) => { - channelRef.current - .sendMultipleFilesMessage(params) - .onPending((pendingMessage) => { - if (pendingMessage.channelUrl === channelRef.current.url) { - updateMessages([pendingMessage], false, sdk.currentUser?.userId); - } - onPending?.(pendingMessage as MultipleFilesMessage); - }) - .onFileUploaded(() => { - // Just re-render to use updated message.messageParams - forceUpdate(); - }) - .onSucceeded((sentMessage) => { - if (sentMessage.channelUrl === channelRef.current.url) { - updateMessages([sentMessage], false, sdk.currentUser?.userId); - } - resolve(sentMessage as MultipleFilesMessage); - }) - .onFailed((err, failedMessage) => { - if (failedMessage && failedMessage.channelUrl === channelRef.current.url) { - updateMessages([failedMessage], false, sdk.currentUser?.userId); - } - reject(err); - }); - }); - }, - ); - - const updateUserMessage = usePreservedCallback( - async (messageId: number, params: UserMessageUpdateParams): Promise => { - if (!channelRef.current || !channelRef.current.url) { - logger?.error?.('[useGroupChannelThreadMessages] channel is required'); - throw new Error('Channel is required'); - } - - const updatedMessage = await channelRef.current.updateUserMessage(messageId, params); - if (updatedMessage.channelUrl === channelRef.current.url && isThreadedMessage(updatedMessage, parentMessageRef.current)) { - updateMessages([updatedMessage], false, sdk.currentUser?.userId); - } - return updatedMessage; - }, - ); - const updateFileMessage = usePreservedCallback( - async (messageId: number, params: FileMessageUpdateParams): Promise => { - if (!channelRef.current || !channelRef.current.url) { - logger?.error?.('[useGroupChannelThreadMessages] channel is required'); - throw new Error('Channel is required'); - } - - const updatedMessage = await channelRef.current.updateFileMessage(messageId, params); - if (updatedMessage.channelUrl === channelRef.current.url && isThreadedMessage(updatedMessage, parentMessageRef.current)) { - updateMessages([updatedMessage], false, sdk.currentUser?.userId); - } - return updatedMessage; - }, - ); - - const resendMessage = usePreservedCallback( - async (failedMessage: T): Promise => { - if (!channelRef.current || !channelRef.current.url) { - logger?.error?.('[useGroupChannelThreadMessages] channel is required'); - throw new Error('Channel is required'); - } - - return new Promise((resolve, reject) => { - let handler: - | MessageRequestHandler - | MessageRequestHandler - | MultipleFilesMessageRequestHandler - | undefined = undefined; - - if (failedMessage.isUserMessage()) handler = channelRef.current.resendMessage(failedMessage); - if (failedMessage.isFileMessage()) handler = channelRef.current.resendMessage(failedMessage); - if (failedMessage.isMultipleFilesMessage()) handler = channelRef.current.resendMessage(failedMessage); - - if (handler) { - if ('onPending' in handler) { - handler.onPending((message) => { - if (message.channelUrl === channelRef.current.url) { - updateMessages([message], false, sdk.currentUser?.userId); - } - }); - } - - if ('onFileUploaded' in handler) { - handler.onFileUploaded(() => { - // Just re-render to use updated message.messageParams - forceUpdate(); - }); - } - - if ('onSucceeded' in handler) { - handler.onSucceeded((message) => { - if (message.channelUrl === channelRef.current.url) { - updateMessages([message], false, sdk.currentUser?.userId); - } - resolve(message as T); - }); - } - - if ('onFailed' in handler) { - handler.onFailed((err, message) => { - if (message && message.channelUrl === channelRef.current.url) { - updateMessages([message], false, sdk.currentUser?.userId); - } - reject(err); - }); - } - } - }); - }, - ); - - const deleteMessage = usePreservedCallback( - async (message: T): Promise => { - if (!channelRef.current || !channelRef.current.url) { - logger?.error?.('[useGroupChannelThreadMessages] channel is required'); - throw new Error('Channel is required'); - } - - if (message.sendingStatus === 'succeeded') { - if (message.isUserMessage()) await channelRef.current.deleteMessage(message); - if (message.isFileMessage()) await channelRef.current.deleteMessage(message); - if (message.isMultipleFilesMessage()) await channelRef.current.deleteMessage(message); - } else { - try { - await collectionRef.current.instance?.removeFailedMessage(message.reqId); - } finally { - deleteMessages([message.messageId], [message.reqId]); - } - } - }, - ); - const resetNewMessages = usePreservedCallback(() => { - updateNewMessages([], true, sdk.currentUser?.userId); - }); - const resetWithStartingPoint = usePreservedCallback(async (startingPoint: number) => { - if (sdk.currentUser && channelRef.current) { - updateLoading(true); - updateMessages([], true, sdk.currentUser?.userId); - await init(startingPoint); - updateLoading(false); - } - }); - - return { - /** - * Initialized state, only available on first render - * */ - initialized, - - /** - * Loading state, status is changes on first mount or when the resetWithStartingPoint is called. - * */ - loading, - - /** - * Refreshing state, status is changes when the refresh is called. - * */ - refreshing, - - /** - * Get messages, this state is for render - * For example, if a user receives a new messages while searching for an old message - * for this case, new messages will be included here. - * */ - messages, - - /** - * If the `shouldCountNewMessages()` is true, only then push in the newMessages state. - * (Return false for the `shouldCountNewMessages()` if the message scroll is the most recent; otherwise, return true.) - * - * A new message means a message that meets the below conditions - * - Not admin message - * - Not updated message - * - Not current user's message - * */ - newMessages, - - /** - * Reset new message list - * @return {void} - * */ - resetNewMessages, - - /** - * Reset message list and create a new collection for latest messages - * @return {Promise} - * */ - refresh, - - /** - * Load previous messages to state - * @return {Promise} - * */ - loadPrevious, - - /** - * Check if there are more previous messages to fetch - * @return {boolean} - * */ - hasPrevious, - - /** - * Load next messages to state - * @return {Promise} - * */ - loadNext, - - /** - * Check if there are more next messages to fetch - * @return {boolean} - * */ - hasNext, - - /** - * Send user message - * @param {UserMessageCreateParams} params user message create params - * @param {function} [onPending] pending message callback - * @return {Promise} succeeded message - * */ - sendUserMessage, - - /** - * Send file message - * @param {FileMessageCreateParams} params file message create params - * @param {function} [onPending] pending message callback - * @return {Promise} succeeded message - * */ - sendFileMessage, - - /** - * Send file messages - * @param {FileMessageCreateParams[]} paramList file message create params - * @param {function} [onPending] pending message callback for each message request - * @return {Promise} succeeded or failed message - * */ - sendFileMessages, - - /** - * Send multiple files message - * @param {MultipleFilesMessageCreateParams} params multiple files message create params - * @param {function} [onPending] pending message callback - * @return {Promise} succeeded message - * */ - sendMultipleFilesMessage, - - /** - * Update user message - * @param {number} messageId - * @param {UserMessageUpdateParams} params user message update params - * @return {Promise} - * */ - updateUserMessage, - - /** - * Update file message - * @param {number} messageId - * @param {FileMessageUpdateParams} params file message update params - * @return {Promise} - * */ - updateFileMessage, - - /** - * Resend failed message - * @template {UserMessage | FileMessage | MultipleFilesMessage} T - * @param {T} failedMessage message to resend - * @return {Promise} - * */ - resendMessage, - - /** - * Delete a message - * @template {UserMessage | FileMessage | MultipleFilesMessage} T - * @param {T} message succeeded or failed message - * @return {Promise} - * */ - deleteMessage, - - /** - * Reset message list and create a new collection with starting point - * @param {number} startingPoint - * @param {function} callback - * @return {void} - * */ - resetWithStartingPoint, - }; -}; From 48c672795fde2436f0fa1f9323b74d233f5524c5 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Thu, 30 May 2024 16:08:43 +0900 Subject: [PATCH 03/46] Fixed lint --- .../src/components/ChannelInput/SendInput.tsx | 15 ++- .../GroupChannelMessageParentMessage.tsx | 2 +- .../GroupChannelMessageReplyInfo.tsx | 57 +++++--- .../GroupChannelMessageRenderer/index.tsx | 25 ++-- .../ReactionAddons/MessageReactionAddon.tsx | 21 ++- .../ThreadParentMessage.file.image.tsx | 9 +- .../ThreadParentMessage.file.tsx | 18 ++- .../ThreadParentMessage.file.video.tsx | 15 ++- .../ThreadParentMessage.file.voice.tsx | 50 +++---- .../ThreadParentMessage.user.og.tsx | 88 +++++++------ .../ThreadParentMessage.user.tsx | 20 +-- .../ThreadParentMessageRenderer/index.tsx | 69 ++++++---- .../component/GroupChannelMessageList.tsx | 29 +++-- .../groupChannel/module/moduleContext.tsx | 2 +- .../src/domain/groupChannel/types.ts | 5 +- .../component/GroupChannelThreadHeader.tsx | 27 ++-- .../component/GroupChannelThreadInput.tsx | 4 +- .../GroupChannelThreadMessageList.tsx | 17 +-- .../GroupChannelThreadParentMessageInfo.tsx | 123 +++++++++++------- .../module/createGroupChannelThreadModule.tsx | 22 ++-- .../module/moduleContext.tsx | 42 +++--- .../src/domain/groupChannelThread/types.ts | 40 +++--- .../fragments/createGroupChannelFragment.tsx | 19 ++- .../createGroupChannelThreadFragment.tsx | 112 ++++++++-------- .../src/localization/StringSet.type.ts | 8 +- .../src/localization/createBaseStringSet.ts | 10 +- packages/uikit-utils/src/ui-format/common.ts | 2 +- sample/src/App.tsx | 5 +- sample/src/libs/navigation.ts | 12 +- .../uikit/groupChannel/GroupChannelScreen.tsx | 5 +- .../groupChannel/GroupChannelThreadScreen.tsx | 11 +- 31 files changed, 508 insertions(+), 376 deletions(-) diff --git a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx index 3c22f9df5..7d0378cc2 100644 --- a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx +++ b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx @@ -69,21 +69,24 @@ const SendInput = forwardRef(function SendInput( visible: voiceMessageInputVisible, setVisible: setVoiceMessageInputVisible, } = useDeferredModalState(); - + const messageReplyParams = useIIFE(() => { const { groupChannel } = sbOptions.uikit; - if (!channel.isGroupChannel() || groupChannel.channel.replyType === 'none' - || (groupChannel.channel.replyType === 'quote_reply' && !messageToReply) - || (groupChannel.channel.replyType === 'thread' && !messageToThread)) { + if ( + !channel.isGroupChannel() || + groupChannel.channel.replyType === 'none' || + (groupChannel.channel.replyType === 'quote_reply' && !messageToReply) || + (groupChannel.channel.replyType === 'thread' && !messageToThread) + ) { return {}; } - + return { parentMessageId: messageToReply?.messageId ?? messageToThread?.messageId, isReplyToChannel: true, }; }); - + const messageMentionParams = useIIFE(() => { const { groupChannel } = sbOptions.uikit; if (!channel.isGroupChannel() || !groupChannel.channel.enableMention) return {}; diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageParentMessage.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageParentMessage.tsx index 2ef26a196..cf781c37c 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageParentMessage.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageParentMessage.tsx @@ -30,7 +30,7 @@ type Props = { channel: SendbirdGroupChannel; message: SendbirdUserMessage | SendbirdFileMessage; childMessage: SendbirdUserMessage | SendbirdFileMessage; - onPress?: (parentMessage: SendbirdMessage, childMessage: SendbirdUserMessage | SendbirdFileMessage) => void; + onPress?: (parentMessage: SendbirdMessage, childMessage: SendbirdUserMessage | SendbirdFileMessage) => void; }; const GroupChannelMessageParentMessage = ({ variant, channel, message, childMessage, onPress }: Props) => { diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx index 0c51d666b..dae1909d8 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -1,6 +1,15 @@ import React from 'react'; + import { User } from '@sendbird/chat'; -import { Avatar, Box, createStyleSheet, Icon, PressBox, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { + Avatar, + Box, + Icon, + PressBox, + Text, + createStyleSheet, + useUIKitTheme, +} from '@sendbird/uikit-react-native-foundation'; import { SendbirdFileMessage, SendbirdGroupChannel, SendbirdMessage, SendbirdUserMessage } from '@sendbird/uikit-utils'; import { useLocalization } from '../../hooks/useContext'; @@ -15,21 +24,25 @@ type Props = { const createRepliedUserAvatars = (mostRepliedUsers: User[]) => { if (!mostRepliedUsers || mostRepliedUsers.length === 0) return null; - + const { palette } = useUIKitTheme(); - + return mostRepliedUsers.slice(0, AVATAR_LIMIT).map((user, index) => { if (index < AVATAR_LIMIT - 1) { - return - - ; + return ( + + + + ); } else { - return - - - + return ( + + + + + - ; + ); } }); }; @@ -37,22 +50,24 @@ const createRepliedUserAvatars = (mostRepliedUsers: User[]) => { const GroupChannelMessageReplyInfo = ({ channel, message, onPress }: Props) => { const { STRINGS } = useLocalization(); const { select, palette } = useUIKitTheme(); - + if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; - + const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_POSTFIX(message.threadInfo.replyCount || 0); const onPressReply = () => { onPress?.(message as SendbirdUserMessage | SendbirdFileMessage); }; - + const renderAvatars = createRepliedUserAvatars(message.threadInfo.mostRepliedUsers); - - return - {renderAvatars} - - {replyCountText} - - ; + + return ( + + {renderAvatars} + + {replyCountText} + + + ); }; const styles = createStyleSheet({ diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index 580593af2..5a853d305 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -61,7 +61,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' nextMessage, ); const variant = isMyMessage(message, currentUser?.userId) ? 'outgoing' : 'incoming'; - + const reactionChildren = useIIFE(() => { const configs = sbOptions.uikitWithAppInfo.groupChannel.channel; if ( @@ -73,13 +73,13 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' } return null; }); - + const renderReplyInfo = useIIFE(() => { if (sbOptions.uikit.groupChannel.channel.replyType !== 'thread') return null; if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; return ; }); - + const resetPlayer = async () => { playerUnsubscribes.current.forEach((unsubscribe) => { try { @@ -158,15 +158,16 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' sendingStatus: isMyMessage(message, currentUser?.userId) ? ( ) : null, - parentMessage: (!hideParentMessage && shouldRenderParentMessage(message)) ? ( - - ) : null, + parentMessage: + !hideParentMessage && shouldRenderParentMessage(message) ? ( + + ) : null, strings: { edited: STRINGS.GROUP_CHANNEL.MESSAGE_BUBBLE_EDITED_POSTFIX, senderName: ('sender' in message && message.sender.nickname) || STRINGS.LABELS.USER_NO_NAME, diff --git a/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx b/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx index 3602e1907..4af09b7c0 100644 --- a/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx +++ b/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx @@ -75,13 +75,21 @@ const createReactionButtons = ( return buttons; }; -const MessageReactionAddon = ({ channel, message, reactionAddonType = 'default'}: { channel: SendbirdBaseChannel; message: SendbirdBaseMessage, reactionAddonType?: ReactionAddonType }) => { +const MessageReactionAddon = ({ + channel, + message, + reactionAddonType = 'default', +}: { + channel: SendbirdBaseChannel; + message: SendbirdBaseMessage; + reactionAddonType?: ReactionAddonType; +}) => { const { colors } = useUIKitTheme(); const { emojiManager, currentUser } = useSendbirdChat(); const { openReactionList, openReactionUserList } = useReaction(); - + if (reactionAddonType === 'default' && !message.reactions?.length) return null; - + const reactionButtons = createReactionButtons( channel, message, @@ -91,9 +99,10 @@ const MessageReactionAddon = ({ channel, message, reactionAddonType = 'default'} (focusIndex) => openReactionUserList({ channel, message, focusIndex }), currentUser?.userId, ); - - const containerStyle = reactionAddonType === 'default' ? styles.reactionContainer : styles.reactionThreadParentMessageContainer; - + + const containerStyle = + reactionAddonType === 'default' ? styles.reactionContainer : styles.reactionThreadParentMessageContainer; + return ( { const fileMessage: SendbirdFileMessage = props.parentMessage as SendbirdFileMessage; if (!fileMessage) return null; - + return ( diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.tsx index c7c2841f9..5527a04ce 100644 --- a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.tsx +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.tsx @@ -1,23 +1,27 @@ import React from 'react'; -import { SendbirdFileMessage, getFileExtension, getFileType, truncate } from '@sendbird/uikit-utils'; -import { Box, createStyleSheet, PressBox, Icon, Text } from '@sendbird/uikit-react-native-foundation'; + +import { Box, Icon, PressBox, Text, createStyleSheet } from '@sendbird/uikit-react-native-foundation'; import { useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { SendbirdFileMessage, getFileExtension, getFileType, truncate } from '@sendbird/uikit-utils'; -import { ThreadParentMessageRendererProps } from './index'; import { useLocalization } from './../../hooks/useContext'; +import { ThreadParentMessageRendererProps } from './index'; const ThreadParentMessageFile = (props: ThreadParentMessageRendererProps) => { const fileMessage: SendbirdFileMessage = props.parentMessage as SendbirdFileMessage; if (!fileMessage) return null; - + const { STRINGS } = useLocalization(); const { select, colors, palette } = useUIKitTheme(); - + const fileType = getFileType(fileMessage.type || getFileExtension(fileMessage.name)); const fileName = STRINGS.GROUP_CHANNEL.MESSAGE_BUBBLE_FILE_TITLE(fileMessage) ?? fileMessage.name; - + return ( - + Promise<{ path: string } | null> } ->; +type Props = ThreadParentMessageRendererProps<{ + fetchThumbnailFromVideoSource: (uri: string) => Promise<{ path: string } | null>; +}>; const ThreadParentMessageFileVideo = (props: Props) => { const fileMessage: SendbirdFileMessage = props.parentMessage as SendbirdFileMessage; if (!fileMessage) return null; - + const uri = getThumbnailUriFromFileMessage(fileMessage); - + return ( diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx index 5fe9daea6..36423dec1 100644 --- a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { millsToMSS, SendbirdFileMessage } from '@sendbird/uikit-utils'; + import { Box, Icon, PressBox, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { LoadingSpinner, ProgressBar } from '@sendbird/uikit-react-native-foundation'; +import { createStyleSheet } from '@sendbird/uikit-react-native-foundation'; +import { SendbirdFileMessage, millsToMSS } from '@sendbird/uikit-utils'; import { ThreadParentMessageRendererProps } from './index'; -import { ProgressBar, LoadingSpinner } from '@sendbird/uikit-react-native-foundation'; -import { createStyleSheet } from '@sendbird/uikit-react-native-foundation'; export type VoiceFileMessageState = { status: 'preparing' | 'playing' | 'paused'; @@ -12,12 +13,10 @@ export type VoiceFileMessageState = { duration: number; }; -type Props = ThreadParentMessageRendererProps< - { - durationMetaArrayKey?: string; - onUnmount: () => void; - } ->; +type Props = ThreadParentMessageRendererProps<{ + durationMetaArrayKey?: string; + onUnmount: () => void; +}>; const ThreadParentMessageFileVoice = (props: Props) => { const { @@ -27,12 +26,12 @@ const ThreadParentMessageFileVoice = (props: Props) => { durationMetaArrayKey = 'KEY_VOICE_MESSAGE_DURATION', onUnmount, } = props; - + const fileMessage: SendbirdFileMessage = parentMessage as SendbirdFileMessage; if (!fileMessage) return null; - + const { colors } = useUIKitTheme(); - + const [state, setState] = useState(() => { const meta = fileMessage.metaArrays.find((it) => it.key === durationMetaArrayKey); const value = meta?.value?.[0]; @@ -43,16 +42,16 @@ const ThreadParentMessageFileVoice = (props: Props) => { duration: initialDuration, }; }); - + useEffect(() => { return () => { onUnmount(); }; }, []); - + const uiColors = colors.ui.groupChannelMessage['incoming']; const remainingTime = state.duration - state.currentTime; - + return ( onToggleVoiceMessage?.(state, setState)} onLongPress={onLongPress}> @@ -72,17 +71,20 @@ const ThreadParentMessageFileVoice = (props: Props) => { {state.status === 'preparing' ? ( ) : ( - )} - {millsToMSS(state.currentTime === 0 ? state.duration : remainingTime)} diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx index 5076724c6..243c2264a 100644 --- a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx @@ -1,39 +1,46 @@ import React from 'react'; -import { Box, createStyleSheet, ImageWithPlaceholder, PressBox, RegexText, type RegexTextPattern, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; + +import { + Box, + ImageWithPlaceholder, + PressBox, + RegexText, + type RegexTextPattern, + Text, + createStyleSheet, + useUIKitTheme, +} from '@sendbird/uikit-react-native-foundation'; import { SendbirdUserMessage, urlRegexStrict, useFreshCallback } from '@sendbird/uikit-utils'; + import { useSendbirdChat } from './../../hooks/useContext'; import { ThreadParentMessageRendererProps } from './index'; -type Props = ThreadParentMessageRendererProps< - { - regexTextPatterns?: RegexTextPattern[]; - renderRegexTextChildren?: (message: SendbirdUserMessage) => string; - } ->; +type Props = ThreadParentMessageRendererProps<{ + regexTextPatterns?: RegexTextPattern[]; + renderRegexTextChildren?: (message: SendbirdUserMessage) => string; +}>; const ThreadParentMessageUserOg = (props: Props) => { const userMessage: SendbirdUserMessage = props.parentMessage as SendbirdUserMessage; if (!userMessage) return null; - + const { sbOptions } = useSendbirdChat(); const { select, colors, palette } = useUIKitTheme(); const enableOgtag = sbOptions.uikitWithAppInfo.groupChannel.channel.enableOgtag; - const onPressMessage = (userMessage: SendbirdUserMessage) => useFreshCallback(() => { - typeof userMessage.ogMetaData?.url === 'string' && props.onPressURL?.(userMessage.ogMetaData.url); - }); - + const onPressMessage = (userMessage: SendbirdUserMessage) => + useFreshCallback(() => { + typeof userMessage.ogMetaData?.url === 'string' && props.onPressURL?.(userMessage.ogMetaData.url); + }); + return ( - + { )} - {userMessage.ogMetaData && enableOgtag && - - {!!userMessage.ogMetaData.defaultImage && ( - - )} - - - {userMessage.ogMetaData.title} - - {!!userMessage.ogMetaData.description && ( - - {userMessage.ogMetaData.description} - + {userMessage.ogMetaData && enableOgtag && ( + + + {!!userMessage.ogMetaData.defaultImage && ( + )} - - {userMessage.ogMetaData.url} - + + + {userMessage.ogMetaData.title} + + {!!userMessage.ogMetaData.description && ( + + {userMessage.ogMetaData.description} + + )} + + {userMessage.ogMetaData.url} + + - - } + + )} ); }; diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.tsx index 5a3a1a909..203e0d06a 100644 --- a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.tsx +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.tsx @@ -1,29 +1,29 @@ import React from 'react'; + import { type RegexTextPattern, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { RegexText, createStyleSheet } from '@sendbird/uikit-react-native-foundation'; import { SendbirdUserMessage, urlRegexStrict } from '@sendbird/uikit-utils'; + import { ThreadParentMessageRendererProps } from './index'; -import { RegexText, createStyleSheet } from '@sendbird/uikit-react-native-foundation'; -type Props = ThreadParentMessageRendererProps< - { - regexTextPatterns?: RegexTextPattern[]; - renderRegexTextChildren?: (message: SendbirdUserMessage) => string; - } ->; +type Props = ThreadParentMessageRendererProps<{ + regexTextPatterns?: RegexTextPattern[]; + renderRegexTextChildren?: (message: SendbirdUserMessage) => string; +}>; const ThreadParentMessageUser = (props: Props) => { const userMessage: SendbirdUserMessage = props.parentMessage as SendbirdUserMessage; if (!userMessage) return null; - + const { colors } = useUIKitTheme(); - + return ( = { parentMessage: SendbirdUserMessage | SendbirdFileMessage; @@ -30,7 +38,7 @@ const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => const { palette } = useUIKitTheme(); const { mediaService, playerService } = usePlatformService(); const parentMessage = props.parentMessage; - + const resetPlayer = async () => { playerUnsubscribes.current.forEach((unsubscribe) => { try { @@ -40,7 +48,7 @@ const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => playerUnsubscribes.current.length = 0; await playerService.reset(); }; - + const messageProps: ThreadParentMessageRendererProps = { onPressURL: (url) => SBUUtils.openURL(url), onToggleVoiceMessage: async (state, setState) => { @@ -55,10 +63,10 @@ const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => if (playerService.state !== 'idle') { await resetPlayer(); } - + const shouldSeekToTime = state.duration > state.currentTime && state.currentTime > 0; let seekFinished = !shouldSeekToTime; - + const forPlayback = playerService.addPlaybackListener(({ stopped, currentTime, duration }) => { if (seekFinished) { setState((prevState) => ({ ...prevState, currentTime: stopped ? 0 : currentTime, duration })); @@ -83,7 +91,7 @@ const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => } }); playerUnsubscribes.current.push(forPlayback, forState); - + await playerService.play(parentMessage.url); if (shouldSeekToTime) { await playerService.seek(state.currentTime); @@ -94,7 +102,7 @@ const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => }, ...props, }; - + const userMessageProps: { renderRegexTextChildren: (message: SendbirdUserMessage) => string; regexTextPatterns: RegexTextPattern[]; @@ -118,7 +126,7 @@ const ThreadParentMessageRenderer = (props: ThreadParentMessageRendererProps) => !isMyMessage(parentMessage, currentUser?.userId) && user.userId === currentUser?.userId ? palette.onBackgroundLight01 : parentProps?.color; - + return ( }, ], }; - + switch (getMessageType(props.parentMessage)) { case 'user': { - return ; + return ; } case 'user.opengraph': { - return ; + return ; } case 'file': case 'file.audio': { - return ; + return ; } case 'file.video': { - return mediaService.getVideoThumbnail({ url: uri, timeMills: 1000 })} - {...messageProps} />; + return ( + mediaService.getVideoThumbnail({ url: uri, timeMills: 1000 })} + {...messageProps} + /> + ); } case 'file.image': { - return ; + return ; } case 'file.voice': { - return { - if (isVoiceMessage(parentMessage) && playerService.uri === parentMessage.url) { - resetPlayer(); - } - }}{...messageProps} />; + return ( + { + if (isVoiceMessage(parentMessage) && playerService.uri === parentMessage.url) { + resetPlayer(); + } + }} + {...messageProps} + /> + ); } default: { return null; diff --git a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx index 58e1d068a..66f9fd2f8 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx @@ -17,7 +17,9 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { const { sdk, sbOptions } = useSendbirdChat(); const { setMessageToEdit, setMessageToReply } = useContext(GroupChannelContexts.Fragment); const { subscribe } = useContext(GroupChannelContexts.PubSub); - const { flatListRef, lazyScrollToBottom, lazyScrollToIndex, onPressReplyMessageInThread } = useContext(GroupChannelContexts.MessageList); + const { flatListRef, lazyScrollToBottom, lazyScrollToIndex, onPressReplyMessageInThread } = useContext( + GroupChannelContexts.MessageList, + ); const id = useUniqHandlerId('GroupChannelMessageList'); const isFirstMount = useIsFirstMount(); @@ -98,16 +100,21 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { scrollToMessageWithCreatedAt(props.searchItem.startingPoint, false, MESSAGE_SEARCH_SAFE_SCROLL_DELAY); } }, [isFirstMount]); - - const onPressParentMessage = useFreshCallback((parentMessage: SendbirdMessage, childMessage: SendbirdSendableMessage) => { - if (onPressReplyMessageInThread && sbOptions.uikit.groupChannel.channel.replyType === 'thread' - && sbOptions.uikit.groupChannel.channel.threadReplySelectType === 'thread') { - onPressReplyMessageInThread(parentMessage as SendbirdSendableMessage, childMessage.createdAt); - } else { - const canScrollToParent = scrollToMessageWithCreatedAt(parentMessage.createdAt, true, 0); - if (!canScrollToParent) toast.show(STRINGS.TOAST.FIND_PARENT_MSG_ERROR, 'error'); - } - }); + + const onPressParentMessage = useFreshCallback( + (parentMessage: SendbirdMessage, childMessage: SendbirdSendableMessage) => { + if ( + onPressReplyMessageInThread && + sbOptions.uikit.groupChannel.channel.replyType === 'thread' && + sbOptions.uikit.groupChannel.channel.threadReplySelectType === 'thread' + ) { + onPressReplyMessageInThread(parentMessage as SendbirdSendableMessage, childMessage.createdAt); + } else { + const canScrollToParent = scrollToMessageWithCreatedAt(parentMessage.createdAt, true, 0); + if (!canScrollToParent) toast.show(STRINGS.TOAST.FIND_PARENT_MSG_ERROR, 'error'); + } + }, + ); return ( { if (sbOptions.uikit.groupChannel.channel.replyType === 'thread' && parentMessage) { onPressReplyMessageInThread?.(parentMessage, Number.MAX_SAFE_INTEGER); diff --git a/packages/uikit-react-native/src/domain/groupChannel/types.ts b/packages/uikit-react-native/src/domain/groupChannel/types.ts index c452e5e6b..45bd7cc71 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/types.ts +++ b/packages/uikit-react-native/src/domain/groupChannel/types.ts @@ -10,7 +10,8 @@ import type { SendbirdFileMessageCreateParams, SendbirdFileMessageUpdateParams, SendbirdGroupChannel, - SendbirdMessage, SendbirdSendableMessage, + SendbirdMessage, + SendbirdSendableMessage, SendbirdUser, SendbirdUserMessage, SendbirdUserMessageCreateParams, @@ -173,7 +174,7 @@ export interface GroupChannelContextsType { timeout?: number; viewPosition?: number; }) => void; - + onPressReplyMessageInThread?: (parentMessage: SendbirdSendableMessage, startingPoint?: number) => void; }>; } diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx index 818c90f7a..b31adb5cc 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx @@ -1,11 +1,11 @@ import React, { useContext } from 'react'; import { View } from 'react-native'; -import { createStyleSheet, Icon, Text, useHeaderStyle, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { Icon, Text, createStyleSheet, useHeaderStyle, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { useLocalization, useSendbirdChat } from '../../../hooks/useContext'; import { GroupChannelThreadContexts } from '../module/moduleContext'; import type { GroupChannelThreadProps } from '../types'; -import { useLocalization, useSendbirdChat } from '../../../hooks/useContext'; const GroupChannelThreadHeader = ({ onPressHeaderLeft }: GroupChannelThreadProps['Header']) => { const { headerTitle, channel } = useContext(GroupChannelThreadContexts.Fragment); @@ -13,22 +13,31 @@ const GroupChannelThreadHeader = ({ onPressHeaderLeft }: GroupChannelThreadProps const { STRINGS } = useLocalization(); const { select, colors, palette } = useUIKitTheme(); const { currentUser } = useSendbirdChat(); - + const renderSubtitle = () => { if (!currentUser) return null; - - return ( - {STRINGS.GROUP_CHANNEL_THREAD.HEADER_SUBTITLE(currentUser.userId, channel)} - ); + + return ( + + {STRINGS.GROUP_CHANNEL_THREAD.HEADER_SUBTITLE(currentUser.userId, channel)} + + ); }; - + return ( - {headerTitle} + + {headerTitle} + {renderSubtitle()} diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx index 836fe7eaf..446f50505 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx @@ -27,7 +27,9 @@ const GroupChannelThreadInput = ({ inputDisabled, ...props }: GroupChannelThread inputMuted={chatAvailableState.muted} inputFrozen={chatAvailableState.frozen} inputDisabled={inputDisabled ?? chatAvailableState.disabled} - MessageToReplyPreview={()=>{return null;}} + MessageToReplyPreview={() => { + return null; + }} {...props} /> ); diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx index aa6cf3cf1..a9ea5f9f3 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx @@ -1,6 +1,7 @@ +import React, { useContext, useEffect, useLayoutEffect } from 'react'; + import { useChannelHandler } from '@sendbird/uikit-chat-hooks'; import { isDifferentChannel, useFreshCallback, useUniqHandlerId } from '@sendbird/uikit-utils'; -import React, { useContext, useEffect, useLayoutEffect } from 'react'; import ChannelMessageList from '../../../components/ChannelMessageList'; import { useSendbirdChat } from '../../../hooks/useContext'; @@ -12,13 +13,13 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi const { setMessageToEdit } = useContext(GroupChannelThreadContexts.Fragment); const { subscribe } = useContext(GroupChannelThreadContexts.PubSub); const { flatListRef, lazyScrollToBottom, lazyScrollToIndex } = useContext(GroupChannelThreadContexts.MessageList); - + const id = useUniqHandlerId('GroupChannelThreadMessageList'); - + const scrollToBottom = useFreshCallback(async (animated = false) => { if (props.hasNext()) { props.onScrolledAwayFromBottom(false); - + await props.onResetMessageList(); props.onScrolledAwayFromBottom(false); lazyScrollToBottom({ animated }); @@ -26,7 +27,7 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi lazyScrollToBottom({ animated }); } }); - + useLayoutEffect(() => { if (props.startingPoint) { const foundMessageIndex = props.messages.findIndex((it) => it.createdAt === props.startingPoint); @@ -36,7 +37,7 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi } } }, [props.startingPoint]); - + useChannelHandler(sdk, id, { onReactionUpdated(channel, event) { if (isDifferentChannel(channel, props.channel)) return; @@ -48,7 +49,7 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi } }, }); - + useEffect(() => { return subscribe(({ type }) => { switch (type) { @@ -67,7 +68,7 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi } }); }, [props.scrolledAwayFromBottom]); - + return ( void; onLongPress?: () => void; bottomSheetItem?: BottomSheetItem }; @@ -20,9 +46,9 @@ const GroupChannelThreadParentMessageInfo = (props: GroupChannelThreadProps['Par const { STRINGS } = useLocalization(); const { colors } = useUIKitTheme(); const { sbOptions } = useSendbirdChat(); - + const nickName = parentMessage.sender?.nickname || STRINGS.LABELS.USER_NO_NAME; - const messageTimestamp = format(new Date(parentMessage.updatedAt), 'MMM dd \'at\' h:mm a'); + const messageTimestamp = format(new Date(parentMessage.updatedAt), "MMM dd 'at' h:mm a"); const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_POSTFIX(parentMessage.threadInfo?.replyCount || 0); const createMessagePressActions = useCreateMessagePressActions({ channel: props.channel, @@ -32,24 +58,26 @@ const GroupChannelThreadParentMessageInfo = (props: GroupChannelThreadProps['Par onEditMessage: setMessageToEdit, }); const { onPress, onLongPress, bottomSheetItem } = createMessagePressActions({ message: parentMessage }); - + const renderMessageInfoAndMenu = () => { return ( - {nickName} - {messageTimestamp} + + {nickName} + + + {messageTimestamp} + - + ); }; - + const renderReplyCount = (replyCountText: string) => { if (replyCountText) { return ( @@ -64,29 +92,33 @@ const GroupChannelThreadParentMessageInfo = (props: GroupChannelThreadProps['Par return null; } }; - + const renderReactionAddons = () => { const configs = sbOptions.uikitWithAppInfo.groupChannel.channel; if (shouldRenderReaction(channel, channel.isSuper ? configs.enableReactionsSupergroup : configs.enableReactions)) { - return - - ; + return ( + + + + ); } else { return null; } }; - + const messageProps: ThreadParentMessageRendererProps = { parentMessage, onPress, onLongPress, }; - + return ( - - {renderMessageInfoAndMenu()} - + {renderMessageInfoAndMenu()} @@ -142,43 +174,40 @@ const styles = createStyleSheet({ }); const useCreateMessagePressActions = ({ - channel, - currentUserId, - onDeleteMessage, - onPressMediaMessage, - onEditMessage, - }: Pick< + channel, + currentUserId, + onDeleteMessage, + onPressMediaMessage, + onEditMessage, +}: Pick< GroupChannelThreadProps['ParentMessageInfo'], - | 'channel' - | 'currentUserId' - | 'onDeleteMessage' - | 'onPressMediaMessage' -> & { onEditMessage: (message: HandleableMessage) => void; }): CreateMessagePressActions => { + 'channel' | 'currentUserId' | 'onDeleteMessage' | 'onPressMediaMessage' +> & { onEditMessage: (message: HandleableMessage) => void }): CreateMessagePressActions => { const { STRINGS } = useLocalization(); const toast = useToast(); const { openSheet } = useBottomSheet(); const { alert } = useAlert(); const { clipboardService, fileService } = usePlatformService(); const { sbOptions } = useSendbirdChat(); - + const onDeleteFailure = (error: Error) => { toast.show(STRINGS.TOAST.DELETE_MSG_ERROR, 'error'); Logger.error(STRINGS.TOAST.DELETE_MSG_ERROR, error); }; - + const onCopyText = (message: HandleableMessage) => { if (message.isUserMessage()) { clipboardService.setString(message.message || ''); toast.show(STRINGS.TOAST.COPY_OK, 'success'); } }; - + const onDownloadFile = (message: HandleableMessage) => { if (message.isFileMessage()) { if (toMegabyte(message.size) > 4) { toast.show(STRINGS.TOAST.DOWNLOAD_START, 'success'); } - + fileService .save({ fileUrl: message.url, fileName: message.name, fileType: message.type }) .then((response) => { @@ -191,7 +220,7 @@ const useCreateMessagePressActions = ({ }); } }; - + const onOpenFile = (message: HandleableMessage) => { if (message.isFileMessage()) { const fileType = getFileType(message.type || getFileExtension(message.name)); @@ -202,7 +231,7 @@ const useCreateMessagePressActions = ({ } } }; - + const alertForMessageDelete = (message: HandleableMessage) => { alert({ title: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_TITLE, @@ -218,10 +247,10 @@ const useCreateMessagePressActions = ({ ], }); }; - + return ({ message }) => { if (!message.isUserMessage() && !message.isFileMessage()) return {}; - + const sheetItems: BottomSheetItem['sheetItems'] = []; const menu = { copy: (message: HandleableMessage) => ({ @@ -246,7 +275,7 @@ const useCreateMessagePressActions = ({ onPress: () => onDownloadFile(message), }), }; - + if (message.isUserMessage()) { sheetItems.push(menu.copy(message)); if (!channel.isEphemeral) { @@ -256,7 +285,7 @@ const useCreateMessagePressActions = ({ } } } - + if (message.isFileMessage()) { if (!isVoiceMessage(message)) { sheetItems.push(menu.download(message)); @@ -267,7 +296,7 @@ const useCreateMessagePressActions = ({ } } } - + const configs = sbOptions.uikitWithAppInfo.groupChannel.channel; const bottomSheetItem: BottomSheetItem = { sheetItems, @@ -278,7 +307,7 @@ const useCreateMessagePressActions = ({ ? ({ onClose }) => : undefined, }; - + if (message.isFileMessage()) { return { onPress: () => onOpenFile(message), diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/module/createGroupChannelThreadModule.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/module/createGroupChannelThreadModule.tsx index 211a230fe..3cdd017db 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/module/createGroupChannelThreadModule.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/module/createGroupChannelThreadModule.tsx @@ -1,24 +1,24 @@ import GroupChannelThreadHeader from '../component/GroupChannelThreadHeader'; import GroupChannelThreadInput from '../component/GroupChannelThreadInput'; import GroupChannelThreadMessageList from '../component/GroupChannelThreadMessageList'; +import GroupChannelThreadParentMessageInfo from '../component/GroupChannelThreadParentMessageInfo'; import GroupChannelThreadStatusEmpty from '../component/GroupChannelThreadStatusEmpty'; import GroupChannelThreadStatusLoading from '../component/GroupChannelThreadStatusLoading'; import GroupChannelThreadSuggestedMentionList from '../component/GroupChannelThreadSuggestedMentionList'; import type { GroupChannelThreadModule } from '../types'; import { GroupChannelThreadContextsProvider } from './moduleContext'; -import GroupChannelThreadParentMessageInfo from '../component/GroupChannelThreadParentMessageInfo'; const createGroupChannelThreadModule = ({ - Header = GroupChannelThreadHeader, - ParentMessageInfo = GroupChannelThreadParentMessageInfo, - MessageList = GroupChannelThreadMessageList, - Input = GroupChannelThreadInput, - SuggestedMentionList = GroupChannelThreadSuggestedMentionList, - StatusLoading = GroupChannelThreadStatusLoading, - StatusEmpty = GroupChannelThreadStatusEmpty, - Provider = GroupChannelThreadContextsProvider, - ...module - }: Partial = {}): GroupChannelThreadModule => { + Header = GroupChannelThreadHeader, + ParentMessageInfo = GroupChannelThreadParentMessageInfo, + MessageList = GroupChannelThreadMessageList, + Input = GroupChannelThreadInput, + SuggestedMentionList = GroupChannelThreadSuggestedMentionList, + StatusLoading = GroupChannelThreadStatusLoading, + StatusEmpty = GroupChannelThreadStatusEmpty, + Provider = GroupChannelThreadContextsProvider, + ...module +}: Partial = {}): GroupChannelThreadModule => { return { Header, ParentMessageInfo, diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx index d5ba44636..6809f9769 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx @@ -15,7 +15,11 @@ import { import ProviderLayout from '../../../components/ProviderLayout'; import { useLocalization } from '../../../hooks/useContext'; import type { PubSub } from '../../../utils/pubsub'; -import type { GroupChannelThreadContextsType, GroupChannelThreadModule, GroupChannelThreadPubSubContextPayload } from '../types'; +import type { + GroupChannelThreadContextsType, + GroupChannelThreadModule, + GroupChannelThreadPubSubContextPayload, +} from '../types'; import { GroupChannelThreadProps } from '../types'; export const GroupChannelThreadContexts: GroupChannelThreadContextsType = { @@ -42,21 +46,21 @@ export const GroupChannelThreadContexts: GroupChannelThreadContextsType = { }; export const GroupChannelThreadContextsProvider: GroupChannelThreadModule['Provider'] = ({ - children, - channel, - parentMessage, - keyboardAvoidOffset = 0, - groupChannelThreadPubSub, - threadedMessages, - }) => { + children, + channel, + parentMessage, + keyboardAvoidOffset = 0, + groupChannelThreadPubSub, + threadedMessages, +}) => { if (!channel) throw new Error('GroupChannel is not provided to GroupChannelThreadModule'); - + const { STRINGS } = useLocalization(); const [messageToEdit, setMessageToEdit] = useState(); const { flatListRef, lazyScrollToIndex, lazyScrollToBottom, scrollToMessage } = useScrollActions({ threadedMessages: threadedMessages, }); - + return ( ) => { const { threadedMessages } = params; const flatListRef = useRef>(null); - + // FIXME: Workaround, should run after data has been applied to UI. const lazyScrollToBottom = useFreshCallback((params) => { if (!flatListRef.current) { logFlatListRefWarning(); return; } - + setTimeout(() => { if (flatListRef.current) { flatListRef.current.scrollToOffset({ offset: 0, animated: params?.animated ?? false }); } }, params?.timeout ?? 0); }); - + // FIXME: Workaround, should run after data has been applied to UI. const lazyScrollToIndex = useFreshCallback((params) => { if (!flatListRef.current) { logFlatListRefWarning(); return; } - + setTimeout(() => { if (flatListRef.current) { flatListRef.current.scrollToIndex({ @@ -122,16 +126,16 @@ const useScrollActions = (params: Pick((messageId, options) => { if (!flatListRef.current) { logFlatListRefWarning(); return false; } - + const foundMessageIndex = threadedMessages.findIndex((it) => it.messageId === messageId); const isIncludedInList = foundMessageIndex > -1; - + if (isIncludedInList) { lazyScrollToIndex({ index: foundMessageIndex, @@ -144,7 +148,7 @@ const useScrollActions = (params: Pick { Logger.warn( 'Cannot find flatListRef.current, please render FlatList and pass the flatListRef' + - 'or please try again after FlatList has been rendered.', + 'or please try again after FlatList has been rendered.', ); }; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts index b61c9a2d6..0f504f800 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts +++ b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts @@ -32,23 +32,23 @@ export interface GroupChannelThreadProps { onChannelDeleted: () => void; onPressHeaderLeft: GroupChannelThreadProps['Header']['onPressHeaderLeft']; onPressMediaMessage?: GroupChannelThreadProps['MessageList']['onPressMediaMessage']; - + onBeforeSendUserMessage?: OnBeforeHandler; onBeforeSendFileMessage?: OnBeforeHandler; onBeforeUpdateUserMessage?: OnBeforeHandler; onBeforeUpdateFileMessage?: OnBeforeHandler; - + renderMessage?: GroupChannelThreadProps['MessageList']['renderMessage']; renderNewMessagesButton?: GroupChannelThreadProps['MessageList']['renderNewMessagesButton']; renderScrollToBottomButton?: GroupChannelThreadProps['MessageList']['renderScrollToBottomButton']; - + enableMessageGrouping?: GroupChannelThreadProps['MessageList']['enableMessageGrouping']; - + keyboardAvoidOffset?: GroupChannelThreadProps['Provider']['keyboardAvoidOffset']; flatListProps?: GroupChannelThreadProps['MessageList']['flatListProps']; sortComparator?: UseGroupChannelMessagesOptions['sortComparator']; searchItem?: GroupChannelThreadProps['MessageList']['searchItem']; - + /** * @description You can specify the query parameters for the message list. * @example @@ -64,7 +64,7 @@ export interface GroupChannelThreadProps { onPressHeaderLeft: () => void; }; ParentMessageInfo: { - channel: SendbirdGroupChannel, + channel: SendbirdGroupChannel; currentUserId?: string; onPressContextMenu?: () => void; onDeleteMessage: (message: SendbirdUserMessage | SendbirdFileMessage) => Promise; @@ -106,7 +106,7 @@ export interface GroupChannelThreadProps { | 'AttachmentsButton', 'inputDisabled' >; - + SuggestedMentionList: SuggestedMentionListProps; Provider: { channel: SendbirdGroupChannel; @@ -185,18 +185,18 @@ export type GroupChannelThreadFragment = React.FC): G if (sbOptions.uikit.groupChannel.channel.replyType === 'none') return ReplyType.NONE; else return ReplyType.ONLY_REPLY_TO_CHANNEL; }); - + const { loading, messages, @@ -121,10 +126,12 @@ const createGroupChannelFragment = (initModule?: Partial): G onPressMediaMessage(message, deleteMessage, uri); }, ); - const _onPressReplyMessageInThread = useFreshCallback(async (message: SendbirdSendableMessage, startingPoint?: number) => { - await onBlurFragment(); - onPressReplyMessageInThread(message, startingPoint); - }); + const _onPressReplyMessageInThread = useFreshCallback( + async (message: SendbirdSendableMessage, startingPoint?: number) => { + await onBlurFragment(); + onPressReplyMessageInThread(message, startingPoint); + }, + ); useEffect(() => { return () => { diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index 025e835d4..53be16708 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -3,51 +3,65 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, useToast } from '@sendbird/uikit-react-native-foundation'; import { useGroupChannelThreadMessages } from '@sendbird/uikit-tools'; import type { SendbirdFileMessage, SendbirdGroupChannel, SendbirdUserMessage } from '@sendbird/uikit-utils'; -import { confirmAndMarkAsRead, messageComparator, NOOP, PASS, useFreshCallback, useRefTracker } from '@sendbird/uikit-utils'; +import { + NOOP, + PASS, + confirmAndMarkAsRead, + messageComparator, + useFreshCallback, + useRefTracker, +} from '@sendbird/uikit-utils'; +import GroupChannelMessageRenderer from '../components/GroupChannelMessageRenderer'; import NewMessagesButton from '../components/NewMessagesButton'; import ScrollToBottomButton from '../components/ScrollToBottomButton'; import StatusComposition from '../components/StatusComposition'; -import GroupChannelMessageRenderer from '../components/GroupChannelMessageRenderer'; import createGroupChannelThreadModule from '../domain/groupChannelThread/module/createGroupChannelThreadModule'; -import type { GroupChannelThreadFragment, GroupChannelThreadModule, GroupChannelThreadProps, GroupChannelThreadPubSubContextPayload } from '../domain/groupChannelThread/types'; +import type { + GroupChannelThreadFragment, + GroupChannelThreadModule, + GroupChannelThreadProps, + GroupChannelThreadPubSubContextPayload, +} from '../domain/groupChannelThread/types'; import { useLocalization, usePlatformService, useSendbirdChat } from '../hooks/useContext'; import pubsub from '../utils/pubsub'; -const createGroupChannelThreadFragment = (initModule?: Partial): GroupChannelThreadFragment => { +const createGroupChannelThreadFragment = ( + initModule?: Partial, +): GroupChannelThreadFragment => { const GroupChannelThreadModule = createGroupChannelThreadModule(initModule); - + return ({ - renderNewMessagesButton = (props) => , - renderScrollToBottomButton = (props) => , - renderMessage, - enableMessageGrouping = true, - onPressHeaderLeft = NOOP, - onPressMediaMessage = NOOP, - onParentMessageDeleted = NOOP, - onChannelDeleted = NOOP, - onBeforeSendUserMessage = PASS, - onBeforeSendFileMessage = PASS, - onBeforeUpdateUserMessage = PASS, - onBeforeUpdateFileMessage = PASS, - channel, - parentMessage, - startingPoint, - keyboardAvoidOffset, - sortComparator = messageComparator, - flatListProps, - }) => { + renderNewMessagesButton = (props) => , + renderScrollToBottomButton = (props) => , + renderMessage, + enableMessageGrouping = true, + onPressHeaderLeft = NOOP, + onPressMediaMessage = NOOP, + onParentMessageDeleted = NOOP, + onChannelDeleted = NOOP, + onBeforeSendUserMessage = PASS, + onBeforeSendFileMessage = PASS, + onBeforeUpdateUserMessage = PASS, + onBeforeUpdateFileMessage = PASS, + channel, + parentMessage, + startingPoint, + keyboardAvoidOffset, + sortComparator = messageComparator, + flatListProps, + }) => { const { playerService, recorderService } = usePlatformService(); const { sdk, currentUser, sbOptions } = useSendbirdChat(); - + const [groupChannelThreadPubSub] = useState(() => pubsub()); const [scrolledAwayFromBottom, setScrolledAwayFromBottom] = useState(false); const scrolledAwayFromBottomRef = useRefTracker(scrolledAwayFromBottom); - + const toast = useToast(); const { STRINGS } = useLocalization(); const [_parentMessage, setParentMessage] = useState(parentMessage); - + const { loading, messages, @@ -85,7 +99,7 @@ const createGroupChannelThreadFragment = (initModule?: Partial { return Promise.allSettled([playerService.reset(), recorderService.reset()]); }; @@ -93,28 +107,27 @@ const createGroupChannelThreadFragment = (initModule?: Partial = useFreshCallback( - async (message, deleteMessage, uri) => { + const _onPressMediaMessage: NonNullable = + useFreshCallback(async (message, deleteMessage, uri) => { await onBlurFragment(); onPressMediaMessage(message, deleteMessage, uri); - }, - ); - + }); + useEffect(() => { return () => { onBlurFragment(); }; }, []); - + const renderItem: GroupChannelThreadProps['MessageList']['renderMessage'] = useFreshCallback((props) => { - const content = renderMessage ? renderMessage(props) : ; - return ( - - {content} - + const content = renderMessage ? ( + renderMessage(props) + ) : ( + ); + return {content}; }); - + const memoizedFlatListProps = useMemo( () => ({ ListEmptyComponent: , @@ -123,24 +136,23 @@ const createGroupChannelThreadFragment = (initModule?: Partial { return await resetWithStartingPoint(Number.MAX_SAFE_INTEGER); }, []); - + const onResetMessageListWithStartingPoint = useCallback(async (startingPoint: number) => { return await resetWithStartingPoint(startingPoint); }, []); - - + const onPending = (message: SendbirdFileMessage | SendbirdUserMessage) => { groupChannelThreadPubSub.publish({ type: 'MESSAGE_SENT_PENDING', data: { message } }); }; - + const onSent = (message: SendbirdFileMessage | SendbirdUserMessage) => { groupChannelThreadPubSub.publish({ type: 'MESSAGE_SENT_SUCCESS', data: { message } }); }; - + const onPressSendUserMessage: GroupChannelThreadProps['Input']['onPressSendUserMessage'] = useFreshCallback( async (params) => { const processedParams = await onBeforeSendUserMessage(params); @@ -171,7 +183,7 @@ const createGroupChannelThreadFragment = (initModule?: Partial - + Header */ HEADER_TITLE: string; HEADER_SUBTITLE: (currentUserId: string, channel: SendbirdGroupChannel) => string; - + /** GroupChannelThread > List */ LIST_DATE_SEPARATOR: (date: Date, locale?: Locale) => string; LIST_BUTTON_NEW_MSG: (newMessages: SendbirdMessage[]) => string; - + /** GroupChannelThread > Message bubble */ MESSAGE_BUBBLE_TIME: (message: SendbirdMessage, locale?: Locale) => string; MESSAGE_BUBBLE_FILE_TITLE: (message: SendbirdFileMessage) => string; MESSAGE_BUBBLE_EDITED_POSTFIX: string; MESSAGE_BUBBLE_UNKNOWN_TITLE: (message: SendbirdMessage) => string; MESSAGE_BUBBLE_UNKNOWN_DESC: (message: SendbirdMessage) => string; - + REPLAY_POSTFIX: (replyCount: number) => string; - + /** GroupChannelThread > Suggested mention list */ MENTION_LIMITED: (mentionLimit: number) => string; }; diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index 9793ffde2..0f26ccd40 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -12,8 +12,8 @@ import { getMessageType, getOpenChannelParticipants, getOpenChannelTitle, - isVoiceMessage, getReplyCountFormat, + isVoiceMessage, } from '@sendbird/uikit-utils'; import { UNKNOWN_USER_ID } from '../constants'; @@ -133,15 +133,15 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp HEADER_SUBTITLE: (uid, channel) => getGroupChannelTitle(uid, channel, USER_NO_NAME, CHANNEL_NO_MEMBERS), LIST_DATE_SEPARATOR: (date, locale) => getDateSeparatorFormat(date, locale ?? dateLocale), LIST_BUTTON_NEW_MSG: (newMessages) => `${newMessages.length} new messages`, - + MESSAGE_BUBBLE_TIME: (message, locale) => getMessageTimeFormat(new Date(message.createdAt), locale ?? dateLocale), MESSAGE_BUBBLE_FILE_TITLE: (message) => message.name, MESSAGE_BUBBLE_EDITED_POSTFIX: ' (edited)', MESSAGE_BUBBLE_UNKNOWN_TITLE: () => '(Unknown message type)', MESSAGE_BUBBLE_UNKNOWN_DESC: () => 'Cannot read this message.', - - REPLAY_POSTFIX: (replyCount:number) => getReplyCountFormat(replyCount), - + + REPLAY_POSTFIX: (replyCount: number) => getReplyCountFormat(replyCount), + MENTION_LIMITED: (mentionLimit) => `You can have up to ${mentionLimit} mentions per message.`, ...overrides?.GROUP_CHANNEL_THREAD, }, diff --git a/packages/uikit-utils/src/ui-format/common.ts b/packages/uikit-utils/src/ui-format/common.ts index eb6264717..44293a6b9 100644 --- a/packages/uikit-utils/src/ui-format/common.ts +++ b/packages/uikit-utils/src/ui-format/common.ts @@ -164,6 +164,6 @@ export const getReplyCountFormat = (replyCount: number) => { } else if (replyCount > 1) { return `${replyCount} replies`; } - + return ''; }; diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 290d43e12..40b08ec2f 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -145,7 +145,10 @@ const Navigations = () => { - + diff --git a/sample/src/libs/navigation.ts b/sample/src/libs/navigation.ts index fa28d1ab9..c110b1d5a 100644 --- a/sample/src/libs/navigation.ts +++ b/sample/src/libs/navigation.ts @@ -147,12 +147,12 @@ export type RouteParamsUnion = params: ChannelUrlParams; } | { - route: Routes.GroupChannelThread; - params: { - channelUrl: string; - serializedMessage: object; - startingPoint?: number; - }; + route: Routes.GroupChannelThread; + params: { + channelUrl: string; + serializedMessage: object; + startingPoint?: number; + }; } /** OpenChannel screens **/ | { diff --git a/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx b/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx index 6ddbe42e3..4d0720610 100644 --- a/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx +++ b/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx @@ -97,7 +97,7 @@ const GroupChannelScreen = () => { // Navigate to group channel settings navigation.push(Routes.GroupChannelSettings, params); }} - onPressReplyMessageInThread={(message,startingPoint) => { + onPressReplyMessageInThread={(message, startingPoint) => { // Navigate to thread if (message) { navigation.push(Routes.GroupChannelThread, { @@ -106,8 +106,7 @@ const GroupChannelScreen = () => { startingPoint: startingPoint, }); } - } - } + }} // messageListQueryParams={{ // prevResultLimit: 20, // customTypesFilter: ['filter'], diff --git a/sample/src/screens/uikit/groupChannel/GroupChannelThreadScreen.tsx b/sample/src/screens/uikit/groupChannel/GroupChannelThreadScreen.tsx index 8dc790c3d..9ec14ad53 100644 --- a/sample/src/screens/uikit/groupChannel/GroupChannelThreadScreen.tsx +++ b/sample/src/screens/uikit/groupChannel/GroupChannelThreadScreen.tsx @@ -2,21 +2,24 @@ import React, { useState } from 'react'; import { useGroupChannel } from '@sendbird/uikit-chat-hooks'; import { createGroupChannelThreadFragment, useSendbirdChat } from '@sendbird/uikit-react-native'; +import type { SendbirdFileMessage, SendbirdUserMessage } from '@sendbird/uikit-utils'; import { useAppNavigation } from '../../../hooks/useAppNavigation'; import { Routes } from '../../../libs/navigation'; -import type { SendbirdFileMessage, SendbirdUserMessage } from '@sendbird/uikit-utils'; const GroupChannelThreadFragment = createGroupChannelThreadFragment(); const GroupChannelThreadScreen = () => { const { navigation, params } = useAppNavigation(); - + const { sdk } = useSendbirdChat(); const { channel } = useGroupChannel(sdk, params.channelUrl); - const [parentMessage] = useState(() => sdk.message.buildMessageFromSerializedData(params.serializedMessage) as SendbirdUserMessage | SendbirdFileMessage); + const [parentMessage] = useState( + () => + sdk.message.buildMessageFromSerializedData(params.serializedMessage) as SendbirdUserMessage | SendbirdFileMessage, + ); if (!channel || !parentMessage) return null; - + return ( Date: Thu, 30 May 2024 21:14:32 +0900 Subject: [PATCH 04/46] Update uikit-tools version --- packages/uikit-react-native/package.json | 2 +- .../ThreadParentMessage.user.og.tsx | 11 +++++++++-- yarn.lock | 8 ++++---- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/uikit-react-native/package.json b/packages/uikit-react-native/package.json index 76fa1e4bd..9c2204b3f 100644 --- a/packages/uikit-react-native/package.json +++ b/packages/uikit-react-native/package.json @@ -62,7 +62,7 @@ "@openspacelabs/react-native-zoomable-view": "^2.1.5", "@sendbird/uikit-chat-hooks": "3.5.3", "@sendbird/uikit-react-native-foundation": "3.5.3", - "@sendbird/uikit-tools": "0.0.1-alpha.66", + "@sendbird/uikit-tools": "0.0.1-alpha.74", "@sendbird/uikit-utils": "3.5.3" }, "devDependencies": { diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx index 243c2264a..4372f8b73 100644 --- a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx @@ -68,7 +68,7 @@ const ThreadParentMessageUserOg = (props: Props) => { {userMessage.ogMetaData && enableOgtag && ( - + {!!userMessage.ogMetaData.defaultImage && ( @@ -97,16 +97,23 @@ const ThreadParentMessageUserOg = (props: Props) => { }; const styles = createStyleSheet({ - ogContainer: { + container: { + borderRadius: 16, overflow: 'hidden', + }, + ogContainer: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 12, maxWidth: 240, + borderBottomLeftRadius: 16, + borderBottomRightRadius: 16, }, ogImage: { width: 240, height: 136, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, }, ogUrl: { marginBottom: 4, diff --git a/yarn.lock b/yarn.lock index 733324812..36f5542a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3389,10 +3389,10 @@ resolved "https://registry.yarnpkg.com/@sendbird/react-native-scrollview-enhancer/-/react-native-scrollview-enhancer-0.2.1.tgz#25de4af78293978a4c0ef6fddee25d822a364c46" integrity sha512-LN+Tm+ZUkE2MBVreg/JI8SVr8SOKRteZN0YFpGzRtbKkP45+pKyPN4JQPf73eFx7qO8zDL+TUVyzz/1MOnIK7g== -"@sendbird/uikit-tools@0.0.1-alpha.66": - version "0.0.1-alpha.66" - resolved "https://registry.yarnpkg.com/@sendbird/uikit-tools/-/uikit-tools-0.0.1-alpha.66.tgz#5328f1768c130b15cdec9f0ab9f21d2da269ba62" - integrity sha512-IE8HHqTcAVunnVhcfsxQCRmOoCNHx3c8ZP3eZeqYJB876DrnM38ne5TZVao2ZFSmbiZZQ7xc9DClEiZuZUgvnQ== +"@sendbird/uikit-tools@0.0.1-alpha.74": + version "0.0.1-alpha.74" + resolved "https://registry.yarnpkg.com/@sendbird/uikit-tools/-/uikit-tools-0.0.1-alpha.74.tgz#be4165a287bf0b3829b6a0f1f0bba2dba6f92f02" + integrity sha512-9s6dvznd/GNHeKpRw2SAjPgKsnE8wZvo67z7aSv2fUhVzPNIzSZy6Ojh6ToDOHptYGFvSLw0wqlx4s+SpqC/NQ== "@sideway/address@^4.1.3": version "4.1.4" From 4f06819b8e9c3a3c3d15a534bc188bae52f9a44a Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 16:27:32 +0900 Subject: [PATCH 05/46] Apply review --- .../GroupChannelMessage/MessageContainer.tsx | 8 ++--- .../src/components/ChannelInput/SendInput.tsx | 33 +++++++++-------- .../src/components/ChannelInput/index.tsx | 2 +- .../components/ChannelMessageList/index.tsx | 10 +++--- .../GroupChannelMessageReplyInfo.tsx | 36 +++++++++++++------ .../GroupChannelMessageRenderer/index.tsx | 6 ++-- .../component/GroupChannelThreadInput.tsx | 2 +- .../GroupChannelThreadParentMessageInfo.tsx | 7 ++-- .../module/moduleContext.tsx | 4 +-- .../src/localization/StringSet.type.ts | 5 +-- .../src/localization/createBaseStringSet.ts | 7 ++-- packages/uikit-utils/src/ui-format/common.ts | 11 ++++++ 12 files changed, 77 insertions(+), 54 deletions(-) diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx index 941d3f694..662d15130 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx @@ -67,9 +67,7 @@ MessageContainer.Incoming = function MessageContainerIncoming({ - - {replyInfo} - + {replyInfo} ); }; @@ -102,9 +100,7 @@ MessageContainer.Outgoing = function MessageContainerOutgoing({ {children} - - {replyInfo} - + {replyInfo} ); }; diff --git a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx index 7d0378cc2..b534f8e56 100644 --- a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx +++ b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx @@ -53,7 +53,7 @@ const SendInput = forwardRef(function SendInput( channel, messageToReply, setMessageToReply, - messageToThread, + messageForThread, }, ref, ) { @@ -69,22 +69,25 @@ const SendInput = forwardRef(function SendInput( visible: voiceMessageInputVisible, setVisible: setVoiceMessageInputVisible, } = useDeferredModalState(); - + const messageReplyParams = useIIFE(() => { const { groupChannel } = sbOptions.uikit; - if ( - !channel.isGroupChannel() || - groupChannel.channel.replyType === 'none' || - (groupChannel.channel.replyType === 'quote_reply' && !messageToReply) || - (groupChannel.channel.replyType === 'thread' && !messageToThread) - ) { - return {}; + + if (channel.isGroupChannel()) { + if (groupChannel.channel.replyType === 'quote_reply' && messageToReply) { + return { + parentMessageId: messageToReply.messageId, + isReplyToChannel: true, + }; + } else if (groupChannel.channel.replyType === 'thread' && messageForThread) { + return { + parentMessageId: messageForThread.messageId, + isReplyToChannel: true, + }; + } } - - return { - parentMessageId: messageToReply?.messageId ?? messageToThread?.messageId, - isReplyToChannel: true, - }; + + return {}; }); const messageMentionParams = useIIFE(() => { @@ -161,7 +164,7 @@ const SendInput = forwardRef(function SendInput( if (inputFrozen) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_DISABLED; if (inputDisabled) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_DISABLED; if (messageToReply) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_REPLY; - if (messageToThread) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_THREAD; + if (messageForThread) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_THREAD; return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_ACTIVE; }; diff --git a/packages/uikit-react-native/src/components/ChannelInput/index.tsx b/packages/uikit-react-native/src/components/ChannelInput/index.tsx index 0d110a192..4aeb848cf 100644 --- a/packages/uikit-react-native/src/components/ChannelInput/index.tsx +++ b/packages/uikit-react-native/src/components/ChannelInput/index.tsx @@ -65,7 +65,7 @@ export type ChannelInputProps = { // reply - only available on group channel messageToReply?: undefined | SendbirdUserMessage | SendbirdFileMessage; setMessageToReply?: (message?: undefined | SendbirdUserMessage | SendbirdFileMessage) => void; - messageToThread?: undefined | SendbirdUserMessage | SendbirdFileMessage; + messageForThread?: undefined | SendbirdUserMessage | SendbirdFileMessage; // mention SuggestedMentionList?: CommonComponent; diff --git a/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx b/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx index 4c970b9b6..72fc94fa4 100644 --- a/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx +++ b/packages/uikit-react-native/src/components/ChannelMessageList/index.tsx @@ -369,12 +369,10 @@ const useCreateMessagePressActions = void; }; @@ -47,38 +48,50 @@ const createRepliedUserAvatars = (mostRepliedUsers: User[]) => { }); }; -const GroupChannelMessageReplyInfo = ({ channel, message, onPress }: Props) => { +const GroupChannelMessageReplyInfo = ({ channel, message, variant, onPress }: Props) => { const { STRINGS } = useLocalization(); const { select, palette } = useUIKitTheme(); if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; - const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_POSTFIX(message.threadInfo.replyCount || 0); + const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_COUNT(message.threadInfo.replyCount || 0); const onPressReply = () => { onPress?.(message as SendbirdUserMessage | SendbirdFileMessage); }; const renderAvatars = createRepliedUserAvatars(message.threadInfo.mostRepliedUsers); - + const containerStyle = variant === 'incoming' ? styles.incomingContainer : styles.outgoingContainer; return ( - + + {renderAvatars} - + {replyCountText} + ); }; const styles = createStyleSheet({ - container: { + incomingContainer: { + marginTop: 4, + justifyContent: 'flex-start', + flexDirection: 'row', + height: 20, + }, + outgoingContainer: { + marginTop: 4, flexDirection: 'row', + justifyContent: 'flex-end', + height: 20, }, - messageContainer: { + replyContainer: { + marginLeft: 40, flexDirection: 'row', - alignItems: 'flex-end', + alignItems: 'center', }, - message: { + replyText: { marginHorizontal: 4, }, avatarContainer: { @@ -87,8 +100,9 @@ const styles = createStyleSheet({ height: 20, }, avatar: { - width: '100%', - height: '100%', + width: 20, + height: 20, + borderRadius: 10, }, avatarOverlay: { position: 'absolute', diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index 5a853d305..b04956071 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -74,10 +74,10 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' return null; }); - const renderReplyInfo = useIIFE(() => { + const replyInfo = useIIFE(() => { if (sbOptions.uikit.groupChannel.channel.replyType !== 'thread') return null; if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; - return ; + return ; }); const resetPlayer = async () => { @@ -154,7 +154,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' groupedWithPrev: groupWithPrev, groupedWithNext: groupWithNext, children: reactionChildren, - replyInfo: renderReplyInfo, + replyInfo: replyInfo, sendingStatus: isMyMessage(message, currentUser?.userId) ? ( ) : null, diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx index 446f50505..e1fdca24d 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx @@ -22,7 +22,7 @@ const GroupChannelThreadInput = ({ inputDisabled, ...props }: GroupChannelThread channel={channel} messageToEdit={messageToEdit} setMessageToEdit={setMessageToEdit} - messageToThread={parentMessage} + messageForThread={parentMessage} keyboardAvoidOffset={keyboardAvoidOffset} inputMuted={chatAvailableState.muted} inputFrozen={chatAvailableState.frozen} diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx index c53c6a455..2b73d4c42 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx @@ -1,4 +1,3 @@ -import { format } from 'date-fns'; import React, { useContext } from 'react'; import { TouchableOpacity, View } from 'react-native'; @@ -48,8 +47,8 @@ const GroupChannelThreadParentMessageInfo = (props: GroupChannelThreadProps['Par const { sbOptions } = useSendbirdChat(); const nickName = parentMessage.sender?.nickname || STRINGS.LABELS.USER_NO_NAME; - const messageTimestamp = format(new Date(parentMessage.updatedAt), "MMM dd 'at' h:mm a"); - const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_POSTFIX(parentMessage.threadInfo?.replyCount || 0); + const messageTimestamp = STRINGS.GROUP_CHANNEL_THREAD.PARENT_MESSAGE_TIME(parentMessage); + const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_COUNT(parentMessage.threadInfo?.replyCount || 0); const createMessagePressActions = useCreateMessagePressActions({ channel: props.channel, currentUserId: props.currentUserId, @@ -102,7 +101,7 @@ const GroupChannelThreadParentMessageInfo = (props: GroupChannelThreadProps['Par channel={props.channel} message={parentMessage} reactionAddonType={'thread_parent_message'} - > + /> ); } else { diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx index 6809f9769..8792eafd0 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useRef, useState } from 'react'; +import React, { createContext, useRef, useState } from 'react'; import type { FlatList } from 'react-native'; import { @@ -70,7 +70,7 @@ export const GroupChannelThreadContextsProvider: GroupChannelThreadModule['Provi parentMessage, keyboardAvoidOffset, messageToEdit: messageToEdit, - setMessageToEdit: useCallback((message) => setMessageToEdit(message), []), + setMessageToEdit, }} > diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index 2b67a7c65..ead0ca1d4 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -142,8 +142,9 @@ export interface StringSet { MESSAGE_BUBBLE_EDITED_POSTFIX: string; MESSAGE_BUBBLE_UNKNOWN_TITLE: (message: SendbirdMessage) => string; MESSAGE_BUBBLE_UNKNOWN_DESC: (message: SendbirdMessage) => string; - - REPLAY_POSTFIX: (replyCount: number) => string; + + PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => string; + REPLAY_COUNT: (replyCount: number) => string; /** GroupChannelThread > Suggested mention list */ MENTION_LIMITED: (mentionLimit: number) => string; diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index 0f26ccd40..fe1900b09 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -1,6 +1,6 @@ import type { Locale } from 'date-fns'; -import type { PartialDeep } from '@sendbird/uikit-utils'; +import { getThreadParentMessageTimeFormat, PartialDeep, SendbirdMessage } from '@sendbird/uikit-utils'; import { getDateSeparatorFormat, getGroupChannelPreviewTime, @@ -139,8 +139,9 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp MESSAGE_BUBBLE_EDITED_POSTFIX: ' (edited)', MESSAGE_BUBBLE_UNKNOWN_TITLE: () => '(Unknown message type)', MESSAGE_BUBBLE_UNKNOWN_DESC: () => 'Cannot read this message.', - - REPLAY_POSTFIX: (replyCount: number) => getReplyCountFormat(replyCount), + + PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => getThreadParentMessageTimeFormat(new Date(message.createdAt), locale ?? dateLocale), + REPLAY_COUNT: (replyCount: number) => getReplyCountFormat(replyCount), MENTION_LIMITED: (mentionLimit) => `You can have up to ${mentionLimit} mentions per message.`, ...overrides?.GROUP_CHANNEL_THREAD, diff --git a/packages/uikit-utils/src/ui-format/common.ts b/packages/uikit-utils/src/ui-format/common.ts index 44293a6b9..b516e3b00 100644 --- a/packages/uikit-utils/src/ui-format/common.ts +++ b/packages/uikit-utils/src/ui-format/common.ts @@ -167,3 +167,14 @@ export const getReplyCountFormat = (replyCount: number) => { return ''; }; + +/** + * Thread parent message time format + * + * @param {Date} date + * @param {Locale} [locale] + * @returns {string} + * */ +export const getThreadParentMessageTimeFormat = (date: Date, locale?: Locale): string => { + return format(date, "MMM dd 'at' h:mm a", { locale }); +}; From 252b977f6eedde5a4e648c321bee005783c08b27 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 16:32:15 +0900 Subject: [PATCH 06/46] Fixed lint --- .../src/components/ChannelInput/SendInput.tsx | 6 +++--- .../GroupChannelMessageReplyInfo.tsx | 12 ++++++------ .../components/GroupChannelMessageRenderer/index.tsx | 9 ++++++++- .../src/localization/StringSet.type.ts | 2 +- .../src/localization/createBaseStringSet.ts | 7 ++++--- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx index b534f8e56..f7d2c7f59 100644 --- a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx +++ b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx @@ -69,10 +69,10 @@ const SendInput = forwardRef(function SendInput( visible: voiceMessageInputVisible, setVisible: setVoiceMessageInputVisible, } = useDeferredModalState(); - + const messageReplyParams = useIIFE(() => { const { groupChannel } = sbOptions.uikit; - + if (channel.isGroupChannel()) { if (groupChannel.channel.replyType === 'quote_reply' && messageToReply) { return { @@ -86,7 +86,7 @@ const SendInput = forwardRef(function SendInput( }; } } - + return {}; }); diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx index ccd74f720..a7170b59c 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -63,12 +63,12 @@ const GroupChannelMessageReplyInfo = ({ channel, message, variant, onPress }: Pr const containerStyle = variant === 'incoming' ? styles.incomingContainer : styles.outgoingContainer; return ( - - {renderAvatars} - - {replyCountText} - - + + {renderAvatars} + + {replyCountText} + + ); }; diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index b04956071..ac3e8b34c 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -77,7 +77,14 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' const replyInfo = useIIFE(() => { if (sbOptions.uikit.groupChannel.channel.replyType !== 'thread') return null; if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; - return ; + return ( + + ); }); const resetPlayer = async () => { diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index ead0ca1d4..22df60e1c 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -142,7 +142,7 @@ export interface StringSet { MESSAGE_BUBBLE_EDITED_POSTFIX: string; MESSAGE_BUBBLE_UNKNOWN_TITLE: (message: SendbirdMessage) => string; MESSAGE_BUBBLE_UNKNOWN_DESC: (message: SendbirdMessage) => string; - + PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => string; REPLAY_COUNT: (replyCount: number) => string; diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index fe1900b09..c1f318d3f 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -1,6 +1,6 @@ import type { Locale } from 'date-fns'; -import { getThreadParentMessageTimeFormat, PartialDeep, SendbirdMessage } from '@sendbird/uikit-utils'; +import { PartialDeep, SendbirdMessage, getThreadParentMessageTimeFormat } from '@sendbird/uikit-utils'; import { getDateSeparatorFormat, getGroupChannelPreviewTime, @@ -139,8 +139,9 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp MESSAGE_BUBBLE_EDITED_POSTFIX: ' (edited)', MESSAGE_BUBBLE_UNKNOWN_TITLE: () => '(Unknown message type)', MESSAGE_BUBBLE_UNKNOWN_DESC: () => 'Cannot read this message.', - - PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => getThreadParentMessageTimeFormat(new Date(message.createdAt), locale ?? dateLocale), + + PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => + getThreadParentMessageTimeFormat(new Date(message.createdAt), locale ?? dateLocale), REPLAY_COUNT: (replyCount: number) => getReplyCountFormat(replyCount), MENTION_LIMITED: (mentionLimit) => `You can have up to ${mentionLimit} mentions per message.`, From a4861acd06436e621406932306d61ddf85c1f21b Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 16:41:12 +0900 Subject: [PATCH 07/46] Apply review --- .../GroupChannelMessageReplyInfo.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx index a7170b59c..4ce3f0344 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -76,15 +76,13 @@ const GroupChannelMessageReplyInfo = ({ channel, message, variant, onPress }: Pr const styles = createStyleSheet({ incomingContainer: { marginTop: 4, - justifyContent: 'flex-start', flexDirection: 'row', - height: 20, + justifyContent: 'flex-start', }, outgoingContainer: { marginTop: 4, flexDirection: 'row', justifyContent: 'flex-end', - height: 20, }, replyContainer: { marginLeft: 40, @@ -96,8 +94,6 @@ const styles = createStyleSheet({ }, avatarContainer: { marginRight: 4, - width: 20, - height: 20, }, avatar: { width: 20, From 169b40e95b7f29ad5cf1aa0dd85ed3cbdc1969b2 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 16:47:04 +0900 Subject: [PATCH 08/46] Apply review --- .../GroupChannelMessageReplyInfo.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx index 4ce3f0344..cba0492bb 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -85,12 +85,12 @@ const styles = createStyleSheet({ justifyContent: 'flex-end', }, replyContainer: { - marginLeft: 40, + marginLeft: 36, flexDirection: 'row', alignItems: 'center', }, replyText: { - marginHorizontal: 4, + marginRight: 4, }, avatarContainer: { marginRight: 4, From f2de01ad5d8a1c37fcf06ed6f62a731b4ea1be3c Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 16:50:25 +0900 Subject: [PATCH 09/46] Apply review --- .../GroupChannelMessageReplyInfo.tsx | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx index cba0492bb..6fd58795a 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -32,13 +32,13 @@ const createRepliedUserAvatars = (mostRepliedUsers: User[]) => { if (index < AVATAR_LIMIT - 1) { return ( - + ); } else { return ( - + @@ -65,7 +65,7 @@ const GroupChannelMessageReplyInfo = ({ channel, message, variant, onPress }: Pr {renderAvatars} - + {replyCountText} @@ -89,17 +89,9 @@ const styles = createStyleSheet({ flexDirection: 'row', alignItems: 'center', }, - replyText: { - marginRight: 4, - }, avatarContainer: { marginRight: 4, }, - avatar: { - width: 20, - height: 20, - borderRadius: 10, - }, avatarOverlay: { position: 'absolute', top: 0, From 528c209d51c37b85225ceeb5f9edd08285bbe1a3 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 17:22:57 +0900 Subject: [PATCH 10/46] Apply review --- .../GroupChannelMessage/MessageContainer.tsx | 9 ++++-- .../GroupChannelMessageReplyInfo.tsx | 29 +++++-------------- .../GroupChannelMessageRenderer/index.tsx | 9 +----- 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx index 662d15130..05e34ee2c 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx @@ -67,7 +67,10 @@ MessageContainer.Incoming = function MessageContainerIncoming({ - {replyInfo} + + + {replyInfo} + ); }; @@ -100,7 +103,9 @@ MessageContainer.Outgoing = function MessageContainerOutgoing({ {children} - {replyInfo} + + {replyInfo} + ); }; diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx index 6fd58795a..b2691c84a 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -19,7 +19,6 @@ const AVATAR_LIMIT = 5; type Props = { channel: SendbirdGroupChannel; message: SendbirdMessage; - variant: 'outgoing' | 'incoming'; onPress?: (message: SendbirdUserMessage | SendbirdFileMessage) => void; }; @@ -48,7 +47,7 @@ const createRepliedUserAvatars = (mostRepliedUsers: User[]) => { }); }; -const GroupChannelMessageReplyInfo = ({ channel, message, variant, onPress }: Props) => { +const GroupChannelMessageReplyInfo = ({ channel, message, onPress }: Props) => { const { STRINGS } = useLocalization(); const { select, palette } = useUIKitTheme(); @@ -60,32 +59,18 @@ const GroupChannelMessageReplyInfo = ({ channel, message, variant, onPress }: Pr }; const renderAvatars = createRepliedUserAvatars(message.threadInfo.mostRepliedUsers); - const containerStyle = variant === 'incoming' ? styles.incomingContainer : styles.outgoingContainer; return ( - - - {renderAvatars} - - {replyCountText} - - - + + {renderAvatars} + + {replyCountText} + + ); }; const styles = createStyleSheet({ - incomingContainer: { - marginTop: 4, - flexDirection: 'row', - justifyContent: 'flex-start', - }, - outgoingContainer: { - marginTop: 4, - flexDirection: 'row', - justifyContent: 'flex-end', - }, replyContainer: { - marginLeft: 36, flexDirection: 'row', alignItems: 'center', }, diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index ac3e8b34c..f8c1dc441 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -77,14 +77,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' const replyInfo = useIIFE(() => { if (sbOptions.uikit.groupChannel.channel.replyType !== 'thread') return null; if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; - return ( - - ); + return ; }); const resetPlayer = async () => { From 1af97bf4658bedfd3107c24e3b47057e7f3855ce Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 17:33:52 +0900 Subject: [PATCH 11/46] Changed threadReplySelectType to thread --- sample/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 40b08ec2f..84d578e0b 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -63,6 +63,7 @@ const App = () => { enableMention: true, typingIndicatorTypes: new Set([TypingIndicatorType.Text, TypingIndicatorType.Bubble]), replyType: 'thread', + threadReplySelectType: 'thread', }, groupChannelList: { enableTypingIndicator: true, From c793d256eb3153191d2dded4246ece9dae7ee923 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 17:43:45 +0900 Subject: [PATCH 12/46] Apply review --- .../ThreadParentMessage.file.voice.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx index 36423dec1..bc2f6b370 100644 --- a/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx @@ -100,6 +100,7 @@ const styles = createStyleSheet({ container: { borderRadius: 16, overflow: 'hidden', + maxWidth: 136, }, }); From a91592062871d815bb6a5ebc875b00201456a42f Mon Sep 17 00:00:00 2001 From: bang9 Date: Fri, 31 May 2024 17:56:16 +0900 Subject: [PATCH 13/46] chore: update parent message reactions UI --- .../ReactionAddons/MessageReactionAddon.tsx | 11 +++++++---- .../component/GroupChannelThreadParentMessageInfo.tsx | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx b/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx index 4af09b7c0..59ef2f205 100644 --- a/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx +++ b/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx @@ -41,6 +41,7 @@ const createReactionButtons = ( onOpenReactionList: () => void, onOpenReactionUserList: (focusIndex: number) => void, currentUserId?: string, + reactionAddonType?: ReactionAddonType, ) => { const reactions = message.reactions ?? []; const buttons = reactions.map((reaction, index) => { @@ -58,7 +59,11 @@ const createReactionButtons = ( url={getEmoji(reaction.key).url} count={getReactionCount(reaction)} reacted={pressed || getUserReacted(reaction, currentUserId)} - style={[isNotLastOfRow && styles.marginRight, isNotLastOfCol && styles.marginBottom]} + style={ + reactionAddonType === 'default' + ? [isNotLastOfRow && styles.marginRight, isNotLastOfCol && styles.marginBottom] + : [styles.marginRight, styles.marginBottom] + } /> )} @@ -98,6 +103,7 @@ const MessageReactionAddon = ({ () => openReactionList({ channel, message }), (focusIndex) => openReactionUserList({ channel, message, focusIndex }), currentUser?.userId, + reactionAddonType, ); const containerStyle = @@ -125,11 +131,8 @@ const styles = createStyleSheet({ borderWidth: 1, }, reactionThreadParentMessageContainer: { - alignItems: 'stretch', flexDirection: 'row', flexWrap: 'wrap', - padding: 8, - borderRadius: 16, }, marginRight: { marginRight: 4.5, diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx index 2b73d4c42..0de06291b 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx @@ -159,8 +159,8 @@ const styles = createStyleSheet({ paddingVertical: 8, }, reactionButtonContainer: { - flexDirection: 'row', - height: 50, + paddingLeft: 16, + marginBottom: 16, }, replyContainer: { flexDirection: 'column', From 6a9b220ae100fddbc500bd7ad7e102691057164f Mon Sep 17 00:00:00 2001 From: bang9 Date: Fri, 31 May 2024 18:21:34 +0900 Subject: [PATCH 14/46] chore: add applyReactionEvent to reaction addon --- .../ReactionAddons/MessageReactionAddon.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx b/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx index 59ef2f205..1fc207925 100644 --- a/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx +++ b/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx @@ -3,6 +3,7 @@ import { Pressable } from 'react-native'; import type { Emoji } from '@sendbird/chat'; import { createStyleSheet, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { useForceUpdate, useGroupChannelHandler } from '@sendbird/uikit-tools'; import type { SendbirdBaseChannel, SendbirdBaseMessage, SendbirdReaction } from '@sendbird/uikit-utils'; import { getReactionCount } from '@sendbird/uikit-utils'; @@ -90,8 +91,18 @@ const MessageReactionAddon = ({ reactionAddonType?: ReactionAddonType; }) => { const { colors } = useUIKitTheme(); - const { emojiManager, currentUser } = useSendbirdChat(); + const { sdk, emojiManager, currentUser } = useSendbirdChat(); const { openReactionList, openReactionUserList } = useReaction(); + const forceUpdate = useForceUpdate(); + + useGroupChannelHandler(sdk, { + async onReactionUpdated(_, event) { + if (event.messageId === message.messageId) { + message.applyReactionEvent(event); + forceUpdate(); + } + }, + }); if (reactionAddonType === 'default' && !message.reactions?.length) return null; From 0e40c3d6e58a558b8e4f965f9528a1ba1caa4e22 Mon Sep 17 00:00:00 2001 From: bang9 Date: Fri, 31 May 2024 18:24:51 +0900 Subject: [PATCH 15/46] chore: resolve message gap issue in thread fragment --- .../GroupChannelMessageRenderer/index.tsx | 21 +++++++++---------- packages/uikit-utils/src/sendbird/message.ts | 9 ++++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index f8c1dc441..ebfdf4c18 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -158,16 +158,15 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' sendingStatus: isMyMessage(message, currentUser?.userId) ? ( ) : null, - parentMessage: - !hideParentMessage && shouldRenderParentMessage(message) ? ( - - ) : null, + parentMessage: shouldRenderParentMessage(message, hideParentMessage) ? ( + + ) : null, strings: { edited: STRINGS.GROUP_CHANNEL.MESSAGE_BUBBLE_EDITED_POSTFIX, senderName: ('sender' in message && message.sender.nickname) || STRINGS.LABELS.USER_NO_NAME, @@ -294,7 +293,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' } else { return 16; } - } else if (nextMessage && shouldRenderParentMessage(nextMessage)) { + } else if (nextMessage && shouldRenderParentMessage(nextMessage, hideParentMessage)) { return 16; } else if (groupWithNext) { return 2; diff --git a/packages/uikit-utils/src/sendbird/message.ts b/packages/uikit-utils/src/sendbird/message.ts index 4f1206ca7..e04b8fbd5 100644 --- a/packages/uikit-utils/src/sendbird/message.ts +++ b/packages/uikit-utils/src/sendbird/message.ts @@ -125,12 +125,13 @@ export function parseSendbirdNotification(dataPayload: RawSendbirdDataPayload): return typeof dataPayload.sendbird === 'string' ? JSON.parse(dataPayload.sendbird) : dataPayload.sendbird; } -export function shouldRenderParentMessage(message: SendbirdMessage): message is ( - | SendbirdUserMessage - | SendbirdFileMessage -) & { +export function shouldRenderParentMessage( + message: SendbirdMessage, + hide = false, +): message is (SendbirdUserMessage | SendbirdFileMessage) & { parentMessage: SendbirdUserMessage | SendbirdFileMessage; } { + if (hide) return false; return !!( (message.isFileMessage() || message.isUserMessage()) && (message.parentMessage?.isFileMessage() || message.parentMessage?.isUserMessage()) From 08d4a54c6d15fbe68b2e1706979ff7582fa8b06d Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 19:09:22 +0900 Subject: [PATCH 16/46] Apply review --- .../src/domain/groupChannelThread/types.ts | 16 ---------------- packages/uikit-react-native/src/index.ts | 3 +++ 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts index 0f504f800..558f21bfd 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts +++ b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts @@ -1,7 +1,6 @@ import type React from 'react'; import type { FlatList } from 'react-native'; -import type { MessageCollectionParams, MessageFilterParams } from '@sendbird/chat/groupChannel'; import type { UseGroupChannelMessagesOptions } from '@sendbird/uikit-chat-hooks'; import type { OnBeforeHandler, @@ -21,8 +20,6 @@ import type { ChannelMessageListProps } from '../../components/ChannelMessageLis import type { CommonComponent } from '../../types'; import type { PubSub } from '../../utils/pubsub'; -export type MessageListQueryParamsType = Omit & MessageFilterParams; - export interface GroupChannelThreadProps { Fragment: { channel: SendbirdGroupChannel; @@ -47,18 +44,6 @@ export interface GroupChannelThreadProps { keyboardAvoidOffset?: GroupChannelThreadProps['Provider']['keyboardAvoidOffset']; flatListProps?: GroupChannelThreadProps['MessageList']['flatListProps']; sortComparator?: UseGroupChannelMessagesOptions['sortComparator']; - searchItem?: GroupChannelThreadProps['MessageList']['searchItem']; - - /** - * @description You can specify the query parameters for the message list. - * @example - * ``` - * - * ``` - * */ - messageListQueryParams?: MessageListQueryParamsType; - /** @deprecated Please use `messageListQueryParams` instead */ - collectionCreator?: UseGroupChannelMessagesOptions['collectionCreator']; }; Header: { onPressHeaderLeft: () => void; @@ -66,7 +51,6 @@ export interface GroupChannelThreadProps { ParentMessageInfo: { channel: SendbirdGroupChannel; currentUserId?: string; - onPressContextMenu?: () => void; onDeleteMessage: (message: SendbirdUserMessage | SendbirdFileMessage) => Promise; onPressMediaMessage?: (message: SendbirdFileMessage, deleteMessage: () => Promise, uri: string) => void; }; diff --git a/packages/uikit-react-native/src/index.ts b/packages/uikit-react-native/src/index.ts index 400b89d82..51053f8ca 100644 --- a/packages/uikit-react-native/src/index.ts +++ b/packages/uikit-react-native/src/index.ts @@ -103,6 +103,9 @@ export * from './domain/groupChannelUserList/types'; export * from './domain/messageSearch/types'; +export * from './domain/groupChannelThread'; +export * from './domain/groupChannelThread/types'; + /** Feature - open channels **/ export * from './domain/openChannel'; export * from './domain/openChannel/types'; From 514efc12bb61e4ed5e1b75af27eaaaa5e44de682 Mon Sep 17 00:00:00 2001 From: bang9 Date: Fri, 31 May 2024 19:08:52 +0900 Subject: [PATCH 17/46] chore: disable message grouping if reply or thread message --- .../src/components/GroupChannelMessageRenderer/index.tsx | 1 + packages/uikit-utils/src/sendbird/message.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index ebfdf4c18..4d37d715a 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -59,6 +59,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' message, prevMessage, nextMessage, + sbOptions.uikit.groupChannel.channel.replyType === 'thread', ); const variant = isMyMessage(message, currentUser?.userId) ? 'outgoing' : 'incoming'; diff --git a/packages/uikit-utils/src/sendbird/message.ts b/packages/uikit-utils/src/sendbird/message.ts index e04b8fbd5..d49790a2d 100644 --- a/packages/uikit-utils/src/sendbird/message.ts +++ b/packages/uikit-utils/src/sendbird/message.ts @@ -66,12 +66,14 @@ export function calcMessageGrouping( curr: SendbirdMessage, prev?: SendbirdMessage, next?: SendbirdMessage, + isReplyThreadType?: boolean, ) { const getPrev = () => { if (!groupEnabled) return false; if (!prev) return false; if (curr.isAdminMessage()) return false; if (!hasSameSender(curr, prev)) return false; + if (curr.parentMessageId || (isReplyThreadType && curr.threadInfo)) return false; if (getMessageTimeFormat(new Date(curr.createdAt)) !== getMessageTimeFormat(new Date(prev.createdAt))) return false; return true; }; @@ -81,6 +83,7 @@ export function calcMessageGrouping( if (!next) return false; if (curr.isAdminMessage()) return false; if (!hasSameSender(curr, next)) return false; + if (curr.parentMessageId || (isReplyThreadType && curr.threadInfo)) return false; if (getMessageTimeFormat(new Date(curr.createdAt)) !== getMessageTimeFormat(new Date(next.createdAt))) return false; return true; }; From 9ae9a63991021a96e99a26d14159c711e793223c Mon Sep 17 00:00:00 2001 From: bang9 Date: Fri, 31 May 2024 19:21:12 +0900 Subject: [PATCH 18/46] chore: update message grouping logic --- .../src/components/GroupChannelMessageRenderer/index.tsx | 1 + .../src/fragments/createGroupChannelThreadFragment.tsx | 2 +- packages/uikit-utils/src/sendbird/message.ts | 7 +++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index 4d37d715a..412a78f26 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -60,6 +60,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' prevMessage, nextMessage, sbOptions.uikit.groupChannel.channel.replyType === 'thread', + shouldRenderParentMessage(message, hideParentMessage), ); const variant = isMyMessage(message, currentUser?.userId) ? 'outgoing' : 'incoming'; diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index 53be16708..fa6a60c7e 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -123,7 +123,7 @@ const createGroupChannelThreadFragment = ( const content = renderMessage ? ( renderMessage(props) ) : ( - + ); return {content}; }); diff --git a/packages/uikit-utils/src/sendbird/message.ts b/packages/uikit-utils/src/sendbird/message.ts index d49790a2d..d57bd1711 100644 --- a/packages/uikit-utils/src/sendbird/message.ts +++ b/packages/uikit-utils/src/sendbird/message.ts @@ -67,13 +67,15 @@ export function calcMessageGrouping( prev?: SendbirdMessage, next?: SendbirdMessage, isReplyThreadType?: boolean, + hasParentMessageUI?: boolean, ) { const getPrev = () => { if (!groupEnabled) return false; if (!prev) return false; if (curr.isAdminMessage()) return false; if (!hasSameSender(curr, prev)) return false; - if (curr.parentMessageId || (isReplyThreadType && curr.threadInfo)) return false; + if (hasParentMessageUI && curr.parentMessageId) return false; + if (isReplyThreadType && curr.threadInfo) return false; if (getMessageTimeFormat(new Date(curr.createdAt)) !== getMessageTimeFormat(new Date(prev.createdAt))) return false; return true; }; @@ -83,7 +85,8 @@ export function calcMessageGrouping( if (!next) return false; if (curr.isAdminMessage()) return false; if (!hasSameSender(curr, next)) return false; - if (curr.parentMessageId || (isReplyThreadType && curr.threadInfo)) return false; + if (hasParentMessageUI && curr.parentMessageId) return false; + if (isReplyThreadType && curr.threadInfo) return false; if (getMessageTimeFormat(new Date(curr.createdAt)) !== getMessageTimeFormat(new Date(next.createdAt))) return false; return true; }; From 431454b8353a259ec694a149bfed7c6a3229d67e Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 19:28:12 +0900 Subject: [PATCH 19/46] chore: add color to more button. --- .../component/GroupChannelThreadParentMessageInfo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx index 0de06291b..9ef70334f 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx @@ -71,7 +71,7 @@ const GroupChannelThreadParentMessageInfo = (props: GroupChannelThreadProps['Par - + ); From d6732fd45884f38a852df9ba14e940ccdf938d8d Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 31 May 2024 19:33:29 +0900 Subject: [PATCH 20/46] chore: fixed lint. --- .../component/GroupChannelThreadParentMessageInfo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx index 9ef70334f..8d49378a5 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx @@ -71,7 +71,7 @@ const GroupChannelThreadParentMessageInfo = (props: GroupChannelThreadProps['Par - + ); From e494858a1102f9f8315aee63eb5dd986c8d67ebd Mon Sep 17 00:00:00 2001 From: bang9 Date: Fri, 31 May 2024 19:32:59 +0900 Subject: [PATCH 21/46] chore: update parent message properly --- .../fragments/createGroupChannelThreadFragment.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index fa6a60c7e..0ebcf13cf 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -153,6 +153,12 @@ const createGroupChannelThreadFragment = ( groupChannelThreadPubSub.publish({ type: 'MESSAGE_SENT_SUCCESS', data: { message } }); }; + const updateIfParentMessage = (message: SendbirdFileMessage | SendbirdUserMessage) => { + if (message.messageId === parentMessage.parentMessageId) { + setParentMessage(message); + } + }; + const onPressSendUserMessage: GroupChannelThreadProps['Input']['onPressSendUserMessage'] = useFreshCallback( async (params) => { const processedParams = await onBeforeSendUserMessage(params); @@ -170,13 +176,15 @@ const createGroupChannelThreadFragment = ( const onPressUpdateUserMessage: GroupChannelThreadProps['Input']['onPressUpdateUserMessage'] = useFreshCallback( async (message, params) => { const processedParams = await onBeforeUpdateUserMessage(params); - await updateUserMessage(message.messageId, processedParams); + const updatedMessage = await updateUserMessage(message.messageId, processedParams); + updateIfParentMessage(updatedMessage); }, ); const onPressUpdateFileMessage: GroupChannelThreadProps['Input']['onPressUpdateFileMessage'] = useFreshCallback( async (message, params) => { const processedParams = await onBeforeUpdateFileMessage(params); - await updateFileMessage(message.messageId, processedParams); + const updatedMessage = await updateFileMessage(message.messageId, processedParams); + updateIfParentMessage(updatedMessage); }, ); const onScrolledAwayFromBottom = useFreshCallback((value: boolean) => { From c519bbb57d360e869e0adbd940db7d01ca1b9bed Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Sat, 1 Jun 2024 16:46:20 +0900 Subject: [PATCH 22/46] chore: improve thread message list. --- .../ChannelThreadMessageList/index.tsx | 406 ++++++++++++++++++ .../src/components/ChatFlatList/index.tsx | 22 +- .../GroupChannelThreadMessageList.tsx | 6 +- .../module/moduleContext.tsx | 2 +- .../src/domain/groupChannelThread/types.ts | 4 +- .../createGroupChannelThreadFragment.tsx | 29 +- 6 files changed, 448 insertions(+), 21 deletions(-) create mode 100644 packages/uikit-react-native/src/components/ChannelThreadMessageList/index.tsx diff --git a/packages/uikit-react-native/src/components/ChannelThreadMessageList/index.tsx b/packages/uikit-react-native/src/components/ChannelThreadMessageList/index.tsx new file mode 100644 index 000000000..40afd50b1 --- /dev/null +++ b/packages/uikit-react-native/src/components/ChannelThreadMessageList/index.tsx @@ -0,0 +1,406 @@ +import React, { Ref } from 'react'; +import { FlatList, FlatListProps, ListRenderItem, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { + BottomSheetItem, + ChannelFrozenBanner, + createStyleSheet, + useAlert, + useBottomSheet, + useToast, + useUIKitTheme, +} from '@sendbird/uikit-react-native-foundation'; +import { + Logger, + SendbirdFileMessage, + SendbirdGroupChannel, + SendbirdMessage, + SendbirdOpenChannel, + SendbirdUserMessage, + getAvailableUriFromFileMessage, + getFileExtension, + getFileType, + isMyMessage, + isVoiceMessage, + messageKeyExtractor, + shouldRenderReaction, + toMegabyte, + useFreshCallback, +} from '@sendbird/uikit-utils'; + +import type { UserProfileContextType } from '../../contexts/UserProfileCtx'; +import { useLocalization, usePlatformService, useSendbirdChat, useUserProfile } from '../../hooks/useContext'; +import SBUUtils from '../../libs/SBUUtils'; +import ChatFlatList from '../ChatFlatList'; +import { ReactionAddons } from '../ReactionAddons'; + +type PressActions = { onPress?: () => void; onLongPress?: () => void; bottomSheetItem?: BottomSheetItem }; +type HandleableMessage = SendbirdUserMessage | SendbirdFileMessage; +type CreateMessagePressActions = (params: { message: SendbirdMessage }) => PressActions; +export type ChannelThreadMessageListProps = { + enableMessageGrouping: boolean; + currentUserId?: string; + channel: T; + messages: SendbirdMessage[]; + newMessages: SendbirdMessage[]; + searchItem?: { startingPoint: number }; + + scrolledAwayFromBottom: boolean; + onScrolledAwayFromBottom: (value: boolean) => void; + onTopReached: () => void; + onBottomReached: () => void; + hasNext: () => boolean; + + onPressNewMessagesButton: (animated?: boolean) => void; + onPressScrollToBottomButton: (animated?: boolean) => void; + + onEditMessage: (message: HandleableMessage) => void; + onDeleteMessage: (message: HandleableMessage) => Promise; + onResendFailedMessage: (failedMessage: HandleableMessage) => Promise; + onPressMediaMessage?: (message: SendbirdFileMessage, deleteMessage: () => Promise, uri: string) => void; + + renderMessage: (props: { + focused: boolean; + message: SendbirdMessage; + prevMessage?: SendbirdMessage; + nextMessage?: SendbirdMessage; + onPress?: () => void; + onLongPress?: () => void; + onShowUserProfile?: UserProfileContextType['show']; + channel: T; + currentUserId?: ChannelThreadMessageListProps['currentUserId']; + enableMessageGrouping: ChannelThreadMessageListProps['enableMessageGrouping']; + bottomSheetItem?: BottomSheetItem; + isFirstItem: boolean; + }) => React.ReactElement | null; + renderNewMessagesButton: + | null + | ((props: { visible: boolean; onPress: () => void; newMessages: SendbirdMessage[] }) => React.ReactElement | null); + renderScrollToBottomButton: null | ((props: { visible: boolean; onPress: () => void }) => React.ReactElement | null); + flatListProps?: Omit, 'data' | 'renderItem'>; +} & { + ref?: Ref> | undefined; +}; + +const ChannelThreadMessageList = ( + { + searchItem, + hasNext, + channel, + onEditMessage, + onDeleteMessage, + onResendFailedMessage, + onPressMediaMessage, + currentUserId, + renderNewMessagesButton, + renderScrollToBottomButton, + renderMessage, + messages, + newMessages, + enableMessageGrouping, + onScrolledAwayFromBottom, + scrolledAwayFromBottom, + onBottomReached, + onTopReached, + flatListProps, + onPressNewMessagesButton, + onPressScrollToBottomButton, + }: ChannelThreadMessageListProps, + ref: React.ForwardedRef>, +) => { + const { STRINGS } = useLocalization(); + const { colors } = useUIKitTheme(); + const { show } = useUserProfile(); + const { left, right } = useSafeAreaInsets(); + const createMessagePressActions = useCreateMessagePressActions({ + channel, + currentUserId, + onEditMessage, + onDeleteMessage, + onResendFailedMessage, + onPressMediaMessage, + }); + + const safeAreaLayout = { paddingLeft: left, paddingRight: right }; + + const renderItem: ListRenderItem = useFreshCallback(({ item, index }) => { + const { onPress, onLongPress, bottomSheetItem } = createMessagePressActions({ message: item }); + return renderMessage({ + message: item, + prevMessage: messages[index - 1], + nextMessage: messages[index + 1], + onPress, + onLongPress, + onShowUserProfile: show, + enableMessageGrouping, + channel, + currentUserId, + focused: (searchItem?.startingPoint ?? -1) === item.createdAt, + bottomSheetItem, + isFirstItem: index === 0, + }); + }); + + return ( + + {channel.isFrozen && ( + + )} + + {renderNewMessagesButton && ( + + {renderNewMessagesButton({ + visible: newMessages.length > 0 && (hasNext() || scrolledAwayFromBottom), + onPress: () => onPressNewMessagesButton(), + newMessages, + })} + + )} + {renderScrollToBottomButton && ( + + {renderScrollToBottomButton({ + visible: hasNext() || scrolledAwayFromBottom, + onPress: () => onPressScrollToBottomButton(), + })} + + )} + + ); +}; + +const useCreateMessagePressActions = ({ + channel, + currentUserId, + onResendFailedMessage, + onEditMessage, + onDeleteMessage, + onPressMediaMessage, +}: Pick< + ChannelThreadMessageListProps, + 'channel' | 'currentUserId' | 'onEditMessage' | 'onDeleteMessage' | 'onResendFailedMessage' | 'onPressMediaMessage' +>): CreateMessagePressActions => { + const { colors } = useUIKitTheme(); + const { STRINGS } = useLocalization(); + const toast = useToast(); + const { openSheet } = useBottomSheet(); + const { alert } = useAlert(); + const { clipboardService, fileService } = usePlatformService(); + const { sbOptions } = useSendbirdChat(); + + const onResendFailure = (error: Error) => { + toast.show(STRINGS.TOAST.RESEND_MSG_ERROR, 'error'); + Logger.error(STRINGS.TOAST.RESEND_MSG_ERROR, error); + }; + + const onDeleteFailure = (error: Error) => { + toast.show(STRINGS.TOAST.DELETE_MSG_ERROR, 'error'); + Logger.error(STRINGS.TOAST.DELETE_MSG_ERROR, error); + }; + + const onCopyText = (message: HandleableMessage) => { + if (message.isUserMessage()) { + clipboardService.setString(message.message || ''); + toast.show(STRINGS.TOAST.COPY_OK, 'success'); + } + }; + + const onDownloadFile = (message: HandleableMessage) => { + if (message.isFileMessage()) { + if (toMegabyte(message.size) > 4) { + toast.show(STRINGS.TOAST.DOWNLOAD_START, 'success'); + } + + fileService + .save({ fileUrl: message.url, fileName: message.name, fileType: message.type }) + .then((response) => { + toast.show(STRINGS.TOAST.DOWNLOAD_OK, 'success'); + Logger.log('File saved to', response); + }) + .catch((err) => { + toast.show(STRINGS.TOAST.DOWNLOAD_ERROR, 'error'); + Logger.log('File save failure', err); + }); + } + }; + + const onOpenFile = (message: HandleableMessage) => { + if (message.isFileMessage()) { + const fileType = getFileType(message.type || getFileExtension(message.name)); + if (['image', 'video', 'audio'].includes(fileType)) { + onPressMediaMessage?.(message, () => onDeleteMessage(message), getAvailableUriFromFileMessage(message)); + } else { + SBUUtils.openURL(message.url); + } + } + }; + + const openSheetForFailedMessage = (message: HandleableMessage) => { + openSheet({ + sheetItems: [ + { + title: STRINGS.LABELS.CHANNEL_MESSAGE_FAILED_RETRY, + onPress: () => onResendFailedMessage(message).catch(onResendFailure), + }, + { + title: STRINGS.LABELS.CHANNEL_MESSAGE_FAILED_REMOVE, + titleColor: colors.ui.dialog.default.none.destructive, + onPress: () => alertForMessageDelete(message), + }, + ], + }); + }; + + const alertForMessageDelete = (message: HandleableMessage) => { + alert({ + title: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_TITLE, + buttons: [ + { text: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_CANCEL }, + { + text: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_OK, + style: 'destructive', + onPress: () => { + onDeleteMessage(message).catch(onDeleteFailure); + }, + }, + ], + }); + }; + + return ({ message }) => { + if (!message.isUserMessage() && !message.isFileMessage()) return {}; + + const sheetItems: BottomSheetItem['sheetItems'] = []; + const menu = { + copy: (message: HandleableMessage) => ({ + icon: 'copy' as const, + title: STRINGS.LABELS.CHANNEL_MESSAGE_COPY, + onPress: () => onCopyText(message), + }), + edit: (message: HandleableMessage) => ({ + icon: 'edit' as const, + title: STRINGS.LABELS.CHANNEL_MESSAGE_EDIT, + onPress: () => onEditMessage(message), + }), + delete: (message: HandleableMessage) => ({ + disabled: message.threadInfo ? message.threadInfo.replyCount > 0 : undefined, + icon: 'delete' as const, + title: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE, + onPress: () => alertForMessageDelete(message), + }), + download: (message: HandleableMessage) => ({ + icon: 'download' as const, + title: STRINGS.LABELS.CHANNEL_MESSAGE_SAVE, + onPress: () => onDownloadFile(message), + }), + }; + + if (message.isUserMessage()) { + sheetItems.push(menu.copy(message)); + if (!channel.isEphemeral) { + if (isMyMessage(message, currentUserId) && message.sendingStatus === 'succeeded') { + sheetItems.push(menu.edit(message)); + sheetItems.push(menu.delete(message)); + } + } + } + + if (message.isFileMessage()) { + if (!isVoiceMessage(message)) { + sheetItems.push(menu.download(message)); + } + if (!channel.isEphemeral) { + if (isMyMessage(message, currentUserId) && message.sendingStatus === 'succeeded') { + sheetItems.push(menu.delete(message)); + } + } + } + + const configs = sbOptions.uikitWithAppInfo.groupChannel.channel; + const bottomSheetItem: BottomSheetItem = { + sheetItems, + HeaderComponent: shouldRenderReaction( + channel, + channel.isGroupChannel() && (channel.isSuper ? configs.enableReactionsSupergroup : configs.enableReactions), + ) + ? ({ onClose }) => + : undefined, + }; + + switch (true) { + case message.sendingStatus === 'pending': { + return { + onPress: undefined, + onLongPress: undefined, + bottomSheetItem: undefined, + }; + } + + case message.sendingStatus === 'failed': { + return { + onPress: () => onResendFailedMessage(message).catch(onResendFailure), + onLongPress: () => openSheetForFailedMessage(message), + bottomSheetItem, + }; + } + + case message.isFileMessage(): { + return { + onPress: () => onOpenFile(message), + onLongPress: () => openSheet(bottomSheetItem), + bottomSheetItem, + }; + } + + default: { + return { + onPress: undefined, + onLongPress: () => openSheet(bottomSheetItem), + bottomSheetItem, + }; + } + } + }; +}; + +const styles = createStyleSheet({ + frozenBanner: { + position: 'absolute', + zIndex: 999, + top: 8, + left: 8, + right: 8, + }, + frozenListPadding: { + paddingBottom: 32, + }, + newMsgButton: { + position: 'absolute', + zIndex: 999, + bottom: 10, + alignSelf: 'center', + }, + scrollButton: { + position: 'absolute', + zIndex: 998, + bottom: 10, + right: 16, + }, +}); + +// NOTE: Due to Generic inference is not working on forwardRef, we need to cast it as typeof ChannelMessageList and implicit `ref` prop +export default React.forwardRef(ChannelThreadMessageList) as typeof ChannelThreadMessageList; diff --git a/packages/uikit-react-native/src/components/ChatFlatList/index.tsx b/packages/uikit-react-native/src/components/ChatFlatList/index.tsx index 241610007..df1609491 100644 --- a/packages/uikit-react-native/src/components/ChatFlatList/index.tsx +++ b/packages/uikit-react-native/src/components/ChatFlatList/index.tsx @@ -32,19 +32,29 @@ const ChatFlatList = forwardRef(function ChatFlatList( ) { const { select } = useUIKitTheme(); const contentOffsetY = useRef(0); + const inverted = useRef(props.inverted ?? Boolean(props.data?.length)); const _onScroll = useFreshCallback>((event) => { onScroll?.(event); - const { contentOffset } = event.nativeEvent; + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; const prevOffsetY = contentOffsetY.current; const currOffsetY = contentOffset.y; - if (BOTTOM_DETECT_THRESHOLD < prevOffsetY && currOffsetY <= BOTTOM_DETECT_THRESHOLD) { - onScrolledAwayFromBottom(false); - } else if (BOTTOM_DETECT_THRESHOLD < currOffsetY && prevOffsetY <= BOTTOM_DETECT_THRESHOLD) { - onScrolledAwayFromBottom(true); + if (inverted.current) { + if (BOTTOM_DETECT_THRESHOLD < prevOffsetY && currOffsetY <= BOTTOM_DETECT_THRESHOLD) { + onScrolledAwayFromBottom(false); + } else if (BOTTOM_DETECT_THRESHOLD < currOffsetY && prevOffsetY <= BOTTOM_DETECT_THRESHOLD) { + onScrolledAwayFromBottom(true); + } + } else { + const bottomDetectThreshold = contentSize.height - layoutMeasurement.height - BOTTOM_DETECT_THRESHOLD; + if (bottomDetectThreshold < prevOffsetY && currOffsetY <= bottomDetectThreshold) { + onScrolledAwayFromBottom(true); + } else if (bottomDetectThreshold < currOffsetY && prevOffsetY <= bottomDetectThreshold) { + onScrolledAwayFromBottom(false); + } } contentOffsetY.current = contentOffset.y; @@ -69,7 +79,7 @@ const ChatFlatList = forwardRef(function ChatFlatList( indicatorStyle={select({ light: 'black', dark: 'white' })} {...props} // FIXME: inverted list of ListEmptyComponent is reversed {@link https://github.com/facebook/react-native/issues/21196#issuecomment-836937743} - inverted={Boolean(props.data?.length)} + inverted={inverted.current} ref={ref} onEndReached={onTopReached} onScrollToIndexFailed={NOOP} diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx index a9ea5f9f3..f548e0304 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx @@ -3,7 +3,7 @@ import React, { useContext, useEffect, useLayoutEffect } from 'react'; import { useChannelHandler } from '@sendbird/uikit-chat-hooks'; import { isDifferentChannel, useFreshCallback, useUniqHandlerId } from '@sendbird/uikit-utils'; -import ChannelMessageList from '../../../components/ChannelMessageList'; +import ChannelThreadMessageList from '../../../components/ChannelThreadMessageList'; import { useSendbirdChat } from '../../../hooks/useContext'; import { GroupChannelThreadContexts } from '../module/moduleContext'; import type { GroupChannelThreadProps } from '../types'; @@ -33,7 +33,7 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi const foundMessageIndex = props.messages.findIndex((it) => it.createdAt === props.startingPoint); const isIncludedInList = foundMessageIndex > -1; if (isIncludedInList) { - lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout: 100 }); + lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout: 250 }); } } }, [props.startingPoint]); @@ -70,7 +70,7 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi }, [props.scrolledAwayFromBottom]); return ( - { if (flatListRef.current) { - flatListRef.current.scrollToOffset({ offset: 0, animated: params?.animated ?? false }); + flatListRef.current.scrollToEnd({ animated: params?.animated ?? false }); } }, params?.timeout ?? 0); }); diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts index 558f21bfd..d1bd043fa 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts +++ b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts @@ -16,7 +16,7 @@ import type { } from '@sendbird/uikit-utils'; import type { ChannelInputProps, SuggestedMentionListProps } from '../../components/ChannelInput'; -import type { ChannelMessageListProps } from '../../components/ChannelMessageList'; +import type { ChannelThreadMessageListProps } from '../../components/ChannelThreadMessageList'; import type { CommonComponent } from '../../types'; import type { PubSub } from '../../utils/pubsub'; @@ -55,7 +55,7 @@ export interface GroupChannelThreadProps { onPressMediaMessage?: (message: SendbirdFileMessage, deleteMessage: () => Promise, uri: string) => void; }; MessageList: Pick< - ChannelMessageListProps, + ChannelThreadMessageListProps, | 'enableMessageGrouping' | 'currentUserId' | 'channel' diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index fa6a60c7e..fa36353c1 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -2,7 +2,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Box, useToast } from '@sendbird/uikit-react-native-foundation'; import { useGroupChannelThreadMessages } from '@sendbird/uikit-tools'; -import type { SendbirdFileMessage, SendbirdGroupChannel, SendbirdUserMessage } from '@sendbird/uikit-utils'; +import { + SendbirdFileMessage, + SendbirdGroupChannel, + type SendbirdMessage, + SendbirdUserMessage, +} from '@sendbird/uikit-utils'; import { NOOP, PASS, @@ -48,7 +53,7 @@ const createGroupChannelThreadFragment = ( parentMessage, startingPoint, keyboardAvoidOffset, - sortComparator = messageComparator, + sortComparator = threadMessageComparator, flatListProps, }) => { const { playerService, recorderService } = usePlatformService(); @@ -130,7 +135,15 @@ const createGroupChannelThreadFragment = ( const memoizedFlatListProps = useMemo( () => ({ - ListEmptyComponent: , + ListHeaderComponent: ( + + ), + inverted: false, contentContainerStyle: { flexGrow: 1 }, ...flatListProps, }), @@ -193,12 +206,6 @@ const createGroupChannelThreadFragment = ( threadedMessages={messages} > - }> Date: Sat, 1 Jun 2024 16:55:24 +0900 Subject: [PATCH 23/46] chore: improve thread message list. --- .../uikit-react-native/src/components/ChatFlatList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uikit-react-native/src/components/ChatFlatList/index.tsx b/packages/uikit-react-native/src/components/ChatFlatList/index.tsx index df1609491..b983b8481 100644 --- a/packages/uikit-react-native/src/components/ChatFlatList/index.tsx +++ b/packages/uikit-react-native/src/components/ChatFlatList/index.tsx @@ -32,6 +32,7 @@ const ChatFlatList = forwardRef(function ChatFlatList( ) { const { select } = useUIKitTheme(); const contentOffsetY = useRef(0); + // FIXME: inverted list of ListEmptyComponent is reversed {@link https://github.com/facebook/react-native/issues/21196#issuecomment-836937743} const inverted = useRef(props.inverted ?? Boolean(props.data?.length)); const _onScroll = useFreshCallback>((event) => { @@ -78,7 +79,6 @@ const ChatFlatList = forwardRef(function ChatFlatList( keyboardShouldPersistTaps={'handled'} indicatorStyle={select({ light: 'black', dark: 'white' })} {...props} - // FIXME: inverted list of ListEmptyComponent is reversed {@link https://github.com/facebook/react-native/issues/21196#issuecomment-836937743} inverted={inverted.current} ref={ref} onEndReached={onTopReached} From fba519d8abaab7870aed6f1c61ec7edd87599fb6 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Sat, 1 Jun 2024 17:17:35 +0900 Subject: [PATCH 24/46] chore: change version for sample test. --- packages/uikit-react-native/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uikit-react-native/package.json b/packages/uikit-react-native/package.json index 9c2204b3f..e07c83d70 100644 --- a/packages/uikit-react-native/package.json +++ b/packages/uikit-react-native/package.json @@ -1,6 +1,6 @@ { "name": "@sendbird/uikit-react-native", - "version": "3.5.3", + "version": "3.5.4", "description": "Sendbird UIKit for React Native: A feature-rich and customizable chat UI kit with messaging, channel management, and user authentication.", "keywords": [ "sendbird", From f4753cd0df0247ae818d0550372923fbfdcc9c77 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Tue, 4 Jun 2024 09:51:13 +0900 Subject: [PATCH 25/46] chore: fixed not going to channel when clicking title. --- .../component/GroupChannelThreadHeader.tsx | 40 +++++++++++-------- .../src/domain/groupChannelThread/types.ts | 4 +- .../createGroupChannelThreadFragment.tsx | 8 ++-- .../groupChannel/GroupChannelThreadScreen.tsx | 2 +- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx index b31adb5cc..93b4eea4f 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx @@ -1,13 +1,20 @@ import React, { useContext } from 'react'; import { View } from 'react-native'; -import { Icon, Text, createStyleSheet, useHeaderStyle, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { + Icon, + PressBox, + Text, + createStyleSheet, + useHeaderStyle, + useUIKitTheme, +} from '@sendbird/uikit-react-native-foundation'; import { useLocalization, useSendbirdChat } from '../../../hooks/useContext'; import { GroupChannelThreadContexts } from '../module/moduleContext'; import type { GroupChannelThreadProps } from '../types'; -const GroupChannelThreadHeader = ({ onPressHeaderLeft }: GroupChannelThreadProps['Header']) => { +const GroupChannelThreadHeader = ({ onPressHeader }: GroupChannelThreadProps['Header']) => { const { headerTitle, channel } = useContext(GroupChannelThreadContexts.Fragment); const { HeaderComponent } = useHeaderStyle(); const { STRINGS } = useLocalization(); @@ -30,21 +37,22 @@ const GroupChannelThreadHeader = ({ onPressHeaderLeft }: GroupChannelThreadProps }; return ( - - - - {headerTitle} - - {renderSubtitle()} + + + + + {headerTitle} + + {renderSubtitle()} + - - } - left={} - onPressLeft={onPressHeaderLeft} - /> + } + left={} + /> + ); }; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts index d1bd043fa..b243e447c 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts +++ b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts @@ -27,7 +27,7 @@ export interface GroupChannelThreadProps { startingPoint?: number; onParentMessageDeleted: () => void; onChannelDeleted: () => void; - onPressHeaderLeft: GroupChannelThreadProps['Header']['onPressHeaderLeft']; + onPressHeader: GroupChannelThreadProps['Header']['onPressHeader']; onPressMediaMessage?: GroupChannelThreadProps['MessageList']['onPressMediaMessage']; onBeforeSendUserMessage?: OnBeforeHandler; @@ -46,7 +46,7 @@ export interface GroupChannelThreadProps { sortComparator?: UseGroupChannelMessagesOptions['sortComparator']; }; Header: { - onPressHeaderLeft: () => void; + onPressHeader: () => void; }; ParentMessageInfo: { channel: SendbirdGroupChannel; diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index 0088be636..08ce2ff6e 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -41,7 +41,7 @@ const createGroupChannelThreadFragment = ( renderScrollToBottomButton = (props) => , renderMessage, enableMessageGrouping = true, - onPressHeaderLeft = NOOP, + onPressHeader = NOOP, onPressMediaMessage = NOOP, onParentMessageDeleted = NOOP, onChannelDeleted = NOOP, @@ -108,9 +108,9 @@ const createGroupChannelThreadFragment = ( const onBlurFragment = () => { return Promise.allSettled([playerService.reset(), recorderService.reset()]); }; - const _onPressHeaderLeft = useFreshCallback(async () => { + const _onPressHeader = useFreshCallback(async () => { await onBlurFragment(); - onPressHeaderLeft(); + onPressHeader(); }); const _onPressMediaMessage: NonNullable = useFreshCallback(async (message, deleteMessage, uri) => { @@ -213,7 +213,7 @@ const createGroupChannelThreadFragment = ( keyboardAvoidOffset={keyboardAvoidOffset} threadedMessages={messages} > - + }> { // Should leave channel, navigate to channel list navigation.navigate(Routes.GroupChannelList); }} - onPressHeaderLeft={() => { + onPressHeader={() => { // Navigate back navigation.goBack(); }} From 8bd34b794c4418c16af7eee08a85f63bda61bd55 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Tue, 4 Jun 2024 15:09:42 +0900 Subject: [PATCH 26/46] chore: fixed to show 'Reply to thread' in the placeholder when there are comments. --- .../src/components/ChannelInput/SendInput.tsx | 8 +++++++- .../uikit-react-native/src/localization/StringSet.type.ts | 3 ++- .../src/localization/createBaseStringSet.ts | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx index f7d2c7f59..421425d11 100644 --- a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx +++ b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx @@ -164,7 +164,13 @@ const SendInput = forwardRef(function SendInput( if (inputFrozen) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_DISABLED; if (inputDisabled) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_DISABLED; if (messageToReply) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_REPLY; - if (messageForThread) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_THREAD; + if (messageForThread) { + if (messageForThread?.threadInfo && messageForThread?.threadInfo.replyCount > 0) { + return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_REPLY_TO_THREAD; + } else { + return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_REPLY_IN_THREAD; + } + } return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_ACTIVE; }; diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index 22df60e1c..b7a2da7d8 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -292,7 +292,8 @@ export interface StringSet { CHANNEL_INPUT_PLACEHOLDER_DISABLED: string; CHANNEL_INPUT_PLACEHOLDER_MUTED: string; CHANNEL_INPUT_PLACEHOLDER_REPLY: string; - CHANNEL_INPUT_PLACEHOLDER_THREAD: string; + CHANNEL_INPUT_PLACEHOLDER_REPLY_IN_THREAD: string; + CHANNEL_INPUT_PLACEHOLDER_REPLY_TO_THREAD: string; CHANNEL_INPUT_EDIT_OK: string; CHANNEL_INPUT_EDIT_CANCEL: string; /** ChannelInput > Attachments **/ diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index c1f318d3f..6e695f680 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -314,7 +314,8 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp CHANNEL_INPUT_PLACEHOLDER_DISABLED: 'Chat not available in this channel.', CHANNEL_INPUT_PLACEHOLDER_MUTED: "You're muted by the operator.", CHANNEL_INPUT_PLACEHOLDER_REPLY: 'Reply to message', - CHANNEL_INPUT_PLACEHOLDER_THREAD: 'Reply in thread', + CHANNEL_INPUT_PLACEHOLDER_REPLY_IN_THREAD: 'Reply in thread', + CHANNEL_INPUT_PLACEHOLDER_REPLY_TO_THREAD: 'Reply to thread', CHANNEL_INPUT_EDIT_OK: 'Save', CHANNEL_INPUT_EDIT_CANCEL: 'Cancel', CHANNEL_INPUT_REPLY_PREVIEW_TITLE: (user) => `Reply to ${user.nickname || USER_NO_NAME}`, From 7f2b70a611575e59490d92ce8366e34e324d7e27 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Tue, 4 Jun 2024 15:55:00 +0900 Subject: [PATCH 27/46] chore: removed NewMessageButton, ScrollToBottomButton in thread. --- .../src/domain/groupChannelThread/types.ts | 4 ---- .../src/fragments/createGroupChannelThreadFragment.tsx | 6 ------ 2 files changed, 10 deletions(-) diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts index b243e447c..a96733f7c 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts +++ b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts @@ -36,8 +36,6 @@ export interface GroupChannelThreadProps { onBeforeUpdateFileMessage?: OnBeforeHandler; renderMessage?: GroupChannelThreadProps['MessageList']['renderMessage']; - renderNewMessagesButton?: GroupChannelThreadProps['MessageList']['renderNewMessagesButton']; - renderScrollToBottomButton?: GroupChannelThreadProps['MessageList']['renderScrollToBottomButton']; enableMessageGrouping?: GroupChannelThreadProps['MessageList']['enableMessageGrouping']; @@ -69,8 +67,6 @@ export interface GroupChannelThreadProps { | 'onDeleteMessage' | 'onPressMediaMessage' | 'renderMessage' - | 'renderNewMessagesButton' - | 'renderScrollToBottomButton' | 'flatListProps' | 'hasNext' | 'searchItem' diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index 08ce2ff6e..994888c83 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -18,8 +18,6 @@ import { } from '@sendbird/uikit-utils'; import GroupChannelMessageRenderer from '../components/GroupChannelMessageRenderer'; -import NewMessagesButton from '../components/NewMessagesButton'; -import ScrollToBottomButton from '../components/ScrollToBottomButton'; import StatusComposition from '../components/StatusComposition'; import createGroupChannelThreadModule from '../domain/groupChannelThread/module/createGroupChannelThreadModule'; import type { @@ -37,8 +35,6 @@ const createGroupChannelThreadFragment = ( const GroupChannelThreadModule = createGroupChannelThreadModule(initModule); return ({ - renderNewMessagesButton = (props) => , - renderScrollToBottomButton = (props) => , renderMessage, enableMessageGrouping = true, onPressHeader = NOOP, @@ -229,8 +225,6 @@ const createGroupChannelThreadFragment = ( hasNext={hasNext} scrolledAwayFromBottom={scrolledAwayFromBottom} onScrolledAwayFromBottom={onScrolledAwayFromBottom} - renderNewMessagesButton={renderNewMessagesButton} - renderScrollToBottomButton={renderScrollToBottomButton} onResendFailedMessage={resendMessage} onDeleteMessage={deleteMessage} onPressMediaMessage={_onPressMediaMessage} From b61954ba765a060e2ae3d6d91e1a817b5384fd67 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Tue, 4 Jun 2024 19:40:26 +0900 Subject: [PATCH 28/46] chore: fixed usernames are not displaying in thread. --- .../src/ui/GroupChannelMessage/MessageContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx index 05e34ee2c..fad2d4296 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx @@ -46,7 +46,7 @@ MessageContainer.Incoming = function MessageContainerIncoming({ {parentMessage} - {!groupedWithPrev && !message.parentMessage && ( + {!groupedWithPrev && !parentMessage && ( {(message.isFileMessage() || message.isUserMessage()) && ( From 1e8b68e1dd2e43ca7ebc329f0fdd0b8de15ef54b Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Tue, 4 Jun 2024 20:20:24 +0900 Subject: [PATCH 29/46] chore: fixed an issue where it is possible to enter an invalid thread. --- .../groupChannel/component/GroupChannelMessageList.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx index 66f9fd2f8..56686bc77 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx @@ -108,7 +108,11 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { sbOptions.uikit.groupChannel.channel.replyType === 'thread' && sbOptions.uikit.groupChannel.channel.threadReplySelectType === 'thread' ) { - onPressReplyMessageInThread(parentMessage as SendbirdSendableMessage, childMessage.createdAt); + if (parentMessage && parentMessage.createdAt >= props.channel?.messageOffsetTimestamp) { + onPressReplyMessageInThread(parentMessage as SendbirdSendableMessage, childMessage.createdAt); + } else { + toast.show(STRINGS.TOAST.FIND_PARENT_MSG_ERROR, 'error'); + } } else { const canScrollToParent = scrollToMessageWithCreatedAt(parentMessage.createdAt, true, 0); if (!canScrollToParent) toast.show(STRINGS.TOAST.FIND_PARENT_MSG_ERROR, 'error'); From 54e37b66492278c933851c9b3a0e64b293bddd68 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Wed, 5 Jun 2024 08:30:35 +0900 Subject: [PATCH 30/46] chore: fixed an issue where incorrect sentences are displayed when replying. --- .../src/localization/createBaseStringSet.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index 6e695f680..b05258b54 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -280,9 +280,11 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp return 'Several people are typing...'; }, REPLY_FROM_SENDER_TO_RECEIVER: (reply, parent, currentUserId = UNKNOWN_USER_ID) => { - const senderNickname = reply.sender.nickname || USER_NO_NAME; - const receiverNickname = parent.sender.nickname || USER_NO_NAME; - return `${reply.sender.userId !== currentUserId ? senderNickname : 'You'} replied to ${receiverNickname}`; + const replySenderNickname = + reply.sender.userId === currentUserId ? 'You' : reply.sender.nickname || USER_NO_NAME; + const parentSenderNickname = + parent.sender.userId === currentUserId ? 'You' : parent.sender.nickname || USER_NO_NAME; + return `${replySenderNickname} replied to ${parentSenderNickname}`; }, MESSAGE_UNAVAILABLE: 'Message unavailable', From 805c80f3e44efcf1b21c4411edaad89afaa0d3b8 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Wed, 5 Jun 2024 08:53:46 +0900 Subject: [PATCH 31/46] chore: removed NewMessageButton, ScrollToBottomButton in thread. --- .../component/GroupChannelThreadMessageList.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx index f548e0304..b72a79de8 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx @@ -76,6 +76,8 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi onEditMessage={setMessageToEdit} onPressNewMessagesButton={scrollToBottom} onPressScrollToBottomButton={scrollToBottom} + renderNewMessagesButton={null} + renderScrollToBottomButton={null} /> ); }; From 742180bfd8b5ecd142622279c6c86cd008ce8156 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 14 Jun 2024 17:08:16 +0900 Subject: [PATCH 32/46] chore: fixed inverted no message. --- .../uikit-react-native/src/components/ChatFlatList/index.tsx | 1 + sample/src/App.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/uikit-react-native/src/components/ChatFlatList/index.tsx b/packages/uikit-react-native/src/components/ChatFlatList/index.tsx index b983b8481..85ae7de6a 100644 --- a/packages/uikit-react-native/src/components/ChatFlatList/index.tsx +++ b/packages/uikit-react-native/src/components/ChatFlatList/index.tsx @@ -34,6 +34,7 @@ const ChatFlatList = forwardRef(function ChatFlatList( const contentOffsetY = useRef(0); // FIXME: inverted list of ListEmptyComponent is reversed {@link https://github.com/facebook/react-native/issues/21196#issuecomment-836937743} const inverted = useRef(props.inverted ?? Boolean(props.data?.length)); + inverted.current = props.inverted ?? Boolean(props.data?.length); const _onScroll = useFreshCallback>((event) => { onScroll?.(event); diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 6adc5f57f..84d578e0b 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -62,7 +62,7 @@ const App = () => { groupChannel: { enableMention: true, typingIndicatorTypes: new Set([TypingIndicatorType.Text, TypingIndicatorType.Bubble]), - replyType: 'quote_reply', + replyType: 'thread', threadReplySelectType: 'thread', }, groupChannelList: { From c2bd6c9df47b4101dee8184758474a09225ea50c Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 14 Jun 2024 21:03:16 +0900 Subject: [PATCH 33/46] chore: update reply count format to display 99+ replies for counts of 99 or more --- .../GroupChannelMessageReplyInfo.tsx | 2 +- .../uikit-react-native/src/localization/StringSet.type.ts | 2 +- .../src/localization/createBaseStringSet.ts | 3 ++- packages/uikit-utils/src/ui-format/common.ts | 7 ++++--- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx index b2691c84a..45f0eedc4 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -53,7 +53,7 @@ const GroupChannelMessageReplyInfo = ({ channel, message, onPress }: Props) => { if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; - const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_COUNT(message.threadInfo.replyCount || 0); + const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_COUNT(message.threadInfo.replyCount || 0, 99); const onPressReply = () => { onPress?.(message as SendbirdUserMessage | SendbirdFileMessage); }; diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index b7a2da7d8..b07bc85d1 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -144,7 +144,7 @@ export interface StringSet { MESSAGE_BUBBLE_UNKNOWN_DESC: (message: SendbirdMessage) => string; PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => string; - REPLAY_COUNT: (replyCount: number) => string; + REPLAY_COUNT: (replyCount: number, minRepliesForPlusFormat?: number) => string; /** GroupChannelThread > Suggested mention list */ MENTION_LIMITED: (mentionLimit: number) => string; diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index b05258b54..ba4aca513 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -142,7 +142,8 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => getThreadParentMessageTimeFormat(new Date(message.createdAt), locale ?? dateLocale), - REPLAY_COUNT: (replyCount: number) => getReplyCountFormat(replyCount), + REPLAY_COUNT: (replyCount: number, minRepliesForPlusFormat?: number) => + getReplyCountFormat(replyCount, minRepliesForPlusFormat), MENTION_LIMITED: (mentionLimit) => `You can have up to ${mentionLimit} mentions per message.`, ...overrides?.GROUP_CHANNEL_THREAD, diff --git a/packages/uikit-utils/src/ui-format/common.ts b/packages/uikit-utils/src/ui-format/common.ts index b516e3b00..fc3718954 100644 --- a/packages/uikit-utils/src/ui-format/common.ts +++ b/packages/uikit-utils/src/ui-format/common.ts @@ -158,13 +158,14 @@ export const millsToMSS = (mills: number) => { * If reply count is 1: 1 'reply' * If the reply count is greater than 1 : '{count} replies' * */ -export const getReplyCountFormat = (replyCount: number) => { - if (replyCount === 1) { +export const getReplyCountFormat = (replyCount: number, minRepliesForPlusFormat?: number) => { + if (minRepliesForPlusFormat && replyCount > minRepliesForPlusFormat) { + return `${minRepliesForPlusFormat}+ replies`; + } else if (replyCount === 1) { return `${replyCount} reply`; } else if (replyCount > 1) { return `${replyCount} replies`; } - return ''; }; From 514785233f094abdcbbf34d1d8f25f3f07fc87c8 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Mon, 17 Jun 2024 12:56:01 +0900 Subject: [PATCH 34/46] chore: fixed issue with mention suggestion list sorting --- packages/uikit-react-native/src/hooks/useMentionSuggestion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts b/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts index 307668626..3393c4bc7 100644 --- a/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts +++ b/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts @@ -89,7 +89,7 @@ const useMentionSuggestion = (params: { .then((members) => members.slice(0, mentionManager.config.suggestionLimit)); } else { return freshChannel.members - .sort((a, b) => a.nickname?.localeCompare(b.nickname)) + .sort((a, b) => a.nickname?.toLowerCase().localeCompare(b.nickname.toLowerCase())) .filter( (member) => member.nickname?.toLowerCase().startsWith(searchString.toLowerCase()) && From f7a433499fc53301fc5420df5e70449f69933c59 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Tue, 18 Jun 2024 16:01:23 +0900 Subject: [PATCH 35/46] chore: fixed issue with file upload size limit exceeded --- .../src/common/useAppFeatures.ts | 3 +- .../src/contexts/SendbirdChatCtx.tsx | 1 + .../fragments/createGroupChannelFragment.tsx | 29 ++++++++++++++----- .../src/localization/StringSet.type.ts | 1 + .../src/localization/createBaseStringSet.ts | 3 ++ 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/packages/uikit-chat-hooks/src/common/useAppFeatures.ts b/packages/uikit-chat-hooks/src/common/useAppFeatures.ts index 592b59612..7670de7be 100644 --- a/packages/uikit-chat-hooks/src/common/useAppFeatures.ts +++ b/packages/uikit-chat-hooks/src/common/useAppFeatures.ts @@ -1,11 +1,12 @@ import { ApplicationAttributes, PremiumFeatures, SendbirdChatSDK } from '@sendbird/uikit-utils'; export const useAppFeatures = (sdk: SendbirdChatSDK) => { - const { premiumFeatureList = [], applicationAttributes = [] } = sdk.appInfo ?? {}; + const { premiumFeatureList = [], applicationAttributes = [], uploadSizeLimit } = sdk.appInfo ?? {}; return { deliveryReceiptEnabled: premiumFeatureList.includes(PremiumFeatures.delivery_receipt), broadcastChannelEnabled: applicationAttributes.includes(ApplicationAttributes.allow_broadcast_channel), superGroupChannelEnabled: applicationAttributes.includes(ApplicationAttributes.allow_super_group_channel), reactionEnabled: applicationAttributes.includes(ApplicationAttributes.reactions), + uploadSizeLimit: uploadSizeLimit, }; }; diff --git a/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx b/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx index 90d223c36..b1b6951ab 100644 --- a/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx +++ b/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx @@ -80,6 +80,7 @@ export type SendbirdChatContextType = { broadcastChannelEnabled: boolean; superGroupChannelEnabled: boolean; reactionEnabled: boolean; + uploadSizeLimit: number | undefined; }; }; }; diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index 4e153326c..e4a85e1fa 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -2,13 +2,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { MessageCollection, MessageFilter } from '@sendbird/chat/groupChannel'; import { ReplyType } from '@sendbird/chat/message'; -import { Box } from '@sendbird/uikit-react-native-foundation'; +import { Box, useToast } from '@sendbird/uikit-react-native-foundation'; import { useGroupChannelMessages } from '@sendbird/uikit-tools'; -import type { +import { SendbirdFileMessage, SendbirdGroupChannel, SendbirdSendableMessage, SendbirdUserMessage, + toMegabyte, } from '@sendbird/uikit-utils'; import { NOOP, @@ -33,7 +34,7 @@ import type { GroupChannelProps, GroupChannelPubSubContextPayload, } from '../domain/groupChannel/types'; -import { usePlatformService, useSendbirdChat } from '../hooks/useContext'; +import { useLocalization, usePlatformService, useSendbirdChat } from '../hooks/useContext'; import pubsub from '../utils/pubsub'; const createGroupChannelFragment = (initModule?: Partial): GroupChannelFragment => { @@ -64,6 +65,8 @@ const createGroupChannelFragment = (initModule?: Partial): G }) => { const { playerService, recorderService } = usePlatformService(); const { sdk, currentUser, sbOptions } = useSendbirdChat(); + const toast = useToast(); + const { STRINGS } = useLocalization(); const [internalSearchItem, setInternalSearchItem] = useState(searchItem); const navigateFromMessageSearch = useCallback(() => Boolean(searchItem), []); @@ -73,8 +76,11 @@ const createGroupChannelFragment = (initModule?: Partial): G const scrolledAwayFromBottomRef = useRefTracker(scrolledAwayFromBottom); const replyType = useIIFE(() => { - if (sbOptions.uikit.groupChannel.channel.replyType === 'none') return ReplyType.NONE; - else return ReplyType.ONLY_REPLY_TO_CHANNEL; + if (sbOptions.uikit.groupChannel.channel.replyType === 'none') { + return ReplyType.NONE; + } else { + return ReplyType.ONLY_REPLY_TO_CHANNEL; + } }); const { @@ -191,8 +197,17 @@ const createGroupChannelFragment = (initModule?: Partial): G const onPressSendFileMessage: GroupChannelProps['Input']['onPressSendFileMessage'] = useFreshCallback( async (params) => { const processedParams = await onBeforeSendFileMessage(params); - const message = await sendFileMessage(processedParams, onPending); - onSent(message); + const fileSize = (processedParams.file as File)?.size ?? processedParams.fileSize; + const uploadSizeLimit = sbOptions.appInfo.uploadSizeLimit; + + if (fileSize && uploadSizeLimit && fileSize > uploadSizeLimit) { + const sizeLimitString = `${toMegabyte(uploadSizeLimit)} MB`; + toast.show(STRINGS.TOAST.FILE_UPLOAD_SIZE_LIMIT_EXCEEDED_ERROR(sizeLimitString), 'error'); + return; + } else { + const message = await sendFileMessage(processedParams, onPending); + onSent(message); + } }, ); const onPressUpdateUserMessage: GroupChannelProps['Input']['onPressUpdateUserMessage'] = useFreshCallback( diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index b07bc85d1..b5b7bb8a2 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -372,6 +372,7 @@ export interface StringSet { GET_CHANNEL_ERROR: string; FIND_PARENT_MSG_ERROR: string; THREAD_PARENT_MESSAGE_DELETED_ERROR: string; + FILE_UPLOAD_SIZE_LIMIT_EXCEEDED_ERROR: (uploadSizeLimit: string) => string; }; PROFILE_CARD: { BUTTON_MESSAGE: string; diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index ba4aca513..5dc4099f2 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -398,6 +398,9 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp GET_CHANNEL_ERROR: "Couldn't retrieve channel.", FIND_PARENT_MSG_ERROR: "Couldn't find the original message for this reply.", THREAD_PARENT_MESSAGE_DELETED_ERROR: "The thread doesn't exist because the parent message was deleted.", + FILE_UPLOAD_SIZE_LIMIT_EXCEEDED_ERROR: (uploadSizeLimit: string) => { + return `The maximum size per file is ${uploadSizeLimit}.`; + }, ...overrides?.TOAST, }, PROFILE_CARD: { From 9fe3a0fde88a1d68b0dcb2235d58151e729be2c1 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Tue, 18 Jun 2024 16:14:15 +0900 Subject: [PATCH 36/46] chore: fixed issue with file upload size limit exceeded --- .../src/fragments/createGroupChannelFragment.tsx | 4 ++-- .../createGroupChannelThreadFragment.tsx | 14 ++++++++++++-- packages/uikit-utils/src/ui-format/common.ts | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index e4a85e1fa..60b0af769 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -9,7 +9,7 @@ import { SendbirdGroupChannel, SendbirdSendableMessage, SendbirdUserMessage, - toMegabyte, + getReadableFileSize, } from '@sendbird/uikit-utils'; import { NOOP, @@ -201,7 +201,7 @@ const createGroupChannelFragment = (initModule?: Partial): G const uploadSizeLimit = sbOptions.appInfo.uploadSizeLimit; if (fileSize && uploadSizeLimit && fileSize > uploadSizeLimit) { - const sizeLimitString = `${toMegabyte(uploadSizeLimit)} MB`; + const sizeLimitString = `${getReadableFileSize(uploadSizeLimit)} MB`; toast.show(STRINGS.TOAST.FILE_UPLOAD_SIZE_LIMIT_EXCEEDED_ERROR(sizeLimitString), 'error'); return; } else { diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index 994888c83..c6215cd6f 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -7,6 +7,7 @@ import { SendbirdGroupChannel, type SendbirdMessage, SendbirdUserMessage, + getReadableFileSize, } from '@sendbird/uikit-utils'; import { NOOP, @@ -178,8 +179,17 @@ const createGroupChannelThreadFragment = ( const onPressSendFileMessage: GroupChannelThreadProps['Input']['onPressSendFileMessage'] = useFreshCallback( async (params) => { const processedParams = await onBeforeSendFileMessage(params); - const message = await sendFileMessage(processedParams, onPending); - onSent(message); + const fileSize = (processedParams.file as File)?.size ?? processedParams.fileSize; + const uploadSizeLimit = sbOptions.appInfo.uploadSizeLimit; + + if (fileSize && uploadSizeLimit && fileSize > uploadSizeLimit) { + const sizeLimitString = `${getReadableFileSize(uploadSizeLimit)} MB`; + toast.show(STRINGS.TOAST.FILE_UPLOAD_SIZE_LIMIT_EXCEEDED_ERROR(sizeLimitString), 'error'); + return; + } else { + const message = await sendFileMessage(processedParams, onPending); + onSent(message); + } }, ); const onPressUpdateUserMessage: GroupChannelThreadProps['Input']['onPressUpdateUserMessage'] = useFreshCallback( diff --git a/packages/uikit-utils/src/ui-format/common.ts b/packages/uikit-utils/src/ui-format/common.ts index fc3718954..be0de1319 100644 --- a/packages/uikit-utils/src/ui-format/common.ts +++ b/packages/uikit-utils/src/ui-format/common.ts @@ -179,3 +179,18 @@ export const getReplyCountFormat = (replyCount: number, minRepliesForPlusFormat? export const getThreadParentMessageTimeFormat = (date: Date, locale?: Locale): string => { return format(date, "MMM dd 'at' h:mm a", { locale }); }; + +/** + * File size format + * + * @param {number} fileSize + * @returns {string} + * */ +export const getReadableFileSize = (fileSize: number): string => { + if (fileSize <= 0) { + return '0 B'; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const digitGroups = Math.floor(Math.log10(fileSize) / Math.log10(1024)); + return `${(fileSize / Math.pow(1024, digitGroups)).toFixed(1)} ${units[digitGroups]}`; +}; From 53900179f9b58284987d67171a8f1eef0289ea53 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 21 Jun 2024 14:51:58 +0900 Subject: [PATCH 37/46] chore: add voice message status manager --- .../Message.file.voice.tsx | 4 +- .../GroupChannelMessageRenderer/index.tsx | 42 +++++++++++++------ .../src/containers/SendbirdUIKitContainer.tsx | 3 ++ .../src/contexts/SendbirdChatCtx.tsx | 5 +++ .../src/libs/VoiceMessageStatusManager.ts | 29 +++++++++++++ 5 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx index 2a9924c43..30d363a49 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx @@ -25,6 +25,7 @@ type Props = GroupChannelMessageProps< { durationMetaArrayKey?: string; onUnmount: () => void; + initialCurrentTime?: number; } >; const VoiceFileMessage = (props: Props) => { @@ -35,6 +36,7 @@ const VoiceFileMessage = (props: Props) => { message, durationMetaArrayKey = 'KEY_VOICE_MESSAGE_DURATION', onUnmount, + initialCurrentTime, } = props; const { colors } = useUIKitTheme(); @@ -45,7 +47,7 @@ const VoiceFileMessage = (props: Props) => { const initialDuration = value ? parseInt(value, 10) : 0; return { status: 'paused', - currentTime: 0, + currentTime: initialCurrentTime || 0, duration: initialDuration, }; }); diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index 412a78f26..13dac84cf 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -51,7 +51,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' }) => { const playerUnsubscribes = useRef<(() => void)[]>([]); const { palette } = useUIKitTheme(); - const { sbOptions, currentUser, mentionManager } = useSendbirdChat(); + const { sbOptions, currentUser, mentionManager, voiceMessageStatusManager } = useSendbirdChat(); const { STRINGS } = useLocalization(); const { mediaService, playerService } = usePlatformService(); const { groupWithPrev, groupWithNext } = calcMessageGrouping( @@ -62,6 +62,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' sbOptions.uikit.groupChannel.channel.replyType === 'thread', shouldRenderParentMessage(message, hideParentMessage), ); + const variant = isMyMessage(message, currentUser?.userId) ? 'outgoing' : 'incoming'; const reactionChildren = useIIFE(() => { @@ -106,6 +107,8 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' }, onToggleVoiceMessage: async (state, setState) => { if (isVoiceMessage(message) && message.sendingStatus === 'succeeded') { + voiceMessageStatusManager.setCurrentTime(message.channelUrl, message.messageId, state.currentTime); + if (playerService.uri === message.url) { if (playerService.state === 'playing') { await playerService.pause(); @@ -226,6 +229,30 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' ], }; + const renderVoiceMessage = () => { + const voiceMessageProps: { + durationMetaArrayKey?: string; + onUnmount: () => void; + initialCurrentTime?: number; + } = { + durationMetaArrayKey: VOICE_MESSAGE_META_ARRAY_DURATION_KEY, + initialCurrentTime: voiceMessageStatusManager.getCurrentTime(message.channelUrl, message.messageId), + onUnmount: () => { + if (isVoiceMessage(message) && playerService.uri === message.url) { + resetPlayer(); + } + }, + }; + + return ( + + ); + }; + const renderMessage = () => { switch (getMessageType(message)) { case 'admin': { @@ -268,18 +295,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' ); } case 'file.voice': { - return ( - { - if (isVoiceMessage(message) && playerService.uri === message.url) { - resetPlayer(); - } - }} - {...messageProps} - /> - ); + return renderVoiceMessage(); } case 'unknown': default: { diff --git a/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx b/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx index c435ccc2a..98d597be6 100644 --- a/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx +++ b/packages/uikit-react-native/src/containers/SendbirdUIKitContainer.tsx @@ -38,6 +38,7 @@ import InternalLocalCacheStorage from '../libs/InternalLocalCacheStorage'; import MentionConfig, { MentionConfigInterface } from '../libs/MentionConfig'; import MentionManager from '../libs/MentionManager'; import VoiceMessageConfig, { VoiceMessageConfigInterface } from '../libs/VoiceMessageConfig'; +import VoiceMessageStatusManager from '../libs/VoiceMessageStatusManager'; import StringSetEn from '../localization/StringSet.en'; import type { StringSet } from '../localization/StringSet.type'; import SBUDynamicModule from '../platform/dynamicModule'; @@ -179,6 +180,7 @@ const SendbirdUIKitContainer = (props: SendbirdUIKitContainerProps) => { const { imageCompressionConfig, voiceMessageConfig, mentionConfig } = useConfigInstance(props); const emojiManager = useMemo(() => new EmojiManager(internalStorage), [internalStorage]); const mentionManager = useMemo(() => new MentionManager(mentionConfig), [mentionConfig]); + const voiceMessageStatusManager = useMemo(() => new VoiceMessageStatusManager(), []); useLayoutEffect(() => { if (!isFirstMount) { @@ -226,6 +228,7 @@ const SendbirdUIKitContainer = (props: SendbirdUIKitContainerProps) => { mentionManager={mentionManager} imageCompressionConfig={imageCompressionConfig} voiceMessageConfig={voiceMessageConfig} + voiceMessageStatusManager={voiceMessageStatusManager} enableAutoPushTokenRegistration={ chatOptions.enableAutoPushTokenRegistration ?? SendbirdUIKit.DEFAULT.AUTO_PUSH_TOKEN_REGISTRATION } diff --git a/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx b/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx index b1b6951ab..352ae5aff 100644 --- a/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx +++ b/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx @@ -14,6 +14,7 @@ import type EmojiManager from '../libs/EmojiManager'; import type ImageCompressionConfig from '../libs/ImageCompressionConfig'; import type MentionManager from '../libs/MentionManager'; import type VoiceMessageConfig from '../libs/VoiceMessageConfig'; +import VoiceMessageStatusManager from '../libs/VoiceMessageStatusManager'; import type { FileType } from '../platform/types'; export interface ChatRelatedFeaturesInUIKit { @@ -27,6 +28,7 @@ interface Props extends ChatRelatedFeaturesInUIKit, React.PropsWithChildren { emojiManager: EmojiManager; mentionManager: MentionManager; + voiceMessageStatusManager: VoiceMessageStatusManager; imageCompressionConfig: ImageCompressionConfig; voiceMessageConfig: VoiceMessageConfig; } @@ -39,6 +41,7 @@ export type SendbirdChatContextType = { // feature related instances emojiManager: EmojiManager; mentionManager: MentionManager; + voiceMessageStatusManager: VoiceMessageStatusManager; imageCompressionConfig: ImageCompressionConfig; voiceMessageConfig: VoiceMessageConfig; @@ -91,6 +94,7 @@ export const SendbirdChatProvider = ({ sdkInstance, emojiManager, mentionManager, + voiceMessageStatusManager, imageCompressionConfig, voiceMessageConfig, enableAutoPushTokenRegistration, @@ -165,6 +169,7 @@ export const SendbirdChatProvider = ({ mentionManager, imageCompressionConfig, voiceMessageConfig, + voiceMessageStatusManager, currentUser, setCurrentUser, diff --git a/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts b/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts new file mode 100644 index 000000000..e79b53d9c --- /dev/null +++ b/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts @@ -0,0 +1,29 @@ +interface VoiceMessageStatus { + currentTime: number; +} + +class VoiceMessageStatusManager { + private statusMap: Map = new Map(); + + private generateKey(channelUrl: string, messageId: number): string { + return `${channelUrl}-${messageId}`; + } + + getCurrentTime(channelUrl: string, messageId: number): number { + const key = this.generateKey(channelUrl, messageId); + console.log(`useVoiceMessageStatus key:${key} currentTime: ${this.statusMap.get(key)?.currentTime}`); + return this.statusMap.get(key)?.currentTime || 0; + } + + setCurrentTime(channelUrl: string, messageId: number, currentTime: number): void { + const key = this.generateKey(channelUrl, messageId); + console.log(`useVoiceMessageStatus updateCurrentTime key:${key} currentTime: ${currentTime}`); + if (!this.statusMap.has(key)) { + this.statusMap.set(key, { currentTime }); + } else { + this.statusMap.get(key)!.currentTime = currentTime; + } + } +} + +export default VoiceMessageStatusManager; From 226fdb688a4d63499cca6531a41eb8be9f826ea5 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Sun, 23 Jun 2024 17:38:02 +0900 Subject: [PATCH 38/46] chore: add voice message status manager --- .../Message.file.voice.tsx | 8 +++++ packages/uikit-react-native/package.json | 2 +- .../GroupChannelMessageRenderer/index.tsx | 5 +-- .../fragments/createGroupChannelFragment.tsx | 3 +- .../createGroupChannelThreadFragment.tsx | 3 +- .../src/libs/VoiceMessageStatusManager.ts | 36 ++++++++++++++----- yarn.lock | 8 ++--- 7 files changed, 48 insertions(+), 17 deletions(-) diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx index 30d363a49..85dfda143 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx @@ -26,6 +26,7 @@ type Props = GroupChannelMessageProps< durationMetaArrayKey?: string; onUnmount: () => void; initialCurrentTime?: number; + onSubscribe?: (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => void; } >; const VoiceFileMessage = (props: Props) => { @@ -37,6 +38,7 @@ const VoiceFileMessage = (props: Props) => { durationMetaArrayKey = 'KEY_VOICE_MESSAGE_DURATION', onUnmount, initialCurrentTime, + onSubscribe, } = props; const { colors } = useUIKitTheme(); @@ -58,6 +60,12 @@ const VoiceFileMessage = (props: Props) => { }; }, []); + useEffect(() => { + onSubscribe?.(props.channel.url, props.message.messageId, (currentTime) => { + setState((prev) => ({ ...prev, currentTime })); + }); + }, []); + const uiColors = colors.ui.groupChannelMessage[variant]; const remainingTime = state.duration - state.currentTime; diff --git a/packages/uikit-react-native/package.json b/packages/uikit-react-native/package.json index 8642bf887..2cc4a4946 100644 --- a/packages/uikit-react-native/package.json +++ b/packages/uikit-react-native/package.json @@ -62,7 +62,7 @@ "@openspacelabs/react-native-zoomable-view": "^2.1.5", "@sendbird/uikit-chat-hooks": "3.5.4", "@sendbird/uikit-react-native-foundation": "3.5.4", - "@sendbird/uikit-tools": "0.0.1-alpha.76", + "@sendbird/uikit-tools": "0.0.1-alpha.77", "@sendbird/uikit-utils": "3.5.4" }, "devDependencies": { diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index 13dac84cf..a9520187d 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -107,8 +107,6 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' }, onToggleVoiceMessage: async (state, setState) => { if (isVoiceMessage(message) && message.sendingStatus === 'succeeded') { - voiceMessageStatusManager.setCurrentTime(message.channelUrl, message.messageId, state.currentTime); - if (playerService.uri === message.url) { if (playerService.state === 'playing') { await playerService.pause(); @@ -124,6 +122,7 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' let seekFinished = !shouldSeekToTime; const forPlayback = playerService.addPlaybackListener(({ stopped, currentTime, duration }) => { + voiceMessageStatusManager.setCurrentTime(message.channelUrl, message.messageId, currentTime); if (seekFinished) { setState((prevState) => ({ ...prevState, currentTime: stopped ? 0 : currentTime, duration })); } @@ -234,9 +233,11 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' durationMetaArrayKey?: string; onUnmount: () => void; initialCurrentTime?: number; + onSubscribe?: (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => void; } = { durationMetaArrayKey: VOICE_MESSAGE_META_ARRAY_DURATION_KEY, initialCurrentTime: voiceMessageStatusManager.getCurrentTime(message.channelUrl, message.messageId), + onSubscribe: voiceMessageStatusManager.subscribe, onUnmount: () => { if (isVoiceMessage(message) && playerService.uri === message.url) { resetPlayer(); diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index 60b0af769..0fb186dfb 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -64,7 +64,7 @@ const createGroupChannelFragment = (initModule?: Partial): G collectionCreator, }) => { const { playerService, recorderService } = usePlatformService(); - const { sdk, currentUser, sbOptions } = useSendbirdChat(); + const { sdk, currentUser, sbOptions, voiceMessageStatusManager } = useSendbirdChat(); const toast = useToast(); const { STRINGS } = useLocalization(); @@ -120,6 +120,7 @@ const createGroupChannelFragment = (initModule?: Partial): G }; const _onPressHeaderLeft = useFreshCallback(async () => { await onBlurFragment(); + voiceMessageStatusManager.clear(); onPressHeaderLeft(); }); const _onPressHeaderRight = useFreshCallback(async () => { diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index c6215cd6f..1b9520b60 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -54,7 +54,7 @@ const createGroupChannelThreadFragment = ( flatListProps, }) => { const { playerService, recorderService } = usePlatformService(); - const { sdk, currentUser, sbOptions } = useSendbirdChat(); + const { sdk, currentUser, sbOptions, voiceMessageStatusManager } = useSendbirdChat(); const [groupChannelThreadPubSub] = useState(() => pubsub()); const [scrolledAwayFromBottom, setScrolledAwayFromBottom] = useState(false); @@ -107,6 +107,7 @@ const createGroupChannelThreadFragment = ( }; const _onPressHeader = useFreshCallback(async () => { await onBlurFragment(); + voiceMessageStatusManager.publishAll(); onPressHeader(); }); const _onPressMediaMessage: NonNullable = diff --git a/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts b/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts index e79b53d9c..05efda8a3 100644 --- a/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts +++ b/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts @@ -1,29 +1,49 @@ interface VoiceMessageStatus { currentTime: number; + subscribers?: Set<(currentTime: number) => void>; } class VoiceMessageStatusManager { private statusMap: Map = new Map(); - private generateKey(channelUrl: string, messageId: number): string { + private generateKey = (channelUrl: string, messageId: number): string => { return `${channelUrl}-${messageId}`; - } + }; - getCurrentTime(channelUrl: string, messageId: number): number { + subscribe = (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => { + const key = this.generateKey(channelUrl, messageId); + if (!this.statusMap.has(key)) { + this.statusMap.set(key, { currentTime: 0, subscribers: new Set() }); + } + + this.statusMap.get(key)!.subscribers?.add(subscriber); + }; + + publishAll = (): void => { + this.statusMap.forEach((status) => { + status.subscribers?.forEach((subscriber) => { + subscriber(status.currentTime); + }); + }); + }; + + getCurrentTime = (channelUrl: string, messageId: number): number => { const key = this.generateKey(channelUrl, messageId); - console.log(`useVoiceMessageStatus key:${key} currentTime: ${this.statusMap.get(key)?.currentTime}`); return this.statusMap.get(key)?.currentTime || 0; - } + }; - setCurrentTime(channelUrl: string, messageId: number, currentTime: number): void { + setCurrentTime = (channelUrl: string, messageId: number, currentTime: number): void => { const key = this.generateKey(channelUrl, messageId); - console.log(`useVoiceMessageStatus updateCurrentTime key:${key} currentTime: ${currentTime}`); if (!this.statusMap.has(key)) { this.statusMap.set(key, { currentTime }); } else { this.statusMap.get(key)!.currentTime = currentTime; } - } + }; + + clear = (): void => { + this.statusMap.clear(); + }; } export default VoiceMessageStatusManager; diff --git a/yarn.lock b/yarn.lock index f935ff794..8210ddae4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3389,10 +3389,10 @@ resolved "https://registry.yarnpkg.com/@sendbird/react-native-scrollview-enhancer/-/react-native-scrollview-enhancer-0.2.1.tgz#25de4af78293978a4c0ef6fddee25d822a364c46" integrity sha512-LN+Tm+ZUkE2MBVreg/JI8SVr8SOKRteZN0YFpGzRtbKkP45+pKyPN4JQPf73eFx7qO8zDL+TUVyzz/1MOnIK7g== -"@sendbird/uikit-tools@0.0.1-alpha.76": - version "0.0.1-alpha.76" - resolved "https://registry.yarnpkg.com/@sendbird/uikit-tools/-/uikit-tools-0.0.1-alpha.76.tgz#7f293aaa61089b644a9f4ca5f7929ce4f0d8885f" - integrity sha512-YbKsbpacI1OIvTGviUwuAAu7HPb/BeCxlo2AW6kyvRbNglocVxxcqvHW8Oe31r2oq98spEt7TPLMDuNyNNM5AA== +"@sendbird/uikit-tools@0.0.1-alpha.77": + version "0.0.1-alpha.77" + resolved "https://registry.yarnpkg.com/@sendbird/uikit-tools/-/uikit-tools-0.0.1-alpha.77.tgz#f305849dc767488b76853be653c2149ec240e558" + integrity sha512-MZEgPmBYGf5QI+gqxVXJmQf880giYt8eJ9WNndvBFAMCXGm6Z12LaurU0IOR87wPyROse5kTy8smwXT8UaiuYQ== "@sideway/address@^4.1.3": version "4.1.4" From ceae6b8a42eb1564185cddd3e32df755ce35143f Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Sun, 23 Jun 2024 17:47:49 +0900 Subject: [PATCH 39/46] chore: change scroll timeout --- .../component/GroupChannelThreadMessageList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx index b72a79de8..91e984800 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx @@ -33,7 +33,7 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi const foundMessageIndex = props.messages.findIndex((it) => it.createdAt === props.startingPoint); const isIncludedInList = foundMessageIndex > -1; if (isIncludedInList) { - lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout: 250 }); + lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout: 500 }); } } }, [props.startingPoint]); From 4e797aaed17c6a87a0b4161b437a9d3b7c57d538 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Sun, 23 Jun 2024 18:15:18 +0900 Subject: [PATCH 40/46] chore: organize code --- .../ui/GroupChannelMessage/Message.file.voice.tsx | 14 ++++++++++---- .../GroupChannelMessageRenderer/index.tsx | 7 +++++-- .../src/fragments/createGroupChannelFragment.tsx | 2 +- .../src/libs/VoiceMessageStatusManager.ts | 9 ++++++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx index 85dfda143..1aa51da71 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/Message.file.voice.tsx @@ -26,7 +26,8 @@ type Props = GroupChannelMessageProps< durationMetaArrayKey?: string; onUnmount: () => void; initialCurrentTime?: number; - onSubscribe?: (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => void; + onSubscribeStatus?: (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => void; + onUnsubscribeStatus?: (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => void; } >; const VoiceFileMessage = (props: Props) => { @@ -38,7 +39,8 @@ const VoiceFileMessage = (props: Props) => { durationMetaArrayKey = 'KEY_VOICE_MESSAGE_DURATION', onUnmount, initialCurrentTime, - onSubscribe, + onSubscribeStatus, + onUnsubscribeStatus, } = props; const { colors } = useUIKitTheme(); @@ -61,9 +63,13 @@ const VoiceFileMessage = (props: Props) => { }, []); useEffect(() => { - onSubscribe?.(props.channel.url, props.message.messageId, (currentTime) => { + const updateCurrentTime = (currentTime: number) => { setState((prev) => ({ ...prev, currentTime })); - }); + }; + onSubscribeStatus?.(props.channel.url, props.message.messageId, updateCurrentTime); + return () => { + onUnsubscribeStatus?.(props.channel.url, props.message.messageId, updateCurrentTime); + }; }, []); const uiColors = colors.ui.groupChannelMessage[variant]; diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index a9520187d..fc4ac4c3c 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -27,6 +27,7 @@ import { GroupChannelContexts } from '../../domain/groupChannel/module/moduleCon import type { GroupChannelProps } from '../../domain/groupChannel/types'; import { useLocalization, usePlatformService, useSendbirdChat } from '../../hooks/useContext'; import SBUUtils from '../../libs/SBUUtils'; +import VoiceMessageStatusManager from '../../libs/VoiceMessageStatusManager'; import { TypingIndicatorType } from '../../types'; import { ReactionAddons } from '../ReactionAddons'; import GroupChannelMessageDateSeparator from './GroupChannelMessageDateSeparator'; @@ -233,11 +234,13 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' durationMetaArrayKey?: string; onUnmount: () => void; initialCurrentTime?: number; - onSubscribe?: (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => void; + onSubscribeStatus?: VoiceMessageStatusManager['subscribe']; + onUnsubscribeStatus?: VoiceMessageStatusManager['unsubscribe']; } = { durationMetaArrayKey: VOICE_MESSAGE_META_ARRAY_DURATION_KEY, initialCurrentTime: voiceMessageStatusManager.getCurrentTime(message.channelUrl, message.messageId), - onSubscribe: voiceMessageStatusManager.subscribe, + onSubscribeStatus: voiceMessageStatusManager.subscribe, + onUnsubscribeStatus: voiceMessageStatusManager.unsubscribe, onUnmount: () => { if (isVoiceMessage(message) && playerService.uri === message.url) { resetPlayer(); diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index 0fb186dfb..a0caa9060 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -119,8 +119,8 @@ const createGroupChannelFragment = (initModule?: Partial): G return Promise.allSettled([playerService.reset(), recorderService.reset()]); }; const _onPressHeaderLeft = useFreshCallback(async () => { - await onBlurFragment(); voiceMessageStatusManager.clear(); + await onBlurFragment(); onPressHeaderLeft(); }); const _onPressHeaderRight = useFreshCallback(async () => { diff --git a/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts b/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts index 05efda8a3..da26d1540 100644 --- a/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts +++ b/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts @@ -15,10 +15,14 @@ class VoiceMessageStatusManager { if (!this.statusMap.has(key)) { this.statusMap.set(key, { currentTime: 0, subscribers: new Set() }); } - this.statusMap.get(key)!.subscribers?.add(subscriber); }; + unsubscribe = (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => { + const key = this.generateKey(channelUrl, messageId); + this.statusMap.get(key)?.subscribers?.delete(subscriber); + }; + publishAll = (): void => { this.statusMap.forEach((status) => { status.subscribers?.forEach((subscriber) => { @@ -42,6 +46,9 @@ class VoiceMessageStatusManager { }; clear = (): void => { + this.statusMap.forEach((status) => { + status.subscribers?.clear(); + }); this.statusMap.clear(); }; } From 7b9072c8e7aaef56caff6864050e14c6a5fcc426 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Sun, 23 Jun 2024 22:32:52 +0900 Subject: [PATCH 41/46] chore: fixed scroll issue --- .../src/components/ChatFlatList/index.tsx | 12 ++++++++-- .../GroupChannelThreadMessageList.tsx | 24 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/uikit-react-native/src/components/ChatFlatList/index.tsx b/packages/uikit-react-native/src/components/ChatFlatList/index.tsx index 85ae7de6a..52e058bcb 100644 --- a/packages/uikit-react-native/src/components/ChatFlatList/index.tsx +++ b/packages/uikit-react-native/src/components/ChatFlatList/index.tsx @@ -72,6 +72,14 @@ const ChatFlatList = forwardRef(function ChatFlatList( ); } + const _onStartReached: () => void = () => { + inverted.current ? onBottomReached() : onTopReached(); + }; + + const _onEndReached: () => void = () => { + inverted.current ? onTopReached() : onBottomReached(); + }; + return ( (function ChatFlatList( {...props} inverted={inverted.current} ref={ref} - onEndReached={onTopReached} + onEndReached={_onEndReached} onScrollToIndexFailed={NOOP} - onStartReached={onBottomReached} + onStartReached={_onStartReached} scrollEventThrottle={16} onScroll={_onScroll} keyExtractor={getMessageUniqId} diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx index 91e984800..6f833774d 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useLayoutEffect } from 'react'; +import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react'; import { useChannelHandler } from '@sendbird/uikit-chat-hooks'; import { isDifferentChannel, useFreshCallback, useUniqHandlerId } from '@sendbird/uikit-utils'; @@ -15,6 +15,19 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi const { flatListRef, lazyScrollToBottom, lazyScrollToIndex } = useContext(GroupChannelThreadContexts.MessageList); const id = useUniqHandlerId('GroupChannelThreadMessageList'); + const ignorePropReached = useRef(false); + + const _onTopReached = () => { + if (!ignorePropReached.current) { + props.onTopReached(); + } + }; + + const _onBottomReached = () => { + if (!ignorePropReached.current) { + props.onBottomReached(); + } + }; const scrollToBottom = useFreshCallback(async (animated = false) => { if (props.hasNext()) { @@ -33,7 +46,12 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi const foundMessageIndex = props.messages.findIndex((it) => it.createdAt === props.startingPoint); const isIncludedInList = foundMessageIndex > -1; if (isIncludedInList) { - lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout: 500 }); + ignorePropReached.current = true; + const timeout = 300; + lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout: timeout }); + setTimeout(() => { + ignorePropReached.current = false; + }, timeout + 50); } } }, [props.startingPoint]); @@ -73,6 +91,8 @@ const GroupChannelThreadMessageList = (props: GroupChannelThreadProps['MessageLi Date: Fri, 28 Jun 2024 15:27:39 +0900 Subject: [PATCH 42/46] chore: apply review. --- .../src/components/ChannelInput/SendInput.tsx | 2 +- .../ChannelThreadMessageList/index.tsx | 4 +- .../src/components/ChatFlatList/index.tsx | 37 +++-------- .../GroupChannelMessageReplyInfo.tsx | 2 +- .../GroupChannelMessageRenderer/index.tsx | 45 +++++-------- .../components/ThreadChatFlatList/index.tsx | 63 +++++++++++++++++++ .../component/GroupChannelMessageList.tsx | 2 +- .../GroupChannelThreadParentMessageInfo.tsx | 2 +- .../fragments/createGroupChannelFragment.tsx | 3 +- .../createGroupChannelThreadFragment.tsx | 1 - .../src/hooks/useMentionSuggestion.ts | 22 ++++--- .../src/localization/StringSet.type.ts | 2 +- .../src/localization/createBaseStringSet.ts | 2 +- 13 files changed, 110 insertions(+), 77 deletions(-) create mode 100644 packages/uikit-react-native/src/components/ThreadChatFlatList/index.tsx diff --git a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx index 421425d11..ba070401d 100644 --- a/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx +++ b/packages/uikit-react-native/src/components/ChannelInput/SendInput.tsx @@ -165,7 +165,7 @@ const SendInput = forwardRef(function SendInput( if (inputDisabled) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_DISABLED; if (messageToReply) return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_REPLY; if (messageForThread) { - if (messageForThread?.threadInfo && messageForThread?.threadInfo.replyCount > 0) { + if (messageForThread.threadInfo && messageForThread.threadInfo.replyCount > 0) { return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_REPLY_TO_THREAD; } else { return STRINGS.LABELS.CHANNEL_INPUT_PLACEHOLDER_REPLY_IN_THREAD; diff --git a/packages/uikit-react-native/src/components/ChannelThreadMessageList/index.tsx b/packages/uikit-react-native/src/components/ChannelThreadMessageList/index.tsx index 40afd50b1..bcbcbec66 100644 --- a/packages/uikit-react-native/src/components/ChannelThreadMessageList/index.tsx +++ b/packages/uikit-react-native/src/components/ChannelThreadMessageList/index.tsx @@ -32,8 +32,8 @@ import { import type { UserProfileContextType } from '../../contexts/UserProfileCtx'; import { useLocalization, usePlatformService, useSendbirdChat, useUserProfile } from '../../hooks/useContext'; import SBUUtils from '../../libs/SBUUtils'; -import ChatFlatList from '../ChatFlatList'; import { ReactionAddons } from '../ReactionAddons'; +import ThreadChatFlatList from '../ThreadChatFlatList'; type PressActions = { onPress?: () => void; onLongPress?: () => void; bottomSheetItem?: BottomSheetItem }; type HandleableMessage = SendbirdUserMessage | SendbirdFileMessage; @@ -147,7 +147,7 @@ const ChannelThreadMessageList = )} - (function ChatFlatList( ) { const { select } = useUIKitTheme(); const contentOffsetY = useRef(0); - // FIXME: inverted list of ListEmptyComponent is reversed {@link https://github.com/facebook/react-native/issues/21196#issuecomment-836937743} - const inverted = useRef(props.inverted ?? Boolean(props.data?.length)); - inverted.current = props.inverted ?? Boolean(props.data?.length); const _onScroll = useFreshCallback>((event) => { onScroll?.(event); - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const { contentOffset } = event.nativeEvent; const prevOffsetY = contentOffsetY.current; const currOffsetY = contentOffset.y; - if (inverted.current) { - if (BOTTOM_DETECT_THRESHOLD < prevOffsetY && currOffsetY <= BOTTOM_DETECT_THRESHOLD) { - onScrolledAwayFromBottom(false); - } else if (BOTTOM_DETECT_THRESHOLD < currOffsetY && prevOffsetY <= BOTTOM_DETECT_THRESHOLD) { - onScrolledAwayFromBottom(true); - } - } else { - const bottomDetectThreshold = contentSize.height - layoutMeasurement.height - BOTTOM_DETECT_THRESHOLD; - if (bottomDetectThreshold < prevOffsetY && currOffsetY <= bottomDetectThreshold) { - onScrolledAwayFromBottom(true); - } else if (bottomDetectThreshold < currOffsetY && prevOffsetY <= bottomDetectThreshold) { - onScrolledAwayFromBottom(false); - } + if (BOTTOM_DETECT_THRESHOLD < prevOffsetY && currOffsetY <= BOTTOM_DETECT_THRESHOLD) { + onScrolledAwayFromBottom(false); + } else if (BOTTOM_DETECT_THRESHOLD < currOffsetY && prevOffsetY <= BOTTOM_DETECT_THRESHOLD) { + onScrolledAwayFromBottom(true); } contentOffsetY.current = contentOffset.y; @@ -72,14 +60,6 @@ const ChatFlatList = forwardRef(function ChatFlatList( ); } - const _onStartReached: () => void = () => { - inverted.current ? onBottomReached() : onTopReached(); - }; - - const _onEndReached: () => void = () => { - inverted.current ? onTopReached() : onBottomReached(); - }; - return ( (function ChatFlatList( keyboardShouldPersistTaps={'handled'} indicatorStyle={select({ light: 'black', dark: 'white' })} {...props} - inverted={inverted.current} + // FIXME: inverted list of ListEmptyComponent is reversed {@link https://github.com/facebook/react-native/issues/21196#issuecomment-836937743} + inverted={Boolean(props.data?.length)} ref={ref} - onEndReached={_onEndReached} + onEndReached={onTopReached} onScrollToIndexFailed={NOOP} - onStartReached={_onStartReached} + onStartReached={onBottomReached} scrollEventThrottle={16} onScroll={_onScroll} keyExtractor={getMessageUniqId} diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx index 45f0eedc4..1e7632f23 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -53,7 +53,7 @@ const GroupChannelMessageReplyInfo = ({ channel, message, onPress }: Props) => { if (!channel || !message.threadInfo || !message.threadInfo.replyCount) return null; - const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_COUNT(message.threadInfo.replyCount || 0, 99); + const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLY_COUNT(message.threadInfo.replyCount || 0, 99); const onPressReply = () => { onPress?.(message as SendbirdUserMessage | SendbirdFileMessage); }; diff --git a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx index fc4ac4c3c..ad70dc93a 100644 --- a/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/index.tsx @@ -27,7 +27,6 @@ import { GroupChannelContexts } from '../../domain/groupChannel/module/moduleCon import type { GroupChannelProps } from '../../domain/groupChannel/types'; import { useLocalization, usePlatformService, useSendbirdChat } from '../../hooks/useContext'; import SBUUtils from '../../libs/SBUUtils'; -import VoiceMessageStatusManager from '../../libs/VoiceMessageStatusManager'; import { TypingIndicatorType } from '../../types'; import { ReactionAddons } from '../ReactionAddons'; import GroupChannelMessageDateSeparator from './GroupChannelMessageDateSeparator'; @@ -229,34 +228,6 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' ], }; - const renderVoiceMessage = () => { - const voiceMessageProps: { - durationMetaArrayKey?: string; - onUnmount: () => void; - initialCurrentTime?: number; - onSubscribeStatus?: VoiceMessageStatusManager['subscribe']; - onUnsubscribeStatus?: VoiceMessageStatusManager['unsubscribe']; - } = { - durationMetaArrayKey: VOICE_MESSAGE_META_ARRAY_DURATION_KEY, - initialCurrentTime: voiceMessageStatusManager.getCurrentTime(message.channelUrl, message.messageId), - onSubscribeStatus: voiceMessageStatusManager.subscribe, - onUnsubscribeStatus: voiceMessageStatusManager.unsubscribe, - onUnmount: () => { - if (isVoiceMessage(message) && playerService.uri === message.url) { - resetPlayer(); - } - }, - }; - - return ( - - ); - }; - const renderMessage = () => { switch (getMessageType(message)) { case 'admin': { @@ -299,7 +270,21 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' ); } case 'file.voice': { - return renderVoiceMessage(); + return ( + { + if (isVoiceMessage(message) && playerService.uri === message.url) { + resetPlayer(); + } + }} + {...messageProps} + /> + ); } case 'unknown': default: { diff --git a/packages/uikit-react-native/src/components/ThreadChatFlatList/index.tsx b/packages/uikit-react-native/src/components/ThreadChatFlatList/index.tsx new file mode 100644 index 000000000..a76ad1de4 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadChatFlatList/index.tsx @@ -0,0 +1,63 @@ +import React, { forwardRef, useRef } from 'react'; +import { FlatListProps, FlatList as RNFlatList, StyleSheet } from 'react-native'; + +import { useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { NOOP, SendbirdMessage, getMessageUniqId, useFreshCallback } from '@sendbird/uikit-utils'; + +import FlatListInternal from '../ChatFlatList/FlatListInternal'; + +const BOTTOM_DETECT_THRESHOLD = 50; +const UNREACHABLE_THRESHOLD = Number.MIN_SAFE_INTEGER; + +type Props = Omit, 'onEndReached'> & { + onBottomReached: () => void; + onTopReached: () => void; + onScrolledAwayFromBottom: (value: boolean) => void; +}; +const ThreadChatFlatList = forwardRef(function ThreadChatFlatList( + { onTopReached, onBottomReached, onScrolledAwayFromBottom, onScroll, ...props }, + ref, +) { + const { select } = useUIKitTheme(); + const contentOffsetY = useRef(0); + + const _onScroll = useFreshCallback>((event) => { + onScroll?.(event); + + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + + const prevOffsetY = contentOffsetY.current; + const currOffsetY = contentOffset.y; + + const bottomDetectThreshold = contentSize.height - layoutMeasurement.height - BOTTOM_DETECT_THRESHOLD; + if (bottomDetectThreshold < prevOffsetY && currOffsetY <= bottomDetectThreshold) { + onScrolledAwayFromBottom(true); + } else if (bottomDetectThreshold < currOffsetY && prevOffsetY <= bottomDetectThreshold) { + onScrolledAwayFromBottom(false); + } + + contentOffsetY.current = contentOffset.y; + }); + + return ( + + ); +}); + +export default ThreadChatFlatList; diff --git a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx index 56686bc77..777860dd6 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx @@ -108,7 +108,7 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { sbOptions.uikit.groupChannel.channel.replyType === 'thread' && sbOptions.uikit.groupChannel.channel.threadReplySelectType === 'thread' ) { - if (parentMessage && parentMessage.createdAt >= props.channel?.messageOffsetTimestamp) { + if (parentMessage.createdAt >= props.channel.messageOffsetTimestamp) { onPressReplyMessageInThread(parentMessage as SendbirdSendableMessage, childMessage.createdAt); } else { toast.show(STRINGS.TOAST.FIND_PARENT_MSG_ERROR, 'error'); diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx index 8d49378a5..828ad9347 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx @@ -48,7 +48,7 @@ const GroupChannelThreadParentMessageInfo = (props: GroupChannelThreadProps['Par const nickName = parentMessage.sender?.nickname || STRINGS.LABELS.USER_NO_NAME; const messageTimestamp = STRINGS.GROUP_CHANNEL_THREAD.PARENT_MESSAGE_TIME(parentMessage); - const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLAY_COUNT(parentMessage.threadInfo?.replyCount || 0); + const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLY_COUNT(parentMessage.threadInfo?.replyCount || 0); const createMessagePressActions = useCreateMessagePressActions({ channel: props.channel, currentUserId: props.currentUserId, diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index a0caa9060..0db9a79a4 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -35,6 +35,7 @@ import type { GroupChannelPubSubContextPayload, } from '../domain/groupChannel/types'; import { useLocalization, usePlatformService, useSendbirdChat } from '../hooks/useContext'; +import { FileType } from '../platform/types'; import pubsub from '../utils/pubsub'; const createGroupChannelFragment = (initModule?: Partial): GroupChannelFragment => { @@ -198,7 +199,7 @@ const createGroupChannelFragment = (initModule?: Partial): G const onPressSendFileMessage: GroupChannelProps['Input']['onPressSendFileMessage'] = useFreshCallback( async (params) => { const processedParams = await onBeforeSendFileMessage(params); - const fileSize = (processedParams.file as File)?.size ?? processedParams.fileSize; + const fileSize = (processedParams.file as FileType)?.size ?? processedParams.fileSize; const uploadSizeLimit = sbOptions.appInfo.uploadSizeLimit; if (fileSize && uploadSizeLimit && fileSize > uploadSizeLimit) { diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index 1b9520b60..4ecb8f956 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -141,7 +141,6 @@ const createGroupChannelThreadFragment = ( onPressMediaMessage={_onPressMediaMessage} /> ), - inverted: false, contentContainerStyle: { flexGrow: 1 }, ...flatListProps, }), diff --git a/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts b/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts index 3393c4bc7..e1bc9136d 100644 --- a/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts +++ b/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts @@ -88,15 +88,19 @@ const useMentionSuggestion = (params: { .then((members) => members.filter((member) => member.userId !== currentUser?.userId)) .then((members) => members.slice(0, mentionManager.config.suggestionLimit)); } else { - return freshChannel.members - .sort((a, b) => a.nickname?.toLowerCase().localeCompare(b.nickname.toLowerCase())) - .filter( - (member) => - member.nickname?.toLowerCase().startsWith(searchString.toLowerCase()) && - member.userId !== currentUser?.userId && - member.isActive, - ) - .slice(0, mentionManager.config.suggestionLimit); + return ( + freshChannel.members + //When using 'org.webkit:android-jsc', there is a problem with sorting lists that include words starting with uppercase and lowercase letters. + //To ensure consistent sorting regardless of the JSC, we compare the words in lowercase. + .sort((a, b) => a.nickname?.toLowerCase().localeCompare(b.nickname.toLowerCase())) + .filter( + (member) => + member.nickname?.toLowerCase().startsWith(searchString.toLowerCase()) && + member.userId !== currentUser?.userId && + member.isActive, + ) + .slice(0, mentionManager.config.suggestionLimit) + ); } }; diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index b5b7bb8a2..16446e19b 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -144,7 +144,7 @@ export interface StringSet { MESSAGE_BUBBLE_UNKNOWN_DESC: (message: SendbirdMessage) => string; PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => string; - REPLAY_COUNT: (replyCount: number, minRepliesForPlusFormat?: number) => string; + REPLY_COUNT: (replyCount: number, minRepliesForPlusFormat?: number) => string; /** GroupChannelThread > Suggested mention list */ MENTION_LIMITED: (mentionLimit: number) => string; diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index 5dc4099f2..a6a803730 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -142,7 +142,7 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => getThreadParentMessageTimeFormat(new Date(message.createdAt), locale ?? dateLocale), - REPLAY_COUNT: (replyCount: number, minRepliesForPlusFormat?: number) => + REPLY_COUNT: (replyCount: number, minRepliesForPlusFormat?: number) => getReplyCountFormat(replyCount, minRepliesForPlusFormat), MENTION_LIMITED: (mentionLimit) => `You can have up to ${mentionLimit} mentions per message.`, From f9064824fe7f5b152d60c46e9b08d9e4e70661c6 Mon Sep 17 00:00:00 2001 From: Hyungu Kang | Airen Date: Fri, 28 Jun 2024 15:36:27 +0900 Subject: [PATCH 43/46] chore: update note --- packages/uikit-react-native/src/hooks/useMentionSuggestion.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts b/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts index e1bc9136d..cc1af2717 100644 --- a/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts +++ b/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts @@ -90,8 +90,8 @@ const useMentionSuggestion = (params: { } else { return ( freshChannel.members - //When using 'org.webkit:android-jsc', there is a problem with sorting lists that include words starting with uppercase and lowercase letters. - //To ensure consistent sorting regardless of the JSC, we compare the words in lowercase. + // NOTE: When using 'org.webkit:android-jsc', there is a problem with sorting lists that include words starting with uppercase and lowercase letters. + // To ensure consistent sorting regardless of the JSC, we compare the words in lowercase. .sort((a, b) => a.nickname?.toLowerCase().localeCompare(b.nickname.toLowerCase())) .filter( (member) => From 6a07692203bee092e92160d83333b2713a99f545 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Fri, 28 Jun 2024 15:39:51 +0900 Subject: [PATCH 44/46] chore: apply review. --- .../uikit-react-native/src/localization/StringSet.type.ts | 2 +- .../src/localization/createBaseStringSet.ts | 3 +-- packages/uikit-utils/src/ui-format/common.ts | 7 ++++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index 16446e19b..48526153d 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -144,7 +144,7 @@ export interface StringSet { MESSAGE_BUBBLE_UNKNOWN_DESC: (message: SendbirdMessage) => string; PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => string; - REPLY_COUNT: (replyCount: number, minRepliesForPlusFormat?: number) => string; + REPLY_COUNT: (replyCount: number, maxReplyCount?: number) => string; /** GroupChannelThread > Suggested mention list */ MENTION_LIMITED: (mentionLimit: number) => string; diff --git a/packages/uikit-react-native/src/localization/createBaseStringSet.ts b/packages/uikit-react-native/src/localization/createBaseStringSet.ts index a6a803730..fec27daa1 100644 --- a/packages/uikit-react-native/src/localization/createBaseStringSet.ts +++ b/packages/uikit-react-native/src/localization/createBaseStringSet.ts @@ -142,8 +142,7 @@ export const createBaseStringSet = ({ dateLocale, overrides }: StringSetCreateOp PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => getThreadParentMessageTimeFormat(new Date(message.createdAt), locale ?? dateLocale), - REPLY_COUNT: (replyCount: number, minRepliesForPlusFormat?: number) => - getReplyCountFormat(replyCount, minRepliesForPlusFormat), + REPLY_COUNT: (replyCount: number, maxReplyCount?: number) => getReplyCountFormat(replyCount, maxReplyCount), MENTION_LIMITED: (mentionLimit) => `You can have up to ${mentionLimit} mentions per message.`, ...overrides?.GROUP_CHANNEL_THREAD, diff --git a/packages/uikit-utils/src/ui-format/common.ts b/packages/uikit-utils/src/ui-format/common.ts index be0de1319..e0c515de1 100644 --- a/packages/uikit-utils/src/ui-format/common.ts +++ b/packages/uikit-utils/src/ui-format/common.ts @@ -157,10 +157,11 @@ export const millsToMSS = (mills: number) => { * Message reply count format * If reply count is 1: 1 'reply' * If the reply count is greater than 1 : '{count} replies' + * If the reply count is greater than {maxReplyCount} : '{maxReplyCount}+ replies' * */ -export const getReplyCountFormat = (replyCount: number, minRepliesForPlusFormat?: number) => { - if (minRepliesForPlusFormat && replyCount > minRepliesForPlusFormat) { - return `${minRepliesForPlusFormat}+ replies`; +export const getReplyCountFormat = (replyCount: number, maxReplyCount?: number) => { + if (maxReplyCount && replyCount > maxReplyCount) { + return `${maxReplyCount}+ replies`; } else if (replyCount === 1) { return `${replyCount} reply`; } else if (replyCount > 1) { From 484199d59e0c05ce72f629cd8ec29deff1ed97d5 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Wed, 3 Jul 2024 12:19:17 +0900 Subject: [PATCH 45/46] chore: apply review when clicking the subtitle --- .../component/GroupChannelMessageList.tsx | 15 ++++--- .../component/GroupChannelThreadHeader.tsx | 41 ++++++++----------- .../src/domain/groupChannelThread/types.ts | 6 ++- .../fragments/createGroupChannelFragment.tsx | 4 ++ .../createGroupChannelThreadFragment.tsx | 14 +++++-- .../groupChannel/GroupChannelThreadScreen.tsx | 9 +++- 6 files changed, 50 insertions(+), 39 deletions(-) diff --git a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx index 777860dd6..cde539f96 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx @@ -1,9 +1,9 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useMemo } from 'react'; import { useChannelHandler } from '@sendbird/uikit-chat-hooks'; import { useToast } from '@sendbird/uikit-react-native-foundation'; import { SendbirdMessage, SendbirdSendableMessage } from '@sendbird/uikit-utils'; -import { isDifferentChannel, useFreshCallback, useIsFirstMount, useUniqHandlerId } from '@sendbird/uikit-utils'; +import { isDifferentChannel, useFreshCallback, useUniqHandlerId } from '@sendbird/uikit-utils'; import ChannelMessageList from '../../../components/ChannelMessageList'; import { MESSAGE_FOCUS_ANIMATION_DELAY, MESSAGE_SEARCH_SAFE_SCROLL_DELAY } from '../../../constants'; @@ -22,7 +22,9 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { ); const id = useUniqHandlerId('GroupChannelMessageList'); - const isFirstMount = useIsFirstMount(); + const isChangedSearchItem = useMemo(() => { + return !!props.searchItem; + }, [props.searchItem]); const scrollToMessageWithCreatedAt = useFreshCallback( (createdAt: number, focusAnimated: boolean, timeout: number): boolean => { @@ -93,13 +95,10 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { }, [props.scrolledAwayFromBottom]); useEffect(() => { - // Only trigger once when message list mount with initial props.searchItem - // - Search screen + searchItem > mount message list - // - Reset message list + searchItem > re-mount message list - if (isFirstMount && props.searchItem) { + if (isChangedSearchItem && props.searchItem) { scrollToMessageWithCreatedAt(props.searchItem.startingPoint, false, MESSAGE_SEARCH_SAFE_SCROLL_DELAY); } - }, [isFirstMount]); + }, [isChangedSearchItem]); const onPressParentMessage = useFreshCallback( (parentMessage: SendbirdMessage, childMessage: SendbirdSendableMessage) => { diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx index 93b4eea4f..cd78ef47a 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx @@ -1,20 +1,13 @@ import React, { useContext } from 'react'; import { View } from 'react-native'; -import { - Icon, - PressBox, - Text, - createStyleSheet, - useHeaderStyle, - useUIKitTheme, -} from '@sendbird/uikit-react-native-foundation'; +import { Icon, Text, createStyleSheet, useHeaderStyle, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; import { useLocalization, useSendbirdChat } from '../../../hooks/useContext'; import { GroupChannelThreadContexts } from '../module/moduleContext'; import type { GroupChannelThreadProps } from '../types'; -const GroupChannelThreadHeader = ({ onPressHeader }: GroupChannelThreadProps['Header']) => { +const GroupChannelThreadHeader = ({ onPressLeft, onPressSubtitle }: GroupChannelThreadProps['Header']) => { const { headerTitle, channel } = useContext(GroupChannelThreadContexts.Fragment); const { HeaderComponent } = useHeaderStyle(); const { STRINGS } = useLocalization(); @@ -26,6 +19,7 @@ const GroupChannelThreadHeader = ({ onPressHeader }: GroupChannelThreadProps['He return ( - - - - {headerTitle} - - {renderSubtitle()} - + + + + {headerTitle} + + {renderSubtitle()} - } - left={} - /> - + + } + left={} + onPressLeft={onPressLeft} + /> ); }; diff --git a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts index a96733f7c..61f247d4e 100644 --- a/packages/uikit-react-native/src/domain/groupChannelThread/types.ts +++ b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts @@ -27,7 +27,8 @@ export interface GroupChannelThreadProps { startingPoint?: number; onParentMessageDeleted: () => void; onChannelDeleted: () => void; - onPressHeader: GroupChannelThreadProps['Header']['onPressHeader']; + onPressHeaderLeft: GroupChannelThreadProps['Header']['onPressLeft']; + onPressHeaderSubtitle?: GroupChannelThreadProps['Header']['onPressSubtitle']; onPressMediaMessage?: GroupChannelThreadProps['MessageList']['onPressMediaMessage']; onBeforeSendUserMessage?: OnBeforeHandler; @@ -44,7 +45,8 @@ export interface GroupChannelThreadProps { sortComparator?: UseGroupChannelMessagesOptions['sortComparator']; }; Header: { - onPressHeader: () => void; + onPressLeft: () => void; + onPressSubtitle: () => void; }; ParentMessageInfo: { channel: SendbirdGroupChannel; diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index 0db9a79a4..4693d7890 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -84,6 +84,10 @@ const createGroupChannelFragment = (initModule?: Partial): G } }); + useEffect(() => { + setInternalSearchItem(searchItem); + }, [searchItem]); + const { loading, messages, diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx index 4ecb8f956..963c05a16 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelThreadFragment.tsx @@ -38,7 +38,8 @@ const createGroupChannelThreadFragment = ( return ({ renderMessage, enableMessageGrouping = true, - onPressHeader = NOOP, + onPressHeaderLeft = NOOP, + onPressHeaderSubtitle = NOOP, onPressMediaMessage = NOOP, onParentMessageDeleted = NOOP, onChannelDeleted = NOOP, @@ -105,10 +106,15 @@ const createGroupChannelThreadFragment = ( const onBlurFragment = () => { return Promise.allSettled([playerService.reset(), recorderService.reset()]); }; - const _onPressHeader = useFreshCallback(async () => { + const _onPressHeaderLeft = useFreshCallback(async () => { await onBlurFragment(); voiceMessageStatusManager.publishAll(); - onPressHeader(); + onPressHeaderLeft(); + }); + const _onPressHeaderSubtitle = useFreshCallback(async () => { + await onBlurFragment(); + voiceMessageStatusManager.publishAll(); + onPressHeaderSubtitle(); }); const _onPressMediaMessage: NonNullable = useFreshCallback(async (message, deleteMessage, uri) => { @@ -219,7 +225,7 @@ const createGroupChannelThreadFragment = ( keyboardAvoidOffset={keyboardAvoidOffset} threadedMessages={messages} > - + }> { // Should leave channel, navigate to channel list navigation.navigate(Routes.GroupChannelList); }} - onPressHeader={() => { + onPressHeaderLeft={() => { // Navigate back navigation.goBack(); }} + onPressHeaderSubtitle={() => { + // Navigate to parent message + navigation.navigate(Routes.GroupChannel, { + channelUrl: channel.url, + searchItem: { startingPoint: parentMessage.createdAt }, + }); + }} /> ); }; From a7d75a790bd4f860d9702afbd715f97f435e5da3 Mon Sep 17 00:00:00 2001 From: OnestarLee Date: Wed, 3 Jul 2024 13:21:42 +0900 Subject: [PATCH 46/46] chore: apply review when clicking the subtitle --- .../domain/groupChannel/component/GroupChannelMessageList.tsx | 2 +- .../src/fragments/createGroupChannelFragment.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx index cde539f96..996e956fd 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx @@ -24,7 +24,7 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { const id = useUniqHandlerId('GroupChannelMessageList'); const isChangedSearchItem = useMemo(() => { return !!props.searchItem; - }, [props.searchItem]); + }, [props.searchItem?.startingPoint]); const scrollToMessageWithCreatedAt = useFreshCallback( (createdAt: number, focusAnimated: boolean, timeout: number): boolean => { diff --git a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx index 4693d7890..853a49eac 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -86,7 +86,7 @@ const createGroupChannelFragment = (initModule?: Partial): G useEffect(() => { setInternalSearchItem(searchItem); - }, [searchItem]); + }, [searchItem?.startingPoint]); const { loading,