diff --git a/src/components/common/RecipientPicker.tsx b/src/components/common/RecipientPicker.tsx index d8ee54e1c..c5f55bb8c 100644 --- a/src/components/common/RecipientPicker.tsx +++ b/src/components/common/RecipientPicker.tsx @@ -1,11 +1,10 @@ -import type { FC } from '../../lib/teact/teact'; import { memo, useMemo, useState } from '../../lib/teact/teact'; -import { getGlobal, withGlobal } from '../../global'; +import { getActions, getGlobal, withGlobal } from '../../global'; -import type { ApiChatType } from '../../api/types'; +import type { ApiChatFolder, ApiChatType } from '../../api/types'; import type { ThreadId } from '../../types'; -import { API_CHAT_TYPES } from '../../config'; +import { ALL_FOLDER_ID, API_CHAT_TYPES } from '../../config'; import { getCanPostInChat, getHasAdminRight, @@ -16,24 +15,30 @@ import { filterPeersByQuery } from '../../global/helpers/peers'; import { filterChatIdsByType, selectChat, selectChatFullInfo, selectIsMonoforumAdmin, selectUser, } from '../../global/selectors'; +import { selectCurrentLimit } from '../../global/selectors/limits'; import { unique } from '../../util/iteratees'; import sortChatIds from './helpers/sortChatIds'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; +import { useFolderManagerForOrderedIds } from '../../hooks/useFolderManager'; +import useFolderTabs from '../../hooks/useFolderTabs'; +import useLastCallback from '../../hooks/useLastCallback'; +import TabList from '../ui/TabList'; import ChatOrUserPicker from './pickers/ChatOrUserPicker'; export type OwnProps = { isOpen: boolean; searchPlaceholder: string; className?: string; - filter?: ApiChatType[]; + filter?: readonly ApiChatType[]; + isLowStackPriority?: boolean; + isForwarding?: boolean; + withFolders?: boolean; loadMore?: NoneToVoidFunction; onSelectRecipient: (peerId: string, threadId?: ThreadId) => void; onClose: NoneToVoidFunction; onCloseAnimationEnd?: NoneToVoidFunction; - isLowStackPriority?: boolean; - isForwarding?: boolean; }; type StateProps = { @@ -42,9 +47,12 @@ type StateProps = { archivedListIds?: string[]; pinnedIds?: string[]; contactIds?: string[]; + chatFoldersById: Record; + orderedFolderIds?: number[]; + maxFolders: number; }; -const RecipientPicker: FC = ({ +const RecipientPicker = ({ isOpen, currentUserId, activeListIds, @@ -54,14 +62,47 @@ const RecipientPicker: FC = ({ filter = API_CHAT_TYPES, className, searchPlaceholder, + isLowStackPriority, + chatFoldersById, + orderedFolderIds, + isForwarding, + maxFolders, + withFolders, loadMore, onSelectRecipient, onClose, onCloseAnimationEnd, - isLowStackPriority, - isForwarding, -}) => { +}: OwnProps & StateProps) => { + const { openLimitReachedModal } = getActions(); const [search, setSearch] = useState(''); + + const [activeFolderIndex, setActiveFolderIndex] = useState(0); + const { displayedFolders, folderTabs } = useFolderTabs({ + sidebarMode: false, + orderedFolderIds, + chatFoldersById, + maxFolders, + isReadOnly: true, + }); + + const shouldRenderFolders = withFolders && folderTabs?.length && !search; + const displayedFolderId = displayedFolders?.[activeFolderIndex]?.id || ALL_FOLDER_ID; + const orderedChatIds = useFolderManagerForOrderedIds(displayedFolderId); + + const handleSwitchFolderIndex = useLastCallback((index: number) => { + const newTab = folderTabs?.[index]; + if (!newTab) return; + + if (newTab.isBlocked) { + openLimitReachedModal({ + limit: 'dialogFilters', + }); + return; + } + + setActiveFolderIndex(index); + }); + const ids = useMemo(() => { if (!isOpen) return undefined; @@ -73,10 +114,12 @@ const RecipientPicker: FC = ({ // No need for expensive global updates on users, so we avoid them const global = getGlobal(); - const peerIds = [ + const allIds = shouldRenderFolders ? (orderedChatIds || []) : [ ...(activeListIds || []), ...((search && archivedListIds) || []), - ].filter((id) => { + ]; + + const peerIds = allIds.filter((id) => { const chat = selectChat(global, id); const user = selectUser(global, id); const hasAdminRights = chat && getHasAdminRight(chat, 'postMessages'); @@ -95,13 +138,15 @@ const RecipientPicker: FC = ({ return !chatFullInfo || getCanPostInChat(chat, undefined, undefined, chatFullInfo); }); + const idsWithAdditions = shouldRenderFolders ? peerIds : unique([ + ...(currentUserId ? [currentUserId] : []), + ...peerIds, + ...(contactIds || []), + ]); + const sorted = sortChatIds( filterPeersByQuery({ - ids: unique([ - ...(currentUserId ? [currentUserId] : []), - ...peerIds, - ...(contactIds || []), - ]), + ids: idsWithAdditions, query: search, }), undefined, @@ -120,10 +165,23 @@ const RecipientPicker: FC = ({ contactIds, filter, isForwarding, + orderedChatIds, + shouldRenderFolders, ]); const renderingIds = useCurrentOrPrev(ids, true)!; + const chatFolders = useMemo(() => { + if (!shouldRenderFolders) return undefined; + return ( + + ); + }, [folderTabs, activeFolderIndex, shouldRenderFolders]); + return ( = ({ currentUserId={currentUserId} searchPlaceholder={searchPlaceholder} search={search} + subheader={chatFolders} + listActiveKey={activeFolderIndex} onSearchChange={setSearch} loadMore={loadMore} onSelectChatOrUser={onSelectRecipient} @@ -145,6 +205,10 @@ const RecipientPicker: FC = ({ export default memo(withGlobal( (global): Complete => { const { + chatFolders: { + byId: chatFoldersById, + orderedIds: orderedFolderIds, + }, chats: { listIds, orderedPinnedIds, @@ -158,6 +222,9 @@ export default memo(withGlobal( pinnedIds: orderedPinnedIds.active, contactIds: global.contactList?.userIds, currentUserId, + chatFoldersById, + orderedFolderIds, + maxFolders: selectCurrentLimit(global, 'dialogFilters'), }; }, )(RecipientPicker)); diff --git a/src/components/common/pickers/ChatOrUserPicker.scss b/src/components/common/pickers/ChatOrUserPicker.scss index 73cd3d203..f249e213b 100644 --- a/src/components/common/pickers/ChatOrUserPicker.scss +++ b/src/components/common/pickers/ChatOrUserPicker.scss @@ -17,26 +17,39 @@ } .modal-header { - .Button { - margin-right: 0.5rem; + flex-direction: column; + align-items: stretch; + + .search-wrapper { + display: flex; + align-items: center; + + .Button { + margin-right: 0.5rem; + } + + .input-group { + flex: 1; + margin: 0; + } + + .form-control { + unicode-bidi: plaintext; + + height: 2.75rem; + padding: 0.5rem; + border: none; + + font-size: 1.25rem; + line-height: 1.75rem; + + box-shadow: none !important; + } } - .input-group { - flex: 1; - margin: 0; - } - - .form-control { - unicode-bidi: plaintext; - - height: 2.75rem; - padding: 0.5rem; - border: none; - - font-size: 1.25rem; - line-height: 1.75rem; - - box-shadow: none !important; + .TabList { + margin-bottom: -0.375rem; + margin-inline: -1rem; } } diff --git a/src/components/common/pickers/ChatOrUserPicker.tsx b/src/components/common/pickers/ChatOrUserPicker.tsx index d9af4a815..148e6de7d 100644 --- a/src/components/common/pickers/ChatOrUserPicker.tsx +++ b/src/components/common/pickers/ChatOrUserPicker.tsx @@ -1,21 +1,28 @@ -import type { FC } from '../../../lib/teact/teact'; -import type React from '../../../lib/teact/teact'; +import type { TeactNode } from '../../../lib/teact/teact'; import { - memo, useCallback, useMemo, useRef, useState, + memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; -import { getActions, getGlobal } from '../../../global'; +import { getActions, getGlobal, withGlobal } from '../../../global'; import type { ApiTopic } from '../../../api/types'; import type { GlobalState } from '../../../global/types'; -import type { ThreadId } from '../../../types'; +import type { AnimationLevel, ThreadId } from '../../../types'; import { PEER_PICKER_ITEM_HEIGHT_PX } from '../../../config'; import { getCanPostInChat, getGroupStatus, getUserStatus, isUserOnline, } from '../../../global/helpers'; import { isApiPeerChat } from '../../../global/helpers/peers'; -import { selectMonoforumChannel, selectPeer, selectTopics, selectUserStatus } from '../../../global/selectors'; +import { + selectMonoforumChannel, + selectPeer, + selectTabState, + selectTopics, + selectUserStatus, +} from '../../../global/selectors'; +import { selectAnimationLevel } from '../../../global/selectors/sharedState'; import buildClassName from '../../../util/buildClassName'; +import { resolveTransitionName } from '../../../util/resolveTransitionName'; import { REM } from '../helpers/mediaDimensions'; import renderText from '../helpers/renderText'; @@ -28,7 +35,7 @@ import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; import Button from '../../ui/Button'; -import InfiniteScroll from '../../ui/InfiniteScroll'; +import InfiniteScroll, { type OwnProps as InfiniteScrollProps } from '../../ui/InfiniteScroll'; import InputText from '../../ui/InputText'; import Loading from '../../ui/Loading'; import Modal from '../../ui/Modal'; @@ -48,6 +55,8 @@ export type OwnProps = { search: string; className?: string; isLowStackPriority?: boolean; + listActiveKey?: number; + subheader?: TeactNode; loadMore?: NoneToVoidFunction; onSearchChange: (search: string) => void; onSelectChatOrUser: (chatOrUserId: string, threadId?: ThreadId) => void; @@ -55,31 +64,40 @@ export type OwnProps = { onCloseAnimationEnd?: NoneToVoidFunction; }; +type StateProps = { + animationLevel: AnimationLevel; + shouldSkipHistoryAnimations?: boolean; +}; + const CHAT_LIST_SLIDE = 0; const TOPIC_LIST_SLIDE = 1; const TOPIC_ICON_SIZE = 2.75 * REM; const ITEM_CLASS_NAME = 'ChatOrUserPicker-item'; const TOPIC_ITEM_HEIGHT_PX = 56; -const ChatOrUserPicker: FC = ({ +const ChatOrUserPicker = ({ isOpen, currentUserId, chatOrUserIds, search, searchPlaceholder, className, + isLowStackPriority, + subheader, + listActiveKey, + animationLevel, + shouldSkipHistoryAnimations, loadMore, onSearchChange, onSelectChatOrUser, onClose, onCloseAnimationEnd, - isLowStackPriority, -}) => { +}: OwnProps & StateProps) => { const { loadTopics } = getActions(); const oldLang = useOldLang(); const lang = useLang(); - const containerRef = useRef(); + const [chatKeyDownHandler, setChatKeyDownHandler] = useState>(); const topicContainerRef = useRef(); const searchRef = useRef(); const topicSearchRef = useRef(); @@ -147,7 +165,7 @@ const ChatOrUserPicker: FC = ({ setTopicSearch(e.currentTarget.value); }); - const handleKeyDown = useKeyboardListNavigation(containerRef, isOpen, (index) => { + const handleChatSelect = useLastCallback((index) => { if (viewportIds && viewportIds.length > 0) { const chatsById = getGlobal().chats.byId; @@ -160,7 +178,11 @@ const ChatOrUserPicker: FC = ({ onSelectChatOrUser(chatId); } } - }, `.${ITEM_CLASS_NAME}`, true); + }); + + const handleKeyDownHandlerUpdate = useLastCallback((handler: React.KeyboardEventHandler) => { + setChatKeyDownHandler(() => handler); + }); const handleTopicKeyDown = useKeyboardListNavigation(topicContainerRef, isOpen, (index) => { if (topicIds?.length) { @@ -246,50 +268,57 @@ const ChatOrUserPicker: FC = ({ return ( <>
-
- - {!topicIds && } - {topicIds?.map((topicId, i) => ( - onSelectChatOrUser(forumId!, topicId)} - style={`top: ${(viewportOffset + i) * TOPIC_ITEM_HEIGHT_PX}px;`} - avatarElement={( - - )} - title={renderText(topics[topicId].title)} +
+
+ + {topicIds?.length ? ( + + {topicIds.map((topicId, i) => ( + onSelectChatOrUser(forumId!, topicId)} + style={`top: ${i * TOPIC_ITEM_HEIGHT_PX}px;`} + avatarElement={( + + )} + title={renderText(topics[topicId].title)} + /> + ))} + + ) : topicIds && !topicIds.length ? ( +

{lang('NothingFound')}

+ ) : ( + + )} ); } @@ -298,40 +327,40 @@ const ChatOrUserPicker: FC = ({ return ( <>
-
+ {subheader} - {viewportIds?.length ? ( - + - {viewportIds.map(renderChatItem)} - - ) : viewportIds && !viewportIds.length ? ( -

{oldLang('lng_blocked_list_not_found')}

- ) : ( - - )} + onLoadMore={getMore} + onSelect={handleChatSelect} + renderItem={renderChatItem} + onKeyDownHandlerUpdate={handleKeyDownHandlerUpdate} + /> + ); } @@ -353,4 +382,63 @@ const ChatOrUserPicker: FC = ({ ); }; -export default memo(ChatOrUserPicker); +type ChatListContentProps = { + isOpen: boolean; + viewportIds?: string[]; + maxHeight: number; + onLoadMore: InfiniteScrollProps['onLoadMore']; + onSelect: (index: number) => void; + renderItem: (id: string, index: number) => TeactNode; + onKeyDownHandlerUpdate: (handler: React.KeyboardEventHandler) => void; +}; + +function ChatListContent({ + isOpen, + viewportIds, + maxHeight, + onLoadMore, + onSelect, + onKeyDownHandlerUpdate, + renderItem, +}: ChatListContentProps) { + const lang = useLang(); + const containerRef = useRef(); + + const handleKeyDown = useKeyboardListNavigation(containerRef, isOpen, onSelect, `.${ITEM_CLASS_NAME}`, true); + + useEffect(() => { + onKeyDownHandlerUpdate(handleKeyDown); + }, [handleKeyDown, onKeyDownHandlerUpdate]); + + return ( + <> + {viewportIds?.length ? ( + + {viewportIds.map(renderItem)} + + ) : viewportIds && !viewportIds.length ? ( +

{lang('NothingFound')}

+ ) : ( + + )} + + ); +} + +export default memo(withGlobal( + (global): Complete => { + return { + animationLevel: selectAnimationLevel(global), + shouldSkipHistoryAnimations: selectTabState(global).shouldSkipHistoryAnimations, + }; + }, +)(ChatOrUserPicker)); diff --git a/src/components/main/ForwardRecipientPicker.tsx b/src/components/main/ForwardRecipientPicker.tsx index 06505f5b3..2de8a57f9 100644 --- a/src/components/main/ForwardRecipientPicker.tsx +++ b/src/components/main/ForwardRecipientPicker.tsx @@ -119,6 +119,7 @@ const ForwardRecipientPicker: FC = ({ onClose={handleClose} onCloseAnimationEnd={unmarkIsShown} isForwarding={isForwarding} + withFolders /> ); }; diff --git a/src/components/ui/InfiniteScroll.tsx b/src/components/ui/InfiniteScroll.tsx index a0430fcfe..3852ef1f6 100644 --- a/src/components/ui/InfiniteScroll.tsx +++ b/src/components/ui/InfiniteScroll.tsx @@ -15,7 +15,7 @@ import { debounce } from '../../util/schedulers'; import useLastCallback from '../../hooks/useLastCallback'; -type OwnProps = { +export type OwnProps = { ref?: ElementRef; style?: string; className?: string; diff --git a/src/hooks/useFolderTabs.ts b/src/hooks/useFolderTabs.ts index 331a0ddf1..36d988e14 100644 --- a/src/hooks/useFolderTabs.ts +++ b/src/hooks/useFolderTabs.ts @@ -23,23 +23,35 @@ type FolderNameOptions = { emojiSize?: number; }; -const useFolderTabs = ({ - sidebarMode, - orderedFolderIds, - chatFoldersById, - maxFolders, - maxChatLists, - folderInvitesById, - maxFolderInvites, -}: { +type Params = { sidebarMode: boolean; orderedFolderIds?: number[]; chatFoldersById: Record; maxFolders: number; +} & ({ + isReadOnly?: false; maxChatLists: number; folderInvitesById: Record; maxFolderInvites: number; -}) => { +} | { + isReadOnly: true; +}); + +const useFolderTabs = (params: Params) => { + const { + sidebarMode, + orderedFolderIds, + chatFoldersById, + maxFolders, + isReadOnly, + } = params; + + const { + maxChatLists, + folderInvitesById, + maxFolderInvites, + } = !isReadOnly ? params : {}; + const lang = useLang(); const { isMobile } = useAppLayout(); @@ -97,89 +109,92 @@ const useFolderTabs = ({ const canShareFolder = selectCanShareFolder(getGlobal(), id); const contextActions: MenuItemContextAction[] = []; - if (canShareFolder) { - contextActions.push({ - title: lang('FilterShare'), - icon: 'link', - handler: () => { - const chatListCount = Object.values(chatFoldersById).reduce((acc, el) => acc + (el.isChatList ? 1 : 0), 0); - if (chatListCount >= maxChatLists && !folder.isChatList) { - openLimitReachedModal({ - limit: 'chatlistJoined', - }); - return; - } + if (!isReadOnly) { + if (canShareFolder) { + contextActions.push({ + title: lang('FilterShare'), + icon: 'link', + handler: () => { + const chatListCount = Object.values(chatFoldersById) + .reduce((acc, el) => acc + (el.isChatList ? 1 : 0), 0); + if (chatListCount >= maxChatLists! && !folder.isChatList) { + openLimitReachedModal({ + limit: 'chatlistJoined', + }); + return; + } - // Greater amount can be after premium downgrade - if (folderInvitesById[id]?.length >= maxFolderInvites) { - openLimitReachedModal({ - limit: 'chatlistInvites', - }); - return; - } + // Greater amount can be after premium downgrade + if (folderInvitesById![id]?.length >= maxFolderInvites!) { + openLimitReachedModal({ + limit: 'chatlistInvites', + }); + return; + } - openShareChatFolderModal({ - folderId: id, + openShareChatFolderModal({ + folderId: id, + }); + }, + }); + } + + if (id === ALL_FOLDER_ID) { + contextActions.push({ + title: lang('FilterEditFolders'), + icon: 'edit', + handler: () => { + openSettingsScreen({ screen: SettingsScreens.Folders }); + }, + }); + + if (folderUnreadChatsCountersById[id]?.length) { + contextActions.push({ + title: lang('ChatListMarkAllAsRead'), + icon: 'readchats', + handler: () => handleReadAllChats(folder.id), }); - }, - }); - } - - if (id === ALL_FOLDER_ID) { - contextActions.push({ - title: lang('FilterEditFolders'), - icon: 'edit', - handler: () => { - openSettingsScreen({ screen: SettingsScreens.Folders }); - }, - }); - - if (folderUnreadChatsCountersById[id]?.length) { + } + } else { contextActions.push({ - title: lang('ChatListMarkAllAsRead'), - icon: 'readchats', - handler: () => handleReadAllChats(folder.id), + title: lang('EditFolder'), + icon: 'edit', + handler: () => { + openEditChatFolder({ folderId: id }); + }, }); - } - } else { - contextActions.push({ - title: lang('EditFolder'), - icon: 'edit', - handler: () => { - openEditChatFolder({ folderId: id }); - }, - }); - if (folderUnreadChatsCountersById[id]?.length) { + if (folderUnreadChatsCountersById[id]?.length) { + contextActions.push({ + title: lang('ChatListMarkAllAsRead'), + icon: 'readchats', + handler: () => handleReadAllChats(folder.id), + }); + } + contextActions.push({ - title: lang('ChatListMarkAllAsRead'), - icon: 'readchats', - handler: () => handleReadAllChats(folder.id), + title: lang('FilterMenuDelete'), + icon: 'delete', + destructive: true, + handler: () => { + openDeleteChatFolderModal({ folderId: id }); + }, }); } - contextActions.push({ - title: lang('FilterMenuDelete'), - icon: 'delete', - destructive: true, - handler: () => { - openDeleteChatFolderModal({ folderId: id }); - }, - }); - } + if (!isMobile) { + contextActions.push({ + isSeparator: true, + }); - if (!isMobile) { - contextActions.push({ - isSeparator: true, - }); - - contextActions.push({ - title: sidebarMode ? lang('TabsPositionTop') : lang('TabsPositionLeft'), - icon: 'forums', - handler: () => { - setSharedSettingOption({ foldersPosition: sidebarMode ? 'top' : 'left' }); - }, - }); + contextActions.push({ + title: sidebarMode ? lang('TabsPositionTop') : lang('TabsPositionLeft'), + icon: 'forums', + handler: () => { + setSharedSettingOption({ foldersPosition: sidebarMode ? 'top' : 'left' }); + }, + }); + } } const folderNameOptions: FolderNameOptions = { @@ -215,15 +230,14 @@ const useFolderTabs = ({ badgeCount: folderCountersById[id]?.chatsCount, isBadgeActive: Boolean(folderCountersById[id]?.notificationsCount), isBlocked, - contextActions: contextActions?.length ? contextActions : undefined, + contextActions: contextActions.length ? contextActions : undefined, emoticon: folderIcon, noTitleAnimations: folder.noTitleAnimations, } satisfies TabWithProperties; }); }, [ displayedFolders, maxFolders, folderCountersById, lang, chatFoldersById, maxChatLists, folderInvitesById, - maxFolderInvites, folderUnreadChatsCountersById, openSettingsScreen, sidebarMode, isMobile, - setSharedSettingOption, + maxFolderInvites, folderUnreadChatsCountersById, isReadOnly, sidebarMode, isMobile, ]); return {