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-foundation/src/assets/icon/icon-thread.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-thread.png new file mode 100644 index 000000000..09e62c802 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-thread.png differ 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 000000000..52cdd4ac2 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-thread@2x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/icon-thread@3x.png b/packages/uikit-react-native-foundation/src/assets/icon/icon-thread@3x.png new file mode 100644 index 000000000..041dda010 Binary files /dev/null and b/packages/uikit-react-native-foundation/src/assets/icon/icon-thread@3x.png differ diff --git a/packages/uikit-react-native-foundation/src/assets/icon/index.ts b/packages/uikit-react-native-foundation/src/assets/icon/index.ts index 7943fea40..98997ed7f 100644 --- a/packages/uikit-react-native-foundation/src/assets/icon/index.ts +++ b/packages/uikit-react-native-foundation/src/assets/icon/index.ts @@ -64,6 +64,7 @@ const IconAssets = { 'streaming': require('./icon-streaming.png'), 'supergroup': require('./icon-supergroup.png'), 'theme': require('./icon-theme.png'), + 'thread': require('./icon-thread.png'), 'thumbnail-none': require('./icon-thumbnail-none.png'), 'unarchive': require('./icon-unarchive.png'), 'user': require('./icon-user.png'), 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..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 @@ -25,6 +25,9 @@ type Props = GroupChannelMessageProps< { durationMetaArrayKey?: string; onUnmount: () => void; + initialCurrentTime?: number; + onSubscribeStatus?: (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => void; + onUnsubscribeStatus?: (channelUrl: string, messageId: number, subscriber: (currentTime: number) => void) => void; } >; const VoiceFileMessage = (props: Props) => { @@ -35,6 +38,9 @@ const VoiceFileMessage = (props: Props) => { message, durationMetaArrayKey = 'KEY_VOICE_MESSAGE_DURATION', onUnmount, + initialCurrentTime, + onSubscribeStatus, + onUnsubscribeStatus, } = props; const { colors } = useUIKitTheme(); @@ -45,7 +51,7 @@ const VoiceFileMessage = (props: Props) => { const initialDuration = value ? parseInt(value, 10) : 0; return { status: 'paused', - currentTime: 0, + currentTime: initialCurrentTime || 0, duration: initialDuration, }; }); @@ -56,6 +62,16 @@ const VoiceFileMessage = (props: Props) => { }; }, []); + useEffect(() => { + 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]; const remainingTime = state.duration - state.currentTime; 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 e9047bfb4..fad2d4296 100644 --- a/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx +++ b/packages/uikit-react-native-foundation/src/ui/GroupChannelMessage/MessageContainer.tsx @@ -23,6 +23,7 @@ const MessageContainer = (props: Props) => { MessageContainer.Incoming = function MessageContainerIncoming({ children, + replyInfo, groupedWithNext, groupedWithPrev, message, @@ -34,43 +35,49 @@ 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 && !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 +103,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, + messageForThread, }, ref, ) { @@ -71,11 +72,22 @@ const SendInput = forwardRef(function SendInput( const messageReplyParams = useIIFE(() => { const { groupChannel } = sbOptions.uikit; - if (!channel.isGroupChannel() || groupChannel.channel.replyType === 'none' || !messageToReply) return {}; - return { - parentMessageId: messageToReply.messageId, - isReplyToChannel: true, - }; + + 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 {}; }); const messageMentionParams = useIIFE(() => { @@ -152,6 +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) { + 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/components/ChannelInput/index.tsx b/packages/uikit-react-native/src/components/ChannelInput/index.tsx index a044efb63..4aeb848cf 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; + 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 c24e6219c..72fc94fa4 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; 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/GroupChannelMessageRenderer/GroupChannelMessageParentMessage.tsx b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageParentMessage.tsx index e89b5ca82..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?: (message: SendbirdMessage) => 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..7a52c638d --- /dev/null +++ b/packages/uikit-react-native/src/components/GroupChannelMessageRenderer/GroupChannelMessageReplyInfo.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +import { User } from '@sendbird/chat'; +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'; + +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.REPLY_COUNT(message.threadInfo.replyCount || 0, 99); + const onPressReply = () => { + onPress?.(message as SendbirdUserMessage | SendbirdFileMessage); + }; + + return ( + + {createRepliedUserAvatars(message.threadInfo.mostRepliedUsers)} + + {replyCountText} + + + ); +}; + +const styles = createStyleSheet({ + replyContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + avatarContainer: { + marginRight: 4, + }, + 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..ad70dc93a 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,14 +42,16 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' onLongPress, onPressParentMessage, onShowUserProfile, + onReplyInThreadMessage, enableMessageGrouping, focused, prevMessage, nextMessage, + hideParentMessage, }) => { 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( @@ -56,8 +59,12 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' message, prevMessage, nextMessage, + sbOptions.uikit.groupChannel.channel.replyType === 'thread', + shouldRenderParentMessage(message, hideParentMessage), ); + const variant = isMyMessage(message, currentUser?.userId) ? 'outgoing' : 'incoming'; + const reactionChildren = useIIFE(() => { const configs = sbOptions.uikitWithAppInfo.groupChannel.channel; if ( @@ -70,6 +77,12 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' return null; }); + const replyInfo = 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 +93,6 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' await playerService.reset(); }; - const variant = isMyMessage(message, currentUser?.userId) ? 'outgoing' : 'incoming'; - const messageProps: Omit, 'message'> = { channel, variant, @@ -111,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 })); } @@ -146,10 +158,11 @@ const GroupChannelMessageRenderer: GroupChannelProps['Fragment']['renderMessage' groupedWithPrev: groupWithPrev, groupedWithNext: groupWithNext, children: reactionChildren, + replyInfo: replyInfo, sendingStatus: isMyMessage(message, currentUser?.userId) ? ( ) : null, - parentMessage: shouldRenderParentMessage(message) ? ( + parentMessage: shouldRenderParentMessage(message, hideParentMessage) ? ( { if (isVoiceMessage(message) && playerService.uri === message.url) { resetPlayer(); @@ -284,7 +300,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-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx b/packages/uikit-react-native/src/components/ReactionAddons/MessageReactionAddon.tsx index c8469d87b..58e729b7b 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'; @@ -12,6 +13,7 @@ import ReactionRoundedButton from './ReactionRoundedButton'; const NUM_COL = 4; const REACTION_MORE_KEY = 'reaction-more-button'; +export type ReactionAddonType = 'default' | 'thread_parent_message'; const getUserReacted = (reaction: SendbirdReaction, userId = UNKNOWN_USER_ID) => { return reaction.userIds.indexOf(userId) > -1; @@ -40,6 +42,7 @@ const createReactionButtons = ( onOpenReactionList: () => void, onOpenReactionUserList: (focusIndex: number) => void, currentUserId?: string, + reactionAddonType?: ReactionAddonType, ) => { const reactions = message.reactions ?? []; const buttons = reactions.map((reaction, index) => { @@ -57,7 +60,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] + } /> )} @@ -74,12 +81,30 @@ 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 { sdk, emojiManager, currentUser } = useSendbirdChat(); const { openReactionList, openReactionUserList } = useReaction(); + const forceUpdate = useForceUpdate(); - if (!message.reactions?.length) return null; + useGroupChannelHandler(sdk, { + async onReactionUpdated(_, event) { + if (event.messageId === message.messageId) { + message.applyReactionEvent(event); + forceUpdate(); + } + }, + }); + + if (reactionAddonType === 'default' && !message.reactions?.length) return null; const reactionButtons = createReactionButtons( channel, @@ -89,12 +114,16 @@ const MessageReactionAddon = ({ channel, message }: { channel: SendbirdBaseChann () => openReactionList({ channel, message }), (focusIndex) => openReactionUserList({ channel, message, focusIndex }), currentUser?.userId, + reactionAddonType, ); + const containerStyle = + reactionAddonType === 'default' ? styles.reactionContainer : styles.reactionThreadParentMessageContainer; + return ( @@ -112,6 +141,10 @@ const styles = createStyleSheet({ borderRadius: 16, borderWidth: 1, }, + reactionThreadParentMessageContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + }, marginRight: { marginRight: 4.5, }, 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/components/ThreadParentMessageRenderer/ThreadParentMessage.file.image.tsx b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.image.tsx new file mode 100644 index 000000000..d5dbc0af8 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.image.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { Box, PressBox, createStyleSheet } from '@sendbird/uikit-react-native-foundation'; +import { ImageWithPlaceholder } from '@sendbird/uikit-react-native-foundation'; +import { SendbirdFileMessage, getThumbnailUriFromFileMessage } from '@sendbird/uikit-utils'; + +import { ThreadParentMessageRendererProps } from './index'; + +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..5527a04ce --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +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 { 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 ( + + + + + + {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..e281c3d53 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.video.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +import { Box, PressBox, VideoThumbnail, createStyleSheet } from '@sendbird/uikit-react-native-foundation'; +import { SendbirdFileMessage, getThumbnailUriFromFileMessage } from '@sendbird/uikit-utils'; + +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..bc2f6b370 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.file.voice.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from 'react'; + +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'; + +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', + maxWidth: 136, + }, +}); + +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..4372f8b73 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/ThreadParentMessage.user.og.tsx @@ -0,0 +1,133 @@ +import React from 'react'; + +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; +}>; + +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({ + 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, + }, + 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..203e0d06a --- /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 { RegexText, createStyleSheet } from '@sendbird/uikit-react-native-foundation'; +import { SendbirdUserMessage, urlRegexStrict } from '@sendbird/uikit-utils'; + +import { ThreadParentMessageRendererProps } from './index'; + +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..3a1cc8df6 --- /dev/null +++ b/packages/uikit-react-native/src/components/ThreadParentMessageRenderer/index.tsx @@ -0,0 +1,194 @@ +import React, { useRef } from 'react'; + +import { RegexTextPattern, Text, useUIKitTheme } from '@sendbird/uikit-react-native-foundation'; +import { + SendbirdFileMessage, + type SendbirdUser, + SendbirdUserMessage, + getMessageType, + isMyMessage, + isVoiceMessage, +} from '@sendbird/uikit-utils'; + +import { VOICE_MESSAGE_META_ARRAY_DURATION_KEY } from '../../constants'; +import SBUUtils from '../../libs/SBUUtils'; +import { usePlatformService, useSendbirdChat } from './../../hooks/useContext'; +import ThreadParentMessageFile from './ThreadParentMessage.file'; +import ThreadParentMessageFileImage from './ThreadParentMessage.file.image'; +import ThreadParentMessageFileVideo from './ThreadParentMessage.file.video'; +import ThreadParentMessageFileVoice, { VoiceFileMessageState } from './ThreadParentMessage.file.voice'; +import ThreadParentMessageUser from './ThreadParentMessage.user'; +import ThreadParentMessageUserOg from './ThreadParentMessage.user.og'; + +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 a07ebdfa0..02ccea626 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'; @@ -64,7 +65,6 @@ export const SendbirdUIKit = Object.freeze({ }, }); -type UnimplementedFeatures = 'threadReplySelectType' | 'replyType' | 'enableReactionsSupergroup'; export type ChatOmittedInitParams = Omit< SendbirdChatParams<[GroupChannelModule, OpenChannelModule]>, (typeof chatOmitKeys)[number] @@ -102,8 +102,7 @@ export type SendbirdUIKitContainerProps = React.PropsWithChildren<{ Partial; uikitOptions?: PartialDeep<{ common: SBUConfig['common']; - groupChannel: Omit & { - replyType: Extract; + groupChannel: Omit & { /** * @deprecated Currently, this feature is turned off by default. If you wish to use this feature, contact us: {@link https://dashboard.sendbird.com/settings/contact_us?category=feedback_and_feature_requests&product=UIKit} */ @@ -180,6 +179,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) { @@ -227,6 +227,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 90d223c36..256553041 100644 --- a/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx +++ b/packages/uikit-react-native/src/contexts/SendbirdChatCtx.tsx @@ -14,7 +14,9 @@ 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'; +import pubsub, { type PubSub } from '../utils/pubsub'; export interface ChatRelatedFeaturesInUIKit { enableAutoPushTokenRegistration: boolean; @@ -27,10 +29,18 @@ interface Props extends ChatRelatedFeaturesInUIKit, React.PropsWithChildren { emojiManager: EmojiManager; mentionManager: MentionManager; + voiceMessageStatusManager: VoiceMessageStatusManager; imageCompressionConfig: ImageCompressionConfig; voiceMessageConfig: VoiceMessageConfig; } +export type GroupChannelFragmentOptionsPubSubContextPayload = { + type: 'OVERRIDE_SEARCH_ITEM_STARTING_POINT'; + data: { + startingPoint: number; + }; +}; + export type SendbirdChatContextType = { sdk: SendbirdChatSDK; currentUser?: SendbirdUser; @@ -39,6 +49,7 @@ export type SendbirdChatContextType = { // feature related instances emojiManager: EmojiManager; mentionManager: MentionManager; + voiceMessageStatusManager: VoiceMessageStatusManager; imageCompressionConfig: ImageCompressionConfig; voiceMessageConfig: VoiceMessageConfig; @@ -46,6 +57,9 @@ export type SendbirdChatContextType = { updateCurrentUserInfo: (nickname?: string, profile?: string | FileType) => Promise; markAsDeliveredWithChannel: (channel: SendbirdGroupChannel) => void; + groupChannelFragmentOptions: { + pubsub: PubSub; + }; sbOptions: { // UIKit options uikit: SBUConfig; @@ -80,6 +94,7 @@ export type SendbirdChatContextType = { broadcastChannelEnabled: boolean; superGroupChannelEnabled: boolean; reactionEnabled: boolean; + uploadSizeLimit: number | undefined; }; }; }; @@ -90,6 +105,7 @@ export const SendbirdChatProvider = ({ sdkInstance, emojiManager, mentionManager, + voiceMessageStatusManager, imageCompressionConfig, voiceMessageConfig, enableAutoPushTokenRegistration, @@ -164,11 +180,15 @@ export const SendbirdChatProvider = ({ mentionManager, imageCompressionConfig, voiceMessageConfig, + voiceMessageStatusManager, currentUser, setCurrentUser, updateCurrentUserInfo, markAsDeliveredWithChannel, + groupChannelFragmentOptions: { + pubsub: pubsub(), + }, // TODO: Options should be moved to the common area at the higher level to be passed to the context of each product. // For example, common -> chat context, common -> calls context 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..4aab5c22f 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx +++ b/packages/uikit-react-native/src/domain/groupChannel/component/GroupChannelMessageList.tsx @@ -2,11 +2,12 @@ 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 { isDifferentChannel, useFreshCallback, useIsFirstMount, useUniqHandlerId } from '@sendbird/uikit-utils'; +import { SendbirdMessage, SendbirdSendableMessage, useIsFirstMount } 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'; +import { GroupChannelFragmentOptionsPubSubContextPayload } from '../../../contexts/SendbirdChatCtx'; import { useLocalization, useSendbirdChat } from '../../../hooks/useContext'; import { GroupChannelContexts } from '../module/moduleContext'; import type { GroupChannelProps } from '../types'; @@ -14,10 +15,12 @@ import type { GroupChannelProps } from '../types'; const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { const toast = useToast(); const { STRINGS } = useLocalization(); - const { sdk } = useSendbirdChat(); + const { sdk, sbOptions, groupChannelFragmentOptions } = 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(); @@ -90,6 +93,17 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { }); }, [props.scrolledAwayFromBottom]); + useEffect(() => { + return groupChannelFragmentOptions.pubsub.subscribe((payload: GroupChannelFragmentOptionsPubSubContextPayload) => { + switch (payload.type) { + case 'OVERRIDE_SEARCH_ITEM_STARTING_POINT': { + scrollToMessageWithCreatedAt(payload.data.startingPoint, false, MESSAGE_SEARCH_SAFE_SCROLL_DELAY); + break; + } + } + }); + }, []); + useEffect(() => { // Only trigger once when message list mount with initial props.searchItem // - Search screen + searchItem > mount message list @@ -99,16 +113,31 @@ const GroupChannelMessageList = (props: GroupChannelProps['MessageList']) => { } }, [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' + ) { + if (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'); + } + }, + ); return ( { 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(); @@ -90,6 +91,14 @@ export const GroupChannelContextsProvider: GroupChannelModule['Provider'] = ({ } }; + 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) { if (messageToReply?.messageId === 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, }} > {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..45bd7cc71 100644 --- a/packages/uikit-react-native/src/domain/groupChannel/types.ts +++ b/packages/uikit-react-native/src/domain/groupChannel/types.ts @@ -11,6 +11,7 @@ import type { SendbirdFileMessageUpdateParams, SendbirdGroupChannel, SendbirdMessage, + SendbirdSendableMessage, SendbirdUser, SendbirdUserMessage, SendbirdUserMessageCreateParams, @@ -30,6 +31,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 +116,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 +174,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..cd78ef47a --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadHeader.tsx @@ -0,0 +1,63 @@ +import React, { useContext } from 'react'; +import { View } from 'react-native'; + +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 = ({ onPressLeft, onPressSubtitle }: 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={onPressLeft} + /> + ); +}; + +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..e1fdca24d --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadInput.tsx @@ -0,0 +1,38 @@ +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..6f833774d --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadMessageList.tsx @@ -0,0 +1,105 @@ +import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react'; + +import { useChannelHandler } from '@sendbird/uikit-chat-hooks'; +import { isDifferentChannel, useFreshCallback, useUniqHandlerId } from '@sendbird/uikit-utils'; + +import ChannelThreadMessageList from '../../../components/ChannelThreadMessageList'; +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 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()) { + 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) { + ignorePropReached.current = true; + const timeout = 300; + lazyScrollToIndex({ index: foundMessageIndex, animated: true, timeout: timeout }); + setTimeout(() => { + ignorePropReached.current = false; + }, timeout + 50); + } + } + }, [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..828ad9347 --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/component/GroupChannelThreadParentMessageInfo.tsx @@ -0,0 +1,326 @@ +import React, { useContext } from 'react'; +import { TouchableOpacity, View } from 'react-native'; + +import { + Avatar, + BottomSheetItem, + Divider, + Icon, + Text, + createStyleSheet, + useAlert, + useBottomSheet, + useToast, + useUIKitTheme, +} from '@sendbird/uikit-react-native-foundation'; +import { + Logger, + SendbirdFileMessage, + SendbirdMessage, + SendbirdUserMessage, + getAvailableUriFromFileMessage, + getFileExtension, + getFileType, + isMyMessage, + isVoiceMessage, + shouldRenderReaction, + toMegabyte, +} from '@sendbird/uikit-utils'; + +import ThreadParentMessageRenderer, { + ThreadParentMessageRendererProps, +} from '../../../components/ThreadParentMessageRenderer'; +import { useLocalization, usePlatformService, useSendbirdChat } from '../../../hooks/useContext'; +import SBUUtils from '../../../libs/SBUUtils'; +import { GroupChannelThreadContexts } from '../module/moduleContext'; +import type { GroupChannelThreadProps } from '../types'; +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 = STRINGS.GROUP_CHANNEL_THREAD.PARENT_MESSAGE_TIME(parentMessage); + const replyCountText = STRINGS.GROUP_CHANNEL_THREAD.REPLY_COUNT(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: { + paddingLeft: 16, + marginBottom: 16, + }, + 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..3cdd017db --- /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 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'; + +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..423fbb404 --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/module/moduleContext.tsx @@ -0,0 +1,165 @@ +import React, { createContext, 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 ( + + + + + {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.scrollToEnd({ 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..61f247d4e --- /dev/null +++ b/packages/uikit-react-native/src/domain/groupChannelThread/types.ts @@ -0,0 +1,184 @@ +import type React from 'react'; +import type { FlatList } from 'react-native'; + +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 { ChannelThreadMessageListProps } from '../../components/ChannelThreadMessageList'; +import type { CommonComponent } from '../../types'; +import type { PubSub } from '../../utils/pubsub'; + +export interface GroupChannelThreadProps { + Fragment: { + channel: SendbirdGroupChannel; + parentMessage: SendbirdUserMessage | SendbirdFileMessage; + startingPoint?: number; + onParentMessageDeleted: () => void; + onChannelDeleted: () => void; + onPressHeaderLeft: GroupChannelThreadProps['Header']['onPressLeft']; + onPressHeaderSubtitle?: GroupChannelThreadProps['Header']['onPressSubtitle']; + onPressMediaMessage?: GroupChannelThreadProps['MessageList']['onPressMediaMessage']; + + onBeforeSendUserMessage?: OnBeforeHandler; + onBeforeSendFileMessage?: OnBeforeHandler; + onBeforeUpdateUserMessage?: OnBeforeHandler; + onBeforeUpdateFileMessage?: OnBeforeHandler; + + renderMessage?: GroupChannelThreadProps['MessageList']['renderMessage']; + + enableMessageGrouping?: GroupChannelThreadProps['MessageList']['enableMessageGrouping']; + + keyboardAvoidOffset?: GroupChannelThreadProps['Provider']['keyboardAvoidOffset']; + flatListProps?: GroupChannelThreadProps['MessageList']['flatListProps']; + sortComparator?: UseGroupChannelMessagesOptions['sortComparator']; + }; + Header: { + onPressLeft: () => void; + onPressSubtitle: () => void; + }; + ParentMessageInfo: { + channel: SendbirdGroupChannel; + currentUserId?: string; + onDeleteMessage: (message: SendbirdUserMessage | SendbirdFileMessage) => Promise; + onPressMediaMessage?: (message: SendbirdFileMessage, deleteMessage: () => Promise, uri: string) => void; + }; + MessageList: Pick< + ChannelThreadMessageListProps, + | 'enableMessageGrouping' + | 'currentUserId' + | 'channel' + | 'messages' + | 'newMessages' + | 'scrolledAwayFromBottom' + | 'onScrolledAwayFromBottom' + | 'onTopReached' + | 'onBottomReached' + | 'onResendFailedMessage' + | 'onDeleteMessage' + | 'onPressMediaMessage' + | 'renderMessage' + | '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..0db9a79a4 100644 --- a/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx +++ b/packages/uikit-react-native/src/fragments/createGroupChannelFragment.tsx @@ -2,9 +2,15 @@ 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 { SendbirdFileMessage, SendbirdGroupChannel, SendbirdUserMessage } from '@sendbird/uikit-utils'; +import { + SendbirdFileMessage, + SendbirdGroupChannel, + SendbirdSendableMessage, + SendbirdUserMessage, + getReadableFileSize, +} from '@sendbird/uikit-utils'; import { NOOP, PASS, @@ -28,7 +34,8 @@ import type { GroupChannelProps, GroupChannelPubSubContextPayload, } from '../domain/groupChannel/types'; -import { usePlatformService, useSendbirdChat } from '../hooks/useContext'; +import { useLocalization, usePlatformService, useSendbirdChat } from '../hooks/useContext'; +import { FileType } from '../platform/types'; import pubsub from '../utils/pubsub'; const createGroupChannelFragment = (initModule?: Partial): GroupChannelFragment => { @@ -45,6 +52,7 @@ const createGroupChannelFragment = (initModule?: Partial): G onPressHeaderRight = NOOP, onPressMediaMessage = NOOP, onChannelDeleted = NOOP, + onPressReplyMessageInThread = NOOP, onBeforeSendUserMessage = PASS, onBeforeSendFileMessage = PASS, onBeforeUpdateUserMessage = PASS, @@ -57,7 +65,9 @@ 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(); const [internalSearchItem, setInternalSearchItem] = useState(searchItem); const navigateFromMessageSearch = useCallback(() => Boolean(searchItem), []); @@ -67,8 +77,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 { @@ -107,6 +120,7 @@ const createGroupChannelFragment = (initModule?: Partial): G return Promise.allSettled([playerService.reset(), recorderService.reset()]); }; const _onPressHeaderLeft = useFreshCallback(async () => { + voiceMessageStatusManager.clear(); await onBlurFragment(); onPressHeaderLeft(); }); @@ -120,6 +134,12 @@ const createGroupChannelFragment = (initModule?: Partial): G onPressMediaMessage(message, deleteMessage, uri); }, ); + const _onPressReplyMessageInThread = useFreshCallback( + async (message: SendbirdSendableMessage, startingPoint?: number) => { + await onBlurFragment(); + onPressReplyMessageInThread(message, startingPoint); + }, + ); useEffect(() => { return () => { @@ -179,8 +199,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 FileType)?.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: GroupChannelProps['Input']['onPressUpdateUserMessage'] = useFreshCallback( @@ -208,6 +237,7 @@ const createGroupChannelFragment = (initModule?: Partial): G keyboardAvoidOffset={keyboardAvoidOffset} messages={messages} onUpdateSearchItem={onUpdateSearchItem} + onPressReplyMessageInThread={_onPressReplyMessageInThread} > , +): GroupChannelThreadFragment => { + const GroupChannelThreadModule = createGroupChannelThreadModule(initModule); + + return ({ + renderMessage, + enableMessageGrouping = true, + onPressHeaderLeft = NOOP, + onPressHeaderSubtitle = NOOP, + onPressMediaMessage = NOOP, + onParentMessageDeleted = NOOP, + onChannelDeleted = NOOP, + onBeforeSendUserMessage = PASS, + onBeforeSendFileMessage = PASS, + onBeforeUpdateUserMessage = PASS, + onBeforeUpdateFileMessage = PASS, + channel, + parentMessage, + startingPoint, + keyboardAvoidOffset, + sortComparator = threadMessageComparator, + flatListProps, + }) => { + const { playerService, recorderService } = usePlatformService(); + const { sdk, currentUser, sbOptions, voiceMessageStatusManager, groupChannelFragmentOptions } = 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(); + voiceMessageStatusManager.publishAll(); + onPressHeaderLeft(); + }); + const _onPressHeaderSubtitle = useFreshCallback(async () => { + await onBlurFragment(); + voiceMessageStatusManager.publishAll(); + groupChannelFragmentOptions.pubsub.publish({ + type: 'OVERRIDE_SEARCH_ITEM_STARTING_POINT', + data: { startingPoint: parentMessage.createdAt }, + }); + onPressHeaderSubtitle(); + }); + 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( + () => ({ + ListHeaderComponent: ( + + ), + 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 updateIfParentMessage = (message: SendbirdFileMessage | SendbirdUserMessage) => { + if (message.messageId === parentMessage.parentMessageId) { + setParentMessage(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 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( + async (message, params) => { + const processedParams = await onBeforeUpdateUserMessage(params); + const updatedMessage = await updateUserMessage(message.messageId, processedParams); + updateIfParentMessage(updatedMessage); + }, + ); + const onPressUpdateFileMessage: GroupChannelThreadProps['Input']['onPressUpdateFileMessage'] = useFreshCallback( + async (message, params) => { + const processedParams = await onBeforeUpdateFileMessage(params); + const updatedMessage = await updateFileMessage(message.messageId, processedParams); + updateIfParentMessage(updatedMessage); + }, + ); + 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 function threadMessageComparator(a: SendbirdMessage, b: SendbirdMessage) { + return messageComparator(a, b) * -1; +} + +export default createGroupChannelThreadFragment; diff --git a/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts b/packages/uikit-react-native/src/hooks/useMentionSuggestion.ts index 307668626..cc1af2717 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?.localeCompare(b.nickname)) - .filter( - (member) => - member.nickname?.toLowerCase().startsWith(searchString.toLowerCase()) && - member.userId !== currentUser?.userId && - member.isActive, - ) - .slice(0, mentionManager.config.suggestionLimit); + return ( + freshChannel.members + // 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) => + 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/index.ts b/packages/uikit-react-native/src/index.ts index 5af081e70..51053f8ca 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 **/ @@ -102,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'; 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..da26d1540 --- /dev/null +++ b/packages/uikit-react-native/src/libs/VoiceMessageStatusManager.ts @@ -0,0 +1,56 @@ +interface VoiceMessageStatus { + currentTime: number; + subscribers?: Set<(currentTime: number) => void>; +} + +class VoiceMessageStatusManager { + private statusMap: Map = new Map(); + + private generateKey = (channelUrl: string, messageId: number): string => { + return `${channelUrl}-${messageId}`; + }; + + 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); + }; + + 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) => { + subscriber(status.currentTime); + }); + }); + }; + + getCurrentTime = (channelUrl: string, messageId: number): number => { + const key = this.generateKey(channelUrl, messageId); + return this.statusMap.get(key)?.currentTime || 0; + }; + + setCurrentTime = (channelUrl: string, messageId: number, currentTime: number): void => { + const key = this.generateKey(channelUrl, messageId); + if (!this.statusMap.has(key)) { + this.statusMap.set(key, { currentTime }); + } else { + this.statusMap.get(key)!.currentTime = currentTime; + } + }; + + clear = (): void => { + this.statusMap.forEach((status) => { + status.subscribers?.clear(); + }); + this.statusMap.clear(); + }; +} + +export default VoiceMessageStatusManager; diff --git a/packages/uikit-react-native/src/localization/StringSet.type.ts b/packages/uikit-react-native/src/localization/StringSet.type.ts index d6256daed..48526153d 100644 --- a/packages/uikit-react-native/src/localization/StringSet.type.ts +++ b/packages/uikit-react-native/src/localization/StringSet.type.ts @@ -127,6 +127,28 @@ 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; + + PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => string; + REPLY_COUNT: (replyCount: number, maxReplyCount?: number) => string; + + /** GroupChannelThread > Suggested mention list */ + MENTION_LIMITED: (mentionLimit: number) => string; + }; GROUP_CHANNEL_SETTINGS: { /** GroupChannelSettings > Header */ HEADER_TITLE: string; @@ -270,6 +292,8 @@ export interface StringSet { CHANNEL_INPUT_PLACEHOLDER_DISABLED: string; CHANNEL_INPUT_PLACEHOLDER_MUTED: string; CHANNEL_INPUT_PLACEHOLDER_REPLY: 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 **/ @@ -286,6 +310,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 +371,8 @@ export interface StringSet { UNKNOWN_ERROR: string; 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 d4a8553bc..fec27daa1 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 { PartialDeep, SendbirdMessage, getThreadParentMessageTimeFormat } from '@sendbird/uikit-utils'; import { getDateSeparatorFormat, getGroupChannelPreviewTime, @@ -12,6 +12,7 @@ import { getMessageType, getOpenChannelParticipants, getOpenChannelTitle, + getReplyCountFormat, isVoiceMessage, } from '@sendbird/uikit-utils'; @@ -127,6 +128,25 @@ 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.', + + PARENT_MESSAGE_TIME: (message: SendbirdMessage, locale?: Locale) => + getThreadParentMessageTimeFormat(new Date(message.createdAt), locale ?? dateLocale), + 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, + }, GROUP_CHANNEL_SETTINGS: { HEADER_TITLE: 'Channel information', HEADER_RIGHT: 'Edit', @@ -260,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', @@ -280,6 +302,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 +316,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_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}`, @@ -371,6 +396,10 @@ 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.", + FILE_UPLOAD_SIZE_LIMIT_EXCEEDED_ERROR: (uploadSizeLimit: string) => { + return `The maximum size per file is ${uploadSizeLimit}.`; + }, ...overrides?.TOAST, }, PROFILE_CARD: { diff --git a/packages/uikit-utils/src/sendbird/message.ts b/packages/uikit-utils/src/sendbird/message.ts index 4f1206ca7..d57bd1711 100644 --- a/packages/uikit-utils/src/sendbird/message.ts +++ b/packages/uikit-utils/src/sendbird/message.ts @@ -66,12 +66,16 @@ export function calcMessageGrouping( curr: SendbirdMessage, 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 (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; }; @@ -81,6 +85,8 @@ export function calcMessageGrouping( if (!next) return false; if (curr.isAdminMessage()) return false; if (!hasSameSender(curr, next)) 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; }; @@ -125,12 +131,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()) diff --git a/packages/uikit-utils/src/ui-format/common.ts b/packages/uikit-utils/src/ui-format/common.ts index ff34e9ace..e0c515de1 100644 --- a/packages/uikit-utils/src/ui-format/common.ts +++ b/packages/uikit-utils/src/ui-format/common.ts @@ -152,3 +152,46 @@ 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' + * If the reply count is greater than {maxReplyCount} : '{maxReplyCount}+ 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) { + return `${replyCount} replies`; + } + 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 }); +}; + +/** + * 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]}`; +}; diff --git a/sample/ios/Podfile.lock b/sample/ios/Podfile.lock deleted file mode 100644 index 293a0581d..000000000 --- a/sample/ios/Podfile.lock +++ /dev/null @@ -1,690 +0,0 @@ -PODS: - - boost (1.76.0) - - DoubleConversion (1.1.6) - - FBLazyVector (0.67.5) - - FBReactNativeSpec (0.67.5): - - RCT-Folly (= 2021.06.28.00-v2) - - RCTRequired (= 0.67.5) - - RCTTypeSafety (= 0.67.5) - - React-Core (= 0.67.5) - - React-jsi (= 0.67.5) - - ReactCommon/turbomodule/core (= 0.67.5) - - Firebase/CoreOnly (8.15.0): - - FirebaseCore (= 8.15.0) - - Firebase/Messaging (8.15.0): - - Firebase/CoreOnly - - FirebaseMessaging (~> 8.15.0) - - FirebaseCore (8.15.0): - - FirebaseCoreDiagnostics (~> 8.0) - - GoogleUtilities/Environment (~> 7.7) - - GoogleUtilities/Logger (~> 7.7) - - FirebaseCoreDiagnostics (8.15.0): - - GoogleDataTransport (~> 9.1) - - GoogleUtilities/Environment (~> 7.7) - - GoogleUtilities/Logger (~> 7.7) - - nanopb (~> 2.30908.0) - - FirebaseInstallations (8.15.0): - - FirebaseCore (~> 8.0) - - GoogleUtilities/Environment (~> 7.7) - - GoogleUtilities/UserDefaults (~> 7.7) - - PromisesObjC (< 3.0, >= 1.2) - - FirebaseMessaging (8.15.0): - - FirebaseCore (~> 8.0) - - FirebaseInstallations (~> 8.0) - - GoogleDataTransport (~> 9.1) - - GoogleUtilities/AppDelegateSwizzler (~> 7.7) - - GoogleUtilities/Environment (~> 7.7) - - GoogleUtilities/Reachability (~> 7.7) - - GoogleUtilities/UserDefaults (~> 7.7) - - nanopb (~> 2.30908.0) - - fmt (6.2.1) - - glog (0.3.5) - - GoogleDataTransport (9.4.1): - - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30911.0, >= 2.30908.0) - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/AppDelegateSwizzler (7.13.0): - - GoogleUtilities/Environment - - GoogleUtilities/Logger - - GoogleUtilities/Network - - GoogleUtilities/Privacy - - GoogleUtilities/Environment (7.13.0): - - GoogleUtilities/Privacy - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.13.0): - - GoogleUtilities/Environment - - GoogleUtilities/Privacy - - GoogleUtilities/Network (7.13.0): - - GoogleUtilities/Logger - - "GoogleUtilities/NSData+zlib" - - GoogleUtilities/Privacy - - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.13.0)": - - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (7.13.0) - - GoogleUtilities/Reachability (7.13.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (7.13.0): - - GoogleUtilities/Logger - - GoogleUtilities/Privacy - - libwebp (1.3.2): - - libwebp/demux (= 1.3.2) - - libwebp/mux (= 1.3.2) - - libwebp/sharpyuv (= 1.3.2) - - libwebp/webp (= 1.3.2) - - libwebp/demux (1.3.2): - - libwebp/webp - - libwebp/mux (1.3.2): - - libwebp/demux - - libwebp/sharpyuv (1.3.2) - - libwebp/webp (1.3.2): - - libwebp/sharpyuv - - nanopb (2.30908.0): - - nanopb/decode (= 2.30908.0) - - nanopb/encode (= 2.30908.0) - - nanopb/decode (2.30908.0) - - nanopb/encode (2.30908.0) - - Permission-Camera (3.8.0): - - RNPermissions - - Permission-Microphone (3.8.0): - - RNPermissions - - Permission-PhotoLibrary (3.8.0): - - RNPermissions - - Permission-PhotoLibraryAddOnly (3.8.0): - - RNPermissions - - PromisesObjC (2.4.0) - - RCT-Folly (2021.06.28.00-v2): - - boost - - DoubleConversion - - fmt (~> 6.2.1) - - glog - - RCT-Folly/Default (= 2021.06.28.00-v2) - - RCT-Folly/Default (2021.06.28.00-v2): - - boost - - DoubleConversion - - fmt (~> 6.2.1) - - glog - - RCTRequired (0.67.5) - - RCTTypeSafety (0.67.5): - - FBLazyVector (= 0.67.5) - - RCT-Folly (= 2021.06.28.00-v2) - - RCTRequired (= 0.67.5) - - React-Core (= 0.67.5) - - React (0.67.5): - - React-Core (= 0.67.5) - - React-Core/DevSupport (= 0.67.5) - - React-Core/RCTWebSocket (= 0.67.5) - - React-RCTActionSheet (= 0.67.5) - - React-RCTAnimation (= 0.67.5) - - React-RCTBlob (= 0.67.5) - - React-RCTImage (= 0.67.5) - - React-RCTLinking (= 0.67.5) - - React-RCTNetwork (= 0.67.5) - - React-RCTSettings (= 0.67.5) - - React-RCTText (= 0.67.5) - - React-RCTVibration (= 0.67.5) - - React-callinvoker (0.67.5) - - React-Core (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default (= 0.67.5) - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/CoreModulesHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/Default (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/DevSupport (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default (= 0.67.5) - - React-Core/RCTWebSocket (= 0.67.5) - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-jsinspector (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTActionSheetHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTAnimationHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTBlobHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTImageHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTLinkingHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTNetworkHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTSettingsHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTTextHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTVibrationHeaders (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-Core/RCTWebSocket (0.67.5): - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/Default (= 0.67.5) - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsiexecutor (= 0.67.5) - - React-perflogger (= 0.67.5) - - Yoga - - React-CoreModules (0.67.5): - - FBReactNativeSpec (= 0.67.5) - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.67.5) - - React-Core/CoreModulesHeaders (= 0.67.5) - - React-jsi (= 0.67.5) - - React-RCTImage (= 0.67.5) - - ReactCommon/turbomodule/core (= 0.67.5) - - React-cxxreact (0.67.5): - - boost (= 1.76.0) - - DoubleConversion - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-callinvoker (= 0.67.5) - - React-jsi (= 0.67.5) - - React-jsinspector (= 0.67.5) - - React-logger (= 0.67.5) - - React-perflogger (= 0.67.5) - - React-runtimeexecutor (= 0.67.5) - - React-jsi (0.67.5): - - boost (= 1.76.0) - - DoubleConversion - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-jsi/Default (= 0.67.5) - - React-jsi/Default (0.67.5): - - boost (= 1.76.0) - - DoubleConversion - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-jsiexecutor (0.67.5): - - DoubleConversion - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-perflogger (= 0.67.5) - - React-jsinspector (0.67.5) - - React-logger (0.67.5): - - glog - - react-native-cameraroll (5.4.0): - - React-Core - - react-native-create-thumbnail (1.6.4): - - React-Core - - react-native-document-picker (8.2.0): - - React-Core - - react-native-file-access (2.6.0): - - React-Core - - ZIPFoundation (= 0.9.11) - - react-native-image-picker (4.10.3): - - React-Core - - react-native-image-resizer (3.0.5): - - React-Core - - react-native-netinfo (9.3.9): - - React-Core - - react-native-safe-area-context (3.4.1): - - React-Core - - react-native-slider (4.4.2): - - React-Core - - react-native-video (5.2.1): - - React-Core - - react-native-video/Video (= 5.2.1) - - react-native-video/Video (5.2.1): - - React-Core - - React-perflogger (0.67.5) - - React-RCTActionSheet (0.67.5): - - React-Core/RCTActionSheetHeaders (= 0.67.5) - - React-RCTAnimation (0.67.5): - - FBReactNativeSpec (= 0.67.5) - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.67.5) - - React-Core/RCTAnimationHeaders (= 0.67.5) - - React-jsi (= 0.67.5) - - ReactCommon/turbomodule/core (= 0.67.5) - - React-RCTBlob (0.67.5): - - FBReactNativeSpec (= 0.67.5) - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/RCTBlobHeaders (= 0.67.5) - - React-Core/RCTWebSocket (= 0.67.5) - - React-jsi (= 0.67.5) - - React-RCTNetwork (= 0.67.5) - - ReactCommon/turbomodule/core (= 0.67.5) - - React-RCTImage (0.67.5): - - FBReactNativeSpec (= 0.67.5) - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.67.5) - - React-Core/RCTImageHeaders (= 0.67.5) - - React-jsi (= 0.67.5) - - React-RCTNetwork (= 0.67.5) - - ReactCommon/turbomodule/core (= 0.67.5) - - React-RCTLinking (0.67.5): - - FBReactNativeSpec (= 0.67.5) - - React-Core/RCTLinkingHeaders (= 0.67.5) - - React-jsi (= 0.67.5) - - ReactCommon/turbomodule/core (= 0.67.5) - - React-RCTNetwork (0.67.5): - - FBReactNativeSpec (= 0.67.5) - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.67.5) - - React-Core/RCTNetworkHeaders (= 0.67.5) - - React-jsi (= 0.67.5) - - ReactCommon/turbomodule/core (= 0.67.5) - - React-RCTSettings (0.67.5): - - FBReactNativeSpec (= 0.67.5) - - RCT-Folly (= 2021.06.28.00-v2) - - RCTTypeSafety (= 0.67.5) - - React-Core/RCTSettingsHeaders (= 0.67.5) - - React-jsi (= 0.67.5) - - ReactCommon/turbomodule/core (= 0.67.5) - - React-RCTText (0.67.5): - - React-Core/RCTTextHeaders (= 0.67.5) - - React-RCTVibration (0.67.5): - - FBReactNativeSpec (= 0.67.5) - - RCT-Folly (= 2021.06.28.00-v2) - - React-Core/RCTVibrationHeaders (= 0.67.5) - - React-jsi (= 0.67.5) - - ReactCommon/turbomodule/core (= 0.67.5) - - React-runtimeexecutor (0.67.5): - - React-jsi (= 0.67.5) - - ReactCommon/turbomodule/core (0.67.5): - - DoubleConversion - - glog - - RCT-Folly (= 2021.06.28.00-v2) - - React-callinvoker (= 0.67.5) - - React-Core (= 0.67.5) - - React-cxxreact (= 0.67.5) - - React-jsi (= 0.67.5) - - React-logger (= 0.67.5) - - React-perflogger (= 0.67.5) - - RNAudioRecorderPlayer (3.6.0): - - React-Core - - RNCAsyncStorage (1.18.1): - - React-Core - - RNCClipboard (1.11.2): - - React-Core - - RNCPushNotificationIOS (1.11.0): - - React-Core - - RNDateTimePicker (5.1.0): - - React-Core - - RNFastImage (8.6.3): - - React-Core - - SDWebImage (~> 5.11.1) - - SDWebImageWebPCoder (~> 0.8.4) - - RNFBApp (14.12.0): - - Firebase/CoreOnly (= 8.15.0) - - React-Core - - RNFBMessaging (14.12.0): - - Firebase/Messaging (= 8.15.0) - - React-Core - - RNFBApp - - RNNotifee (5.7.0): - - React-Core - - RNNotifeeCore - - RNNotifeeCore (5.7.0): - - RNNotifeeCore/NotifeeCore (= 5.7.0) - - RNNotifeeCore/NotifeeCore (5.7.0) - - RNPermissions (3.8.0): - - React-Core - - RNScreens (3.14.0): - - React-Core - - React-RCTImage - - SDWebImage (5.11.1): - - SDWebImage/Core (= 5.11.1) - - SDWebImage/Core (5.11.1) - - SDWebImageWebPCoder (0.8.5): - - libwebp (~> 1.0) - - SDWebImage/Core (~> 5.10) - - Yoga (1.14.0) - - ZIPFoundation (0.9.11) - -DEPENDENCIES: - - boost (from `../../node_modules/react-native/third-party-podspecs/boost.podspec`) - - DoubleConversion (from `../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - - FBLazyVector (from `../../node_modules/react-native/Libraries/FBLazyVector`) - - FBReactNativeSpec (from `../../node_modules/react-native/React/FBReactNativeSpec`) - - glog (from `../../node_modules/react-native/third-party-podspecs/glog.podspec`) - - Permission-Camera (from `../../node_modules/react-native-permissions/ios/Camera`) - - Permission-Microphone (from `../../node_modules/react-native-permissions/ios/Microphone`) - - Permission-PhotoLibrary (from `../../node_modules/react-native-permissions/ios/PhotoLibrary`) - - Permission-PhotoLibraryAddOnly (from `../../node_modules/react-native-permissions/ios/PhotoLibraryAddOnly`) - - RCT-Folly (from `../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - - RCTRequired (from `../../node_modules/react-native/Libraries/RCTRequired`) - - RCTTypeSafety (from `../../node_modules/react-native/Libraries/TypeSafety`) - - React (from `../../node_modules/react-native/`) - - React-callinvoker (from `../../node_modules/react-native/ReactCommon/callinvoker`) - - React-Core (from `../../node_modules/react-native/`) - - React-Core/DevSupport (from `../../node_modules/react-native/`) - - React-Core/RCTWebSocket (from `../../node_modules/react-native/`) - - React-CoreModules (from `../../node_modules/react-native/React/CoreModules`) - - React-cxxreact (from `../../node_modules/react-native/ReactCommon/cxxreact`) - - React-jsi (from `../../node_modules/react-native/ReactCommon/jsi`) - - React-jsiexecutor (from `../../node_modules/react-native/ReactCommon/jsiexecutor`) - - React-jsinspector (from `../../node_modules/react-native/ReactCommon/jsinspector`) - - React-logger (from `../../node_modules/react-native/ReactCommon/logger`) - - "react-native-cameraroll (from `../../node_modules/@react-native-camera-roll/camera-roll`)" - - react-native-create-thumbnail (from `../../node_modules/react-native-create-thumbnail`) - - react-native-document-picker (from `../../node_modules/react-native-document-picker`) - - react-native-file-access (from `../../node_modules/react-native-file-access`) - - react-native-image-picker (from `../../node_modules/react-native-image-picker`) - - "react-native-image-resizer (from `../../node_modules/@bam.tech/react-native-image-resizer`)" - - "react-native-netinfo (from `../../node_modules/@react-native-community/netinfo`)" - - react-native-safe-area-context (from `../../node_modules/react-native-safe-area-context`) - - "react-native-slider (from `../../node_modules/@react-native-community/slider`)" - - react-native-video (from `../../node_modules/react-native-video`) - - React-perflogger (from `../../node_modules/react-native/ReactCommon/reactperflogger`) - - React-RCTActionSheet (from `../../node_modules/react-native/Libraries/ActionSheetIOS`) - - React-RCTAnimation (from `../../node_modules/react-native/Libraries/NativeAnimation`) - - React-RCTBlob (from `../../node_modules/react-native/Libraries/Blob`) - - React-RCTImage (from `../../node_modules/react-native/Libraries/Image`) - - React-RCTLinking (from `../../node_modules/react-native/Libraries/LinkingIOS`) - - React-RCTNetwork (from `../../node_modules/react-native/Libraries/Network`) - - React-RCTSettings (from `../../node_modules/react-native/Libraries/Settings`) - - React-RCTText (from `../../node_modules/react-native/Libraries/Text`) - - React-RCTVibration (from `../../node_modules/react-native/Libraries/Vibration`) - - React-runtimeexecutor (from `../../node_modules/react-native/ReactCommon/runtimeexecutor`) - - ReactCommon/turbomodule/core (from `../../node_modules/react-native/ReactCommon`) - - RNAudioRecorderPlayer (from `../../node_modules/react-native-audio-recorder-player`) - - "RNCAsyncStorage (from `../../node_modules/@react-native-async-storage/async-storage`)" - - "RNCClipboard (from `../../node_modules/@react-native-clipboard/clipboard`)" - - "RNCPushNotificationIOS (from `../../node_modules/@react-native-community/push-notification-ios`)" - - "RNDateTimePicker (from `../../node_modules/@react-native-community/datetimepicker`)" - - RNFastImage (from `../../node_modules/react-native-fast-image`) - - "RNFBApp (from `../../node_modules/@react-native-firebase/app`)" - - "RNFBMessaging (from `../../node_modules/@react-native-firebase/messaging`)" - - "RNNotifee (from `../../node_modules/@notifee/react-native`)" - - "RNNotifeeCore (from `../../node_modules/@notifee/react-native/RNNotifeeCore.podspec`)" - - RNPermissions (from `../../node_modules/react-native-permissions`) - - RNScreens (from `../../node_modules/react-native-screens`) - - Yoga (from `../../node_modules/react-native/ReactCommon/yoga`) - -SPEC REPOS: - trunk: - - Firebase - - FirebaseCore - - FirebaseCoreDiagnostics - - FirebaseInstallations - - FirebaseMessaging - - fmt - - GoogleDataTransport - - GoogleUtilities - - libwebp - - nanopb - - PromisesObjC - - SDWebImage - - SDWebImageWebPCoder - - ZIPFoundation - -EXTERNAL SOURCES: - boost: - :podspec: "../../node_modules/react-native/third-party-podspecs/boost.podspec" - DoubleConversion: - :podspec: "../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" - FBLazyVector: - :path: "../../node_modules/react-native/Libraries/FBLazyVector" - FBReactNativeSpec: - :path: "../../node_modules/react-native/React/FBReactNativeSpec" - glog: - :podspec: "../../node_modules/react-native/third-party-podspecs/glog.podspec" - Permission-Camera: - :path: "../../node_modules/react-native-permissions/ios/Camera" - Permission-Microphone: - :path: "../../node_modules/react-native-permissions/ios/Microphone" - Permission-PhotoLibrary: - :path: "../../node_modules/react-native-permissions/ios/PhotoLibrary" - Permission-PhotoLibraryAddOnly: - :path: "../../node_modules/react-native-permissions/ios/PhotoLibraryAddOnly" - RCT-Folly: - :podspec: "../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" - RCTRequired: - :path: "../../node_modules/react-native/Libraries/RCTRequired" - RCTTypeSafety: - :path: "../../node_modules/react-native/Libraries/TypeSafety" - React: - :path: "../../node_modules/react-native/" - React-callinvoker: - :path: "../../node_modules/react-native/ReactCommon/callinvoker" - React-Core: - :path: "../../node_modules/react-native/" - React-CoreModules: - :path: "../../node_modules/react-native/React/CoreModules" - React-cxxreact: - :path: "../../node_modules/react-native/ReactCommon/cxxreact" - React-jsi: - :path: "../../node_modules/react-native/ReactCommon/jsi" - React-jsiexecutor: - :path: "../../node_modules/react-native/ReactCommon/jsiexecutor" - React-jsinspector: - :path: "../../node_modules/react-native/ReactCommon/jsinspector" - React-logger: - :path: "../../node_modules/react-native/ReactCommon/logger" - react-native-cameraroll: - :path: "../../node_modules/@react-native-camera-roll/camera-roll" - react-native-create-thumbnail: - :path: "../../node_modules/react-native-create-thumbnail" - react-native-document-picker: - :path: "../../node_modules/react-native-document-picker" - react-native-file-access: - :path: "../../node_modules/react-native-file-access" - react-native-image-picker: - :path: "../../node_modules/react-native-image-picker" - react-native-image-resizer: - :path: "../../node_modules/@bam.tech/react-native-image-resizer" - react-native-netinfo: - :path: "../../node_modules/@react-native-community/netinfo" - react-native-safe-area-context: - :path: "../../node_modules/react-native-safe-area-context" - react-native-slider: - :path: "../../node_modules/@react-native-community/slider" - react-native-video: - :path: "../../node_modules/react-native-video" - React-perflogger: - :path: "../../node_modules/react-native/ReactCommon/reactperflogger" - React-RCTActionSheet: - :path: "../../node_modules/react-native/Libraries/ActionSheetIOS" - React-RCTAnimation: - :path: "../../node_modules/react-native/Libraries/NativeAnimation" - React-RCTBlob: - :path: "../../node_modules/react-native/Libraries/Blob" - React-RCTImage: - :path: "../../node_modules/react-native/Libraries/Image" - React-RCTLinking: - :path: "../../node_modules/react-native/Libraries/LinkingIOS" - React-RCTNetwork: - :path: "../../node_modules/react-native/Libraries/Network" - React-RCTSettings: - :path: "../../node_modules/react-native/Libraries/Settings" - React-RCTText: - :path: "../../node_modules/react-native/Libraries/Text" - React-RCTVibration: - :path: "../../node_modules/react-native/Libraries/Vibration" - React-runtimeexecutor: - :path: "../../node_modules/react-native/ReactCommon/runtimeexecutor" - ReactCommon: - :path: "../../node_modules/react-native/ReactCommon" - RNAudioRecorderPlayer: - :path: "../../node_modules/react-native-audio-recorder-player" - RNCAsyncStorage: - :path: "../../node_modules/@react-native-async-storage/async-storage" - RNCClipboard: - :path: "../../node_modules/@react-native-clipboard/clipboard" - RNCPushNotificationIOS: - :path: "../../node_modules/@react-native-community/push-notification-ios" - RNDateTimePicker: - :path: "../../node_modules/@react-native-community/datetimepicker" - RNFastImage: - :path: "../../node_modules/react-native-fast-image" - RNFBApp: - :path: "../../node_modules/@react-native-firebase/app" - RNFBMessaging: - :path: "../../node_modules/@react-native-firebase/messaging" - RNNotifee: - :path: "../../node_modules/@notifee/react-native" - RNNotifeeCore: - :path: "../../node_modules/@notifee/react-native/RNNotifeeCore.podspec" - RNPermissions: - :path: "../../node_modules/react-native-permissions" - RNScreens: - :path: "../../node_modules/react-native-screens" - Yoga: - :path: "../../node_modules/react-native/ReactCommon/yoga" - -SPEC CHECKSUMS: - boost: a7c83b31436843459a1961bfd74b96033dc77234 - DoubleConversion: 831926d9b8bf8166fd87886c4abab286c2422662 - FBLazyVector: d2db9d00883282819d03bbd401b2ad4360d47580 - FBReactNativeSpec: 94da4d84ba3b1acf459103320882daa481a2b62d - Firebase: 5f8193dff4b5b7c5d5ef72ae54bb76c08e2b841d - FirebaseCore: 5743c5785c074a794d35f2fff7ecc254a91e08b1 - FirebaseCoreDiagnostics: 92e07a649aeb66352b319d43bdd2ee3942af84cb - FirebaseInstallations: 40bd9054049b2eae9a2c38ef1c3dd213df3605cd - FirebaseMessaging: 5e5118a2383b3531e730d974680954c679ca0a13 - fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 85ecdd10ee8d8ec362ef519a6a45ff9aa27b2e85 - GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a - GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 - libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 - nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 - Permission-Camera: e6d142d7d8b714afe0a83e5e6ae17eb949f1e3e9 - Permission-Microphone: 644b1de8bcc2afcaf934e09a22bee507a95796a7 - Permission-PhotoLibrary: 31787bbe77d0d3ae6a5267b8435e4a2e9ef78f1d - Permission-PhotoLibraryAddOnly: 16c92ad62b802514f3f788e00b298080be996337 - PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - RCT-Folly: 803a9cfd78114b2ec0f140cfa6fa2a6bafb2d685 - RCTRequired: 412e994c1e570cf35378a32c18fd46e50634938b - RCTTypeSafety: ef27340c728e6d673af345ed69e479a010c8a2d8 - React: 36b9f5116572e5b80f01e562bb1f1451e8848e47 - React-callinvoker: 91e62870884d3db3a0db33bbb1ba4e53fa5210ca - React-Core: 765ccc3861be1b93c7d5ca37f6b06e2efd6e7999 - React-CoreModules: da2ddff50a92576b6d58fbfc80a62ba3f81d8a4e - React-cxxreact: b54cffd4feb550c3213cd38db4a2a4bdd896f715 - React-jsi: 103674913e4159a07df20ef214c6b563e90e7b2e - React-jsiexecutor: e9895ccae253323ca70f693945fecbba091f0abd - React-jsinspector: ec4fe4f65ccf9d67c8429dda955d3412db8a25ef - React-logger: 85f4ef06b9723714b2dfa5b0e5502b673b271b58 - react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151 - react-native-create-thumbnail: e022bcdcba8a0b4529a50d3fa1a832ec921be39d - react-native-document-picker: 495c444c0c773c6e83a5d91165890ecb1c0a399a - react-native-file-access: a4398950e02999b5ab97f3055feb7ee28e6615a3 - react-native-image-picker: 60f4246eb5bb7187fc15638a8c1f13abd3820695 - react-native-image-resizer: 00ceb0e05586c7aadf061eea676957a6c2ec60fa - react-native-netinfo: 22c082970cbd99071a4e5aa7a612ac20d66b08f0 - react-native-safe-area-context: 9e40fb181dac02619414ba1294d6c2a807056ab9 - react-native-slider: 33b8d190b59d4f67a541061bb91775d53d617d9d - react-native-video: c26780b224543c62d5e1b2a7244a5cd1b50e8253 - React-perflogger: d32ee13196f4ae2e4098fb7f8e7ed4f864c6fb0f - React-RCTActionSheet: 81779c09e34a6a3d6b15783407ba9016a774f535 - React-RCTAnimation: b778eaaa42a884abcc5719035a7a0b2f54672658 - React-RCTBlob: 8edfc04c117decb0e7d4e6ab32bec91707e63ecb - React-RCTImage: 2022097f1291bfebd0003e477318c72b07853578 - React-RCTLinking: bd8d889c65695181342541ce4420e9419845084c - React-RCTNetwork: eae64b805d967bf3ece2cec3ad09218eeb32cb74 - React-RCTSettings: 0645af8aec5f40726e98d434a07ff58e75a81aa9 - React-RCTText: e55de507cda263ff58404c3e7d75bf76c2b80813 - React-RCTVibration: c3b8a3245267a3849b0c7cb91a37606bf5f3aa65 - React-runtimeexecutor: 434efc9e5b6d0f14f49867f130b39376c971c1aa - ReactCommon: a30c2448e5a88bae6fcb0e3da124c14ae493dac1 - RNAudioRecorderPlayer: 4690a7cd9e4fd8e58d9671936a7bc3b686e59051 - RNCAsyncStorage: b90b71f45b8b97be43bc4284e71a6af48ac9f547 - RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc - RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8 - RNDateTimePicker: 1dd15d7ed1ab7d999056bc77879a42920d139c12 - RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 - RNFBApp: e4439717c23252458da2b41b81b4b475c86f90c4 - RNFBMessaging: 40dac204b4197a2661dec5be964780c6ec39bf65 - RNNotifee: 40cc97ddc290e102894ea5381e90905f50598cfe - RNNotifeeCore: 2d6233c6e9cf7755b8f460061c7811113043d2d3 - RNPermissions: 215c54462104b3925b412b0fb3c9c497b21c358b - RNScreens: 4830eb40e0793b38849965cd27f4f3a7d7bc65c1 - SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d - SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d - Yoga: 099a946cbf84c9b32ffdc4278d72db26710ecf92 - ZIPFoundation: b1f0de4eed33e74a676f76e12559ab6b75990197 - -PODFILE CHECKSUM: c506a1796d7f1a6bdde6b56fb1bc43e8ac339917 - -COCOAPODS: 1.15.2 diff --git a/sample/src/App.tsx b/sample/src/App.tsx index fbc348b71..84d578e0b 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,8 @@ const App = () => { groupChannel: { enableMention: true, typingIndicatorTypes: new Set([TypingIndicatorType.Text, TypingIndicatorType.Bubble]), + replyType: 'thread', + threadReplySelectType: 'thread', }, groupChannelList: { enableTypingIndicator: true, @@ -147,6 +150,7 @@ const Navigations = () => { name={Routes.GroupChannelRegisterOperator} component={GroupChannelRegisterOperatorScreen} /> + diff --git a/sample/src/libs/navigation.ts b/sample/src/libs/navigation.ts index f3fd00c2b..c110b1d5a 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..4d0720610 100644 --- a/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx +++ b/sample/src/screens/uikit/groupChannel/GroupChannelScreen.tsx @@ -97,6 +97,16 @@ 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..d3ed0343b --- /dev/null +++ b/sample/src/screens/uikit/groupChannel/GroupChannelThreadScreen.tsx @@ -0,0 +1,56 @@ +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'; + +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(); + }} + onPressHeaderSubtitle={() => { + // Navigate to parent message + navigation.navigate(Routes.GroupChannel, { + channelUrl: channel.url, + }); + }} + /> + ); +}; + +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'; 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"