import React, { useState, useCallback, useEffect, useRef } from 'react'; import { View, TouchableOpacity, Image, Modal, Text, FlatList, Dimensions, Alert, ScrollView, Linking, Platform, ActivityIndicator } from 'react-native'; import { GiftedChat, Bubble, InputToolbar, Actions, IMessage, Send, BubbleProps, Composer, TimeProps, MessageProps } from 'react-native-gifted-chat'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; import { useActionSheet } from '@expo/react-native-action-sheet'; import { GestureHandlerRootView, LongPressGestureHandler, Swipeable } from 'react-native-gesture-handler'; import { AvatarWithInitials, Header, WarningModal } from 'src/components'; import { Colors } from 'src/theme'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { Video } from 'expo-av'; import ChatMessageBox from '../Components/ChatMessageBox'; import ReplyMessageBar from '../Components/ReplyMessageBar'; import { useSharedValue, withTiming } from 'react-native-reanimated'; import { BlurView } from 'expo-blur'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import Clipboard from '@react-native-clipboard/clipboard'; import { trigger } from 'react-native-haptic-feedback'; import ReactModal from 'react-native-modal'; import { storage, StoreType } from 'src/storage'; import { usePostDeleteMessageMutation, usePostGetChatWithQuery, usePostMessagesReadMutation, usePostReactToMessageMutation, usePostSendMessageMutation } from '@api/chat'; import { CustomMessage, Message, Reaction } from '../types'; import { API_HOST, WEBSOCKET_URL } from 'src/constants'; import { getFontSize } from 'src/utils'; import ReactionBar from '../Components/ReactionBar'; import OptionsMenu from '../Components/OptionsMenu'; import EmojiSelectorModal from '../Components/EmojiSelectorModal'; import { styles } from './styles'; import SendIcon from 'assets/icons/messages/send.svg'; import { SheetManager } from 'react-native-actions-sheet'; import { NAVIGATION_PAGES } from 'src/types'; import * as Notifications from 'expo-notifications'; import { usePushNotification } from 'src/contexts/PushNotificationContext'; import ReactionsListModal from '../Components/ReactionsListModal'; const options = { enableVibrateFallback: true, ignoreAndroidSystemSettings: false }; const reactionEmojis = ['👍', '❤️', '😂', '😮', '😭']; const ChatScreen = ({ route }: { route: any }) => { const { id, name, avatar }: { id: number; name: string; avatar: string | null } = route.params; const currentUserId = storage.get('uid', StoreType.STRING) as number; const token = storage.get('token', StoreType.STRING) as string; const insets = useSafeAreaInsets(); const [messages, setMessages] = useState(); const { showActionSheetWithOptions } = useActionSheet(); const navigation = useNavigation(); const { data: chatData, isFetching, refetch } = usePostGetChatWithQuery(token, id, -1, -1, true); const { mutateAsync: sendMessage } = usePostSendMessageMutation(); const swipeableRowRef = useRef(null); const messageContainerRef = useRef | null>(null); const [selectedMedia, setSelectedMedia] = useState(null); const [replyMessage, setReplyMessage] = useState(null); const [modalInfo, setModalInfo] = useState({ visible: false, type: 'confirm', message: '', action: () => {} }); const [selectedMessage, setSelectedMessage] = useState | null>(null); const [emojiSelectorVisible, setEmojiSelectorVisible] = useState(false); const [messagePosition, setMessagePosition] = useState<{ x: number; y: number; width: number; height: number; isMine: boolean; } | null>(null); const [isModalVisible, setIsModalVisible] = useState(false); const [unreadMessageIndex, setUnreadMessageIndex] = useState(null); const { mutateAsync: markMessagesAsRead } = usePostMessagesReadMutation(); const { mutateAsync: deleteMessage } = usePostDeleteMessageMutation(); const { mutateAsync: reactToMessage } = usePostReactToMessageMutation(); const [highlightedMessageId, setHighlightedMessageId] = useState(null); const [isRerendering, setIsRerendering] = useState(false); const [isTyping, setIsTyping] = useState(false); const messageRefs = useRef<{ [key: string]: any }>({}); const flatList = useRef(null); const scrollY = useSharedValue(0); const { isSubscribed } = usePushNotification(); const socket = useRef(null); const closeModal = () => { setModalInfo({ ...modalInfo, visible: false }); }; const dismissChatNotifications = async (chatWithUserId: number) => { const { status } = await Notifications.getPermissionsAsync(); if (status !== 'granted' || !isSubscribed) { setModalInfo({ visible: true, type: 'success', message: 'To use this feature we need your permission to access your notifications. You will be redirected to the notification settings screen where you need to enable them.', action: () => // @ts-ignore navigation.navigate(NAVIGATION_PAGES.MENU_DRAWER, { screen: NAVIGATION_PAGES.NOTIFICATIONS }) }); return; } const getNotificationData = (notification: Notifications.Notification) => { if (Platform.OS === 'android') { const data = notification.request.content.data; if (data?.params) { try { return JSON.parse(data.params) ?? {}; } catch (error) { console.error('Error parsing params:', error); return {}; } } else { Notifications.dismissNotificationAsync(notification.request.identifier); return {}; } } else { const data = (notification.request.trigger as Notifications.PushNotificationTrigger) ?.payload; if (data?.params) { try { return JSON.parse(data.params as string) ?? {}; } catch (error) { console.error('Error parsing params:', error); return {}; } } } }; const clearNotificationsFromUser = async (userId: number) => { const presentedNotifications = await Notifications.getPresentedNotificationsAsync(); presentedNotifications.forEach((notification) => { const parsedParams = getNotificationData(notification); const conversation_with_user = parsedParams?.id; if (conversation_with_user === userId) { Notifications.dismissNotificationAsync(notification.request.identifier); } }); }; await clearNotificationsFromUser(chatWithUserId); Notifications.setNotificationHandler({ handleNotification: async (notification) => { let conversation_with_user = 0; const parsedParams = getNotificationData(notification); conversation_with_user = parsedParams?.id; if (conversation_with_user === chatWithUserId) { return { shouldShowAlert: false, shouldPlaySound: false, shouldSetBadge: false }; } return { shouldShowAlert: true, shouldPlaySound: false, shouldSetBadge: false }; } }); return () => { Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: false, shouldSetBadge: false }) }); }; }; useEffect(() => { let unsubscribe: any; const setupNotificationHandler = async () => { unsubscribe = await dismissChatNotifications(id); }; setupNotificationHandler(); return () => { if (unsubscribe) unsubscribe(); }; }, [id]); useEffect(() => { socket.current = new WebSocket(WEBSOCKET_URL); socket.current.onopen = () => { socket.current?.send(JSON.stringify({ token })); }; socket.current.onmessage = (event) => { const data = JSON.parse(event.data); handleWebSocketMessage(data); }; socket.current.onclose = () => { console.log('WebSocket connection closed chat screen'); }; return () => { socket.current?.close(); }; }, [token]); const handleWebSocketMessage = (data: any) => { switch (data.action) { case 'new_message': if (data.conversation_with === id) { refetch(); } break; case 'is_typing': if (data.conversation_with === id) { setIsTyping(true); } break; case 'stopped_typing': if (data.conversation_with === id) { setIsTyping(false); } break; case 'new_reaction': if (data.conversation_with === id) { refetch(); } break; default: break; } }; const sendWebSocketMessage = (action: string) => { if (socket.current && socket.current.readyState === WebSocket.OPEN) { socket.current.send(JSON.stringify({ action, conversation_with: id })); } }; const handleTyping = (isTyping: boolean) => { if (isTyping) { sendWebSocketMessage('is_typing'); } else { sendWebSocketMessage('stopped_typing'); } }; const mapApiMessageToGiftedMessage = (message: Message): CustomMessage => { return { _id: message.id, text: message.text, createdAt: new Date(message.sent_datetime + 'Z'), user: { _id: message.sender, name: message.sender === id ? name : 'Me' }, replyMessage: message.reply_to_id !== -1 ? { text: message.reply_to.text, id: message.reply_to.id, name: message.reply_to.sender === id ? name : 'Me' } : null, reactions: JSON.parse(message.reactions || '{}'), attachment: message.attachement !== -1 ? message.attachement : null, pending: message.status === 1, sent: message.status === 2, received: message.status === 3, deleted: message.status === 4 }; }; useFocusEffect( useCallback(() => { if (chatData?.messages) { const mappedMessages = chatData.messages.map(mapApiMessageToGiftedMessage); if (unreadMessageIndex === null && Platform.OS === 'ios') { const firstUnreadIndex = mappedMessages.findLastIndex( (msg) => !msg.received && !msg?.deleted && msg.user._id === id ); if (firstUnreadIndex !== -1) { setUnreadMessageIndex(firstUnreadIndex); const unreadMarker: any = { _id: 'unreadMarker', text: 'Unread messages', system: true }; mappedMessages.splice(firstUnreadIndex + 1, 0, unreadMarker); } else { setUnreadMessageIndex(0); } } setMessages(mappedMessages); } }, [chatData]) ); const sentToServer = useRef>(new Set()); const handleViewableItemsChanged = ({ viewableItems }: { viewableItems: any[] }) => { const newViewableUnreadMessages = viewableItems .filter( (item) => !item.item.received && !item.item.deleted && !item.item.system && item.item.user._id === id && !sentToServer.current.has(item.item._id) ) .map((item) => item.item._id); if (newViewableUnreadMessages.length > 0) { markMessagesAsRead( { token, from_user: id, messages_id: newViewableUnreadMessages }, { onSuccess: (res) => { newViewableUnreadMessages.forEach((id) => sentToServer.current.add(id)); // sendWebSocketMessage('messages_read'); sendWebSocketMessage('new_message'); } } ); } }; const renderSystemMessage = (props: any) => { if (props.currentMessage._id === 'unreadMarker') { return ( {props.currentMessage.text} ); } return null; }; const clearReplyMessage = () => setReplyMessage(null); const handleLongPress = (message: CustomMessage, props: BubbleProps) => { const messageRef = messageRefs.current[message._id]; setSelectedMessage(props); trigger('impactMedium', options); const isMine = message.user._id === +currentUserId; if (messageRef) { messageRef.measureInWindow((x: number, y: number, width: number, height: number) => { const screenHeight = Dimensions.get('window').height; const spaceAbove = y - insets.top; const spaceBelow = screenHeight - (y + height) - insets.bottom * 2; let finalY = y; scrollY.value = 0; if (isNaN(y) || isNaN(height)) { console.error("Invalid measurement values for 'y' or 'height'", { y, height }); return; } if (spaceBelow < 160) { const extraShift = 160 - spaceBelow; finalY -= extraShift; } if (spaceAbove < 50) { const extraShift = 50 - spaceAbove; finalY += extraShift; } if (spaceBelow < 160 || spaceAbove < 50) { const targetY = screenHeight / 2 - height / 2; scrollY.value = withTiming(finalY - finalY); } if (height > Dimensions.get('window').height - 200) { finalY = 100; } finalY = isNaN(finalY) ? 0 : finalY; setMessagePosition({ x, y: finalY, width, height, isMine }); setIsModalVisible(true); }); } }; const openEmojiSelector = () => { SheetManager.show('emoji-selector'); trigger('impactLight', options); }; const closeEmojiSelector = () => { SheetManager.hide('emoji-selector'); }; const handleReactionPress = (emoji: string, messageId: number) => { addReaction(messageId, emoji); }; const handleDeleteMessage = (messageId: number) => { deleteMessage( { token, message_id: messageId, conversation_with_user: id }, { onSuccess: () => { setMessages((prevMessages) => prevMessages ? prevMessages.filter((msg) => msg._id !== messageId) : [] ); // sendWebSocketMessage('message_deleted'); refetch(); sendWebSocketMessage('new_message'); } } ); }; const handleOptionPress = (option: string) => { if (!selectedMessage) return; switch (option) { case 'reply': setReplyMessage(selectedMessage.currentMessage); setIsModalVisible(false); break; case 'copy': Clipboard.setString(selectedMessage?.currentMessage?.text ?? ''); setIsModalVisible(false); Alert.alert('Copied'); break; case 'delete': handleDeleteMessage(selectedMessage.currentMessage?._id); setIsModalVisible(false); break; default: break; } closeEmojiSelector(); }; const openReactionList = ( reactions: { uid: number; name: string; reaction: string }[], messageId: number ) => { SheetManager.show('reactions-list-modal', { payload: { users: reactions, currentUserId: +currentUserId, token, messageId, conversation_with_user: id, setMessages, sendWebSocketMessage } as any }); }; const renderTimeContainer = (time: TimeProps) => { const createdAt = new Date(time.currentMessage.createdAt); const formattedTime = createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true }); const hasReactions = time.currentMessage.reactions && Array.isArray(time.currentMessage.reactions) && time.currentMessage.reactions.length > 0; return ( {hasReactions && ( Array.isArray(time.currentMessage.reactions) && openReactionList( time.currentMessage.reactions.map((reaction) => ({ ...reaction, name: reaction.uid === id ? name : 'Me' })), time.currentMessage._id ) } > {Object.entries( (Array.isArray(time.currentMessage.reactions) ? time.currentMessage.reactions : [] ).reduce( (acc: Record, { reaction }: { reaction: string }) => { if (!acc[reaction]) { acc[reaction] = { count: 0 }; } acc[reaction].count += 1; return acc; }, {} ) ).map(([emoji, { count }]: any) => { return ( {emoji} {(count as number) > 1 ? ` ${count}` : ''} ); })} )} {formattedTime} {renderTicks(time.currentMessage)} ); }; const renderSelectedMessage = () => selectedMessage && ( null} renderTime={renderTimeContainer} /> ); const handleBackgroundPress = () => { setIsModalVisible(false); setSelectedMessage(null); closeEmojiSelector(); }; useFocusEffect( useCallback(() => { navigation?.getParent()?.setOptions({ tabBarStyle: { display: 'none' } }); }, [navigation]) ); const onSend = useCallback( (newMessages: CustomMessage[] = []) => { if (replyMessage) { newMessages[0].replyMessage = { text: replyMessage.text, id: replyMessage._id, name: replyMessage.user._id === id ? name : 'Me' }; } const message = { ...newMessages[0], pending: true }; sendMessage( { token, to_uid: id, text: message.text, reply_to_id: replyMessage ? (replyMessage._id as number) : -1 }, { onSuccess: () => sendWebSocketMessage('new_message'), onError: (err) => console.log('err', err) } ); setMessages((previousMessages) => GiftedChat.append(previousMessages ?? [], [message])); clearReplyMessage(); }, [replyMessage] ); const openActionSheet = () => { const options = ['Open Camera', 'Select from gallery', 'Cancel']; const cancelButtonIndex = 2; showActionSheetWithOptions( { options, cancelButtonIndex }, async (buttonIndex) => { if (buttonIndex === 0) { openCamera(); } else if (buttonIndex === 1) { openGallery(); } } ); }; const openCamera = async () => { const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); if (permissionResult.granted === false) { alert('Permission denied to access camera'); return; } const result = await ImagePicker.launchCameraAsync({ mediaTypes: ImagePicker.MediaTypeOptions.All, quality: 1, allowsEditing: true }); if (!result.canceled) { const newMedia = { _id: Date.now().toString(), createdAt: new Date(), user: { _id: +currentUserId, name: 'Me' }, image: result.assets[0].type === 'image' ? result.assets[0].uri : null, video: result.assets[0].type === 'video' ? result.assets[0].uri : null }; setMessages((previousMessages) => GiftedChat.append(previousMessages ?? [], [newMedia as any]) ); } }; const openGallery = async () => { const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permissionResult.granted === false) { alert('Denied'); return; } const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.All, allowsMultipleSelection: true, quality: 1 }); if (!result.canceled && result.assets) { const imageMessages = result.assets.map((asset) => ({ _id: Date.now().toString() + asset.uri, createdAt: new Date(), user: { _id: +currentUserId, name: 'Me' }, image: asset.type === 'image' ? asset.uri : null, video: asset.type === 'video' ? asset.uri : null })); setMessages((previousMessages) => GiftedChat.append(previousMessages ?? [], imageMessages as any[]) ); } }; const renderMessageVideo = (props: BubbleProps) => { const { currentMessage } = props; if (currentMessage.video) { return ( handleLongPress(currentMessage, props)} > setSelectedMedia(currentMessage.video)} style={styles.mediaContainer} > ); } return null; }; const addReaction = (messageId: number, reaction: string) => { if (!messages) return; const updatedMessages = messages.map((msg: any) => { if (msg._id === messageId) { const updatedReactions: Reaction[] = [ ...(Array.isArray(msg.reactions) ? msg.reactions?.filter((r: Reaction) => r.uid !== +currentUserId) : []), { datetime: new Date().toISOString(), reaction: reaction, uid: +currentUserId } ]; return { ...msg, reactions: updatedReactions }; } return msg; }); setMessages(updatedMessages); reactToMessage( { token, message_id: messageId, reaction: reaction, conversation_with_user: id }, { onSuccess: () => sendWebSocketMessage('new_reaction'), onError: (err) => console.log('err', err) } ); setIsModalVisible(false); }; const updateRowRef = useCallback( (ref: any) => { if ( ref && replyMessage && ref.props.children.props.currentMessage?._id === replyMessage._id ) { swipeableRowRef.current = ref; } }, [replyMessage] ); const renderReplyMessageView = (props: BubbleProps) => { if (!props.currentMessage) { return null; } const { currentMessage } = props; if (!currentMessage || !currentMessage?.replyMessage) { return null; } return ( { if (currentMessage?.replyMessage?.id) { scrollToMessage(currentMessage.replyMessage.id); } }} > {currentMessage.replyMessage.name} {currentMessage.replyMessage.text} ); }; const scrollToMessage = (messageId: number) => { if (!messages) return; const messageIndex = messages.findIndex((message) => message._id === messageId); if (messageIndex !== -1 && flatList.current) { flatList.current.scrollToIndex({ index: messageIndex, animated: true, viewPosition: 0.5 }); setHighlightedMessageId(messageId); } }; useEffect(() => { if (highlightedMessageId && isRerendering) { setTimeout(() => { setHighlightedMessageId(null); setIsRerendering(false); }, 1500); } }, [highlightedMessageId, isRerendering]); useEffect(() => { if (replyMessage && swipeableRowRef.current) { swipeableRowRef.current.close(); swipeableRowRef.current = null; } }, [replyMessage]); const renderMessageImage = (props: any) => { const { currentMessage } = props; return ( setSelectedMedia(currentMessage.image)} style={styles.imageContainer} > ); }; const renderTicks = (message: CustomMessage) => { if (message.user._id === id) return null; return message.received ? ( ) : message.sent ? ( ) : message.pending ? ( ) : null; }; const renderBubble = (props: BubbleProps) => { const { currentMessage } = props; if (currentMessage.deleted) { const text = currentMessage.text.length ? props.currentMessage.text : 'This message was deleted'; return ( null} currentMessage={{ ...props.currentMessage, text: text }} renderMessageText={() => ( {text} )} wrapperStyle={{ right: { backgroundColor: Colors.DARK_BLUE }, left: { backgroundColor: Colors.FILL_LIGHT } }} textStyle={{ left: { color: Colors.DARK_BLUE }, right: { color: Colors.WHITE } }} /> ); } const isHighlighted = currentMessage._id === highlightedMessageId; const backgroundColor = isHighlighted ? Colors.ORANGE : currentMessage.user._id === +currentUserId ? Colors.DARK_BLUE : Colors.FILL_LIGHT; return ( { if (ref && currentMessage) { messageRefs.current[currentMessage._id] = ref; } }} collapsable={false} > handleLongPress(currentMessage, props)} renderTicks={() => null} renderTime={renderTimeContainer} /> ); }; const renderInputToolbar = (props: any) => ( // } // // onPressActionButton={openActionSheet} // /> null } containerStyle={{ backgroundColor: Colors.FILL_LIGHT }} /> ); const renderScrollToBottom = () => { return ( { if (flatList.current) { flatList.current.scrollToIndex({ index: 0, animated: true }); } }} > ); }; const shouldUpdateMessage = ( props: MessageProps, nextProps: MessageProps ) => { setIsRerendering(true); const currentId = nextProps.currentMessage._id; return currentId === highlightedMessageId; }; return (
navigation.navigate( ...([NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: id }] as never) ) } > {avatar ? ( ) : ( n[0]) .join('') ?? '' } flag={API_HOST + 'flag.png'} size={30} fontSize={12} /> )} } /> {messages ? ( { const wait = new Promise((resolve) => setTimeout(resolve, 300)); wait.then(() => { flatList.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); }); } }} renderSystemMessage={renderSystemMessage} onSend={(newMessages: CustomMessage[]) => onSend(newMessages)} user={{ _id: +currentUserId, name: 'Me' }} renderBubble={renderBubble} renderMessageImage={renderMessageImage} renderInputToolbar={renderInputToolbar} renderCustomView={renderReplyMessageView} isCustomViewBottom={false} messageContainerRef={messageContainerRef} minComposerHeight={34} onInputTextChanged={(text) => handleTyping(text.length > 0)} isTyping={isTyping} renderSend={(props) => ( {props.text?.trim() && ( )} {!props.text?.trim() && } )} textInputProps={{ ...styles.composer, selectionColor: Colors.LIGHT_GRAY }} placeholder="" renderMessage={(props) => ( )} updateRowRef={updateRowRef} setReplyOnSwipeOpen={setReplyMessage} /> )} renderChatFooter={() => ( )} // renderMessageVideo={renderMessageVideo} renderAvatar={null} maxComposerHeight={100} renderComposer={(props) => } keyboardShouldPersistTaps="handled" renderChatEmpty={() => ( {`No messages yet.\nFeel free to start the conversation.`} )} shouldUpdateMessage={shouldUpdateMessage} scrollToBottom={true} scrollToBottomComponent={renderScrollToBottom} scrollToBottomStyle={{ backgroundColor: 'transparent' }} parsePatterns={(linkStyle) => [ { type: 'url', style: { color: Colors.ORANGE, textDecorationLine: 'underline' }, onPress: (url: string) => Linking.openURL(url), onLongPress: (url: string) => { Clipboard.setString(url ?? ''); Alert.alert('Link copied'); } } ]} /> ) : ( )} {selectedMedia && selectedMedia?.includes('.mp4') ? ( {renderSelectedMessage()} { modalInfo.action(); closeModal(); }} /> ); }; export default ChatScreen;