UNPKG

@cometchat/chat-uikit-react-native

Version:

Ready-to-use Chat UI Components for React Native

603 lines (592 loc) 30.4 kB
import React, { useCallback, useEffect, useRef, useState } from "react"; import { Modal, Platform, Text, TouchableOpacity, View, } from "react-native"; import { CometChatBottomSheet, CometChatGroupsEvents, CometChatUIEventHandler, CometChatUIKit, } from "../shared"; import { CometChat } from "@cometchat/chat-sdk-react-native"; import { CometChatList, localize } from "../shared"; import { MessageTypeConstants } from "../shared/constants/UIKitConstants"; import { deepMerge } from "../shared/helper/helperFunctions"; import { Icon } from "../shared/icons/Icon"; import { getUnixTimestamp, getUnixTimestampInMilliseconds, } from "../shared/utils/CometChatMessageHelper"; import { CometChatTooltipMenu } from "../shared/views/CometChatTooltipMenu"; import { ErrorEmptyView } from "../shared/views/ErrorEmptyView/ErrorEmptyView"; import { useTheme } from "../theme"; import { Skeleton } from "./Skeleton"; import ChangeCircle from "../shared/icons/components/change-circle"; import Block from "../shared/icons/components/block"; import Cancel from "../shared/icons/components/cancel"; /** * Component to render and manage the list of group members. * * This component renders a list of group members using CometChatList and provides functionality * to manage member roles (change scope), ban, or remove members through menus and modals. * * @param props - Props of type CometChatGroupMembersInterface. * @returns JSX.Element. */ export const CometChatGroupMembers = (props) => { const { SubtitleView, ItemView, AppBarOptions, searchPlaceholderText = "Search", showBackButton = false, onSelection, onSubmit, hideSearch, EmptyView, ErrorView, LoadingView, groupMemberRequestBuilder, searchRequestBuilder, group, hideError, onBack, selectionMode = "none", style = {}, TrailingView, LeadingView, onEmpty, onLoad, options, addOptions, onError, searchKeyword = "", hideKickMemberOption, hideBanMemberOption, hideScopeChangeOption, hideLoadingState, usersStatusVisibility = true, hideHeader = false, excludeOwner, hideSubmitButton = false, ...newProps } = props; // Get theme information and merge with provided style overrides. const theme = useTheme(); const mergedStyle = deepMerge(theme.groupMemberStyle, style); // State management for UI elements const [hideSearchError, setHideSearchError] = useState(false); const [isBottomSheetVisible, setIsBottomSheetVisible] = useState(false); const [selectedItem, setSelectedItem] = useState(null); const tooltipPositon = React.useRef({ pageX: 0, pageY: 0 }); const [tooltipVisible, setTooltipVisible] = useState(false); const [modalType, setModalType] = useState(""); const modalVisible = modalType !== ""; const [selectedRole, setSelectedRole] = useState("Participant"); const [currentUserRole, setCurrentUserRole] = useState(null); const listRef = useRef(null); const [isToolTipDismissed, setIsToolTipDismissed] = useState(false); const loggedInUser = React.useRef(CometChatUIKit.loggedInUser); // Define role permissions for current user actions const RolePermissions = { OWNER: ["Change Scope", "Ban", "Kick"], ADMIN: ["Change Scope", "Ban", "Kick"], MODERATOR: ["Change Scope", "Ban", "Kick"], PARTICIPANT: [], }; // Determine available roles based on current user's role const roles = React.useMemo(() => { if (currentUserRole === "moderator") { return ["Moderator", "Participant"]; } return ["Admin", "Moderator", "Participant"]; }, [currentUserRole]); // Set current user role based on whether the logged in user is the owner or not. useEffect(() => { if (CometChatUIKit.loggedInUser.getUid() === group.getOwner()) { setCurrentUserRole("owner"); } else { setCurrentUserRole(group.getScope()); } }, [group]); /** * Renders an empty state view when no users are available. * Also calls `onEmpty` if provided, so the parent can react to an empty list. */ const EmptyStateView = useCallback(() => { // Let parent know it's empty if user provided a callback useEffect(() => { onEmpty?.(); }, []); return (<View style={{ flex: 1 }}> <ErrorEmptyView title={localize("NO_USERS_AVAILABLE")} subTitle={localize("ADD_CONTACTS")} Icon={<Icon name='user-empty-icon' icon={mergedStyle?.emptyStateStyle?.icon} color={mergedStyle?.emptyStateStyle?.iconStyle?.tintColor} height={mergedStyle?.emptyStateStyle?.iconStyle?.height} width={mergedStyle?.emptyStateStyle?.iconStyle?.width} containerStyle={mergedStyle?.emptyStateStyle?.iconContainerStyle}/>} containerStyle={{ flex: 1, justifyContent: "center", alignItems: "center", paddingHorizontal: "10%", }} titleStyle={mergedStyle?.emptyStateStyle?.titleStyle} subTitleStyle={mergedStyle?.emptyStateStyle?.subTitleStyle}/> </View>); }, [theme]); /** * Renders an error state view when something goes wrong. * Also hides the search bar when the error view is active. */ const ErrorStateView = useCallback(() => { useEffect(() => { setHideSearchError(true); // Hide search when error view is active }, []); return (<View style={{ flex: 1 }}> <ErrorEmptyView title={localize("OOPS")} subTitle={localize("SOMETHING_WENT_WRONG")} tertiaryTitle={localize("WRONG_TEXT_TRY_AGAIN")} Icon={<Icon icon={mergedStyle?.errorStateStyle?.icon} color={mergedStyle?.errorStateStyle?.iconStyle?.tintColor} height={mergedStyle?.errorStateStyle?.iconStyle?.height} width={mergedStyle?.errorStateStyle?.iconStyle?.width} containerStyle={mergedStyle?.errorStateStyle?.iconContainerStyle}/>} containerStyle={{ flex: 1, justifyContent: "center", alignItems: "center", paddingHorizontal: "10%", }} titleStyle={mergedStyle?.errorStateStyle?.titleStyle} subTitleStyle={mergedStyle?.errorStateStyle?.subTitleStyle}/> </View>); }, [theme]); /** * Returns an action badge (OWNER, ADMIN, MODERATOR) or no badge for participant. */ const TailViewContent = (user) => { const userRole = user?.getScope(); if (userRole === "participant") { return <></>; } if (user.getUid() === group.getOwner()) { return (<View style={{ ...mergedStyle.ownerBadgeStyle?.containerStyle }}> <Text style={{ ...mergedStyle.ownerBadgeStyle?.textStyle }}>{localize("OWNER")}</Text> </View>); } if (userRole === "moderator") { return (<View style={{ ...mergedStyle.moderatorBadgeStyle?.containerStyle }}> <Text style={{ ...mergedStyle.moderatorBadgeStyle?.textStyle }}> {localize("MODERATOR")} </Text> </View>); } if (userRole === "admin") { return (<View style={{ ...mergedStyle.adminBadgeStyle?.containerStyle }}> <Text style={{ ...mergedStyle.adminBadgeStyle?.textStyle }}>{localize("ADMIN")}</Text> </View>); } return <></>; }; /** * Removes (kicks) a user from the group. */ const removeGroupMember = async (groupId, user) => { try { const removedMember = await CometChat.kickGroupMember(groupId, user.getUid()); console.log("Group member removed successfully:", removedMember); // Remove the user from the list view. listRef.current && listRef.current.removeItemFromList(user.getUid()); // Update group's member count if possible. if (group.setMembersCount && typeof group.setMembersCount === "function") { group.setMembersCount(group.getMembersCount() - 1); } // Clear the modal and selected item state. setModalType(""); setSelectedItem(null); // Create an action object to notify about the removal. let action = new CometChat.Action(group.getGuid(), MessageTypeConstants.groupMember, CometChat.RECEIVER_TYPE.GROUP, CometChat.CATEGORY_ACTION); action.setActionBy(loggedInUser.current); action.setActionOn(user); action.setActionFor(group); action.setMessage(`${loggedInUser.current.getName()} kicked ${user.getName()}`); action.setSentAt(getUnixTimestamp()); action.setMuid(String(getUnixTimestampInMilliseconds())); action.setSender(loggedInUser.current); action.setReceiver(group); action.setConversationId("group_" + group.getGuid()); // Emit the group event for a kicked member. CometChatUIEventHandler.emitGroupEvent(CometChatGroupsEvents.ccGroupMemberKicked, { message: action, kickedUser: user, kickedBy: loggedInUser.current, kickedFrom: group, }); } catch (error) { console.error("Error removing user:", error); } }; /** * Bans a user from the group. */ const banGroupMember = async (user) => { try { await CometChat.banGroupMember(group.getGuid(), user.getUid()); // Remove the user from the list view. listRef.current && listRef.current.removeItemFromList(user.getUid()); // Clear the modal and selected item state. setModalType(""); setSelectedItem(null); // Create an action object to notify about the ban. let action = new CometChat.Action(group.getGuid(), MessageTypeConstants.groupMember, CometChat.RECEIVER_TYPE.GROUP, CometChat.CATEGORY_ACTION); action.setActionBy(loggedInUser.current); action.setActionOn(user); action.setActionFor(group); action.setMessage(`${loggedInUser.current.getName()} banned ${user.getName()}`); action.setSentAt(getUnixTimestamp()); action.setMuid(String(getUnixTimestampInMilliseconds())); action.setSender(loggedInUser.current); action.setReceiver(group); group.setMembersCount(group.getMembersCount() - 1); // Emit the group event for a banned member. CometChatUIEventHandler.emitGroupEvent(CometChatGroupsEvents.ccGroupMemberBanned, { message: action, kickedUser: user, kickedBy: loggedInUser.current, kickedFrom: group, }); } catch (error) { console.error("Error banning user:", error); } }; /** * Generate default menu items (Change Scope, Ban, Remove). * Then filter them out based on hide props (hideBanMemberOption, etc.), * or based on current user role (RolePermissions). */ const getDefaultMenuItems = (item) => { const defaultItems = [ { text: "Change Scope", onPress: () => { const currentScope = item.getScope(); setTooltipVisible(false); const formattedScope = currentScope.charAt(0).toUpperCase() + currentScope.slice(1); setIsBottomSheetVisible(true); setSelectedRole(formattedScope); }, icon: (<ChangeCircle color={theme.color.textPrimary} height={theme.spacing.spacing.s6} width={theme.spacing.spacing.s6}/>), textStyle: { color: theme.color.textPrimary }, disabled: false, }, { text: "Ban", onPress: () => { setTooltipVisible(false); setModalType("ban"); }, icon: (<Block color={theme.color.textPrimary} height={theme.spacing.spacing.s6} width={theme.spacing.spacing.s6}/>), textStyle: { color: theme.color.textPrimary }, disabled: false, }, { text: "Kick", onPress: () => { setTooltipVisible(false); setModalType("kick"); }, icon: (<Cancel color={theme.color.textPrimary} height={theme.spacing.spacing.s6} width={theme.spacing.spacing.s6}/>), textStyle: { color: theme.color.textPrimary }, disabled: false, }, ]; let filteredItems = defaultItems; // Hide Kick? if (hideKickMemberOption) { filteredItems = filteredItems.filter((i) => i.text !== "Kick"); } // Hide Ban? if (hideBanMemberOption) { filteredItems = filteredItems.filter((i) => i.text !== "Ban"); } // Hide Scope? if (hideScopeChangeOption) { filteredItems = filteredItems.filter((i) => i.text !== "Change Scope"); } // Now filter by user role permissions filteredItems = filteredItems.filter((itemMenu) => RolePermissions[currentUserRole?.toUpperCase()].includes(itemMenu.text)); return filteredItems; }; /** * Merges default menu items with user-defined ones or replaces them entirely. * - If options is defined, we use that array as the entire set of menu items. * - If addOptions is defined, we append them to the default set. */ const buildMenuItems = (item) => { // If options is provided, it completely replaces default items if (options) { const replaced = options(item, group); return replaced; } // Else we get the default let defaultItems = getDefaultMenuItems(item); // If addOptions is provided, we append them if (addOptions) { const appended = addOptions(item, group); defaultItems = [...defaultItems, ...appended]; } return defaultItems; }; return (<View style={mergedStyle.containerStyle}> <CometChatList ref={listRef} hideHeader={hideHeader} onError={onError} selectionMode={selectionMode} hideStickyHeader={true} hideSubmitButton={hideSubmitButton} onSelection={onSelection} onSubmit={onSubmit} hideBackButton={!showBackButton} SubtitleView={SubtitleView ?? SubtitleView} searchPlaceholderText={searchPlaceholderText} TrailingView={TrailingView ?? TailViewContent} hideSearch={hideSearch ? hideSearch : hideSearchError} title={localize("MEMBERS")} searchRequestBuilder={searchRequestBuilder} requestBuilder={groupMemberRequestBuilder || new CometChat.GroupMembersRequestBuilder(group["guid"]) .setLimit(30) .setSearchKeyword(searchKeyword)} hideError={hideError} onBack={onBack} ItemView={ItemView} listStyle={mergedStyle} LoadingView={hideLoadingState ? () => <></> // will not render anything if true : LoadingView ? LoadingView : () => <Skeleton />} EmptyView={EmptyView ? EmptyView : () => <EmptyStateView />} ErrorView={ErrorView ? ErrorView : () => <ErrorStateView />} statusIndicatorType={(user) => usersStatusVisibility ? user?.getStatus() : null} LeadingView={LeadingView} AppBarOptions={AppBarOptions} listItemKey={"uid"} {...newProps} onItemLongPress={(member, e) => { // 1) If user explicitly provided a callback, call it and return if (props.onItemLongPress) { props.onItemLongPress(member); return; } // 2) Otherwise, use the default scope-based code const targetIsOwner = member.uid === group.getOwner(); const targetScope = member.scope; // Role-based restrictions if (currentUserRole === "owner") { // Owner can perform any action. } else if (currentUserRole === "admin") { // Admin cannot manage owner or other admins. if (targetIsOwner || targetScope === "admin") { return; } } else if (currentUserRole === "moderator") { // Moderator cannot manage owner, admin or other moderators. if (targetIsOwner || targetScope === "admin" || targetScope === "moderator") { return; } } else if (currentUserRole === "participant") { return; } // Only open if user is not self if (member.uid === CometChatUIKit.loggedInUser.getUid()) { return; } if (["owner", "admin", "moderator"].includes(currentUserRole)) { if (e && e.nativeEvent) { tooltipPositon.current = { pageX: e.nativeEvent.pageX, pageY: e.nativeEvent.pageY, }; } else { tooltipPositon.current = { pageX: 200, pageY: 100 }; } setSelectedItem(member); setTooltipVisible(true); setIsToolTipDismissed(false); } }} onItemPress={(member) => { if (props.onItemPress) { props.onItemPress(member); } }} onListFetched={(fetchedList) => { const selfUid = loggedInUser.current.getUid(); let finalList = fetchedList; if (excludeOwner) { finalList = fetchedList.filter((m) => m.getUid() !== selfUid); // Remove from rendered list as well (safe call) listRef.current?.removeItemFromList(selfUid); } if (finalList.length === 0) { onEmpty?.(); } else { onLoad?.(finalList); } }}/> {/* Render tooltip menu if an item is selected and selectionMode is "none" */} {selectedItem && selectionMode === "none" && (<View style={{ position: "absolute", top: tooltipPositon.current.pageY, left: tooltipPositon.current.pageX, zIndex: 9999, }}> <CometChatTooltipMenu visible={tooltipVisible} onClose={() => { setTooltipVisible(false); Platform.OS === "android" && setIsToolTipDismissed(true); }} onDismiss={() => { setIsToolTipDismissed(true); }} event={{ nativeEvent: tooltipPositon.current, }} // Build the final menu items menuItems={buildMenuItems(selectedItem).map((mi) => ({ text: mi.text, onPress: mi.onPress, icon: mi.icon, textStyle: mi.textStyle, iconStyle: mi.iconStyle, iconContainerStyle: mi.iconContainerStyle, disabled: mi.disabled, }))}/> </View>)} {/* Bottom sheet for changing member's role (scope) */} <CometChatBottomSheet isOpen={isBottomSheetVisible} doNotOccupyEntireHeight onClose={() => { setIsBottomSheetVisible(false); setSelectedItem(null); }} onDismiss={() => setSelectedItem(null)}> {selectedItem ? (<View style={mergedStyle?.changeScope?.container}> <View style={mergedStyle.changeScope?.iconContainerStyle}> <Icon name='change-circle' icon={mergedStyle?.changeScope?.icon} color={mergedStyle?.changeScope?.iconStyle?.tintColor} height={mergedStyle?.changeScope?.iconStyle?.height} width={mergedStyle?.changeScope?.iconStyle?.width} containerStyle={mergedStyle?.changeScope?.iconStyle}/> </View> <Text style={mergedStyle?.changeScope?.titleStyle}>{localize("CHANGE_SCOPE")}</Text> <Text style={mergedStyle?.changeScope?.subTitleStyle}> {localize("SCOPE_CHANGE_INFO")} </Text> <View style={mergedStyle?.changeScope?.actionBox}> {roles // For moderators: do not allow demotion to Participant if already a moderator .filter((role) => { if (currentUserRole === "moderator") { const targetScope = selectedItem.getScope(); if (targetScope === "moderator" && role === "Participant") { return false; } } return true; }) .map((role) => { const isDisabled = currentUserRole === "moderator" && role === "Admin"; return (<TouchableOpacity key={role} activeOpacity={isDisabled ? 1 : 0.7} onPress={() => { if (!isDisabled) { setSelectedRole(role); } }} style={[ { opacity: isDisabled ? 0.5 : 1 }, mergedStyle?.changeScope?.actionListStyle, ]}> <Text style={[ theme.typography.heading4.medium, { color: theme.color.textPrimary }, ]}> {role} </Text> <View style={{ height: 24, width: 24, borderRadius: 12, borderWidth: 2, borderColor: theme.color.primary, justifyContent: "center", alignItems: "center", }}> {selectedRole === role && (<View style={{ height: 12, width: 12, borderRadius: 6, backgroundColor: theme.color.primary, }}/>)} </View> </TouchableOpacity>); })} </View> <View style={mergedStyle.changeScope?.buttonContainer}> <TouchableOpacity style={mergedStyle?.changeScope?.cancelStyle?.containerStyle} onPress={() => { setIsBottomSheetVisible(false); setSelectedItem(null); }}> <Text style={mergedStyle?.changeScope?.cancelStyle?.textStyle}> {localize("CANCEL")} </Text> </TouchableOpacity> <TouchableOpacity style={mergedStyle?.changeScope?.confirmStyle?.containerStyle} onPress={async () => { if (selectedItem && selectedRole) { const currentScope = selectedItem.getScope(); const scopeToSet = selectedRole.toLowerCase(); // If no change is needed, just close the bottom sheet. if (scopeToSet === currentScope) { setIsBottomSheetVisible(false); setSelectedItem(null); setSelectedRole("Participant"); return; } try { await CometChat.updateGroupMemberScope(group.getGuid(), selectedItem.getUid(), scopeToSet); // Update the scope of the selected item and refresh the list. selectedItem.setScope(scopeToSet); listRef.current?.updateList(selectedItem); setIsBottomSheetVisible(false); // Create an action object to notify about the scope change. let action = new CometChat.Action(group["guid"], "groupMember", CometChat.RECEIVER_TYPE.GROUP, CometChat.CATEGORY_ACTION); action.setActionBy(loggedInUser.current); action.setActionOn(selectedItem); action.setActionFor(group); action.setMessage(`${loggedInUser.current.getName()} made ${selectedItem.getName()} ${scopeToSet} `); action.setSentAt(getUnixTimestamp()); action.setMuid(String(getUnixTimestampInMilliseconds())); action.setSender(loggedInUser.current); action.setReceiver(group); // Emit the group event for a scope change. CometChatUIEventHandler.emitGroupEvent(CometChatGroupsEvents.ccGroupMemberScopeChanged, { action, updatedUser: selectedItem, scopeChangedTo: scopeToSet, scopeChangedFrom: selectedItem.getScope(), group, }); setSelectedItem(null); setSelectedRole("Participant"); } catch (error) { console.error("Error updating user scope:", error); } } }}> <Text style={mergedStyle.changeScope?.confirmStyle?.textStyle}> {localize("SAVE")} </Text> </TouchableOpacity> </View> </View>) : null} </CometChatBottomSheet> {/* Render modal for ban or remove actions if an item is selected */} <Modal animationType='fade' transparent={true} visible={modalVisible} onRequestClose={() => setModalType("")} onDismiss={() => { setSelectedItem(null); }}> {modalVisible && selectedItem ? (<> <TouchableOpacity onPress={() => setModalType("")} style={{ flex: 1 }}> <View style={{ flex: 1, backgroundColor: "rgba(0, 0, 0, 0.5)" }}/> </TouchableOpacity> {modalType && (<View style={{ ...(modalType === "ban" ? mergedStyle.banModalStyle?.containerStyle : mergedStyle.removeModalStyle?.containerStyle), }}> <View style={{ ...(modalType === "ban" ? mergedStyle.banModalStyle?.iconContainerStyle : mergedStyle.removeModalStyle?.iconContainerStyle), }}> <Icon name='delete' icon={modalType === "ban" ? mergedStyle.banModalStyle?.icon : mergedStyle.removeModalStyle?.icon} color={modalType === "ban" ? mergedStyle?.banModalStyle?.iconStyle?.tintColor : mergedStyle?.removeModalStyle?.iconStyle?.tintColor} height={modalType === "ban" ? mergedStyle?.banModalStyle?.iconStyle?.height : mergedStyle?.removeModalStyle?.iconStyle?.height} width={modalType === "ban" ? mergedStyle?.banModalStyle?.iconStyle?.width : mergedStyle?.removeModalStyle?.iconStyle?.width} imageStyle={modalType === "ban" ? mergedStyle?.banModalStyle?.iconStyle : mergedStyle?.removeModalStyle?.iconStyle}/> </View> <View> <Text style={modalType === "ban" ? mergedStyle.banModalStyle?.titleTextStyle : mergedStyle.removeModalStyle?.titleTextStyle}> {modalType === "ban" ? `${localize("BAN")} ${selectedItem.getName()} ?` : `${localize("KICK")} ${selectedItem.getName()} ?`} </Text> <Text style={modalType === "ban" ? mergedStyle.banModalStyle?.subTitleTextStyle : mergedStyle.removeModalStyle?.subTitleTextStyle}> {modalType === "ban" ? `${localize("BAN_MEMBER_CONFIRM")}${selectedItem.getName()}?` : `${localize("REMOVE_MEMBER_CONFIRM")}${selectedItem.getName()}?`} </Text> <View style={{ ...(modalType === "ban" ? mergedStyle.banModalStyle?.alertContainer : mergedStyle.removeModalStyle?.alertContainer), }}> <TouchableOpacity style={{ ...(modalType === "ban" ? mergedStyle.banModalStyle?.cancelStyle?.containerStyle : mergedStyle.removeModalStyle?.cancelStyle?.containerStyle), }} onPress={() => setModalType("")}> <Text style={{ ...(modalType === "ban" ? mergedStyle.banModalStyle?.cancelStyle?.textStyle : mergedStyle.removeModalStyle?.cancelStyle?.textStyle), }}> {localize("NO")} </Text> </TouchableOpacity> <TouchableOpacity style={{ ...(modalType === "ban" ? mergedStyle.banModalStyle?.confirmStyle?.containerStyle : mergedStyle.removeModalStyle?.confirmStyle?.containerStyle), }} onPress={async () => { if (selectedItem) { if (modalType === "ban") { await banGroupMember(selectedItem); } else { await removeGroupMember(group.getGuid(), selectedItem); } } }}> <Text style={{ ...(modalType === "ban" ? mergedStyle.banModalStyle?.confirmStyle?.textStyle : mergedStyle.removeModalStyle?.confirmStyle?.textStyle), }}> {localize("YES")} </Text> </TouchableOpacity> </View> </View> </View>)} </>) : null} </Modal> </View>); }; //# sourceMappingURL=CometChatGroupMembers.js.map