index.tsx 41 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393
  1. import React, { useState, useCallback, useEffect, useRef } from 'react';
  2. import {
  3. View,
  4. TouchableOpacity,
  5. Image,
  6. Modal,
  7. Text,
  8. FlatList,
  9. Dimensions,
  10. Alert,
  11. ScrollView,
  12. Linking,
  13. ActivityIndicator,
  14. AppState,
  15. AppStateStatus,
  16. TextInput
  17. } from 'react-native';
  18. import {
  19. GiftedChat,
  20. Bubble,
  21. InputToolbar,
  22. IMessage,
  23. Send,
  24. BubbleProps,
  25. Composer,
  26. TimeProps,
  27. MessageProps
  28. } from 'react-native-gifted-chat';
  29. import { MaterialCommunityIcons } from '@expo/vector-icons';
  30. import { GestureHandlerRootView, Swipeable } from 'react-native-gesture-handler';
  31. import { AvatarWithInitials, Header, WarningModal } from 'src/components';
  32. import { Colors } from 'src/theme';
  33. import { useFocusEffect, useNavigation } from '@react-navigation/native';
  34. import { Video } from 'expo-av';
  35. import ChatMessageBox from '../Components/ChatMessageBox';
  36. import ReplyMessageBar from '../Components/ReplyMessageBar';
  37. import { useSharedValue, withTiming } from 'react-native-reanimated';
  38. import { BlurView } from 'expo-blur';
  39. import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
  40. import Clipboard from '@react-native-clipboard/clipboard';
  41. import { trigger } from 'react-native-haptic-feedback';
  42. import ReactModal from 'react-native-modal';
  43. import { storage, StoreType } from 'src/storage';
  44. import {
  45. usePostDeleteMessageMutation,
  46. usePostGetChatWithQuery,
  47. usePostMessagesReadMutation,
  48. usePostReactToMessageMutation,
  49. usePostSendMessageMutation
  50. } from '@api/chat';
  51. import { CustomMessage, Message, Reaction } from '../types';
  52. import { API_HOST, WEBSOCKET_URL } from 'src/constants';
  53. import ReactionBar from '../Components/ReactionBar';
  54. import OptionsMenu from '../Components/OptionsMenu';
  55. import EmojiSelectorModal from '../Components/EmojiSelectorModal';
  56. import { styles } from './styles';
  57. import SendIcon from 'assets/icons/messages/send.svg';
  58. import { SheetManager } from 'react-native-actions-sheet';
  59. import { NAVIGATION_PAGES } from 'src/types';
  60. import { usePushNotification } from 'src/contexts/PushNotificationContext';
  61. import ReactionsListModal from '../Components/ReactionsListModal';
  62. import { dismissChatNotifications } from '../utils';
  63. import { useMessagesStore } from 'src/stores/unreadMessagesStore';
  64. import BanIcon from 'assets/icons/messages/ban.svg';
  65. const options = {
  66. enableVibrateFallback: true,
  67. ignoreAndroidSystemSettings: false
  68. };
  69. const reactionEmojis = ['👍', '❤️', '😂', '😮', '😭'];
  70. const ChatScreen = ({ route }: { route: any }) => {
  71. const token = storage.get('token', StoreType.STRING) as string;
  72. const {
  73. id,
  74. name,
  75. avatar,
  76. userType
  77. }: {
  78. id: number;
  79. name: string;
  80. avatar: string | null;
  81. userType: 'normal' | 'not_exist' | 'blocked';
  82. } = route.params;
  83. const userName =
  84. userType === 'blocked'
  85. ? 'Account is blocked'
  86. : userType === 'not_exist'
  87. ? 'Account does not exist'
  88. : name;
  89. const currentUserId = storage.get('uid', StoreType.STRING) as number;
  90. const insets = useSafeAreaInsets();
  91. const [messages, setMessages] = useState<CustomMessage[] | null>();
  92. const navigation = useNavigation();
  93. const [prevThenMessageId, setPrevThenMessageId] = useState<number>(-1);
  94. const {
  95. data: chatData,
  96. refetch,
  97. isFetching
  98. } = usePostGetChatWithQuery(token, id, 50, prevThenMessageId, true);
  99. const { mutateAsync: sendMessage } = usePostSendMessageMutation();
  100. const swipeableRowRef = useRef<Swipeable | null>(null);
  101. const messageContainerRef = useRef<FlatList<IMessage> | null>(null);
  102. const [selectedMedia, setSelectedMedia] = useState<any>(null);
  103. const [replyMessage, setReplyMessage] = useState<CustomMessage | null>(null);
  104. const [modalInfo, setModalInfo] = useState({
  105. visible: false,
  106. type: 'confirm',
  107. message: '',
  108. action: () => {}
  109. });
  110. const [selectedMessage, setSelectedMessage] = useState<BubbleProps<CustomMessage> | null>(null);
  111. const [emojiSelectorVisible, setEmojiSelectorVisible] = useState(false);
  112. const [messagePosition, setMessagePosition] = useState<{
  113. x: number;
  114. y: number;
  115. width: number;
  116. height: number;
  117. isMine: boolean;
  118. } | null>(null);
  119. const [isModalVisible, setIsModalVisible] = useState(false);
  120. const [unreadMessageIndex, setUnreadMessageIndex] = useState<number | null>(null);
  121. const { mutateAsync: markMessagesAsRead } = usePostMessagesReadMutation();
  122. const { mutateAsync: deleteMessage } = usePostDeleteMessageMutation();
  123. const { mutateAsync: reactToMessage } = usePostReactToMessageMutation();
  124. const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
  125. const [isRerendering, setIsRerendering] = useState<boolean>(false);
  126. const [isTyping, setIsTyping] = useState<boolean>(false);
  127. const messageRefs = useRef<{ [key: string]: any }>({});
  128. const flatList = useRef<FlatList | null>(null);
  129. const scrollY = useSharedValue(0);
  130. const { isSubscribed } = usePushNotification();
  131. const [isLoadingEarlier, setIsLoadingEarlier] = useState(false);
  132. const [hasMoreMessages, setHasMoreMessages] = useState(true);
  133. const updateUnreadMessagesCount = useMessagesStore((state) => state.updateUnreadMessagesCount);
  134. const appState = useRef(AppState.currentState);
  135. const textInputRef = useRef<TextInput>(null);
  136. const socket = useRef<WebSocket | null>(null);
  137. const closeModal = () => {
  138. setModalInfo({ ...modalInfo, visible: false });
  139. };
  140. useEffect(() => {
  141. let unsubscribe: any;
  142. const setupNotificationHandler = async () => {
  143. unsubscribe = await dismissChatNotifications(id, isSubscribed, setModalInfo, navigation);
  144. };
  145. setupNotificationHandler();
  146. return () => {
  147. if (unsubscribe) unsubscribe();
  148. updateUnreadMessagesCount();
  149. };
  150. }, [id]);
  151. useEffect(() => {
  152. socket.current = new WebSocket(WEBSOCKET_URL);
  153. socket.current.onopen = () => {
  154. socket.current?.send(JSON.stringify({ token }));
  155. };
  156. socket.current.onmessage = (event) => {
  157. const data = JSON.parse(event.data);
  158. handleWebSocketMessage(data);
  159. };
  160. socket.current.onclose = () => {
  161. console.log('WebSocket connection closed chat screen');
  162. };
  163. return () => {
  164. if (socket.current) {
  165. socket.current.close();
  166. socket.current = null;
  167. }
  168. };
  169. }, [token]);
  170. useEffect(() => {
  171. const handleAppStateChange = async (nextAppState: AppStateStatus) => {
  172. if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
  173. if (!socket.current || socket.current.readyState === WebSocket.CLOSED) {
  174. socket.current = new WebSocket(WEBSOCKET_URL);
  175. socket.current.onopen = () => {
  176. socket.current?.send(JSON.stringify({ token }));
  177. };
  178. socket.current.onmessage = (event) => {
  179. const data = JSON.parse(event.data);
  180. handleWebSocketMessage(data);
  181. };
  182. }
  183. await dismissChatNotifications(id, isSubscribed, setModalInfo, navigation);
  184. }
  185. };
  186. const subscription = AppState.addEventListener('change', handleAppStateChange);
  187. return () => {
  188. subscription.remove();
  189. if (socket.current) {
  190. socket.current.close();
  191. socket.current = null;
  192. }
  193. };
  194. }, [token]);
  195. const handleWebSocketMessage = (data: any) => {
  196. switch (data.action) {
  197. case 'new_message':
  198. if (data.conversation_with === id && data.message) {
  199. const newMessage = mapApiMessageToGiftedMessage(data.message);
  200. setMessages((previousMessages) => {
  201. const messageExists =
  202. previousMessages && previousMessages.some((msg) => msg._id === newMessage._id);
  203. if (!messageExists) {
  204. return GiftedChat.append(previousMessages ?? [], [newMessage]);
  205. }
  206. return previousMessages;
  207. });
  208. }
  209. break;
  210. case 'new_reaction':
  211. if (data.conversation_with === id && data.reaction) {
  212. updateMessageWithReaction(data.reaction);
  213. }
  214. break;
  215. case 'unreact':
  216. if (data.conversation_with === id && data.unreacted_message_id) {
  217. removeReactionFromMessage(data.unreacted_message_id);
  218. }
  219. break;
  220. case 'delete_message':
  221. if (data.conversation_with === id && data.deleted_message_id) {
  222. removeDeletedMessage(data.deleted_message_id);
  223. }
  224. break;
  225. case 'is_typing':
  226. if (data.conversation_with === id) {
  227. setIsTyping(true);
  228. }
  229. break;
  230. case 'stopped_typing':
  231. if (data.conversation_with === id) {
  232. setIsTyping(false);
  233. }
  234. break;
  235. case 'messages_read':
  236. if (data.conversation_with === id && data.read_messages_ids) {
  237. setMessages(
  238. (prevMessages) =>
  239. prevMessages?.map((msg) => {
  240. if (data.read_messages_ids.includes(msg._id)) {
  241. return { ...msg, received: true };
  242. }
  243. return msg;
  244. }) ?? []
  245. );
  246. }
  247. break;
  248. default:
  249. break;
  250. }
  251. };
  252. const updateMessageWithReaction = (reactionData: any) => {
  253. setMessages(
  254. (prevMessages) =>
  255. prevMessages?.map((msg) => {
  256. if (msg._id === reactionData.message_id) {
  257. const updatedReactions = [
  258. ...(Array.isArray(msg.reactions)
  259. ? msg.reactions?.filter((r: any) => r.uid !== reactionData.uid)
  260. : []),
  261. reactionData
  262. ];
  263. return { ...msg, reactions: updatedReactions };
  264. }
  265. return msg;
  266. }) ?? []
  267. );
  268. };
  269. const removeReactionFromMessage = (messageId: number) => {
  270. setMessages(
  271. (prevMessages) =>
  272. prevMessages?.map((msg) => {
  273. if (msg._id === messageId) {
  274. const updatedReactions = Array.isArray(msg.reactions)
  275. ? msg.reactions?.filter((r: any) => r.uid !== id)
  276. : [];
  277. return { ...msg, reactions: updatedReactions };
  278. }
  279. return msg;
  280. }) ?? []
  281. );
  282. };
  283. const removeDeletedMessage = (messageId: number) => {
  284. setMessages(
  285. (prevMessages) =>
  286. prevMessages?.map((msg) => {
  287. if (msg._id === messageId) {
  288. return {
  289. ...msg,
  290. deleted: true,
  291. text: 'This message was deleted',
  292. pending: false,
  293. sent: false,
  294. received: false
  295. };
  296. }
  297. return msg;
  298. }) ?? []
  299. );
  300. };
  301. useEffect(() => {
  302. const pingInterval = setInterval(() => {
  303. if (socket.current && socket.current.readyState === WebSocket.OPEN) {
  304. socket.current.send(JSON.stringify({ action: 'ping', conversation_with: id }));
  305. } else {
  306. socket.current = new WebSocket(WEBSOCKET_URL);
  307. socket.current.onopen = () => {
  308. socket.current?.send(JSON.stringify({ token }));
  309. };
  310. socket.current.onmessage = (event) => {
  311. const data = JSON.parse(event.data);
  312. handleWebSocketMessage(data);
  313. };
  314. return () => {
  315. if (socket.current) {
  316. socket.current.close();
  317. socket.current = null;
  318. }
  319. };
  320. }
  321. }, 50000);
  322. return () => clearInterval(pingInterval);
  323. }, []);
  324. const sendWebSocketMessage = (
  325. action: string,
  326. message: CustomMessage | null = null,
  327. reaction: string | null = null,
  328. readMessagesIds: number[] | null = null
  329. ) => {
  330. if (socket.current && socket.current.readyState === WebSocket.OPEN) {
  331. const data: any = {
  332. action,
  333. conversation_with: id
  334. };
  335. if (action === 'new_message' && message) {
  336. data.message = {
  337. id: message._id,
  338. text: message.text,
  339. sender: +currentUserId,
  340. sent_datetime: new Date().toISOString().replace('T', ' ').substring(0, 19),
  341. reply_to_id: message.replyMessage?.id ?? -1,
  342. reply_to: message.replyMessage ?? null,
  343. reactions: message.reactions ?? '{}',
  344. status: 2,
  345. attachement: -1
  346. };
  347. }
  348. if (action === 'new_reaction' && message && reaction) {
  349. data.reaction = {
  350. message_id: message._id,
  351. reaction,
  352. uid: +currentUserId,
  353. datetime: new Date().toISOString()
  354. };
  355. }
  356. if (action === 'unreact' && message) {
  357. data.message_id = message._id;
  358. }
  359. if (action === 'delete_message' && message) {
  360. data.message_id = message._id;
  361. }
  362. if (action === 'messages_read' && readMessagesIds) {
  363. data.messages_ids = readMessagesIds;
  364. }
  365. socket.current.send(JSON.stringify(data));
  366. }
  367. };
  368. const handleTyping = (isTyping: boolean) => {
  369. if (isTyping) {
  370. sendWebSocketMessage('is_typing');
  371. } else {
  372. sendWebSocketMessage('stopped_typing');
  373. }
  374. };
  375. const mapApiMessageToGiftedMessage = (message: Message): CustomMessage => {
  376. return {
  377. _id: message.id,
  378. text: message.text,
  379. createdAt: new Date(message.sent_datetime + 'Z'),
  380. user: {
  381. _id: message.sender,
  382. name: message.sender === id ? userName : 'Me'
  383. },
  384. replyMessage:
  385. message.reply_to_id !== -1
  386. ? {
  387. text: message.reply_to.text,
  388. id: message.reply_to.id,
  389. name: message.reply_to.sender === id ? userName : 'Me'
  390. }
  391. : null,
  392. reactions: JSON.parse(message.reactions || '{}'),
  393. attachment: message.attachement !== -1 ? message.attachement : null,
  394. pending: message.status === 1,
  395. sent: message.status === 2,
  396. received: message.status === 3,
  397. deleted: message.status === 4
  398. };
  399. };
  400. useFocusEffect(
  401. useCallback(() => {
  402. refetch();
  403. }, [])
  404. );
  405. useFocusEffect(
  406. useCallback(() => {
  407. if (chatData?.messages) {
  408. const mappedMessages = chatData.messages.map(mapApiMessageToGiftedMessage);
  409. if (unreadMessageIndex === null && !isFetching) {
  410. const firstUnreadIndex = mappedMessages.findLastIndex(
  411. (msg) => !msg.received && !msg?.deleted && msg.user._id === id
  412. );
  413. if (firstUnreadIndex !== -1) {
  414. setUnreadMessageIndex(firstUnreadIndex);
  415. const unreadMarker: any = {
  416. _id: 'unreadMarker',
  417. text: 'Unread messages',
  418. system: true
  419. };
  420. mappedMessages.splice(firstUnreadIndex + 1, 0, unreadMarker);
  421. setTimeout(() => {
  422. if (flatList.current) {
  423. flatList.current.scrollToIndex({
  424. index: firstUnreadIndex,
  425. animated: true,
  426. viewPosition: 0.5
  427. });
  428. }
  429. }, 500);
  430. } else {
  431. setUnreadMessageIndex(0);
  432. }
  433. }
  434. setMessages((previousMessages) => {
  435. const newMessages = mappedMessages.filter(
  436. (newMsg) => !previousMessages?.some((oldMsg) => oldMsg._id === newMsg._id)
  437. );
  438. return prevThenMessageId !== -1 && previousMessages
  439. ? GiftedChat.prepend(previousMessages, newMessages)
  440. : mappedMessages;
  441. });
  442. if (mappedMessages.length < 50) {
  443. setHasMoreMessages(false);
  444. }
  445. if (mappedMessages.length === 0 && !modalInfo.visible) {
  446. setTimeout(() => {
  447. textInputRef.current?.focus();
  448. }, 500);
  449. }
  450. setIsLoadingEarlier(false);
  451. }
  452. }, [chatData])
  453. );
  454. useEffect(() => {
  455. if (messages?.length === 0 && !modalInfo.visible) {
  456. setTimeout(() => {
  457. textInputRef.current?.focus();
  458. }, 500);
  459. }
  460. }, [modalInfo]);
  461. const loadEarlierMessages = async () => {
  462. if (!hasMoreMessages || isLoadingEarlier || !messages) return;
  463. setIsLoadingEarlier(true);
  464. const previousMessageId = messages[messages.length - 1]._id;
  465. setPrevThenMessageId(previousMessageId);
  466. };
  467. const sentToServer = useRef<Set<number>>(new Set());
  468. const handleViewableItemsChanged = ({ viewableItems }: { viewableItems: any[] }) => {
  469. const newViewableUnreadMessages = viewableItems
  470. .filter(
  471. (item) =>
  472. !item.item.received &&
  473. !item.item.deleted &&
  474. !item.item.system &&
  475. item.item.user._id === id &&
  476. !sentToServer.current.has(item.item._id)
  477. )
  478. .map((item) => item.item._id);
  479. if (newViewableUnreadMessages.length > 0) {
  480. markMessagesAsRead(
  481. {
  482. token,
  483. from_user: id,
  484. messages_id: newViewableUnreadMessages
  485. },
  486. {
  487. onSuccess: () => {
  488. newViewableUnreadMessages.forEach((id) => sentToServer.current.add(id));
  489. sendWebSocketMessage('messages_read', null, null, newViewableUnreadMessages);
  490. }
  491. }
  492. );
  493. }
  494. };
  495. const renderSystemMessage = (props: any) => {
  496. if (props.currentMessage._id === 'unreadMarker') {
  497. return (
  498. <View style={styles.unreadMessagesContainer}>
  499. <Text style={styles.unreadMessagesText}>{props.currentMessage.text}</Text>
  500. </View>
  501. );
  502. }
  503. return null;
  504. };
  505. const clearReplyMessage = () => setReplyMessage(null);
  506. const handleLongPress = (message: CustomMessage, props: BubbleProps<CustomMessage>) => {
  507. const messageRef = messageRefs.current[message._id];
  508. setSelectedMessage(props);
  509. trigger('impactMedium', options);
  510. const isMine = message.user._id === +currentUserId;
  511. if (messageRef) {
  512. messageRef.measureInWindow((x: number, y: number, width: number, height: number) => {
  513. const screenHeight = Dimensions.get('window').height;
  514. const spaceAbove = y - insets.top;
  515. const spaceBelow = screenHeight - (y + height) - insets.bottom * 2;
  516. let finalY = y;
  517. scrollY.value = 0;
  518. if (isNaN(y) || isNaN(height)) {
  519. console.error("Invalid measurement values for 'y' or 'height'", { y, height });
  520. return;
  521. }
  522. if (spaceBelow < 160) {
  523. const extraShift = 160 - spaceBelow;
  524. finalY -= extraShift;
  525. }
  526. if (spaceAbove < 50) {
  527. const extraShift = 50 - spaceAbove;
  528. finalY += extraShift;
  529. }
  530. if (spaceBelow < 160 || spaceAbove < 50) {
  531. const targetY = screenHeight / 2 - height / 2;
  532. scrollY.value = withTiming(finalY - finalY);
  533. }
  534. if (height > Dimensions.get('window').height - 200) {
  535. finalY = 100;
  536. }
  537. finalY = isNaN(finalY) ? 0 : finalY;
  538. setMessagePosition({ x, y: finalY, width, height, isMine });
  539. setIsModalVisible(true);
  540. });
  541. }
  542. };
  543. const openEmojiSelector = () => {
  544. SheetManager.show('emoji-selector');
  545. trigger('impactLight', options);
  546. };
  547. const closeEmojiSelector = () => {
  548. SheetManager.hide('emoji-selector');
  549. };
  550. const handleReactionPress = (emoji: string, messageId: number) => {
  551. addReaction(messageId, emoji);
  552. };
  553. const handleDeleteMessage = (messageId: number) => {
  554. deleteMessage(
  555. {
  556. token,
  557. message_id: messageId,
  558. conversation_with_user: id
  559. },
  560. {
  561. onSuccess: () => {
  562. setMessages(
  563. (prevMessages) =>
  564. prevMessages?.map((msg) => {
  565. if (msg._id === messageId) {
  566. return {
  567. ...msg,
  568. deleted: true,
  569. text: 'This message was deleted',
  570. pending: false,
  571. sent: false,
  572. received: false
  573. };
  574. }
  575. return msg;
  576. }) ?? []
  577. );
  578. const messageToDelete = messages?.find((msg) => msg._id === messageId);
  579. if (messageToDelete) {
  580. sendWebSocketMessage('delete_message', messageToDelete, null, null);
  581. }
  582. }
  583. }
  584. );
  585. };
  586. const handleOptionPress = (option: string) => {
  587. if (!selectedMessage) return;
  588. switch (option) {
  589. case 'reply':
  590. setReplyMessage(selectedMessage.currentMessage);
  591. setIsModalVisible(false);
  592. break;
  593. case 'copy':
  594. Clipboard.setString(selectedMessage?.currentMessage?.text ?? '');
  595. setIsModalVisible(false);
  596. Alert.alert('Copied');
  597. break;
  598. case 'delete':
  599. handleDeleteMessage(selectedMessage.currentMessage?._id);
  600. setIsModalVisible(false);
  601. break;
  602. default:
  603. break;
  604. }
  605. closeEmojiSelector();
  606. };
  607. const openReactionList = (
  608. reactions: { uid: number; name: string; reaction: string }[],
  609. messageId: number
  610. ) => {
  611. SheetManager.show('reactions-list-modal', {
  612. payload: {
  613. users: reactions,
  614. currentUserId: +currentUserId,
  615. token,
  616. messageId,
  617. conversation_with_user: id,
  618. setMessages,
  619. sendWebSocketMessage
  620. } as any
  621. });
  622. };
  623. const renderTimeContainer = (time: TimeProps<CustomMessage>) => {
  624. const createdAt = new Date(time.currentMessage.createdAt);
  625. const formattedTime = createdAt.toLocaleTimeString([], {
  626. hour: '2-digit',
  627. minute: '2-digit',
  628. hour12: true
  629. });
  630. const hasReactions =
  631. time.currentMessage.reactions &&
  632. Array.isArray(time.currentMessage.reactions) &&
  633. time.currentMessage.reactions.length > 0;
  634. return (
  635. <View
  636. style={[
  637. styles.bottomContainer,
  638. {
  639. justifyContent: hasReactions ? 'space-between' : 'flex-end'
  640. }
  641. ]}
  642. >
  643. {hasReactions && (
  644. <TouchableOpacity
  645. style={[
  646. styles.bottomCustomContainer,
  647. {
  648. backgroundColor:
  649. time.position === 'left' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'
  650. }
  651. ]}
  652. onPress={() =>
  653. Array.isArray(time.currentMessage.reactions) &&
  654. openReactionList(
  655. time.currentMessage.reactions.map((reaction) => ({
  656. ...reaction,
  657. name: reaction.uid === id ? userName : 'Me'
  658. })),
  659. time.currentMessage._id
  660. )
  661. }
  662. >
  663. {Object.entries(
  664. (Array.isArray(time.currentMessage.reactions)
  665. ? time.currentMessage.reactions
  666. : []
  667. ).reduce(
  668. (acc: Record<string, { count: number }>, { reaction }: { reaction: string }) => {
  669. if (!acc[reaction]) {
  670. acc[reaction] = { count: 0 };
  671. }
  672. acc[reaction].count += 1;
  673. return acc;
  674. },
  675. {}
  676. )
  677. ).map(([emoji, { count }]: any) => {
  678. return (
  679. <View key={emoji}>
  680. <Text style={{}}>
  681. {emoji}
  682. {(count as number) > 1 ? ` ${count}` : ''}
  683. </Text>
  684. </View>
  685. );
  686. })}
  687. </TouchableOpacity>
  688. )}
  689. <View style={styles.timeContainer}>
  690. <Text style={styles.timeText}>{formattedTime}</Text>
  691. {renderTicks(time.currentMessage)}
  692. </View>
  693. </View>
  694. );
  695. };
  696. const renderSelectedMessage = () =>
  697. selectedMessage && (
  698. <View
  699. style={{
  700. maxHeight: '80%',
  701. width: messagePosition?.width,
  702. position: 'absolute',
  703. top: messagePosition?.y,
  704. left: messagePosition?.x
  705. }}
  706. >
  707. <ScrollView>
  708. <Bubble
  709. {...selectedMessage}
  710. wrapperStyle={{
  711. right: { backgroundColor: Colors.DARK_BLUE },
  712. left: { backgroundColor: Colors.FILL_LIGHT }
  713. }}
  714. textStyle={{
  715. right: { color: Colors.WHITE },
  716. left: { color: Colors.DARK_BLUE }
  717. }}
  718. renderTicks={() => null}
  719. renderTime={renderTimeContainer}
  720. />
  721. </ScrollView>
  722. </View>
  723. );
  724. const handleBackgroundPress = () => {
  725. setIsModalVisible(false);
  726. setSelectedMessage(null);
  727. closeEmojiSelector();
  728. };
  729. useFocusEffect(
  730. useCallback(() => {
  731. navigation?.getParent()?.setOptions({
  732. tabBarStyle: {
  733. display: 'none'
  734. }
  735. });
  736. }, [navigation])
  737. );
  738. const onSend = useCallback(
  739. (newMessages: CustomMessage[] = []) => {
  740. if (replyMessage) {
  741. newMessages[0].replyMessage = {
  742. text: replyMessage.text,
  743. id: replyMessage._id,
  744. name: replyMessage.user._id === id ? userName : 'Me'
  745. };
  746. }
  747. const message = { ...newMessages[0], pending: true };
  748. setMessages((previousMessages) => GiftedChat.append(previousMessages ?? [], [message]));
  749. sendMessage(
  750. {
  751. token,
  752. to_uid: id,
  753. text: message.text,
  754. reply_to_id: replyMessage ? (replyMessage._id as number) : -1
  755. },
  756. {
  757. onSuccess: (res) => {
  758. const newMessage = {
  759. _id: res.message_id,
  760. text: message.text,
  761. replyMessage: { ...message.replyMessage, sender: replyMessage?.user?._id }
  762. };
  763. setMessages((previousMessages) =>
  764. (previousMessages ?? []).map((msg) =>
  765. msg._id === message._id ? { ...msg, _id: res.message_id } : msg
  766. )
  767. );
  768. sendWebSocketMessage('new_message', newMessage as unknown as CustomMessage);
  769. },
  770. onError: (err) => console.log('err', err)
  771. }
  772. );
  773. clearReplyMessage();
  774. },
  775. [replyMessage]
  776. );
  777. const addReaction = (messageId: number, reaction: string) => {
  778. if (!messages) return;
  779. const updatedMessages = messages.map((msg: any) => {
  780. if (msg._id === messageId) {
  781. const updatedReactions: Reaction[] = [
  782. ...(Array.isArray(msg.reactions)
  783. ? msg.reactions?.filter((r: Reaction) => r.uid !== +currentUserId)
  784. : []),
  785. { datetime: new Date().toISOString(), reaction: reaction, uid: +currentUserId }
  786. ];
  787. return {
  788. ...msg,
  789. reactions: updatedReactions
  790. };
  791. }
  792. return msg;
  793. });
  794. setMessages(updatedMessages);
  795. reactToMessage(
  796. { token, message_id: messageId, reaction: reaction, conversation_with_user: id },
  797. {
  798. onSuccess: () => {
  799. const message = messages.find((msg) => msg._id === messageId);
  800. if (message) {
  801. sendWebSocketMessage('new_reaction', message, reaction);
  802. }
  803. },
  804. onError: (err) => console.log('err', err)
  805. }
  806. );
  807. setIsModalVisible(false);
  808. };
  809. const updateRowRef = useCallback(
  810. (ref: any) => {
  811. if (
  812. ref &&
  813. replyMessage &&
  814. ref.props.children.props.currentMessage?._id === replyMessage._id
  815. ) {
  816. swipeableRowRef.current = ref;
  817. }
  818. },
  819. [replyMessage]
  820. );
  821. const renderReplyMessageView = (props: BubbleProps<CustomMessage>) => {
  822. if (!props.currentMessage) {
  823. return null;
  824. }
  825. const { currentMessage } = props;
  826. if (!currentMessage || !currentMessage?.replyMessage) {
  827. return null;
  828. }
  829. return (
  830. <TouchableOpacity
  831. style={[
  832. styles.replyMessageContainer,
  833. {
  834. backgroundColor:
  835. currentMessage.user._id === id ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.2)',
  836. borderColor: currentMessage.user._id === id ? Colors.DARK_BLUE : Colors.WHITE
  837. }
  838. ]}
  839. onPress={() => {
  840. if (currentMessage?.replyMessage?.id) {
  841. scrollToMessage(currentMessage.replyMessage.id);
  842. }
  843. }}
  844. >
  845. <View style={styles.replyContent}>
  846. <Text
  847. style={[
  848. styles.replyAuthorName,
  849. { color: currentMessage.user._id === id ? Colors.DARK_BLUE : Colors.WHITE }
  850. ]}
  851. >
  852. {currentMessage.replyMessage.name}
  853. </Text>
  854. <Text
  855. numberOfLines={1}
  856. style={[
  857. styles.replyMessageText,
  858. { color: currentMessage.user._id === id ? Colors.DARK_BLUE : Colors.WHITE }
  859. ]}
  860. >
  861. {currentMessage.replyMessage.text}
  862. </Text>
  863. </View>
  864. </TouchableOpacity>
  865. );
  866. };
  867. const scrollToMessage = (messageId: number) => {
  868. if (!messages) return;
  869. const messageIndex = messages.findIndex((message) => message._id === messageId);
  870. if (messageIndex !== -1 && flatList.current) {
  871. flatList.current.scrollToIndex({
  872. index: messageIndex,
  873. animated: true,
  874. viewPosition: 0.5
  875. });
  876. setHighlightedMessageId(messageId);
  877. }
  878. };
  879. useEffect(() => {
  880. if (highlightedMessageId && isRerendering) {
  881. setTimeout(() => {
  882. setHighlightedMessageId(null);
  883. setIsRerendering(false);
  884. }, 1500);
  885. }
  886. }, [highlightedMessageId, isRerendering]);
  887. useEffect(() => {
  888. if (replyMessage && swipeableRowRef.current) {
  889. swipeableRowRef.current.close();
  890. swipeableRowRef.current = null;
  891. }
  892. }, [replyMessage]);
  893. const renderMessageImage = (props: any) => {
  894. const { currentMessage } = props;
  895. return (
  896. <TouchableOpacity
  897. onPress={() => setSelectedMedia(currentMessage.image)}
  898. style={styles.imageContainer}
  899. >
  900. <Image source={{ uri: currentMessage.image }} style={styles.chatImage} resizeMode="cover" />
  901. </TouchableOpacity>
  902. );
  903. };
  904. const renderTicks = (message: CustomMessage) => {
  905. if (message.user._id === id) return null;
  906. return message.received ? (
  907. <View>
  908. <MaterialCommunityIcons name="check-all" size={16} color={Colors.WHITE} />
  909. </View>
  910. ) : message.sent ? (
  911. <View>
  912. <MaterialCommunityIcons name="check" size={16} color={Colors.WHITE} />
  913. </View>
  914. ) : message.pending ? (
  915. <View>
  916. <MaterialCommunityIcons name="check" size={16} color={Colors.LIGHT_GRAY} />
  917. </View>
  918. ) : null;
  919. };
  920. const renderBubble = (props: BubbleProps<CustomMessage>) => {
  921. const { currentMessage } = props;
  922. if (currentMessage.deleted) {
  923. const text = currentMessage.text.length
  924. ? props.currentMessage.text
  925. : 'This message was deleted';
  926. return (
  927. <View>
  928. <Bubble
  929. {...props}
  930. renderTime={() => null}
  931. currentMessage={{
  932. ...props.currentMessage,
  933. text: text
  934. }}
  935. renderMessageText={() => (
  936. <View style={{ paddingHorizontal: 12, paddingVertical: 6 }}>
  937. <Text style={{ color: Colors.LIGHT_GRAY, fontStyle: 'italic', fontSize: 12 }}>
  938. {text}
  939. </Text>
  940. </View>
  941. )}
  942. wrapperStyle={{
  943. right: {
  944. backgroundColor: Colors.DARK_BLUE
  945. },
  946. left: {
  947. backgroundColor: Colors.FILL_LIGHT
  948. }
  949. }}
  950. textStyle={{
  951. left: {
  952. color: Colors.DARK_BLUE
  953. },
  954. right: {
  955. color: Colors.WHITE
  956. }
  957. }}
  958. />
  959. </View>
  960. );
  961. }
  962. const isHighlighted = currentMessage._id === highlightedMessageId;
  963. const backgroundColor = isHighlighted
  964. ? Colors.ORANGE
  965. : currentMessage.user._id === +currentUserId
  966. ? Colors.DARK_BLUE
  967. : Colors.FILL_LIGHT;
  968. return (
  969. <View
  970. key={`${currentMessage._id}-${isHighlighted ? 'highlighted' : 'normal'}`}
  971. ref={(ref) => {
  972. if (ref && currentMessage) {
  973. messageRefs.current[currentMessage._id] = ref;
  974. }
  975. }}
  976. collapsable={false}
  977. >
  978. <Bubble
  979. {...props}
  980. wrapperStyle={{
  981. right: {
  982. backgroundColor: backgroundColor
  983. },
  984. left: {
  985. backgroundColor: backgroundColor
  986. }
  987. }}
  988. textStyle={{
  989. left: {
  990. color: Colors.DARK_BLUE
  991. },
  992. right: {
  993. color: Colors.FILL_LIGHT
  994. }
  995. }}
  996. onLongPress={() => handleLongPress(currentMessage, props)}
  997. renderTicks={() => null}
  998. renderTime={renderTimeContainer}
  999. />
  1000. </View>
  1001. );
  1002. };
  1003. const renderInputToolbar = (props: any) => (
  1004. <InputToolbar
  1005. {...props}
  1006. renderActions={() =>
  1007. // <Actions
  1008. // icon={() => <MaterialCommunityIcons name="plus" size={28} color={Colors.DARK_BLUE} />}
  1009. // // onPressActionButton={openActionSheet}
  1010. // />
  1011. null
  1012. }
  1013. containerStyle={{
  1014. backgroundColor: Colors.FILL_LIGHT
  1015. }}
  1016. />
  1017. );
  1018. const renderScrollToBottom = () => {
  1019. return (
  1020. <TouchableOpacity
  1021. style={styles.scrollToBottom}
  1022. onPress={() => {
  1023. if (flatList.current) {
  1024. flatList.current.scrollToIndex({ index: 0, animated: true });
  1025. }
  1026. }}
  1027. >
  1028. <MaterialCommunityIcons name="chevron-down" size={24} color={Colors.WHITE} />
  1029. </TouchableOpacity>
  1030. );
  1031. };
  1032. const shouldUpdateMessage = (
  1033. props: MessageProps<IMessage>,
  1034. nextProps: MessageProps<IMessage>
  1035. ) => {
  1036. setIsRerendering(true);
  1037. const currentId = nextProps.currentMessage._id;
  1038. return currentId === highlightedMessageId;
  1039. };
  1040. return (
  1041. <SafeAreaView
  1042. edges={['top']}
  1043. style={{
  1044. height: '100%'
  1045. }}
  1046. >
  1047. <View style={{ paddingHorizontal: '5%' }}>
  1048. <Header
  1049. label={userName}
  1050. rightElement={
  1051. <TouchableOpacity
  1052. onPress={() =>
  1053. navigation.navigate(
  1054. ...([NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: id }] as never)
  1055. )
  1056. }
  1057. disabled={userType !== 'normal'}
  1058. >
  1059. {avatar && userType === 'normal' ? (
  1060. <Image source={{ uri: API_HOST + avatar }} style={styles.avatar} />
  1061. ) : userType === 'normal' ? (
  1062. <AvatarWithInitials
  1063. text={
  1064. name
  1065. .split(/ (.+)/)
  1066. .map((n) => n[0])
  1067. .join('') ?? ''
  1068. }
  1069. flag={API_HOST + 'flag.png'}
  1070. size={30}
  1071. fontSize={12}
  1072. />
  1073. ) : (
  1074. <BanIcon fill={Colors.RED} width={30} height={30} />
  1075. )}
  1076. </TouchableOpacity>
  1077. }
  1078. />
  1079. </View>
  1080. <GestureHandlerRootView style={styles.container}>
  1081. {messages ? (
  1082. <GiftedChat
  1083. messages={messages as CustomMessage[]}
  1084. listViewProps={{
  1085. ref: flatList,
  1086. showsVerticalScrollIndicator: false,
  1087. initialNumToRender: 30,
  1088. onViewableItemsChanged: handleViewableItemsChanged,
  1089. viewabilityConfig: { itemVisiblePercentThreshold: 50 },
  1090. onScrollToIndexFailed: (info: any) => {
  1091. const wait = new Promise((resolve) => setTimeout(resolve, 300));
  1092. wait.then(() => {
  1093. flatList.current?.scrollToIndex({
  1094. index: info.index,
  1095. animated: true,
  1096. viewPosition: 0.5
  1097. });
  1098. });
  1099. }
  1100. }}
  1101. renderSystemMessage={renderSystemMessage}
  1102. onSend={(newMessages: CustomMessage[]) => onSend(newMessages)}
  1103. user={{ _id: +currentUserId, name: 'Me' }}
  1104. renderBubble={renderBubble}
  1105. renderMessageImage={renderMessageImage}
  1106. renderInputToolbar={renderInputToolbar}
  1107. renderCustomView={renderReplyMessageView}
  1108. isCustomViewBottom={false}
  1109. messageContainerRef={messageContainerRef}
  1110. minComposerHeight={34}
  1111. onInputTextChanged={(text) => handleTyping(text.length > 0)}
  1112. textInputRef={textInputRef}
  1113. isTyping={isTyping}
  1114. renderSend={(props) => (
  1115. <View style={styles.sendBtn}>
  1116. {props.text?.trim() && (
  1117. <Send
  1118. {...props}
  1119. containerStyle={{
  1120. justifyContent: 'center'
  1121. }}
  1122. >
  1123. <SendIcon fill={Colors.DARK_BLUE} />
  1124. </Send>
  1125. )}
  1126. {!props.text?.trim() && <SendIcon fill={Colors.LIGHT_GRAY} />}
  1127. </View>
  1128. )}
  1129. textInputProps={{ ...styles.composer, selectionColor: Colors.LIGHT_GRAY }}
  1130. placeholder=""
  1131. renderMessage={(props) => (
  1132. <ChatMessageBox
  1133. {...(props as MessageProps<CustomMessage>)}
  1134. updateRowRef={updateRowRef}
  1135. setReplyOnSwipeOpen={setReplyMessage}
  1136. />
  1137. )}
  1138. renderChatFooter={() => (
  1139. <ReplyMessageBar clearReply={clearReplyMessage} message={replyMessage} />
  1140. )}
  1141. renderAvatar={null}
  1142. maxComposerHeight={100}
  1143. renderComposer={(props) => <Composer {...props} />}
  1144. keyboardShouldPersistTaps="handled"
  1145. renderChatEmpty={() => (
  1146. <View style={styles.emptyChat}>
  1147. <Text
  1148. style={styles.emptyChatText}
  1149. >{`No messages yet.\nFeel free to start the conversation.`}</Text>
  1150. </View>
  1151. )}
  1152. shouldUpdateMessage={shouldUpdateMessage}
  1153. scrollToBottom={true}
  1154. scrollToBottomComponent={renderScrollToBottom}
  1155. scrollToBottomStyle={{ backgroundColor: 'transparent' }}
  1156. parsePatterns={(linkStyle) => [
  1157. {
  1158. type: 'url',
  1159. style: { color: Colors.ORANGE, textDecorationLine: 'underline' },
  1160. onPress: (url: string) => Linking.openURL(url),
  1161. onLongPress: (url: string) => {
  1162. Clipboard.setString(url ?? '');
  1163. Alert.alert('Link copied');
  1164. }
  1165. }
  1166. ]}
  1167. infiniteScroll={true}
  1168. loadEarlier={hasMoreMessages}
  1169. isLoadingEarlier={isLoadingEarlier}
  1170. onLoadEarlier={loadEarlierMessages}
  1171. renderLoadEarlier={() => (
  1172. <View style={{ paddingVertical: 20 }}>
  1173. <ActivityIndicator size="small" color={Colors.DARK_BLUE} />
  1174. </View>
  1175. )}
  1176. />
  1177. ) : (
  1178. <ActivityIndicator size="large" color={Colors.DARK_BLUE} />
  1179. )}
  1180. <Modal visible={!!selectedMedia} transparent={true}>
  1181. <View style={styles.modalContainer}>
  1182. {selectedMedia && selectedMedia?.includes('.mp4') ? (
  1183. <Video
  1184. source={{ uri: selectedMedia }}
  1185. style={styles.fullScreenMedia}
  1186. useNativeControls
  1187. />
  1188. ) : (
  1189. <Image source={{ uri: selectedMedia ?? '' }} style={styles.fullScreenMedia} />
  1190. )}
  1191. <TouchableOpacity onPress={() => setSelectedMedia(null)} style={styles.closeButton}>
  1192. <MaterialCommunityIcons name="close" size={30} color="white" />
  1193. </TouchableOpacity>
  1194. </View>
  1195. </Modal>
  1196. <ReactModal
  1197. isVisible={isModalVisible}
  1198. onBackdropPress={handleBackgroundPress}
  1199. style={styles.reactModalContainer}
  1200. animationIn="fadeIn"
  1201. animationOut="fadeOut"
  1202. useNativeDriver
  1203. backdropColor="transparent"
  1204. >
  1205. <BlurView
  1206. intensity={80}
  1207. style={styles.modalBackground}
  1208. experimentalBlurMethod="dimezisBlurView"
  1209. >
  1210. <TouchableOpacity
  1211. style={styles.modalBackground}
  1212. activeOpacity={1}
  1213. onPress={handleBackgroundPress}
  1214. >
  1215. <ReactionBar
  1216. messagePosition={messagePosition}
  1217. selectedMessage={selectedMessage}
  1218. reactionEmojis={reactionEmojis}
  1219. handleReactionPress={handleReactionPress}
  1220. openEmojiSelector={openEmojiSelector}
  1221. />
  1222. {renderSelectedMessage()}
  1223. <OptionsMenu
  1224. selectedMessage={selectedMessage}
  1225. handleOptionPress={handleOptionPress}
  1226. messagePosition={messagePosition}
  1227. />
  1228. <EmojiSelectorModal
  1229. visible={emojiSelectorVisible}
  1230. selectedMessage={selectedMessage}
  1231. addReaction={addReaction}
  1232. closeEmojiSelector={closeEmojiSelector}
  1233. />
  1234. </TouchableOpacity>
  1235. </BlurView>
  1236. </ReactModal>
  1237. <WarningModal
  1238. isVisible={modalInfo.visible}
  1239. onClose={closeModal}
  1240. type={modalInfo.type}
  1241. message={modalInfo.message}
  1242. action={() => {
  1243. modalInfo.action();
  1244. closeModal();
  1245. }}
  1246. />
  1247. <ReactionsListModal />
  1248. </GestureHandlerRootView>
  1249. <View
  1250. style={{
  1251. height: insets.bottom,
  1252. backgroundColor: Colors.FILL_LIGHT
  1253. }}
  1254. />
  1255. </SafeAreaView>
  1256. );
  1257. };
  1258. export default ChatScreen;