import React, { useState, useCallback, useEffect, useRef } from 'react'; import { View, TouchableOpacity, Image, Text, FlatList, Dimensions, Alert, ScrollView, Linking, ActivityIndicator, AppState, AppStateStatus, TextInput, Platform } from 'react-native'; import { GiftedChat, Bubble, InputToolbar, IMessage, Send, BubbleProps, Composer, TimeProps, MessageProps, Actions, isSameUser, isSameDay, SystemMessage, MessageText } from 'react-native-gifted-chat'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { GestureHandlerRootView, Swipeable } from 'react-native-gesture-handler'; import { Header, WarningModal } from 'src/components'; import { Colors } from 'src/theme'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { Audio } 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 { editMessageInStorage, sendMessageOffline, checkAndSendSavedMessages, storage, StoreType } from 'src/storage'; import { usePostGetGroupChatQuery, usePostSendGroupMessageMutation, usePostReactToGroupMessageMutation, usePostGroupMessagesReadMutation, usePostDeleteGroupMessageMutation, usePostGetPinnedGroupMessageQuery, usePostSetPinGroupMessageMutation, usePostGetGroupSettingsQuery, usePostGetGroupMembersQuery, usePostEditGroupMessageMutation } from '@api/chat'; import { CustomMessage, GroupMessage, Reaction } from '../types'; import { API_HOST, APP_VERSION, WEBSOCKET_URL } from 'src/constants'; import ReactionBar from '../Components/ReactionBar'; import OptionsMenu from '../Components/OptionsMenu'; import EmojiSelectorModal from '../Components/EmojiSelectorModal'; import TypingIndicator from '../Components/TypingIndicator'; import { styles } from '../ChatScreen/styles'; import SendIcon from 'assets/icons/messages/send.svg'; import { SheetManager } from 'react-native-actions-sheet'; import { NAVIGATION_PAGES } from 'src/types'; import { usePushNotification } from 'src/contexts/PushNotificationContext'; import ReactionsListModal from '../Components/ReactionsListModal'; import { dismissChatNotifications, isMessageEdited } from '../utils'; import { useMessagesStore } from 'src/stores/unreadMessagesStore'; import FileViewer from 'react-native-file-viewer'; import * as FileSystem from 'expo-file-system'; import ImageView from 'better-react-native-image-viewing'; import * as MediaLibrary from 'expo-media-library'; import BanIcon from 'assets/icons/messages/ban.svg'; import AttachmentsModal from '../Components/AttachmentsModal'; import RenderMessageVideo from '../Components/renderMessageVideo'; import RenderMessageImage from '../Components/RenderMessageImage'; import MessageLocation from '../Components/MessageLocation'; import GroupIcon from 'assets/icons/messages/group-chat.svg'; import { CACHED_ATTACHMENTS_DIR } from 'src/constants/constants'; import GroupStatusModal from '../Components/GroupStatusModal'; import PinIcon from 'assets/icons/messages/pin.svg'; import MentionsList from '../Components/MentionsList'; import { useConnection } from 'src/contexts/ConnectionContext'; import moment from 'moment'; const options = { enableVibrateFallback: true, ignoreAndroidSystemSettings: false }; const reactionEmojis = ['👍', '❤️', '😂', '😮', '😭']; const GroupChatScreen = ({ route }: { route: any }) => { const token = storage.get('token', StoreType.STRING) as string; const [isConnected, setIsConnected] = useState(true); const netInfo = useConnection(); const { group_token, name, avatar, userType = 'normal' }: { group_token: string; name: string; avatar: string | null; userType: 'normal' | 'not_exist' | 'blocked'; } = route.params; const groupName = userType === 'blocked' ? 'Account is blocked' : userType === 'not_exist' ? 'Account does not exist' : name; const currentUserId = storage.get('uid', StoreType.STRING) as number; const insets = useSafeAreaInsets(); const [groupAvatar, setGroupAvatar] = useState(null); const [messages, setMessages] = useState(); const navigation = useNavigation(); const [prevThenMessageId, setPrevThenMessageId] = useState(-1); const { data: chatData, refetch: refetch, isFetching: isFetching } = usePostGetGroupChatQuery(token, group_token, 50, prevThenMessageId, true); const [canSeeMembers, setCanSeeMembers] = useState(false); const { data: pinData, refetch: refetchPinned } = usePostGetPinnedGroupMessageQuery( token, group_token, true ); const { data } = usePostGetGroupSettingsQuery(token, group_token, true); const { data: members, refetch: refetchMembers } = usePostGetGroupMembersQuery( token, group_token, canSeeMembers ); const { mutateAsync: sendMessage } = usePostSendGroupMessageMutation(); const [isSearchingMessage, setIsSearchingMessage] = useState(null); const swipeableRowRef = useRef(null); const messageContainerRef = useRef | null>(null); const [selectedMedia, setSelectedMedia] = useState(null); const [pinned, setPinned] = useState(null); const [replyMessage, setReplyMessage] = useState(null); const [modalInfo, setModalInfo] = useState({ visible: false, type: 'confirm', message: '', action: () => {}, buttonTitle: '', title: '' }); 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 } = usePostGroupMessagesReadMutation(); const { mutateAsync: deleteMessage } = usePostDeleteGroupMessageMutation(); const { mutateAsync: reactToMessage } = usePostReactToGroupMessageMutation(); const { mutateAsync: pinMessage } = usePostSetPinGroupMessageMutation(); const { mutateAsync: editMessage } = usePostEditGroupMessageMutation(); const [highlightedMessageId, setHighlightedMessageId] = useState(null); const [isRerendering, setIsRerendering] = useState(false); const [isTyping, setIsTyping] = useState(null); const messageRefs = useRef<{ [key: string]: any }>({}); const flatList = useRef(null); const scrollY = useSharedValue(0); const { isSubscribed } = usePushNotification(); const [isLoadingEarlier, setIsLoadingEarlier] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(true); const updateUnreadMessagesCount = useMessagesStore((state) => state.updateUnreadMessagesCount); const [insetsColor, setInsetsColor] = useState(Colors.FILL_LIGHT); const [text, setText] = useState(''); const [mentionList, setMentionList] = useState([]); const [showMentions, setShowMentions] = useState(false); const [inputHeight, setInputHeight] = useState(45); const [editingMessage, setEditingMessage] = useState(null); const [cacheKey, setCacheKey] = useState(Date.now()); const appState = useRef(AppState.currentState); const textInputRef = useRef(null); const socket = useRef(null); const scrollViewRef = useRef(null); useEffect(() => { if (isModalVisible) { setTimeout(() => { scrollViewRef.current?.scrollToEnd({ animated: false }); }, 50); } }, [isModalVisible]); useFocusEffect( useCallback(() => { setCacheKey(Date.now()); }, [navigation]) ); useEffect(() => { if (netInfo && netInfo.isConnected !== null) { setIsConnected(netInfo.isConnected); if (netInfo.isConnected) { checkAndSendSavedMessages(); refetch(); } } }, [netInfo]); const closeModal = () => { setModalInfo({ ...modalInfo, visible: false }); }; useEffect(() => { if (!Audio || !Audio.setAudioModeAsync) { return; } Audio.setAudioModeAsync({ allowsRecordingIOS: false, staysActiveInBackground: false, playsInSilentModeIOS: true, shouldDuckAndroid: true, playThroughEarpieceAndroid: false }); }, []); useEffect(() => { if (pinData && pinData?.message) { setPinned(pinData.message); } }, [pinData]); const onSendMedia = useCallback( async (files: { uri: string; type: 'image' | 'video' }[]) => { for (const file of files) { const tempMessage: CustomMessage = { _id: Date.now() + Math.random(), text: '', createdAt: new Date(), user: { _id: +currentUserId, name: 'Me', avatar: null as never }, reactions: {}, deleted: false, attachment: { id: -1, filename: file.type, filetype: file.type, attachment_link: file.uri, attachment_full_url: file.uri }, pending: true, isSending: true, edited: false, image: file.type === 'image' ? file.uri : undefined, video: file.type === 'video' ? file.uri : undefined }; if (replyMessage) { tempMessage.replyMessage = { text: replyMessage.text, id: replyMessage._id, name: replyMessage.user._id !== +currentUserId ? (replyMessage.user.name as string) : 'Me' }; } setMessages((previousMessages) => GiftedChat.append(previousMessages ?? [], [tempMessage])); const messageData = { token, to_group_token: group_token, text: '', reply_to_id: replyMessage ? (replyMessage._id as number) : -1, attachment: { uri: file.uri, type: file.type, name: file.uri.split('/').pop() } }; const res = await sendMessage(messageData, { onSuccess: (res) => { const { attachment, message_id } = res; const newMessage = { _id: message_id, text: '', attachment, replyMessage: { ...tempMessage.replyMessage, sender: replyMessage?.user?._id }, image: file.type === 'image' ? API_HOST + attachment.attachment_full_url : undefined, video: file.type === 'video' ? file.uri : undefined }; setMessages((previousMessages) => (previousMessages ?? []).map((msg) => msg._id === tempMessage._id ? { ...msg, _id: res.message_id, isSending: false } : msg ) ); sendWebSocketMessage('new_message', newMessage as unknown as CustomMessage); } }); clearReplyMessage(); } }, [replyMessage] ); const onSendLocation = useCallback( async (coords: { latitude: number; longitude: number }) => { const tempMessage: CustomMessage = { _id: Date.now() + Math.random(), text: '', createdAt: new Date(), user: { _id: +currentUserId, name: 'Me', avatar: null as never }, pending: true, deleted: false, edited: false, reactions: {}, attachment: { id: -1, filename: 'location.json', filetype: 'nomadmania/location', lat: coords.latitude, lng: coords.longitude } }; if (replyMessage) { tempMessage.replyMessage = { text: replyMessage.text, id: replyMessage._id, name: replyMessage.user._id !== +currentUserId ? (replyMessage.user.name as string) : 'Me' }; } setMessages((previousMessages) => GiftedChat.append(previousMessages ?? [], [tempMessage])); const locationData = JSON.stringify({ lat: coords.latitude, lng: coords.longitude }); const fileUri = FileSystem.documentDirectory + 'location.json'; await FileSystem.writeAsStringAsync(fileUri, locationData); const locationFile = { uri: fileUri, type: 'application/json', name: 'location.json' }; const messageData = { token, to_group_token: group_token, text: tempMessage.text, reply_to_id: replyMessage ? (replyMessage._id as number) : -1, attachment: locationFile }; sendMessage(messageData, { onSuccess: async (res) => { const { attachment, message_id } = res; const newMessage = { _id: message_id, text: '', attachment, replyMessage: { ...tempMessage.replyMessage, sender: replyMessage?.user?._id } }; setMessages((previousMessages) => (previousMessages ?? []).map((msg) => msg._id === tempMessage._id ? { ...msg, _id: res.message_id } : msg ) ); sendWebSocketMessage('new_message', newMessage as unknown as CustomMessage); await FileSystem.deleteAsync(fileUri); }, onError: async (err) => { await FileSystem.deleteAsync(fileUri); } }); clearReplyMessage(); }, [replyMessage] ); const onSendFile = useCallback( (files: { uri: string; type: string; name?: string }[]) => { const newMsgs = files.map((file) => { const msg: CustomMessage = { _id: Date.now() + Math.random(), text: '', createdAt: new Date(), user: { _id: +currentUserId, name: 'Me', avatar: null as never }, deleted: false, reactions: {}, isSending: true, edited: false, attachment: { id: -1, filename: file.name ?? 'File', filetype: file.type, attachment_link: file.uri } }; if (replyMessage) { msg.replyMessage = { text: replyMessage.text, id: replyMessage._id, name: replyMessage.user._id !== +currentUserId ? (replyMessage.user.name as string) : 'Me' }; } if (file.type.includes('image')) { msg.image = file.uri; } else if (file.type.includes('video')) { msg.video = file.uri; } setMessages((previousMessages) => GiftedChat.append(previousMessages ?? [], [msg])); const messageData = { token, to_group_token: group_token, text: '', reply_to_id: replyMessage ? (replyMessage._id as number) : -1, attachment: { uri: file.uri, type: file.type, name: file.name || file.uri.split('/').pop() } }; sendMessage(messageData, { onSuccess: (res) => { const { attachment, message_id } = res; const newMessage = { _id: message_id, text: '', attachment, replyMessage: { ...msg.replyMessage, sender: replyMessage?.user?._id }, image: file.type === 'image' ? API_HOST + attachment.attachment_full_url : undefined, video: file.type === 'video' ? file.uri : undefined }; setMessages((previousMessages) => (previousMessages ?? []).map((prevMsg) => prevMsg._id === msg._id ? { ...prevMsg, _id: res.message_id, attachment: res.attachment, isSending: false, image: res.attachment?.attachment_small_url && file.type?.startsWith('image') ? API_HOST + res.attachment.attachment_small_url : undefined, video: res.attachment?.attachment_link && file.type?.startsWith('video') ? API_HOST + res.attachment.attachment_link : undefined } : prevMsg ) ); sendWebSocketMessage('new_message', newMessage as unknown as CustomMessage); } }); return msg; }); clearReplyMessage(); }, [replyMessage] ); async function openFileInApp(uri: string, fileName: string) { try { const dirExist = await FileSystem.getInfoAsync(CACHED_ATTACHMENTS_DIR); if (!dirExist.exists) { await FileSystem.makeDirectoryAsync(CACHED_ATTACHMENTS_DIR, { intermediates: true }); } const fileUri = `${CACHED_ATTACHMENTS_DIR}${fileName}`; const fileExists = await FileSystem.getInfoAsync(fileUri); if (fileExists.exists && fileExists.size > 1024) { await FileViewer.open(fileUri, { showOpenWithDialog: true, showAppsSuggestions: true }); return; } const { uri: localUri } = await FileSystem.downloadAsync(uri, fileUri, { headers: { Nmtoken: token, 'App-Version': APP_VERSION, Platform: Platform.OS } }); await FileViewer.open(localUri, { showOpenWithDialog: true, showAppsSuggestions: true }); } catch (err) { console.warn('openFileInApp error:', err); Alert.alert('Cannot open file', 'No application found to open this file.'); } } async function downloadFileToDevice(currentMessage: CustomMessage) { if (!currentMessage.image && !currentMessage.video) { return; } const fileUrl = currentMessage.video ? currentMessage.video : API_HOST + currentMessage.attachment?.attachment_full_url; const fileType = currentMessage.attachment?.filetype || 'application/octet-stream'; let fileExt = fileType.split('/').pop() || (currentMessage.video ? 'mp4' : 'jpg'); if (Platform.OS === 'android' && fileType === 'video/quicktime') { fileExt = 'mp4'; } const fileName = currentMessage.attachment?.filename?.split('.')[0] || 'file'; const fileUri = `${FileSystem.cacheDirectory}${fileName}.${fileExt}`; try { const { status } = await MediaLibrary.requestPermissionsAsync(); if (status !== 'granted') { return; } const downloadOptions = { headers: { Nmtoken: token, 'App-Version': APP_VERSION, Platform: Platform.OS } }; const { uri } = await FileSystem.downloadAsync(fileUrl, fileUri, downloadOptions); await MediaLibrary.createAssetAsync(uri); Alert.alert( 'Success', `${fileType.startsWith('video') ? 'Video' : 'Image'} saved to gallery.` ); } catch (error) { Alert.alert('Error', 'Failed to download the file.'); } } const renderMessageFile = (props: BubbleProps) => { const { currentMessage } = props; const leftMessage = currentMessage?.user?._id !== +currentUserId; if (!currentMessage?.attachment) return null; const { attachment_link, filename } = currentMessage.attachment; const fileName = filename ?? 'Attachment'; const uri = API_HOST + attachment_link; return ( { openFileInApp(uri, fileName); }} onLongPress={() => handleLongPress(currentMessage, props)} disabled={currentMessage?.isSending} > {currentMessage?.isSending ? ( ) : ( )} {fileName} ); }; const renderMessageLocation = (props: BubbleProps) => { const { currentMessage } = props; if (!currentMessage?.attachment) return null; const { lat, lng } = currentMessage.attachment; if (!lat || !lng) return null; return ( ); }; const onShareLiveLocation = useCallback(() => {}, []); useEffect(() => { if (data && data.settings) { setCanSeeMembers(data.settings.members_can_see_members === 1 || data.settings.admin === 1); } }, [data]); useEffect(() => { let unsubscribe: any; const setupNotificationHandler = async () => { unsubscribe = await dismissChatNotifications( group_token, isSubscribed, setModalInfo, navigation ); }; setupNotificationHandler(); return () => { if (unsubscribe) unsubscribe(); updateUnreadMessagesCount(); }; }, [group_token]); 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 () => { if (socket.current) { socket.current.close(); socket.current = null; } }; }, [token]); useEffect(() => { const handleAppStateChange = async (nextAppState: AppStateStatus) => { const prevState = appState.current; appState.current = nextAppState; if (prevState.match(/inactive|background/) && nextAppState === 'active') { refetch(); if (!socket.current || socket.current.readyState === WebSocket.CLOSED) { 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); }; } await dismissChatNotifications(group_token, isSubscribed, setModalInfo, navigation); } }; const subscription = AppState.addEventListener('change', handleAppStateChange); return () => { subscription.remove(); if (socket.current) { socket.current.close(); socket.current = null; } }; }, [token]); const handleWebSocketMessage = (data: any) => { switch (data.action) { case 'new_message': if (data.group_token === group_token && data.message) { const newMessage = mapApiMessageToGiftedMessage(data.message); setMessages((previousMessages) => { const messageExists = previousMessages && previousMessages.some((msg) => msg._id === newMessage._id); if (!messageExists) { return GiftedChat.append(previousMessages ?? [], [ { ...newMessage, user: { _id: data.uid, name: data.name, avatar: API_HOST + data.avatar } } ]); } return previousMessages; }); } break; case 'new_reaction': if (data.group_token === group_token && data.reaction) { // todo: name updateMessageWithReaction(data.reaction); } break; case 'unreact': if (data.group_token === group_token && data.unreacted_message_id) { // todo: name removeReactionFromMessage(data.unreacted_message_id); } break; case 'delete_message': if (data.group_token === group_token && data.deleted_message_id) { removeDeletedMessage(data.deleted_message_id); } break; case 'is_typing': if (data.group_token === group_token && data.uid !== +currentUserId) { setIsTyping(data.name); } break; case 'stopped_typing': if (data.group_token === group_token) { setIsTyping(null); } break; case 'messages_read': if (data.group_token === group_token && data.read_messages_ids) { setMessages( (prevMessages) => prevMessages?.map((msg) => { if (data.read_messages_ids.includes(msg._id)) { return { ...msg, received: true }; } return msg; }) ?? [] ); } break; case 'messages_received': if (data.group_token === group_token && data.received_messages_ids) { setMessages( (prevMessages) => prevMessages?.map((msg) => { if (data.received_messages_ids.includes(msg._id)) { return { ...msg, sent: true }; } return msg; }) ?? [] ); } break; case 'edited_message': if (data.group_token === group_token && data.message) { setMessages( (prevMessages) => prevMessages?.map((msg) => { if (msg._id === data.message.id) { return { ...msg, text: data.message.text, edited: true }; } return msg; }) ?? [] ); } break; default: break; } }; const updateMessageWithReaction = (reactionData: any) => { setMessages( (prevMessages) => prevMessages?.map((msg) => { if (msg._id === reactionData.message_id) { const updatedReactions = [ ...(Array.isArray(msg.reactions) ? msg.reactions?.filter((r: any) => r.uid !== reactionData.uid) : []), reactionData ]; return { ...msg, reactions: updatedReactions }; } return msg; }) ?? [] ); }; const removeReactionFromMessage = (messageId: number) => { setMessages( (prevMessages) => prevMessages?.map((msg) => { if (msg._id === messageId) { const updatedReactions = Array.isArray(msg.reactions) ? msg.reactions?.filter((r: any) => r.uid === +currentUserId) : []; return { ...msg, reactions: updatedReactions }; } return msg; }) ?? [] ); }; const removeDeletedMessage = (messageId: number) => { setMessages( (prevMessages) => prevMessages?.map((msg) => { if (msg._id === messageId) { return { ...msg, deleted: true, text: 'This message was deleted', pending: false, sent: false, received: false }; } return msg; }) ?? [] ); }; useEffect(() => { const pingInterval = setInterval(() => { if (socket.current && socket.current.readyState === WebSocket.OPEN) { socket.current.send( JSON.stringify({ action: 'ping', conversation_with_group: group_token }) ); } else { 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); }; return () => { if (socket.current) { socket.current.close(); socket.current = null; } }; } }, 50000); return () => clearInterval(pingInterval); }, []); const sendWebSocketMessage = ( action: string, message: CustomMessage | null = null, reaction: string | null = null, readMessagesIds: number[] | null = null ) => { if (socket.current && socket.current.readyState === WebSocket.OPEN) { const data: any = { action, conversation_with_group: group_token }; if (action === 'new_message' && message) { data.message = { id: message._id, text: message.text, sender: +currentUserId, sent_datetime: new Date().toISOString().replace('T', ' ').substring(0, 19), reply_to_id: message.replyMessage?.id ?? -1, reply_to: message.replyMessage ?? null, reactions: message.reactions ?? '{}', status: 2, attachement: message.attachment ? message.attachment : -1 }; } if (action === 'new_reaction' && message && reaction) { data.reaction = { message_id: message._id, reaction, uid: +currentUserId, datetime: new Date().toISOString() }; } if (action === 'unreact' && message) { data.message_id = message._id; } if (action === 'delete_message' && message) { data.message_id = message._id; } if (action === 'messages_read' && readMessagesIds) { data.messages_ids = readMessagesIds; } if (action === 'edited_message' && message) { data.message = { id: message._id, text: message.text }; } socket.current.send(JSON.stringify(data)); } }; const handleTyping = (isTyping: boolean) => { if (isTyping) { sendWebSocketMessage('is_typing'); } else { sendWebSocketMessage('stopped_typing'); } }; const mapApiMessageToGiftedMessage = (message: GroupMessage): CustomMessage => { return { _id: message.id, text: message.text, createdAt: new Date(message.sent_datetime + 'Z'), user: { _id: message.sender, name: message.sender !== +currentUserId ? message.sender_name : 'Me', avatar: message.sender !== +currentUserId && message.sender_avatar ? API_HOST + message.sender_avatar : message.sender === +currentUserId ? (null as never) : undefined }, replyMessage: message.reply_to_id && message.reply_to_id > 0 ? { text: message.reply_to.text, id: message.reply_to.id, name: message.reply_to.sender !== +currentUserId ? message.reply_to?.sender_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, edited: isMessageEdited(message.edits), isSending: message?.isSending ? message.isSending : false, video: message.attachement !== -1 && message.attachement?.filetype?.startsWith('video') ? API_HOST + message.attachement?.attachment_link : null, image: message.attachement !== -1 && message.attachement?.filetype?.startsWith('image') ? API_HOST + message.attachement?.attachment_small_url : null, system: message.sender === -1 }; }; useFocusEffect( useCallback(() => { refetch(); }, []) ); useFocusEffect( useCallback(() => { if (chatData?.groupAvatar) { setGroupAvatar(API_HOST + chatData.groupAvatar); } if (chatData?.messages) { const mappedMessages = chatData.messages.map(mapApiMessageToGiftedMessage); if (unreadMessageIndex === null && !isFetching) { const firstUnreadIndex = mappedMessages.findLastIndex( (msg) => !msg.received && !msg?.deleted && msg.user._id !== +currentUserId ); if (firstUnreadIndex !== -1) { setUnreadMessageIndex(firstUnreadIndex); const unreadMarker: any = { _id: 'unreadMarker', text: 'Unread messages', system: true }; mappedMessages.splice(firstUnreadIndex + 1, 0, unreadMarker); setTimeout(() => { if (flatList.current) { flatList.current.scrollToIndex({ index: firstUnreadIndex, animated: true, viewPosition: 0.5 }); } }, 500); } else { setUnreadMessageIndex(0); } } setMessages((previousMessages) => { const newMessages = mappedMessages.filter( (newMsg) => !previousMessages?.some((oldMsg) => oldMsg._id === newMsg._id) ); let unsentMapped = []; if (prevThenMessageId === -1) { const unsentMessages = storage.get(`unsent_${group_token}`, StoreType.STRING) ? JSON.parse(storage.get(`unsent_${group_token}`, StoreType.STRING) as string) : []; unsentMapped = unsentMessages.map(mapApiMessageToGiftedMessage); } return prevThenMessageId !== -1 && previousMessages ? GiftedChat.prepend(previousMessages, newMessages) : [...unsentMapped, ...mappedMessages]; }); if (mappedMessages.length < 50) { setHasMoreMessages(false); } if (mappedMessages.length === 0 && !modalInfo.visible) { setTimeout(() => { textInputRef.current?.focus(); }, 500); } setIsLoadingEarlier(false); } }, [chatData]) ); useEffect(() => { if (messages) { if (isSearchingMessage) { const messageIndex = messages.findIndex((msg) => msg._id === isSearchingMessage); if (messageIndex !== -1 && flatList.current) { setIsSearchingMessage(null); } scrollToMessage(isSearchingMessage); } } }, [messages]); useEffect(() => { if (messages?.length === 0 && !modalInfo.visible) { setTimeout(() => { textInputRef.current?.focus(); }, 500); } }, [modalInfo]); const loadEarlierMessages = async () => { if (!hasMoreMessages || (isLoadingEarlier && !isSearchingMessage) || !messages) return; setIsLoadingEarlier(true); const previousMessageId = messages[messages.length - 1]._id; setPrevThenMessageId(previousMessageId); }; const sentToServer = useRef>(new Set()); const handleViewableItemsChanged = ({ viewableItems }: { viewableItems: any[] }) => { const newViewableUnreadMessages = viewableItems .filter( (item) => !item.item.received && !item.item.deleted && item.item._id !== 'unreadMarker' && item.item.user._id !== +currentUserId && !sentToServer.current.has(item.item._id) ) .map((item) => item.item._id); if (newViewableUnreadMessages.length > 0) { markMessagesAsRead( { token, group_token, messages_id: newViewableUnreadMessages }, { onSuccess: (res) => { newViewableUnreadMessages.forEach((id) => sentToServer.current.add(id)); sendWebSocketMessage('messages_read', null, null, newViewableUnreadMessages); } } ); } }; const renderSystemMessage = (props: any) => { if (props.currentMessage._id === 'unreadMarker') { return ( {props.currentMessage.text} ); } else if (props.currentMessage.user._id === -1) { return ( ); } return null; }; const clearReplyMessage = () => setReplyMessage(null); const clearEditMessage = () => { setEditingMessage(null); setText(''); }; 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; } const maxSpaceBelow = isMine ? 280 : 200; if (spaceBelow < maxSpaceBelow) { const extraShift = maxSpaceBelow - spaceBelow; finalY -= extraShift; } if (spaceAbove < 50) { const extraShift = 50 - spaceAbove; finalY += extraShift; } if (spaceBelow < 220 || 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, group_token }, { onSuccess: () => { setMessages( (prevMessages) => prevMessages?.map((msg) => { if (msg._id === messageId) { return { ...msg, deleted: true, text: 'This message was deleted', pending: false, sent: false, received: false, attachment: null, image: undefined, video: undefined }; } return msg; }) ?? [] ); const messageToDelete = messages?.find((msg) => msg._id === messageId); if (messageToDelete) { sendWebSocketMessage('delete_message', messageToDelete, null, null); } } } ); }; const handlePinMessage = (messageId: number, pin: 0 | 1) => { pinMessage( { token, message_id: messageId, group_token, pin }, { onSuccess: () => { refetchPinned(); if (pin === 0) { setPinned(null); } } } ); }; 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; case 'download': downloadFileToDevice(selectedMessage.currentMessage); setIsModalVisible(false); break; case 'info': SheetManager.show('group-status', { payload: { messageId: selectedMessage.currentMessage._id, groupToken: group_token, setInsetsColor } as any }); setIsModalVisible(false); setInsetsColor(Colors.WHITE); break; case 'pin': handlePinMessage(selectedMessage.currentMessage?._id, 1); setIsModalVisible(false); break; case 'edit': handleEditMessage(selectedMessage.currentMessage); 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: group_token, setMessages, sendWebSocketMessage, isGroup: true, groupToken: group_token } 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 !== +currentUserId ? reaction?.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}` : ''} ); })} )} {time.currentMessage.edited && Edited} {formattedTime} {renderTicks(time.currentMessage)} ); }; const renderSelectedMessage = () => { if (!selectedMessage) return; const messageToCompare = selectedMessage.previousMessage; const showUserName = selectedMessage.position === 'left' && selectedMessage.currentMessage && messageToCompare && (!isSameUser(selectedMessage.currentMessage, messageToCompare) || !isSameDay(selectedMessage.currentMessage, messageToCompare)); return ( 120 ? messagePosition?.y : 0, overflow: 'hidden' }} > 120 ? 0 : 120, marginRight: 8 }, left: { backgroundColor: Colors.FILL_LIGHT, marginTop: messagePosition?.y && messagePosition?.y > 120 ? 0 : 120, marginLeft: 8 } }} textStyle={{ right: { color: Colors.WHITE }, left: { color: Colors.DARK_BLUE } }} renderTicks={() => null} renderTime={renderTimeContainer} renderCustomView={() => ( {showUserName ? ( {selectedMessage.currentMessage.user.name} ) : null} {selectedMessage.currentMessage.attachment?.filetype === 'nomadmania/location' ? renderMessageLocation(selectedMessage) : selectedMessage.currentMessage.attachment && !selectedMessage.currentMessage.image && !selectedMessage.currentMessage.video ? renderMessageFile(selectedMessage) : renderReplyMessageView(selectedMessage)} )} /> ); }; const handleBackgroundPress = () => { setIsModalVisible(false); setSelectedMessage(null); closeEmojiSelector(); }; useFocusEffect( useCallback(() => { navigation?.getParent()?.setOptions({ tabBarStyle: { display: 'none' } }); }, [navigation]) ); const replaceMentionsWithNames = (text: string) => { const userList = members?.settings ?? []; return text.replace(/@\{(\d+)\}/g, (_, uid) => { const user = userList.find((m) => m.uid === +uid); return user ? `@${user.name}` : `@{${uid}}`; }); }; const handleEditMessage = (message: CustomMessage) => { setReplyMessage(null); setEditingMessage({ ...message, text: replaceMentionsWithNames(message.text) }); setText(replaceMentionsWithNames(message.text)); textInputRef.current?.focus(); }; const onSend = useCallback( (newMessages: CustomMessage[] = []) => { if (editingMessage) { if (editingMessage.text !== newMessages[0].text) { const editedText = transformMessageForServer(newMessages[0].text); setMessages((prevMessages) => (prevMessages ?? []).map((msg) => msg._id === editingMessage._id ? { ...msg, text: editedText, isSending: true } : msg ) ); editMessage( { token, group_token: group_token, message_id: editingMessage._id, text: editedText }, { onSuccess: () => { editMessageInStorage(group_token, editingMessage._id, editedText, true); const editedMessage = { _id: editingMessage._id, text: editedText }; setMessages((previousMessages) => (previousMessages ?? []).map((msg) => msg._id === editingMessage._id ? { ...msg, isSending: false, edited: true } : msg ) ); sendWebSocketMessage('edited_message', editedMessage as unknown as CustomMessage); } } ); } clearEditMessage(); clearReplyMessage(); return; } if (replyMessage) { newMessages[0].replyMessage = { text: transformMessageForServer(replyMessage.text), id: replyMessage._id, name: replyMessage.user._id !== +currentUserId ? (replyMessage.user.name as string) : 'Me' }; } const user = { _id: +currentUserId, name: 'Me', avatar: null as never }; const message = { ...newMessages[0], pending: true, isSending: true, user }; setMessages((previousMessages) => GiftedChat.append(previousMessages ?? [], [ { ...message, text: transformMessageForServer(newMessages[0].text) } ]) ); if (!isConnected) { const staticMessage = { id: message._id, text: transformMessageForServer(message.text), isSending: true, sender: +currentUserId, status: 1, sent_datetime: moment(message.createdAt).utc().format('YYYY-MM-DD HH:mm:ss'), reply_to_id: message.replyMessage ? message.replyMessage.id : -1, reactions: '{}', edits: '{}', attachement: -1, encrypted: 0, sender_avatar: null, sender_name: 'Me' }; sendMessageOffline(group_token, staticMessage as any); clearReplyMessage(); return; } sendMessage( { token, to_group_token: group_token, text: transformMessageForServer(message.text), reply_to_id: replyMessage ? (replyMessage._id as number) : -1 }, { onSuccess: (res) => { const newMessage = { _id: res.message_id, text: message.text, replyMessage: { ...message.replyMessage, sender: replyMessage?.user?._id } }; setMessages((previousMessages) => (previousMessages ?? []).map((msg) => msg._id === message._id ? { ...msg, _id: res.message_id, isSending: false } : msg ) ); sendWebSocketMessage('new_message', newMessage as unknown as CustomMessage); } } ); clearReplyMessage(); }, [replyMessage, editingMessage, isConnected] ); 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, group_token: group_token }, { onSuccess: () => { const message = messages.find((msg) => msg._id === messageId); if (message) { sendWebSocketMessage('new_reaction', message, reaction); } } } ); 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) { setIsSearchingMessage(null); flatList.current.scrollToIndex({ index: messageIndex, animated: true, viewPosition: 0.5 }); setHighlightedMessageId(messageId); setMessages((previousMessages) => (previousMessages ?? []).map((msg) => msg._id === messageId ? { ...msg, isRendering: msg?.isRendering ? false : true } : msg ) ); } if (hasMoreMessages && messageIndex === -1) { setIsSearchingMessage(messageId); loadEarlierMessages(); } }; 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 renderTicks = (message: CustomMessage) => { if (message.user._id !== +currentUserId) return null; if (message.isSending) { return ( ); } 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; const messageToCompare = props.previousMessage; const showUserName = props.position === 'left' && currentMessage && messageToCompare && (!isSameUser(currentMessage, messageToCompare) || !isSameDay(currentMessage, messageToCompare)); return ( { if (ref && currentMessage) { messageRefs.current[currentMessage._id] = ref; } }} collapsable={false} > handleLongPress(currentMessage, props)} renderTicks={() => null} renderTime={renderTimeContainer} renderCustomView={() => { return ( {showUserName ? ( {/* {'~ '} */} {props.currentMessage.user.name} ) : null} {currentMessage.attachment?.filetype === 'nomadmania/location' ? renderMessageLocation(props) : currentMessage.attachment && !currentMessage.image && !currentMessage.video ? renderMessageFile(props) : renderReplyMessageView(props)} ); }} /> ); }; const openAttachmentsModal = () => { SheetManager.show('chat-attachments', { payload: { name: groupName, uid: group_token, setModalInfo, closeOptions: () => {}, onSendMedia, onSendLocation, onShareLiveLocation, onSendFile, isGroup: true } as any }); }; const renderInputToolbar = (props: any) => { if (!chatData?.can_send_messages) return null; return ( <> {showMentions && canSeeMembers ? ( ) : null} { setInputHeight(e.nativeEvent.layout.height); }} > userType === 'normal' && !editingMessage ? ( ( )} onPressActionButton={openAttachmentsModal} /> ) : 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; }; const onInputTextChanged = (value: string) => { handleTyping(value.length > 0); setText(value); const mentionMatch = value.match(/(^|\s)(@\w*)$/); if (mentionMatch) { setShowMentions(true); const searchText = mentionMatch[2].slice(1).toLowerCase(); setMentionList( (members?.settings ?? [])?.filter( (m) => m.name.toLowerCase().includes(searchText) && m.uid !== +currentUserId ) ); } else { setShowMentions(false); } }; const onMentionSelect = (member: { uid: number; name: string }) => { const words = text.split(' '); words[words.length - 1] = `@${member.name} `; setText(words.join(' ')); setShowMentions(false); }; const transformMessageForServer = (text: string) => { let transformedText = text; members?.settings?.forEach((member) => { const mentionRegex = new RegExp(`@${member.name}\\b`, 'g'); transformedText = transformedText.replace(mentionRegex, `@{${member.uid}}`); }); return transformedText; }; return (
navigation.navigate( ...([NAVIGATION_PAGES.GROUP_SETTINGS, { groupToken: group_token }] as never) ) } disabled={userType !== 'normal'} > {groupAvatar && userType === 'normal' ? ( ) : userType === 'normal' ? ( ) : ( )} } /> {pinned && ( scrollToMessage(pinned.id)} > {pinned.text} {data?.settings?.admin === 1 && ( handlePinMessage(pinned.id, 0)} > )} )} {messages && ((canSeeMembers && members?.settings) || (data && data.settings && data.settings.members_can_see_members === 0 && data.settings.admin === 0) || !isConnected) ? ( { 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={(props) => ( )} showUserAvatar={true} onPressAvatar={(user) => { navigation.navigate( ...([NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: user._id }] as never) ); }} renderInputToolbar={renderInputToolbar} renderCustomView={renderReplyMessageView} isCustomViewBottom={false} messageContainerRef={messageContainerRef} minComposerHeight={34} onInputTextChanged={onInputTextChanged} textInputRef={textInputRef} isTyping={isTyping ? true : false} renderTypingIndicator={() => } renderSend={(props) => editingMessage ? ( {props.text?.trim() && ( )} {!props.text?.trim() && ( )} ) : ( {props.text?.trim() && ( )} {!props.text?.trim() && } ) } renderMessageVideo={(props) => ( )} textInputProps={{ ...styles.composer, selectionColor: Colors.LIGHT_GRAY }} placeholder="" renderMessage={(props) => ( )} updateRowRef={updateRowRef} setReplyOnSwipeOpen={setReplyMessage} /> )} renderChatFooter={() => ( )} 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'); } }, { pattern: /@\{(\d+)\}/g, renderText: (messageText: string) => { const tagId = messageText.slice(2, messageText.length - 1); const user = (members?.settings ?? [])?.find((m) => m.uid === +tagId); if (user) { return ( navigation.navigate( ...([ NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: user.uid } ] as never) ) } > @{user.name} ); } else { return messageText; } } } ]} infiniteScroll={true} loadEarlier={hasMoreMessages} isLoadingEarlier={isLoadingEarlier} onLoadEarlier={loadEarlierMessages} renderLoadEarlier={() => ( )} /> ) : ( )} setSelectedMedia(null)} backgroundColor={Colors.DARK_BLUE} /> {renderSelectedMessage()} { modalInfo.action(); closeModal(); }} /> ); }; export default GroupChatScreen;