import React, { useContext, Ref } from 'react'; import { FlatList, FlatListProps, ListRenderItem, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BottomSheetItem, ChannelFrozenBanner, createStyleSheet, useAlert, useBottomSheet, useToast, useUIKitTheme, CustomComponentContext } from '@gathertown/uikit-react-native-foundation'; import { Logger, SendbirdFileMessage, SendbirdGroupChannel, SendbirdMessage, SendbirdOpenChannel, SendbirdUserMessage, getAvailableUriFromFileMessage, getFileExtension, getFileType, isMyMessage, messageKeyExtractor, shouldRenderReaction, toMegabyte, useFreshCallback, } from '@gathertown/uikit-utils'; import type { UserProfileContextType } from '../../contexts/UserProfileCtx'; import { useLocalization, usePlatformService, useSendbirdChat, useUserProfile } from '../../hooks/useContext'; import SBUUtils from '../../libs/SBUUtils'; import type { CommonComponent } from '../../types'; import ChatFlatList from '../ChatFlatList'; import { ReactionAddons } from '../ReactionAddons'; import { UserMessage } from '@sendbird/chat/message'; type PressActions = { onPress?: () => void; onLongPress?: () => void }; type HandleableMessage = SendbirdUserMessage | SendbirdFileMessage; export type ChannelMessageListProps = { 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; onReplyMessage?: (message: HandleableMessage) => void; // only available on group channel onDeleteMessage: (message: HandleableMessage) => Promise; onResendFailedMessage: (failedMessage: HandleableMessage) => Promise; onPressParentMessage?: (parentMessage: SendbirdMessage) => void; onPressMediaMessage?: (message: SendbirdFileMessage, deleteMessage: () => Promise, uri: string) => void; renderMessage: (props: { focused: boolean; message: SendbirdMessage; prevMessage?: SendbirdMessage; nextMessage?: SendbirdMessage; onPress?: () => void; onLongPress?: () => void; onPressParentMessage?: ChannelMessageListProps['onPressParentMessage']; onShowUserProfile?: UserProfileContextType['show']; channel: T; currentUserId?: ChannelMessageListProps['currentUserId']; enableMessageGrouping: ChannelMessageListProps['enableMessageGrouping']; }) => React.ReactElement | null; renderNewMessagesButton: null | CommonComponent<{ visible: boolean; onPress: () => void; newMessages: SendbirdMessage[]; }>; renderScrollToBottomButton: null | CommonComponent<{ visible: boolean; onPress: () => void; }>; flatListProps?: Omit, 'data' | 'renderItem'>; } & { ref?: Ref> | undefined; }; const ChannelMessageList = ( { searchItem, hasNext, channel, onEditMessage, onReplyMessage, onDeleteMessage, onResendFailedMessage, onPressMediaMessage, onPressParentMessage, currentUserId, renderNewMessagesButton, renderScrollToBottomButton, renderMessage, messages, newMessages, enableMessageGrouping, onScrolledAwayFromBottom, scrolledAwayFromBottom, onBottomReached, onTopReached, flatListProps, onPressNewMessagesButton, onPressScrollToBottomButton, }: ChannelMessageListProps, ref: React.ForwardedRef>, ) => { const { STRINGS } = useLocalization(); const { colors } = useUIKitTheme(); const { show } = useUserProfile(); const { left, right } = useSafeAreaInsets(); const getMessagePressActions = useGetMessagePressActions({ channel, currentUserId, onEditMessage, onReplyMessage, onDeleteMessage, onResendFailedMessage, onPressMediaMessage, }); const ctx = useContext(CustomComponentContext); const safeAreaLayout = { paddingLeft: left, paddingRight: right }; const renderItem: ListRenderItem = useFreshCallback(({ item, index }) => { const { onPress, onLongPress } = getMessagePressActions(item); return renderMessage({ message: item, prevMessage: messages[index + 1], nextMessage: messages[index - 1], onPress, onLongPress, onPressParentMessage, onShowUserProfile: show, enableMessageGrouping, channel, currentUserId, focused: (searchItem?.startingPoint ?? -1) === item.createdAt, }); }); return ( {channel.isFrozen && ( )} {ctx?.renderCombinedNewMessagesScrollToBottomButton && ( {ctx.renderCombinedNewMessagesScrollToBottomButton({ visible: hasNext() || scrolledAwayFromBottom, onPress: () => newMessages.length > 0 ? onPressNewMessagesButton() : onPressScrollToBottomButton(), newMessagesCount: newMessages.length, })} )} {renderNewMessagesButton && !ctx?.renderCombinedNewMessagesScrollToBottomButton && ( {renderNewMessagesButton({ visible: newMessages.length > 0 && (hasNext() || scrolledAwayFromBottom), onPress: () => onPressNewMessagesButton(), newMessages, })} )} {renderScrollToBottomButton && !ctx?.renderCombinedNewMessagesScrollToBottomButton && ( {renderScrollToBottomButton({ visible: hasNext() || scrolledAwayFromBottom, onPress: () => onPressScrollToBottomButton(), })} )} ); }; const useGetMessagePressActions = ({ channel, currentUserId, onResendFailedMessage, onEditMessage, onDeleteMessage, onPressMediaMessage, }: Pick< ChannelMessageListProps, | 'channel' | 'currentUserId' | 'onEditMessage' | 'onReplyMessage' | 'onDeleteMessage' | 'onResendFailedMessage' | 'onPressMediaMessage' >) => { const { colors } = useUIKitTheme(); const { STRINGS } = useLocalization(); const toast = useToast(); const { openSheet } = useBottomSheet(); const { alert } = useAlert(); const { clipboardService, fileService } = usePlatformService(); const { sbOptions } = useSendbirdChat(); const onFailureToReSend = (error: Error) => { toast.show(STRINGS.TOAST.RESEND_MSG_ERROR, 'error'); Logger.error(STRINGS.TOAST.RESEND_MSG_ERROR, error); }; const handleFailedMessage = (message: HandleableMessage) => { openSheet({ sheetItems: [ { title: STRINGS.LABELS.CHANNEL_MESSAGE_FAILED_RETRY, onPress: () => { onResendFailedMessage(message).catch(onFailureToReSend); }, }, { title: STRINGS.LABELS.CHANNEL_MESSAGE_FAILED_REMOVE, titleColor: colors.ui.dialog.default.none.destructive, onPress: () => confirmDelete(message), }, ], }); }; const confirmDelete = (message: HandleableMessage) => { alert({ title: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_TITLE, message: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_DESCRIPTION, buttons: [ { text: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_CANCEL, style: 'cancel', }, { text: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE_CONFIRM_OK, onPress: () => { onDeleteMessage(message).catch(() => toast.show(STRINGS.TOAST.DELETE_MSG_ERROR, 'error')); }, }, ], }); }; return (msg: SendbirdMessage) => { if (!msg.isUserMessage() && !msg.isFileMessage()) { return { onPress: undefined, onLongPress: undefined }; } const sheetItems: BottomSheetItem['sheetItems'] = []; const response: PressActions = { onPress: undefined, onLongPress: undefined, }; const isCurrentUserSuccessfulMessage = isMyMessage(msg, currentUserId) && msg.sendingStatus === 'succeeded'; const pushCopySheetItem = (msg: UserMessage) => { sheetItems.push({ icon: 'copy', title: STRINGS.LABELS.CHANNEL_MESSAGE_COPY, onPress: () => { clipboardService.setString(msg.message || ''); toast.show(STRINGS.TOAST.COPY_OK, 'success'); }, }); }; if (msg.isUserMessage() && !(!channel.isEphemeral && isCurrentUserSuccessfulMessage)) { pushCopySheetItem(msg); } if (msg.isFileMessage()) { sheetItems.push({ icon: 'download', title: STRINGS.LABELS.CHANNEL_MESSAGE_SAVE, onPress: async () => { if (toMegabyte(msg.size) > 4) { toast.show(STRINGS.TOAST.DOWNLOAD_START, 'success'); } fileService .save({ fileUrl: msg.url, fileName: msg.name, fileType: msg.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); }); }, }); } if (!channel.isEphemeral) { if (isCurrentUserSuccessfulMessage) { if (msg.isUserMessage()) { sheetItems.push({ icon: 'edit', title: STRINGS.LABELS.CHANNEL_MESSAGE_EDIT, onPress: () => onEditMessage(msg), }); pushCopySheetItem(msg); } sheetItems.push({ disabled: msg.threadInfo ? msg.threadInfo.replyCount > 0 : undefined, icon: 'delete', title: STRINGS.LABELS.CHANNEL_MESSAGE_DELETE, onPress: () => confirmDelete(msg), style: 'destructive', }); } // Mobile threads are read-only for now /* if (channel.isGroupChannel() && sbOptions.uikit.groupChannel.channel.replyType === 'quote_reply') { sheetItems.push({ disabled: Boolean(msg.parentMessageId), icon: 'reply', title: STRINGS.LABELS.CHANNEL_MESSAGE_REPLY, onPress: () => onReplyMessage?.(msg), }); } */ } if (msg.isFileMessage()) { const fileType = getFileType(msg.type || getFileExtension(msg.name)); switch (fileType) { case 'image': case 'video': case 'audio': { response.onPress = () => { onPressMediaMessage?.(msg, () => onDeleteMessage(msg), getAvailableUriFromFileMessage(msg)); }; break; } default: { response.onPress = () => SBUUtils.openURL(msg.url); break; } } } if (sheetItems.length > 0) { response.onLongPress = () => { openSheet({ sheetItems, HeaderComponent: shouldRenderReaction( channel, sbOptions.uikitWithAppInfo.groupChannel.channel.enableReactions, ) ? ({ onClose }) => : undefined, }); }; } if (msg.sendingStatus === 'failed') { response.onLongPress = () => handleFailedMessage(msg); response.onPress = () => { onResendFailedMessage(msg).catch(onFailureToReSend); }; } if (msg.sendingStatus === 'pending') { response.onLongPress = undefined; response.onPress = undefined; } return response; }; }; 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(ChannelMessageList) as typeof ChannelMessageList;