index.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. import React, { useState, useEffect, useRef, useCallback } from 'react';
  2. import {
  3. View,
  4. Text,
  5. TouchableOpacity,
  6. Image,
  7. Platform,
  8. TouchableHighlight,
  9. AppState,
  10. AppStateStatus
  11. } from 'react-native';
  12. import { AvatarWithInitials, HorizontalTabView, Input, WarningModal } from 'src/components';
  13. import { NAVIGATION_PAGES } from 'src/types';
  14. import { useFocusEffect, useNavigation } from '@react-navigation/native';
  15. import AddChatIcon from 'assets/icons/messages/chat-plus.svg';
  16. import { API_HOST, WEBSOCKET_URL } from 'src/constants';
  17. import { Colors } from 'src/theme';
  18. import SwipeableRow from './Components/SwipeableRow';
  19. import { FlashList } from '@shopify/flash-list';
  20. import ReadIcon from 'assets/icons/messages/check-read.svg';
  21. import UnreadIcon from 'assets/icons/messages/check-unread.svg';
  22. import SearchModal from './Components/SearchUsersModal';
  23. import { SheetManager } from 'react-native-actions-sheet';
  24. import MoreModal from './Components/MoreModal';
  25. import SearchIcon from 'assets/icons/search.svg';
  26. import { storage, StoreType } from 'src/storage';
  27. import { usePostGetBlockedQuery, usePostGetChatsListQuery } from '@api/chat';
  28. import { Blocked, Chat } from './types';
  29. import PinIcon from 'assets/icons/messages/pin.svg';
  30. import { formatDate } from './utils';
  31. import { routes } from './constants';
  32. import { styles } from './styles';
  33. import { useChatStore } from 'src/stores/chatStore';
  34. import BellSlashIcon from 'assets/icons/messages/bell-slash.svg';
  35. import BanIcon from 'assets/icons/messages/ban.svg';
  36. import SwipeableBlockedRow from './Components/SwipeableBlockedRow';
  37. import { useMessagesStore } from 'src/stores/unreadMessagesStore';
  38. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  39. import GroupIcon from 'assets/icons/messages/group-chat.svg';
  40. const TypingIndicator = ({ name }: { name?: string }) => {
  41. const [dots, setDots] = useState('');
  42. useEffect(() => {
  43. const interval = setInterval(() => {
  44. setDots((prevDots) => {
  45. if (prevDots.length >= 3) {
  46. return '';
  47. }
  48. return prevDots + '.';
  49. });
  50. }, 500);
  51. return () => clearInterval(interval);
  52. }, []);
  53. return name ? (
  54. <Text style={styles.typingText}>
  55. {name} is typing{dots}
  56. </Text>
  57. ) : (
  58. <Text style={styles.typingText}>Typing{dots}</Text>
  59. );
  60. };
  61. const MessagesScreen = () => {
  62. const insets = useSafeAreaInsets();
  63. const navigation = useNavigation();
  64. const token = storage.get('token', StoreType.STRING) as string;
  65. const [chats, setChats] = useState<Chat[]>([]);
  66. const [index, setIndex] = useState(0);
  67. const { data: chatsData, refetch } = usePostGetChatsListQuery(token, index === 2 ? 1 : 0, true);
  68. const { data: blockedData, refetch: refetchBlocked } = usePostGetBlockedQuery(token, true);
  69. const [blocked, setBlocked] = useState<Blocked[]>([]);
  70. const updateUnreadMessagesCount = useMessagesStore((state) => state.updateUnreadMessagesCount);
  71. const currentUserId = storage.get('uid', StoreType.STRING) as string;
  72. const [filteredChats, setFilteredChats] = useState<{
  73. all: Chat[];
  74. unread: Chat[];
  75. archived: Chat[];
  76. blocked: Blocked[];
  77. }>({ all: [], unread: [], archived: [], blocked: [] });
  78. const [search, setSearch] = useState('');
  79. const openRowRef = useRef<any>(null);
  80. const { isWarningModalVisible, setIsWarningModalVisible } = useChatStore();
  81. const [typingUsers, setTypingUsers] = useState<
  82. { [key: string]: boolean } | { [key: string]: { firstName: string; isTyping: boolean } }
  83. >({});
  84. const appState = useRef(AppState.currentState);
  85. const socket = useRef<WebSocket | null>(null);
  86. const initializeSocket = () => {
  87. if (socket.current) {
  88. socket.current.close();
  89. }
  90. setTimeout(() => {
  91. socket.current = new WebSocket(WEBSOCKET_URL);
  92. socket.current.onopen = () => {
  93. socket.current?.send(JSON.stringify({ token }));
  94. };
  95. socket.current.onmessage = (event) => {
  96. const data = JSON.parse(event.data);
  97. handleWebSocketMessage(data);
  98. };
  99. socket.current.onclose = () => {
  100. console.log('WebSocket connection closed');
  101. };
  102. }, 500);
  103. };
  104. useEffect(() => {
  105. const handleAppStateChange = (nextAppState: AppStateStatus) => {
  106. if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
  107. if (!socket.current || socket.current.readyState === WebSocket.CLOSED) {
  108. socket.current = new WebSocket(WEBSOCKET_URL);
  109. socket.current.onopen = () => {
  110. socket.current?.send(JSON.stringify({ token }));
  111. };
  112. socket.current.onmessage = (event) => {
  113. const data = JSON.parse(event.data);
  114. handleWebSocketMessage(data);
  115. };
  116. }
  117. }
  118. };
  119. const subscription = AppState.addEventListener('change', handleAppStateChange);
  120. return () => {
  121. subscription.remove();
  122. if (socket.current) {
  123. socket.current.close();
  124. socket.current = null;
  125. }
  126. };
  127. }, [token]);
  128. useEffect(() => {
  129. const pingInterval = setInterval(() => {
  130. if (socket.current && socket.current.readyState === WebSocket.OPEN) {
  131. socket.current.send(JSON.stringify({ action: 'ping', conversation_with: 0 }));
  132. } else {
  133. initializeSocket();
  134. return () => {
  135. if (socket.current) {
  136. socket.current.close();
  137. socket.current = null;
  138. }
  139. };
  140. }
  141. }, 50000);
  142. return () => clearInterval(pingInterval);
  143. }, []);
  144. const handleWebSocketMessage = (data: any) => {
  145. switch (data.action) {
  146. case 'new_message':
  147. case 'messages_read':
  148. refetch();
  149. break;
  150. case 'is_typing':
  151. if (data.conversation_with) {
  152. setTypingUsers((prev) => ({
  153. ...prev,
  154. [data.conversation_with]: true
  155. }));
  156. } else if (data.group_token) {
  157. setTypingUsers((prev) => ({
  158. ...prev,
  159. [data.group_token]: {
  160. isTyping: true,
  161. firstName: data.name?.split(' ')[0]
  162. }
  163. }));
  164. }
  165. break;
  166. case 'stopped_typing':
  167. if (data.conversation_with) {
  168. setTypingUsers((prev) => ({
  169. ...prev,
  170. [data.conversation_with]: false
  171. }));
  172. } else if (data.group_token) {
  173. setTypingUsers((prev) => ({
  174. ...prev,
  175. [data.group_token]: false
  176. }));
  177. }
  178. break;
  179. default:
  180. break;
  181. }
  182. };
  183. const handleRowOpen = (ref: any) => {
  184. if (openRowRef.current && openRowRef.current !== ref) {
  185. openRowRef.current.close();
  186. }
  187. openRowRef.current = ref;
  188. };
  189. useFocusEffect(() => {
  190. navigation.getParent()?.setOptions({
  191. tabBarStyle: {
  192. display: 'flex',
  193. ...Platform.select({
  194. android: {
  195. height: 58
  196. }
  197. })
  198. }
  199. });
  200. });
  201. useEffect(() => {
  202. if (chatsData && chatsData.conversations) {
  203. setChats(chatsData.conversations);
  204. }
  205. }, [chatsData]);
  206. useEffect(() => {
  207. if (blockedData && blockedData.blocked) {
  208. setBlocked(blockedData.blocked);
  209. }
  210. }, [blockedData]);
  211. useFocusEffect(
  212. useCallback(() => {
  213. refetch();
  214. initializeSocket();
  215. updateUnreadMessagesCount();
  216. return () => {
  217. if (socket.current) {
  218. socket.current.close();
  219. socket.current = null;
  220. }
  221. };
  222. }, [token])
  223. );
  224. const filterChatsByTab = () => {
  225. let filteredList = chats;
  226. if (index === 3) {
  227. setFilteredChats((prev) => ({ ...prev, blocked }));
  228. return;
  229. }
  230. if (index === 1) {
  231. filteredList = chats.filter((chat) => chat.unread_count > 0);
  232. }
  233. filteredList.sort((a, b) => {
  234. if (b.pin - a.pin !== 0) {
  235. return b.pin - a.pin;
  236. }
  237. if (b.pin_order - a.pin_order !== 0) {
  238. return b.pin_order - a.pin_order;
  239. }
  240. return new Date(b.updated).getTime() - new Date(a.updated).getTime();
  241. });
  242. setFilteredChats((prev) => ({ ...prev, [routes[index].key]: filteredList }));
  243. };
  244. useEffect(() => {
  245. filterChatsByTab();
  246. }, [chats, index, blocked]);
  247. const searchFilter = (text: string) => {
  248. if (text) {
  249. const newData =
  250. chats?.filter((item: Chat) => {
  251. const itemData = item.short ? item.short.toLowerCase() : ''.toLowerCase();
  252. const textData = text.toLowerCase();
  253. return itemData.indexOf(textData) > -1;
  254. }) ?? [];
  255. setFilteredChats((prev) => ({ ...prev, [routes[index].key]: newData }));
  256. setSearch(text);
  257. } else {
  258. filterChatsByTab();
  259. setSearch(text);
  260. }
  261. };
  262. const renderChatItem = ({ item }: { item: Chat }) => {
  263. const name =
  264. item.user_type === 'blocked'
  265. ? 'Account is blocked'
  266. : item.user_type === 'not_exist'
  267. ? 'Account does not exist'
  268. : item.name;
  269. return (
  270. <SwipeableRow
  271. chat={{
  272. uid: item.uid,
  273. groupToken: item.group_chat_token,
  274. name: item.name,
  275. avatar: item.avatar,
  276. pin: item.pin,
  277. archive: item.archive,
  278. muted: item.muted,
  279. userType: item.user_type ?? 'normal'
  280. }}
  281. token={token}
  282. onRowOpen={handleRowOpen}
  283. refetch={refetch}
  284. refetchBlocked={refetchBlocked}
  285. >
  286. <TouchableHighlight
  287. key={
  288. item.uid
  289. ? `${item.uid}-${typingUsers[item.uid]}`
  290. : `${item.group_chat_token}-${typingUsers[item.group_chat_token ?? '']}`
  291. }
  292. activeOpacity={0.8}
  293. onPress={() => {
  294. if (!item.uid) {
  295. navigation.navigate(
  296. ...([
  297. NAVIGATION_PAGES.GROUP_CHAT,
  298. {
  299. group_token: item.group_chat_token,
  300. name: item.name,
  301. avatar: item.avatar,
  302. userType: item.user_type
  303. }
  304. ] as never)
  305. );
  306. } else {
  307. navigation.navigate(
  308. ...([
  309. NAVIGATION_PAGES.CHAT,
  310. {
  311. id: item.uid,
  312. name: item.name,
  313. avatar: item.avatar,
  314. userType: item.user_type
  315. }
  316. ] as never)
  317. );
  318. }
  319. }}
  320. underlayColor={Colors.FILL_LIGHT}
  321. >
  322. <View style={styles.chatItem}>
  323. {item.avatar && (item.user_type === 'normal' || !item.user_type) ? (
  324. <Image
  325. source={{
  326. uri: API_HOST + item.avatar,
  327. cache: item.group_chat_token ? 'reload' : 'force-cache'
  328. }}
  329. style={styles.avatar}
  330. />
  331. ) : item.uid && (item.user_type === 'normal' || !item.user_type) ? (
  332. <AvatarWithInitials
  333. text={
  334. item.name
  335. ?.split(/ (.+)/)
  336. .map((n) => n[0])
  337. .join('') ?? ''
  338. }
  339. flag={API_HOST + item?.flag}
  340. size={54}
  341. />
  342. ) : item.user_type === 'normal' || !item.user_type ? (
  343. <GroupIcon fill={Colors.DARK_BLUE} width={54} height={54} />
  344. ) : (
  345. <BanIcon fill={Colors.RED} width={54} height={54} />
  346. )}
  347. <View style={{ flex: 1, gap: 6 }}>
  348. <View style={[styles.rowContainer, { alignItems: 'center' }]}>
  349. <Text
  350. style={[
  351. styles.chatName,
  352. item.user_type === 'not_exist' || item.user_type === 'blocked'
  353. ? { color: Colors.RED }
  354. : {}
  355. ]}
  356. >
  357. {name}
  358. </Text>
  359. <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
  360. {item.pin === 1 ? <PinIcon height={12} fill={Colors.DARK_BLUE} /> : null}
  361. {item.muted === 1 ? <BellSlashIcon height={12} fill={Colors.DARK_BLUE} /> : null}
  362. {item.sent_by === +currentUserId && item.status === 3 ? (
  363. <ReadIcon fill={Colors.DARK_BLUE} />
  364. ) : item.sent_by === +currentUserId &&
  365. (item.status === 2 || item.status === 1) ? (
  366. <UnreadIcon fill={Colors.LIGHT_GRAY} />
  367. ) : null}
  368. <Text style={styles.chatTime}>{formatDate(item.updated)}</Text>
  369. </View>
  370. </View>
  371. <View style={[styles.rowContainer, { flex: 1, gap: 6 }]}>
  372. {item.uid && typingUsers[item.uid] ? (
  373. <TypingIndicator />
  374. ) : item.group_chat_token &&
  375. typingUsers[item.group_chat_token] &&
  376. (typingUsers[item.group_chat_token] as any)?.firstName ? (
  377. <TypingIndicator name={(typingUsers[item.group_chat_token] as any).firstName} />
  378. ) : (
  379. <Text numberOfLines={2} style={styles.chatMessage}>
  380. {item.attachement_name && item.attachement_name.length
  381. ? item.attachement_name
  382. : item.short}
  383. </Text>
  384. )}
  385. {item.unread_count > 0 ? (
  386. <View style={styles.unreadBadge}>
  387. <Text style={styles.unreadText}>
  388. {item.unread_count > 99 ? '99+' : item.unread_count}
  389. </Text>
  390. </View>
  391. ) : null}
  392. </View>
  393. </View>
  394. </View>
  395. </TouchableHighlight>
  396. </SwipeableRow>
  397. );
  398. };
  399. const renderBlockedItem = ({ item }: { item: Blocked }) => {
  400. return (
  401. <SwipeableBlockedRow
  402. data={{
  403. id: item.id,
  404. first_name: item.first_name,
  405. last_name: item.last_name,
  406. avatar: item.avatar
  407. }}
  408. token={token}
  409. onRowOpen={handleRowOpen}
  410. refetchBlocked={refetchBlocked}
  411. >
  412. <TouchableHighlight
  413. activeOpacity={0.8}
  414. onPress={() =>
  415. navigation.navigate(
  416. ...([NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: item.id }] as never)
  417. )
  418. }
  419. underlayColor={Colors.FILL_LIGHT}
  420. >
  421. <View style={[styles.chatItem, { alignItems: 'center' }]}>
  422. {item.avatar ? (
  423. <Image
  424. source={{ uri: API_HOST + item.avatar }}
  425. style={[styles.avatar, { width: 30, height: 30, borderRadius: 15 }]}
  426. />
  427. ) : (
  428. <AvatarWithInitials
  429. text={item.first_name[0] + item.last_name[0]}
  430. flag={API_HOST + item?.flag}
  431. size={32}
  432. fontSize={12}
  433. />
  434. )}
  435. <View style={{ flex: 1, gap: 6 }}>
  436. <View style={[styles.rowContainer, { alignItems: 'center' }]}>
  437. <Text style={styles.chatName}>{item.first_name + ' ' + item.last_name}</Text>
  438. <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
  439. <BanIcon height={12} fill={Colors.RED} />
  440. </View>
  441. </View>
  442. </View>
  443. </View>
  444. </TouchableHighlight>
  445. </SwipeableBlockedRow>
  446. );
  447. };
  448. return (
  449. <View style={{ paddingTop: insets.top, flex: 1, marginLeft: 0, marginRight: 0, gap: 12 }}>
  450. <View style={styles.header}>
  451. <View style={{ width: 30 }} />
  452. <Text style={styles.title}>Messages</Text>
  453. <TouchableOpacity
  454. onPress={() => SheetManager.show('search-modal')}
  455. style={{ width: 30, alignItems: 'flex-end' }}
  456. >
  457. <AddChatIcon />
  458. </TouchableOpacity>
  459. </View>
  460. {/* <View style={[{ paddingHorizontal: '4%' }]}>
  461. <Input
  462. inputMode={'search'}
  463. placeholder={'Search'}
  464. onChange={(text) => searchFilter(text)}
  465. value={search}
  466. icon={<SearchIcon fill={'#C8C8C8'} width={14} height={14} />}
  467. height={38}
  468. />
  469. </View> */}
  470. <HorizontalTabView
  471. index={index}
  472. setIndex={setIndex}
  473. routes={routes}
  474. tabBarStyle={{ paddingHorizontal: '4%' }}
  475. renderScene={({ route }: { route: { key: keyof typeof filteredChats } }) =>
  476. route.key === 'blocked' ? (
  477. <FlashList
  478. viewabilityConfig={{
  479. waitForInteraction: true,
  480. itemVisiblePercentThreshold: 50,
  481. minimumViewTime: 1000
  482. }}
  483. data={filteredChats[route.key]}
  484. renderItem={renderBlockedItem}
  485. keyExtractor={(item, index) => `${item.id}-${index}`}
  486. estimatedItemSize={50}
  487. />
  488. ) : (
  489. <FlashList
  490. viewabilityConfig={{
  491. waitForInteraction: true,
  492. itemVisiblePercentThreshold: 50,
  493. minimumViewTime: 1000
  494. }}
  495. data={filteredChats[route.key]}
  496. renderItem={renderChatItem}
  497. keyExtractor={(item, index) => `${item.uid}-${index}`}
  498. estimatedItemSize={78}
  499. extraData={typingUsers}
  500. />
  501. )
  502. }
  503. />
  504. <SearchModal />
  505. <MoreModal />
  506. <WarningModal
  507. type={'delete'}
  508. buttonTitle={isWarningModalVisible?.buttonTitle ?? 'Delete'}
  509. isVisible={!!isWarningModalVisible}
  510. onClose={() => setIsWarningModalVisible(null)}
  511. title={isWarningModalVisible?.title}
  512. message={isWarningModalVisible?.message}
  513. action={isWarningModalVisible?.action}
  514. />
  515. </View>
  516. );
  517. };
  518. export default MessagesScreen;