From 729e4ad7919b873c6ed2341e5a7ed874b0aac635 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Thu, 29 Aug 2024 15:52:16 +0200 Subject: [PATCH] UI: Picker refactoring (#4773) --- src/bundles/extra.ts | 1 - src/components/common/CountryPickerModal.tsx | 24 +- .../common/DeleteMessageModal.module.scss | 14 +- .../common/FullNameTitle.module.scss | 23 +- src/components/common/FullNameTitle.tsx | 17 +- src/components/common/GroupChatInfo.tsx | 19 +- src/components/common/Picker.scss | 69 ---- src/components/common/Picker.tsx | 325 --------------- src/components/common/RecipientPicker.tsx | 2 +- src/components/common/StickerSetCard.scss | 4 - .../{ => pickers}/ChatOrUserPicker.scss | 0 .../common/{ => pickers}/ChatOrUserPicker.tsx | 50 +-- src/components/common/pickers/ItemPicker.tsx | 245 +++++++++++ src/components/common/pickers/PeerPicker.tsx | 391 ++++++++++++++++++ .../common/pickers/PickerItem.module.scss | 101 +++++ src/components/common/pickers/PickerItem.tsx | 104 +++++ .../common/pickers/PickerModal.module.scss | 26 ++ src/components/common/pickers/PickerModal.tsx | 58 +++ .../{ => pickers}/PickerSelectedItem.scss | 1 + .../{ => pickers}/PickerSelectedItem.tsx | 28 +- .../common/pickers/PickerStyles.module.scss | 75 ++++ src/components/left/ChatFolderModal.tsx | 1 - src/components/left/main/LeftMainHeader.tsx | 2 +- src/components/left/newChat/NewChatStep1.tsx | 8 +- src/components/left/search/ChatResults.tsx | 2 +- src/components/left/search/LeftSearch.scss | 2 +- .../left/settings/BlockUserModal.tsx | 2 +- src/components/left/settings/Settings.scss | 45 +- .../left/settings/SettingsActiveSessions.scss | 6 +- .../left/settings/SettingsActiveSessions.tsx | 6 +- .../left/settings/SettingsActiveWebsites.tsx | 2 +- .../SettingsDoNotTranslate.module.scss | 10 - .../left/settings/SettingsDoNotTranslate.tsx | 113 ++--- .../left/settings/SettingsGeneral.tsx | 1 + .../left/settings/SettingsLanguage.tsx | 51 ++- src/components/left/settings/SettingsMain.tsx | 15 + .../left/settings/SettingsPrivacy.tsx | 12 +- .../settings/SettingsPrivacyVisibility.tsx | 2 +- ...SettingsPrivacyVisibilityExceptionList.tsx | 8 +- .../left/settings/SettingsQuickReaction.tsx | 1 + .../settings/folders/SettingsFolders.scss | 12 +- .../folders/SettingsFoldersChatFilters.tsx | 10 +- .../settings/folders/SettingsFoldersEdit.tsx | 17 +- .../folders/SettingsShareChatlist.tsx | 12 +- .../main/AppendEntityPicker.async.tsx | 18 - .../main/AppendEntityPicker.module.scss | 46 --- .../main/AppendEntityPickerModal.tsx | 282 ------------- .../premium/GiveawayChannelPickerModal.tsx | 144 +++++++ .../main/premium/GiveawayModal.module.scss | 6 + src/components/main/premium/GiveawayModal.tsx | 114 ++--- .../premium/GiveawayTypeOption.module.scss | 6 +- .../main/premium/GiveawayUserPickerModal.tsx | 143 +++++++ .../main/premium/PremiumGiftingModal.tsx | 8 +- .../PremiumSubscriptionOption.module.scss | 6 +- .../DeleteSelectedMessageModal.module.scss | 6 +- src/components/middle/message/Giveaway.tsx | 5 +- src/components/middle/message/Poll.tsx | 1 - .../middle/search/MiddleSearch.module.scss | 2 + src/components/middle/search/MiddleSearch.tsx | 2 +- .../modals/chatlist/ChatlistAlready.tsx | 12 +- .../modals/chatlist/ChatlistDelete.tsx | 7 +- .../modals/chatlist/ChatlistNew.tsx | 7 +- .../collectible/CollectibleInfoModal.tsx | 2 +- .../modals/common/TableInfoModal.tsx | 2 +- .../inviteViaLink/InviteViaLinkModal.tsx | 15 +- src/components/right/AddChatMembers.tsx | 8 +- src/components/right/Profile.scss | 4 - .../right/management/ManageReactions.tsx | 1 + .../right/management/Management.scss | 19 +- .../right/management/RemoveGroupUserModal.tsx | 2 +- .../story/privacy/AllowDenyList.tsx | 8 +- src/components/story/privacy/CloseFriends.tsx | 8 +- src/components/ui/Checkbox.scss | 57 ++- src/components/ui/Checkbox.tsx | 12 +- src/components/ui/CheckboxGroup.tsx | 3 - src/components/ui/ListItem.scss | 11 +- src/components/ui/ListItemWithOptions.tsx | 86 ++++ src/components/ui/Modal.tsx | 10 +- src/components/ui/Radio.scss | 57 ++- src/components/ui/Radio.tsx | 18 +- src/components/ui/RadioGroup.tsx | 3 + .../ui/RangeSliderWithMarks.module.scss | 2 +- src/global/actions/apiUpdaters/messages.ts | 6 +- src/global/helpers/chats.ts | 21 +- src/global/helpers/peers.ts | 47 +++ src/styles/_variables.scss | 4 + src/styles/index.scss | 1 + src/styles/themes.json | 1 + 88 files changed, 1980 insertions(+), 1160 deletions(-) delete mode 100644 src/components/common/Picker.scss delete mode 100644 src/components/common/Picker.tsx rename src/components/common/{ => pickers}/ChatOrUserPicker.scss (100%) rename src/components/common/{ => pickers}/ChatOrUserPicker.tsx (86%) create mode 100644 src/components/common/pickers/ItemPicker.tsx create mode 100644 src/components/common/pickers/PeerPicker.tsx create mode 100644 src/components/common/pickers/PickerItem.module.scss create mode 100644 src/components/common/pickers/PickerItem.tsx create mode 100644 src/components/common/pickers/PickerModal.module.scss create mode 100644 src/components/common/pickers/PickerModal.tsx rename src/components/common/{ => pickers}/PickerSelectedItem.scss (99%) rename src/components/common/{ => pickers}/PickerSelectedItem.tsx (78%) create mode 100644 src/components/common/pickers/PickerStyles.module.scss delete mode 100644 src/components/main/AppendEntityPicker.async.tsx delete mode 100644 src/components/main/AppendEntityPicker.module.scss delete mode 100644 src/components/main/AppendEntityPickerModal.tsx create mode 100644 src/components/main/premium/GiveawayChannelPickerModal.tsx create mode 100644 src/components/main/premium/GiveawayUserPickerModal.tsx create mode 100644 src/components/ui/ListItemWithOptions.tsx create mode 100644 src/global/helpers/peers.ts diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 7700b5912..a48735e31 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -19,7 +19,6 @@ export { default as PremiumMainModal } from '../components/main/premium/PremiumM export { default as GiftPremiumModal } from '../components/main/premium/GiftPremiumModal'; export { default as GiveawayModal } from '../components/main/premium/GiveawayModal'; export { default as PremiumGiftingModal } from '../components/main/premium/PremiumGiftingModal'; -export { default as AppendEntityPickerModal } from '../components/main/AppendEntityPickerModal'; export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal'; export { default as StatusPickerMenu } from '../components/left/main/StatusPickerMenu'; export { default as BoostModal } from '../components/modals/boost/BoostModal'; diff --git a/src/components/common/CountryPickerModal.tsx b/src/components/common/CountryPickerModal.tsx index 58aed9072..01f5d4b5c 100644 --- a/src/components/common/CountryPickerModal.tsx +++ b/src/components/common/CountryPickerModal.tsx @@ -15,7 +15,7 @@ import usePrevious from '../../hooks/usePrevious'; import Button from '../ui/Button'; import Modal from '../ui/Modal'; import Icon from './icons/Icon'; -import Picker from './Picker'; +import ItemPicker from './pickers/ItemPicker'; import styles from './CountryPickerModal.module.scss'; @@ -47,9 +47,13 @@ const CountryPickerModal: FC = ({ return []; } - return countryList - .filter((country) => !country.isHidden) - .map((country) => country.iso2); + return countryList.filter((country) => !country.isHidden && country.iso2 !== 'FT') + .map(({ + iso2, defaultName, + }) => ({ + value: iso2, + label: defaultName, + })); }, [countryList]); const handleSelectedIdsChange = useLastCallback((newSelectedIds: string[]) => { @@ -92,14 +96,14 @@ const CountryPickerModal: FC = ({
-
diff --git a/src/components/common/DeleteMessageModal.module.scss b/src/components/common/DeleteMessageModal.module.scss index b8ff4c9dd..749b30f8f 100644 --- a/src/components/common/DeleteMessageModal.module.scss +++ b/src/components/common/DeleteMessageModal.module.scss @@ -6,7 +6,7 @@ display: flex; align-items: center; gap: 1rem; - margin-bottom: 1rem; + margin: 0 1rem; } .title { @@ -15,7 +15,7 @@ .actionTitle { margin-top: 1.5rem; - margin-left: 0.5rem; + margin-left: 1rem; color: var(--color-links); font-size: 1rem; font-weight: bold; @@ -28,7 +28,7 @@ .listItemButton { margin-top: 0.1875rem; - margin-left: 0.4375rem; + margin-left: 1rem; } .button { @@ -52,16 +52,12 @@ overflow: hidden; max-height: 0; /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ - transition: max-height 0.3s ease-out; + transition: max-height 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); } .restrictionContainerOpen, .dropdownListOpen { max-height: 100vh; /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ - transition: max-height 0.3s ease-in; -} - -:global(.Checkbox) { - padding-left: 3.875rem; + transition: max-height 0.3s ease-in-out; } diff --git a/src/components/common/FullNameTitle.module.scss b/src/components/common/FullNameTitle.module.scss index 2106a4835..e3a0240cf 100644 --- a/src/components/common/FullNameTitle.module.scss +++ b/src/components/common/FullNameTitle.module.scss @@ -3,15 +3,22 @@ align-items: center; gap: 0.25rem; - .fullName { - margin-bottom: 0; - - &.canCopy { - pointer-events: all; - } - } - :global(.custom-emoji) { color: var(--color-primary); } } + +.fullName { + font-size: 1rem; + margin-bottom: 0; + + &.canCopy { + pointer-events: all; + } +} + +.ellipsis { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/components/common/FullNameTitle.tsx b/src/components/common/FullNameTitle.tsx index 6ff417bcc..38a72a8ba 100644 --- a/src/components/common/FullNameTitle.tsx +++ b/src/components/common/FullNameTitle.tsx @@ -38,9 +38,10 @@ type OwnProps = { isSavedDialog?: boolean; noLoopLimit?: boolean; canCopyTitle?: boolean; + iconElement?: React.ReactNode; + allowMultiLine?: boolean; onEmojiStatusClick?: NoneToVoidFunction; observeIntersection?: ObserveFn; - iconElement?: React.ReactNode; }; const FullNameTitle: FC = ({ @@ -54,9 +55,10 @@ const FullNameTitle: FC = ({ isSavedDialog, noLoopLimit, canCopyTitle, + iconElement, + allowMultiLine, onEmojiStatusClick, observeIntersection, - iconElement, }) => { const lang = useOldLang(); const { showNotification } = getActions(); @@ -99,7 +101,9 @@ const FullNameTitle: FC = ({ if (specialTitle) { return (
-

{specialTitle}

+

+ {specialTitle} +

); } @@ -109,7 +113,12 @@ const FullNameTitle: FC = ({

{renderText(title || '')} diff --git a/src/components/common/GroupChatInfo.tsx b/src/components/common/GroupChatInfo.tsx index c5271ff11..56afbb05c 100644 --- a/src/components/common/GroupChatInfo.tsx +++ b/src/components/common/GroupChatInfo.tsx @@ -5,12 +5,12 @@ import { getActions, withGlobal } from '../../global'; import type { ApiChat, ApiThreadInfo, ApiTopic, ApiTypingStatus, ApiUser, } from '../../api/types'; -import type { LangFn } from '../../hooks/useOldLang'; import type { IconName } from '../../types/icons'; import { MediaViewerOrigin, type StoryViewerOrigin, type ThreadId } from '../../types'; import { getChatTypeString, + getGroupStatus, getMainUsername, isChatSuperGroup, } from '../../global/helpers'; @@ -261,23 +261,6 @@ const GroupChatInfo: FC = ({ ); }; -function getGroupStatus(lang: LangFn, chat: ApiChat) { - const chatTypeString = lang(getChatTypeString(chat)); - const { membersCount } = chat; - - if (chat.isRestricted) { - return chatTypeString === 'Channel' ? 'channel is inaccessible' : 'group is inaccessible'; - } - - if (!membersCount) { - return chatTypeString; - } - - return chatTypeString === 'Channel' - ? lang('Subscribers', membersCount, 'i') - : lang('Members', membersCount, 'i'); -} - export default memo(withGlobal( (global, { chatId, threadId }): StateProps => { const chat = selectChat(global, chatId); diff --git a/src/components/common/Picker.scss b/src/components/common/Picker.scss deleted file mode 100644 index 3f460805b..000000000 --- a/src/components/common/Picker.scss +++ /dev/null @@ -1,69 +0,0 @@ -.Picker { - height: 100%; - display: flex; - flex-direction: column; - overflow: hidden; - - .picker-header { - padding: 0 1rem 0.25rem 0.75rem; - border-bottom: 1px solid var(--color-borders); - display: flex; - flex-flow: row wrap; - flex-shrink: 0; - - overflow-y: auto; - max-height: 20rem; - - .input-group { - margin-bottom: 0.5rem; - margin-left: 0.5rem; - flex-grow: 1; - } - - .form-control { - height: 2rem; - border: none; - border-radius: 0; - padding: 0; - box-shadow: none; - } - } - - .picker-category-title { - color: var(--color-text-secondary); - padding-inline: 1rem; - font-weight: 500; - - &:not(:first-child) { - border-top: 1px solid var(--color-borders); - padding-top: 0.75rem; - margin-top: 0.375rem; - } - } - - .picker-list { - flex-grow: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 0.5rem; - - &.withRoundedCheckbox { - padding: 0; - } - - @media (max-width: 600px) { - padding-left: 0 !important; - padding-right: 0 !important; - } - } - - .no-results { - height: 100%; - margin: 0; - padding: 1rem 1rem; - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-secondary); - } -} diff --git a/src/components/common/Picker.tsx b/src/components/common/Picker.tsx deleted file mode 100644 index 99022348a..000000000 --- a/src/components/common/Picker.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { - memo, useCallback, useEffect, useMemo, useRef, -} from '../../lib/teact/teact'; - -import type { ApiCountry } from '../../api/types'; -import type { CustomPeer, CustomPeerType, UniqueCustomPeer } from '../../types'; - -import { requestMeasure } from '../../lib/fasterdom/fasterdom'; -import { isUserId } from '../../global/helpers'; -import buildClassName from '../../util/buildClassName'; -import { buildCollectionByKey } from '../../util/iteratees'; - -import useInfiniteScroll from '../../hooks/useInfiniteScroll'; -import useLastCallback from '../../hooks/useLastCallback'; -import useOldLang from '../../hooks/useOldLang'; - -import Checkbox from '../ui/Checkbox'; -import InfiniteScroll from '../ui/InfiniteScroll'; -import InputText from '../ui/InputText'; -import ListItem from '../ui/ListItem'; -import Loading from '../ui/Loading'; -import GroupChatInfo from './GroupChatInfo'; -import PickerSelectedItem from './PickerSelectedItem'; -import PrivateChatInfo from './PrivateChatInfo'; - -import './Picker.scss'; - -type OwnProps = { - className?: string; - categories?: UniqueCustomPeer[]; - itemIds: string[]; - selectedCategories?: CustomPeerType[]; - selectedIds: string[]; - lockedSelectedIds?: string[]; - lockedUnselectedIds?: string[]; - lockedUnselectedSubtitle?: string; - filterValue?: string; - filterPlaceholder?: string; - categoryPlaceholderKey?: string; - notFoundText?: string; - searchInputId?: string; - isLoading?: boolean; - noScrollRestore?: boolean; - isSearchable?: boolean; - isRoundCheckbox?: boolean; - forceShowSelf?: boolean; - isViewOnly?: boolean; - onSelectedCategoriesChange?: (categories: CustomPeerType[]) => void; - onSelectedIdsChange?: (ids: string[]) => void; - onFilterChange?: (value: string) => void; - onDisabledClick?: (id: string, isSelected: boolean) => void; - onLoadMore?: () => void; - isCountryList?: boolean; - countryList?: ApiCountry[]; -}; - -// Focus slows down animation, also it breaks transition layout in Chrome -const FOCUS_DELAY_MS = 500; - -const MAX_FULL_ITEMS = 10; -const ALWAYS_FULL_ITEMS_COUNT = 5; - -const Picker: FC = ({ - className, - categories, - itemIds, - selectedCategories, - categoryPlaceholderKey, - selectedIds, - filterValue, - filterPlaceholder, - notFoundText, - searchInputId, - isLoading, - noScrollRestore, - isSearchable, - isRoundCheckbox, - lockedSelectedIds, - lockedUnselectedIds, - lockedUnselectedSubtitle, - forceShowSelf, - isViewOnly, - onSelectedCategoriesChange, - onSelectedIdsChange, - onFilterChange, - onDisabledClick, - onLoadMore, - isCountryList, - countryList, -}) => { - // eslint-disable-next-line no-null/no-null - const inputRef = useRef(null); - const shouldMinimize = selectedIds.length > MAX_FULL_ITEMS; - - useEffect(() => { - if (!isSearchable) return; - setTimeout(() => { - requestMeasure(() => { - inputRef.current!.focus(); - }); - }, FOCUS_DELAY_MS); - }, [isSearchable]); - - const lockedSelectedIdsSet = useMemo(() => new Set(lockedSelectedIds), [lockedSelectedIds]); - const lockedUnselectedIdsSet = useMemo(() => new Set(lockedUnselectedIds), [lockedUnselectedIds]); - - const unlockedSelectedIds = useMemo(() => { - return selectedIds.filter((id) => !lockedSelectedIdsSet.has(id)); - }, [lockedSelectedIdsSet, selectedIds]); - - const categoriesByType = useMemo(() => { - if (!categories) return {}; - return buildCollectionByKey(categories, 'type'); - }, [categories]); - - const sortedItemIds = useMemo(() => { - if (filterValue) { - return itemIds; - } - - const lockedSelectedBucket: string[] = []; - const unlockedBucket: string[] = []; - const lockedUnselectableBucket: string[] = []; - - itemIds.forEach((id) => { - if (lockedSelectedIdsSet.has(id)) { - lockedSelectedBucket.push(id); - } else if (lockedUnselectedIdsSet.has(id)) { - lockedUnselectableBucket.push(id); - } else { - unlockedBucket.push(id); - } - }); - - return lockedSelectedBucket.concat(unlockedBucket, lockedUnselectableBucket); - }, [filterValue, itemIds, lockedSelectedIdsSet, lockedUnselectedIdsSet]); - - const handleItemClick = useLastCallback((id: string) => { - if (lockedSelectedIdsSet.has(id)) { - onDisabledClick?.(id, true); - return; - } - - if (lockedUnselectedIdsSet.has(id)) { - onDisabledClick?.(id, false); - return; - } - - if (categoriesByType[id]) { - const categoryType = categoriesByType[id].type; - const newSelectedCategories = selectedCategories?.slice() || []; - if (newSelectedCategories.includes(categoryType)) { - newSelectedCategories.splice(newSelectedCategories.indexOf(categoryType), 1); - } else { - newSelectedCategories.push(categoryType); - } - onSelectedCategoriesChange?.(newSelectedCategories); - } else { - const newSelectedIds = selectedIds.slice(); - if (newSelectedIds.includes(id)) { - newSelectedIds.splice(newSelectedIds.indexOf(id), 1); - } else { - newSelectedIds.push(id); - } - onSelectedIdsChange?.(newSelectedIds); - } - onFilterChange?.(''); - }); - - const handleFilterChange = useLastCallback((e: React.ChangeEvent) => { - const { value } = e.currentTarget; - onFilterChange?.(value); - }); - - const [viewportIds, getMore] = useInfiniteScroll( - onLoadMore, sortedItemIds, Boolean(filterValue), - ); - - const lang = useOldLang(); - - const countriesByIso = useMemo(() => { - if (!countryList) return undefined; - return buildCollectionByKey(countryList, 'iso2'); - }, [countryList]); - - const renderCategory = useLastCallback((category: CustomPeer) => { - return ( - - ); - }); - - const renderChatInfo = useLastCallback((id: string) => { - const isUnselectable = lockedUnselectedIdsSet.has(id); - if (isCountryList && countriesByIso) { - const country = countriesByIso[id]; - return
{country.defaultName}
; - } else if (isUserId(id)) { - return ( - - ); - } else { - return ; - } - }); - - const renderItem = useCallback((id: string, isCategory?: boolean) => { - const category = isCategory ? categoriesByType[id] : undefined; - const shouldRenderLockIcon = lockedUnselectedIdsSet.has(id); - const isLocked = lockedSelectedIdsSet.has(id) || shouldRenderLockIcon; - const isChecked = category ? selectedCategories?.includes(category.type) : selectedIds.includes(id); - const renderCheckbox = () => { - return (isViewOnly || shouldRenderLockIcon) ? undefined : ( - - ); - }; - return ( - handleItemClick(id)} - ripple - > - {!isRoundCheckbox ? renderCheckbox() : undefined} - {category ? renderCategory(category) : renderChatInfo(id)} - {isRoundCheckbox ? renderCheckbox() : undefined} - - ); - }, [ - categoriesByType, isRoundCheckbox, isViewOnly, lockedSelectedIdsSet, lockedUnselectedIdsSet, - onDisabledClick, renderChatInfo, selectedCategories, selectedIds, - ]); - - const beforeChildren = useMemo(() => { - return ( -
- {Boolean(categories?.length) && ( - <> - {categoryPlaceholderKey &&
{lang(categoryPlaceholderKey)}
} - {categories?.map((category) => renderItem(category.type, true))} -
{lang('FilterChats')}
- - )} -
- ); - }, [categories, categoryPlaceholderKey, lang, renderItem]); - - return ( -
- {isSearchable && ( -
- {selectedCategories?.map((category) => ( - - ))} - {lockedSelectedIds?.map((id, i) => ( - - ))} - {unlockedSelectedIds.map((id, i) => ( - - ))} - -
- )} - - {viewportIds?.length ? ( - - {viewportIds.map((id) => renderItem(id))} - - ) : !isLoading && viewportIds && !viewportIds.length ? ( -

{notFoundText || 'Sorry, nothing found.'}

- ) : ( - - )} -
- ); -}; - -export default memo(Picker); diff --git a/src/components/common/RecipientPicker.tsx b/src/components/common/RecipientPicker.tsx index 366468bde..a4b033eba 100644 --- a/src/components/common/RecipientPicker.tsx +++ b/src/components/common/RecipientPicker.tsx @@ -20,7 +20,7 @@ import sortChatIds from './helpers/sortChatIds'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useOldLang from '../../hooks/useOldLang'; -import ChatOrUserPicker from './ChatOrUserPicker'; +import ChatOrUserPicker from './pickers/ChatOrUserPicker'; export type OwnProps = { isOpen: boolean; diff --git a/src/components/common/StickerSetCard.scss b/src/components/common/StickerSetCard.scss index cf8f29175..e252d97ad 100644 --- a/src/components/common/StickerSetCard.scss +++ b/src/components/common/StickerSetCard.scss @@ -1,8 +1,4 @@ .StickerSetCard { - .settings-item &.ListItem { - margin-bottom: 0.5rem; - } - .StickerButton, .Button { width: 3rem; diff --git a/src/components/common/ChatOrUserPicker.scss b/src/components/common/pickers/ChatOrUserPicker.scss similarity index 100% rename from src/components/common/ChatOrUserPicker.scss rename to src/components/common/pickers/ChatOrUserPicker.scss diff --git a/src/components/common/ChatOrUserPicker.tsx b/src/components/common/pickers/ChatOrUserPicker.tsx similarity index 86% rename from src/components/common/ChatOrUserPicker.tsx rename to src/components/common/pickers/ChatOrUserPicker.tsx index aa1e0592e..1be70ba6d 100644 --- a/src/components/common/ChatOrUserPicker.tsx +++ b/src/components/common/pickers/ChatOrUserPicker.tsx @@ -1,34 +1,34 @@ -import type { FC } from '../../lib/teact/teact'; +import type { FC } from '../../../lib/teact/teact'; import React, { memo, useMemo, useRef, useState, -} from '../../lib/teact/teact'; -import { getActions, getGlobal } from '../../global'; +} from '../../../lib/teact/teact'; +import { getActions, getGlobal } from '../../../global'; -import type { ApiTopic } from '../../api/types'; -import type { ThreadId } from '../../types'; +import type { ApiTopic } from '../../../api/types'; +import type { ThreadId } from '../../../types'; -import { CHAT_HEIGHT_PX } from '../../config'; -import { getCanPostInChat, isUserId } from '../../global/helpers'; -import buildClassName from '../../util/buildClassName'; -import { REM } from './helpers/mediaDimensions'; -import renderText from './helpers/renderText'; +import { CHAT_HEIGHT_PX } from '../../../config'; +import { getCanPostInChat, isUserId } from '../../../global/helpers'; +import buildClassName from '../../../util/buildClassName'; +import { REM } from '../helpers/mediaDimensions'; +import renderText from '../helpers/renderText'; -import useInfiniteScroll from '../../hooks/useInfiniteScroll'; -import useInputFocusOnOpen from '../../hooks/useInputFocusOnOpen'; -import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; -import useLastCallback from '../../hooks/useLastCallback'; -import useOldLang from '../../hooks/useOldLang'; +import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; +import useInputFocusOnOpen from '../../../hooks/useInputFocusOnOpen'; +import useKeyboardListNavigation from '../../../hooks/useKeyboardListNavigation'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; -import Button from '../ui/Button'; -import InfiniteScroll from '../ui/InfiniteScroll'; -import InputText from '../ui/InputText'; -import ListItem from '../ui/ListItem'; -import Loading from '../ui/Loading'; -import Modal from '../ui/Modal'; -import Transition from '../ui/Transition'; -import GroupChatInfo from './GroupChatInfo'; -import PrivateChatInfo from './PrivateChatInfo'; -import TopicIcon from './TopicIcon'; +import Button from '../../ui/Button'; +import InfiniteScroll from '../../ui/InfiniteScroll'; +import InputText from '../../ui/InputText'; +import ListItem from '../../ui/ListItem'; +import Loading from '../../ui/Loading'; +import Modal from '../../ui/Modal'; +import Transition from '../../ui/Transition'; +import GroupChatInfo from '../GroupChatInfo'; +import PrivateChatInfo from '../PrivateChatInfo'; +import TopicIcon from '../TopicIcon'; import './ChatOrUserPicker.scss'; diff --git a/src/components/common/pickers/ItemPicker.tsx b/src/components/common/pickers/ItemPicker.tsx new file mode 100644 index 000000000..d4634ee9c --- /dev/null +++ b/src/components/common/pickers/ItemPicker.tsx @@ -0,0 +1,245 @@ +import type { TeactNode } from '../../../lib/teact/teact'; +import React, { + memo, useCallback, useEffect, + useMemo, + useRef, +} from '../../../lib/teact/teact'; + +import { requestMeasure } from '../../../lib/fasterdom/fasterdom'; +import buildClassName from '../../../util/buildClassName'; +import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; + +import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import Checkbox from '../../ui/Checkbox'; +import InfiniteScroll from '../../ui/InfiniteScroll'; +import InputText from '../../ui/InputText'; +import Loading from '../../ui/Loading'; +import Radio from '../../ui/Radio'; +import Icon from '../icons/Icon'; +import PickerItem from './PickerItem'; + +import styles from './PickerStyles.module.scss'; + +export type ItemPickerOption = { + label: TeactNode; + subLabel?: string; + disabled?: boolean; + isLoading?: boolean; + value: string; +}; + +type SingleModeProps = { + allowMultiple?: false; + itemInputType?: 'radio'; + selectedValue?: string; + selectedValues?: never; // Help TS to throw an error if this is passed + onSelectedValueChange?: (value: string) => void; +}; + +type MultipleModeProps = { + allowMultiple: true; + itemInputType: 'checkbox'; + selectedValue?: never; + selectedValues: string[]; + lockedSelectedValues?: string[]; + lockedUnselectedValues?: string[]; + onSelectedValuesChange?: (values: string[]) => void; +}; + +type OwnProps = { + className?: string; + isSearchable?: boolean; + searchInputId?: string; + items: ItemPickerOption[]; + itemClassName?: string; + filterValue?: string; + filterPlaceholder?: string; + notFoundText?: string; + isLoading?: boolean; + noScrollRestore?: boolean; + isViewOnly?: boolean; + withDefaultPadding?: boolean; + onFilterChange?: (value: string) => void; + onDisabledClick?: (value: string, isSelected: boolean) => void; + onLoadMore?: () => void; +} & (SingleModeProps | MultipleModeProps); + +// Focus slows down animation, also it breaks transition layout in Chrome +const FOCUS_DELAY_MS = 500; + +const ITEM_CLASS_NAME = 'ItemPickerItem'; + +const ItemPicker = ({ + className, + isSearchable, + searchInputId, + items, + filterValue, + notFoundText, + isLoading, + noScrollRestore, + filterPlaceholder, + isViewOnly, + itemInputType, + itemClassName, + withDefaultPadding, + onFilterChange, + onDisabledClick, + onLoadMore, + ...optionalProps +}: OwnProps) => { + const lang = useOldLang(); + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(null); + + const allowMultiple = optionalProps.allowMultiple; + const lockedSelectedValues = allowMultiple ? optionalProps.lockedSelectedValues : undefined; + const lockedUnselectedValues = allowMultiple ? optionalProps.lockedUnselectedValues : undefined; + + useEffect(() => { + if (!isSearchable) return; + setTimeout(() => { + requestMeasure(() => { + inputRef.current!.focus(); + }); + }, FOCUS_DELAY_MS); + }, [isSearchable]); + + const selectedValues = useMemo(() => { + if (allowMultiple) { + return optionalProps.selectedValues; + } + + return optionalProps.selectedValue ? [optionalProps.selectedValue] : MEMO_EMPTY_ARRAY; + }, [allowMultiple, optionalProps.selectedValue, optionalProps.selectedValues]); + + const lockedSelectedValuesSet = useMemo(() => new Set(lockedSelectedValues), [lockedSelectedValues]); + const lockedUnselectedValuesSet = useMemo(() => new Set(lockedUnselectedValues), [lockedUnselectedValues]); + + const sortedItemValuesList = useMemo(() => { + if (filterValue) { + return items.map((item) => item.value); + } + + const lockedSelectedBucket: ItemPickerOption[] = []; + const unlockedBucket: ItemPickerOption[] = []; + const lockedUnselectableBucket: ItemPickerOption[] = []; + + items.forEach((item) => { + if (lockedSelectedValuesSet.has(item.value)) { + lockedSelectedBucket.push(item); + } else if (lockedUnselectedValuesSet.has(item.value)) { + lockedUnselectableBucket.push(item); + } else { + unlockedBucket.push(item); + } + }); + + return lockedSelectedBucket.concat(unlockedBucket, lockedUnselectableBucket).map((item) => item.value); + }, [filterValue, items, lockedSelectedValuesSet, lockedUnselectedValuesSet]); + + const handleItemClick = useLastCallback((value: string) => { + if (allowMultiple) { + const newSelectedValues = selectedValues.slice(); + const index = newSelectedValues.indexOf(value); + if (index >= 0) { + newSelectedValues.splice(index, 1); + } else { + newSelectedValues.push(value); + } + + optionalProps.onSelectedValuesChange?.(newSelectedValues); + return; + } + + optionalProps.onSelectedValueChange?.(value); + }); + + const [viewportValuesList, getMore] = useInfiniteScroll( + onLoadMore, sortedItemValuesList, Boolean(filterValue), + ); + + const handleFilterChange = useLastCallback((e: React.ChangeEvent) => { + const { value } = e.currentTarget; + onFilterChange?.(value); + }); + + const renderItem = useCallback((value: string) => { + const item = items.find((itemOption) => itemOption.value === value); + if (!item) return undefined; + + const { label, subLabel, isLoading: isItemLoading } = item; + const isAlwaysUnselected = lockedUnselectedValuesSet.has(value); + const isAlwaysSelected = lockedSelectedValuesSet.has(value); + const isLocked = isAlwaysUnselected || isAlwaysSelected; + const isChecked = selectedValues.includes(value); + + function getInputElement() { + if (isLocked) return ; + if (itemInputType === 'radio') { + return ; + } + if (itemInputType === 'checkbox') { + return ; + } + return undefined; + } + + return ( + handleItemClick(value)} + // eslint-disable-next-line react/jsx-no-bind + onDisabledClick={onDisabledClick && (() => onDisabledClick(value, isAlwaysSelected))} + /> + ); + }, [ + items, lockedUnselectedValuesSet, lockedSelectedValuesSet, selectedValues, isViewOnly, onDisabledClick, + itemInputType, itemClassName, + ]); + + return ( +
+ {isSearchable && ( +
+ +
+ )} + + {viewportValuesList?.length ? ( + + {viewportValuesList.map((value) => renderItem(value))} + + ) : !isLoading && viewportValuesList && !viewportValuesList.length ? ( +

{notFoundText || lang('SearchEmptyViewTitle')}

+ ) : ( + + )} +
+ ); +}; + +export default memo(ItemPicker); diff --git a/src/components/common/pickers/PeerPicker.tsx b/src/components/common/pickers/PeerPicker.tsx new file mode 100644 index 000000000..3edfe5dbf --- /dev/null +++ b/src/components/common/pickers/PeerPicker.tsx @@ -0,0 +1,391 @@ +import React, { + memo, useCallback, useEffect, useMemo, useRef, +} from '../../../lib/teact/teact'; +import { getGlobal } from '../../../global'; + +import type { CustomPeerType, UniqueCustomPeer } from '../../../types'; + +import { DEBUG } from '../../../config'; +import { requestMeasure } from '../../../lib/fasterdom/fasterdom'; +import { getGroupStatus, getUserStatus, isUserOnline } from '../../../global/helpers'; +import { getPeerTypeKey, isApiPeerChat } from '../../../global/helpers/peers'; +import { selectPeer, selectUserStatus } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { buildCollectionByKey } from '../../../util/iteratees'; +import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; + +import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import Checkbox from '../../ui/Checkbox'; +import InfiniteScroll from '../../ui/InfiniteScroll'; +import InputText from '../../ui/InputText'; +import Loading from '../../ui/Loading'; +import Radio from '../../ui/Radio'; +import Avatar from '../Avatar'; +import FullNameTitle from '../FullNameTitle'; +import Icon from '../icons/Icon'; +import PickerItem from './PickerItem'; +import PickerSelectedItem from './PickerSelectedItem'; + +import styles from './PickerStyles.module.scss'; + +type SingleModeProps = { + allowMultiple?: false; + itemInputType?: 'radio'; + selectedId?: string; + selectedIds?: never; // Help TS to throw an error if this is passed + selectedCategory?: CustomPeerType; + selectedCategories?: never; + onSelectedCategoryChange?: (category: CustomPeerType) => void; + onSelectedIdChange?: (id: string) => void; +}; + +type MultipleModeProps = { + allowMultiple: true; + itemInputType: 'checkbox'; + selectedId?: never; + selectedIds: string[]; + lockedSelectedIds?: string[]; + lockedUnselectedIds?: string[]; + selectedCategory?: never; + selectedCategories?: CustomPeerType[]; + onSelectedCategoriesChange?: (categories: CustomPeerType[]) => void; + onSelectedIdsChange?: (Ids: string[]) => void; +}; + +type OwnProps = { + className?: string; + categories?: UniqueCustomPeer[]; + itemIds: string[]; + lockedUnselectedSubtitle?: string; + filterValue?: string; + filterPlaceholder?: string; + categoryPlaceholderKey?: string; + notFoundText?: string; + searchInputId?: string; + itemClassName?: string; + isLoading?: boolean; + noScrollRestore?: boolean; + isSearchable?: boolean; + forceShowSelf?: boolean; + isViewOnly?: boolean; + withStatus?: boolean; + withPeerTypes?: boolean; + withDefaultPadding?: boolean; + onFilterChange?: (value: string) => void; + onDisabledClick?: (id: string, isSelected: boolean) => void; + onLoadMore?: () => void; +} & (SingleModeProps | MultipleModeProps); + +// Focus slows down animation, also it breaks transition layout in Chrome +const FOCUS_DELAY_MS = 500; + +const MAX_FULL_ITEMS = 10; +const ALWAYS_FULL_ITEMS_COUNT = 5; + +const ITEM_CLASS_NAME = 'PeerPickerItem'; + +const PeerPicker = ({ + className, + categories, + itemIds, + categoryPlaceholderKey, + filterValue, + filterPlaceholder, + notFoundText, + searchInputId, + itemClassName, + isLoading, + noScrollRestore, + isSearchable, + lockedUnselectedSubtitle, + forceShowSelf, + isViewOnly, + itemInputType, + withStatus, + withPeerTypes, + withDefaultPadding, + onFilterChange, + onDisabledClick, + onLoadMore, + ...optionalProps +}: OwnProps) => { + const lang = useOldLang(); + + const allowMultiple = optionalProps.allowMultiple; + const lockedSelectedIds = allowMultiple ? optionalProps.lockedSelectedIds : undefined; + const lockedUnselectedIds = allowMultiple ? optionalProps.lockedUnselectedIds : undefined; + const selectedCategories = useMemo(() => { + if (allowMultiple) { + return optionalProps.selectedCategories; + } + + return optionalProps.selectedCategory ? [optionalProps.selectedCategory] : MEMO_EMPTY_ARRAY; + }, [allowMultiple, optionalProps.selectedCategory, optionalProps.selectedCategories]); + + const selectedIds = useMemo(() => { + if (allowMultiple) { + return optionalProps.selectedIds; + } + + return optionalProps.selectedId ? [optionalProps.selectedId] : MEMO_EMPTY_ARRAY; + }, [allowMultiple, optionalProps.selectedId, optionalProps.selectedIds]); + + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(null); + const shouldMinimize = selectedIds.length > MAX_FULL_ITEMS; + + useEffect(() => { + if (!isSearchable) return; + setTimeout(() => { + requestMeasure(() => { + inputRef.current!.focus(); + }); + }, FOCUS_DELAY_MS); + }, [isSearchable]); + + const lockedSelectedIdsSet = useMemo(() => new Set(lockedSelectedIds), [lockedSelectedIds]); + const lockedUnselectedIdsSet = useMemo(() => new Set(lockedUnselectedIds), [lockedUnselectedIds]); + + const unlockedSelectedIds = useMemo(() => { + return selectedIds.filter((id) => !lockedSelectedIdsSet.has(id)); + }, [lockedSelectedIdsSet, selectedIds]); + + const categoriesByType = useMemo(() => { + if (!categories) return {}; + return buildCollectionByKey(categories, 'type'); + }, [categories]); + + const sortedItemIds = useMemo(() => { + if (filterValue) { + return itemIds; + } + + const lockedSelectedBucket: string[] = []; + const unlockedBucket: string[] = []; + const lockedUnselectableBucket: string[] = []; + + itemIds.forEach((id) => { + if (lockedSelectedIdsSet.has(id)) { + lockedSelectedBucket.push(id); + } else if (lockedUnselectedIdsSet.has(id)) { + lockedUnselectableBucket.push(id); + } else { + unlockedBucket.push(id); + } + }); + + return lockedSelectedBucket.concat(unlockedBucket, lockedUnselectableBucket); + }, [filterValue, itemIds, lockedSelectedIdsSet, lockedUnselectedIdsSet]); + + const handleItemClick = useLastCallback((id: string) => { + if (lockedSelectedIdsSet.has(id)) { + onDisabledClick?.(id, true); + return; + } + + if (lockedUnselectedIdsSet.has(id)) { + onDisabledClick?.(id, false); + return; + } + + if (allowMultiple && categoriesByType[id]) { + const categoryType = categoriesByType[id].type; + const newSelectedCategories = selectedCategories?.slice() || []; + if (newSelectedCategories.includes(categoryType)) { + newSelectedCategories.splice(newSelectedCategories.indexOf(categoryType), 1); + } else { + newSelectedCategories.push(categoryType); + } + optionalProps.onSelectedCategoriesChange?.(newSelectedCategories); + + return; + } + + if (allowMultiple) { + const newSelectedIds = selectedIds.slice(); + if (newSelectedIds.includes(id)) { + newSelectedIds.splice(newSelectedIds.indexOf(id), 1); + } else { + newSelectedIds.push(id); + } + optionalProps.onSelectedIdsChange?.(newSelectedIds); + + return; + } + + if (categoriesByType[id]) { + optionalProps.onSelectedCategoryChange?.(categoriesByType[id].type); + return; + } + + optionalProps.onSelectedIdChange?.(id); + }); + + const handleFilterChange = useLastCallback((e: React.ChangeEvent) => { + const { value } = e.currentTarget; + onFilterChange?.(value); + }); + + const [viewportIds, getMore] = useInfiniteScroll( + onLoadMore, sortedItemIds, Boolean(filterValue), + ); + + const renderItem = useCallback((id: string, isCategory?: boolean) => { + const global = getGlobal(); + const category = isCategory ? categoriesByType[id] : undefined; + const peer = !isCategory ? selectPeer(global, id) : undefined; + + const peerOrCategory = peer || category; + if (!peerOrCategory) { + if (DEBUG) return
No peer or category with ID {id}
; + return undefined; + } + + const isSelf = peer && !isApiPeerChat(peer) ? (peer.isSelf && !forceShowSelf) : undefined; + + const isAlwaysUnselected = lockedUnselectedIdsSet.has(id); + const isAlwaysSelected = lockedSelectedIdsSet.has(id); + const isLocked = isAlwaysUnselected || isAlwaysSelected; + const isChecked = category ? selectedCategories?.includes(category.type) : selectedIds.includes(id); + + function getInputElement() { + if (isLocked) return ; + if (itemInputType === 'radio') { + return ; + } + if (itemInputType === 'checkbox') { + return ; + } + return undefined; + } + + function getSubtitle() { + if (isAlwaysUnselected) return [lockedUnselectedSubtitle]; + if (withStatus && peer) { + if (isApiPeerChat(peer)) { + return [getGroupStatus(lang, peer)]; + } + + const userStatus = selectUserStatus(global, peer.id); + return [ + getUserStatus(lang, peer, userStatus), + buildClassName(isUserOnline(peer, userStatus, true) && styles.onlineStatus), + ]; + } + if (withPeerTypes && peer) { + const langKey = getPeerTypeKey(peer); + return langKey && [lang(langKey)]; + } + return undefined; + } + + const [subtitle, subtitleClassName] = getSubtitle() || []; + + return ( + } + avatarElement={( + + )} + subtitle={subtitle} + subtitleClassName={subtitleClassName} + disabled={isLocked} + inactive={isViewOnly} + ripple + inputElement={getInputElement()} + inputPosition="end" + // eslint-disable-next-line react/jsx-no-bind + onClick={() => handleItemClick(id)} + // eslint-disable-next-line react/jsx-no-bind + onDisabledClick={onDisabledClick && (() => onDisabledClick(id, isAlwaysSelected))} + /> + ); + }, [ + categoriesByType, forceShowSelf, isViewOnly, itemClassName, itemInputType, lang, lockedSelectedIdsSet, + lockedUnselectedIdsSet, lockedUnselectedSubtitle, onDisabledClick, selectedCategories, selectedIds, + withPeerTypes, withStatus, + ]); + + const beforeChildren = useMemo(() => { + if (!categories?.length) return undefined; + return ( +
+ {categoryPlaceholderKey &&
{lang(categoryPlaceholderKey)}
} + {categories?.map((category) => renderItem(category.type, true))} +
{lang('FilterChats')}
+
+ ); + }, [categories, categoryPlaceholderKey, lang, renderItem]); + + return ( +
+ {isSearchable && ( +
+ {selectedCategories?.map((category) => ( + + ))} + {lockedSelectedIds?.map((id, i) => ( + + ))} + {unlockedSelectedIds.map((id, i) => ( + + ))} + +
+ )} + + {viewportIds?.length ? ( + + {viewportIds.map((id) => renderItem(id))} + + ) : !isLoading && viewportIds && !viewportIds.length ? ( +

{notFoundText || 'Sorry, nothing found.'}

+ ) : ( + + )} +
+ ); +}; + +export default memo(PeerPicker); diff --git a/src/components/common/pickers/PickerItem.module.scss b/src/components/common/pickers/PickerItem.module.scss new file mode 100644 index 000000000..9bd38364d --- /dev/null +++ b/src/components/common/pickers/PickerItem.module.scss @@ -0,0 +1,101 @@ +.root { + display: grid; + grid-template-rows: 1fr 1fr; + grid-template-columns: min-content min-content 1fr min-content; + align-items: center; + + position: relative; + padding: 0.25rem; + min-height: 2.5rem; + + border-radius: var(--border-radius-default); + overflow: hidden; + + background-color: var(--background-color); + color: var(--color-text); + line-height: 1.25; + + transition-property: background-color, opacity; + transition-duration: 150ms; + + @media (max-width: 600px) { + border-radius: 0; + } +} + +.clickable { + cursor: var(--custom-cursor, pointer); + + @media (hover: hover) { + &:hover, + &:focus-visible { + background-color: var(--color-item-hover); + } + } +} + +.separator { + grid-row: 2; + grid-column: 2 / 4; + /* stylelint-disable-next-line plugin/whole-pixel */ + height: 0.5px; + background-color: var(--color-dividers); + align-self: end; + margin-bottom: -0.1875rem; +} + +.disabled { + cursor: unset; + opacity: 0.5; +} + +.title { + grid-row: 1 / 3; + grid-column: 3; +} + +.subtitle { + font-size: 0.875rem; + color: var(--color-text-secondary); + grid-row: 2; + grid-column: 3; + align-self: start; +} + +.title, .subtitle { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.withAvatar, .multiline { + height: 3.5rem; // Force chat item height +} + +.multiline { + .title { + grid-row: 1; + } +} + +.input { + padding-inline: 0.75rem; +} + +.startInput { + grid-row: 1 / 3; + grid-column: 1; + margin-inline-end: 1.25rem; +} + +.endInput { + grid-row: 1 / 3; + grid-column: 4; + margin-inline-start: 1.25rem; +} + +.avatarElement { + grid-row: 1 / 3; + grid-column: 2; + margin-inline-end: 1.25rem; +} diff --git a/src/components/common/pickers/PickerItem.tsx b/src/components/common/pickers/PickerItem.tsx new file mode 100644 index 000000000..4aa3c084a --- /dev/null +++ b/src/components/common/pickers/PickerItem.tsx @@ -0,0 +1,104 @@ +import React, { type TeactNode } from '../../../lib/teact/teact'; + +import buildClassName from '../../../util/buildClassName'; +import { IS_IOS } from '../../../util/windowEnvironment'; + +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import RippleEffect from '../../ui/RippleEffect'; + +import styles from './PickerItem.module.scss'; + +type OwnProps = { + title: TeactNode; + subtitle?: TeactNode; + avatarElement?: TeactNode; + inputElement?: TeactNode; + inputPosition?: 'start' | 'end'; + disabled?: boolean; + inactive?: boolean; + ripple?: boolean; + className?: string; + titleClassName?: string; + subtitleClassName?: string; + onClick?: NoneToVoidFunction; + onDisabledClick?: NoneToVoidFunction; +}; + +const PickerItem = ({ + title, + subtitle, + avatarElement, + inputElement, + inputPosition = 'start', + disabled, + inactive, + ripple, + className, + titleClassName, + subtitleClassName, + onClick, + onDisabledClick, +}: OwnProps) => { + const lang = useOldLang(); + + const isClickable = !inactive && !disabled; + const handleClick = useLastCallback(() => { + if (inactive) return; + + if (disabled) { + onDisabledClick?.(); + return; + } + + onClick?.(); + }); + + return ( +
+ {!disabled && !inactive && ripple && } + {inputElement && ( +
+ {inputElement} +
+ )} + {avatarElement && ( +
+ {avatarElement} +
+ )} +
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} + {!inputElement && IS_IOS && ( +
+ )} +
+ ); +}; + +export default PickerItem; diff --git a/src/components/common/pickers/PickerModal.module.scss b/src/components/common/pickers/PickerModal.module.scss new file mode 100644 index 000000000..b4784aae3 --- /dev/null +++ b/src/components/common/pickers/PickerModal.module.scss @@ -0,0 +1,26 @@ +.content { + display: flex; + flex-direction: column; +} + +.fixedHeight .content { + height: 90vh; +} + +.withSearch { + .content { + padding: 0; + } + + .header { + margin-bottom: -0.75rem; // Pull search closer to the header + } +} + +.header { + padding: 0.25rem !important; +} + +.buttonWrapper { + padding: 0.25rem; +} diff --git a/src/components/common/pickers/PickerModal.tsx b/src/components/common/pickers/PickerModal.tsx new file mode 100644 index 000000000..61ad6de0b --- /dev/null +++ b/src/components/common/pickers/PickerModal.tsx @@ -0,0 +1,58 @@ +import React, { memo } from '../../../lib/teact/teact'; + +import buildClassName from '../../../util/buildClassName'; + +import useOldLang from '../../../hooks/useOldLang'; + +import Button from '../../ui/Button'; +import Modal, { type OwnProps as ModalProps } from '../../ui/Modal'; + +import styles from './PickerModal.module.scss'; + +type OwnProps = { + confirmButtonText?: string; + isConfirmDisabled?: boolean; + shouldAdaptToSearch?: boolean; + withFixedHeight?: boolean; + onConfirm?: NoneToVoidFunction; +} & ModalProps; + +const PickerModal = ({ + confirmButtonText, + isConfirmDisabled, + shouldAdaptToSearch, + withFixedHeight, + onConfirm, + ...modalProps +}: OwnProps) => { + const lang = useOldLang(); + + return ( + + {modalProps.children} +
+ +
+
+ ); +}; + +export default memo(PickerModal); diff --git a/src/components/common/PickerSelectedItem.scss b/src/components/common/pickers/PickerSelectedItem.scss similarity index 99% rename from src/components/common/PickerSelectedItem.scss rename to src/components/common/pickers/PickerSelectedItem.scss index 317ae762e..87d185654 100644 --- a/src/components/common/PickerSelectedItem.scss +++ b/src/components/common/pickers/PickerSelectedItem.scss @@ -4,6 +4,7 @@ background: var(--color-chat-hover); height: 2rem; min-width: 2rem; + margin-bottom: 0.5rem; margin-left: 0.25rem; margin-right: 0.25rem; padding-right: 1rem; diff --git a/src/components/common/PickerSelectedItem.tsx b/src/components/common/pickers/PickerSelectedItem.tsx similarity index 78% rename from src/components/common/PickerSelectedItem.tsx rename to src/components/common/pickers/PickerSelectedItem.tsx index b800e21db..2e318730f 100644 --- a/src/components/common/PickerSelectedItem.tsx +++ b/src/components/common/pickers/PickerSelectedItem.tsx @@ -1,21 +1,21 @@ -import type { TeactNode } from '../../lib/teact/teact'; -import React, { memo } from '../../lib/teact/teact'; -import { withGlobal } from '../../global'; +import type { TeactNode } from '../../../lib/teact/teact'; +import React, { memo } from '../../../lib/teact/teact'; +import { withGlobal } from '../../../global'; -import type { ApiChat, ApiUser } from '../../api/types'; -import type { CustomPeer } from '../../types'; -import type { IconName } from '../../types/icons'; +import type { ApiChat, ApiUser } from '../../../api/types'; +import type { CustomPeer } from '../../../types'; +import type { IconName } from '../../../types/icons'; -import { getChatTitle, getUserFirstOrLastName } from '../../global/helpers'; -import { selectChat, selectUser } from '../../global/selectors'; -import buildClassName from '../../util/buildClassName'; -import { getPeerColorClass } from './helpers/peerColor'; -import renderText from './helpers/renderText'; +import { getChatTitle, getUserFirstOrLastName } from '../../../global/helpers'; +import { selectChat, selectUser } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import { getPeerColorClass } from '../helpers/peerColor'; +import renderText from '../helpers/renderText'; -import useOldLang from '../../hooks/useOldLang'; +import useOldLang from '../../../hooks/useOldLang'; -import Avatar from './Avatar'; -import Icon from './icons/Icon'; +import Avatar from '../Avatar'; +import Icon from '../icons/Icon'; import './PickerSelectedItem.scss'; diff --git a/src/components/common/pickers/PickerStyles.module.scss b/src/components/common/pickers/PickerStyles.module.scss new file mode 100644 index 000000000..bbe216df9 --- /dev/null +++ b/src/components/common/pickers/PickerStyles.module.scss @@ -0,0 +1,75 @@ +@use "../../../styles/mixins"; + +.container { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--color-borders); + display: flex; + flex-flow: row wrap; + flex-shrink: 0; + + overflow-y: auto; + max-height: 20rem; + + :global(.input-group) { + margin-bottom: 0; + flex-grow: 1; + } + + :global(.form-control) { + height: 2rem; + border: none; + border-radius: 0; + padding: 0; + box-shadow: none !important; + } +} + +.pickerCategoryTitle { + color: var(--color-text-secondary); + padding-inline: 0.5rem; + font-weight: 500; + + &:not(:first-child) { + border-top: 1px solid var(--color-borders); + padding-top: 0.75rem; + margin-top: 0.375rem; + } +} + +.pickerList { + flex-grow: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.padded { + padding: 0.5rem; + scrollbar-gutter: stable; + + @include mixins.adapt-padding-to-scrollbar(0.5rem); + + @media (max-width: 600px) { + padding-inline: 0; + } +} + +.noResults { + height: 100%; + margin: 0; + padding: 1rem 1rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); +} + +.onlineStatus { + color: var(--color-primary); +} diff --git a/src/components/left/ChatFolderModal.tsx b/src/components/left/ChatFolderModal.tsx index 05f3573eb..0e36c31b4 100644 --- a/src/components/left/ChatFolderModal.tsx +++ b/src/components/left/ChatFolderModal.tsx @@ -87,7 +87,6 @@ const ChatFolderModal: FC = ({ options={folders} selected={selectedFolderIds} onChange={setSelectedFolderIds} - round />
- = ({ searchInputId="new-group-picker-search" isLoading={isSearching} isSearchable + allowMultiple + withStatus + itemInputType="checkbox" + withDefaultPadding onSelectedIdsChange={onSelectedMemberIdsChange} onFilterChange={handleFilterChange} /> diff --git a/src/components/left/search/ChatResults.tsx b/src/components/left/search/ChatResults.tsx index ec78d6c74..aaca9f57a 100644 --- a/src/components/left/search/ChatResults.tsx +++ b/src/components/left/search/ChatResults.tsx @@ -28,7 +28,7 @@ import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; import useOldLang from '../../../hooks/useOldLang'; import NothingFound from '../../common/NothingFound'; -import PickerSelectedItem from '../../common/PickerSelectedItem'; +import PickerSelectedItem from '../../common/pickers/PickerSelectedItem'; import InfiniteScroll from '../../ui/InfiniteScroll'; import Link from '../../ui/Link'; import ChatMessage from './ChatMessage'; diff --git a/src/components/left/search/LeftSearch.scss b/src/components/left/search/LeftSearch.scss index 989f5c0eb..fd3b88a97 100644 --- a/src/components/left/search/LeftSearch.scss +++ b/src/components/left/search/LeftSearch.scss @@ -184,7 +184,7 @@ } .search-section { - padding: 0 0.125rem 0.5rem 0.5rem; + padding: 0 0.5rem 0.5rem 0.5rem; .section-heading { color: var(--color-text-secondary); diff --git a/src/components/left/settings/BlockUserModal.tsx b/src/components/left/settings/BlockUserModal.tsx index adf2bfde8..e734db7c3 100644 --- a/src/components/left/settings/BlockUserModal.tsx +++ b/src/components/left/settings/BlockUserModal.tsx @@ -13,7 +13,7 @@ import { unique } from '../../../util/iteratees'; import useOldLang from '../../../hooks/useOldLang'; -import ChatOrUserPicker from '../../common/ChatOrUserPicker'; +import ChatOrUserPicker from '../../common/pickers/ChatOrUserPicker'; export type OwnProps = { isOpen: boolean; diff --git a/src/components/left/settings/Settings.scss b/src/components/left/settings/Settings.scss index 2babdff62..c08f9decd 100644 --- a/src/components/left/settings/Settings.scss +++ b/src/components/left/settings/Settings.scss @@ -118,12 +118,21 @@ margin-bottom: 0.25rem; } } + + .ListItem.narrow { + margin-bottom: 0; + + .ListItem-button { + padding: 1rem; + } + } } .settings-item-simple, .settings-item { - padding: 1.5rem 1.5rem 1rem; + padding: 1.5rem 0.5rem 1rem; + @include mixins.adapt-padding-to-scrollbar(0.5rem); @include mixins.side-panel-section; } @@ -137,7 +146,8 @@ &-header { font-size: 1rem; color: var(--color-text-secondary); - margin-bottom: 2rem; + margin-bottom: 1.25rem; + padding-inline-start: 1rem; position: relative; &[dir="rtl"] { @@ -154,6 +164,7 @@ color: var(--color-text-secondary); margin-top: -0.5rem; margin-bottom: 1.5rem; + padding-inline-start: 1rem; .settings-content.two-fa &, .settings-content.password-form &, @@ -189,9 +200,7 @@ } .ListItem { - margin: 0 -1rem 1rem; - - &:last-child { + &.narrow { margin-bottom: 0; } @@ -302,11 +311,7 @@ .RangeSlider { margin-bottom: 1.0625rem; - } - - .Checkbox, - .radio-group { - margin: 0 -1rem 0.5rem; + padding-inline: 1rem; } .radio-group { @@ -324,6 +329,10 @@ margin-top: 2rem; } + > .Checkbox, > .Radio { + padding-inline-start: 4.25rem; + } + &__current-value { margin-inline-start: auto; padding-inline-start: 0.5rem; @@ -332,6 +341,14 @@ } } +.settings-picker { + padding-block: 0; +} + +.settings-picker-title { + margin-inline-start: var(--picker-title-shift); +} + .settings-fab-wrapper { height: calc(100% - var(--header-height)); position: relative; @@ -368,8 +385,6 @@ } .settings-dropdown-section { - margin: 0 -0.75rem 1rem -1rem; - .DropdownList { position: relative; @@ -381,7 +396,7 @@ .SettingsDefaultReaction { .current-default-reaction { - margin-inline-end: 2rem; + margin-inline-end: 1.1875rem; } } @@ -393,6 +408,10 @@ margin: inherit; } +.settings-item-picker { + padding: 1.5rem 0.5rem 0.5rem; +} + .block-user-button { z-index: var(--z-chat-float-button); } diff --git a/src/components/left/settings/SettingsActiveSessions.scss b/src/components/left/settings/SettingsActiveSessions.scss index a37353fcd..fb91a5ad0 100644 --- a/src/components/left/settings/SettingsActiveSessions.scss +++ b/src/components/left/settings/SettingsActiveSessions.scss @@ -14,7 +14,11 @@ $icons: "android", "apple", "brave", "chrome", "edge", "firefox", "linux", "oper background-repeat: no-repeat; background-size: 2rem; flex: 0 0 2rem; - margin-inline-end: 1.5rem !important; + margin-inline-end: 1.25rem !important; + } + + .icon-stop { + margin-inline: 0.25rem 1.5rem !important; } @each $icon in $icons { diff --git a/src/components/left/settings/SettingsActiveSessions.tsx b/src/components/left/settings/SettingsActiveSessions.tsx index 37afe9f9c..e4f334b88 100644 --- a/src/components/left/settings/SettingsActiveSessions.tsx +++ b/src/components/left/settings/SettingsActiveSessions.tsx @@ -145,7 +145,7 @@ const SettingsActiveSessions: FC = ({ function renderCurrentSession(session: ApiSession) { return (
-

+

{lang('AuthSessions.CurrentSession')}

@@ -177,7 +177,7 @@ const SettingsActiveSessions: FC = ({ function renderOtherSessions(sessionHashes: string[]) { return (
-

+

{lang('OtherSessions')}

@@ -189,7 +189,7 @@ const SettingsActiveSessions: FC = ({ function renderAutoTerminate() { return (
-

+

{lang('TerminateOldSessionHeader')}

diff --git a/src/components/left/settings/SettingsActiveWebsites.tsx b/src/components/left/settings/SettingsActiveWebsites.tsx index 4186c215b..f4e7ec90c 100644 --- a/src/components/left/settings/SettingsActiveWebsites.tsx +++ b/src/components/left/settings/SettingsActiveWebsites.tsx @@ -81,7 +81,7 @@ const SettingsActiveWebsites: FC = ({ function renderSessions(sessionHashes: string[]) { return (
-

+

{lang('WebSessionsTitle')}

diff --git a/src/components/left/settings/SettingsDoNotTranslate.module.scss b/src/components/left/settings/SettingsDoNotTranslate.module.scss index de02d24c4..72f3984ce 100644 --- a/src/components/left/settings/SettingsDoNotTranslate.module.scss +++ b/src/components/left/settings/SettingsDoNotTranslate.module.scss @@ -6,14 +6,4 @@ .item { overflow: hidden; min-height: 25rem; - padding-bottom: 0 !important; -} - -.checkbox { - margin-right: 0 !important; -} - -.languages { - overflow-y: auto; - margin-bottom: 0 !important; } diff --git a/src/components/left/settings/SettingsDoNotTranslate.tsx b/src/components/left/settings/SettingsDoNotTranslate.tsx index 5fd4a0dd5..d15df69bd 100644 --- a/src/components/left/settings/SettingsDoNotTranslate.tsx +++ b/src/components/left/settings/SettingsDoNotTranslate.tsx @@ -1,22 +1,21 @@ import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useMemo, useState, + memo, useMemo, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ISettings } from '../../../types'; -import type { IRadioOption } from '../../ui/CheckboxGroup'; import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../../config'; import buildClassName from '../../../util/buildClassName'; -import { partition, unique } from '../../../util/iteratees'; +import { partition } from '../../../util/iteratees'; import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useHistoryBack from '../../../hooks/useHistoryBack'; +import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; -import Checkbox from '../../ui/Checkbox'; -import InputText from '../../ui/InputText'; +import ItemPicker, { type ItemPickerOption } from '../../common/pickers/ItemPicker'; import styles from './SettingsDoNotTranslate.module.scss'; @@ -62,11 +61,11 @@ const SettingsDoNotTranslate: FC = ({ const lang = useOldLang(); const language = lang.code || 'en'; - const [displayedOptions, setDisplayedOptions] = useState([]); - const [search, setSearch] = useState(''); + const [displayedOptions, setDisplayedOptions] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); - const options: IRadioOption[] = useMemo(() => { - return SUPPORTED_LANGUAGES.map((langCode: string) => { + const displayedOptionList: ItemPickerOption[] = useMemo(() => { + const options = SUPPORTED_LANGUAGES.map((langCode: string) => { const translatedNames = new Intl.DisplayNames([language], { type: 'language' }); const translatedName = translatedNames.of(langCode)!; @@ -78,58 +77,33 @@ const SettingsDoNotTranslate: FC = ({ translatedName, originalName, }; - }).map(({ langCode, translatedName, originalName }) => ({ + }).filter(Boolean).map(({ langCode, translatedName, originalName }) => ({ label: translatedName, subLabel: originalName, value: langCode, })); - }, [language]); - useEffect(() => { - if (!isActive) setSearch(''); - }, [isActive]); - - useEffectWithPrevDeps(([prevIsActive]) => { - if (prevIsActive === isActive) return; - if (isActive && displayedOptions.length) return; - - const current = options.find((option) => option.value === language); - const otherLanguages = options.filter((option) => option.value !== language); - - const [selected, unselected] = partition(otherLanguages, (option) => doNotTranslate.includes(option.value)); - - setDisplayedOptions([current!, ...selected, ...unselected]); - }, [isActive, doNotTranslate, displayedOptions.length, language, options]); - - const handleChange = useCallback((event: React.ChangeEvent) => { - const { value, checked } = event.currentTarget; - let newDoNotTranslate: string[]; - if (checked) { - newDoNotTranslate = unique([...doNotTranslate, value]); - } else { - newDoNotTranslate = doNotTranslate.filter((v) => v !== value); + if (!searchQuery.trim()) { + const currentLanguageOption = options.find((option) => option.value === language); + const otherOptionList = options.filter((option) => option.value !== language); + return currentLanguageOption ? [currentLanguageOption, ...otherOptionList] : options; } + return options?.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); + }, [language, searchQuery]); + + useEffectWithPrevDeps(([prevIsActive, prevLanguage]) => { + if (prevIsActive === isActive && prevLanguage?.find((option) => option === language)) return; + const [selected] = partition(displayedOptionList, (option) => doNotTranslate.includes(option.value)); + setDisplayedOptions([...selected.map((option) => option.value)]); + }, [isActive, doNotTranslate, displayedOptions.length, language, displayedOptionList]); + + const handleChange = useLastCallback((newSelectedIds: string[]) => { + setDisplayedOptions(newSelectedIds); setSettingOption({ - doNotTranslate: newDoNotTranslate, + doNotTranslate: newSelectedIds, }); - }, [doNotTranslate, setSettingOption]); - - const handleSearch = useCallback((e: React.ChangeEvent) => { - setSearch(e.target.value); - }, []); - - const filteredDisplayedOptions = useMemo(() => { - if (!search.trim()) { - return displayedOptions; - } - - return displayedOptions.filter((option) => ( - option.label.toString().toLowerCase().includes(search.toLowerCase()) - || option.subLabel?.toLowerCase().includes(search.toLowerCase()) - || option.value.toLowerCase().includes(search.toLowerCase()) - )); - }, [displayedOptions, search]); + }); useHistoryBack({ isActive, @@ -137,28 +111,21 @@ const SettingsDoNotTranslate: FC = ({ }); return ( -
-
- +
+ -
- {filteredDisplayedOptions.map((option) => ( - - ))} -
); diff --git a/src/components/left/settings/SettingsGeneral.tsx b/src/components/left/settings/SettingsGeneral.tsx index 92a370525..2ea1071ac 100644 --- a/src/components/left/settings/SettingsGeneral.tsx +++ b/src/components/left/settings/SettingsGeneral.tsx @@ -146,6 +146,7 @@ const SettingsGeneral: FC = ({ onScreenSelect(SettingsScreens.GeneralChatBackground)} > diff --git a/src/components/left/settings/SettingsLanguage.tsx b/src/components/left/settings/SettingsLanguage.tsx index 0cf97ed22..caea44032 100644 --- a/src/components/left/settings/SettingsLanguage.tsx +++ b/src/components/left/settings/SettingsLanguage.tsx @@ -4,7 +4,6 @@ import React, { } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { ApiLanguage } from '../../../api/types'; import type { ISettings, LangCode } from '../../../types'; import { SettingsScreens } from '../../../types'; @@ -17,10 +16,10 @@ import useHistoryBack from '../../../hooks/useHistoryBack'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; +import ItemPicker, { type ItemPickerOption } from '../../common/pickers/ItemPicker'; import Checkbox from '../../ui/Checkbox'; import ListItem from '../../ui/ListItem'; import Loading from '../../ui/Loading'; -import RadioGroup from '../../ui/RadioGroup'; type OwnProps = { isActive?: boolean; @@ -77,8 +76,19 @@ const SettingsLanguage: FC = ({ }); const options = useMemo(() => { - return languages ? buildOptions(languages) : undefined; - }, [languages]); + if (!languages) return undefined; + const currentLangCode = (window.navigator.language || 'en').toLowerCase(); + const shortLangCode = currentLangCode.substr(0, 2); + + return languages.map(({ langCode, nativeName, name }) => ({ + value: langCode, + label: nativeName, + subLabel: name, + isLoading: langCode === selectedLanguage && isLoading, + } satisfies ItemPickerOption)).sort((a) => { + return currentLangCode && (a.value === currentLangCode || a.value === shortLangCode) ? -1 : 0; + }); + }, [isLoading, languages, selectedLanguage]); const handleShouldTranslateChange = useLastCallback((newValue: boolean) => { setSettingOption({ canTranslate: newValue }); @@ -128,7 +138,6 @@ const SettingsLanguage: FC = ({ onCheck={handleShouldTranslateChange} /> = ({ /> {(canTranslate || canTranslateChatsEnabled) && ( {lang('DoNotTranslate')} @@ -149,15 +159,17 @@ const SettingsLanguage: FC = ({

)} -
-

{lang('Localization.InterfaceLanguage')}

+
+

+ {lang('Localization.InterfaceLanguage')} +

{options ? ( - ) : ( @@ -167,19 +179,6 @@ const SettingsLanguage: FC = ({ ); }; -function buildOptions(languages: ApiLanguage[]) { - const currentLangCode = (window.navigator.language || 'en').toLowerCase(); - const shortLangCode = currentLangCode.substr(0, 2); - - return languages.map(({ langCode, nativeName, name }) => ({ - value: langCode, - label: nativeName, - subLabel: name, - })).sort((a) => { - return currentLangCode && (a.value === currentLangCode || a.value === shortLangCode) ? -1 : 0; - }); -} - export default memo(withGlobal( (global): StateProps => { const { diff --git a/src/components/left/settings/SettingsMain.tsx b/src/components/left/settings/SettingsMain.tsx index b545d1396..9bde85136 100644 --- a/src/components/left/settings/SettingsMain.tsx +++ b/src/components/left/settings/SettingsMain.tsx @@ -95,6 +95,7 @@ const SettingsMain: FC = ({ )} onScreenSelect(SettingsScreens.General)} > @@ -102,6 +103,7 @@ const SettingsMain: FC = ({ onScreenSelect(SettingsScreens.Performance)} > @@ -109,6 +111,7 @@ const SettingsMain: FC = ({ onScreenSelect(SettingsScreens.Notifications)} > @@ -116,6 +119,7 @@ const SettingsMain: FC = ({ onScreenSelect(SettingsScreens.DataStorage)} > @@ -123,6 +127,7 @@ const SettingsMain: FC = ({ onScreenSelect(SettingsScreens.Privacy)} > @@ -130,6 +135,7 @@ const SettingsMain: FC = ({ onScreenSelect(SettingsScreens.Folders)} > @@ -137,6 +143,7 @@ const SettingsMain: FC = ({ onScreenSelect(SettingsScreens.ActiveSessions)} > @@ -145,6 +152,7 @@ const SettingsMain: FC = ({ onScreenSelect(SettingsScreens.Language)} > @@ -153,6 +161,7 @@ const SettingsMain: FC = ({ onScreenSelect(SettingsScreens.Stickers)} > @@ -164,6 +173,7 @@ const SettingsMain: FC = ({ } className="settings-main-menu-star" + narrow // eslint-disable-next-line react/jsx-no-bind onClick={() => openPremiumModal()} > @@ -174,6 +184,7 @@ const SettingsMain: FC = ({ } className="settings-main-menu-star" + narrow // eslint-disable-next-line react/jsx-no-bind onClick={() => openStarsBalanceModal({})} > @@ -187,6 +198,7 @@ const SettingsMain: FC = ({ openPremiumGiftingModal()} > @@ -197,12 +209,14 @@ const SettingsMain: FC = ({
{lang('AskAQuestion')} openUrl({ url: FAQ_URL })} > @@ -210,6 +224,7 @@ const SettingsMain: FC = ({ openUrl({ url: PRIVACY_URL })} > diff --git a/src/components/left/settings/SettingsPrivacy.tsx b/src/components/left/settings/SettingsPrivacy.tsx index 520f83617..02b71b69b 100644 --- a/src/components/left/settings/SettingsPrivacy.tsx +++ b/src/components/left/settings/SettingsPrivacy.tsx @@ -103,9 +103,11 @@ const SettingsPrivacy: FC = ({ }, [updateContentSettings]); function getVisibilityValue(setting?: ApiPrivacySettings) { - const { visibility, shouldAllowPremium } = setting || {}; - const blockCount = setting ? setting.blockChatIds.length + setting.blockUserIds.length : 0; - const allowCount = setting ? setting.allowChatIds.length + setting.allowUserIds.length : 0; + if (!setting) return lang('Loading'); + + const { visibility, shouldAllowPremium } = setting; + const blockCount = setting.blockChatIds.length + setting.blockUserIds.length; + const allowCount = setting.allowChatIds.length + setting.allowUserIds.length; const total = []; if (blockCount) total.push(`-${blockCount}`); if (allowCount) total.push(`+${allowCount}`); @@ -135,6 +137,7 @@ const SettingsPrivacy: FC = ({
onScreenSelect(SettingsScreens.PrivacyBlockedUsers)} > @@ -176,6 +179,7 @@ const SettingsPrivacy: FC = ({ {webAuthCount > 0 && ( onScreenSelect(SettingsScreens.ActiveWebsites)} > @@ -186,7 +190,7 @@ const SettingsPrivacy: FC = ({
-

{lang('PrivacyTitle')}

+

{lang('PrivacyTitle')}

{!isPremiumRequired && (primaryExceptionLists.shouldShowAllowed || primaryExceptionLists.shouldShowDenied) && (
-

+

{lang('PrivacyExceptions')}

{primaryExceptionLists.shouldShowAllowed && ( diff --git a/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx b/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx index 095bfa618..e58ee98cc 100644 --- a/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx +++ b/src/components/left/settings/SettingsPrivacyVisibilityExceptionList.tsx @@ -20,7 +20,7 @@ import { useFolderManagerForOrderedIds } from '../../../hooks/useFolderManager'; import useHistoryBack from '../../../hooks/useHistoryBack'; import useOldLang from '../../../hooks/useOldLang'; -import Picker from '../../common/Picker'; +import PeerPicker from '../../common/pickers/PeerPicker'; import FloatingActionButton from '../../ui/FloatingActionButton'; export type OwnProps = { @@ -139,7 +139,7 @@ const SettingsPrivacyVisibilityExceptionList: FC = ({ return (
- = ({ onSelectedIdsChange={handleSelectedContactIdsChange} onSelectedCategoriesChange={handleSelectedCategoriesChange} onFilterChange={setSearchQuery} + allowMultiple + itemInputType="checkbox" + withDefaultPadding + withStatus /> = ({ options={options} selected={selectedReaction} onChange={handleChange} + withIcon />
); diff --git a/src/components/left/settings/folders/SettingsFolders.scss b/src/components/left/settings/folders/SettingsFolders.scss index d1e3f1198..aa84357ad 100644 --- a/src/components/left/settings/folders/SettingsFolders.scss +++ b/src/components/left/settings/folders/SettingsFolders.scss @@ -12,6 +12,10 @@ } .settings-folders-list-item { + .ListItem-main-icon { + margin-inline-end: 1.875rem !important; + } + .ChatInfo { display: flex; align-items: center; @@ -99,7 +103,9 @@ padding-top: 0.75rem; } -.down { - font-size: 1.5rem; - margin-right: 0.5rem; +.Picker { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; } diff --git a/src/components/left/settings/folders/SettingsFoldersChatFilters.tsx b/src/components/left/settings/folders/SettingsFoldersChatFilters.tsx index 8d6ee5540..726b6046a 100644 --- a/src/components/left/settings/folders/SettingsFoldersChatFilters.tsx +++ b/src/components/left/settings/folders/SettingsFoldersChatFilters.tsx @@ -19,7 +19,7 @@ import useLastCallback from '../../../../hooks/useLastCallback'; import useOldLang from '../../../../hooks/useOldLang'; import Icon from '../../../common/icons/Icon'; -import Picker from '../../../common/Picker'; +import PeerPicker from '../../../common/pickers/PeerPicker'; import FloatingActionButton from '../../../ui/FloatingActionButton'; import Loading from '../../../ui/Loading'; @@ -128,6 +128,7 @@ const SettingsFoldersChatFilters: FC = ({ }, }); } + setIsTouched(true); }); useHistoryBack({ @@ -141,7 +142,7 @@ const SettingsFoldersChatFilters: FC = ({ return (
- = ({ categoryPlaceholderKey="FilterChatTypes" searchInputId="new-group-picker-search" isSearchable - isRoundCheckbox + withDefaultPadding + withPeerTypes + allowMultiple + itemInputType="checkbox" onSelectedIdsChange={handleSelectedIdsChange} onSelectedCategoriesChange={handleSelectedChatTypesChange} onFilterChange={handleFilterChange} diff --git a/src/components/left/settings/folders/SettingsFoldersEdit.tsx b/src/components/left/settings/folders/SettingsFoldersEdit.tsx index 978559c2f..156a3a959 100644 --- a/src/components/left/settings/folders/SettingsFoldersEdit.tsx +++ b/src/components/left/settings/folders/SettingsFoldersEdit.tsx @@ -28,7 +28,6 @@ import useOldLang from '../../../../hooks/useOldLang'; import AnimatedIcon from '../../../common/AnimatedIcon'; import GroupChatInfo from '../../../common/GroupChatInfo'; -import Icon from '../../../common/icons/Icon'; import PrivateChatInfo from '../../../common/PrivateChatInfo'; import FloatingActionButton from '../../../ui/FloatingActionButton'; import InputText from '../../../ui/InputText'; @@ -267,10 +266,12 @@ const SettingsFoldersEdit: FC = ({ {(!isExpanded && leftChatsCount > 0) && ( - {lang('FilterShowMoreChats', leftChatsCount, 'i')} )} @@ -315,8 +316,9 @@ const SettingsFoldersEdit: FC = ({

{lang('FilterInclude')}

{lang('FilterAddChats')} @@ -331,8 +333,9 @@ const SettingsFoldersEdit: FC = ({

{lang('FilterExclude')}

{lang('FilterAddChats')} @@ -348,8 +351,9 @@ const SettingsFoldersEdit: FC = ({

{lang('ChatListFilter.CreateLinkNew')} @@ -357,8 +361,9 @@ const SettingsFoldersEdit: FC = ({ {invites?.map((invite) => ( = ({ isDisabled={!chatsCount || isTouched} /> -
- +
diff --git a/src/components/main/AppendEntityPicker.async.tsx b/src/components/main/AppendEntityPicker.async.tsx deleted file mode 100644 index 35aa59dee..000000000 --- a/src/components/main/AppendEntityPicker.async.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; -import React from '../../lib/teact/teact'; - -import type { OwnProps } from './AppendEntityPickerModal'; - -import { Bundles } from '../../util/moduleLoader'; - -import useModuleLoader from '../../hooks/useModuleLoader'; - -const AppendEntityPickerModalAsync: FC = (props) => { - const { isOpen } = props; - const AppendEntityPickerModal = useModuleLoader(Bundles.Extra, 'AppendEntityPickerModal', !isOpen); - - // eslint-disable-next-line react/jsx-props-no-spreading - return AppendEntityPickerModal ? : undefined; -}; - -export default AppendEntityPickerModalAsync; diff --git a/src/components/main/AppendEntityPicker.module.scss b/src/components/main/AppendEntityPicker.module.scss deleted file mode 100644 index 8ae2b3467..000000000 --- a/src/components/main/AppendEntityPicker.module.scss +++ /dev/null @@ -1,46 +0,0 @@ -.root :global(.modal-content) { - padding: 0; -} - -.root :global(.modal-dialog) { - max-width: 55vh; -} - -.root :global(.modal-dialog), .root :global(.modal-content) { - overflow: hidden; -} - -.main { - height: 90vh; -} - -.filter { - padding: 0.375rem 1rem 0.25rem 0.75rem; - margin-bottom: 0.625rem; - background-color: var(--color-background); - box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent); - border-bottom: 0.625rem solid var(--color-background-secondary); - display: flex; - flex-flow: row wrap; - align-items: center; - flex-shrink: 0; - overflow-y: auto; - max-height: 20rem; -} - -.title { - margin: 0; -} - -.buttons { - width: 100%; - background: var(--color-background); - position: absolute; - bottom: 0; - z-index: 1; - padding: 0.75rem; -} - -.picker { - height: 75vh; -} diff --git a/src/components/main/AppendEntityPickerModal.tsx b/src/components/main/AppendEntityPickerModal.tsx deleted file mode 100644 index 172458c2f..000000000 --- a/src/components/main/AppendEntityPickerModal.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { - memo, - useMemo, - useState, -} from '../../lib/teact/teact'; -import { getActions, getGlobal, withGlobal } from '../../global'; - -import type { ApiChat, ApiChatMember, ApiUserStatus } from '../../api/types'; - -import { - filterChatsByName, - filterUsersByName, isChatChannel, isChatPublic, isChatSuperGroup, isUserBot, sortUserIds, -} from '../../global/helpers'; -import { selectChat, selectChatFullInfo } from '../../global/selectors'; -import buildClassName from '../../util/buildClassName'; -import { unique } from '../../util/iteratees'; -import sortChatIds from '../common/helpers/sortChatIds'; - -import useFlag from '../../hooks/useFlag'; -import useLastCallback from '../../hooks/useLastCallback'; -import useOldLang from '../../hooks/useOldLang'; - -import Icon from '../common/icons/Icon'; -import Picker from '../common/Picker'; -import Button from '../ui/Button'; -import ConfirmDialog from '../ui/ConfirmDialog'; -import Modal from '../ui/Modal'; - -import styles from './AppendEntityPicker.module.scss'; - -export type OwnProps = { - isOpen?: boolean; - onClose: () => void; - chatId?: string; - entityType: 'members' | 'channels' | undefined; - onSubmit: (value: string[]) => void; - selectionLimit: number; -}; - -interface StateProps { - chatId?: string; - members?: ApiChatMember[]; - adminMembersById?: Record; - userStatusesById: Record; - channelList?: (ApiChat | undefined)[] | undefined; - isChannel?: boolean; - isSuperGroup?: boolean; - currentUserId?: string | undefined; -} - -const AppendEntityPickerModal: FC = ({ - chatId, - isOpen, - onClose, - members, - adminMembersById, - userStatusesById, - entityType, - isChannel, - isSuperGroup, - onSubmit, - currentUserId, - selectionLimit, -}) => { - const { showNotification } = getActions(); - - const lang = useOldLang(); - const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); - - const [selectedIds, setSelectedIds] = useState([]); - const [pendingChannelId, setPendingChannelId] = useState(undefined); - const [searchQuery, setSearchQuery] = useState(''); - - const channelsIds = useMemo(() => { - const chatsById = getGlobal().chats.byId; - const activeChatIds = getGlobal().chats.listIds.active; - - return activeChatIds!.map((id) => chatsById[id]) - .filter((chat) => chat && (isChatChannel(chat) - || isChatSuperGroup(chat)) && chat.id !== chatId) - .map((chat) => chat!.id); - }, [chatId]); - - const adminIds = useMemo(() => { - return adminMembersById && Object.keys(adminMembersById); - }, [adminMembersById]); - - const memberIds = useMemo(() => { - const usersById = getGlobal().users.byId; - if (!members || !usersById) { - return []; - } - - const userIds = sortUserIds( - members.map(({ userId }) => userId), - usersById, - userStatusesById, - ); - - return adminIds ? userIds.filter((userId) => userId !== currentUserId) : userIds; - }, [adminIds, currentUserId, members, userStatusesById]); - - const displayedMembersIds = useMemo(() => { - const usersById = getGlobal().users.byId; - const filteredContactIds = memberIds ? filterUsersByName(memberIds, usersById, searchQuery) : []; - - return sortChatIds(unique(filteredContactIds).filter((userId) => { - const user = usersById[userId]; - if (!user) { - return true; - } - - return !isUserBot(user); - })); - }, [memberIds, searchQuery]); - - const displayedChannelIds = useMemo(() => { - const chatsById = getGlobal().chats.byId; - const foundChannelIds = channelsIds ? filterChatsByName(lang, channelsIds, chatsById, searchQuery) : []; - - return sortChatIds(unique(foundChannelIds).filter((contactId) => { - const chat = chatsById[contactId]; - if (!chat) { - return true; - } - - return isChannel || isSuperGroup; - }), - false, - selectedIds); - }, [channelsIds, lang, searchQuery, selectedIds, isSuperGroup, isChannel]); - - const handleCloseButtonClick = useLastCallback(() => { - onSubmit([]); - onClose(); - }); - - const handleSendIdList = useLastCallback(() => { - onSubmit(selectedIds); - onClose(); - }); - - const confirmPrivateLinkChannelSelection = useLastCallback(() => { - if (pendingChannelId) { - setSelectedIds((prevIds) => unique([...prevIds, pendingChannelId])); - } - closeConfirmModal(); - }); - - const handleSelectedMemberIdsChange = useLastCallback((newSelectedIds: string[]) => { - if (newSelectedIds.length > selectionLimit) { - showNotification({ - message: lang('BoostingSelectUpToWarningUsers', selectionLimit), - }); - return; - } - setSelectedIds(newSelectedIds); - }); - - const handleSelectedChannelIdsChange = useLastCallback((newSelectedIds: string[]) => { - const chatsById = getGlobal().chats.byId; - const newlyAddedIds = newSelectedIds.filter((id) => !selectedIds.includes(id)); - const privateLinkChannelId = newlyAddedIds.find((id) => { - const chat = chatsById[id]; - return chat && !isChatPublic(chat); - }); - - if (selectedIds?.length >= selectionLimit) { - showNotification({ - message: lang('BoostingSelectUpToWarningChannelsPlural', selectionLimit), - }); - return; - } - - if (privateLinkChannelId) { - setPendingChannelId(privateLinkChannelId); - openConfirmModal(); - } else { - setSelectedIds(newSelectedIds); - } - }); - - const handleClose = useLastCallback(() => { - onSubmit([]); - onClose(); - }); - - function renderSearchField() { - return ( -
- -

{lang(entityType === 'channels' - ? 'RequestPeer.ChooseChannelTitle' : 'BoostingAwardSpecificUsers')} -

-
- ); - } - - return ( - -
- {renderSearchField()} -
- -
-
- -
-
- -
- ); -}; - -export default memo(withGlobal((global, { chatId, entityType }): StateProps => { - const { statusesById: userStatusesById } = global.users; - let isChannel; - let isSuperGroup; - let members: ApiChatMember[] | undefined; - let adminMembersById: Record | undefined; - let currentUserId: string | undefined; - - if (entityType === 'members') { - currentUserId = global.currentUserId; - const chatFullInfo = chatId ? selectChatFullInfo(global, chatId) : undefined; - if (chatFullInfo) { - members = chatFullInfo.members; - adminMembersById = chatFullInfo.adminMembersById; - } - } if (entityType === 'channels') { - const chat = chatId ? selectChat(global, chatId) : undefined; - if (chat) { - isChannel = isChatChannel(chat); - isSuperGroup = isChatSuperGroup(chat); - } - } - - return { - chatId, - members, - adminMembersById, - userStatusesById, - isChannel, - isSuperGroup, - currentUserId, - }; -})(AppendEntityPickerModal)); diff --git a/src/components/main/premium/GiveawayChannelPickerModal.tsx b/src/components/main/premium/GiveawayChannelPickerModal.tsx new file mode 100644 index 000000000..f83bf01fc --- /dev/null +++ b/src/components/main/premium/GiveawayChannelPickerModal.tsx @@ -0,0 +1,144 @@ +import React, { + memo, useEffect, useMemo, useState, +} from '../../../lib/teact/teact'; +import { getActions, getGlobal } from '../../../global'; + +import { + filterChatsByName, isChatChannel, isChatPublic, isChatSuperGroup, +} from '../../../global/helpers'; +import { unique } from '../../../util/iteratees'; +import sortChatIds from '../../common/helpers/sortChatIds'; + +import useFlag from '../../../hooks/useFlag'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import PeerPicker from '../../common/pickers/PeerPicker'; +import PickerModal from '../../common/pickers/PickerModal'; +import ConfirmDialog from '../../ui/ConfirmDialog'; + +type OwnProps = { + isOpen?: boolean; + giveawayChatId?: string; + selectionLimit: number; + initialSelectedIds: string[]; + onSelectedIdsConfirmed: (newSelectedIds: string[]) => void; + onClose: NoneToVoidFunction; +}; + +const GiveawayChannelPickerModal = ({ + isOpen, + giveawayChatId, + selectionLimit, + initialSelectedIds, + onSelectedIdsConfirmed, + onClose, +}: OwnProps) => { + const { showNotification } = getActions(); + const lang = useOldLang(); + + const [pendingChannelId, setPendingChannelId] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(''); + const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); + const [selectedIds, setSelectedIds] = useState(initialSelectedIds); + + useEffect(() => { + setSelectedIds(initialSelectedIds); + }, [initialSelectedIds]); + + const channelIds = useMemo(() => { + const global = getGlobal(); + const chatsById = global.chats.byId; + const { active, archived } = global.chats.listIds; + const ids = (active || []).concat(archived || []); + + return unique(ids).map((id) => chatsById[id]) + .filter((chat) => ( + chat && ( + isChatChannel(chat) || isChatSuperGroup(chat) + ) && chat.id !== giveawayChatId + )).map((chat) => chat.id); + }, [giveawayChatId]); + + const displayedChannelIds = useMemo(() => { + const chatsById = getGlobal().chats.byId; + const foundChannelIds = channelIds ? filterChatsByName(lang, channelIds, chatsById, searchQuery) : []; + + return sortChatIds(foundChannelIds, + false, + selectedIds); + }, [channelIds, lang, searchQuery, selectedIds]); + + const handleSelectedChannelIdsChange = useLastCallback((newSelectedIds: string[]) => { + const chatsById = getGlobal().chats.byId; + const newlyAddedIds = newSelectedIds.filter((id) => !selectedIds.includes(id)); + const privateLinkChannelId = newlyAddedIds.find((id) => { + const chat = chatsById[id]; + return chat && !isChatPublic(chat); + }); + + if (selectedIds?.length >= selectionLimit) { + showNotification({ + message: lang('BoostingSelectUpToWarningChannelsPlural', selectionLimit), + }); + return; + } + + if (privateLinkChannelId) { + setPendingChannelId(privateLinkChannelId); + openConfirmModal(); + } else { + setSelectedIds(newSelectedIds); + } + }); + + const confirmPrivateLinkChannelSelection = useLastCallback(() => { + if (pendingChannelId) { + setSelectedIds(unique([...selectedIds, pendingChannelId])); + } + closeConfirmModal(); + }); + + const handleModalConfirm = useLastCallback(() => { + onSelectedIdsConfirmed(selectedIds); + onClose(); + }); + + return ( + + + + + ); +}; + +export default memo(GiveawayChannelPickerModal); diff --git a/src/components/main/premium/GiveawayModal.module.scss b/src/components/main/premium/GiveawayModal.module.scss index 9fb6e860f..9291c2786 100644 --- a/src/components/main/premium/GiveawayModal.module.scss +++ b/src/components/main/premium/GiveawayModal.module.scss @@ -266,6 +266,12 @@ margin-inline-start: initial !important; } +.removeChannel { + font-size: 1.5rem; + color: var(--color-text-secondary); + padding: 0.5rem; +} + @media (max-width: 600px) { .root :global(.modal-dialog) { width: 100%; diff --git a/src/components/main/premium/GiveawayModal.tsx b/src/components/main/premium/GiveawayModal.tsx index 315affbe1..b44ae5a79 100644 --- a/src/components/main/premium/GiveawayModal.tsx +++ b/src/components/main/premium/GiveawayModal.tsx @@ -41,8 +41,9 @@ import Modal from '../../ui/Modal'; import RadioGroup from '../../ui/RadioGroup'; import RangeSliderWithMarks from '../../ui/RangeSliderWithMarks'; import Switcher from '../../ui/Switcher'; -import AppendEntityPickerModal from '../AppendEntityPickerModal'; +import GiveawayChannelPickerModal from './GiveawayChannelPickerModal'; import GiveawayTypeOption from './GiveawayTypeOption'; +import GiveawayUserPickerModal from './GiveawayUserPickerModal'; import PremiumSubscriptionOption from './PremiumSubscriptionOption'; import styles from './GiveawayModal.module.scss'; @@ -121,8 +122,8 @@ const GiveawayModal: FC = ({ const [isCalendarOpened, openCalendar, closeCalendar] = useFlag(); const [isCountryPickerModalOpen, openCountryPickerModal, closeCountryPickerModal] = useFlag(); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); - const [isEntityPickerModalOpen, openEntityPickerModal, closeEntityPickerModal] = useFlag(); - const [entityType, setEntityType] = useState<'members' | 'channels' | undefined>(undefined); + const [isUserPickerModalOpen, openUserPickerModal, closeUserPickerModal] = useFlag(); + const [isChannelPickerModalOpen, openChannelPickerModal, closeChannelPickerModal] = useFlag(); const TYPE_OPTIONS: TypeOption[] = [{ name: 'BoostingCreateGiveaway', @@ -139,43 +140,44 @@ const GiveawayModal: FC = ({ actions: 'createSpecificUsers', isLink: true, onClickAction: () => { - openEntityPickerModal(); - setEntityType('members'); + openUserPickerModal(); }, }]; const [customExpireDate, setCustomExpireDate] = useState(Date.now() + DEFAULT_CUSTOM_EXPIRE_DATE); const [isHeaderHidden, setHeaderHidden] = useState(true); - const [selectedUserCount, setSelectedUserCount] = useState(DEFAULT_BOOST_COUNT); + const [selectedRandomUserCount, setSelectedRandomUserCount] = useState(DEFAULT_BOOST_COUNT); const [selectedGiveawayOption, setGiveawayOption] = useState(TYPE_OPTIONS[0].value); const [selectedSubscriberOption, setSelectedSubscriberOption] = useState('all'); const [selectedMonthOption, setSelectedMonthOption] = useState(); const [selectedUserIds, setSelectedUserIds] = useState([]); const [selectedChannelIds, setSelectedChannelIds] = useState([]); - const [selectedCountriesIds, setSelectedCountriesIds] = useState([]); + const [selectedCountryIds, setSelectedCountryIds] = useState([]); const [shouldShowWinners, setShouldShowWinners] = useState(false); const [shouldShowPrizes, setShouldShowPrizes] = useState(false); const [prizeDescription, setPrizeDescription] = useState(undefined); const [dataPrepaidGiveaway, setDataPrepaidGiveaway] = useState(undefined); - const boostQuantity = selectedUserCount * giveawayBoostPerPremiumLimit; + const isRandomUsers = selectedGiveawayOption === 'random_users'; + const selectedUserCount = isRandomUsers ? selectedRandomUserCount : selectedUserIds.length; + const boostQuantity = selectedUserCount * giveawayBoostPerPremiumLimit; const SUBSCRIBER_OPTIONS = useMemo(() => [ { value: 'all', label: lang(isChannel ? 'BoostingAllSubscribers' : 'BoostingAllMembers'), - subLabel: selectedCountriesIds && selectedCountriesIds.length > 0 - ? lang('Giveaway.ReceiverType.Countries', selectedCountriesIds.length) + subLabel: selectedCountryIds && selectedCountryIds.length > 0 + ? lang('Giveaway.ReceiverType.Countries', selectedCountryIds.length) : lang('BoostingFromAllCountries'), }, { value: 'new', label: lang(isChannel ? 'BoostingNewSubscribers' : 'BoostingNewMembers'), - subLabel: selectedCountriesIds && selectedCountriesIds.length > 0 - ? lang('Giveaway.ReceiverType.Countries', selectedCountriesIds.length) + subLabel: selectedCountryIds && selectedCountryIds.length > 0 + ? lang('Giveaway.ReceiverType.Countries', selectedCountryIds.length) : lang('BoostingFromAllCountries'), }, - ], [isChannel, lang, selectedCountriesIds]); + ], [isChannel, lang, selectedCountryIds]); const monthQuantity = lang('Months', selectedMonthOption); @@ -184,9 +186,8 @@ const GiveawayModal: FC = ({ }, [gifts, selectedMonthOption, selectedUserCount]); const filteredGifts = useMemo(() => { - return gifts?.filter((gift) => gift.users - === (selectedUserIds?.length ? selectedUserIds?.length : selectedUserCount)); - }, [gifts, selectedUserIds, selectedUserCount]); + return gifts?.filter((gift) => gift.users === selectedUserCount); + }, [gifts, selectedUserCount]); const fullMonthlyAmount = useMemo(() => { if (!filteredGifts?.length) { @@ -213,7 +214,7 @@ const GiveawayModal: FC = ({ useEffect(() => { if (prepaidGiveaway) { - setSelectedUserCount(prepaidGiveaway.quantity); + setSelectedRandomUserCount(prepaidGiveaway.quantity); setDataPrepaidGiveaway(prepaidGiveaway); } }, [prepaidGiveaway]); @@ -235,25 +236,25 @@ const GiveawayModal: FC = ({ }); const handleClick = useLastCallback(() => { - if (selectedUserIds?.length) { + if (isRandomUsers) { openInvoice({ - type: 'giftcode', - boostChannelId: chatId!, - userIds: selectedUserIds, + type: 'giveaway', + chatId: chatId!, + additionalChannelIds: selectedChannelIds, + isOnlyForNewSubscribers: selectedSubscriberOption === 'new', + countries: selectedCountryIds, + areWinnersVisible: shouldShowWinners, + prizeDescription, + untilDate: customExpireDate / 1000, currency: selectedGift!.currency, amount: selectedGift!.amount, option: selectedGift!, }); } else { openInvoice({ - type: 'giveaway', - chatId: chatId!, - additionalChannelIds: selectedChannelIds, - isOnlyForNewSubscribers: selectedSubscriberOption === 'new', - countries: selectedCountriesIds, - areWinnersVisible: shouldShowWinners, - prizeDescription, - untilDate: customExpireDate / 1000, + type: 'giftcode', + boostChannelId: chatId!, + userIds: selectedUserIds, currency: selectedGift!.currency, amount: selectedGift!.amount, option: selectedGift!, @@ -269,7 +270,7 @@ const GiveawayModal: FC = ({ giveawayId: dataPrepaidGiveaway!.id, paymentPurpose: { additionalChannelIds: selectedChannelIds, - countries: selectedCountriesIds, + countries: selectedCountryIds, prizeDescription, areWinnersVisible: shouldShowWinners, untilDate: customExpireDate / 1000, @@ -282,8 +283,8 @@ const GiveawayModal: FC = ({ closeGiveawayModal(); }); - const handleUserCountChange = useLastCallback((newValue) => { - setSelectedUserCount(newValue); + const handleRandomUserCountChange = useLastCallback((newValue) => { + setSelectedRandomUserCount(newValue); }); const handlePrizeDescriptionChange = useLastCallback((e: ChangeEvent) => { @@ -295,11 +296,6 @@ const GiveawayModal: FC = ({ return selectedUserIds?.map((userId) => getUserFullName(usersById[userId])).join(', '); }, [selectedUserIds]); - const handleAdd = useLastCallback(() => { - openEntityPickerModal(); - setEntityType('channels'); - }); - function handleScroll(e: React.UIEvent) { const { scrollTop } = e.currentTarget; @@ -321,13 +317,18 @@ const GiveawayModal: FC = ({ }); const handleSetCountriesListChange = useLastCallback((value: string[]) => { - setSelectedCountriesIds(value); + setSelectedCountryIds(value); }); - const handleSetIdsListChange = useLastCallback((value: string[]) => { - return entityType === 'members' - ? (value?.length ? setSelectedUserIds(value) : setGiveawayOption('random_users')) - : setSelectedChannelIds(value); + const handleSelectedUserIdsChange = useLastCallback((newSelectedIds: string[]) => { + setSelectedUserIds(newSelectedIds); + if (!newSelectedIds.length) { + setGiveawayOption('random_users'); + } + }); + + const handleSelectedChannelIdsChange = useLastCallback((newSelectedIds: string[]) => { + setSelectedChannelIds(newSelectedIds); }); const handleClose = useLastCallback(() => { @@ -503,7 +504,7 @@ const GiveawayModal: FC = ({ @@ -537,7 +538,7 @@ const GiveawayModal: FC = ({ className="chat-item-clickable contact-list-item" /* eslint-disable-next-line react/jsx-no-bind */ onClick={() => deleteParticipantsHandler(channelId)} - rightElement={()} + rightElement={()} > = ({ @@ -705,14 +706,21 @@ const GiveawayModal: FC = ({ onSubmit={handleSetCountriesListChange} selectionLimit={countrySelectionLimit} /> - + void; + onClose: NoneToVoidFunction; +}; + +type StateProps = { + members?: ApiChatMember[]; + adminMembersById?: Record; +}; + +const GiveawayUserPickerModal = ({ + isOpen, + selectionLimit, + members, + adminMembersById, + initialSelectedIds, + onSelectedIdsConfirmed, + onClose, +}: OwnProps & StateProps) => { + const { showNotification } = getActions(); + const lang = useOldLang(); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedIds, setSelectedIds] = useState(initialSelectedIds); + + useEffect(() => { + setSelectedIds(initialSelectedIds); + }, [initialSelectedIds]); + + const memberIds = useMemo(() => { + const global = getGlobal(); + const { byId, statusesById } = global.users; + if (!members?.length) { + return []; + } + + const adminIdsSet = adminMembersById && new Set(Object.keys(adminMembersById)); + + const userIds = sortUserIds( + members.map(({ userId }) => userId), + byId, + statusesById, + ); + + return adminIdsSet ? userIds.filter((userId) => !adminIdsSet.has(userId)) : userIds; + }, [adminMembersById, members]); + + const displayedMemberIds = useMemo(() => { + const usersById = getGlobal().users.byId; + const filteredContactIds = memberIds ? filterUsersByName(memberIds, usersById, searchQuery) : []; + + return sortChatIds(unique(filteredContactIds).filter((userId) => { + const user = usersById[userId]; + if (!user) { + return true; + } + + return !isUserBot(user); + })); + }, [memberIds, searchQuery]); + + const handleSelectedMemberIdsChange = useLastCallback((newSelectedIds: string[]) => { + if (newSelectedIds.length > selectionLimit) { + showNotification({ + message: lang('BoostingSelectUpToWarningUsers', selectionLimit), + }); + return; + } + setSelectedIds(newSelectedIds); + }); + + const handleModalConfirm = useLastCallback(() => { + onSelectedIdsConfirmed(selectedIds); + onClose(); + }); + + return ( + + + + ); +}; + +export default memo(withGlobal((global, { giveawayChatId }): StateProps => { + const chatFullInfo = giveawayChatId ? selectChatFullInfo(global, giveawayChatId) : undefined; + if (!chatFullInfo) { + return {}; + } + + return { + members: chatFullInfo.members, + adminMembersById: chatFullInfo.adminMembersById, + }; +})(GiveawayUserPickerModal)); diff --git a/src/components/main/premium/PremiumGiftingModal.tsx b/src/components/main/premium/PremiumGiftingModal.tsx index 52371c120..f90aaf109 100644 --- a/src/components/main/premium/PremiumGiftingModal.tsx +++ b/src/components/main/premium/PremiumGiftingModal.tsx @@ -16,7 +16,7 @@ import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; import Icon from '../../common/icons/Icon'; -import Picker from '../../common/Picker'; +import PeerPicker from '../../common/pickers/PeerPicker'; import Button from '../../ui/Button'; import Modal from '../../ui/Modal'; @@ -106,7 +106,7 @@ const PremiumGiftingModal: FC = ({
{renderSearchField()}
- = ({ onSelectedIdsChange={handleSelectedUserIdsChange} onFilterChange={setSearchQuery} isSearchable + withDefaultPadding + withStatus + allowMultiple + itemInputType="checkbox" />
diff --git a/src/components/main/premium/PremiumSubscriptionOption.module.scss b/src/components/main/premium/PremiumSubscriptionOption.module.scss index 7c55417e0..b305de18a 100644 --- a/src/components/main/premium/PremiumSubscriptionOption.module.scss +++ b/src/components/main/premium/PremiumSubscriptionOption.module.scss @@ -15,7 +15,7 @@ .giveawayWrapper { position: relative; display: block; - padding-inline: 3.6875rem 1rem; + padding-inline: 3.8125rem 1rem; cursor: var(--custom-cursor, pointer); @@ -55,7 +55,7 @@ content: ""; display: block; position: absolute; - inset-inline-start: 1.0625rem; + inset-inline-start: 1rem; top: 50%; width: 1.25rem; height: 1.25rem; @@ -71,7 +71,7 @@ } &::after { - inset-inline-start: 1.375rem; + inset-inline-start: 1.3125rem; width: 0.625rem; height: 0.625rem; border-radius: 50%; diff --git a/src/components/middle/DeleteSelectedMessageModal.module.scss b/src/components/middle/DeleteSelectedMessageModal.module.scss index 9bb3406f7..5784b37ff 100644 --- a/src/components/middle/DeleteSelectedMessageModal.module.scss +++ b/src/components/middle/DeleteSelectedMessageModal.module.scss @@ -6,7 +6,7 @@ display: flex; align-items: center; gap: 1rem; - margin-bottom: 1rem; + margin: 0 1rem; } .title { @@ -15,7 +15,7 @@ .actionTitle { margin-top: 1.5rem; - margin-left: 0.5rem; + margin-left: 1rem; color: var(--color-links); font-size: 1rem; font-weight: 500; @@ -28,7 +28,7 @@ .listItemButton { margin-top: 0.1875rem; - margin-left: 0.4375rem; + margin-left: 1rem; } .button { diff --git a/src/components/middle/message/Giveaway.tsx b/src/components/middle/message/Giveaway.tsx index 25c525e0d..0376efb19 100644 --- a/src/components/middle/message/Giveaway.tsx +++ b/src/components/middle/message/Giveaway.tsx @@ -8,8 +8,9 @@ import type { } from '../../../api/types'; import { - getChatTitle, getUserFullName, isApiPeerChat, isOwnMessage, + getChatTitle, getUserFullName, isOwnMessage, } from '../../../global/helpers'; +import { isApiPeerChat } from '../../../global/helpers/peers'; import { selectCanPlayAnimatedEmojis, selectChat, @@ -29,7 +30,7 @@ import useOldLang from '../../../hooks/useOldLang'; import AnimatedIconFromSticker from '../../common/AnimatedIconFromSticker'; import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview'; -import PickerSelectedItem from '../../common/PickerSelectedItem'; +import PickerSelectedItem from '../../common/pickers/PickerSelectedItem'; import Button from '../../ui/Button'; import ConfirmDialog from '../../ui/ConfirmDialog'; import Separator from '../../ui/Separator'; diff --git a/src/components/middle/message/Poll.tsx b/src/components/middle/message/Poll.tsx index 772bd3630..296cf5ca7 100644 --- a/src/components/middle/message/Poll.tsx +++ b/src/components/middle/message/Poll.tsx @@ -298,7 +298,6 @@ const Poll: FC = ({ onChange={handleCheckboxChange} disabled={message.isScheduled || isSubmitting} loadingOptions={isSubmitting ? chosenOptions : undefined} - round /> ) : ( diff --git a/src/components/middle/search/MiddleSearch.module.scss b/src/components/middle/search/MiddleSearch.module.scss index 20269f082..c20dbd089 100644 --- a/src/components/middle/search/MiddleSearch.module.scss +++ b/src/components/middle/search/MiddleSearch.module.scss @@ -219,6 +219,8 @@ color: var(--color-text-secondary); background-color: var(--color-item-active); font-weight: 500; + + margin-bottom: 0; } .selectedType { diff --git a/src/components/middle/search/MiddleSearch.tsx b/src/components/middle/search/MiddleSearch.tsx index 1c6dc68f0..fb6a0916c 100644 --- a/src/components/middle/search/MiddleSearch.tsx +++ b/src/components/middle/search/MiddleSearch.tsx @@ -49,7 +49,7 @@ import useOldLang from '../../../hooks/useOldLang'; import Avatar from '../../common/Avatar'; import Icon from '../../common/icons/Icon'; -import PickerSelectedItem from '../../common/PickerSelectedItem'; +import PickerSelectedItem from '../../common/pickers/PickerSelectedItem'; import Button from '../../ui/Button'; import InfiniteScroll from '../../ui/InfiniteScroll'; import SearchInput from '../../ui/SearchInput'; diff --git a/src/components/modals/chatlist/ChatlistAlready.tsx b/src/components/modals/chatlist/ChatlistAlready.tsx index b0d58ab5f..48362bc70 100644 --- a/src/components/modals/chatlist/ChatlistAlready.tsx +++ b/src/components/modals/chatlist/ChatlistAlready.tsx @@ -9,7 +9,7 @@ import renderText from '../../common/helpers/renderText'; import useOldLang from '../../../hooks/useOldLang'; -import Picker from '../../common/Picker'; +import PeerPicker from '../../common/pickers/PeerPicker'; import Badge from '../../ui/Badge'; import Button from '../../ui/Button'; @@ -72,10 +72,13 @@ const ChatlistAlready: FC = ({ invite, folder }) => { {selectedPeerIds.length === invite.missingPeerIds.length ? lang('DeselectAll') : lang('SelectAll')}
- )} @@ -84,10 +87,13 @@ const ChatlistAlready: FC = ({ invite, folder }) => { {lang('FolderLinkHeaderAlready')} -