import React, { JSX, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { ActivityIndicator, ColorValue, FlatList, GestureResponderEvent, ImageSourcePropType, ImageStyle, Platform, StyleProp, Text, TextStyle, View, ViewStyle, } from "react-native"; import { LOADING, NO_DATA_FOUND, SOMETHING_WRONG } from "../../constants/UIKitConstants"; import { CometChat } from "@cometchat/chat-sdk-react-native"; import { useTheme } from "../../../theme"; import { CometChatTheme } from "../../../theme/type"; import { CometChatUIKit } from "../../CometChatUiKit"; import { DeepPartial } from "../../helper/types"; import { Icon } from "../../icons/Icon"; import { CometChatListItem } from "../CometChatListItem"; import { CometChatStatusIndicatorInterface, StatusIndicatorStyles, } from "../CometChatStatusIndicator"; import Header from "./Header"; import styles from "./styles"; import { useCometChatTranslation } from "../../resources/CometChatLocalizeNew"; export interface CometChatListActionsInterface { updateList: (prop: any) => void; updateAndMoveToFirst: (item: any) => void; addItemToList: (item: any, position?: number) => void; removeItemFromList: (itemId: string | number) => void; getListItem: (itemId: string | number) => any; getSelectedItems: () => Array; getAllListItems: () => Array; reload: () => void; } export interface CometChatListStylesInterface { containerStyle: ViewStyle; onlineStatusColor?: ColorValue; separatorColor?: string; loadingIconTint?: ColorValue; sectionHeaderTextStyle?: TextStyle; confirmSelectionStyle: { icon?: ImageSourcePropType | JSX.Element; iconStyle?: ImageStyle; iconContainerStyle?: ImageStyle; }; selectionCancelStyle: { icon?: ImageSourcePropType | JSX.Element; iconStyle?: ImageStyle; iconContainerStyle?: ImageStyle; }; titleSeparatorStyle: ViewStyle; searchStyle?: { textStyle: TextStyle; placehodlerTextStyle?: TextStyle; containerStyle: ViewStyle; icon?: ImageSourcePropType | JSX.Element; iconStyle: ImageStyle; }; titleStyle: TextStyle; titleViewStyle?: ViewStyle; backButtonIcon?: ImageSourcePropType | JSX.Element; backButtonIconStyle: ImageStyle; itemStyle: { avatarStyle: CometChatTheme["avatarStyle"]; containerStyle: ViewStyle; titleStyle: TextStyle; subtitleStyle: TextStyle; statusIndicatorStyle?: Partial; headViewContainerStyle?: StyleProp; titleSubtitleContainerStyle?: StyleProp; trailingViewContainerStyle?: StyleProp; }; emptyStateStyle: Partial<{ titleStyle: TextStyle; subTitleStyle: TextStyle; containerStyle: ViewStyle; icon: ImageSourcePropType | JSX.Element; iconStyle?: ImageStyle; iconContainerStyle?: ViewStyle; }>; errorStateStyle: Partial<{ titleStyle: TextStyle; subTitleStyle: TextStyle; containerStyle: ViewStyle; icon: ImageSourcePropType | JSX.Element; iconStyle?: ImageStyle; iconContainerStyle?: ViewStyle; }>; headerContainerStyle?: ViewStyle; backButtonIconContainerStyle: ViewStyle; } export interface CometChatListProps { ItemView?: (item: any) => JSX.Element; LeadingView?: (item: any) => JSX.Element; TitleView?: (item: any) => JSX.Element; SubtitleView?: (item: any) => JSX.Element; TrailingView?: (item: any) => JSX.Element; disableUsersPresence?: boolean; AppBarOptions?: React.FC; searchPlaceholderText?: string; hideBackButton?: boolean; selectionMode?: "none" | "single" | "multiple"; onSelection?: (list: any) => void; onSubmit?: (list: any) => void; hideSearch?: boolean; hideHeader?: boolean; title?: string; EmptyView?: React.FC; emptyStateText?: string; errorStateText?: string; ErrorView?: React.FC; LoadingView?: React.FC; requestBuilder?: any; searchRequestBuilder?: any; hideError?: boolean; onItemPress?: (user: any) => void; onItemLongPress?: (user: any, e: GestureResponderEvent) => void; onError?: (error: CometChat.CometChatException) => void; onBack?: (() => void) | undefined; listItemKey: "uid" | "guid" | "conversationId"; listStyle?: DeepPartial; hideSubmitButton?: boolean; statusIndicatorType?: (item: any) => CometChatStatusIndicatorInterface["type"] | null; hideStickyHeader?: boolean; /** * Called once the list has been fetched or updated. * Returns the final array of items currently in the list. */ onListFetched?: (fetchedList: any[]) => void; /** * Custom search view component to display instead of the default search input. */ SearchView?: () => JSX.Element; /** * Callback triggered when the search bar is clicked or focused. */ onSearchBarClicked?: () => void; } let lastCall: any; let lastReject: Function; /** * @class Users is a component useful for displaying the header and users in a list * @description This component displays a header and list of users with subtitle,avatar,status * @Version 1.0.0 * @author CometChat * */ export const CometChatList = React.forwardRef( (props, ref) => { const connectionListenerId = "connectionListener_" + new Date().getTime(); const theme = useTheme(); const { t } = useCometChatTranslation(); const [isLoadingMore, setIsLoadingMore] = useState(false); const [hasMoreData, setHasMoreData] = useState(true); const { LeadingView, TitleView, SubtitleView, TrailingView, disableUsersPresence = false, ItemView, AppBarOptions, searchPlaceholderText, hideBackButton, selectionMode = "none", onSelection = () => {}, onSubmit, hideSearch = false, title = "Title", EmptyView, emptyStateText = t("NO_USERS_FOUND"), errorStateText = t("SOMETHING_WRONG"), ErrorView, LoadingView, requestBuilder, hideHeader, searchRequestBuilder = undefined, hideError = false, onItemPress = () => {}, onItemLongPress = () => {}, onError, onBack, listItemKey = "uid", listStyle, hideSubmitButton, statusIndicatorType, hideStickyHeader = false, SearchView, onSearchBarClicked, } = props; // functions which can be accessed by parents useImperativeHandle(ref, () => { return { updateList, addItemToList, removeItemFromList, getListItem, updateAndMoveToFirst, getSelectedItems, getAllListItems, reload, }; }); const [searchInput, setSearchInput] = useState( requestBuilder ? requestBuilder.searchKeyword ? requestBuilder.searchKeyword : "" : searchRequestBuilder ? searchRequestBuilder.searchKeyword ? searchRequestBuilder.searchKeyword : "" : "" ); const searchInputRef = useRef( requestBuilder ? requestBuilder.searchKeyword ? requestBuilder.searchKeyword : "" : searchRequestBuilder ? searchRequestBuilder.searchKeyword ? searchRequestBuilder.searchKeyword : "" : "" ); const [selectedItems, setSelectedItems] = useState<{ [key: string]: any }>({}); const [shouldSelect, setShouldSelect] = useState( props.selectionMode === "single" || props.selectionMode === "multiple" ); const listHandlerRef = useRef(null); const initialRunRef = useRef(true); const selectionEffectDidMountRef = useRef(false); const [list, setList] = useState([]); const listRef = useRef([]); const [dataLoadingStatus, setDataLoadingStatus] = useState(LOADING); // Keep listRef in sync with list state for use in imperative methods useEffect(() => { listRef.current = list; }, [list]); useEffect(() => { // Skip calling onSelection on initial mount; only trigger after a user-driven change if (!selectionEffectDidMountRef.current) { selectionEffectDidMountRef.current = true; return; } if (props.onSelection) { const selectedArray = Object.values(selectedItems); props.onSelection(selectedArray); } }, [selectedItems]); // Debounced search handler const searchHandler = (searchText: string) => { setSearchInput(searchText); setHasMoreData(true); // Decide which builder to use (searchRequestBuilder preferred) let builder = searchRequestBuilder || requestBuilder; if (!builder) { // no builder available, nothing to do return; } // If a separate searchRequestBuilder exists and searchText present, // set the search keyword on it; otherwise use the main requestBuilder. let builtRequest; if (searchRequestBuilder && searchText) { builtRequest = searchRequestBuilder.setSearchKeyword(searchText).build(); } else if (requestBuilder) { builtRequest = requestBuilder.setSearchKeyword(searchText).build(); } else { // fallback: if there's no search keyword, build base builder builtRequest = builder.build(); } // important: store the built request so fetchNext uses the same instance listHandlerRef.current = builtRequest; // Then call getList with the built request getSearch(builtRequest); }; const getSearch = (builtReq: any) => { getList(builtReq) .then((newlist: any) => { setDataLoadingStatus(NO_DATA_FOUND); setList(newlist); }) .catch((error) => { if (error && error["message"] == "Promise cancelled") { // Handle promise cancellation if necessary } else { setDataLoadingStatus(SOMETHING_WRONG); errorHandler(error); } }); }; const getSelectedItems = () => { let markedItems: any[] = []; Object.keys(selectedItems).forEach((item) => { const listItem = getListItem(item); if (listItem) markedItems.push(listItem); }); return markedItems; }; useEffect(() => { CometChat.addConnectionListener( connectionListenerId, new CometChat.ConnectionListener({ onConnected: () => { if (requestBuilder) { if (searchInputRef.current) listHandlerRef.current = requestBuilder .setSearchKeyword(searchInputRef.current) .build(); else listHandlerRef.current = requestBuilder.build(); } getList(listHandlerRef.current) .then((newlist: any[]) => { setDataLoadingStatus(NO_DATA_FOUND); setList(newlist); }) .catch((error) => { if (error && error["message"] === "Promise cancelled") { // Handle promise cancellation if necessary } else { setDataLoadingStatus(SOMETHING_WRONG); errorHandler(error); } }); }, inConnecting: () => { console.log("ConnectionListener => In connecting"); }, onDisconnected: () => { console.log("ConnectionListener => On Disconnected"); }, }) ); return () => { CometChat.removeConnectionListener(connectionListenerId); }; }, []); useEffect(() => { if (initialRunRef.current === true) { if (requestBuilder) { if (searchInput) listHandlerRef.current = requestBuilder.setSearchKeyword(searchInput).build(); else listHandlerRef.current = requestBuilder.build(); } initialRunRef.current = false; handleList(false); } }, []); useEffect(() => { searchInputRef.current = searchInput; }, [searchInput]); /** * Updates the list of users to be displayed * @param */ const updateList = (item: any) => { setList((prevList) => { let newList = [...prevList]; let itemKey = newList.findIndex((u) => u[listItemKey] === item[listItemKey]); if (itemKey > -1) { newList.splice(itemKey, 1, item); if (newList.length === 0) setDataLoadingStatus(NO_DATA_FOUND); return newList; } return prevList; }); }; /** * This will move item to first location if item doesn't exist then add it to first location. * @param item */ const updateAndMoveToFirst = (item: any) => { setList((prevList) => { let newList = [...prevList]; let itemKey = newList.findIndex((u) => u[listItemKey] === item[listItemKey]); if (itemKey > -1) { newList.splice(itemKey, 1); } return [item, ...newList]; }); }; const addItemToList = (item: any, position?: number) => { setList((prev: any[]) => { if (position !== undefined) { if (position === 0) return [item, ...prev]; if (position >= prev.length) return [...prev, item]; else return [...prev.slice(0, position - 1), item, ...prev.slice(position)]; } return [...prev, item]; }); }; const removeItemFromList = (uid: string | number) => { setList((prev: any[]) => { let newList = prev.filter((item: any) => item[listItemKey] !== uid); if (newList.length === 0) setDataLoadingStatus(NO_DATA_FOUND); return newList; }); if (ItemView === undefined && shouldSelect) { let newSelectedItems = { ...selectedItems }; if (Object.keys(selectedItems).includes(uid.toString())) { delete newSelectedItems[uid]; setSelectedItems(newSelectedItems); } } }; const getListItem = (itemId: string | number): any => { return listRef.current.find((item: any) => item[listItemKey] === itemId); }; /** * Get all list items */ const getAllListItems = (): any[] => { return listRef.current; }; /** * Handle list fetching with pagination * @param {boolean} throughKeyword - Pass true if wants to set only new users. */ const handleList = (throughKeyword?: boolean) => { // Prevent multiple fetches if (isLoadingMore || (!throughKeyword && !hasMoreData)) return; setIsLoadingMore(true); // If we're resetting due to a new keyword, create a fresh builder instance if (throughKeyword) { // prefer searchRequestBuilder if provided const baseBuilder = searchRequestBuilder || requestBuilder; if (baseBuilder) { const keyword = searchInputRef.current ?? ""; if (searchRequestBuilder && keyword) { listHandlerRef.current = searchRequestBuilder.setSearchKeyword(keyword).build(); } else if (requestBuilder) { listHandlerRef.current = requestBuilder.setSearchKeyword(keyword).build(); } else { listHandlerRef.current = baseBuilder.build(); } } // reset pagination flags setHasMoreData(true); } getList(listHandlerRef.current) .then((newlist: any[] = []) => { let finalList: any[] = []; if (throughKeyword || list.length === 0) { // If we're resetting the list or there is no existing list if (throughKeyword) setHasMoreData(true); else if (newlist.length === 0) setHasMoreData(false); finalList = newlist; if (finalList.length === 0) { setDataLoadingStatus(NO_DATA_FOUND); } else { setDataLoadingStatus(""); } setList(finalList); } else { // Append to existing list finalList = [...list, ...newlist]; setList(finalList); // When the backend returns nothing more, mark the end of data if (newlist.length === 0) setHasMoreData(false); } // If we *did* get results but less than a full "page", also stop further loads if (newlist.length === 0) setHasMoreData(false); props.onListFetched?.(finalList); setIsLoadingMore(false); }) .catch((error) => { if (error && error["message"] === "Promise cancelled") { // Handle promise cancellation if necessary } else { setDataLoadingStatus(SOMETHING_WRONG); errorHandler(error); } setIsLoadingMore(false); }); }; /** * Reloads the list by fetching fresh data (matches Calls tab behavior) */ const reload = () => { // Prevent multiple reloads if a load operation is already in progress if (isLoadingMore) return; setDataLoadingStatus(LOADING); setHasMoreData(true); if (requestBuilder) { if (searchInputRef.current) listHandlerRef.current = requestBuilder.setSearchKeyword(searchInputRef.current).build(); else listHandlerRef.current = requestBuilder.build(); } handleList(true); }; const renderFooter = useCallback(() => { if (!isLoadingMore || !hasMoreData) return null; return ( ); }, [isLoadingMore, hasMoreData]); /** * Handle errors */ const errorHandler = (errorCode: any) => { onError && onError(errorCode); // CometChatUserEvents.emit(CometChatUserEvents.onUserError, errorCode); }; /** * Handle item selection based on selection mode */ const handleSelection = useCallback( (listItem: any) => { if (selectionMode === "none") return; const itemKey = listItem.value[listItemKey]; setSelectedItems((prev: any) => { let newState = { ...prev }; if (selectionMode === "multiple") { if (newState[itemKey]) { delete newState[itemKey]; } else { newState[itemKey] = listItem.value; } } else if (selectionMode === "single") { if (newState[itemKey]) { delete newState[itemKey]; } else { newState = { [itemKey]: listItem.value }; } } // Notify parent about selection change return newState; }); }, [selectionMode, onSelection, listItemKey] ); /** * Handle Cancel action */ const handleCancelSelection = () => { setSelectedItems({}); }; /** * Handle Confirm action */ const handleConfirmSelection = () => { onSubmit && onSubmit(Object.values(selectedItems)); // Optionally, you might want to clear the selection after confirmation setSelectedItems({}); }; const onListItemPress = (item: any) => { if (shouldSelect) { handleSelection(item); } else { onItemPress(item.value); } }; const onListItemLongPress = (item: any, e: GestureResponderEvent) => { handleSelection(item); onItemLongPress(item.value, e); }; const selectedCount = Object.keys(selectedItems).length; const renderItemView = useCallback( ({ item, index }: any) => { if (item.header) { if (hideStickyHeader) return null; const headerLetter = item.value; return ( {headerLetter} ); } return ( ) ?? ({ marginHorizontal: 9 } as StyleProp) } titleSubtitleContainerStyle={ (listStyle?.itemStyle?.titleSubtitleContainerStyle as StyleProp) ?? ({} as StyleProp) } trailingViewContainerStyle={ (listStyle?.itemStyle?.trailingViewContainerStyle as StyleProp) ?? ({} as StyleProp) } avatarURL={item.value.avatar || undefined} avatarStyle={listStyle?.itemStyle?.avatarStyle} SubtitleView={SubtitleView ? SubtitleView(item.value) : undefined} TrailingView={TrailingView ? TrailingView(item.value) : undefined} onPress={() => { onListItemPress(item); }} // onLongPress={() => { // onListItemLongPress(item); // }} onLongPress={(id: any, e: any) => { // const listItem = getListItem(id); onListItemLongPress(item, e); }} /> ); }, [ listItemKey, selectedItems, shouldSelect, listStyle, SubtitleView, TitleView, TrailingView, disableUsersPresence, theme, onListItemPress, onListItemLongPress, hideStickyHeader, LeadingView, ] ); /** * Gets the list of users */ const getList = (reqBuilder: any): Promise => { const promise = new Promise((resolve, reject) => { const cancel = () => { clearTimeout(lastCall); lastReject(new Error("Promise cancelled")); }; if (lastCall) { cancel(); } lastCall = setTimeout(() => { reqBuilder ?.fetchNext() .then((listItems: any) => { resolve(listItems); }) .catch((error: CometChat.CometChatException) => { reject(error); }); }, 500); lastReject = reject; }); return promise; }; /** * Returns a container of users if exists else returns the corresponding decorator message */ const getMessageContainer = useCallback(() => { let messageContainer: JSX.Element = <>; if (list.length === 0 && dataLoadingStatus.toLowerCase() === NO_DATA_FOUND) { messageContainer = EmptyView ? ( ) : ( {emptyStateText} ); } else if (!hideError && dataLoadingStatus.toLowerCase() === SOMETHING_WRONG) { messageContainer = ErrorView ? ( ) : ( {listStyle?.errorStateStyle?.icon && ( )} {errorStateText} ); } else { let currentLetter = ""; const listWithHeaders: any[] = []; if (list.length) { list.forEach((listItem: any) => { const chr = listItem?.name && listItem.name[0].toUpperCase(); if (!hideStickyHeader && chr !== currentLetter && !ItemView) { currentLetter = chr; listWithHeaders.push({ value: currentLetter, header: true, }); } listWithHeaders.push({ value: listItem, header: false }); }); messageContainer = ( { return ItemView(item?.value); } : renderItemView } keyExtractor={(item, index) => { const itemValue = { ...item.value }; let key = itemValue[listItemKey] ? `${itemValue[listItemKey]}` : undefined; if (!key) { //section header is also an item in the list if (itemValue[0] && itemValue[0].length === 1) { key = itemValue[0] + "_" + index; } } return key ?? index + ""; }} onMomentumScrollEnd={(event) => { const contentOffsetY = event.nativeEvent.contentOffset.y; const contentHeight = event.nativeEvent.contentSize.height; const layoutHeight = event.nativeEvent.layoutMeasurement.height; if (contentOffsetY + layoutHeight >= contentHeight - 10) { handleList(); } }} showsVerticalScrollIndicator={false} ListFooterComponent={renderFooter} keyboardShouldPersistTaps='always' keyboardDismissMode={Platform.OS === 'ios' ? 'on-drag' : 'none'} /> ); } } return messageContainer; }, [list, selectedItems, theme, dataLoadingStatus, isLoadingMore, hasMoreData, shouldSelect]); /** * Handle the rendering based on loading status */ // if (list.length === 0 && dataLoadingStatus.toLowerCase() === LOADING) { // if (LoadingView) return ; // } else { return (
searchHandler(searchInput)} selectionCancelStyle={listStyle?.selectionCancelStyle} confirmSelectionStyle={listStyle?.confirmSelectionStyle} searchStyle={listStyle?.searchStyle} selectedCount={selectedCount} SearchView={SearchView} onSearchBarClicked={onSearchBarClicked} /> {getMessageContainer()} {list.length === 0 && dataLoadingStatus.toLowerCase() === LOADING ? ( LoadingView ? ( ) : ( ) ) : null} {/* Add a fallback like `null` if nothing needs to be rendered */} ); } // } );