|
@@ -0,0 +1,1658 @@
|
|
|
+import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
|
+import {
|
|
|
+ View,
|
|
|
+ TouchableOpacity,
|
|
|
+ Image,
|
|
|
+ Modal,
|
|
|
+ Text,
|
|
|
+ FlatList,
|
|
|
+ Dimensions,
|
|
|
+ Alert,
|
|
|
+ ScrollView,
|
|
|
+ Linking,
|
|
|
+ ActivityIndicator,
|
|
|
+ AppState,
|
|
|
+ AppStateStatus,
|
|
|
+ TextInput,
|
|
|
+ Keyboard,
|
|
|
+ Platform,
|
|
|
+ UIManager,
|
|
|
+ LayoutAnimation,
|
|
|
+ Animated,
|
|
|
+ Easing,
|
|
|
+ InputAccessoryView,
|
|
|
+ KeyboardAvoidingView
|
|
|
+} from 'react-native';
|
|
|
+import {
|
|
|
+ GiftedChat,
|
|
|
+ Bubble,
|
|
|
+ InputToolbar,
|
|
|
+ IMessage,
|
|
|
+ Send,
|
|
|
+ BubbleProps,
|
|
|
+ Composer,
|
|
|
+ TimeProps,
|
|
|
+ MessageProps,
|
|
|
+ Actions
|
|
|
+} from 'react-native-gifted-chat';
|
|
|
+import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
+import { GestureHandlerRootView, Swipeable } from 'react-native-gesture-handler';
|
|
|
+import { AvatarWithInitials, Header, WarningModal } from 'src/components';
|
|
|
+import { Colors } from 'src/theme';
|
|
|
+import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
|
|
+import { ResizeMode, Video } from 'expo-av';
|
|
|
+import ChatMessageBox from '../Components/ChatMessageBox';
|
|
|
+import ReplyMessageBar from '../Components/ReplyMessageBar';
|
|
|
+import { useSharedValue, withTiming } from 'react-native-reanimated';
|
|
|
+import { BlurView } from 'expo-blur';
|
|
|
+import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
+import Clipboard from '@react-native-clipboard/clipboard';
|
|
|
+import { trigger } from 'react-native-haptic-feedback';
|
|
|
+import ReactModal from 'react-native-modal';
|
|
|
+import { storage, StoreType } from 'src/storage';
|
|
|
+import {
|
|
|
+ usePostDeleteMessageMutation,
|
|
|
+ usePostGetChatWithQuery,
|
|
|
+ usePostMessagesReadMutation,
|
|
|
+ usePostReactToMessageMutation,
|
|
|
+ usePostSendMessageMutation
|
|
|
+} from '@api/chat';
|
|
|
+import { CustomMessage, Message, Reaction } from '../types';
|
|
|
+import { API_HOST, WEBSOCKET_URL } from 'src/constants';
|
|
|
+import ReactionBar from '../Components/ReactionBar';
|
|
|
+import OptionsMenu from '../Components/OptionsMenu';
|
|
|
+import EmojiSelectorModal from '../Components/EmojiSelectorModal';
|
|
|
+import { styles } from './styles';
|
|
|
+import SendIcon from 'assets/icons/messages/send.svg';
|
|
|
+import { SheetManager } from 'react-native-actions-sheet';
|
|
|
+import { NAVIGATION_PAGES } from 'src/types';
|
|
|
+import { usePushNotification } from 'src/contexts/PushNotificationContext';
|
|
|
+import ReactionsListModal from '../Components/ReactionsListModal';
|
|
|
+import { dismissChatNotifications } from '../utils';
|
|
|
+import { useMessagesStore } from 'src/stores/unreadMessagesStore';
|
|
|
+import * as Location from 'expo-location';
|
|
|
+
|
|
|
+import BanIcon from 'assets/icons/messages/ban.svg';
|
|
|
+import AttachmentsModal from '../Components/AttachmentsModal';
|
|
|
+import MegaphoneIcon from 'assets/icons/messages/megaphone.svg';
|
|
|
+import ChatOptionsBlock from '../Components/ChatOptionsBlock';
|
|
|
+
|
|
|
+// if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
|
|
+// UIManager.setLayoutAnimationEnabledExperimental(false);
|
|
|
+// }
|
|
|
+
|
|
|
+const options = {
|
|
|
+ enableVibrateFallback: true,
|
|
|
+ ignoreAndroidSystemSettings: false
|
|
|
+};
|
|
|
+
|
|
|
+const reactionEmojis = ['👍', '❤️', '😂', '😮', '😭'];
|
|
|
+
|
|
|
+const ChatScreen = ({ route }: { route: any }) => {
|
|
|
+ const token = storage.get('token', StoreType.STRING) as string;
|
|
|
+ const {
|
|
|
+ id,
|
|
|
+ name,
|
|
|
+ avatar,
|
|
|
+ userType
|
|
|
+ }: {
|
|
|
+ id: number;
|
|
|
+ name: string;
|
|
|
+ avatar: string | null;
|
|
|
+ userType: 'normal' | 'not_exist' | 'blocked';
|
|
|
+ } = route.params;
|
|
|
+ const userName =
|
|
|
+ userType === 'blocked'
|
|
|
+ ? 'Account is blocked'
|
|
|
+ : userType === 'not_exist'
|
|
|
+ ? 'Account does not exist'
|
|
|
+ : name;
|
|
|
+
|
|
|
+ const currentUserId = storage.get('uid', StoreType.STRING) as number;
|
|
|
+ const insets = useSafeAreaInsets();
|
|
|
+ const [messages, setMessages] = useState<CustomMessage[] | null>();
|
|
|
+ const navigation = useNavigation();
|
|
|
+ const [prevThenMessageId, setPrevThenMessageId] = useState<number>(-1);
|
|
|
+ const {
|
|
|
+ data: chatData,
|
|
|
+ refetch,
|
|
|
+ isFetching
|
|
|
+ } = usePostGetChatWithQuery(token, id, 50, prevThenMessageId, true);
|
|
|
+ const { mutateAsync: sendMessage } = usePostSendMessageMutation();
|
|
|
+
|
|
|
+ const swipeableRowRef = useRef<Swipeable | null>(null);
|
|
|
+ const messageContainerRef = useRef<FlatList<IMessage> | null>(null);
|
|
|
+ const [selectedMedia, setSelectedMedia] = useState<any>(null);
|
|
|
+
|
|
|
+ const [replyMessage, setReplyMessage] = useState<CustomMessage | null>(null);
|
|
|
+ const [modalInfo, setModalInfo] = useState({
|
|
|
+ visible: false,
|
|
|
+ type: 'confirm',
|
|
|
+ message: '',
|
|
|
+ action: () => {},
|
|
|
+ buttonTitle: '',
|
|
|
+ title: ''
|
|
|
+ });
|
|
|
+
|
|
|
+ const [selectedMessage, setSelectedMessage] = useState<BubbleProps<CustomMessage> | null>(null);
|
|
|
+ const [emojiSelectorVisible, setEmojiSelectorVisible] = useState(false);
|
|
|
+ const [messagePosition, setMessagePosition] = useState<{
|
|
|
+ x: number;
|
|
|
+ y: number;
|
|
|
+ width: number;
|
|
|
+ height: number;
|
|
|
+ isMine: boolean;
|
|
|
+ } | null>(null);
|
|
|
+
|
|
|
+ const [isModalVisible, setIsModalVisible] = useState(false);
|
|
|
+ const [unreadMessageIndex, setUnreadMessageIndex] = useState<number | null>(null);
|
|
|
+ const { mutateAsync: markMessagesAsRead } = usePostMessagesReadMutation();
|
|
|
+ const { mutateAsync: deleteMessage } = usePostDeleteMessageMutation();
|
|
|
+ const { mutateAsync: reactToMessage } = usePostReactToMessageMutation();
|
|
|
+
|
|
|
+ const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
|
|
+ const [isRerendering, setIsRerendering] = useState<boolean>(false);
|
|
|
+ const [isTyping, setIsTyping] = useState<boolean>(false);
|
|
|
+
|
|
|
+ const messageRefs = useRef<{ [key: string]: any }>({});
|
|
|
+ const flatList = useRef<FlatList | null>(null);
|
|
|
+ const scrollY = useSharedValue(0);
|
|
|
+ const { isSubscribed } = usePushNotification();
|
|
|
+ const [isLoadingEarlier, setIsLoadingEarlier] = useState(false);
|
|
|
+ const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
|
|
+ const updateUnreadMessagesCount = useMessagesStore((state) => state.updateUnreadMessagesCount);
|
|
|
+
|
|
|
+ const [showOptions, setShowOptions] = useState<boolean>(false);
|
|
|
+ const [keyboardHeight, setKeyboardHeight] = useState<number>(0);
|
|
|
+ const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
|
|
|
+ const [lastKeyboardHeight, setLastKeyboardHeight] = useState(300);
|
|
|
+
|
|
|
+ const appState = useRef(AppState.currentState);
|
|
|
+ const textInputRef = useRef<TextInput>(null);
|
|
|
+
|
|
|
+ const socket = useRef<WebSocket | null>(null);
|
|
|
+
|
|
|
+ const closeModal = () => {
|
|
|
+ setModalInfo({ ...modalInfo, visible: false });
|
|
|
+ };
|
|
|
+
|
|
|
+ const translateY = useRef(new Animated.Value(0)).current;
|
|
|
+ const keyboardAnimDuration = useRef(250);
|
|
|
+
|
|
|
+ // useEffect(() => {
|
|
|
+ // const onKeyboardWillShow = (e: any) => {
|
|
|
+ // setIsKeyboardOpen(true);
|
|
|
+ // setLastKeyboardHeight(e.endCoordinates.height - insets.bottom || 300);
|
|
|
+ // keyboardAnimDuration.current = e.duration;
|
|
|
+ // };
|
|
|
+
|
|
|
+ // const onKeyboardWillHide = (e: any) => {
|
|
|
+ // setIsKeyboardOpen(false);
|
|
|
+ // };
|
|
|
+
|
|
|
+ // const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
|
+ // const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
|
+
|
|
|
+ // const kbShowSub = Keyboard.addListener(showEvent, onKeyboardWillShow);
|
|
|
+ // const kbHideSub = Keyboard.addListener(hideEvent, onKeyboardWillHide);
|
|
|
+
|
|
|
+ // return () => {
|
|
|
+ // kbShowSub.remove();
|
|
|
+ // kbHideSub.remove();
|
|
|
+ // };
|
|
|
+ // }, [insets.bottom, showOptions, translateY]);
|
|
|
+
|
|
|
+ // useEffect(() => {
|
|
|
+ // let toValue = 0;
|
|
|
+ // if (isKeyboardOpen) {
|
|
|
+ // toValue = keyboardHeight;
|
|
|
+ // } else if (showOptions) {
|
|
|
+ // toValue = keyboardHeight;
|
|
|
+ // } else {
|
|
|
+ // toValue = 0;
|
|
|
+ // }
|
|
|
+
|
|
|
+ // Animated.timing(translateY, {
|
|
|
+ // toValue,
|
|
|
+ // duration: keyboardAnimDuration.current ?? 250,
|
|
|
+ // easing: Easing.inOut(Easing.ease),
|
|
|
+ // useNativeDriver: false
|
|
|
+ // }).start();
|
|
|
+ // }, [isKeyboardOpen, showOptions, keyboardHeight]);
|
|
|
+
|
|
|
+ const handleInputFocus = () => {
|
|
|
+ // if (!showOptions) {
|
|
|
+ // setImmediate(() => {
|
|
|
+ // if (Platform.OS === 'ios') {
|
|
|
+ // LayoutAnimation.configureNext({
|
|
|
+ // duration: keyboardAnimDuration.current ?? 250,
|
|
|
+ // update: { type: 'keyboard' }
|
|
|
+ // });
|
|
|
+ // }
|
|
|
+ // setShowOptions(true);
|
|
|
+ // Animated.timing(translateY, {
|
|
|
+ // toValue: keyboardHeight,
|
|
|
+ // duration: keyboardAnimDuration.current ?? 250,
|
|
|
+ // easing: Easing.inOut(Easing.ease),
|
|
|
+ // useNativeDriver: false
|
|
|
+ // }).start();
|
|
|
+ // });
|
|
|
+ // }
|
|
|
+ };
|
|
|
+ const toggleOptions = useCallback(() => {
|
|
|
+ if (showOptions && !isKeyboardOpen) {
|
|
|
+ textInputRef.current?.focus();
|
|
|
+ } else {
|
|
|
+ if (isKeyboardOpen) {
|
|
|
+ Keyboard.dismiss();
|
|
|
+ setShowOptions(true);
|
|
|
+ } else {
|
|
|
+ if (Platform.OS === 'ios') {
|
|
|
+ LayoutAnimation.configureNext({
|
|
|
+ duration: keyboardAnimDuration.current ?? 250,
|
|
|
+ update: { type: 'keyboard' }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ !showOptions && setShowOptions(true);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, [showOptions, isKeyboardOpen]);
|
|
|
+
|
|
|
+ // const handleTouchOutside = () => {
|
|
|
+ // if (showOptions) {
|
|
|
+ // if (Platform.OS === 'ios') {
|
|
|
+ // LayoutAnimation.configureNext({
|
|
|
+ // duration: keyboardAnimDuration.current ?? 250,
|
|
|
+ // update: { type: 'keyboard' }
|
|
|
+ // });
|
|
|
+ // }
|
|
|
+ // setShowOptions(false);
|
|
|
+ // }
|
|
|
+ // Keyboard.dismiss();
|
|
|
+ // };
|
|
|
+
|
|
|
+ const onSendMedia = useCallback((files: { uri: string; type: 'image' }[]) => {
|
|
|
+ const newMsgs = files.map((file) => {
|
|
|
+ const msg: IMessage = {
|
|
|
+ _id: Date.now() + Math.random(),
|
|
|
+ text: '',
|
|
|
+ createdAt: new Date(),
|
|
|
+ user: { _id: +currentUserId, name: 'Me' }
|
|
|
+ };
|
|
|
+
|
|
|
+ if (file.type === 'image') {
|
|
|
+ msg.image = file.uri;
|
|
|
+ } else if (file.type === 'video') {
|
|
|
+ msg.video = file.uri;
|
|
|
+ }
|
|
|
+ return msg;
|
|
|
+ });
|
|
|
+
|
|
|
+ setMessages((prev) => GiftedChat.append(prev, newMsgs));
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const onSendLocation = useCallback((coords: { latitude: number; longitude: number }) => {
|
|
|
+ const locMsg: IMessage = {
|
|
|
+ _id: Date.now() + Math.random(),
|
|
|
+ text: `Location: lat=${coords.latitude}, lon=${coords.longitude}`,
|
|
|
+ createdAt: new Date(),
|
|
|
+ user: { _id: +currentUserId, name: 'Me' },
|
|
|
+ location: coords
|
|
|
+ };
|
|
|
+ setMessages((prev) => GiftedChat.append(prev, [locMsg]));
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const onShareLiveLocation = useCallback(() => {
|
|
|
+ const liveMsg: IMessage = {
|
|
|
+ _id: 'live-loc-' + Date.now(),
|
|
|
+ text: 'Sharing live location...',
|
|
|
+ createdAt: new Date(),
|
|
|
+ user: { _id: +currentUserId, name: 'Me' },
|
|
|
+ system: false
|
|
|
+ };
|
|
|
+ setMessages((prev) => GiftedChat.append(prev, [liveMsg]));
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const renderMessageVideo = (props: any) => {
|
|
|
+ const { currentMessage } = props;
|
|
|
+ if (!currentMessage?.video) return null;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <View style={{ width: 200, height: 200, backgroundColor: '#000' }}>
|
|
|
+ <Video
|
|
|
+ source={{ uri: currentMessage.video }}
|
|
|
+ style={{ flex: 1 }}
|
|
|
+ useNativeControls
|
|
|
+ resizeMode={ResizeMode.CONTAIN}
|
|
|
+ />
|
|
|
+ </View>
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const renderOptionsView = () => {
|
|
|
+ // if (!showOptions) return null;
|
|
|
+ return (
|
|
|
+ <Animated.View
|
|
|
+ style={{
|
|
|
+ transform: [{ translateY }]
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <ChatOptionsBlock
|
|
|
+ blockHeight={lastKeyboardHeight}
|
|
|
+ closeOptions={() => setShowOptions(false)}
|
|
|
+ onSendMedia={onSendMedia}
|
|
|
+ onSendLocation={onSendLocation}
|
|
|
+ onShareLiveLocation={onShareLiveLocation}
|
|
|
+ />
|
|
|
+ </Animated.View>
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ let unsubscribe: any;
|
|
|
+
|
|
|
+ const setupNotificationHandler = async () => {
|
|
|
+ unsubscribe = await dismissChatNotifications(id, isSubscribed, setModalInfo, navigation);
|
|
|
+ };
|
|
|
+
|
|
|
+ setupNotificationHandler();
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (unsubscribe) unsubscribe();
|
|
|
+ updateUnreadMessagesCount();
|
|
|
+ };
|
|
|
+ }, [id]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ socket.current = new WebSocket(WEBSOCKET_URL);
|
|
|
+
|
|
|
+ socket.current.onopen = () => {
|
|
|
+ socket.current?.send(JSON.stringify({ token }));
|
|
|
+ };
|
|
|
+
|
|
|
+ socket.current.onmessage = (event) => {
|
|
|
+ const data = JSON.parse(event.data);
|
|
|
+ handleWebSocketMessage(data);
|
|
|
+ };
|
|
|
+
|
|
|
+ socket.current.onclose = () => {
|
|
|
+ console.log('WebSocket connection closed chat screen');
|
|
|
+ };
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (socket.current) {
|
|
|
+ socket.current.close();
|
|
|
+ socket.current = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [token]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const handleAppStateChange = async (nextAppState: AppStateStatus) => {
|
|
|
+ if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
|
|
|
+ if (!socket.current || socket.current.readyState === WebSocket.CLOSED) {
|
|
|
+ socket.current = new WebSocket(WEBSOCKET_URL);
|
|
|
+ socket.current.onopen = () => {
|
|
|
+ socket.current?.send(JSON.stringify({ token }));
|
|
|
+ };
|
|
|
+ socket.current.onmessage = (event) => {
|
|
|
+ const data = JSON.parse(event.data);
|
|
|
+ handleWebSocketMessage(data);
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ await dismissChatNotifications(id, isSubscribed, setModalInfo, navigation);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const subscription = AppState.addEventListener('change', handleAppStateChange);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ subscription.remove();
|
|
|
+ if (socket.current) {
|
|
|
+ socket.current.close();
|
|
|
+ socket.current = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }, [token]);
|
|
|
+
|
|
|
+ const handleWebSocketMessage = (data: any) => {
|
|
|
+ switch (data.action) {
|
|
|
+ case 'new_message':
|
|
|
+ if (data.conversation_with === id && data.message) {
|
|
|
+ const newMessage = mapApiMessageToGiftedMessage(data.message);
|
|
|
+ setMessages((previousMessages) => {
|
|
|
+ const messageExists =
|
|
|
+ previousMessages && previousMessages.some((msg) => msg._id === newMessage._id);
|
|
|
+ if (!messageExists) {
|
|
|
+ return GiftedChat.append(previousMessages ?? [], [newMessage]);
|
|
|
+ }
|
|
|
+ return previousMessages;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'new_reaction':
|
|
|
+ if (data.conversation_with === id && data.reaction) {
|
|
|
+ updateMessageWithReaction(data.reaction);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'unreact':
|
|
|
+ if (data.conversation_with === id && data.unreacted_message_id) {
|
|
|
+ removeReactionFromMessage(data.unreacted_message_id);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'delete_message':
|
|
|
+ if (data.conversation_with === id && data.deleted_message_id) {
|
|
|
+ removeDeletedMessage(data.deleted_message_id);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'is_typing':
|
|
|
+ if (data.conversation_with === id) {
|
|
|
+ setIsTyping(true);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'stopped_typing':
|
|
|
+ if (data.conversation_with === id) {
|
|
|
+ setIsTyping(false);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ case 'messages_read':
|
|
|
+ if (data.conversation_with === id && data.read_messages_ids) {
|
|
|
+ setMessages(
|
|
|
+ (prevMessages) =>
|
|
|
+ prevMessages?.map((msg) => {
|
|
|
+ if (data.read_messages_ids.includes(msg._id)) {
|
|
|
+ return { ...msg, received: true };
|
|
|
+ }
|
|
|
+ return msg;
|
|
|
+ }) ?? []
|
|
|
+ );
|
|
|
+ }
|
|
|
+ break;
|
|
|
+
|
|
|
+ default:
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const updateMessageWithReaction = (reactionData: any) => {
|
|
|
+ setMessages(
|
|
|
+ (prevMessages) =>
|
|
|
+ prevMessages?.map((msg) => {
|
|
|
+ if (msg._id === reactionData.message_id) {
|
|
|
+ const updatedReactions = [
|
|
|
+ ...(Array.isArray(msg.reactions)
|
|
|
+ ? msg.reactions?.filter((r: any) => r.uid !== reactionData.uid)
|
|
|
+ : []),
|
|
|
+ reactionData
|
|
|
+ ];
|
|
|
+ return { ...msg, reactions: updatedReactions };
|
|
|
+ }
|
|
|
+ return msg;
|
|
|
+ }) ?? []
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const removeReactionFromMessage = (messageId: number) => {
|
|
|
+ setMessages(
|
|
|
+ (prevMessages) =>
|
|
|
+ prevMessages?.map((msg) => {
|
|
|
+ if (msg._id === messageId) {
|
|
|
+ const updatedReactions = Array.isArray(msg.reactions)
|
|
|
+ ? msg.reactions?.filter((r: any) => r.uid !== id)
|
|
|
+ : [];
|
|
|
+ return { ...msg, reactions: updatedReactions };
|
|
|
+ }
|
|
|
+ return msg;
|
|
|
+ }) ?? []
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const removeDeletedMessage = (messageId: number) => {
|
|
|
+ setMessages(
|
|
|
+ (prevMessages) =>
|
|
|
+ prevMessages?.map((msg) => {
|
|
|
+ if (msg._id === messageId) {
|
|
|
+ return {
|
|
|
+ ...msg,
|
|
|
+ deleted: true,
|
|
|
+ text: 'This message was deleted',
|
|
|
+ pending: false,
|
|
|
+ sent: false,
|
|
|
+ received: false
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return msg;
|
|
|
+ }) ?? []
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const pingInterval = setInterval(() => {
|
|
|
+ if (socket.current && socket.current.readyState === WebSocket.OPEN) {
|
|
|
+ socket.current.send(JSON.stringify({ action: 'ping', conversation_with: id }));
|
|
|
+ } else {
|
|
|
+ socket.current = new WebSocket(WEBSOCKET_URL);
|
|
|
+ socket.current.onopen = () => {
|
|
|
+ socket.current?.send(JSON.stringify({ token }));
|
|
|
+ };
|
|
|
+ socket.current.onmessage = (event) => {
|
|
|
+ const data = JSON.parse(event.data);
|
|
|
+ handleWebSocketMessage(data);
|
|
|
+ };
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ if (socket.current) {
|
|
|
+ socket.current.close();
|
|
|
+ socket.current = null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }, 50000);
|
|
|
+
|
|
|
+ return () => clearInterval(pingInterval);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const sendWebSocketMessage = (
|
|
|
+ action: string,
|
|
|
+ message: CustomMessage | null = null,
|
|
|
+ reaction: string | null = null,
|
|
|
+ readMessagesIds: number[] | null = null
|
|
|
+ ) => {
|
|
|
+ if (socket.current && socket.current.readyState === WebSocket.OPEN) {
|
|
|
+ const data: any = {
|
|
|
+ action,
|
|
|
+ conversation_with: id
|
|
|
+ };
|
|
|
+
|
|
|
+ if (action === 'new_message' && message) {
|
|
|
+ data.message = {
|
|
|
+ id: message._id,
|
|
|
+ text: message.text,
|
|
|
+ sender: +currentUserId,
|
|
|
+ sent_datetime: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
|
|
+ reply_to_id: message.replyMessage?.id ?? -1,
|
|
|
+ reply_to: message.replyMessage ?? null,
|
|
|
+ reactions: message.reactions ?? '{}',
|
|
|
+ status: 2,
|
|
|
+ attachement: -1
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (action === 'new_reaction' && message && reaction) {
|
|
|
+ data.reaction = {
|
|
|
+ message_id: message._id,
|
|
|
+ reaction,
|
|
|
+ uid: +currentUserId,
|
|
|
+ datetime: new Date().toISOString()
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (action === 'unreact' && message) {
|
|
|
+ data.message_id = message._id;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (action === 'delete_message' && message) {
|
|
|
+ data.message_id = message._id;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (action === 'messages_read' && readMessagesIds) {
|
|
|
+ data.messages_ids = readMessagesIds;
|
|
|
+ }
|
|
|
+
|
|
|
+ socket.current.send(JSON.stringify(data));
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleTyping = (isTyping: boolean) => {
|
|
|
+ if (isTyping) {
|
|
|
+ sendWebSocketMessage('is_typing');
|
|
|
+ } else {
|
|
|
+ sendWebSocketMessage('stopped_typing');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const mapApiMessageToGiftedMessage = (message: Message): CustomMessage => {
|
|
|
+ return {
|
|
|
+ _id: message.id,
|
|
|
+ text: message.text,
|
|
|
+ createdAt: new Date(message.sent_datetime + 'Z'),
|
|
|
+ user: {
|
|
|
+ _id: message.sender,
|
|
|
+ name: message.sender === id ? userName : 'Me'
|
|
|
+ },
|
|
|
+ replyMessage:
|
|
|
+ message.reply_to_id !== -1
|
|
|
+ ? {
|
|
|
+ text: message.reply_to.text,
|
|
|
+ id: message.reply_to.id,
|
|
|
+ name: message.reply_to.sender === id ? userName : 'Me'
|
|
|
+ }
|
|
|
+ : null,
|
|
|
+ reactions: JSON.parse(message.reactions || '{}'),
|
|
|
+ attachment: message.attachement !== -1 ? message.attachement : null,
|
|
|
+ pending: message.status === 1,
|
|
|
+ sent: message.status === 2,
|
|
|
+ received: message.status === 3,
|
|
|
+ deleted: message.status === 4
|
|
|
+ };
|
|
|
+ };
|
|
|
+
|
|
|
+ useFocusEffect(
|
|
|
+ useCallback(() => {
|
|
|
+ refetch();
|
|
|
+ }, [])
|
|
|
+ );
|
|
|
+
|
|
|
+ useFocusEffect(
|
|
|
+ useCallback(() => {
|
|
|
+ if (chatData?.messages) {
|
|
|
+ const mappedMessages = chatData.messages.map(mapApiMessageToGiftedMessage);
|
|
|
+
|
|
|
+ if (unreadMessageIndex === null && !isFetching) {
|
|
|
+ const firstUnreadIndex = mappedMessages.findLastIndex(
|
|
|
+ (msg) => !msg.received && !msg?.deleted && msg.user._id === id
|
|
|
+ );
|
|
|
+
|
|
|
+ if (firstUnreadIndex !== -1) {
|
|
|
+ setUnreadMessageIndex(firstUnreadIndex);
|
|
|
+
|
|
|
+ const unreadMarker: any = {
|
|
|
+ _id: 'unreadMarker',
|
|
|
+ text: 'Unread messages',
|
|
|
+ system: true
|
|
|
+ };
|
|
|
+
|
|
|
+ mappedMessages.splice(firstUnreadIndex + 1, 0, unreadMarker);
|
|
|
+ setTimeout(() => {
|
|
|
+ if (flatList.current) {
|
|
|
+ flatList.current.scrollToIndex({
|
|
|
+ index: firstUnreadIndex,
|
|
|
+ animated: true,
|
|
|
+ viewPosition: 0.5
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, 500);
|
|
|
+ } else {
|
|
|
+ setUnreadMessageIndex(0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ setMessages((previousMessages) => {
|
|
|
+ const newMessages = mappedMessages.filter(
|
|
|
+ (newMsg) => !previousMessages?.some((oldMsg) => oldMsg._id === newMsg._id)
|
|
|
+ );
|
|
|
+ return prevThenMessageId !== -1 && previousMessages
|
|
|
+ ? GiftedChat.prepend(previousMessages, newMessages)
|
|
|
+ : mappedMessages;
|
|
|
+ });
|
|
|
+
|
|
|
+ if (mappedMessages.length < 50) {
|
|
|
+ setHasMoreMessages(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mappedMessages.length === 0 && !modalInfo.visible) {
|
|
|
+ setTimeout(() => {
|
|
|
+ textInputRef.current?.focus();
|
|
|
+ }, 500);
|
|
|
+ }
|
|
|
+
|
|
|
+ setIsLoadingEarlier(false);
|
|
|
+ }
|
|
|
+ }, [chatData])
|
|
|
+ );
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (messages?.length === 0 && !modalInfo.visible) {
|
|
|
+ setTimeout(() => {
|
|
|
+ textInputRef.current?.focus();
|
|
|
+ }, 500);
|
|
|
+ }
|
|
|
+ }, [modalInfo]);
|
|
|
+
|
|
|
+ const loadEarlierMessages = async () => {
|
|
|
+ if (!hasMoreMessages || isLoadingEarlier || !messages) return;
|
|
|
+
|
|
|
+ setIsLoadingEarlier(true);
|
|
|
+
|
|
|
+ const previousMessageId = messages[messages.length - 1]._id;
|
|
|
+
|
|
|
+ setPrevThenMessageId(previousMessageId);
|
|
|
+ };
|
|
|
+
|
|
|
+ const sentToServer = useRef<Set<number>>(new Set());
|
|
|
+
|
|
|
+ const handleViewableItemsChanged = ({ viewableItems }: { viewableItems: any[] }) => {
|
|
|
+ const newViewableUnreadMessages = viewableItems
|
|
|
+ .filter(
|
|
|
+ (item) =>
|
|
|
+ !item.item.received &&
|
|
|
+ !item.item.deleted &&
|
|
|
+ !item.item.system &&
|
|
|
+ item.item.user._id === id &&
|
|
|
+ !sentToServer.current.has(item.item._id)
|
|
|
+ )
|
|
|
+ .map((item) => item.item._id);
|
|
|
+
|
|
|
+ if (newViewableUnreadMessages.length > 0) {
|
|
|
+ markMessagesAsRead(
|
|
|
+ {
|
|
|
+ token,
|
|
|
+ from_user: id,
|
|
|
+ messages_id: newViewableUnreadMessages
|
|
|
+ },
|
|
|
+ {
|
|
|
+ onSuccess: () => {
|
|
|
+ newViewableUnreadMessages.forEach((id) => sentToServer.current.add(id));
|
|
|
+ sendWebSocketMessage('messages_read', null, null, newViewableUnreadMessages);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const renderSystemMessage = (props: any) => {
|
|
|
+ if (props.currentMessage._id === 'unreadMarker') {
|
|
|
+ return (
|
|
|
+ <View style={styles.unreadMessagesContainer}>
|
|
|
+ <Text style={styles.unreadMessagesText}>{props.currentMessage.text}</Text>
|
|
|
+ </View>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const clearReplyMessage = () => setReplyMessage(null);
|
|
|
+
|
|
|
+ const handleLongPress = (message: CustomMessage, props: BubbleProps<CustomMessage>) => {
|
|
|
+ const messageRef = messageRefs.current[message._id];
|
|
|
+
|
|
|
+ setSelectedMessage(props);
|
|
|
+ trigger('impactMedium', options);
|
|
|
+
|
|
|
+ const isMine = message.user._id === +currentUserId;
|
|
|
+
|
|
|
+ if (messageRef) {
|
|
|
+ messageRef.measureInWindow((x: number, y: number, width: number, height: number) => {
|
|
|
+ const screenHeight = Dimensions.get('window').height;
|
|
|
+ const spaceAbove = y - insets.top;
|
|
|
+ const spaceBelow = screenHeight - (y + height) - insets.bottom * 2;
|
|
|
+
|
|
|
+ let finalY = y;
|
|
|
+ scrollY.value = 0;
|
|
|
+
|
|
|
+ if (isNaN(y) || isNaN(height)) {
|
|
|
+ console.error("Invalid measurement values for 'y' or 'height'", { y, height });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (spaceBelow < 160) {
|
|
|
+ const extraShift = 160 - spaceBelow;
|
|
|
+ finalY -= extraShift;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (spaceAbove < 50) {
|
|
|
+ const extraShift = 50 - spaceAbove;
|
|
|
+ finalY += extraShift;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (spaceBelow < 160 || spaceAbove < 50) {
|
|
|
+ const targetY = screenHeight / 2 - height / 2;
|
|
|
+ scrollY.value = withTiming(finalY - finalY);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (height > Dimensions.get('window').height - 200) {
|
|
|
+ finalY = 100;
|
|
|
+ }
|
|
|
+
|
|
|
+ finalY = isNaN(finalY) ? 0 : finalY;
|
|
|
+
|
|
|
+ setMessagePosition({ x, y: finalY, width, height, isMine });
|
|
|
+ setIsModalVisible(true);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const openEmojiSelector = () => {
|
|
|
+ SheetManager.show('emoji-selector');
|
|
|
+ trigger('impactLight', options);
|
|
|
+ };
|
|
|
+
|
|
|
+ const closeEmojiSelector = () => {
|
|
|
+ SheetManager.hide('emoji-selector');
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleReactionPress = (emoji: string, messageId: number) => {
|
|
|
+ addReaction(messageId, emoji);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleDeleteMessage = (messageId: number) => {
|
|
|
+ deleteMessage(
|
|
|
+ {
|
|
|
+ token,
|
|
|
+ message_id: messageId,
|
|
|
+ conversation_with_user: id
|
|
|
+ },
|
|
|
+ {
|
|
|
+ onSuccess: () => {
|
|
|
+ setMessages(
|
|
|
+ (prevMessages) =>
|
|
|
+ prevMessages?.map((msg) => {
|
|
|
+ if (msg._id === messageId) {
|
|
|
+ return {
|
|
|
+ ...msg,
|
|
|
+ deleted: true,
|
|
|
+ text: 'This message was deleted',
|
|
|
+ pending: false,
|
|
|
+ sent: false,
|
|
|
+ received: false
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return msg;
|
|
|
+ }) ?? []
|
|
|
+ );
|
|
|
+ const messageToDelete = messages?.find((msg) => msg._id === messageId);
|
|
|
+ if (messageToDelete) {
|
|
|
+ sendWebSocketMessage('delete_message', messageToDelete, null, null);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleOptionPress = (option: string) => {
|
|
|
+ if (!selectedMessage) return;
|
|
|
+
|
|
|
+ switch (option) {
|
|
|
+ case 'reply':
|
|
|
+ setReplyMessage(selectedMessage.currentMessage);
|
|
|
+ setIsModalVisible(false);
|
|
|
+ break;
|
|
|
+ case 'copy':
|
|
|
+ Clipboard.setString(selectedMessage?.currentMessage?.text ?? '');
|
|
|
+ setIsModalVisible(false);
|
|
|
+ Alert.alert('Copied');
|
|
|
+ break;
|
|
|
+ case 'delete':
|
|
|
+ handleDeleteMessage(selectedMessage.currentMessage?._id);
|
|
|
+ setIsModalVisible(false);
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ closeEmojiSelector();
|
|
|
+ };
|
|
|
+
|
|
|
+ const openReactionList = (
|
|
|
+ reactions: { uid: number; name: string; reaction: string }[],
|
|
|
+ messageId: number
|
|
|
+ ) => {
|
|
|
+ SheetManager.show('reactions-list-modal', {
|
|
|
+ payload: {
|
|
|
+ users: reactions,
|
|
|
+ currentUserId: +currentUserId,
|
|
|
+ token,
|
|
|
+ messageId,
|
|
|
+ conversation_with_user: id,
|
|
|
+ setMessages,
|
|
|
+ sendWebSocketMessage
|
|
|
+ } as any
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const renderTimeContainer = (time: TimeProps<CustomMessage>) => {
|
|
|
+ const createdAt = new Date(time.currentMessage.createdAt);
|
|
|
+
|
|
|
+ const formattedTime = createdAt.toLocaleTimeString([], {
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ hour12: true
|
|
|
+ });
|
|
|
+
|
|
|
+ const hasReactions =
|
|
|
+ time.currentMessage.reactions &&
|
|
|
+ Array.isArray(time.currentMessage.reactions) &&
|
|
|
+ time.currentMessage.reactions.length > 0;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <View
|
|
|
+ style={[
|
|
|
+ styles.bottomContainer,
|
|
|
+ {
|
|
|
+ justifyContent: hasReactions ? 'space-between' : 'flex-end'
|
|
|
+ }
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ {hasReactions && (
|
|
|
+ <TouchableOpacity
|
|
|
+ style={[
|
|
|
+ styles.bottomCustomContainer,
|
|
|
+ {
|
|
|
+ backgroundColor:
|
|
|
+ time.position === 'left' ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.2)'
|
|
|
+ }
|
|
|
+ ]}
|
|
|
+ onPress={() =>
|
|
|
+ Array.isArray(time.currentMessage.reactions) &&
|
|
|
+ openReactionList(
|
|
|
+ time.currentMessage.reactions.map((reaction) => ({
|
|
|
+ ...reaction,
|
|
|
+ name: reaction.uid === id ? userName : 'Me'
|
|
|
+ })),
|
|
|
+ time.currentMessage._id
|
|
|
+ )
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {Object.entries(
|
|
|
+ (Array.isArray(time.currentMessage.reactions)
|
|
|
+ ? time.currentMessage.reactions
|
|
|
+ : []
|
|
|
+ ).reduce(
|
|
|
+ (acc: Record<string, { count: number }>, { reaction }: { reaction: string }) => {
|
|
|
+ if (!acc[reaction]) {
|
|
|
+ acc[reaction] = { count: 0 };
|
|
|
+ }
|
|
|
+ acc[reaction].count += 1;
|
|
|
+ return acc;
|
|
|
+ },
|
|
|
+ {}
|
|
|
+ )
|
|
|
+ ).map(([emoji, { count }]: any) => {
|
|
|
+ return (
|
|
|
+ <View key={emoji}>
|
|
|
+ <Text style={{}}>
|
|
|
+ {emoji}
|
|
|
+ {(count as number) > 1 ? ` ${count}` : ''}
|
|
|
+ </Text>
|
|
|
+ </View>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </TouchableOpacity>
|
|
|
+ )}
|
|
|
+ <View style={styles.timeContainer}>
|
|
|
+ <Text style={styles.timeText}>{formattedTime}</Text>
|
|
|
+ {renderTicks(time.currentMessage)}
|
|
|
+ </View>
|
|
|
+ </View>
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const renderSelectedMessage = () =>
|
|
|
+ selectedMessage && (
|
|
|
+ <View
|
|
|
+ style={{
|
|
|
+ maxHeight: '80%',
|
|
|
+ width: messagePosition?.width,
|
|
|
+ position: 'absolute',
|
|
|
+ top: messagePosition?.y,
|
|
|
+ left: messagePosition?.x
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <ScrollView>
|
|
|
+ <Bubble
|
|
|
+ {...selectedMessage}
|
|
|
+ wrapperStyle={{
|
|
|
+ right: { backgroundColor: Colors.DARK_BLUE },
|
|
|
+ left: { backgroundColor: Colors.FILL_LIGHT }
|
|
|
+ }}
|
|
|
+ textStyle={{
|
|
|
+ right: { color: Colors.WHITE },
|
|
|
+ left: { color: Colors.DARK_BLUE }
|
|
|
+ }}
|
|
|
+ renderTicks={() => null}
|
|
|
+ renderTime={renderTimeContainer}
|
|
|
+ />
|
|
|
+ </ScrollView>
|
|
|
+ </View>
|
|
|
+ );
|
|
|
+
|
|
|
+ const handleBackgroundPress = () => {
|
|
|
+ setIsModalVisible(false);
|
|
|
+ setSelectedMessage(null);
|
|
|
+ closeEmojiSelector();
|
|
|
+ };
|
|
|
+
|
|
|
+ useFocusEffect(
|
|
|
+ useCallback(() => {
|
|
|
+ navigation?.getParent()?.setOptions({
|
|
|
+ tabBarStyle: {
|
|
|
+ display: 'none'
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }, [navigation])
|
|
|
+ );
|
|
|
+
|
|
|
+ const onSend = useCallback(
|
|
|
+ (newMessages: CustomMessage[] = []) => {
|
|
|
+ if (replyMessage) {
|
|
|
+ newMessages[0].replyMessage = {
|
|
|
+ text: replyMessage.text,
|
|
|
+ id: replyMessage._id,
|
|
|
+ name: replyMessage.user._id === id ? userName : 'Me'
|
|
|
+ };
|
|
|
+ }
|
|
|
+ const message = { ...newMessages[0], pending: true };
|
|
|
+
|
|
|
+ setMessages((previousMessages) => GiftedChat.append(previousMessages ?? [], [message]));
|
|
|
+
|
|
|
+ sendMessage(
|
|
|
+ {
|
|
|
+ token,
|
|
|
+ to_uid: id,
|
|
|
+ text: message.text,
|
|
|
+ reply_to_id: replyMessage ? (replyMessage._id as number) : -1
|
|
|
+ },
|
|
|
+ {
|
|
|
+ onSuccess: (res) => {
|
|
|
+ const newMessage = {
|
|
|
+ _id: res.message_id,
|
|
|
+ text: message.text,
|
|
|
+ replyMessage: { ...message.replyMessage, sender: replyMessage?.user?._id }
|
|
|
+ };
|
|
|
+
|
|
|
+ setMessages((previousMessages) =>
|
|
|
+ (previousMessages ?? []).map((msg) =>
|
|
|
+ msg._id === message._id ? { ...msg, _id: res.message_id } : msg
|
|
|
+ )
|
|
|
+ );
|
|
|
+ sendWebSocketMessage('new_message', newMessage as unknown as CustomMessage);
|
|
|
+ },
|
|
|
+ onError: (err) => console.log('err', err)
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ clearReplyMessage();
|
|
|
+ },
|
|
|
+ [replyMessage]
|
|
|
+ );
|
|
|
+
|
|
|
+ const addReaction = (messageId: number, reaction: string) => {
|
|
|
+ if (!messages) return;
|
|
|
+
|
|
|
+ const updatedMessages = messages.map((msg: any) => {
|
|
|
+ if (msg._id === messageId) {
|
|
|
+ const updatedReactions: Reaction[] = [
|
|
|
+ ...(Array.isArray(msg.reactions)
|
|
|
+ ? msg.reactions?.filter((r: Reaction) => r.uid !== +currentUserId)
|
|
|
+ : []),
|
|
|
+ { datetime: new Date().toISOString(), reaction: reaction, uid: +currentUserId }
|
|
|
+ ];
|
|
|
+
|
|
|
+ return {
|
|
|
+ ...msg,
|
|
|
+ reactions: updatedReactions
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return msg;
|
|
|
+ });
|
|
|
+
|
|
|
+ setMessages(updatedMessages);
|
|
|
+
|
|
|
+ reactToMessage(
|
|
|
+ { token, message_id: messageId, reaction: reaction, conversation_with_user: id },
|
|
|
+ {
|
|
|
+ onSuccess: () => {
|
|
|
+ const message = messages.find((msg) => msg._id === messageId);
|
|
|
+ if (message) {
|
|
|
+ sendWebSocketMessage('new_reaction', message, reaction);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onError: (err) => console.log('err', err)
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ setIsModalVisible(false);
|
|
|
+ };
|
|
|
+
|
|
|
+ const updateRowRef = useCallback(
|
|
|
+ (ref: any) => {
|
|
|
+ if (
|
|
|
+ ref &&
|
|
|
+ replyMessage &&
|
|
|
+ ref.props.children.props.currentMessage?._id === replyMessage._id
|
|
|
+ ) {
|
|
|
+ swipeableRowRef.current = ref;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [replyMessage]
|
|
|
+ );
|
|
|
+
|
|
|
+ const renderReplyMessageView = (props: BubbleProps<CustomMessage>) => {
|
|
|
+ if (!props.currentMessage) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ const { currentMessage } = props;
|
|
|
+
|
|
|
+ if (!currentMessage || !currentMessage?.replyMessage) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <TouchableOpacity
|
|
|
+ style={[
|
|
|
+ styles.replyMessageContainer,
|
|
|
+ {
|
|
|
+ backgroundColor:
|
|
|
+ currentMessage.user._id === id ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.2)',
|
|
|
+ borderColor: currentMessage.user._id === id ? Colors.DARK_BLUE : Colors.WHITE
|
|
|
+ }
|
|
|
+ ]}
|
|
|
+ onPress={() => {
|
|
|
+ if (currentMessage?.replyMessage?.id) {
|
|
|
+ scrollToMessage(currentMessage.replyMessage.id);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <View style={styles.replyContent}>
|
|
|
+ <Text
|
|
|
+ style={[
|
|
|
+ styles.replyAuthorName,
|
|
|
+ { color: currentMessage.user._id === id ? Colors.DARK_BLUE : Colors.WHITE }
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ {currentMessage.replyMessage.name}
|
|
|
+ </Text>
|
|
|
+
|
|
|
+ <Text
|
|
|
+ numberOfLines={1}
|
|
|
+ style={[
|
|
|
+ styles.replyMessageText,
|
|
|
+ { color: currentMessage.user._id === id ? Colors.DARK_BLUE : Colors.WHITE }
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ {currentMessage.replyMessage.text}
|
|
|
+ </Text>
|
|
|
+ </View>
|
|
|
+ </TouchableOpacity>
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const scrollToMessage = (messageId: number) => {
|
|
|
+ if (!messages) return;
|
|
|
+
|
|
|
+ const messageIndex = messages.findIndex((message) => message._id === messageId);
|
|
|
+
|
|
|
+ if (messageIndex !== -1 && flatList.current) {
|
|
|
+ flatList.current.scrollToIndex({
|
|
|
+ index: messageIndex,
|
|
|
+ animated: true,
|
|
|
+ viewPosition: 0.5
|
|
|
+ });
|
|
|
+
|
|
|
+ setHighlightedMessageId(messageId);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (highlightedMessageId && isRerendering) {
|
|
|
+ setTimeout(() => {
|
|
|
+ setHighlightedMessageId(null);
|
|
|
+ setIsRerendering(false);
|
|
|
+ }, 1500);
|
|
|
+ }
|
|
|
+ }, [highlightedMessageId, isRerendering]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (replyMessage && swipeableRowRef.current) {
|
|
|
+ swipeableRowRef.current.close();
|
|
|
+ swipeableRowRef.current = null;
|
|
|
+ }
|
|
|
+ }, [replyMessage]);
|
|
|
+
|
|
|
+ const renderMessageImage = (props: any) => {
|
|
|
+ const { currentMessage } = props;
|
|
|
+ return (
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => setSelectedMedia(currentMessage.image)}
|
|
|
+ style={styles.imageContainer}
|
|
|
+ >
|
|
|
+ <Image source={{ uri: currentMessage.image }} style={styles.chatImage} resizeMode="cover" />
|
|
|
+ </TouchableOpacity>
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const renderTicks = (message: CustomMessage) => {
|
|
|
+ if (message.user._id === id) return null;
|
|
|
+
|
|
|
+ return message.received ? (
|
|
|
+ <View>
|
|
|
+ <MaterialCommunityIcons name="check-all" size={16} color={Colors.WHITE} />
|
|
|
+ </View>
|
|
|
+ ) : message.sent ? (
|
|
|
+ <View>
|
|
|
+ <MaterialCommunityIcons name="check" size={16} color={Colors.WHITE} />
|
|
|
+ </View>
|
|
|
+ ) : message.pending ? (
|
|
|
+ <View>
|
|
|
+ <MaterialCommunityIcons name="check" size={16} color={Colors.LIGHT_GRAY} />
|
|
|
+ </View>
|
|
|
+ ) : null;
|
|
|
+ };
|
|
|
+
|
|
|
+ const renderBubble = (props: BubbleProps<CustomMessage>) => {
|
|
|
+ const { currentMessage } = props;
|
|
|
+
|
|
|
+ if (currentMessage.deleted) {
|
|
|
+ const text = currentMessage.text.length
|
|
|
+ ? props.currentMessage.text
|
|
|
+ : 'This message was deleted';
|
|
|
+
|
|
|
+ return (
|
|
|
+ <View>
|
|
|
+ <Bubble
|
|
|
+ {...props}
|
|
|
+ renderTime={() => null}
|
|
|
+ currentMessage={{
|
|
|
+ ...props.currentMessage,
|
|
|
+ text: text
|
|
|
+ }}
|
|
|
+ renderMessageText={() => (
|
|
|
+ <View style={{ paddingHorizontal: 12, paddingVertical: 6 }}>
|
|
|
+ <Text style={{ color: Colors.LIGHT_GRAY, fontStyle: 'italic', fontSize: 12 }}>
|
|
|
+ {text}
|
|
|
+ </Text>
|
|
|
+ </View>
|
|
|
+ )}
|
|
|
+ wrapperStyle={{
|
|
|
+ right: {
|
|
|
+ backgroundColor: Colors.DARK_BLUE
|
|
|
+ },
|
|
|
+ left: {
|
|
|
+ backgroundColor: Colors.FILL_LIGHT
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ textStyle={{
|
|
|
+ left: {
|
|
|
+ color: Colors.DARK_BLUE
|
|
|
+ },
|
|
|
+ right: {
|
|
|
+ color: Colors.WHITE
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </View>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const isHighlighted = currentMessage._id === highlightedMessageId;
|
|
|
+ const backgroundColor = isHighlighted
|
|
|
+ ? Colors.ORANGE
|
|
|
+ : currentMessage.user._id === +currentUserId
|
|
|
+ ? Colors.DARK_BLUE
|
|
|
+ : Colors.FILL_LIGHT;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <View
|
|
|
+ key={`${currentMessage._id}-${isHighlighted ? 'highlighted' : 'normal'}`}
|
|
|
+ ref={(ref) => {
|
|
|
+ if (ref && currentMessage) {
|
|
|
+ messageRefs.current[currentMessage._id] = ref;
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ collapsable={false}
|
|
|
+ >
|
|
|
+ <Bubble
|
|
|
+ {...props}
|
|
|
+ wrapperStyle={{
|
|
|
+ right: {
|
|
|
+ backgroundColor: backgroundColor
|
|
|
+ },
|
|
|
+ left: {
|
|
|
+ backgroundColor: backgroundColor
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ textStyle={{
|
|
|
+ left: {
|
|
|
+ color: Colors.DARK_BLUE
|
|
|
+ },
|
|
|
+ right: {
|
|
|
+ color: Colors.FILL_LIGHT
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onLongPress={() => handleLongPress(currentMessage, props)}
|
|
|
+ renderTicks={() => null}
|
|
|
+ renderTime={renderTimeContainer}
|
|
|
+ />
|
|
|
+ </View>
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const openAttachmentsModal = () => {
|
|
|
+ SheetManager.show('chat-attachments', {
|
|
|
+ payload: {
|
|
|
+ name: userName,
|
|
|
+ uid: id,
|
|
|
+ setModalInfo,
|
|
|
+ closeOptions: () => setShowOptions(false),
|
|
|
+ onSendMedia,
|
|
|
+ onSendLocation,
|
|
|
+ onShareLiveLocation
|
|
|
+ } as any
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const renderInputToolbar = (props: any) => (
|
|
|
+ <View>
|
|
|
+ <InputToolbar
|
|
|
+ {...props}
|
|
|
+ renderActions={() => (
|
|
|
+ <Actions
|
|
|
+ icon={() => (
|
|
|
+ <MaterialCommunityIcons
|
|
|
+ name={!isKeyboardOpen && showOptions ? 'keyboard-outline' : 'plus'}
|
|
|
+ size={28}
|
|
|
+ color={Colors.DARK_BLUE}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ onPressActionButton={openAttachmentsModal}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ containerStyle={{
|
|
|
+ backgroundColor: Colors.FILL_LIGHT
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {/* <InputAccessoryView nativeID={'inputAccessoryViewID'}>
|
|
|
+ <InputToolbar
|
|
|
+ {...props}
|
|
|
+ renderActions={() => (
|
|
|
+ <Actions
|
|
|
+ icon={() => (
|
|
|
+ <MaterialCommunityIcons
|
|
|
+ name={!isKeyboardOpen && showOptions ? 'keyboard-outline' : 'plus'}
|
|
|
+ size={28}
|
|
|
+ color={Colors.DARK_BLUE}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ onPressActionButton={toggleOptions}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ containerStyle={{
|
|
|
+ backgroundColor: Colors.FILL_LIGHT
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </InputAccessoryView> */}
|
|
|
+
|
|
|
+ {/* {showOptions && renderOptionsView()} */}
|
|
|
+ </View>
|
|
|
+ );
|
|
|
+
|
|
|
+ const renderScrollToBottom = () => {
|
|
|
+ return (
|
|
|
+ <TouchableOpacity
|
|
|
+ style={styles.scrollToBottom}
|
|
|
+ onPress={() => {
|
|
|
+ if (flatList.current) {
|
|
|
+ flatList.current.scrollToIndex({ index: 0, animated: true });
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <MaterialCommunityIcons name="chevron-down" size={24} color={Colors.WHITE} />
|
|
|
+ </TouchableOpacity>
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ const shouldUpdateMessage = (
|
|
|
+ props: MessageProps<IMessage>,
|
|
|
+ nextProps: MessageProps<IMessage>
|
|
|
+ ) => {
|
|
|
+ setIsRerendering(true);
|
|
|
+ const currentId = nextProps.currentMessage._id;
|
|
|
+ return currentId === highlightedMessageId;
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <SafeAreaView
|
|
|
+ edges={['top']}
|
|
|
+ style={{
|
|
|
+ height: '100%'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <KeyboardAvoidingView
|
|
|
+ style={{ flex: 1 }}
|
|
|
+ behavior={'height'}
|
|
|
+ keyboardVerticalOffset={Platform.select({ android: 34 })}
|
|
|
+ >
|
|
|
+ <View style={{ paddingHorizontal: '5%' }}>
|
|
|
+ <Header
|
|
|
+ label={userName}
|
|
|
+ textColor={userType !== 'normal' ? Colors.RED : Colors.DARK_BLUE}
|
|
|
+ rightElement={
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() =>
|
|
|
+ navigation.navigate(
|
|
|
+ ...([NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: id }] as never)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ disabled={userType !== 'normal'}
|
|
|
+ >
|
|
|
+ {avatar && userType === 'normal' ? (
|
|
|
+ <Image source={{ uri: API_HOST + avatar }} style={styles.avatar} />
|
|
|
+ ) : userType === 'normal' ? (
|
|
|
+ <AvatarWithInitials
|
|
|
+ text={
|
|
|
+ name
|
|
|
+ .split(/ (.+)/)
|
|
|
+ .map((n) => n[0])
|
|
|
+ .join('') ?? ''
|
|
|
+ }
|
|
|
+ flag={API_HOST + 'flag.png'}
|
|
|
+ size={30}
|
|
|
+ fontSize={12}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <BanIcon fill={Colors.RED} width={30} height={30} />
|
|
|
+ )}
|
|
|
+ </TouchableOpacity>
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </View>
|
|
|
+
|
|
|
+ <GestureHandlerRootView style={styles.container}>
|
|
|
+ {messages ? (
|
|
|
+ <GiftedChat
|
|
|
+ messages={messages as CustomMessage[]}
|
|
|
+ listViewProps={{
|
|
|
+ ref: flatList,
|
|
|
+ // onTouchStart: handleTouchOutside,
|
|
|
+ // onTouchEnd: handleTouchOutside,
|
|
|
+ showsVerticalScrollIndicator: false,
|
|
|
+ initialNumToRender: 30,
|
|
|
+ onViewableItemsChanged: handleViewableItemsChanged,
|
|
|
+ viewabilityConfig: { itemVisiblePercentThreshold: 50 },
|
|
|
+ onScrollToIndexFailed: (info: any) => {
|
|
|
+ const wait = new Promise((resolve) => setTimeout(resolve, 300));
|
|
|
+ wait.then(() => {
|
|
|
+ flatList.current?.scrollToIndex({
|
|
|
+ index: info.index,
|
|
|
+ animated: true,
|
|
|
+ viewPosition: 0.5
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ renderSystemMessage={renderSystemMessage}
|
|
|
+ onSend={(newMessages: CustomMessage[]) => onSend(newMessages)}
|
|
|
+ user={{ _id: +currentUserId, name: 'Me' }}
|
|
|
+ renderBubble={renderBubble}
|
|
|
+ renderMessageImage={renderMessageImage}
|
|
|
+ renderInputToolbar={renderInputToolbar}
|
|
|
+ renderCustomView={renderReplyMessageView}
|
|
|
+ isCustomViewBottom={false}
|
|
|
+ messageContainerRef={messageContainerRef}
|
|
|
+ minComposerHeight={34}
|
|
|
+ onInputTextChanged={(text) => handleTyping(text.length > 0)}
|
|
|
+ textInputRef={textInputRef}
|
|
|
+ isTyping={isTyping}
|
|
|
+ renderSend={(props) => (
|
|
|
+ <View style={styles.sendBtn}>
|
|
|
+ {props.text?.trim() && (
|
|
|
+ <Send
|
|
|
+ {...props}
|
|
|
+ containerStyle={{
|
|
|
+ justifyContent: 'center'
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <SendIcon fill={Colors.DARK_BLUE} />
|
|
|
+ </Send>
|
|
|
+ )}
|
|
|
+ {!props.text?.trim() && <SendIcon fill={Colors.LIGHT_GRAY} />}
|
|
|
+ </View>
|
|
|
+ )}
|
|
|
+ renderMessageVideo={renderMessageVideo}
|
|
|
+ textInputProps={{
|
|
|
+ ...styles.composer,
|
|
|
+ selectionColor: Colors.LIGHT_GRAY,
|
|
|
+ onFocus: handleInputFocus
|
|
|
+ }}
|
|
|
+ isKeyboardInternallyHandled={false}
|
|
|
+ placeholder=""
|
|
|
+ renderMessage={(props) => (
|
|
|
+ <ChatMessageBox
|
|
|
+ {...(props as MessageProps<CustomMessage>)}
|
|
|
+ updateRowRef={updateRowRef}
|
|
|
+ setReplyOnSwipeOpen={setReplyMessage}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ renderChatFooter={() => (
|
|
|
+ <ReplyMessageBar clearReply={clearReplyMessage} message={replyMessage} />
|
|
|
+ )}
|
|
|
+ renderAvatar={null}
|
|
|
+ maxComposerHeight={100}
|
|
|
+ renderComposer={(props) => <Composer {...props} />}
|
|
|
+ keyboardShouldPersistTaps="handled"
|
|
|
+ renderChatEmpty={() => (
|
|
|
+ <View style={styles.emptyChat}>
|
|
|
+ <Text
|
|
|
+ style={styles.emptyChatText}
|
|
|
+ >{`No messages yet.\nFeel free to start the conversation.`}</Text>
|
|
|
+ </View>
|
|
|
+ )}
|
|
|
+ shouldUpdateMessage={shouldUpdateMessage}
|
|
|
+ scrollToBottom={true}
|
|
|
+ scrollToBottomComponent={renderScrollToBottom}
|
|
|
+ scrollToBottomStyle={{ backgroundColor: 'transparent' }}
|
|
|
+ parsePatterns={(linkStyle) => [
|
|
|
+ {
|
|
|
+ type: 'url',
|
|
|
+ style: { color: Colors.ORANGE, textDecorationLine: 'underline' },
|
|
|
+ onPress: (url: string) => Linking.openURL(url),
|
|
|
+ onLongPress: (url: string) => {
|
|
|
+ Clipboard.setString(url ?? '');
|
|
|
+ Alert.alert('Link copied');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]}
|
|
|
+ infiniteScroll={true}
|
|
|
+ loadEarlier={hasMoreMessages}
|
|
|
+ isLoadingEarlier={isLoadingEarlier}
|
|
|
+ onLoadEarlier={loadEarlierMessages}
|
|
|
+ renderLoadEarlier={() => (
|
|
|
+ <View style={{ paddingVertical: 20 }}>
|
|
|
+ <ActivityIndicator size="small" color={Colors.DARK_BLUE} />
|
|
|
+ </View>
|
|
|
+ )}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <ActivityIndicator size="large" color={Colors.DARK_BLUE} />
|
|
|
+ )}
|
|
|
+
|
|
|
+ <Modal visible={!!selectedMedia} transparent={true}>
|
|
|
+ <View style={styles.modalContainer}>
|
|
|
+ {selectedMedia && selectedMedia?.includes('.mp4') ? (
|
|
|
+ <Video
|
|
|
+ source={{ uri: selectedMedia }}
|
|
|
+ style={styles.fullScreenMedia}
|
|
|
+ useNativeControls
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <Image source={{ uri: selectedMedia ?? '' }} style={styles.fullScreenMedia} />
|
|
|
+ )}
|
|
|
+ <TouchableOpacity onPress={() => setSelectedMedia(null)} style={styles.closeButton}>
|
|
|
+ <MaterialCommunityIcons name="close" size={30} color="white" />
|
|
|
+ </TouchableOpacity>
|
|
|
+ </View>
|
|
|
+ </Modal>
|
|
|
+
|
|
|
+ <ReactModal
|
|
|
+ isVisible={isModalVisible}
|
|
|
+ onBackdropPress={handleBackgroundPress}
|
|
|
+ style={styles.reactModalContainer}
|
|
|
+ animationIn="fadeIn"
|
|
|
+ animationOut="fadeOut"
|
|
|
+ useNativeDriver
|
|
|
+ backdropColor="transparent"
|
|
|
+ >
|
|
|
+ <BlurView
|
|
|
+ intensity={80}
|
|
|
+ style={styles.modalBackground}
|
|
|
+ experimentalBlurMethod="dimezisBlurView"
|
|
|
+ >
|
|
|
+ <TouchableOpacity
|
|
|
+ style={styles.modalBackground}
|
|
|
+ activeOpacity={1}
|
|
|
+ onPress={handleBackgroundPress}
|
|
|
+ >
|
|
|
+ <ReactionBar
|
|
|
+ messagePosition={messagePosition}
|
|
|
+ selectedMessage={selectedMessage}
|
|
|
+ reactionEmojis={reactionEmojis}
|
|
|
+ handleReactionPress={handleReactionPress}
|
|
|
+ openEmojiSelector={openEmojiSelector}
|
|
|
+ />
|
|
|
+ {renderSelectedMessage()}
|
|
|
+ <OptionsMenu
|
|
|
+ selectedMessage={selectedMessage}
|
|
|
+ handleOptionPress={handleOptionPress}
|
|
|
+ messagePosition={messagePosition}
|
|
|
+ />
|
|
|
+ <EmojiSelectorModal
|
|
|
+ visible={emojiSelectorVisible}
|
|
|
+ selectedMessage={selectedMessage}
|
|
|
+ addReaction={addReaction}
|
|
|
+ closeEmojiSelector={closeEmojiSelector}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ </BlurView>
|
|
|
+ </ReactModal>
|
|
|
+
|
|
|
+ <WarningModal
|
|
|
+ isVisible={modalInfo.visible}
|
|
|
+ onClose={closeModal}
|
|
|
+ type={modalInfo.type}
|
|
|
+ message={modalInfo.message}
|
|
|
+ buttonTitle={modalInfo.buttonTitle}
|
|
|
+ title={modalInfo.title}
|
|
|
+ action={() => {
|
|
|
+ modalInfo.action();
|
|
|
+ closeModal();
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <AttachmentsModal />
|
|
|
+ <ReactionsListModal />
|
|
|
+ <Animated.View
|
|
|
+ style={{
|
|
|
+ transform: [{ translateY }]
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* {renderOptionsView()} */}
|
|
|
+ </Animated.View>
|
|
|
+ </GestureHandlerRootView>
|
|
|
+ </KeyboardAvoidingView>
|
|
|
+ {showOptions && renderOptionsView()}
|
|
|
+ <View
|
|
|
+ style={{
|
|
|
+ height: insets.bottom,
|
|
|
+ backgroundColor: Colors.FILL_LIGHT
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </SafeAreaView>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default ChatScreen;
|