import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { View, TouchableOpacity, Image, Text, FlatList, Dimensions, Alert, ScrollView, Linking, ActivityIndicator, AppState, AppStateStatus, TextInput, Platform, Keyboard } from 'react-native'; import { GiftedChat, Bubble, InputToolbar, IMessage, Send, BubbleProps, Composer, TimeProps, MessageProps, Actions, isSameUser, isSameDay, SystemMessage, ComposerProps } from 'react-native-gifted-chat'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { GestureHandlerRootView, Swipeable } from 'react-native-gesture-handler'; import { CustomImageViewer, Header, WarningModal } from 'src/components'; import { Colors } from 'src/theme'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { setAudioModeAsync } from 'expo-audio'; 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 { usePostGetGroupChatQuery, usePostGetPinnedGroupMessageQuery, usePostSetPinGroupMessageMutation, usePostGetGroupSettingsQuery, usePostGetGroupMembersQuery } from '@api/chat'; import { CustomMessage, 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 { compressImageWithProgress, compressVideoWithProgress, dismissChatNotifications, isMessageEdited } from '../utils'; import { useMessagesStore } from 'src/stores/unreadMessagesStore'; import FileViewer from 'react-native-file-viewer'; import * as FileSystem from 'expo-file-system/legacy'; import Share from 'react-native-share'; 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 { useMessagesLive } from 'src/watermelondb/features/chat/hooks/useChatThread'; import { Message } from 'src/watermelondb/models'; import { addMessageDirtyAction, OutgoingWsEvent, reconcileChatRange, triggerMessagePush, upsertMessagesIntoDB } from 'src/watermelondb/features/chat/data/message.sync'; import { database } from 'src/watermelondb'; import { Q } from '@nozbe/watermelondb'; import { createOptimisticMessage } from 'src/watermelondb/features/chat/data/createOptimisticMessage'; import _ from 'lodash'; const options = { enableVibrateFallback: true, ignoreAndroidSystemSettings: false }; const reactionEmojis = ['👍', '❤️', '😂', '😮', '😭']; export async function findGroupMsgRecord(id: number, groupToken: string): Promise { const messagesCollection = database.get('messages'); const res = await messagesCollection .query(Q.where('chat_key', 'g:' + groupToken), Q.where('message_id', id)) .fetch(); return res[0] ?? null; } 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', announcement, canSendMessages }: { group_token: string; name: string; avatar: string | null; userType: 'normal' | 'not_exist' | 'blocked'; announcement: 0 | 1; canSendMessages: 0 | 1; } = 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 navigation = useNavigation(); const [prevThenMessageId, setPrevThenMessageId] = useState(-1); const [visibleBeforeId, setVisibleBeforeId] = useState(null); const { data: chatData, refetch, isFetching, isFetchedAfterMount, isRefetching } = 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 [storedMembers, setStoredMembers] = useState(null); 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: pinMessage } = usePostSetPinGroupMessageMutation(); 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 [extraMessages, setExtraMessages] = useState([]); const { scrollToMessageId } = route.params ?? {}; const allMessages = useMessagesLive({ groupChatToken: group_token, limit: 50, aroundMessageId: scrollToMessageId }); const appState = useRef(AppState.currentState); const textInputRef = useRef(null); const socket = useRef(null); const scrollViewRef = useRef(null); const mapApiMessageToGiftedMessage = (message: Message): CustomMessage => { const attachment = message.attachment && message.attachment !== '-1' ? JSON.parse(message.attachment) : null; const replyData = message.replyToId && message.replyToId !== -1 && message.replyTo ? JSON.parse(message.replyTo) : '{}'; return { _id: message.messageId ?? message.id, text: message.text ?? '', createdAt: new Date(message.sentAt + 'Z'), user: { _id: message.senderId, name: message.senderId !== +currentUserId ? message.senderName : 'Me', avatar: message.senderId !== +currentUserId && message.senderAvatar ? API_HOST + message.senderAvatar : message.senderId === +currentUserId ? (null as never) : undefined }, replyMessage: message.replyToId && message.replyToId !== -1 && replyData ? { id: replyData.id, text: replyData.text, name: replyData.sender !== +currentUserId ? replyData.sender_name : 'Me' } : null, reactions: JSON.parse(message.reactions || '{}'), attachment, pending: message.status === 1, sent: message.status === 2, received: message.status === 3, deleted: message.status === 4, edited: message.edits ? isMessageEdited(message.edits) : false, isSending: message?.isSending ? message.isSending : false, image: attachment && attachment?.filetype?.startsWith('image') ? attachment.filetype === 'image/processing' ? attachment?.local_uri : API_HOST + attachment?.attachment_small_url : null, video: attachment && attachment?.filetype?.startsWith('video') ? attachment.filetype === 'video/processing' ? attachment?.local_uri : API_HOST + attachment?.attachment_link : null, system: message.senderId === -1 }; }; const giftedMessages = useMemo( () => [ ...allMessages.map(mapApiMessageToGiftedMessage), ...extraMessages.map(mapApiMessageToGiftedMessage) ], [allMessages, extraMessages] ); useEffect(() => { if (!scrollToMessageId) return; if (!giftedMessages.length) return; const index = giftedMessages.findIndex((m) => m._id === scrollToMessageId); if (index === -1) return; setTimeout(() => { requestAnimationFrame(() => { flatList.current?.scrollToIndex({ index, animated: false, viewPosition: 0.5 }); }); }, 500); }, [giftedMessages, scrollToMessageId]); useEffect(() => { if (isModalVisible) { setTimeout(() => { scrollViewRef.current?.scrollToEnd({ animated: false }); }, 50); } }, [isModalVisible]); useFocusEffect( useCallback(() => { setCacheKey(Date.now()); }, [navigation]) ); const closeModal = () => { setModalInfo({ ...modalInfo, visible: false }); }; useEffect(() => { setAudioModeAsync({ allowsRecording: false, playsInSilentMode: true, interruptionModeAndroid: 'duckOthers', interruptionMode: 'mixWithOthers' }); }, []); const [isKeyboardVisible, setKeyboardVisible] = useState(false); useEffect(() => { const keyboardWillShow = Keyboard.addListener( Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', () => setKeyboardVisible(true) ); const keyboardWillHide = Keyboard.addListener( Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', () => setKeyboardVisible(false) ); return () => { keyboardWillShow.remove(); keyboardWillHide.remove(); }; }, []); useEffect(() => { if (pinData && pinData?.message) { setPinned(pinData.message); } }, [pinData]); const onSendMedia = useCallback( async (files: { uri: string; type: 'image' | 'video' }[]) => { const formatedReply = replyMessage ? { text: replyMessage.text, id: replyMessage._id, name: replyMessage.user._id !== +currentUserId ? replyMessage.user.name : 'Me', sender: replyMessage.user._id, sender_name: replyMessage.user._id !== +currentUserId ? replyMessage.user.name : 'Me' } : null; for (const file of files) { const optimisticId = await createOptimisticMessage({ groupToken: group_token, currentUserId: +currentUserId, uiAttachment: { id: -1, filename: file.type, filetype: file.type === 'image' ? 'image/processing' : 'video/processing', local_uri: file.uri, attachment_link: file.uri, attachment_full_url: file.uri }, sendAttachment: { uri: file.uri, type: file.type, name: file.uri.split('/').pop() }, replyMessage: formatedReply, shouldAddDirty: false }); const updateProgress = async (progress: number) => {}; let compressedUri = file.uri; if (file.type === 'image') { compressedUri = await compressImageWithProgress(file.uri, updateProgress); } if (file.type === 'video') { compressedUri = await compressVideoWithProgress(file.uri, updateProgress); } const optimisticMessage = await findGroupMsgRecord(optimisticId, group_token); if (optimisticMessage) { await database.write(async () => { optimisticMessage.update((m) => { m.attachment = JSON.stringify({ ...JSON.parse(m.attachment ?? '{}'), local_uri: compressedUri }); addMessageDirtyAction(optimisticMessage, { type: 'send', value: { text, currentUid: currentUserId, attachment: { uri: compressedUri, type: file.type, name: file.uri.split('/').pop() }, reply_to_id: formatedReply ? formatedReply.id : -1, replyMessage: formatedReply } }); }); }); } } clearReplyMessage(); await triggerMessagePush(token, sendWsEvent); }, [replyMessage] ); const onSendLocation = useCallback( async (coords: { latitude: number; longitude: number }) => { const locationData = JSON.stringify({ lat: coords.latitude, lng: coords.longitude }); const fileUri = FileSystem.documentDirectory + 'location.json'; await FileSystem.writeAsStringAsync(fileUri, locationData); const formatedReply = replyMessage ? { text: replyMessage.text, id: replyMessage._id, name: replyMessage.user._id !== +currentUserId ? replyMessage.user.name : 'Me', sender: replyMessage.user._id, sender_name: replyMessage.user._id !== +currentUserId ? replyMessage.user.name : 'Me' } : null; await createOptimisticMessage({ groupToken: group_token, currentUserId: +currentUserId, uiAttachment: { id: -1, filename: 'location.json', filetype: 'nomadmania/location', lat: coords.latitude, lng: coords.longitude }, sendAttachment: { uri: fileUri, type: 'application/json', name: 'location.json' }, replyMessage: formatedReply }); clearReplyMessage(); await triggerMessagePush(token, sendWsEvent); }, [replyMessage] ); const onSendFile = useCallback( async (files: { uri: string; type: string; name?: string }[]) => { const formatedReply = replyMessage ? { text: replyMessage.text, id: replyMessage._id, name: replyMessage.user._id !== +currentUserId ? replyMessage.user.name : 'Me', sender: replyMessage.user._id, sender_name: replyMessage.user._id !== +currentUserId ? replyMessage.user.name : 'Me' } : null; for (const file of files) { await createOptimisticMessage({ groupToken: group_token, currentUserId: +currentUserId, uiAttachment: { id: -1, filename: file.name ?? 'File', filetype: file.type, attachment_link: file.uri }, sendAttachment: { uri: file.uri, type: file.type, name: file.name || file.uri.split('/').pop() }, replyMessage: formatedReply }); } clearReplyMessage(); await triggerMessagePush(token, sendWsEvent); }, [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 downloadOptions = { headers: { Nmtoken: token, 'App-Version': APP_VERSION, Platform: Platform.OS } }; const { uri } = await FileSystem.downloadAsync(fileUrl, fileUri, downloadOptions); await Share.open({ url: uri, type: fileType, failOnCancel: false }); } 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); storage.set( `canSeeMembers-${group_token}`, data.settings.members_can_see_members === 1 || data.settings.admin === 1 ); } else { const parsedData = (storage.get(`canSeeMembers-${group_token}`, StoreType.BOOLEAN) as boolean) ?? true; setCanSeeMembers(parsedData); } }, [data]); useEffect(() => { if (members && members.settings) { setStoredMembers(members.settings); storage.set(`members-${group_token}`, JSON.stringify(members.settings)); } else { const parsedMembers = JSON.parse( (storage.get(`members-${group_token}`, StoreType.STRING) as string) ?? '[]' ); setStoredMembers(parsedMembers); } }, [members]); 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) => { try { const data = JSON.parse(event.data); handleWebSocketMessage(data); } catch { console.log('Invalid WS message:', event.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) => { try { const data = JSON.parse(event.data); handleWebSocketMessage(data); } catch { console.log('Invalid WS message:', event.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 = async (data: any) => { switch (data.action) { case 'new_message': if (data.group_token === group_token && data.message && data.uid !== +currentUserId) { await upsertMessagesIntoDB({ groupToken: group_token, apiMessages: [data.message], avatar: data.avatar, name: data.name }); } break; case 'new_reaction': if (data.group_token === group_token && data.reaction && data.uid !== +currentUserId) { const record = await findGroupMsgRecord(data.reaction.message_id, group_token); if (!record) return; await database.write(async () => { record.update((m) => { const current = m.reactions ? JSON.parse(m.reactions) : []; m.reactions = JSON.stringify([ ...(Array.isArray(current) ? current?.filter((r: any) => r.uid !== data.reaction.uid) : []) ]); }); }); } break; case 'unreact': if ( data.group_token === group_token && data.unreacted_message_id && data.uid !== +currentUserId ) { const record = await findGroupMsgRecord(data.unreacted_message_id, group_token); if (!record) return; await database.write(async () => { record.update((m) => { const current = m.reactions ? JSON.parse(m.reactions) : []; m.reactions = JSON.stringify( Array.isArray(current) ? current?.filter((r: any) => r.uid === +currentUserId) : [] ); }); }); } break; case 'delete_message': if ( data.group_token === group_token && data.deleted_message_id && data.uid !== +currentUserId ) { const record = await findGroupMsgRecord(data.deleted_message_id, group_token); if (!record) return; await database.write(async () => { record.update((m) => { m.status = 4; m.text = 'This message was deleted'; m.attachment = null; m.replyTo = null; m.replyToId = -1; }); }); } 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': const readIds = data.read_messages_ids; if ( data.group_token === group_token && Array.isArray(readIds) && readIds.length && data.uid !== +currentUserId ) { const records = await database .get('messages') .query(Q.where('chat_key', 'g:' + group_token), Q.where('message_id', Q.oneOf(readIds))) .fetch(); if (!records.length) return; await database.write(async () => { records.forEach((msg: Message) => { msg.update((r) => { r.status = 3; (r as any)._raw._status = 'synced'; (r as any)._raw._changed = ''; }); }); }); } break; case 'messages_received': const receivedIds = data.received_messages_ids; if ( data.group_token === group_token && Array.isArray(receivedIds) && receivedIds.length && data.uid !== +currentUserId ) { const records = await database .get('messages') .query( Q.where('chat_key', 'g:' + group_token), Q.where('message_id', Q.oneOf(receivedIds)) ) .fetch(); if (!records.length) return; await database.write(async () => { records.forEach((r) => { r.update((m) => { m.status = 2; m.isSending = false; (m as any)._raw._status = 'synced'; (m as any)._raw._changed = ''; }); }); }); } break; case 'edited_message': if (data.group_token === group_token && data.message && data.uid !== +currentUserId) { const record = await findGroupMsgRecord(data.message.id, group_token); if (!record) return; await database.write(async () => { record.update((m) => { m.text = data.message.text; m.edits = '[{}]'; }); }); } break; default: break; } }; 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) => { try { const data = JSON.parse(event.data); handleWebSocketMessage(data); } catch { console.log('Invalid WS message:', event.data); } }; return () => { if (socket.current) { socket.current.close(); socket.current = null; } }; } }, 50000); return () => clearInterval(pingInterval); }, []); const sendWsEvent = useCallback((event: OutgoingWsEvent) => { if (!socket.current) return; if (socket.current.readyState !== WebSocket.OPEN) return; if (event.action === 'new_message' && event.payload) { socket.current.send( JSON.stringify({ action: event.action, conversation_with_group: group_token, message: { id: event.payload.message._id, text: event.payload.message.text, sender: +currentUserId, sent_datetime: new Date().toISOString().replace('T', ' ').substring(0, 19), reply_to_id: event.payload.message.replyMessage?.id ?? -1, reply_to: event.payload.message.replyMessage ?? null, reactions: event.payload.message.reactions ?? '{}', status: 2, attachement: event.payload.message.attachment ? event.payload.message.attachment : -1 } }) ); } }, []); 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_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'); } }; useFocusEffect( useCallback(() => { refetch(); }, []) ); const didInitUnreadRef = useRef(false); useEffect(() => { if (chatData?.groupAvatar) { setGroupAvatar(API_HOST + chatData.groupAvatar); } if (didInitUnreadRef.current) return; if (!giftedMessages.length) return; if (!chatData?.messages?.length) return; if (isFetching) return; const firstUnreadIndexFromServer = chatData.messages .slice() .reverse() .findIndex( (msg) => msg.status !== 3 && msg.status !== 4 && msg.sender !== +currentUserId && msg.sender !== -1 ); if (firstUnreadIndexFromServer === -1) { didInitUnreadRef.current = true; setUnreadMessageIndex(null); return; } const unreadMessageId = chatData.messages[chatData.messages.length - 1 - firstUnreadIndexFromServer]?.id; if (!unreadMessageId) return; didInitUnreadRef.current = true; setUnreadMessageIndex(unreadMessageId); const giftedIndex = giftedMessages.findIndex((m) => m._id === unreadMessageId); if (giftedIndex !== -1) { setTimeout(() => { flatList.current?.scrollToIndex({ index: giftedIndex, animated: true, viewPosition: 0.5 }); }, 400); } }, [giftedMessages, chatData]); const giftedMessagesWithUnread = useMemo(() => { if (unreadMessageIndex == null || unreadMessageIndex === -1) { return giftedMessages; } const index = giftedMessages.findIndex((m) => m._id === unreadMessageIndex); if (index === -1) return giftedMessages; const unreadMarker = { _id: 'unreadMarker', text: 'Unread messages', system: true }; const copy = [...giftedMessages]; copy.splice(index + 1, 0, unreadMarker as any); return copy; }, [giftedMessages, unreadMessageIndex]); const reconcileDebounced = useMemo(() => _.debounce(reconcileChatRange, 300), []); useEffect(() => { return () => { reconcileDebounced.cancel(); }; }, []); useEffect(() => { if (!isFetchedAfterMount) { if (!isRefetching) { refetch(); } return; } if (!chatData?.messages?.length) { setHasMoreMessages(false); return; } upsertMessagesIntoDB({ groupToken: group_token, apiMessages: chatData.messages }); reconcileDebounced(`g:${group_token}`, chatData.messages, Boolean(prevThenMessageId < 0)); setVisibleBeforeId(prevThenMessageId); if (chatData.messages.length < 50) { setHasMoreMessages(false); } }, [chatData, isRefetching]); useEffect(() => { if (giftedMessages) { if (isSearchingMessage) { const messageIndex = giftedMessages.findIndex((msg) => msg._id === isSearchingMessage); if (messageIndex !== -1 && flatList.current) { setIsSearchingMessage(null); } scrollToMessage(isSearchingMessage); } } }, [giftedMessages]); const loadEarlierMessages = async () => { if ((isLoadingEarlier && !isSearchingMessage) || !hasMoreMessages || !giftedMessages) return; const oldest = giftedMessages[giftedMessages.length - 1]; if (!oldest?._id) return; setPrevThenMessageId(oldest._id); }; useEffect(() => { const getExtraData = async () => { setIsLoadingEarlier(true); const chatKey = `g:${group_token}`; const older = await database .get('messages') .query( Q.where('chat_key', chatKey), Q.where('message_id', Q.lt(visibleBeforeId as number)), Q.sortBy('sent_at', Q.desc), Q.take(50) ) .fetch(); if (!older.length) { setHasMoreMessages(false); } else { setExtraMessages((prev) => [...prev, ...older]); } setIsLoadingEarlier(false); }; if (visibleBeforeId && visibleBeforeId !== -1) { getExtraData(); } }, [visibleBeforeId]); const sentToServer = useRef>(new Set()); const handleViewableItemsChanged = _.throttle( async ({ 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) { const messagesToUpdate = await database .get('messages') .query( Q.where('chat_key', 'g:' + group_token), Q.where('message_id', Q.oneOf(newViewableUnreadMessages)) ) .fetch(); if (messagesToUpdate.length > 0) { await database.write(async () => { messagesToUpdate.forEach((msg: Message) => { msg.update((r) => { r.status = 3; addMessageDirtyAction(r, { type: 'read', value: { messagesIds: [msg.messageId] } }); }); sentToServer.current.add(msg.messageId as number); }); }); await triggerMessagePush(token, sendWsEvent); } sendWebSocketMessage('messages_read', null, null, newViewableUnreadMessages); } }, 1000 ); 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 = async (messageId: number) => { const existingMsg = await findGroupMsgRecord(messageId, group_token); if (existingMsg) { await database.write(async () => { existingMsg.update((r) => { r.status = 4; r.text = 'This message was deleted'; r.attachment = null; r.replyToId = null; addMessageDirtyAction(r, { type: 'delete' }); }); }); await triggerMessagePush(token, sendWsEvent); } const messageToDelete = giftedMessages?.find((msg) => msg._id === messageId); 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': setIsModalVisible(false); setTimeout(() => { downloadFileToDevice(selectedMessage.currentMessage); }, 300); 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, 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 = storedMembers ?? []; return text.replace(/@\{(\d+)\}/g, (_, uid) => { const user = userList.find((m: any) => 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( async (newMessages: CustomMessage[] = []) => { if (editingMessage) { if (editingMessage.text !== newMessages[0].text) { const editedText = transformMessageForServer(newMessages[0].text); const existingMsg = await findGroupMsgRecord(editingMessage._id, group_token); if (existingMsg) { await database.write(async () => { existingMsg.update((r) => { r.text = editedText; r.edits = '[{}]'; addMessageDirtyAction(r, { type: 'edit', value: { text: editedText } }); }); }); clearEditMessage(); clearReplyMessage(); await triggerMessagePush(token, sendWsEvent); } const editedMessage = { _id: editingMessage._id, text: editedText }; sendWebSocketMessage('edited_message', editedMessage as unknown as CustomMessage); } return; } const msg = newMessages[0]; const formatedReply = replyMessage ? { text: transformMessageForServer(replyMessage.text), id: replyMessage._id, name: replyMessage.user._id !== +currentUserId ? replyMessage.user.name : 'Me', sender: replyMessage.user._id, sender_name: replyMessage.user._id !== +currentUserId ? replyMessage.user.name : 'Me' } : null; await createOptimisticMessage({ groupToken: group_token, currentUserId: +currentUserId, text: transformMessageForServer(msg.text), replyMessage: formatedReply }); clearReplyMessage(); await triggerMessagePush(token, sendWsEvent); }, [replyMessage, editingMessage, isConnected, storedMembers] ); const addReaction = async (messageId: number, reaction: string) => { const existingMsg = await findGroupMsgRecord(messageId, group_token); if (existingMsg) { const messageReactions = existingMsg.reactions ? JSON.parse(existingMsg.reactions) : null; const updatedReactions: Reaction[] = [ ...(Array.isArray(messageReactions) ? messageReactions?.filter((r: Reaction) => r.uid !== +currentUserId) : []), { datetime: new Date().toISOString(), reaction: reaction, uid: +currentUserId } ]; const newReactions = JSON.stringify(updatedReactions); await database.write(async () => { existingMsg.update((r) => { r.reactions = newReactions; addMessageDirtyAction(r, { type: 'reaction', value: reaction as string }); }); }); setIsModalVisible(false); await triggerMessagePush(token, sendWsEvent); } const message = giftedMessages.find((msg) => msg._id === messageId); sendWebSocketMessage('new_reaction', message, reaction); }; 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} {replaceMentionsWithNames(currentMessage.replyMessage.text)} ); }; const scrollToMessage = (messageId: number) => { if (!giftedMessages) return; const messageIndex = giftedMessages.findIndex((message) => message._id === messageId); if (messageIndex !== -1 && flatList.current) { setIsSearchingMessage(null); flatList.current.scrollToIndex({ index: messageIndex, animated: true, viewPosition: 0.5 }); setHighlightedMessageId(messageId); } 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 (!canSendMessages) 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( (storedMembers ?? [])?.filter( (m: any) => 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; storedMembers?.forEach((member: any) => { const mentionRegex = new RegExp(`@${member.name}\\b`, 'g'); transformedText = transformedText.replace(mentionRegex, `@{${member.uid}}`); }); return transformedText; }; const renderComposer = useCallback((props: ComposerProps) => { return ( // ); }, []); return (
navigation.navigate( ...([NAVIGATION_PAGES.GROUP_SETTINGS, { groupToken: group_token }] as never) ) } disabled={userType !== 'normal' || announcement === 1} > {groupAvatar && userType === 'normal' ? ( ) : userType === 'normal' ? ( ) : ( )} } /> {pinned && ( scrollToMessage(pinned.id)} > {pinned.text} {data?.settings?.admin === 1 && ( handlePinMessage(pinned.id, 0)} > )} )} {giftedMessagesWithUnread && ((canSeeMembers && storedMembers) || (data && data.settings && !canSeeMembers) || !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 as any} minComposerHeight={34} onInputTextChanged={onInputTextChanged} textInputRef={textInputRef as any} 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={120} renderComposer={renderComposer} keyboardShouldPersistTaps="handled" renderChatEmpty={() => ( {`No messages yet.\nFeel free to start the conversation.`} )} shouldUpdateMessage={shouldUpdateMessage} isScrollToBottomEnabled={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 = (storedMembers ?? [])?.find((m: any) => 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(); }} /> {!isKeyboardVisible ? ( ) : null} ); }; export default GroupChatScreen;