diff --git a/babel.config.js b/babel.config.js index 688f890e..599d5ad0 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,6 +2,7 @@ module.exports = { presets: ['module:metro-react-native-babel-preset'], plugins: [ '@babel/plugin-transform-async-generator-functions', + '@babel/plugin-transform-async-to-generator', 'react-native-reanimated/plugin', 'preval', [ diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 457b227a..a725e89e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -580,8 +580,6 @@ PODS: - React-Core - RNCMaskedView (0.1.11): - React - - RNCPicker (2.6.1): - - React-Core - RNCPushNotificationIOS (1.11.0): - React-Core - RNDateTimePicker (6.7.5): @@ -739,7 +737,6 @@ DEPENDENCIES: - RNBootSplash (from `../node_modules/react-native-bootsplash`) - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCMaskedView (from `../node_modules/@react-native-community/masked-view`)" - - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - "RNCPushNotificationIOS (from `../node_modules/@react-native-community/push-notification-ios`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) @@ -903,8 +900,6 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-async-storage/async-storage" RNCMaskedView: :path: "../node_modules/@react-native-community/masked-view" - RNCPicker: - :path: "../node_modules/@react-native-picker/picker" RNCPushNotificationIOS: :path: "../node_modules/@react-native-community/push-notification-ios" RNDateTimePicker: @@ -1014,7 +1009,6 @@ SPEC CHECKSUMS: RNBootSplash: 85f6b879c080e958afdb4c62ee04497b05fd7552 RNCAsyncStorage: 687bb9e85dd3d45b966662440dcfc0cd962347e6 RNCMaskedView: 0e1bc4bfa8365eba5fbbb71e07fbdc0555249489 - RNCPicker: b18aaf30df596e9b1738e7c1f9ee55402a229dca RNCPushNotificationIOS: 64218f3c776c03d7408284a819b2abfda1834bc8 RNDateTimePicker: 65e1d202799460b286ff5e741d8baf54695e8abd RNDeviceInfo: aad3c663b25752a52bf8fce93f2354001dd185aa diff --git a/package.json b/package.json index 945bc074..2df83e07 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "react-native-geolocation-service": "^5.3.0-beta.1", "react-native-gesture-handler": "^1.10.3", "react-native-get-random-values": "^1.9.0", + "react-native-gifted-chat": "^2.4.0", "react-native-google-places-autocomplete": "^2.2.0", "react-native-image-picker": "^4.4.0", "react-native-image-resizer": "^1.4.5", @@ -86,7 +87,7 @@ "react-native-maps": "^2.0.0-beta.14", "react-native-maps-directions": "^1.8.0", "react-native-mmkv-storage": "^0.9.0", - "react-native-modal": "^12.0.2", + "react-native-modal": "^13.0.1", "react-native-navigation-apps": "^1.0.27", "react-native-open-maps": "^0.4.0", "react-native-permissions": "^3.0.5", @@ -104,7 +105,7 @@ "react-native-webview": "^13.6.0", "react-native-youtube": "^2.0.2", "react-redux": "^7.2.5", - "socketcluster-client": "^16.0.4", + "socketcluster-client": "^17.1.1", "tailwind-rn": "^3.0.1", "tailwindcss": "^2.2.4", "uuid": "^9.0.0" @@ -112,6 +113,7 @@ "devDependencies": { "@babel/core": "^7.20.0", "@babel/plugin-transform-async-generator-functions": "^7.22.15", + "@babel/plugin-transform-async-to-generator": "^7.24.1", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", "@react-native/eslint-config": "^0.72.2", diff --git a/src/features/Core/MainStack.js b/src/features/Core/MainStack.js index ccc53376..a6882ae5 100644 --- a/src/features/Core/MainStack.js +++ b/src/features/Core/MainStack.js @@ -9,6 +9,9 @@ import ProofScreen from 'shared/ProofScreen'; import MainScreen from './screens/MainScreen'; import SearchScreen from './screens/SearchScreen'; import IssueScreen from './screens/IssueScreen'; +import ChatScreen from './screens/ChatScreen'; +import ChatsScreen from './screens/ChatsScreen'; +import ChannelScreen from './screens/ChannelScreen'; const RootStack = createStackNavigator(); @@ -30,6 +33,9 @@ const MainStack = ({ route }) => { + + + diff --git a/src/features/Core/screens/ChannelScreen.js b/src/features/Core/screens/ChannelScreen.js new file mode 100644 index 00000000..43723794 --- /dev/null +++ b/src/features/Core/screens/ChannelScreen.js @@ -0,0 +1,95 @@ +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { useNavigation } from '@react-navigation/native'; +import { useFleetbase } from 'hooks'; +import React, { useEffect, useState } from 'react'; +import Toast from 'react-native-toast-message'; +import { ActivityIndicator, Keyboard, KeyboardAvoidingView, Pressable, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import tailwind from 'tailwind'; +import { getColorCode, logError, translate } from 'utils'; + +const ChannelScreen = ({ route }) => { + const navigation = useNavigation(); + const fleetbase = useFleetbase(); + const [name, setName] = useState(); + const [isLoading, setIsLoading] = useState(false); + const [channelId, setChannelId] = useState(); + + useEffect(() => { + if (route?.params) { + const { data } = route.params; + setChannelId(data?.id); + setName(data?.name); + } + }, [route]); + + const saveChannel = () => { + setIsLoading(true); + const adapter = fleetbase.getAdapter(); + const data = { name }; + if (channelId) { + return adapter + .put(`chat-channels/${channelId}`, data) + .then(res => { + navigation.navigate('ChatScreen', { channel: res }); + }) + .catch(logError) + .finally(() => setIsLoading(false)); + } else { + return adapter + .post('chat-channels', { name }) + .then(res => { + navigation.navigate('ChatScreen', { channel: res }); + Toast.show({ + type: 'success', + text1: `Channel created successfully`, + }); + }) + .catch(logError) + .finally(() => setIsLoading(false)); + } + }; + + return ( + + + + {channelId ? translate('Core.ChannelScreen.update-channel') : translate('Core.ChannelScreen.title')} + navigation.pop(2)} style={tailwind('mr-4')}> + + + + + + + + + + {channelId ? translate('Core.ChannelScreen.update') : translate('Core.ChannelScreen.name')} + + + + + + + {isLoading && } + + {channelId ? translate('Core.ChannelScreen.update-channel') : translate('Core.ChannelScreen.title')} + + + + + + + + ); +}; + +export default ChannelScreen; diff --git a/src/features/Core/screens/ChatScreen.js b/src/features/Core/screens/ChatScreen.js new file mode 100644 index 00000000..f79ac018 --- /dev/null +++ b/src/features/Core/screens/ChatScreen.js @@ -0,0 +1,397 @@ +import { faAngleLeft, faEdit, faPaperPlane, faTrash, faUser } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { useNavigation } from '@react-navigation/native'; +import { useDriver, useFleetbase } from 'hooks'; +import React, { useEffect, useState } from 'react'; +import { ActivityIndicator, Alert, FlatList, Platform, ScrollView, Text, TouchableOpacity, View } from 'react-native'; +import FastImage from 'react-native-fast-image'; +import FileViewer from 'react-native-file-viewer'; +import RNFS from 'react-native-fs'; +import { Actions, Bubble, GiftedChat, InputToolbar, Send } from 'react-native-gifted-chat'; +import { launchImageLibrary } from 'react-native-image-picker'; +import Modal from 'react-native-modal'; +import { tailwind } from 'tailwind'; +import { createSocketAndListen, translate } from 'utils'; + +const isAndroid = Platform.OS === 'android'; + +const ChatScreen = ({ route }) => { + const { channel: channelProps } = route.params; + const fleetbase = useFleetbase(); + const adapter = fleetbase.getAdapter(); + const navigation = useNavigation(); + const [channel, setChannel] = useState(channelProps); + const [messages, setMessages] = useState([]); + const [users, setUsers] = useState([]); + const [isLoading] = useState(false); + const [showUserList, setShowUserList] = useState(false); + const driver = useDriver(); + const driverUser = driver[0].attributes.user; + + useEffect(() => { + setChannel(channelProps); + }, [route.params]); + + useEffect(() => { + if (!channel) return; + fetchUsers(channel?.id); + const messages = parseMessages(channel.feed); + setMessages(messages); + }, [channel]); + + useEffect(() => { + if (!channel) return; + + console.log(`[Connecting to socket on channel chat.${channel.id}]`); + createSocketAndListen(`chat.${channel.id}`, socketEvent => { + console.log('Socket channel id: ', channel.id); + console.log('Socket event: ', socketEvent); + const { event, data } = socketEvent; + console.log('Socket event: ', event, data); + return reloadChannel(channel?.id); + }); + }, [channel]); + + const parseMessages = messages => { + return messages + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + .map((message, index) => { + return parseMessage(message, index); + }); + }; + + const parseMessage = (message, index) => { + const isSystem = message.type == 'log'; + + const user = isSystem ? { _id: index, name: 'System' } : { _id: index, name: message?.data?.sender?.name, avatar: message?.data?.sender?.avatar }; + + return { + _id: message?.data?.id, + text: isSystem ? message.data.resolved_content : message.data.content, + createdAt: message.data.updated_at, + system: isSystem, + sent: true, + image: message?.data?.attachments?.length > 0 ? message.data.attachments[0].url : '', + user, + }; + }; + + const currentParticipant = channel?.participants.find(chatParticipant => { + return chatParticipant.user === driverUser; + }); + + const channelUsers = channel?.participants.map(item => item.id); + + const uploadFile = async url => { + try { + const resBase64 = await adapter.post('files/base64', { + data: url.base64, + file_name: url.fileName, + subject_id: channel.id, + subject_type: 'chat_attachment', + type: 'chat_channel', + }); + + const messageRes = await adapter.post(`chat-channels/${channel?.id}/send-message`, { + sender: currentParticipant?.id, + content: resBase64.original_filename, + file: resBase64.id, + }); + + setMessages(previousMessages => GiftedChat.append(previousMessages, parseMessage({ data: messageRes }, messageRes.id))); + } catch (error) { + console.error('Error uploading file:', error); + } + }; + + const chooseFile = () => { + const options = { + title: 'Select File', + storageOptions: { + skipBackup: true, + path: 'images', + }, + quality: 0.5, + maxWidth: 800, + maxHeight: 600, + includeBase64: true, + }; + + launchImageLibrary(options, response => { + if (response.didCancel) { + if (!response) return; + } else if (response.error) { + console.log('ImagePicker Error: ', response.error); + } else { + uploadFile(response?.assets[0]); + } + }); + }; + + const openMedia = async url => { + const fileNameParts = url?.split('/')?.pop()?.split('?'); + const fileName = fileNameParts.length > 0 ? fileNameParts[0] : ''; + + const localFile = `${RNFS.DocumentDirectoryPath}/${fileName}`; + + const options = { + fromUrl: url, + toFile: localFile, + }; + + RNFS.downloadFile(options).promise.then(() => { + RNFS.readDir(RNFS.DocumentDirectoryPath); + FileViewer.open(localFile); + }); + }; + + const toggleUserList = () => { + setShowUserList(!showUserList); + }; + + const fetchUsers = async id => { + try { + const response = await adapter.get(`chat-channels/${id}/available-participants`); + setUsers(response); + } catch (error) { + console.error('Error fetching users:', error); + } + }; + + const reloadChannel = async id => { + try { + const res = await adapter.get(`chat-channels/${id}`); + setChannel(res); + } catch (error) { + console.error('Error: ', error); + } + }; + + const addParticipant = async (channelId, participantId, participantName) => { + const isParticipantAdded = channel.participants.some(participant => participant.user === participantId); + + if (isParticipantAdded) { + Alert.alert('Alert', `${participantName} is already a part of this channel.`, [{ text: 'OK' }]); + return; + } + + try { + await adapter.post(`chat-channels/${channelId}/add-participant`, { user: participantId }); + + await reloadChannel(channel.id); + + setShowUserList(false); + } catch (error) { + console.error('Add participant:', error); + } + }; + + const renderPartificants = ({ participants }) => { + return ( + + {participants.map(participant => ( + + + + + + + confirmRemove(participant.id)}> + + + + {participant.name.length > 7 ? participant.name.substring(0, 7) + '..' : participant.name} + + ))} + + ); + }; + + const confirmRemove = participantId => { + Alert.alert( + 'Confirmation', + 'Are you sure you wish to remove this participant from the chat?', + [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'OK', + onPress: () => removeParticipant(participantId), + }, + ], + { cancelable: false } + ); + }; + + const removeParticipant = async participantId => { + try { + await adapter.delete(`chat-channels/remove-participant/${participantId}`); + await reloadChannel(channel.id); + } catch (error) { + console.error('Remove participant:', error); + } + }; + + const onSend = async newMessage => { + try { + await adapter.post(`chat-channels/${channel?.id}/send-message`, { sender: currentParticipant.id, content: newMessage[0].text }); + setShowUserList(false); + setMessages(previousMessages => GiftedChat.append(previousMessages, { + ...newMessage, sent: false})); + } catch (error) { + console.error('Send error:', error); + } + }; + + const renderSend = props => { + return ( + + + + ); + }; + + const renderBubble = props => { + if (props.currentMessage.image) { + return ( + openMedia(props.currentMessage.image)}> + + + ); + } else { + return ( + + ); + } + }; + + const renderActions = () => chooseFile()} optionTintColor="#222B45" />; + + return ( + + + + navigation.pop(2)}> + + + + + {channel?.name}{' '} + navigation.navigate('ChannelScreen', { data: channel })}> + + + + + + + + + + + + + + {translate('Core.ChatScreen.title')}: + + {isLoading ? ( + + + + ) : ( + item.id.toString()} + renderItem={({ item }) => ( + addParticipant(channel.id, item.id, item.name, item.avatar_url)} + style={tailwind('flex flex-row items-center py-2 bg-gray-900 rounded-lg mb-2')}> + + + + + {item.name} + + )} + /> + )} + + + + + {renderPartificants({ + participants: channel?.participants || [], + onDelete: removeParticipant, + })} + + + onSend(messages)} + user={{ + _id: channelUsers?.id, + }} + renderBubble={renderBubble} + alwaysShowSend + renderInputToolbar={props => } + renderSend={renderSend} + renderActions={renderActions} + /> + + + ); +}; + +export default ChatScreen; diff --git a/src/features/Core/screens/ChatsScreen.js b/src/features/Core/screens/ChatsScreen.js new file mode 100644 index 00000000..84331091 --- /dev/null +++ b/src/features/Core/screens/ChatsScreen.js @@ -0,0 +1,146 @@ +import { useNavigation } from '@react-navigation/native'; +import { format } from 'date-fns'; +import { useFleetbase, useMountedState } from 'hooks'; +import React, { useEffect, useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, TouchableHighlight, TouchableOpacity, View, Platform } from 'react-native'; +import FastImage from 'react-native-fast-image'; +import { SwipeListView } from 'react-native-swipe-list-view'; +import Toast from 'react-native-toast-message'; +import { tailwind } from 'tailwind'; +import { translate } from 'utils'; + +const isAndroid = Platform.OS === 'android'; + +const ChatsScreen = () => { + const navigation = useNavigation(); + const isMounted = useMountedState(); + const fleetbase = useFleetbase(); + const [channels, setChannels] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const unsubscribe = navigation.addListener('focus', () => { + fetchChannels(); + }); + + return unsubscribe; + }, [isMounted]); + + const fetchChannels = async () => { + setIsLoading(true); + try { + const adapter = fleetbase.getAdapter(); + const response = await adapter.get('chat-channels'); + setChannels(response); + setIsLoading(false); + return response; + } catch (error) { + console.error('Error fetching channels:', error); + setIsLoading(false); + return []; + } + }; + + useEffect(() => { + fetchChannels(); + }, []); + + const formatTime = dateTime => { + const date = new Date(dateTime); + const formattedTime = format(date, 'HH:mm'); + return formattedTime; + }; + + const handleDelete = async itemId => { + try { + const adapter = fleetbase.getAdapter(); + await adapter.delete(`chat-channels/${itemId}`).then(res => { + Toast.show({ + type: 'success', + text1: `Channel deleted`, + }); + }); + setChannels(channels.filter(item => item.id !== itemId)); + } catch (error) { + console.error('Error deleting channel:', error); + } + }; + + const renderItem = ({ item }) => ( + navigation.navigate('ChatScreen', { channel: item })} + underlayColor={tailwind('bg-gray-900')}> + + + + {item.name} + {item.message} + + + {formatTime(item.created_at)} + + + + ); + + const renderHiddenItem = ({ item }) => ( + + handleDelete(item.id)} style={[styles.backRightBtn, styles.backRightBtnRight]}> + {translate('Core.ChatsScreen.delete')} + + + ); + + return ( + + {isLoading && ( + + + + )} + + + navigation.navigate('ChannelScreen')}> + + {translate('Core.ChatsScreen.create-channel')} + + + + + + + ); +}; + +export default ChatsScreen; + +const styles = StyleSheet.create({ + backRightBtn: { + alignItems: 'center', + bottom: 0, + justifyContent: 'center', + position: 'absolute', + top: 0, + width: 75, + }, + backRightBtnRight: { + backgroundColor: '#FF3A3A', + right: 4, + top: 10, + bottom: 2, + marginRight: 12, + marginLeft: 6, + borderRadius: 12, + }, + loaderContainer: { + position: 'absolute', + top: '50%', + left: '60%', + transform: [{ translateX: -50 }, { translateY: -50 }], + zIndex: 10, + }, +}); diff --git a/src/features/Core/screens/MainScreen.js b/src/features/Core/screens/MainScreen.js index 105906ea..a90d7bab 100644 --- a/src/features/Core/screens/MainScreen.js +++ b/src/features/Core/screens/MainScreen.js @@ -1,4 +1,4 @@ -import { faCalendarDay, faClipboardList, faFileAlt, faRoute, faUser, faWallet } from '@fortawesome/free-solid-svg-icons'; +import { faCalendarDay, faClipboardList, faFileAlt, faRoute, faUser, faWallet, faTextHeight } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { useRoute } from '@react-navigation/native'; @@ -16,6 +16,7 @@ import { createNewOrderLocalNotificationObject, getColorCode, listenForOrdersFro import { syncDevice } from 'utils/Auth'; import { getCurrentLocation, trackDriver } from 'utils/Geo'; import IssuesScreen from './IssuesScreen'; +import ChatsScreen from './ChatsScreen'; const { addEventListener, removeEventListener } = EventRegister; const Tab = createBottomTabNavigator(); @@ -141,6 +142,9 @@ const MainScreen = ({ navigation, route }) => { case 'Issue': icon = faFileAlt; break; + case 'Chat': + icon = faTextHeight; + break; } // You can return any component that you like here! return ; @@ -160,6 +164,7 @@ const MainScreen = ({ navigation, route }) => { {/* */} {/* */} + diff --git a/src/hooks/use-fleetbase.js b/src/hooks/use-fleetbase.js index a9b04d6f..abc567d0 100644 --- a/src/hooks/use-fleetbase.js +++ b/src/hooks/use-fleetbase.js @@ -3,7 +3,7 @@ import config from 'config'; import { isObject } from 'utils'; import { get, getString } from 'utils/Storage'; -const useFleetbase = () => { +const useFleetbase = (namespace) => { let { FLEETBASE_KEY, FLEETBASE_HOST, FLEETBASE_NAMESPACE } = config; let _DRIVER = get('driver'); let _FLEETBASE_KEY = getString('_FLEETBASE_KEY'); @@ -23,7 +23,7 @@ const useFleetbase = () => { const fleetbase = new Fleetbase(FLEETBASE_KEY, { host: FLEETBASE_HOST, - namespace: FLEETBASE_NAMESPACE, + namespace: FLEETBASE_NAMESPACE ?? namespace, }); return fleetbase; diff --git a/src/utils/Helper.js b/src/utils/Helper.js index 3114ad94..0e98d02e 100644 --- a/src/utils/Helper.js +++ b/src/utils/Helper.js @@ -501,21 +501,21 @@ export default class HelperUtil { // Create socket connection const socket = socketClusterClient.create(socketConnectionConfig); - // Listen for socket connection errors - (async () => { - // eslint-disable-next-line no-unused-vars - for await (let event of socket.listener('error')) { - console.log('[Socket Error]', event); - } - })(); - - // Listen for socket connection - (async () => { - // eslint-disable-next-line no-unused-vars - for await (let event of socket.listener('connect')) { - console.log('[Socket Connected]', event); - } - })(); + // // Listen for socket connection errors + // (async () => { + // // eslint-disable-next-line no-unused-vars + // for await (let event of socket.listener('error')) { + // console.log('[Socket Error]', event); + // } + // })(); + + // // Listen for socket connection + // (async () => { + // // eslint-disable-next-line no-unused-vars + // for await (let event of socket.listener('connect')) { + // console.log('[Socket Connected]', event); + // } + // })(); // create channel from channel id const channel = socket.subscribe(channelId); @@ -523,14 +523,51 @@ export default class HelperUtil { // subscribe to channel await channel.listener('subscribe').once(); - // listen to incoming data with callback - (async () => { - for await (let output of channel) { - if (typeof callback === 'function') { - callback(output); + const handleAsyncIteration = async asyncIterable => { + console.log('[handleAsyncIteration]'); + const iterator = asyncIterable[Symbol.asyncIterator](); + + const processNext = async () => { + console.log('[processNext]'); + try { + const { value, done } = await iterator.next(); + console.log('[processNext is done?]', done); + console.log('[processNext has value]', JSON.stringify(value)); + if (done) { + // If no more data, exit the function + return; + } + + // Process the value + console.log('[processNext has value]', JSON.stringify(value)); + if (typeof callback === 'function') { + callback(value); + } + + // Recursively process the next item + return processNext(); + } catch (error) { + console.error('Error during async iteration:', error); + // Optionally, handle the error or re-throw it + // throw error; } - } + }; + + // Start processing + return processNext(); + }; + + (async () => { + await handleAsyncIteration(channel); })(); + // // listen to incoming data with callback + // (async () => { + // for await (let output of channel) { + // if (typeof callback === 'function') { + // callback(output); + // } + // } + // })(); } static async listenForOrdersFromSocket(channelId, callback) { diff --git a/translations/en.json b/translations/en.json index a046b288..41d9bebf 100644 --- a/translations/en.json +++ b/translations/en.json @@ -58,6 +58,19 @@ "report": "Report", "driverName": "Driver Name", "vehicleName": "Vehicle Name" + }, + "ChannelScreen": { + "name": "Name", + "title": "Create channel", + "update": "Update", + "update-channel": "Update channel" + }, + "ChatsScreen": { + "delete": "Delete", + "create-channel": "Create channel" + }, + "ChatScreen": { + "title": "Select User" } }, "Exceptions": {