| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204 |
- 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
- } from 'react-native-gifted-chat';
- import { MaterialCommunityIcons } from '@expo/vector-icons';
- import { GestureHandlerRootView, Swipeable } from 'react-native-gesture-handler';
- import { AvatarWithInitials, 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 { usePostGetChatWithQuery, usePostCanSendMessageQuery } 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 { styles } from './styles';
- import SendIcon from 'assets/icons/messages/send.svg';
- import { SheetManager } from 'react-native-actions-sheet';
- import { NAVIGATION_PAGES } from 'src/types';
- import { 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 { CACHED_ATTACHMENTS_DIR } from 'src/constants/constants';
- 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 findMsgRecord(id: number, chatId: number): Promise<Message | null> {
- const messagesCollection = database.get<Message>('messages');
- const res = await messagesCollection
- .query(Q.where('chat_key', 'u:' + chatId), Q.where('message_id', id))
- .fetch();
- return res[0] ?? null;
- }
- const ChatScreen = ({ route }: { route: any }) => {
- const token = storage.get('token', StoreType.STRING) as string;
- const [isConnected, setIsConnected] = useState<boolean>(true);
- const netInfo = useConnection();
- const {
- id,
- name,
- avatar,
- userType = 'normal'
- }: {
- id: number;
- name: string;
- avatar: string | null;
- userType: 'normal' | 'not_exist' | 'blocked';
- } = route.params;
- const userName =
- 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 navigation = useNavigation();
- const [prevThenMessageId, setPrevThenMessageId] = useState<number>(-1);
- const [visibleBeforeId, setVisibleBeforeId] = useState<number | null>(null);
- const { data: canSendMessage } = usePostCanSendMessageQuery(token, id, true);
- const {
- data: chatData,
- refetch,
- isFetching,
- isFetchedAfterMount,
- isRefetching
- } = usePostGetChatWithQuery(token, id, 50, prevThenMessageId, true);
- const swipeableRowRef = useRef<Swipeable | null>(null);
- const messageContainerRef = useRef<FlatList<IMessage> | null>(null);
- const [selectedMedia, setSelectedMedia] = useState<any>(null);
- const [replyMessage, setReplyMessage] = useState<CustomMessage | null>(null);
- const [modalInfo, setModalInfo] = useState<any>({
- visible: false,
- type: 'confirm',
- message: '',
- action: () => {},
- buttonTitle: '',
- title: ''
- });
- const [selectedMessage, setSelectedMessage] = useState<BubbleProps<CustomMessage> | 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<number | null>(null);
- const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
- const [isRerendering, setIsRerendering] = useState<boolean>(false);
- const [isTyping, setIsTyping] = useState<boolean>(false);
- const messageRefs = useRef<{ [key: string]: any }>({});
- const flatList = useRef<FlatList | null>(null);
- const scrollY = useSharedValue(0);
- const { isSubscribed } = usePushNotification();
- const [isLoadingEarlier, setIsLoadingEarlier] = useState(false);
- const [hasMoreMessages, setHasMoreMessages] = useState(true);
- const [extraMessages, setExtraMessages] = useState<Message[]>([]);
- const updateUnreadMessagesCount = useMessagesStore((state) => state.updateUnreadMessagesCount);
- const [text, setText] = useState('');
- const [editingMessage, setEditingMessage] = useState<CustomMessage | null>(null);
- const appState = useRef(AppState.currentState);
- const textInputRef = useRef<TextInput>(null);
- const socket = useRef<WebSocket | null>(null);
- const scrollViewRef = useRef<ScrollView>(null);
- const { scrollToMessageId } = route.params ?? {};
- const allMessages = useMessagesLive({
- chatUid: id,
- limit: 50,
- aroundMessageId: scrollToMessageId
- });
- 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 === id ? userName : 'Me'
- },
- replyMessage:
- message.replyToId && message.replyToId !== -1 && replyData
- ? {
- text: replyData.text,
- id: replyData.id,
- name: replyData.sender === id ? userName : '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,
- video:
- attachment && attachment?.filetype?.startsWith('video')
- ? attachment.filetype === 'video/processing'
- ? attachment?.local_uri
- : API_HOST + attachment?.attachment_link
- : null,
- image:
- attachment && attachment?.filetype?.startsWith('image')
- ? attachment.filetype === 'image/processing'
- ? attachment?.local_uri
- : API_HOST + attachment?.attachment_small_url
- : null
- };
- };
- 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]);
- const didInitUnreadRef = useRef(false);
- useFocusEffect(
- useCallback(() => {
- didInitUnreadRef.current = false;
- return () => {
- didInitUnreadRef.current = false;
- };
- }, [id])
- );
- useEffect(() => {
- if (isModalVisible) {
- setTimeout(() => {
- scrollViewRef.current?.scrollToEnd({ animated: false });
- }, 50);
- }
- }, [isModalVisible]);
- useEffect(() => {
- if (netInfo && netInfo.isConnected !== null) {
- setIsConnected(netInfo.isConnected);
- }
- }, [netInfo]);
- useEffect(() => {
- if (!isConnected) return;
- let cancelled = false;
- const runSync = async () => {
- if (cancelled) return;
- await triggerMessagePush(token, sendWsEvent);
- };
- runSync();
- const interval = setInterval(runSync, 15000);
- return () => {
- cancelled = true;
- clearInterval(interval);
- };
- }, [isConnected, id]);
- 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 (canSendMessage && canSendMessage.need_authentication_or_friend === 1) {
- setModalInfo({
- visible: true,
- type: 'success',
- message: (
- <Text>
- Only friends and trusted members can send messages.{'\n'}
- {'\n'}Befriend this nomad or{' '}
- <Text
- style={{
- color: Colors.ORANGE,
- textDecorationLine: 'underline',
- textDecorationColor: Colors.ORANGE
- }}
- onPress={() =>
- Linking.openURL('https://nomadmania.com/trust/').catch((err) =>
- console.error('Failed to open trust URL:', err)
- )
- }
- >
- become trusted
- </Text>
- !
- </Text>
- ),
- action: () => {},
- buttonTitle: 'OK',
- title: 'Oops!'
- });
- // textInputRef.current?.blur();
- }
- }, [canSendMessage]);
- const onSendMedia = useCallback(
- async (files: { uri: string; type: 'image' | 'video' }[]) => {
- const formatedReply = replyMessage
- ? {
- text: replyMessage.text,
- id: replyMessage._id,
- name: replyMessage.user._id === id ? userName : 'Me',
- sender: replyMessage.user._id
- }
- : null;
- for (const file of files) {
- const optimisticId = await createOptimisticMessage({
- chatUid: id,
- 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 findMsgRecord(optimisticId, id);
- 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 === id ? userName : 'Me',
- sender: replyMessage.user._id
- }
- : null;
- await createOptimisticMessage({
- chatUid: id,
- 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 === id ? userName : 'Me',
- sender: replyMessage.user._id
- }
- : null;
- for (const file of files) {
- await createOptimisticMessage({
- chatUid: id,
- 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<CustomMessage>) => {
- 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 (
- <TouchableOpacity
- style={[
- styles.fileContainer,
- { backgroundColor: leftMessage ? 'rgba(15, 63, 79, 0.2)' : 'rgba(244, 244, 244, 0.2)' }
- ]}
- onPress={() => {
- openFileInApp(uri, fileName);
- }}
- onLongPress={() => handleLongPress(currentMessage, props)}
- disabled={currentMessage?.isSending}
- >
- {currentMessage?.isSending ? (
- <ActivityIndicator
- size="small"
- color={leftMessage ? Colors.DARK_BLUE : Colors.FILL_LIGHT}
- />
- ) : (
- <MaterialCommunityIcons
- name="file"
- size={32}
- color={leftMessage ? Colors.DARK_BLUE : Colors.FILL_LIGHT}
- />
- )}
- <Text
- style={[
- styles.fileNameText,
- { color: leftMessage ? Colors.DARK_BLUE : Colors.FILL_LIGHT }
- ]}
- >
- {fileName}
- </Text>
- </TouchableOpacity>
- );
- };
- const renderMessageLocation = (props: BubbleProps<CustomMessage>) => {
- const { currentMessage } = props;
- if (!currentMessage?.attachment) return null;
- const { lat, lng } = currentMessage.attachment;
- if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
- return (
- <View
- style={[
- {
- alignItems: 'center',
- borderRadius: 8,
- marginVertical: 6,
- marginHorizontal: 6,
- width: 220
- }
- ]}
- >
- <MessageLocation
- props={props}
- lat={lat as number}
- lng={lng as number}
- onLongPress={handleLongPress}
- />
- </View>
- );
- };
- const onShareLiveLocation = useCallback(() => {
- const liveMsg: IMessage = {
- _id: 'live-loc-' + Date.now(),
- text: 'Sharing live location...',
- createdAt: new Date(),
- user: { _id: +currentUserId, name: 'Me' },
- system: false
- };
- }, []);
- useEffect(() => {
- let unsubscribe: any;
- const setupNotificationHandler = async () => {
- unsubscribe = await dismissChatNotifications(id, isSubscribed, setModalInfo, navigation);
- };
- setupNotificationHandler();
- return () => {
- if (unsubscribe) unsubscribe();
- updateUnreadMessagesCount();
- };
- }, [id]);
- 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 = (ev) => {
- 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(id, 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.conversation_with === id && data.message && data.uid !== +currentUserId) {
- await upsertMessagesIntoDB({ chatUid: id, apiMessages: [data.message] });
- }
- break;
- case 'new_reaction':
- if (data.conversation_with === id && data.reaction && data.uid !== +currentUserId) {
- const record = await findMsgRecord(data.reaction.message_id, id);
- 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.conversation_with === id &&
- data.unreacted_message_id &&
- data.uid !== +currentUserId
- ) {
- const record = await findMsgRecord(data.unreacted_message_id, id);
- 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 !== id) : []
- );
- });
- });
- }
- break;
- case 'delete_message':
- if (
- data.conversation_with === id &&
- data.deleted_message_id &&
- data.uid !== +currentUserId
- ) {
- const record = await findMsgRecord(data.deleted_message_id, id);
- 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.conversation_with === id) {
- setIsTyping(true);
- }
- break;
- case 'stopped_typing':
- if (data.conversation_with === id) {
- setIsTyping(false);
- }
- break;
- case 'messages_read':
- const readIds = data.read_messages_ids;
- if (
- data.conversation_with === id &&
- Array.isArray(readIds) &&
- readIds.length &&
- data.uid !== +currentUserId
- ) {
- const records = await database
- .get<Message>('messages')
- .query(Q.where('chat_key', 'u:' + id), 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.conversation_with === id &&
- Array.isArray(receivedIds) &&
- receivedIds.length &&
- data.uid !== +currentUserId
- ) {
- const records = await database
- .get<Message>('messages')
- .query(Q.where('chat_key', 'u:' + id), 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.conversation_with === id && data.message && data.uid !== +currentUserId) {
- const record = await findMsgRecord(data.message.id, id);
- 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: id }));
- } 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: id,
- 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: id
- };
- 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();
- }, [])
- );
- useEffect(() => {
- 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 === id);
- 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({ chatUid: id, apiMessages: chatData.messages });
- reconcileDebounced(`u:${id}`, chatData.messages, Boolean(prevThenMessageId < 0));
- setVisibleBeforeId(prevThenMessageId);
- if (chatData.messages.length < 50) {
- setHasMoreMessages(false);
- }
- }, [chatData, isRefetching]);
- useEffect(() => {
- if (!isFetchedAfterMount || isFetching) return;
- if (
- giftedMessages?.length === 0 &&
- !modalInfo.visible &&
- !canSendMessage?.need_authentication_or_friend
- ) {
- setTimeout(() => {
- textInputRef.current?.focus();
- }, 500);
- }
- }, [isFetchedAfterMount]);
- const loadEarlierMessages = async () => {
- if (isLoadingEarlier || !hasMoreMessages || !giftedMessages) return;
- const oldest = giftedMessages[giftedMessages.length - 1];
- if (!oldest?._id) return;
- setPrevThenMessageId(oldest._id);
- };
- useEffect(() => {
- const getExtraData = async () => {
- setIsLoadingEarlier(true);
- const chatKey = `u:${id}`;
- const older = await database
- .get<Message>('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<Set<number>>(new Set());
- const handleViewableItemsChanged = _.throttle(
- async ({ viewableItems }: { viewableItems: any[] }) => {
- const newViewableUnreadMessages = viewableItems
- .filter(
- (item) =>
- !item.item.received &&
- !item.item.deleted &&
- !item.item.system &&
- item.item.user._id === id &&
- !sentToServer.current.has(item.item._id)
- )
- .map((item) => item.item._id);
- if (newViewableUnreadMessages.length > 0) {
- const messagesToUpdate = await database
- .get<Message>('messages')
- .query(
- Q.where('chat_key', 'u:' + id),
- 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 (
- <View style={styles.unreadMessagesContainer}>
- <Text style={styles.unreadMessagesText}>{props.currentMessage.text}</Text>
- </View>
- );
- }
- return null;
- };
- const clearReplyMessage = () => setReplyMessage(null);
- const clearEditMessage = () => {
- setEditingMessage(null);
- setText('');
- };
- const onInputTextChanged = (value: string) => {
- handleTyping(value.length > 0);
- setText(value);
- };
- const handleLongPress = (message: CustomMessage, props: BubbleProps<CustomMessage>) => {
- const messageRef = messageRefs.current[message._id];
- setSelectedMessage(props);
- trigger('impactMedium', options);
- const isMine = message.user._id === +currentUserId;
- if (messageRef) {
- messageRef.measureInWindow((x: number, y: number, width: number, height: number) => {
- const screenHeight = Dimensions.get('window').height;
- const spaceAbove = y - insets.top;
- const spaceBelow = screenHeight - (y + height) - insets.bottom * 2;
- let finalY = y;
- scrollY.value = 0;
- if (isNaN(y) || isNaN(height)) {
- console.error("Invalid measurement values for 'y' or 'height'", { y, height });
- return;
- }
- if (spaceBelow < 200) {
- const extraShift = 200 - spaceBelow;
- finalY -= extraShift;
- }
- if (spaceAbove < 50) {
- const extraShift = 50 - spaceAbove;
- finalY += extraShift;
- }
- if (spaceBelow < 180 || 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 findMsgRecord(messageId, id);
- 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 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 '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: id,
- sendWebSocketMessage
- } as any
- });
- };
- const renderTimeContainer = (time: TimeProps<CustomMessage>) => {
- 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 (
- <View
- style={[
- styles.bottomContainer,
- {
- justifyContent: hasReactions ? 'space-between' : 'flex-end'
- }
- ]}
- >
- {hasReactions && (
- <TouchableOpacity
- style={[
- styles.bottomCustomContainer,
- {
- backgroundColor:
- time.position === 'left' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'
- }
- ]}
- onPress={() =>
- Array.isArray(time.currentMessage.reactions) &&
- openReactionList(
- time.currentMessage.reactions.map((reaction) => ({
- ...reaction,
- name: reaction.uid === id ? userName : 'Me'
- })),
- time.currentMessage._id
- )
- }
- >
- {Object.entries(
- (Array.isArray(time.currentMessage.reactions)
- ? time.currentMessage.reactions
- : []
- ).reduce(
- (acc: Record<string, { count: number }>, { reaction }: { reaction: string }) => {
- if (!acc[reaction]) {
- acc[reaction] = { count: 0 };
- }
- acc[reaction].count += 1;
- return acc;
- },
- {}
- )
- ).map(([emoji, { count }]: any) => {
- return (
- <View key={emoji}>
- <Text style={{}}>
- {emoji}
- {(count as number) > 1 ? ` ${count}` : ''}
- </Text>
- </View>
- );
- })}
- </TouchableOpacity>
- )}
- <View style={styles.timeContainer}>
- {time.currentMessage.edited && <Text style={styles.timeText}>Edited</Text>}
- <Text style={[styles.timeText, time.currentMessage.edited ? { paddingLeft: 0 } : {}]}>
- {formattedTime}
- </Text>
- {renderTicks(time.currentMessage)}
- </View>
- </View>
- );
- };
- const renderSelectedMessage = () =>
- selectedMessage && (
- <View
- style={{
- top: messagePosition?.y && messagePosition?.y > 120 ? messagePosition?.y : 0,
- overflow: 'hidden'
- }}
- >
- <ScrollView
- ref={scrollViewRef}
- contentContainerStyle={{
- paddingBottom: 60
- }}
- showsVerticalScrollIndicator={false}
- >
- <Bubble
- {...selectedMessage}
- wrapperStyle={{
- right: {
- backgroundColor: Colors.DARK_BLUE,
- marginTop: messagePosition?.y && messagePosition?.y > 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={() =>
- selectedMessage.currentMessage.attachment?.filetype === 'nomadmania/location'
- ? renderMessageLocation(selectedMessage)
- : selectedMessage.currentMessage.attachment &&
- !selectedMessage.currentMessage.image &&
- !selectedMessage.currentMessage.video
- ? renderMessageFile(selectedMessage)
- : renderReplyMessageView(selectedMessage)
- }
- />
- <OptionsMenu
- selectedMessage={selectedMessage}
- handleOptionPress={handleOptionPress}
- messagePosition={messagePosition}
- />
- </ScrollView>
- </View>
- );
- const handleBackgroundPress = () => {
- setIsModalVisible(false);
- setSelectedMessage(null);
- closeEmojiSelector();
- };
- useFocusEffect(
- useCallback(() => {
- navigation?.getParent()?.setOptions({
- tabBarStyle: {
- display: 'none'
- }
- });
- }, [navigation])
- );
- const handleEditMessage = (message: CustomMessage) => {
- setReplyMessage(null);
- setEditingMessage(message);
- setText(message.text);
- textInputRef.current?.focus();
- };
- const onSend = useCallback(
- async (newMessages: CustomMessage[] = []) => {
- if (editingMessage) {
- if (editingMessage.text !== newMessages[0].text) {
- const existingMsg = await findMsgRecord(editingMessage._id, id);
- if (existingMsg) {
- await database.write(async () => {
- existingMsg.update((r) => {
- r.text = newMessages[0].text;
- r.edits = '[{}]';
- addMessageDirtyAction(r, {
- type: 'edit',
- value: {
- text: newMessages[0].text
- }
- });
- });
- });
- clearEditMessage();
- clearReplyMessage();
- await triggerMessagePush(token, sendWsEvent);
- }
- const editedMessage = {
- _id: editingMessage._id,
- text: newMessages[0].text
- };
- sendWebSocketMessage('edited_message', editedMessage as unknown as CustomMessage);
- }
- return;
- }
- const msg = newMessages[0];
- const formatedReply = replyMessage
- ? {
- text: replyMessage.text,
- id: replyMessage._id,
- name: replyMessage.user._id === id ? userName : 'Me',
- sender: replyMessage.user._id
- }
- : null;
- await createOptimisticMessage({
- chatUid: id,
- currentUserId: +currentUserId,
- text: msg.text,
- replyMessage: formatedReply
- });
- clearReplyMessage();
- await triggerMessagePush(token, sendWsEvent);
- },
- [replyMessage, editingMessage, isConnected]
- );
- const addReaction = async (messageId: number, reaction: string) => {
- const existingMsg = await findMsgRecord(messageId, id);
- 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
- });
- });
- });
- 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<CustomMessage>) => {
- if (!props.currentMessage) {
- return null;
- }
- const { currentMessage } = props;
- if (!currentMessage || !currentMessage?.replyMessage) {
- return null;
- }
- return (
- <TouchableOpacity
- style={[
- styles.replyMessageContainer,
- {
- backgroundColor:
- currentMessage.user._id === id ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.2)',
- borderColor: currentMessage.user._id === id ? Colors.DARK_BLUE : Colors.WHITE
- }
- ]}
- onPress={() => {
- if (currentMessage?.replyMessage?.id) {
- scrollToMessage(currentMessage.replyMessage.id);
- }
- }}
- >
- <View style={styles.replyContent}>
- <Text
- style={[
- styles.replyAuthorName,
- { color: currentMessage.user._id === id ? Colors.DARK_BLUE : Colors.WHITE }
- ]}
- >
- {currentMessage.replyMessage.name}
- </Text>
- <Text
- numberOfLines={1}
- style={[
- styles.replyMessageText,
- { color: currentMessage.user._id === id ? Colors.DARK_BLUE : Colors.WHITE }
- ]}
- >
- {currentMessage.replyMessage.text}
- </Text>
- </View>
- </TouchableOpacity>
- );
- };
- const scrollToMessage = (messageId: number) => {
- if (!giftedMessages) return;
- const messageIndex = giftedMessages.findIndex((message) => message._id === messageId);
- if (messageIndex !== -1 && flatList.current) {
- flatList.current.scrollToIndex({
- index: messageIndex,
- animated: true,
- viewPosition: 0.5
- });
- setHighlightedMessageId(messageId);
- }
- };
- useEffect(() => {
- if (highlightedMessageId && isRerendering) {
- setTimeout(() => {
- setHighlightedMessageId(null);
- setIsRerendering(false);
- }, 1500);
- }
- }, [highlightedMessageId, isRerendering]);
- useEffect(() => {
- if (replyMessage && swipeableRowRef.current) {
- swipeableRowRef.current.close();
- swipeableRowRef.current = null;
- }
- }, [replyMessage]);
- const renderTicks = (message: CustomMessage) => {
- if (message.user._id === id) return null;
- if (message.isSending) {
- return (
- <View>
- <ActivityIndicator
- size={16}
- color={Colors.LIGHT_GRAY}
- style={{ transform: 'scale(0.8)' }}
- />
- </View>
- );
- }
- return message.received ? (
- <View>
- <MaterialCommunityIcons name="check-all" size={16} color={Colors.WHITE} />
- </View>
- ) : message.sent ? (
- <View>
- <MaterialCommunityIcons name="check" size={16} color={Colors.WHITE} />
- </View>
- ) : message.pending ? (
- <View>
- <MaterialCommunityIcons name="check" size={16} color={Colors.LIGHT_GRAY} />
- </View>
- ) : null;
- };
- const renderBubble = (props: BubbleProps<CustomMessage>) => {
- const { currentMessage } = props;
- if (currentMessage.deleted) {
- const text = currentMessage.text.length
- ? props.currentMessage.text
- : 'This message was deleted';
- return (
- <View>
- <Bubble
- {...props}
- renderTime={() => null}
- currentMessage={{
- ...props.currentMessage,
- text: text
- }}
- renderMessageText={() => (
- <View style={{ paddingHorizontal: 12, paddingVertical: 6 }}>
- <Text style={{ color: Colors.LIGHT_GRAY, fontStyle: 'italic', fontSize: 12 }}>
- {text}
- </Text>
- </View>
- )}
- wrapperStyle={{
- right: {
- backgroundColor: Colors.DARK_BLUE
- },
- left: {
- backgroundColor: Colors.FILL_LIGHT
- }
- }}
- textStyle={{
- left: {
- color: Colors.DARK_BLUE
- },
- right: {
- color: Colors.WHITE
- }
- }}
- />
- </View>
- );
- }
- const isHighlighted = currentMessage._id === highlightedMessageId;
- const backgroundColor = isHighlighted
- ? Colors.ORANGE
- : currentMessage.user._id === +currentUserId
- ? Colors.DARK_BLUE
- : Colors.FILL_LIGHT;
- return (
- <View
- key={`${currentMessage._id}-${isHighlighted ? 'highlighted' : 'normal'}`}
- ref={(ref) => {
- if (ref && currentMessage) {
- messageRefs.current[currentMessage._id] = ref;
- }
- }}
- collapsable={false}
- >
- <Bubble
- {...props}
- wrapperStyle={{
- right: {
- backgroundColor: backgroundColor
- },
- left: {
- backgroundColor: backgroundColor
- }
- }}
- textStyle={{
- left: {
- color: Colors.DARK_BLUE
- },
- right: {
- color: Colors.FILL_LIGHT
- }
- }}
- onLongPress={() => handleLongPress(currentMessage, props)}
- renderTicks={() => null}
- renderTime={renderTimeContainer}
- renderCustomView={() =>
- currentMessage.attachment?.filetype === 'nomadmania/location'
- ? renderMessageLocation(props)
- : currentMessage.attachment && !currentMessage.image && !currentMessage.video
- ? renderMessageFile(props)
- : renderReplyMessageView(props)
- }
- />
- </View>
- );
- };
- const openAttachmentsModal = () => {
- SheetManager.show('chat-attachments', {
- payload: {
- name: userName,
- uid: id,
- setModalInfo,
- closeOptions: () => {},
- onSendMedia,
- onSendLocation,
- onShareLiveLocation,
- onSendFile
- } as any
- });
- };
- const renderInputToolbar = (props: any) => (
- <InputToolbar
- {...props}
- renderActions={() =>
- userType === 'normal' && !editingMessage ? (
- <Actions
- icon={() => <MaterialCommunityIcons name="plus" size={28} color={Colors.DARK_BLUE} />}
- onPressActionButton={openAttachmentsModal}
- />
- ) : null
- }
- containerStyle={{
- backgroundColor: Colors.FILL_LIGHT
- }}
- />
- );
- const renderScrollToBottom = () => {
- return (
- <TouchableOpacity
- style={styles.scrollToBottom}
- onPress={() => {
- if (flatList.current) {
- flatList.current.scrollToIndex({ index: 0, animated: true });
- }
- }}
- >
- <MaterialCommunityIcons name="chevron-down" size={24} color={Colors.WHITE} />
- </TouchableOpacity>
- );
- };
- const shouldUpdateMessage = (
- props: MessageProps<IMessage>,
- nextProps: MessageProps<IMessage>
- ) => {
- setIsRerendering(true);
- const currentId = nextProps.currentMessage._id;
- return currentId === highlightedMessageId;
- };
- return (
- <SafeAreaView
- edges={['top']}
- style={{
- height: '100%'
- }}
- >
- <View style={{ paddingHorizontal: '5%' }}>
- <Header
- label={userName}
- textColor={userType !== 'normal' ? Colors.RED : Colors.DARK_BLUE}
- rightElement={
- <TouchableOpacity
- onPress={() =>
- navigation.navigate(
- ...([NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: id }] as never)
- )
- }
- disabled={userType !== 'normal'}
- >
- {avatar && userType === 'normal' ? (
- <Image source={{ uri: API_HOST + avatar }} style={styles.avatar} />
- ) : userType === 'normal' ? (
- <AvatarWithInitials
- text={
- name
- .split(/ (.+)/)
- .map((n) => n[0])
- .join('') ?? ''
- }
- flag={API_HOST + 'flag.png'}
- size={30}
- fontSize={12}
- />
- ) : (
- <BanIcon fill={Colors.RED} width={30} height={30} />
- )}
- </TouchableOpacity>
- }
- />
- </View>
- <GestureHandlerRootView style={styles.container}>
- {giftedMessagesWithUnread ? (
- <GiftedChat
- messages={giftedMessagesWithUnread}
- text={text}
- listViewProps={{
- ref: flatList,
- showsVerticalScrollIndicator: false,
- initialNumToRender: 20,
- onViewableItemsChanged: handleViewableItemsChanged,
- viewabilityConfig: { itemVisiblePercentThreshold: 50 },
- onScrollToIndexFailed: (info: any) => {
- 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) => (
- <RenderMessageImage
- props={props}
- token={token}
- currentUserId={+currentUserId}
- onLongPress={handleLongPress}
- setSelectedMedia={setSelectedMedia}
- />
- )}
- renderInputToolbar={renderInputToolbar}
- renderCustomView={renderReplyMessageView}
- isCustomViewBottom={false}
- messageContainerRef={messageContainerRef as any}
- minComposerHeight={34}
- onInputTextChanged={onInputTextChanged}
- textInputRef={textInputRef as any}
- isTyping={isTyping}
- renderSend={(props) =>
- editingMessage ? (
- <View style={[styles.sendBtn, { paddingHorizontal: 8 }]}>
- {props.text?.trim() && (
- <Send
- {...props}
- containerStyle={{
- justifyContent: 'center'
- }}
- >
- <View style={styles.editBtn}>
- <MaterialCommunityIcons name="check" size={22} color={Colors.WHITE} />
- </View>
- </Send>
- )}
- {!props.text?.trim() && (
- <View style={[styles.editBtn, { backgroundColor: Colors.LIGHT_GRAY }]}>
- <MaterialCommunityIcons name="check" size={22} color={Colors.WHITE} />
- </View>
- )}
- </View>
- ) : (
- <View style={styles.sendBtn}>
- {props.text?.trim() && (
- <Send
- {...props}
- containerStyle={{
- justifyContent: 'center'
- }}
- >
- <SendIcon fill={Colors.DARK_BLUE} />
- </Send>
- )}
- {!props.text?.trim() && <SendIcon fill={Colors.LIGHT_GRAY} />}
- </View>
- )
- }
- renderMessageVideo={(props) => (
- <RenderMessageVideo
- props={props}
- token={token}
- currentUserId={+currentUserId}
- onLongPress={handleLongPress}
- />
- )}
- textInputProps={{
- ...styles.composer,
- selectionColor: Colors.LIGHT_GRAY
- }}
- placeholder=""
- renderMessage={(props) => (
- <ChatMessageBox
- {...(props as MessageProps<CustomMessage>)}
- updateRowRef={updateRowRef}
- setReplyOnSwipeOpen={setReplyMessage}
- />
- )}
- renderChatFooter={() => (
- <ReplyMessageBar
- clearReply={clearReplyMessage}
- clearEditMessage={clearEditMessage}
- message={replyMessage}
- editingMessage={editingMessage}
- />
- )}
- renderAvatar={null}
- maxComposerHeight={120}
- // renderComposer={(props) => (
- // <Composer
- // {...props}
- // textInputStyle={styles.composer}
- // multiline={true}
- // // // @ts-expect-error
- // // composerHeight="auto"
- // />
- // )}
- renderComposer={(props) => {
- return (
- <View style={{ flex: 1, paddingHorizontal: 10, paddingTop: 6 }}>
- <TextInput
- ref={textInputRef}
- multiline
- placeholder=""
- value={props.text}
- onChangeText={props.onTextChanged}
- style={styles.composer}
- selectionColor={Colors.LIGHT_GRAY}
- />
- </View>
- );
- }}
- keyboardShouldPersistTaps="handled"
- renderChatEmpty={() => (
- <View style={styles.emptyChat}>
- <Text
- style={styles.emptyChatText}
- >{`No messages yet.\nFeel free to start the conversation.`}</Text>
- </View>
- )}
- 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');
- }
- }
- ]}
- infiniteScroll={true}
- loadEarlier={hasMoreMessages}
- isLoadingEarlier={isLoadingEarlier}
- onLoadEarlier={loadEarlierMessages}
- renderLoadEarlier={() => (
- <View style={{ paddingVertical: 20 }}>
- <ActivityIndicator size="small" color={Colors.DARK_BLUE} />
- </View>
- )}
- />
- ) : (
- <ActivityIndicator size="large" color={Colors.DARK_BLUE} />
- )}
- <CustomImageViewer
- images={[
- {
- uri: selectedMedia,
- cache: 'force-cache',
- headers: { Nmtoken: token, 'App-Version': APP_VERSION, Platform: Platform.OS }
- }
- ]}
- imageIndex={0}
- visible={!!selectedMedia}
- onRequestClose={() => setSelectedMedia(null)}
- backgroundColor={Colors.DARK_BLUE}
- />
- <ReactModal
- isVisible={isModalVisible}
- onBackdropPress={handleBackgroundPress}
- onBackButtonPress={handleBackgroundPress}
- style={styles.reactModalContainer}
- animationIn="fadeIn"
- animationOut="fadeOut"
- useNativeDriver
- backdropColor="transparent"
- >
- <BlurView
- intensity={80}
- style={styles.modalBackground}
- experimentalBlurMethod="dimezisBlurView"
- >
- <TouchableOpacity
- style={styles.modalBackground}
- activeOpacity={1}
- onPress={handleBackgroundPress}
- >
- <ReactionBar
- messagePosition={messagePosition}
- selectedMessage={selectedMessage}
- reactionEmojis={reactionEmojis}
- handleReactionPress={handleReactionPress}
- openEmojiSelector={openEmojiSelector}
- />
- {renderSelectedMessage()}
- <EmojiSelectorModal
- visible={emojiSelectorVisible}
- selectedMessage={selectedMessage}
- addReaction={addReaction}
- closeEmojiSelector={closeEmojiSelector}
- />
- </TouchableOpacity>
- </BlurView>
- </ReactModal>
- <WarningModal
- isVisible={modalInfo.visible}
- onClose={closeModal}
- type={modalInfo.type}
- message={modalInfo.message}
- buttonTitle={modalInfo.buttonTitle}
- title={modalInfo.title}
- action={() => {
- modalInfo.action();
- closeModal();
- }}
- />
- <AttachmentsModal />
- <ReactionsListModal />
- </GestureHandlerRootView>
- {!isKeyboardVisible ? (
- <View
- style={{
- height: insets.bottom,
- backgroundColor: Colors.FILL_LIGHT
- }}
- />
- ) : null}
- </SafeAreaView>
- );
- };
- export default ChatScreen;
|