index.tsx 38 KB

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