import React, { Ref } from 'react'; import { FlatList, FlatListProps, ListRenderItem, View } from 'react-native'; import { BottomSheetItem, ChannelFrozenBanner, createStyleSheet, useAlert, useBottomSheet, useToast, useUIKitTheme, } from '@sendbird/uikit-react-native-foundation'; import { Logger, SendbirdFileMessage, SendbirdGroupChannel, SendbirdMessage, SendbirdOpenChannel, SendbirdUserMessage, getAvailableUriFromFileMessage, getFileExtension, getFileType, isMyMessage, isVoiceMessage, messageKeyExtractor, shouldRenderReaction, toMegabyte, useFreshCallback, useSafeAreaPadding, } from '@sendbird/uikit-utils'; import type { UserProfileContextType } from '../../contexts/UserProfileCtx'; import { useLocalization, usePlatformService, useSBUHandlers, useSendbirdChat, useUserProfile, } from '../../hooks/useContext'; import SBUUtils from '../../libs/SBUUtils'; import { ReactionAddons } from '../ReactionAddons'; import ThreadChatFlatList from '../ThreadChatFlatList'; type PressActions = { onPress?: () => void; onLongPress?: () => void; bottomSheetItem?: BottomSheetItem }; type HandleableMessage = SendbirdUserMessage | SendbirdFileMessage; type CreateMessagePressActions = (params: { message: SendbirdMessage }) => PressActions; export type ChannelThreadMessageListProps = { enableMessageGrouping: boolean; currentUserId?: string; channel: T; messages: SendbirdMessage[]; newMessages: SendbirdMessage[]; searchItem?: { startingPoint: number }; scrolledAwayFromBottom: boolean; onScrolledAwayFromBottom: (value: boolean) => void; onTopReached: () => void; onBottomReached: () => void; hasNext: () => boolean; onPressNewMessagesButton: (animated?: boolean) => void; onPressScrollToBottomButton: (animated?: boolean) => void; onEditMessage: (message: HandleableMessage) => void; onDeleteMessage: (message: HandleableMessage) => Promise; onResendFailedMessage: (failedMessage: HandleableMessage) => Promise; onPressMediaMessage?: (message: SendbirdFileMessage, deleteMessage: () => Promise, uri: string) => void; renderMessage: (props: { focused: boolean; message: SendbirdMessage; prevMessage?: SendbirdMessage; nextMessage?: SendbirdMessage; onPress?: () => void; onLongPress?: () => void; onShowUserProfile?: UserProfileContextType['show']; channel: T; currentUserId?: ChannelThreadMessageListProps['currentUserId']; enableMessageGrouping: ChannelThreadMessageListProps['enableMessageGrouping']; bottomSheetItem?: BottomSheetItem; isFirstItem: boolean; }) => React.ReactElement | null; renderNewMessagesButton: | null | ((props: { visible: boolean; onPress: () => void; newMessages: SendbirdMessage[] }) => React.ReactElement | null); renderScrollToBottomButton: null | ((props: { visible: boolean; onPress: () => void }) => React.ReactElement | null); flatListProps?: Omit, 'data' | 'renderItem'>; } & { ref?: Ref> | undefined; }; const ChannelThreadMessageList = ( { searchItem, hasNext, channel, onEditMessage, onDeleteMessage, onResendFailedMessage, onPressMediaMessage, currentUserId, renderNewMessagesButton, renderScrollToBottomButton, renderMessage, messages, newMessages, enableMessageGrouping, onScrolledAwayFromBottom, scrolledAwayFromBottom, onBottomReached, onTopReached, flatListProps, onPressNewMessagesButton, onPressScrollToBottomButton, }: ChannelThreadMessageListProps, ref: React.ForwardedRef>, ) => { const { STRINGS } = useLocalization(); const { colors } = useUIKitTheme(); const { show } = useUserProfile(); const safeAreaLayout = useSafeAreaPadding(['left', 'right']); const createMessagePressActions = useCreateMessagePressActions({ channel, currentUserId, onEditMessage, onDeleteMessage, onResendFailedMessage, onPressMediaMessage, }); 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 handlers = useSBUHandlers(); 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)); handlers.onOpenFileURL?.(message.url); } else { const openFile = handlers.onOpenFileURL ?? SBUUtils.openURL; openFile(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, start: 8, end: 8, }, frozenListPadding: { paddingBottom: 32, }, newMsgButton: { position: 'absolute', zIndex: 999, bottom: 10, alignSelf: 'center', }, scrollButton: { position: 'absolute', zIndex: 998, bottom: 10, end: 16, }, }); // NOTE: Due to Generic inference is not working on forwardRef, we need to cast it properly for React 19 compatibility export default React.forwardRef(ChannelThreadMessageList) as ( props: ChannelThreadMessageListProps, ) => React.ReactElement | null;