MoreModal.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. import React, { useState } from 'react';
  2. import { View, Text, Image, StyleSheet, TouchableOpacity } from 'react-native';
  3. import ActionSheet, { SheetManager } from 'react-native-actions-sheet';
  4. import { Colors } from 'src/theme';
  5. import { API_HOST } from 'src/constants';
  6. import { getFontSize } from 'src/utils';
  7. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  8. import { ChatProps, WarningProps } from '../types';
  9. import { useNavigation } from '@react-navigation/native';
  10. import { NAVIGATION_PAGES } from 'src/types';
  11. import { usePostReportConversationMutation } from '@api/chat';
  12. import { useChatStore } from 'src/stores/chatStore';
  13. import TrashIcon from 'assets/icons/travels-screens/trash-solid.svg';
  14. import BanIcon from 'assets/icons/messages/ban.svg';
  15. import BellSlashIcon from 'assets/icons/messages/bell-slash.svg';
  16. import { AvatarWithInitials } from 'src/components';
  17. import MegaphoneIcon from 'assets/icons/messages/megaphone.svg';
  18. import ExitIcon from 'assets/icons/messages/exit.svg';
  19. import GroupIcon from 'assets/icons/messages/group-chat.svg';
  20. import { database } from 'src/watermelondb';
  21. import { BlockedUser, Chat } from 'src/watermelondb/models';
  22. import { Q } from '@nozbe/watermelondb';
  23. import {
  24. addDirtyAction,
  25. syncChatsIncremental
  26. } from 'src/watermelondb/features/chat/data/chat.sync';
  27. async function findChatRecord(chatData: ChatProps | null): Promise<Chat | null> {
  28. if (!chatData) return null;
  29. const chatsCollection = database.get<Chat>('chats');
  30. if (chatData.uid) {
  31. const res = await chatsCollection.query(Q.where('chat_uid', chatData.uid)).fetch();
  32. return res[0] ?? null;
  33. }
  34. if (chatData.groupToken) {
  35. const res = await chatsCollection
  36. .query(Q.where('group_chat_token', chatData.groupToken))
  37. .fetch();
  38. return res[0] ?? null;
  39. }
  40. return null;
  41. }
  42. async function upsertBlockedUserFromChat(chatData: ChatProps) {
  43. if (!chatData.uid) return;
  44. const blockedCollection = database.get<BlockedUser>('blocked_users');
  45. const existing = await blockedCollection.query(Q.where('user_id', chatData.uid)).fetch();
  46. const [firstName, ...rest] = (chatData.name ?? '').split(' ');
  47. const lastName = rest.join(' ');
  48. await database.write(async () => {
  49. if (existing.length > 0) {
  50. await existing[0].update((r) => {
  51. r.firstName = firstName || r.firstName;
  52. r.lastName = lastName || r.lastName;
  53. r.avatar = chatData.avatar ?? r.avatar;
  54. r.removed = false;
  55. });
  56. } else {
  57. await blockedCollection.create((r) => {
  58. r.userId = chatData.uid!;
  59. r.firstName = firstName || '';
  60. r.lastName = lastName || '';
  61. r.avatar = chatData.avatar ?? null;
  62. r.removed = false;
  63. });
  64. }
  65. });
  66. }
  67. const MoreModal = () => {
  68. const insets = useSafeAreaInsets();
  69. const navigation = useNavigation();
  70. const { setIsWarningModalVisible } = useChatStore();
  71. const [chatData, setChatData] = useState<
  72. | (ChatProps & {
  73. token: string;
  74. })
  75. | null
  76. >(null);
  77. const [name, setName] = useState<string | null>(null);
  78. const { mutateAsync: reportUser } = usePostReportConversationMutation();
  79. const [shouldOpenWarningModal, setShouldOpenWarningModal] = useState<WarningProps | null>(null);
  80. const handleSheetOpen = (
  81. payload:
  82. | (ChatProps & {
  83. token: string;
  84. })
  85. | null
  86. ) => {
  87. setChatData(payload);
  88. setName(
  89. payload?.userType === 'blocked'
  90. ? 'Account is blocked'
  91. : payload?.userType === 'not_exist'
  92. ? 'Account does not exist'
  93. : (payload?.name ?? null)
  94. );
  95. };
  96. const handleMute = async () => {
  97. if (!chatData) return;
  98. const newMuted = chatData.muted === 1 ? 0 : 1;
  99. const chatRec = await findChatRecord(chatData);
  100. if (chatRec) {
  101. await database.write(() =>
  102. chatRec.update((r) => {
  103. r.muted = newMuted;
  104. addDirtyAction(r, { type: 'mute', value: newMuted });
  105. })
  106. );
  107. }
  108. setChatData((prev) => (prev ? { ...prev, muted: newMuted } : prev));
  109. try {
  110. await syncChatsIncremental(chatData.token);
  111. } catch (e) {
  112. console.warn('mute sync failed (will retry later):', e);
  113. }
  114. };
  115. const handleBlock = async () => {
  116. if (!chatData) return;
  117. setShouldOpenWarningModal({
  118. visible: true,
  119. title: 'Block user',
  120. buttonTitle: 'Block',
  121. message: `Are you sure you want to block ${name}?\nThis user will be blocked and you will not be able to send or receive messages from him/her.`,
  122. action: async () => {
  123. const chatRec = await findChatRecord(chatData);
  124. if (chatRec) {
  125. await database.write(() =>
  126. chatRec.update((r) => {
  127. r.removed = true;
  128. addDirtyAction(r, { type: 'block' });
  129. })
  130. );
  131. }
  132. await upsertBlockedUserFromChat(chatData);
  133. try {
  134. await syncChatsIncremental(chatData.token);
  135. } catch (e) {
  136. console.warn('block sync failed:', e);
  137. }
  138. }
  139. });
  140. setTimeout(() => {
  141. SheetManager.hide('more-modal');
  142. setShouldOpenWarningModal(null);
  143. }, 300);
  144. };
  145. const handleReport = async () => {
  146. if (!chatData) return;
  147. setShouldOpenWarningModal({
  148. visible: true,
  149. title: `Report ${name}`,
  150. buttonTitle: 'Report',
  151. message: `Are you sure you want to report ${name}?\nIf you proceed, the chat history with ${name} will become visible to NomadMania admins for investigation.`,
  152. action: async () => {
  153. chatData.uid &&
  154. (await reportUser({
  155. token: chatData.token,
  156. reported_user_id: chatData.uid
  157. }));
  158. }
  159. });
  160. setTimeout(() => {
  161. SheetManager.hide('more-modal');
  162. setShouldOpenWarningModal(null);
  163. }, 300);
  164. };
  165. const handleDelete = async () => {
  166. if (!chatData) return;
  167. setShouldOpenWarningModal({
  168. visible: true,
  169. title: 'Delete conversation',
  170. message: `Are you sure you want to delete conversation with ${name}?\nThis conversation will be deleted for both sides.`,
  171. action: async () => {
  172. const chatRec = await findChatRecord(chatData);
  173. if (chatRec) {
  174. await database.write(() =>
  175. chatRec.update((r) => {
  176. r.removed = true;
  177. addDirtyAction(r, { type: 'delete' });
  178. })
  179. );
  180. }
  181. try {
  182. await syncChatsIncremental(chatData.token);
  183. } catch (e) {
  184. console.warn('delete sync failed:', e);
  185. }
  186. }
  187. });
  188. setTimeout(() => {
  189. SheetManager.hide('more-modal');
  190. setShouldOpenWarningModal(null);
  191. }, 300);
  192. };
  193. const handleLeaveGroup = async () => {
  194. if (!chatData) return;
  195. setShouldOpenWarningModal({
  196. visible: true,
  197. title: `Leave group ${name}`,
  198. buttonTitle: 'Leave',
  199. message: `Are you sure you want to leave ${name}?`,
  200. action: async () => {
  201. const chatRec = await findChatRecord(chatData);
  202. if (chatRec) {
  203. await database.write(() =>
  204. chatRec.update((r) => {
  205. r.removed = true;
  206. addDirtyAction(r, { type: 'leave_group' });
  207. })
  208. );
  209. }
  210. try {
  211. await syncChatsIncremental(chatData.token);
  212. } catch (e) {
  213. console.warn('leaveGroup sync failed:', e);
  214. }
  215. }
  216. });
  217. setTimeout(() => {
  218. SheetManager.hide('more-modal');
  219. setShouldOpenWarningModal(null);
  220. }, 300);
  221. };
  222. const handleDeleteGroup = async () => {
  223. if (!chatData) return;
  224. setShouldOpenWarningModal({
  225. visible: true,
  226. title: `Delete ${name}`,
  227. message: `Are you sure you want to delete this group chat?\nThis action will remove the chat from your history, but it won't affect other participants.`,
  228. action: async () => {
  229. const chatRec = await findChatRecord(chatData);
  230. if (chatRec) {
  231. await database.write(() =>
  232. chatRec.update((r) => {
  233. r.removed = true;
  234. addDirtyAction(r, { type: 'delete' });
  235. })
  236. );
  237. }
  238. try {
  239. await syncChatsIncremental(chatData.token);
  240. } catch (e) {
  241. console.warn('deleteGroup sync failed:', e);
  242. }
  243. }
  244. });
  245. setTimeout(() => {
  246. SheetManager.hide('more-modal');
  247. setShouldOpenWarningModal(null);
  248. }, 300);
  249. };
  250. return (
  251. <ActionSheet
  252. id="more-modal"
  253. gestureEnabled={true}
  254. onBeforeShow={(sheetRef) => {
  255. const payload = sheetRef || null;
  256. handleSheetOpen(payload);
  257. }}
  258. onClose={() => {
  259. if (shouldOpenWarningModal) {
  260. setIsWarningModalVisible(shouldOpenWarningModal);
  261. }
  262. }}
  263. containerStyle={styles.sheetContainer}
  264. defaultOverlayOpacity={0.5}
  265. indicatorStyle={{ backgroundColor: 'transparent' }}
  266. safeAreaInsets={{ top: insets.top, bottom: insets.bottom || 24, left: 0, right: 0 }}
  267. >
  268. {chatData && (
  269. <View style={[styles.container]}>
  270. <TouchableOpacity
  271. style={styles.header}
  272. onPress={() => {
  273. SheetManager.hide('more-modal');
  274. if (chatData?.uid) {
  275. navigation.navigate(
  276. ...([NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: chatData.uid }] as never)
  277. );
  278. }
  279. }}
  280. disabled={chatData?.userType !== 'normal'}
  281. >
  282. {chatData?.avatar && (chatData.userType === 'normal' || !chatData.userType) ? (
  283. <Image source={{ uri: API_HOST + chatData.avatar }} style={styles.avatar} />
  284. ) : chatData.uid && (chatData.userType === 'normal' || !chatData.userType) ? (
  285. <AvatarWithInitials
  286. text={
  287. chatData.name
  288. .split(/ (.+)/)
  289. .map((n) => n[0])
  290. .join('') ?? ''
  291. }
  292. flag={API_HOST + 'flag.png'}
  293. size={32}
  294. fontSize={12}
  295. />
  296. ) : chatData.userType === 'normal' || !chatData.userType ? (
  297. <GroupIcon fill={Colors.DARK_BLUE} width={32} height={32} />
  298. ) : (
  299. <BanIcon fill={Colors.RED} width={32} height={32} />
  300. )}
  301. <Text
  302. style={[styles.name, chatData?.userType !== 'normal' ? { color: Colors.RED } : {}]}
  303. >
  304. {name}
  305. </Text>
  306. </TouchableOpacity>
  307. {chatData?.userType === 'normal' && (
  308. <View style={styles.optionsContainer}>
  309. <TouchableOpacity style={styles.option} onPress={handleMute}>
  310. <Text style={styles.optionText}>{chatData.muted === 1 ? 'Unmute' : 'Mute'}</Text>
  311. <BellSlashIcon fill={Colors.DARK_BLUE} />
  312. </TouchableOpacity>
  313. </View>
  314. )}
  315. {!chatData.announcement ? (
  316. <View style={[styles.optionsContainer, { paddingVertical: 0, gap: 0 }]}>
  317. {chatData?.groupToken && (
  318. <TouchableOpacity
  319. style={[styles.option, styles.dangerOption]}
  320. onPress={handleLeaveGroup}
  321. >
  322. <Text style={[styles.optionText, styles.dangerText]}>Leave group chat</Text>
  323. <ExitIcon fill={Colors.RED} width={16} />
  324. </TouchableOpacity>
  325. )}
  326. {chatData?.userType === 'normal' && chatData?.uid && (
  327. <>
  328. <TouchableOpacity
  329. style={[styles.option, styles.dangerOption]}
  330. onPress={handleReport}
  331. >
  332. <Text style={[styles.optionText, styles.dangerText]}>Report {name}</Text>
  333. <MegaphoneIcon fill={Colors.RED} />
  334. </TouchableOpacity>
  335. <TouchableOpacity
  336. style={[styles.option, styles.dangerOption]}
  337. onPress={handleBlock}
  338. >
  339. <Text style={[styles.optionText, styles.dangerText]}>Block {name}</Text>
  340. <BanIcon fill={Colors.RED} />
  341. </TouchableOpacity>
  342. </>
  343. )}
  344. {chatData?.uid ? (
  345. <TouchableOpacity
  346. style={[styles.option, styles.dangerOption]}
  347. onPress={handleDelete}
  348. >
  349. <Text style={[styles.optionText, styles.dangerText]}>Delete chat</Text>
  350. <TrashIcon fill={Colors.RED} width={18} height={18} />
  351. </TouchableOpacity>
  352. ) : (
  353. <TouchableOpacity
  354. style={[styles.option, styles.dangerOption]}
  355. onPress={handleDeleteGroup}
  356. >
  357. <Text style={[styles.optionText, styles.dangerText]}>Delete group chat</Text>
  358. <TrashIcon fill={Colors.RED} width={18} height={18} />
  359. </TouchableOpacity>
  360. )}
  361. </View>
  362. ) : null}
  363. </View>
  364. )}
  365. </ActionSheet>
  366. );
  367. };
  368. const styles = StyleSheet.create({
  369. sheetContainer: {
  370. borderTopLeftRadius: 15,
  371. borderTopRightRadius: 15
  372. },
  373. container: {
  374. backgroundColor: 'white',
  375. paddingHorizontal: 16,
  376. paddingTop: 8,
  377. gap: 16
  378. },
  379. header: {
  380. flexDirection: 'row',
  381. alignItems: 'center',
  382. borderBottomWidth: 1,
  383. borderBottomColor: Colors.FILL_LIGHT,
  384. gap: 8
  385. },
  386. avatar: {
  387. width: 32,
  388. height: 32,
  389. borderRadius: 16,
  390. borderWidth: 1,
  391. borderColor: Colors.FILL_LIGHT
  392. },
  393. name: {
  394. fontSize: getFontSize(14),
  395. fontFamily: 'montserrat-700',
  396. color: Colors.DARK_BLUE
  397. },
  398. optionsContainer: {
  399. paddingVertical: 10,
  400. paddingHorizontal: 8,
  401. gap: 16,
  402. borderRadius: 8,
  403. backgroundColor: Colors.FILL_LIGHT
  404. },
  405. option: {
  406. flexDirection: 'row',
  407. alignItems: 'center',
  408. justifyContent: 'space-between'
  409. },
  410. optionText: {
  411. fontSize: getFontSize(12),
  412. fontWeight: '600',
  413. color: Colors.DARK_BLUE
  414. },
  415. dangerOption: {
  416. paddingVertical: 10,
  417. borderBottomWidth: 1,
  418. borderBlockColor: Colors.WHITE
  419. },
  420. dangerText: {
  421. color: Colors.RED
  422. }
  423. });
  424. export default MoreModal;