stream-chat-react
Version:
React components to create chat conversations or livestream style chat
152 lines (151 loc) • 9.09 kB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { useConnectionRecoveredListener } from './hooks/useConnectionRecoveredListener';
import { useMobileNavigation } from './hooks/useMobileNavigation';
import { usePaginatedChannels } from './hooks/usePaginatedChannels';
import { useChannelListShape, usePrepareShapeHandlers, } from './hooks/useChannelListShape';
import { useStateStore } from '../../store';
import { ChannelListMessenger } from './ChannelListMessenger';
import { Avatar as DefaultAvatar } from '../Avatar';
import { ChannelPreview } from '../ChannelPreview/ChannelPreview';
import { ChannelSearch as DefaultChannelSearch } from '../ChannelSearch/ChannelSearch';
import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator';
import { LoadingChannels } from '../Loading/LoadingChannels';
import { LoadMorePaginator } from '../LoadMore/LoadMorePaginator';
import { ChannelListContextProvider, useChatContext, useComponentContext, } from '../../context';
import { NullComponent } from '../UtilityComponents';
import { MAX_QUERY_CHANNELS_LIMIT, moveChannelUpwards } from './utils';
const DEFAULT_FILTERS = {};
const DEFAULT_OPTIONS = {};
const DEFAULT_SORT = {};
const searchControllerStateSelector = (nextValue) => ({
searchIsActive: nextValue.isActive,
});
const UnMemoizedChannelList = (props) => {
const { additionalChannelSearchProps, allowNewMessagesFromUnfilteredChannels = true, Avatar = DefaultAvatar, channelRenderFilterFn, ChannelSearch = DefaultChannelSearch, customActiveChannel, customQueryChannels, EmptyStateIndicator = DefaultEmptyStateIndicator, filters = {}, getLatestMessagePreview, List = ChannelListMessenger, LoadingErrorIndicator = NullComponent, LoadingIndicator = LoadingChannels, lockChannelOrder = false, onAddedToChannel, onChannelDeleted, onChannelHidden, onChannelTruncated, onChannelUpdated, onChannelVisible, onMessageNew, onMessageNewHandler, onRemovedFromChannel, options, Paginator = LoadMorePaginator, Preview, recoveryThrottleIntervalMs, renderChannels, sendChannelsToList = false, setActiveChannelOnMount = true, showChannelSearch = false, sort = DEFAULT_SORT, watchers = {}, } = props;
const { channel, channelsQueryState, client, closeMobileNav, customClasses, navOpen = false, searchController, setActiveChannel, theme, useImageFlagEmojisOnWindows, } = useChatContext('ChannelList');
const { Search } = useComponentContext(); // FIXME: us component context to retrieve ChannelPreview UI components too
const channelListRef = useRef(null);
const [channelUpdateCount, setChannelUpdateCount] = useState(0);
const [searchActive, setSearchActive] = useState(false);
// Indicator relevant when Search component that relies on SearchController is used
const { searchIsActive } = useStateStore(searchController.state, searchControllerStateSelector);
/**
* Set a channel with id {customActiveChannel} as active and move it to the top of the list.
* If customActiveChannel prop is absent, then set the first channel in list as active channel.
*/
const activeChannelHandler = async (channels, setChannels) => {
if (!channels.length ||
channels.length > (options?.limit || MAX_QUERY_CHANNELS_LIMIT)) {
return;
}
if (customActiveChannel) {
// FIXME: this is wrong...
let customActiveChannelObject = channels.find((chan) => chan.id === customActiveChannel);
if (!customActiveChannelObject) {
[customActiveChannelObject] = await client.queryChannels({
id: customActiveChannel,
});
}
if (customActiveChannelObject) {
setActiveChannel(customActiveChannelObject, watchers);
const newChannels = moveChannelUpwards({
channels,
channelToMove: customActiveChannelObject,
sort,
});
setChannels(newChannels);
}
return;
}
if (setActiveChannelOnMount) {
setActiveChannel(channels[0], watchers);
}
};
/**
* For some events, inner properties on the channel will update but the shallow comparison will not
* force a re-render. Incrementing this dummy variable ensures the channel previews update.
*/
const forceUpdate = useCallback(() => setChannelUpdateCount((count) => count + 1), []);
const onSearch = useCallback((event) => {
setSearchActive(!!event.target.value);
additionalChannelSearchProps?.onSearch?.(event);
}, [additionalChannelSearchProps]);
const onSearchExit = useCallback(() => {
setSearchActive(false);
additionalChannelSearchProps?.onSearchExit?.();
}, [additionalChannelSearchProps]);
const { channels, hasNextPage, loadNextPage, setChannels } = usePaginatedChannels(client, filters || DEFAULT_FILTERS, sort || DEFAULT_SORT, options || DEFAULT_OPTIONS, activeChannelHandler, recoveryThrottleIntervalMs, customQueryChannels);
const loadedChannels = channelRenderFilterFn
? channelRenderFilterFn(channels)
: channels;
useMobileNavigation(channelListRef, navOpen, closeMobileNav);
const { customHandler, defaultHandler } = usePrepareShapeHandlers({
allowNewMessagesFromUnfilteredChannels,
filters,
lockChannelOrder,
onAddedToChannel,
onChannelDeleted,
onChannelHidden,
onChannelTruncated,
onChannelUpdated,
onChannelVisible,
onMessageNew,
onMessageNewHandler,
onRemovedFromChannel,
setChannels,
sort,
// TODO: implement
// customHandleChannelListShape
});
useChannelListShape(customHandler ?? defaultHandler);
// TODO: maybe move this too
useConnectionRecoveredListener(forceUpdate);
useEffect(() => {
const handleEvent = (event) => {
if (event.cid === channel?.cid) {
setActiveChannel();
}
};
client.on('channel.deleted', handleEvent);
client.on('channel.hidden', handleEvent);
return () => {
client.off('channel.deleted', handleEvent);
client.off('channel.hidden', handleEvent);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channel?.cid]);
const renderChannel = (item) => {
const previewProps = {
activeChannel: channel,
Avatar,
channel: item,
// forces the update of preview component on channel update
channelUpdateCount,
getLatestMessagePreview,
key: item.cid,
Preview,
setActiveChannel,
watchers,
};
return React.createElement(ChannelPreview, { ...previewProps });
};
const baseClass = 'str-chat__channel-list';
const className = clsx(customClasses?.chat ?? 'str-chat', theme, customClasses?.channelList ?? `${baseClass} ${baseClass}-react`, {
'str-chat--windows-flags': useImageFlagEmojisOnWindows && navigator.userAgent.match(/Win/),
[`${baseClass}--open`]: navOpen,
});
const showChannelList = (!searchActive && !searchIsActive) || additionalChannelSearchProps?.popupResults;
return (React.createElement(ChannelListContextProvider, { value: { channels, hasNextPage, loadNextPage, setChannels } },
React.createElement("div", { className: className, ref: channelListRef },
showChannelSearch &&
(Search ? (React.createElement(Search, { directMessagingChannelType: additionalChannelSearchProps?.channelType, disabled: additionalChannelSearchProps?.disabled, exitSearchOnInputBlur: additionalChannelSearchProps?.clearSearchOnClickOutside, placeholder: additionalChannelSearchProps?.placeholder })) : (React.createElement(ChannelSearch, { onSearch: onSearch, onSearchExit: onSearchExit, setChannels: setChannels, ...additionalChannelSearchProps }))),
showChannelList && (React.createElement(List, { error: channelsQueryState.error, loadedChannels: sendChannelsToList ? loadedChannels : undefined, loading: !!channelsQueryState.queryInProgress &&
['reload', 'uninitialized'].includes(channelsQueryState.queryInProgress), LoadingErrorIndicator: LoadingErrorIndicator, LoadingIndicator: LoadingIndicator, setChannels: setChannels }, !loadedChannels?.length ? (React.createElement(EmptyStateIndicator, { listType: 'channel' })) : (React.createElement(Paginator, { hasNextPage: hasNextPage, isLoading: channelsQueryState.queryInProgress === 'load-more', loadNextPage: loadNextPage }, renderChannels
? renderChannels(loadedChannels, renderChannel)
: loadedChannels.map((channel) => renderChannel(channel)))))))));
};
/**
* Renders a preview list of Channels, allowing you to select the Channel you want to open
*/
export const ChannelList = React.memo(UnMemoizedChannelList);