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"