UNPKG

@cometchat/chat-uikit-react-native

Version:

Ready-to-use Chat UI Components for React Native

409 lines (407 loc) 18.7 kB
import { CometChat } from "@cometchat/chat-sdk-react-native"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FlatList, Image, Text, TouchableOpacity, View, ActivityIndicator, } from "react-native"; import { CometChatAvatar, localize } from "../../shared"; import { CallTypeConstants } from "../../shared/constants/UIKitConstants"; import { CometChatUIEventHandler } from "../../shared/events/CometChatUIEventHandler/CometChatUIEventHandler"; import { CallUIEvents } from "../CallEvents"; import { CallingPackage } from "../CallingPackage"; import { CallUtils } from "../CallUtils"; import { CometChatOutgoingCall } from "../CometChatOutgoingCall"; import { BackIcon } from "./resources"; import { Style } from "./style"; import { Icon } from "../../shared/icons/Icon"; import { useTheme } from "../../theme"; import { DateHelper, dateHelperInstance } from "../../shared/helper/dateHelper"; import { ErrorEmptyView } from "../../shared/views/ErrorEmptyView/ErrorEmptyView"; import { Skeleton } from "./Skeleton"; import { deepMerge } from "../../shared/helper/helperFunctions"; import { CometChatTooltipMenu } from "../../shared/views/CometChatTooltipMenu"; const listenerId = "callEventListener_" + new Date().getTime(); const CometChatCalls = CallingPackage.CometChatCalls; /** * CometChatCallLogs component. * * This component displays a list of call logs with support for custom item views, * pull-to-refresh, error and empty states, as well as outgoing call initiation. * * @param {CometChatCallLogsConfigurationInterface} props - Component configuration props. * @returns {JSX.Element} The rendered call logs component. */ export const CometChatCallLogs = (props) => { const { LeadingView, TitleView, SubtitleView, ItemView, TrailingView, AppBarOptions, callLogRequestBuilder, showBackButton = false, EmptyView, ErrorView, LoadingView, hideError, onCallIconPress, onItemPress, onError, onBack, style, outgoingCallConfiguration, datePattern, onLoad, onEmpty, onItemLongPress, hideHeader, hideLoadingState, addOptions, options, } = props; const [list, setList] = useState([]); const [listState, setListState] = useState("loading"); const [showOutgoingCallScreen, setShowOutgoingCallScreen] = useState(false); const theme = useTheme(); const mergedCallLogsStyle = useMemo(() => { return deepMerge(theme.callLogsStyles, style ?? {}); }, [theme, style]); const loggedInUser = useRef(undefined); const callLogRequestBuilderRef = useRef(undefined); const outGoingCall = useRef(undefined); // State for tooltip functionality const [tooltipVisible, setTooltipVisible] = useState(false); const [selectedCall, setSelectedCall] = useState(null); const tooltipPosition = useRef({ pageX: 0, pageY: 0, }); const [hasMoreData, setHasMoreData] = useState(true); /** * Function to build the list of menu items for the tooltip: * - `options(call)` completely replaces defaults * - `addOptions(call)` appends to the default * - No default menu items in this snippet; so defaults is an empty array */ const buildMenuItems = (call) => { if (options) { return options(call); } let defaultMenuItems = []; // no default items here if (addOptions) { return [...defaultMenuItems, ...addOptions(call)]; } return defaultMenuItems; }; /** * Show tooltip if user hasn't provided a custom onItemLongPress. */ const handleItemLongPress = (call, e) => { if (onItemLongPress) { // If the developer has provided a custom long-press handler, call that and return. onItemLongPress({ call }); return; } // Otherwise, show the tooltip if there are menu items const items = buildMenuItems(call); if (items.length === 0) return; if (e && e.nativeEvent) { tooltipPosition.current = { pageX: e.nativeEvent.pageX, pageY: e.nativeEvent.pageY, }; } else { tooltipPosition.current = { pageX: 200, pageY: 100 }; } setSelectedCall(call); setTooltipVisible(true); }; /** * Initializes the call log request builder. */ function setRequestBuilder() { const reqBuilder = callLogRequestBuilder ? callLogRequestBuilder.setAuthToken(loggedInUser.current.getAuthToken()) : new CometChatCalls.CallLogRequestBuilder() .setLimit(30) .setAuthToken(loggedInUser.current.getAuthToken() || "") .setCallCategory("call"); callLogRequestBuilderRef.current = reqBuilder.build(); } /** * Fetches the call logs using the configured request builder. */ const fetchCallLogs = () => { setListState("loading"); callLogRequestBuilderRef .current.fetchNext() .then((callLogs) => { if (callLogRequestBuilderRef.current.limit > callLogs.length) { setHasMoreData(false); } if (callLogs.length > 0) { const updatedList = [...list, ...callLogs]; setList(updatedList); onLoad && onLoad(updatedList); } else { // If no new logs are returned and the current list is empty, trigger onEmpty if (list.length === 0) { onEmpty && onEmpty(); } } setListState("done"); }) .catch((err) => { onError && onError(err); setListState("error"); }); }; // Setup logged-in user and call listeners on mount. useEffect(() => { CometChat.getLoggedinUser() .then((u) => { loggedInUser.current = u; setRequestBuilder(); fetchCallLogs(); }) .catch((e) => { onError && onError(e); }); // Listener for outgoing call rejection CometChat.addCallListener(listenerId, new CometChat.CallListener({ onOutgoingCallRejected: (call) => { setShowOutgoingCallScreen(false); outGoingCall.current = undefined; }, })); // UI event listener for call rejection and call end CometChatUIEventHandler.addCallListener(listenerId, { ccCallRejected: (call) => { outGoingCall.current = undefined; setShowOutgoingCallScreen(false); }, ccCallEnded: () => { outGoingCall.current = undefined; setShowOutgoingCallScreen(false); }, }); return () => { // Cleanup call listeners when component unmounts CometChat.removeCallListener(listenerId); CometChatUIEventHandler.removeCallListener(listenerId); }; }, []); /** * Initiates a call based on the provided call log and type. * * @param {any} call - The call log item. * @param {any} type - The type of call (audio or video). */ const makeCall = (call, type) => { if (type == CallTypeConstants.audio || type == CallTypeConstants.video) { let user = call?.getReceiverType() == "user" ? loggedInUser.current?.getUid() === call?.getInitiator()?.getUid() ? call.getReceiver() : call?.getInitiator() : undefined; let group = call?.getReceiverType() == "group" ? loggedInUser.current?.getUid() === call?.getInitiator()?.getUid() ? call.getReceiver() : call?.getInitiator() : undefined; var receiverID = user ? user.getUid() : group ? group.getGuid() : undefined; var callType = type; var receiverType = user ? CometChat.RECEIVER_TYPE.USER : group ? CometChat.RECEIVER_TYPE.GROUP : undefined; if (!receiverID || !receiverType) return; var callObject = new CometChat.Call(receiverID, callType, receiverType, CometChat.CATEGORY_CALL); CometChat.initiateCall(callObject).then((initiatedCall) => { outGoingCall.current = initiatedCall; setShowOutgoingCallScreen(true); CometChatUIEventHandler.emitCallEvent(CallUIEvents.ccOutgoingCall, { call: outGoingCall.current, }); }, (error) => { console.log("Call initialization failed with exception:", error); CometChatUIEventHandler.emitCallEvent(CallUIEvents.ccCallFailed, { call }); onError && onError(error); }); } else { console.log("Invalid call type.", type); return; } }; /** * Handles the press event on the call icon. */ const onPress = (item) => { if (onCallIconPress) { onCallIconPress(item); } else { if (item?.getReceiverType() == "user") { makeCall(item, item.getType()); } } }; /** * Extracts and returns call details for display. */ const getCallDetails = (call) => { const { mode, initiator, receiver, receiverType } = call; if (mode == "meet") { return { title: receiver["name"], avatarUrl: receiver["icon"], }; } else if (mode == "call") { return { title: receiverType === "group" ? receiver["name"] : loggedInUser.current?.getUid() == initiator?.getUid() ? receiver["name"] : initiator["name"], avatarUrl: receiverType === "group" ? receiver["avatar"] : loggedInUser.current?.getUid() == initiator?.getUid() ? receiver["avatar"] : initiator["avatar"], }; } return { title: "", avatarUrl: undefined }; }; /** * Renders each call log item. */ const _render = ({ item, index }) => { // If user provides a custom item view, use that if (ItemView) return ItemView(item); const { title, avatarUrl } = getCallDetails(item); const callStatus = CallUtils.getCallStatusForCallLogs(item, loggedInUser.current); return (<TouchableOpacity onPress={() => onItemPress?.(item)} onLongPress={(e) => handleItemLongPress(item, e)}> <View style={mergedCallLogsStyle.itemStyle.containerStyle}> {LeadingView ? (LeadingView(item)) : (<CometChatAvatar name={title} image={{ uri: avatarUrl }} style={{ ...mergedCallLogsStyle.itemStyle.avatarStyle, }}/>)} <View> {TitleView ? (TitleView(item)) : (<Text style={[ mergedCallLogsStyle.itemStyle.titleTextStyle, callStatus === "missed" ? mergedCallLogsStyle.itemStyle.missedCallTitleTextStyle : {}, ]}> {title} </Text>)} <View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}> <Icon name={callStatus === "missed" ? "call-missed-outgoing" : callStatus === "incoming" ? "call-received" : "call-made"} size={16} color={callStatus === "missed" ? mergedCallLogsStyle.itemStyle.missedCallStatusIconStyle.tintColor : callStatus === "incoming" ? mergedCallLogsStyle.itemStyle.incomingCallStatusIconStyle.tintColor : mergedCallLogsStyle.itemStyle.outgoingCallStatusIconStyle.tintColor} containerStyle={{ marginTop: 2 }}/> {SubtitleView ? (SubtitleView(item)) : (<Text style={mergedCallLogsStyle.itemStyle.subTitleTextStyle}> {dateHelperInstance.getFormattedDate(item["initiatedAt"] * 1000, datePattern ?? DateHelper.patterns.callBubble)} </Text>)} </View> </View> {TrailingView ? (TrailingView(item)) : (<TouchableOpacity onPress={() => onPress(item)} style={{ marginLeft: "auto", }}> <Icon name={item.type === "audio" ? "call" : "videocam"} size={24} color={mergedCallLogsStyle.itemStyle.callIconStyle.tintColor} imageStyle={mergedCallLogsStyle.itemStyle.callIconStyle}/> </TouchableOpacity>)} </View> </TouchableOpacity>); }; /** * Renders the error view for the call logs. */ const ErrorStateView = useCallback(() => { if (hideError) return null; if (ErrorView) return <ErrorView />; return (<ErrorEmptyView title='Oops!' subTitle={localize("SOMETHING_WENT_WRONG")} tertiaryTitle={localize("WRONG_TEXT_TRY_AGAIN")} Icon={<Icon name='error-state' size={theme.spacing.margin.m15 << 1} containerStyle={{ marginBottom: theme.spacing.margin.m5, }}/>} containerStyle={Style.errorEmptyContainer} titleStyle={mergedCallLogsStyle.errorStateStyle?.titleStyle} subTitleStyle={mergedCallLogsStyle.errorStateStyle?.subTitleStyle} RetryView={<TouchableOpacity onPress={fetchCallLogs} style={{ backgroundColor: theme.color.primary, paddingVertical: theme.spacing.spacing.s3, paddingHorizontal: theme.spacing.spacing.s10, borderRadius: theme.spacing.radius.r2, marginTop: theme.spacing.spacing.s5, }}> <Text style={{ color: theme.color.primaryButtonIcon, ...theme.typography.button.medium, }}> {localize("RETRY")} </Text> </TouchableOpacity>}/>); }, [theme]); /** * Renders the empty state view for call logs. */ const EmptyStateView = useCallback(() => { if (EmptyView) return <EmptyView />; return (<ErrorEmptyView title='No Call Logs Yet' subTitle='Make or receive calls to see your call history listed here' Icon={<Icon name='call-fill' size={theme.spacing.spacing.s15 << 1} color={theme.color.neutral300} containerStyle={{ marginBottom: theme.spacing.spacing.s5, }}/>} containerStyle={Style.errorEmptyContainer} titleStyle={mergedCallLogsStyle.emptyStateStyle?.titleStyle} subTitleStyle={mergedCallLogsStyle.emptyStateStyle?.subTitleStyle}/>); }, [theme]); const renderFooter = useCallback(() => { if (listState !== "loading" || !hasMoreData) return null; return (<View style={{ paddingVertical: 20, flex: 1, alignItems: "center", justifyContent: "center", }}> <ActivityIndicator size='small' color={theme.color.primary}/> </View>); }, [theme, listState]); return (<View style={{ backgroundColor: theme.color.background1, height: "100%", width: "100%", }}> <> {/* Header with optional back button and app bar options */} {!hideHeader && (<View style={[ Style.row, Style.headerStyle, { padding: theme.spacing.spacing.s4, borderBottomWidth: 1, borderBottomColor: theme.color.borderLight, ...mergedCallLogsStyle.titleSeparatorStyle, }, ]}> <View style={Style.row}> {showBackButton ? (<TouchableOpacity style={Style.imageStyle} onPress={onBack}> <Image source={BackIcon} style={[Style.imageStyle, { tintColor: theme.color.iconPrimary }]}/> </TouchableOpacity>) : null} <Text style={mergedCallLogsStyle.titleTextStyle}>{localize("CALLS")}</Text> </View> <View style={Style.row}>{AppBarOptions && <AppBarOptions />}</View> </View>)} {/* Render call logs based on state */} {listState === "loading" && list.length === 0 ? (!hideLoadingState ? (LoadingView ? (<LoadingView />) : (<Skeleton style={mergedCallLogsStyle.skeletonStyle}/>)) : (<View />)) : listState === "error" && list.length === 0 ? (<ErrorStateView />) : list.length === 0 ? (<EmptyStateView />) : (<FlatList data={list} keyExtractor={(item, index) => item.sessionId + "_" + index} extraData={{ list, listState }} renderItem={_render} onEndReached={fetchCallLogs} ListFooterComponent={renderFooter}/>)} </> {/* Outgoing call screen */} {showOutgoingCallScreen && (<CometChatOutgoingCall call={outGoingCall.current} onEndCallButtonPressed={(call) => { CometChat.rejectCall(call?.getSessionId(), CometChat.CALL_STATUS.CANCELLED).then((rejectedCall) => { CometChatUIEventHandler.emitCallEvent(CallUIEvents.ccCallRejected, { call: rejectedCall, }); }, (err) => { onError && onError(err); }); }} {...outgoingCallConfiguration}/>)} {/* Tooltip menu for calls (on default long-press) */} {selectedCall && tooltipVisible && (<View style={{ position: "absolute", top: tooltipPosition.current.pageY, left: tooltipPosition.current.pageX, zIndex: 9999, }}> <CometChatTooltipMenu visible={tooltipVisible} onClose={() => setTooltipVisible(false)} onDismiss={() => setTooltipVisible(false)} event={{ nativeEvent: tooltipPosition.current, }} menuItems={buildMenuItems(selectedCall).map((menuItem) => ({ text: menuItem.text, onPress: () => { menuItem.onPress(); setTooltipVisible(false); }, textColor: menuItem.textStyle?.color, iconColor: menuItem.iconStyle?.tintColor, disabled: menuItem.disabled, }))}/> </View>)} {/* End tooltip menu */} </View>); }; //# sourceMappingURL=CometChatCallLogs.js.map