index.tsx 15 KB

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