UI: Picker refactoring (#4773)

This commit is contained in:
Alexander Zinchuk 2024-08-29 15:52:16 +02:00
parent 5f5536b6a0
commit 729e4ad791
88 changed files with 1980 additions and 1160 deletions

View File

@ -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';

View File

@ -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<OwnProps> = ({
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<OwnProps> = ({
</div>
<div className={buildClassName(styles.main, 'custom-scroll')}>
<Picker
<ItemPicker
className={styles.picker}
itemIds={displayedIds}
selectedIds={selectedCountryIds}
onSelectedIdsChange={handleSelectedIdsChange}
items={displayedIds}
selectedValues={selectedCountryIds}
onSelectedValuesChange={handleSelectedIdsChange}
noScrollRestore={noPickerScrollRestore}
isCountryList
countryList={countryList}
allowMultiple
itemInputType="checkbox"
/>
</div>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<OwnProps> = ({
@ -54,9 +55,10 @@ const FullNameTitle: FC<OwnProps> = ({
isSavedDialog,
noLoopLimit,
canCopyTitle,
iconElement,
allowMultiLine,
onEmojiStatusClick,
observeIntersection,
iconElement,
}) => {
const lang = useOldLang();
const { showNotification } = getActions();
@ -99,7 +101,9 @@ const FullNameTitle: FC<OwnProps> = ({
if (specialTitle) {
return (
<div className={buildClassName('title', styles.root, className)}>
<h3>{specialTitle}</h3>
<h3 className={buildClassName('fullName', styles.fullName, !allowMultiLine && styles.ellipsis)}>
{specialTitle}
</h3>
</div>
);
}
@ -109,7 +113,12 @@ const FullNameTitle: FC<OwnProps> = ({
<h3
dir="auto"
role="button"
className={buildClassName('fullName', styles.fullName, canCopyTitle && styles.canCopy)}
className={buildClassName(
'fullName',
styles.fullName,
!allowMultiLine && styles.ellipsis,
canCopyTitle && styles.canCopy,
)}
onClick={handleTitleClick}
>
{renderText(title || '')}

View File

@ -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<OwnProps & StateProps> = ({
);
};
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<OwnProps>(
(global, { chatId, threadId }): StateProps => {
const chat = selectChat(global, chatId);

View File

@ -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);
}
}

View File

@ -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<OwnProps> = ({
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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<PrivateChatInfo
customPeer={category}
/>
);
});
const renderChatInfo = useLastCallback((id: string) => {
const isUnselectable = lockedUnselectedIdsSet.has(id);
if (isCountryList && countriesByIso) {
const country = countriesByIso[id];
return <div>{country.defaultName}</div>;
} else if (isUserId(id)) {
return (
<PrivateChatInfo
forceShowSelf={forceShowSelf}
userId={id}
status={isUnselectable ? lockedUnselectedSubtitle : undefined}
/>
);
} else {
return <GroupChatInfo chatId={id} status={isUnselectable ? lockedUnselectedSubtitle : undefined} />;
}
});
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 : (
<Checkbox
label=""
disabled={isLocked}
checked={isChecked}
round={isRoundCheckbox}
/>
);
};
return (
<ListItem
key={id}
className={buildClassName('chat-item-clickable picker-list-item', isRoundCheckbox && 'chat-item')}
disabled={isLocked}
inactive={isViewOnly}
allowDisabledClick={Boolean(onDisabledClick)}
secondaryIcon={shouldRenderLockIcon ? 'lock-badge' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleItemClick(id)}
ripple
>
{!isRoundCheckbox ? renderCheckbox() : undefined}
{category ? renderCategory(category) : renderChatInfo(id)}
{isRoundCheckbox ? renderCheckbox() : undefined}
</ListItem>
);
}, [
categoriesByType, isRoundCheckbox, isViewOnly, lockedSelectedIdsSet, lockedUnselectedIdsSet,
onDisabledClick, renderChatInfo, selectedCategories, selectedIds,
]);
const beforeChildren = useMemo(() => {
return (
<div key="categories">
{Boolean(categories?.length) && (
<>
{categoryPlaceholderKey && <div className="picker-category-title">{lang(categoryPlaceholderKey)}</div>}
{categories?.map((category) => renderItem(category.type, true))}
<div className="picker-category-title">{lang('FilterChats')}</div>
</>
)}
</div>
);
}, [categories, categoryPlaceholderKey, lang, renderItem]);
return (
<div className={buildClassName('Picker', className)}>
{isSearchable && (
<div className="picker-header custom-scroll" dir={lang.isRtl ? 'rtl' : undefined}>
{selectedCategories?.map((category) => (
<PickerSelectedItem
customPeer={categoriesByType[category]}
onClick={handleItemClick}
clickArg={category}
canClose
/>
))}
{lockedSelectedIds?.map((id, i) => (
<PickerSelectedItem
peerId={id}
isMinimized={shouldMinimize && i < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT}
forceShowSelf={forceShowSelf}
onClick={handleItemClick}
clickArg={id}
/>
))}
{unlockedSelectedIds.map((id, i) => (
<PickerSelectedItem
peerId={id}
isMinimized={
shouldMinimize && i + (lockedSelectedIds?.length || 0) < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT
}
canClose
onClick={handleItemClick}
clickArg={id}
/>
))}
<InputText
id={searchInputId}
ref={inputRef}
value={filterValue}
onChange={handleFilterChange}
placeholder={filterPlaceholder || lang('SelectChat')}
/>
</div>
)}
{viewportIds?.length ? (
<InfiniteScroll
className={buildClassName('picker-list', 'custom-scroll', isRoundCheckbox && 'withRoundedCheckbox')}
items={viewportIds}
beforeChildren={beforeChildren}
onLoadMore={getMore}
noScrollRestore={noScrollRestore}
>
{viewportIds.map((id) => renderItem(id))}
</InfiniteScroll>
) : !isLoading && viewportIds && !viewportIds.length ? (
<p className="no-results">{notFoundText || 'Sorry, nothing found.'}</p>
) : (
<Loading />
)}
</div>
);
};
export default memo(Picker);

View File

@ -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;

View File

@ -1,8 +1,4 @@
.StickerSetCard {
.settings-item &.ListItem {
margin-bottom: 0.5rem;
}
.StickerButton,
.Button {
width: 3rem;

View File

@ -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';

View File

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 <Icon name="lock-badge" />;
if (itemInputType === 'radio') {
return <Radio checked={isChecked} disabled={isLocked} isLoading={isItemLoading} onlyInput />;
}
if (itemInputType === 'checkbox') {
return <Checkbox checked={isChecked} disabled={isLocked} isLoading={isItemLoading} onlyInput />;
}
return undefined;
}
return (
<PickerItem
key={value}
className={buildClassName(ITEM_CLASS_NAME, itemClassName)}
title={label}
subtitle={subLabel}
disabled={isLocked}
inactive={isViewOnly}
ripple
inputElement={getInputElement()}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleItemClick(value)}
// eslint-disable-next-line react/jsx-no-bind
onDisabledClick={onDisabledClick && (() => onDisabledClick(value, isAlwaysSelected))}
/>
);
}, [
items, lockedUnselectedValuesSet, lockedSelectedValuesSet, selectedValues, isViewOnly, onDisabledClick,
itemInputType, itemClassName,
]);
return (
<div className={buildClassName(styles.container, className)}>
{isSearchable && (
<div className={buildClassName(styles.header, 'custom-scroll')} dir={lang.isRtl ? 'rtl' : undefined}>
<InputText
id={searchInputId}
ref={inputRef}
value={filterValue}
onChange={handleFilterChange}
placeholder={filterPlaceholder || lang('Search')}
/>
</div>
)}
{viewportValuesList?.length ? (
<InfiniteScroll
className={buildClassName(styles.pickerList, withDefaultPadding && styles.padded, 'custom-scroll')}
items={viewportValuesList}
itemSelector={`.${ITEM_CLASS_NAME}`}
onLoadMore={getMore}
noScrollRestore={noScrollRestore}
>
{viewportValuesList.map((value) => renderItem(value))}
</InfiniteScroll>
) : !isLoading && viewportValuesList && !viewportValuesList.length ? (
<p className={styles.noResults}>{notFoundText || lang('SearchEmptyViewTitle')}</p>
) : (
<Loading />
)}
</div>
);
};
export default memo(ItemPicker);

View File

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 <div key={id}>No peer or category with ID {id}</div>;
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 <Icon name="lock-badge" />;
if (itemInputType === 'radio') {
return <Radio checked={isChecked} disabled={isLocked} onlyInput />;
}
if (itemInputType === 'checkbox') {
return <Checkbox checked={isChecked} disabled={isLocked} onlyInput />;
}
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 (
<PickerItem
key={id}
className={buildClassName(ITEM_CLASS_NAME, itemClassName)}
title={<FullNameTitle peer={peerOrCategory} />}
avatarElement={(
<Avatar
peer={peer || category}
isSavedMessages={isSelf}
size="medium"
/>
)}
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 (
<div key="categories">
{categoryPlaceholderKey && <div className={styles.pickerCategoryTitle}>{lang(categoryPlaceholderKey)}</div>}
{categories?.map((category) => renderItem(category.type, true))}
<div className={styles.pickerCategoryTitle}>{lang('FilterChats')}</div>
</div>
);
}, [categories, categoryPlaceholderKey, lang, renderItem]);
return (
<div className={buildClassName(styles.container, className)}>
{isSearchable && (
<div className={buildClassName(styles.header, 'custom-scroll')} dir={lang.isRtl ? 'rtl' : undefined}>
{selectedCategories?.map((category) => (
<PickerSelectedItem
customPeer={categoriesByType[category]}
onClick={handleItemClick}
clickArg={category}
canClose
/>
))}
{lockedSelectedIds?.map((id, i) => (
<PickerSelectedItem
peerId={id}
isMinimized={shouldMinimize && i < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT}
forceShowSelf={forceShowSelf}
onClick={handleItemClick}
clickArg={id}
/>
))}
{unlockedSelectedIds.map((id, i) => (
<PickerSelectedItem
peerId={id}
isMinimized={
shouldMinimize && i + (lockedSelectedIds?.length || 0) < selectedIds.length - ALWAYS_FULL_ITEMS_COUNT
}
canClose
onClick={handleItemClick}
clickArg={id}
/>
))}
<InputText
id={searchInputId}
ref={inputRef}
value={filterValue}
onChange={handleFilterChange}
placeholder={filterPlaceholder || lang('SelectChat')}
/>
</div>
)}
{viewportIds?.length ? (
<InfiniteScroll
className={buildClassName(styles.pickerList, withDefaultPadding && styles.padded, 'custom-scroll')}
items={viewportIds}
itemSelector={`.${ITEM_CLASS_NAME}`}
beforeChildren={beforeChildren}
onLoadMore={getMore}
noScrollRestore={noScrollRestore}
>
{viewportIds.map((id) => renderItem(id))}
</InfiniteScroll>
) : !isLoading && viewportIds && !viewportIds.length ? (
<p className={styles.noResults}>{notFoundText || 'Sorry, nothing found.'}</p>
) : (
<Loading />
)}
</div>
);
};
export default memo(PeerPicker);

View File

@ -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;
}

View File

@ -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 (
<div
className={buildClassName(
styles.root,
subtitle && styles.multiline,
disabled && styles.disabled,
isClickable && styles.clickable,
avatarElement && styles.withAvatar,
className,
)}
onClick={handleClick}
dir={lang.isRtl ? 'rtl' : undefined}
role={isClickable ? 'button' : undefined}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={isClickable ? 0 : undefined}
>
{!disabled && !inactive && ripple && <RippleEffect />}
{inputElement && (
<div className={buildClassName(
styles.input,
inputPosition === 'end' ? styles.endInput : styles.startInput,
)}
>
{inputElement}
</div>
)}
{avatarElement && (
<div className={styles.avatarElement}>
{avatarElement}
</div>
)}
<div className={buildClassName(styles.title, titleClassName)}>
{title}
</div>
{subtitle && (
<div className={buildClassName(styles.subtitle, subtitleClassName)}>
{subtitle}
</div>
)}
{!inputElement && IS_IOS && (
<div className={styles.separator} />
)}
</div>
);
};
export default PickerItem;

View File

@ -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;
}

View File

@ -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 (
<Modal
// eslint-disable-next-line react/jsx-props-no-spreading
{...modalProps}
isSlim
className={buildClassName(
shouldAdaptToSearch && styles.withSearch,
withFixedHeight && styles.fixedHeight,
modalProps.className,
)}
contentClassName={buildClassName(styles.content, modalProps.contentClassName)}
headerClassName={buildClassName(styles.header, modalProps.headerClassName)}
>
{modalProps.children}
<div className={styles.buttonWrapper}>
<Button
onClick={onConfirm || modalProps.onClose}
color="primary"
size="smaller"
disabled={isConfirmDisabled}
>
{confirmButtonText || lang('Confirm')}
</Button>
</div>
</Modal>
);
};
export default memo(PickerModal);

View File

@ -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;

View File

@ -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';

View File

@ -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);
}

View File

@ -87,7 +87,6 @@ const ChatFolderModal: FC<OwnProps & StateProps> = ({
options={folders}
selected={selectedFolderIds}
onChange={setSelectedFolderIds}
round
/>
<div className="dialog-buttons">
<Button color="primary" className="confirm-dialog-button" isText onClick={handleSubmit}>

View File

@ -36,7 +36,7 @@ import useOldLang from '../../../hooks/useOldLang';
import { useFullscreenStatus } from '../../../hooks/window/useFullscreen';
import useLeftHeaderButtonRtlForumTransition from './hooks/useLeftHeaderButtonRtlForumTransition';
import PickerSelectedItem from '../../common/PickerSelectedItem';
import PickerSelectedItem from '../../common/pickers/PickerSelectedItem';
import StoryToggler from '../../story/StoryToggler';
import Button from '../../ui/Button';
import DropdownMenu from '../../ui/DropdownMenu';

View File

@ -10,7 +10,7 @@ import sortChatIds from '../../common/helpers/sortChatIds';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useOldLang from '../../../hooks/useOldLang';
import Picker from '../../common/Picker';
import PeerPicker from '../../common/pickers/PeerPicker';
import Button from '../../ui/Button';
import FloatingActionButton from '../../ui/FloatingActionButton';
@ -99,7 +99,7 @@ const NewChatStep1: FC<OwnProps & StateProps> = ({
<h3>{lang('GroupAddMembers')}</h3>
</div>
<div className="NewChat-inner step-1">
<Picker
<PeerPicker
itemIds={displayedIds}
selectedIds={selectedMemberIds}
filterValue={searchQuery}
@ -107,6 +107,10 @@ const NewChatStep1: FC<OwnProps & StateProps> = ({
searchInputId="new-group-picker-search"
isLoading={isSearching}
isSearchable
allowMultiple
withStatus
itemInputType="checkbox"
withDefaultPadding
onSelectedIdsChange={onSelectedMemberIdsChange}
onFilterChange={handleFilterChange}
/>

View File

@ -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';

View File

@ -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);

View File

@ -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;

View File

@ -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);
}

View File

@ -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 {

View File

@ -145,7 +145,7 @@ const SettingsActiveSessions: FC<OwnProps & StateProps> = ({
function renderCurrentSession(session: ApiSession) {
return (
<div className="settings-item">
<h4 className="settings-item-header mb-4" dir={lang.isRtl ? 'rtl' : undefined}>
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('AuthSessions.CurrentSession')}
</h4>
@ -177,7 +177,7 @@ const SettingsActiveSessions: FC<OwnProps & StateProps> = ({
function renderOtherSessions(sessionHashes: string[]) {
return (
<div className="settings-item">
<h4 className="settings-item-header mb-4" dir={lang.isRtl ? 'rtl' : undefined}>
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('OtherSessions')}
</h4>
@ -189,7 +189,7 @@ const SettingsActiveSessions: FC<OwnProps & StateProps> = ({
function renderAutoTerminate() {
return (
<div className="settings-item">
<h4 className="settings-item-header mb-4" dir={lang.isRtl ? 'rtl' : undefined}>
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('TerminateOldSessionHeader')}
</h4>

View File

@ -81,7 +81,7 @@ const SettingsActiveWebsites: FC<OwnProps & StateProps> = ({
function renderSessions(sessionHashes: string[]) {
return (
<div className="settings-item">
<h4 className="settings-item-header mb-4" dir={lang.isRtl ? 'rtl' : undefined}>
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('WebSessionsTitle')}
</h4>

View File

@ -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;
}

View File

@ -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<OwnProps & StateProps> = ({
const lang = useOldLang();
const language = lang.code || 'en';
const [displayedOptions, setDisplayedOptions] = useState<IRadioOption[]>([]);
const [search, setSearch] = useState('');
const [displayedOptions, setDisplayedOptions] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState<string>('');
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<OwnProps & StateProps> = ({
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<OwnProps & StateProps> = ({
});
return (
<div className={buildClassName(styles.root, 'settings-content custom-scroll')}>
<div className={buildClassName(styles.item, 'settings-item')}>
<InputText
key="search"
value={search}
onChange={handleSearch}
placeholder={lang('Search')}
teactExperimentControlled
<div className={buildClassName(styles.root, 'settings-content infinite-scroll')}>
<div className={buildClassName(styles.item)}>
<ItemPicker
className={styles.picker}
items={displayedOptionList}
selectedValues={displayedOptions}
onSelectedValuesChange={handleChange}
filterValue={searchQuery}
onFilterChange={setSearchQuery}
isSearchable
allowMultiple
withDefaultPadding
itemInputType="checkbox"
searchInputId="lang-picker-search"
/>
<div className={buildClassName(styles.languages, 'radio-group custom-scroll')}>
{filteredDisplayedOptions.map((option) => (
<Checkbox
className={styles.checkbox}
label={option.label}
subLabel={option.subLabel}
checked={doNotTranslate.includes(option.value)}
value={option.value}
key={option.value}
onChange={handleChange}
/>
))}
</div>
</div>
</div>
);

View File

@ -146,6 +146,7 @@ const SettingsGeneral: FC<OwnProps & StateProps> = ({
<ListItem
icon="photo"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.GeneralChatBackground)}
>

View File

@ -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<OwnProps & StateProps> = ({
});
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<OwnProps & StateProps> = ({
onCheck={handleShouldTranslateChange}
/>
<Checkbox
className="pb-2"
label={lang('ShowTranslateChatButton')}
checked={canTranslateChatsEnabled}
disabled={!isCurrentUserPremium}
@ -138,6 +147,7 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
/>
{(canTranslate || canTranslateChatsEnabled) && (
<ListItem
narrow
onClick={handleDoNotSelectOpen}
>
{lang('DoNotTranslate')}
@ -149,15 +159,17 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
</p>
</div>
)}
<div className="settings-item">
<h4 className="settings-item-header mb-4">{lang('Localization.InterfaceLanguage')}</h4>
<div className="settings-item settings-item-picker">
<h4 className="settings-item-header settings-picker-title">
{lang('Localization.InterfaceLanguage')}
</h4>
{options ? (
<RadioGroup
name="language-settings"
options={options}
selected={selectedLanguage}
loadingOption={isLoading ? selectedLanguage : undefined}
onChange={handleChange}
<ItemPicker
items={options}
selectedValue={selectedLanguage}
onSelectedValueChange={handleChange}
itemInputType="radio"
className="settings-picker"
/>
) : (
<Loading />
@ -167,19 +179,6 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
);
};
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<OwnProps>(
(global): StateProps => {
const {

View File

@ -95,6 +95,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
)}
<ListItem
icon="settings"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.General)}
>
@ -102,6 +103,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="animations"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Performance)}
>
@ -109,6 +111,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="unmute"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Notifications)}
>
@ -116,6 +119,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="data"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.DataStorage)}
>
@ -123,6 +127,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="lock"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Privacy)}
>
@ -130,6 +135,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="folder"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Folders)}
>
@ -137,6 +143,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="active-sessions"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.ActiveSessions)}
>
@ -145,6 +152,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="language"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Language)}
>
@ -153,6 +161,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="stickers"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.Stickers)}
>
@ -164,6 +173,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
<ListItem
leftElement={<StarIcon className="icon ListItem-main-icon" type="premium" size="big" />}
className="settings-main-menu-star"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openPremiumModal()}
>
@ -174,6 +184,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
<ListItem
leftElement={<StarIcon className="icon ListItem-main-icon" type="gold" size="big" />}
className="settings-main-menu-star"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openStarsBalanceModal({})}
>
@ -187,6 +198,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
<ListItem
icon="gift"
className="settings-main-menu-star"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openPremiumGiftingModal()}
>
@ -197,12 +209,14 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
<div className="settings-main-menu">
<ListItem
icon="ask-support"
narrow
onClick={openSupportDialog}
>
{lang('AskAQuestion')}
</ListItem>
<ListItem
icon="help"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openUrl({ url: FAQ_URL })}
>
@ -210,6 +224,7 @@ const SettingsMain: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="privacy-policy"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openUrl({ url: PRIVACY_URL })}
>

View File

@ -103,9 +103,11 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
}, [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<OwnProps & StateProps> = ({
<div className="settings-item pt-3">
<ListItem
icon="delete-user"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.PrivacyBlockedUsers)}
>
@ -176,6 +179,7 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
{webAuthCount > 0 && (
<ListItem
icon="web"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onScreenSelect(SettingsScreens.ActiveWebsites)}
>
@ -186,7 +190,7 @@ const SettingsPrivacy: FC<OwnProps & StateProps> = ({
</div>
<div className="settings-item">
<h4 className="settings-item-header mb-4" dir={lang.isRtl ? 'rtl' : undefined}>{lang('PrivacyTitle')}</h4>
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>{lang('PrivacyTitle')}</h4>
<ListItem
narrow

View File

@ -293,7 +293,7 @@ function PrivacySubsection({
</div>
{!isPremiumRequired && (primaryExceptionLists.shouldShowAllowed || primaryExceptionLists.shouldShowDenied) && (
<div className="settings-item">
<h4 className="settings-item-header mb-4" dir={lang.isRtl ? 'rtl' : undefined}>
<h4 className="settings-item-header" dir={lang.isRtl ? 'rtl' : undefined}>
{lang('PrivacyExceptions')}
</h4>
{primaryExceptionLists.shouldShowAllowed && (

View File

@ -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<OwnProps & StateProps> = ({
return (
<div className="NewChat-inner step-1">
<Picker
<PeerPicker
categories={withPremiumCategory ? PREMIUM_CATEGORY : undefined}
itemIds={displayedIds || []}
selectedIds={newSelectedContactIds}
@ -152,6 +152,10 @@ const SettingsPrivacyVisibilityExceptionList: FC<OwnProps & StateProps> = ({
onSelectedIdsChange={handleSelectedContactIdsChange}
onSelectedCategoriesChange={handleSelectedCategoriesChange}
onFilterChange={setSearchQuery}
allowMultiple
itemInputType="checkbox"
withDefaultPadding
withStatus
/>
<FloatingActionButton

View File

@ -58,6 +58,7 @@ const SettingsQuickReaction: FC<OwnProps & StateProps> = ({
options={options}
selected={selectedReaction}
onChange={handleChange}
withIcon
/>
</div>
);

View File

@ -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;
}

View File

@ -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<OwnProps & StateProps> = ({
},
});
}
setIsTouched(true);
});
useHistoryBack({
@ -141,7 +142,7 @@ const SettingsFoldersChatFilters: FC<OwnProps & StateProps> = ({
return (
<div className="Picker settings-folders-chat-list">
<Picker
<PeerPicker
categories={shouldHideChatTypes ? undefined : chatTypes}
itemIds={displayedIds}
selectedIds={selectedChatIds}
@ -151,7 +152,10 @@ const SettingsFoldersChatFilters: FC<OwnProps & StateProps> = ({
categoryPlaceholderKey="FilterChatTypes"
searchInputId="new-group-picker-search"
isSearchable
isRoundCheckbox
withDefaultPadding
withPeerTypes
allowMultiple
itemInputType="checkbox"
onSelectedIdsChange={handleSelectedIdsChange}
onSelectedCategoriesChange={handleSelectedChatTypesChange}
onFilterChange={handleFilterChange}

View File

@ -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<OwnProps & StateProps> = ({
{(!isExpanded && leftChatsCount > 0) && (
<ListItem
key="load-more"
className="settings-folders-list-item"
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={clickHandler}
icon="down"
>
<Icon name="down" className="down" />
{lang('FilterShowMoreChats', leftChatsCount, 'i')}
</ListItem>
)}
@ -315,8 +316,9 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>{lang('FilterInclude')}</h4>
<ListItem
className="settings-folders-list-item color-primary mb-0"
className="settings-folders-list-item color-primary"
icon="add"
narrow
onClick={onAddIncludedChats}
>
{lang('FilterAddChats')}
@ -331,8 +333,9 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
<h4 className="settings-item-header mb-3" dir={lang.isRtl ? 'rtl' : undefined}>{lang('FilterExclude')}</h4>
<ListItem
className="settings-folders-list-item color-primary mb-0"
className="settings-folders-list-item color-primary"
icon="add"
narrow
onClick={onAddExcludedChats}
>
{lang('FilterAddChats')}
@ -348,8 +351,9 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
</h4>
<ListItem
className="settings-folders-list-item color-primary mb-0"
className="settings-folders-list-item color-primary"
icon="add"
narrow
onClick={handleCreateInviteClick}
>
{lang('ChatListFilter.CreateLinkNew')}
@ -357,8 +361,9 @@ const SettingsFoldersEdit: FC<OwnProps & StateProps> = ({
{invites?.map((invite) => (
<ListItem
className="settings-folders-list-item mb-0"
className="settings-folders-list-item"
icon="link"
narrow
multiline
onClick={handleEditInviteClick}
clickArg={invite.url}

View File

@ -22,7 +22,7 @@ import useOldLang from '../../../../hooks/useOldLang';
import AnimatedIcon from '../../../common/AnimatedIcon';
import LinkField from '../../../common/LinkField';
import Picker from '../../../common/Picker';
import PeerPicker from '../../../common/pickers/PeerPicker';
import FloatingActionButton from '../../../ui/FloatingActionButton';
import Spinner from '../../../ui/Spinner';
@ -167,14 +167,16 @@ const SettingsShareChatlist: FC<OwnProps & StateProps> = ({
isDisabled={!chatsCount || isTouched}
/>
<div className="settings-item settings-item-chatlist">
<Picker
<div className="settings-item settings-item-picker">
<PeerPicker
itemIds={itemIds}
lockedSelectedIds={lockedIds}
lockedUnselectedIds={lockedIds}
onSelectedIdsChange={handleSelectedIdsChange}
selectedIds={selectedIds}
onDisabledClick={handleClickDisabled}
isRoundCheckbox
allowMultiple
withStatus
itemInputType="checkbox"
/>
</div>

View File

@ -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<OwnProps> = (props) => {
const { isOpen } = props;
const AppendEntityPickerModal = useModuleLoader(Bundles.Extra, 'AppendEntityPickerModal', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return AppendEntityPickerModal ? <AppendEntityPickerModal {...props} /> : undefined;
};
export default AppendEntityPickerModalAsync;

View File

@ -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;
}

View File

@ -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<string, ApiChatMember>;
userStatusesById: Record<string, ApiUserStatus>;
channelList?: (ApiChat | undefined)[] | undefined;
isChannel?: boolean;
isSuperGroup?: boolean;
currentUserId?: string | undefined;
}
const AppendEntityPickerModal: FC<OwnProps & StateProps> = ({
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<string[]>([]);
const [pendingChannelId, setPendingChannelId] = useState<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<string>('');
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 (
<div className={styles.filter} dir={lang.isRtl ? 'rtl' : undefined}>
<Button
round
size="smaller"
color="translucent"
// eslint-disable-next-line react/jsx-no-bind
onClick={handleCloseButtonClick}
ariaLabel={lang('Close')}
>
<Icon name="close" />
</Button>
<h3 className={styles.title}>{lang(entityType === 'channels'
? 'RequestPeer.ChooseChannelTitle' : 'BoostingAwardSpecificUsers')}
</h3>
</div>
);
}
return (
<Modal
className={styles.root}
isOpen={isOpen}
onClose={handleClose}
onEnter={handleSendIdList}
>
<div className={styles.main}>
{renderSearchField()}
<div className={buildClassName(styles.main, 'custom-scroll')}>
<Picker
className={styles.picker}
itemIds={entityType === 'members' ? displayedMembersIds : displayedChannelIds}
selectedIds={selectedIds}
filterValue={searchQuery}
filterPlaceholder={lang('Search')}
searchInputId={`${entityType}-picker-search`}
onSelectedIdsChange={entityType === 'channels'
? handleSelectedChannelIdsChange : handleSelectedMemberIdsChange}
onFilterChange={setSearchQuery}
isSearchable
/>
</div>
<div className={styles.buttons}>
<Button size="smaller" onClick={handleSendIdList}>
{lang('Save')}
</Button>
</div>
</div>
<ConfirmDialog
title={lang('BoostingGiveawayPrivateChannel')}
text={lang('BoostingGiveawayPrivateChannelWarning')}
confirmLabel={lang('Add')}
isOpen={isConfirmModalOpen}
onClose={closeConfirmModal}
confirmHandler={confirmPrivateLinkChannelSelection}
/>
</Modal>
);
};
export default memo(withGlobal<OwnProps>((global, { chatId, entityType }): StateProps => {
const { statusesById: userStatusesById } = global.users;
let isChannel;
let isSuperGroup;
let members: ApiChatMember[] | undefined;
let adminMembersById: Record<string, ApiChatMember> | 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));

View File

@ -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<string | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<string>('');
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag();
const [selectedIds, setSelectedIds] = useState<string[]>(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 (
<PickerModal
isOpen={isOpen}
onClose={onClose}
title={lang('RequestPeer.ChooseChannelTitle')}
hasCloseButton
shouldAdaptToSearch
withFixedHeight
confirmButtonText={lang('Save')}
onConfirm={handleModalConfirm}
onEnter={handleModalConfirm}
>
<PeerPicker
itemIds={displayedChannelIds}
selectedIds={selectedIds}
filterValue={searchQuery}
filterPlaceholder={lang('Search')}
onSelectedIdsChange={handleSelectedChannelIdsChange}
onFilterChange={setSearchQuery}
isSearchable
withDefaultPadding
withStatus
allowMultiple
itemInputType="checkbox"
/>
<ConfirmDialog
title={lang('BoostingGiveawayPrivateChannel')}
text={lang('BoostingGiveawayPrivateChannelWarning')}
confirmLabel={lang('Add')}
isOpen={isConfirmModalOpen}
onClose={closeConfirmModal}
confirmHandler={confirmPrivateLinkChannelSelection}
/>
</PickerModal>
);
};
export default memo(GiveawayChannelPickerModal);

View File

@ -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%;

View File

@ -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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
actions: 'createSpecificUsers',
isLink: true,
onClickAction: () => {
openEntityPickerModal();
setEntityType('members');
openUserPickerModal();
},
}];
const [customExpireDate, setCustomExpireDate] = useState<number>(Date.now() + DEFAULT_CUSTOM_EXPIRE_DATE);
const [isHeaderHidden, setHeaderHidden] = useState(true);
const [selectedUserCount, setSelectedUserCount] = useState<number>(DEFAULT_BOOST_COUNT);
const [selectedRandomUserCount, setSelectedRandomUserCount] = useState<number>(DEFAULT_BOOST_COUNT);
const [selectedGiveawayOption, setGiveawayOption] = useState<ApiGiveawayType>(TYPE_OPTIONS[0].value);
const [selectedSubscriberOption, setSelectedSubscriberOption] = useState<SubscribersType>('all');
const [selectedMonthOption, setSelectedMonthOption] = useState<number | undefined>();
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>([]);
const [selectedCountriesIds, setSelectedCountriesIds] = useState<string[] | undefined>([]);
const [selectedCountryIds, setSelectedCountryIds] = useState<string[] | undefined>([]);
const [shouldShowWinners, setShouldShowWinners] = useState<boolean>(false);
const [shouldShowPrizes, setShouldShowPrizes] = useState<boolean>(false);
const [prizeDescription, setPrizeDescription] = useState<string | undefined>(undefined);
const [dataPrepaidGiveaway, setDataPrepaidGiveaway] = useState<ApiPrepaidGiveaway | undefined>(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<OwnProps & StateProps> = ({
}, [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<OwnProps & StateProps> = ({
useEffect(() => {
if (prepaidGiveaway) {
setSelectedUserCount(prepaidGiveaway.quantity);
setSelectedRandomUserCount(prepaidGiveaway.quantity);
setDataPrepaidGiveaway(prepaidGiveaway);
}
}, [prepaidGiveaway]);
@ -235,25 +236,25 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
});
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<OwnProps & StateProps> = ({
giveawayId: dataPrepaidGiveaway!.id,
paymentPurpose: {
additionalChannelIds: selectedChannelIds,
countries: selectedCountriesIds,
countries: selectedCountryIds,
prizeDescription,
areWinnersVisible: shouldShowWinners,
untilDate: customExpireDate / 1000,
@ -282,8 +283,8 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
closeGiveawayModal();
});
const handleUserCountChange = useLastCallback((newValue) => {
setSelectedUserCount(newValue);
const handleRandomUserCountChange = useLastCallback((newValue) => {
setSelectedRandomUserCount(newValue);
});
const handlePrizeDescriptionChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
@ -295,11 +296,6 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
return selectedUserIds?.map((userId) => getUserFullName(usersById[userId])).join(', ');
}, [selectedUserIds]);
const handleAdd = useLastCallback(() => {
openEntityPickerModal();
setEntityType('channels');
});
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const { scrollTop } = e.currentTarget;
@ -321,13 +317,18 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
});
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<OwnProps & StateProps> = ({
<RangeSliderWithMarks
rangeCount={selectedUserCount}
marks={userCountOptions}
onChange={handleUserCountChange}
onChange={handleRandomUserCountChange}
/>
</div>
@ -537,7 +538,7 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
className="chat-item-clickable contact-list-item"
/* eslint-disable-next-line react/jsx-no-bind */
onClick={() => deleteParticipantsHandler(channelId)}
rightElement={(<Icon name="close" />)}
rightElement={(<Icon name="close" className={styles.removeChannel} />)}
>
<GroupChatInfo
chatId={channelId.toString()}
@ -550,7 +551,7 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
<ListItem
icon="add"
ripple
onClick={handleAdd}
onClick={openChannelPickerModal}
className={styles.addButton}
iconClassName={styles.addChannel}
>
@ -705,14 +706,21 @@ const GiveawayModal: FC<OwnProps & StateProps> = ({
onSubmit={handleSetCountriesListChange}
selectionLimit={countrySelectionLimit}
/>
<AppendEntityPickerModal
key={entityType}
isOpen={isEntityPickerModalOpen}
onClose={closeEntityPickerModal}
entityType={entityType}
chatId={chatId}
onSubmit={handleSetIdsListChange}
selectionLimit={entityType === 'members' ? userSelectionLimit : GIVEAWAY_MAX_ADDITIONAL_CHANNELS}
<GiveawayUserPickerModal
isOpen={isUserPickerModalOpen}
onClose={closeUserPickerModal}
onSelectedIdsConfirmed={handleSelectedUserIdsChange}
initialSelectedIds={selectedUserIds}
selectionLimit={userSelectionLimit}
giveawayChatId={chatId}
/>
<GiveawayChannelPickerModal
isOpen={isChannelPickerModalOpen}
onClose={closeChannelPickerModal}
initialSelectedIds={selectedChannelIds}
onSelectedIdsConfirmed={handleSelectedChannelIdsChange}
selectionLimit={GIVEAWAY_MAX_ADDITIONAL_CHANNELS}
giveawayChatId={chatId}
/>
<ConfirmDialog
title={lang('BoostingStartGiveawayConfirmTitle')}

View File

@ -1,7 +1,7 @@
.wrapper {
position: relative;
display: block;
padding-inline: 3.5rem 1rem;
padding-inline: 3.8125rem 1rem;
border: none;
margin-bottom: 0;
@ -42,7 +42,7 @@
content: "";
display: block;
position: absolute;
inset-inline-start: 1.0625rem;
inset-inline-start: 1rem;
top: 50%;
width: 1.25rem;
height: 1.25rem;
@ -58,7 +58,7 @@
}
&::after {
inset-inline-start: 1.375rem;
inset-inline-start: 1.3125rem;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;

View File

@ -0,0 +1,143 @@
import React, {
memo, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global';
import type { ApiChatMember } from '../../../api/types';
import {
filterUsersByName,
isUserBot,
sortUserIds,
} from '../../../global/helpers';
import { selectChatFullInfo } from '../../../global/selectors';
import { unique } from '../../../util/iteratees';
import sortChatIds from '../../common/helpers/sortChatIds';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import PeerPicker from '../../common/pickers/PeerPicker';
import PickerModal from '../../common/pickers/PickerModal';
type OwnProps = {
isOpen?: boolean;
// eslint-disable-next-line react/no-unused-prop-types
giveawayChatId?: string;
selectionLimit: number;
initialSelectedIds: string[];
onSelectedIdsConfirmed: (newSelectedIds: string[]) => void;
onClose: NoneToVoidFunction;
};
type StateProps = {
members?: ApiChatMember[];
adminMembersById?: Record<string, ApiChatMember>;
};
const GiveawayUserPickerModal = ({
isOpen,
selectionLimit,
members,
adminMembersById,
initialSelectedIds,
onSelectedIdsConfirmed,
onClose,
}: OwnProps & StateProps) => {
const { showNotification } = getActions();
const lang = useOldLang();
const [searchQuery, setSearchQuery] = useState<string>('');
const [selectedIds, setSelectedIds] = useState<string[]>(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 (
<PickerModal
isOpen={isOpen}
onClose={onClose}
title={lang('BoostingAwardSpecificUsers')}
hasCloseButton
shouldAdaptToSearch
withFixedHeight
confirmButtonText={lang('Save')}
onConfirm={handleModalConfirm}
onEnter={handleModalConfirm}
>
<PeerPicker
itemIds={displayedMemberIds}
selectedIds={selectedIds}
filterValue={searchQuery}
filterPlaceholder={lang('Search')}
onSelectedIdsChange={handleSelectedMemberIdsChange}
onFilterChange={setSearchQuery}
isSearchable
withDefaultPadding
withStatus
allowMultiple
itemInputType="checkbox"
/>
</PickerModal>
);
};
export default memo(withGlobal<OwnProps>((global, { giveawayChatId }): StateProps => {
const chatFullInfo = giveawayChatId ? selectChatFullInfo(global, giveawayChatId) : undefined;
if (!chatFullInfo) {
return {};
}
return {
members: chatFullInfo.members,
adminMembersById: chatFullInfo.adminMembersById,
};
})(GiveawayUserPickerModal));

View File

@ -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<OwnProps & StateProps> = ({
<div className={styles.main}>
{renderSearchField()}
<div className={buildClassName(styles.main, 'custom-scroll')}>
<Picker
<PeerPicker
className={styles.picker}
itemIds={displayedUserIds}
selectedIds={selectedUserIds}
@ -116,6 +116,10 @@ const PremiumGiftingModal: FC<OwnProps & StateProps> = ({
onSelectedIdsChange={handleSelectedUserIdsChange}
onFilterChange={setSearchQuery}
isSearchable
withDefaultPadding
withStatus
allowMultiple
itemInputType="checkbox"
/>
</div>
<div className={styles.buttons}>

View File

@ -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%;

View File

@ -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 {

View File

@ -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';

View File

@ -298,7 +298,6 @@ const Poll: FC<OwnProps & StateProps> = ({
onChange={handleCheckboxChange}
disabled={message.isScheduled || isSubmitting}
loadingOptions={isSubmitting ? chosenOptions : undefined}
round
/>
)
: (

View File

@ -219,6 +219,8 @@
color: var(--color-text-secondary);
background-color: var(--color-item-active);
font-weight: 500;
margin-bottom: 0;
}
.selectedType {

View File

@ -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';

View File

@ -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<OwnProps> = ({ invite, folder }) => {
{selectedPeerIds.length === invite.missingPeerIds.length ? lang('DeselectAll') : lang('SelectAll')}
</div>
</div>
<Picker
<PeerPicker
itemIds={invite.missingPeerIds}
onSelectedIdsChange={setSelectedPeerIds}
selectedIds={selectedPeerIds}
allowMultiple
withStatus
itemInputType="checkbox"
/>
</>
)}
@ -84,10 +87,13 @@ const ChatlistAlready: FC<OwnProps> = ({ invite, folder }) => {
{lang('FolderLinkHeaderAlready')}
</div>
</div>
<Picker
<PeerPicker
itemIds={invite.alreadyPeerIds}
lockedSelectedIds={invite.alreadyPeerIds}
selectedIds={invite.alreadyPeerIds}
allowMultiple
withStatus
itemInputType="checkbox"
/>
</div>
<Button

View File

@ -10,7 +10,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';
@ -64,10 +64,13 @@ const ChatlistDelete: FC<OwnProps> = ({
{selectedPeerIds.length === suggestedPeerIds.length ? lang('DeselectAll') : lang('SelectAll')}
</div>
</div>
<Picker
<PeerPicker
itemIds={suggestedPeerIds}
onSelectedIdsChange={setSelectedPeerIds}
selectedIds={selectedPeerIds}
allowMultiple
withStatus
itemInputType="checkbox"
/>
</div>
</>

View File

@ -11,7 +11,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';
@ -69,11 +69,14 @@ const ChatlistNew: FC<OwnProps> = ({ invite }) => {
{selectedPeerIds.length === invite.peerIds.length ? lang('DeselectAll') : lang('SelectAll')}
</div>
</div>
<Picker
<PeerPicker
itemIds={invite.peerIds}
lockedSelectedIds={joinedIds}
onSelectedIdsChange={setSelectedPeerIds}
selectedIds={selectedPeerIds}
allowMultiple
withStatus
itemInputType="checkbox"
/>
</div>
<Button

View File

@ -21,7 +21,7 @@ import useOldLang from '../../../hooks/useOldLang';
import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview';
import Icon from '../../common/icons/Icon';
import PickerSelectedItem from '../../common/PickerSelectedItem';
import PickerSelectedItem from '../../common/pickers/PickerSelectedItem';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';

View File

@ -9,7 +9,7 @@ import buildClassName from '../../../util/buildClassName';
import useLastCallback from '../../../hooks/useLastCallback';
import Avatar from '../../common/Avatar';
import PickerSelectedItem from '../../common/PickerSelectedItem';
import PickerSelectedItem from '../../common/pickers/PickerSelectedItem';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';

View File

@ -12,13 +12,14 @@ import type { TabState } from '../../../global/types';
import { getUserFullName } from '../../../global/helpers';
import { selectChat } from '../../../global/selectors';
import { partition } from '../../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import renderText from '../../common/helpers/renderText';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import AvatarList from '../../common/AvatarList';
import Picker from '../../common/Picker';
import PeerPicker from '../../common/pickers/PeerPicker';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
import Separator from '../../ui/Separator';
@ -43,7 +44,7 @@ const InviteViaLinkModal: FC<OwnProps & StateProps> = ({
const lang = useOldLang();
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
const userIds = useMemo(() => missingUsers?.map((user) => user.id), [missingUsers]);
const userIds = useMemo(() => missingUsers?.map((user) => user.id) || MEMO_EMPTY_ARRAY, [missingUsers]);
const [unselectableIds, selectableIds] = useMemo(() => {
if (!missingUsers?.length) return [[], []];
const [requirePremiumIds, regularIds] = partition(missingUsers, (user) => user.isRequiringPremiumToMessage);
@ -114,8 +115,6 @@ const InviteViaLinkModal: FC<OwnProps & StateProps> = ({
return lang(langKey, params, undefined, topListPeers.length);
}, [invitableWithPremiumIds, isEveryPremiumBlocksPm, lang, topListPeers]);
if (!userIds) return undefined;
const hasPremiumSection = Boolean(topListPeers?.length);
const hasSelectableSection = Boolean(selectableIds?.length);
@ -170,15 +169,17 @@ const InviteViaLinkModal: FC<OwnProps & StateProps> = ({
<p className={styles.contentText}>
{inviteSectionText}
</p>
<Picker
<PeerPicker
className={styles.userPicker}
itemIds={userIds!}
itemIds={userIds}
selectedIds={selectedMemberIds}
lockedUnselectedIds={unselectableIds}
lockedUnselectedSubtitle={lang('InvitePremiumBlockedUser')}
onSelectedIdsChange={setSelectedMemberIds}
isViewOnly={!canSendInviteLink}
isRoundCheckbox
allowMultiple
withStatus
itemInputType="checkbox"
/>
{canSendInviteLink && (
<Button

View File

@ -20,7 +20,7 @@ import useHistoryBack from '../../hooks/useHistoryBack';
import useOldLang from '../../hooks/useOldLang';
import usePrevious from '../../hooks/usePrevious';
import Picker from '../common/Picker';
import PeerPicker from '../common/pickers/PeerPicker';
import FloatingActionButton from '../ui/FloatingActionButton';
import Spinner from '../ui/Spinner';
@ -116,7 +116,7 @@ const AddChatMembers: FC<OwnProps & StateProps> = ({
return (
<div className="AddChatMembers">
<div className="AddChatMembers-inner">
<Picker
<PeerPicker
itemIds={displayedIds}
selectedIds={selectedMemberIds}
filterValue={searchQuery}
@ -126,7 +126,11 @@ const AddChatMembers: FC<OwnProps & StateProps> = ({
onSelectedIdsChange={setSelectedMemberIds}
onFilterChange={handleFilterChange}
isSearchable
withDefaultPadding
noScrollRestore={noPickerScrollRestore}
allowMultiple
withStatus
itemInputType="checkbox"
/>
<FloatingActionButton

View File

@ -133,10 +133,6 @@
@media (max-width: 600px) {
padding: 0.5rem 0;
.ListItem.chat-item-clickable {
margin: 0;
}
}
}

View File

@ -241,6 +241,7 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
{title}
</div>
)}
withIcon
onChange={handleReactionChange}
/>
</div>

View File

@ -16,7 +16,7 @@
}
.section {
padding: 1rem 1.5rem;
padding: 1rem 0.5rem;
@include mixins.side-panel-section;
@ -45,8 +45,6 @@
}
.ListItem {
margin: 0 -0.75rem;
.Reaction {
display: flex;
align-items: center;
@ -54,11 +52,7 @@
.ReactionStaticEmoji {
width: 1.5rem;
margin-right: 1rem;
}
&:last-child {
margin-bottom: 0;
margin-right: 1.6875rem;
}
.multiline-item .subtitle {
@ -75,11 +69,6 @@
}
}
&:not(.picker-list-item) .Checkbox {
margin-top: 2rem;
margin-bottom: 2rem;
}
&.narrow {
.Checkbox {
margin-top: 1rem;
@ -198,10 +187,6 @@
}
}
.Checkbox {
padding-left: 3.875rem;
}
.Spinner {
margin: 2rem auto;
}

View File

@ -12,7 +12,7 @@ import { selectChatFullInfo } from '../../../global/selectors';
import useOldLang from '../../../hooks/useOldLang';
import ChatOrUserPicker from '../../common/ChatOrUserPicker';
import ChatOrUserPicker from '../../common/pickers/ChatOrUserPicker';
export type OwnProps = {
chat: ApiChat;

View File

@ -8,7 +8,7 @@ import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import useOldLang from '../../../hooks/useOldLang';
import Picker from '../../common/Picker';
import PeerPicker from '../../common/pickers/PeerPicker';
interface OwnProps {
id: string;
@ -38,7 +38,7 @@ function AllowDenyList({
}, [contactListIds, currentUserId, searchQuery, selectedIds, usersById]);
return (
<Picker
<PeerPicker
key={id}
itemIds={displayedIds}
selectedIds={selectedIds ?? MEMO_EMPTY_ARRAY}
@ -47,9 +47,13 @@ function AllowDenyList({
filterPlaceholder={lang('Search')}
searchInputId={`${id}-picker-search`}
isSearchable
withDefaultPadding
forceShowSelf
onSelectedIdsChange={onSelect}
onFilterChange={setSearchQuery}
allowMultiple
withStatus
itemInputType="checkbox"
/>
);
}

View File

@ -13,7 +13,7 @@ import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import Picker from '../../common/Picker';
import PeerPicker from '../../common/pickers/PeerPicker';
import FloatingActionButton from '../../ui/FloatingActionButton';
import styles from './CloseFriends.module.scss';
@ -64,15 +64,19 @@ function CloseFriends({
return (
<>
<Picker
<PeerPicker
itemIds={displayedIds || []}
selectedIds={newSelectedContactIds}
filterValue={searchQuery}
filterPlaceholder={lang('Search')}
searchInputId="close-friends-picker-search"
isSearchable
withDefaultPadding
onSelectedIdsChange={handleSelectedContactIdsChange}
onFilterChange={setSearchQuery}
allowMultiple
withStatus
itemInputType="checkbox"
/>
<div className={buildClassName(styles.buttonHolder, isSubmitShown && styles.active)}>

View File

@ -1,7 +1,7 @@
.Checkbox {
display: block;
position: relative;
padding-left: 4.5rem;
padding-inline-start: 3.5rem;
text-align: left;
margin-bottom: 1.5rem;
line-height: 1.5rem;
@ -12,19 +12,13 @@
opacity: 0.5;
}
&.round {
&.withIcon {
padding-inline-start: 1rem;
.Checkbox-main {
&::before {
border-radius: 50%;
}
&::before,
&::after {
background:
var(--color-primary)
url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjkuOEw1LjggOC45IDIuMSA1LjJjLS40LS40LTEuMS0uNC0xLjYgMC0uNC40LS40IDEuMSAwIDEuNkw1IDExLjJjLjQuNCAxLjEuNCAxLjYgMGw4LjktOC45Yy40LS40LjQtMS4xIDAtMS42LS41LS40LTEuMi0uNC0xLjYuMXoiIGZpbGw9IiNGRkYiLz48L3N2Zz4=)
no-repeat 50% 50%;
background-size: 0.75rem;
border-radius: 50%;
left: auto;
right: 1.1875rem;
}
}
}
@ -75,7 +69,7 @@
content: "";
display: block;
position: absolute;
left: 0.6875rem;
left: 1rem;
top: 0.1875rem;
width: 1.125rem;
height: 1.125rem;
@ -120,6 +114,16 @@
}
}
&.withSubLabel {
.Checkbox-main {
&::before,
&::after {
top: 0.875rem;
left: 1rem;
}
}
}
.Nested-avatar-list {
&::before,
&::after {
@ -150,9 +154,6 @@
}
&[dir="rtl"] {
padding-left: 0;
padding-right: 4.5rem;
&.loading {
.Spinner {
left: auto;
@ -169,7 +170,7 @@
&::before,
&::after {
left: auto;
right: 1.1875rem;
right: 1rem;
}
}
}
@ -180,28 +181,28 @@
align-items: center;
gap: 0.3125rem;
margin-bottom: 0.5625rem;
padding-left: 4.1875rem;
padding-inline-start: 4.1875rem;
.Checkbox-main {
&::before,
&::after {
top: 0.875rem;
left: 0.75rem;
left: 1.375rem;
}
}
}
&.permission-group {
padding-left: 4rem;
padding-inline-start: 3.625rem;
.Checkbox-main {
&::before,
&::after {
left: 0.75rem;
left: 1rem;
}
}
}
&.avatar {
padding-left: 6.625rem;
padding-inline-start: 6.625rem;
margin-bottom: 1.125rem;
}
@ -220,6 +221,18 @@
top: 1.875rem;
left: 2.875rem;
}
&.onlyInput {
padding-inline-start: 1.125rem;
.Checkbox-main {
&::before,
&::after {
left: 0;
right: 0;
}
}
}
}
.nested-checkbox-group {

View File

@ -25,18 +25,19 @@ type OwnProps = {
id?: string;
name?: string;
value?: string;
label: TeactNode;
label?: TeactNode;
labelText?: TeactNode;
subLabel?: string;
checked?: boolean;
rightIcon?: IconName;
disabled?: boolean;
tabIndex?: number;
round?: boolean;
withIcon?: boolean;
blocking?: boolean;
permissionGroup?: boolean;
isLoading?: boolean;
withCheckedCallback?: boolean;
onlyInput?: boolean;
className?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>, nestedOptionList?: IRadioOption) => void;
onCheck?: (isChecked: boolean) => void;
@ -58,12 +59,13 @@ const Checkbox: FC<OwnProps> = ({
checked,
tabIndex,
disabled,
round,
withIcon,
blocking,
permissionGroup,
isLoading,
className,
rightIcon,
onlyInput,
onChange,
onCheck,
onClickLabel,
@ -109,12 +111,14 @@ const Checkbox: FC<OwnProps> = ({
const labelClassName = buildClassName(
'Checkbox',
disabled && 'disabled',
round && 'round',
withIcon && 'withIcon',
isLoading && 'loading',
blocking && 'blocking',
nestedCheckbox && 'nested',
subLabel && 'withSubLabel',
permissionGroup && 'permission-group',
Boolean(leftElement) && 'avatar',
onlyInput && 'onlyInput',
className,
);

View File

@ -22,7 +22,6 @@ type OwnProps = {
options: IRadioOption[];
selected?: string[];
disabled?: boolean;
round?: boolean;
nestedCheckbox?: boolean;
loadingOptions?: string[];
onChange: (value: string[]) => void;
@ -33,7 +32,6 @@ const CheckboxGroup: FC<OwnProps> = ({
options,
selected = [],
disabled,
round,
nestedCheckbox,
loadingOptions,
onChange,
@ -89,7 +87,6 @@ const CheckboxGroup: FC<OwnProps> = ({
value={option.value}
checked={selected.indexOf(option.value) !== -1}
disabled={option.disabled || disabled}
round={round}
isLoading={loadingOptions ? loadingOptions.indexOf(option.value) !== -1 : undefined}
onChange={handleChange}
nestedCheckbox={nestedCheckbox}

View File

@ -62,7 +62,7 @@
outline: none !important;
display: flex;
align-items: center;
padding: 1rem;
padding: 1rem 0.8125rem;
position: relative;
overflow: hidden;
white-space: nowrap;
@ -82,7 +82,6 @@
> .ListItem-main-icon {
font-size: 1.5rem;
margin-inline-start: 0.125rem;
margin-inline-end: 1.25rem;
color: var(--color-text-secondary);
}
@ -180,6 +179,10 @@
.ListItem-button {
padding: 0.5rem 1rem;
}
.ListItem-main-icon {
margin-inline-end: 1.75rem;
}
}
&.inactive {
@ -399,6 +402,10 @@
padding-left: 4rem;
}
.withSubLabel {
height: 2.5rem;
}
&[dir="rtl"] {
.Checkbox {
padding-left: 0;

View File

@ -0,0 +1,86 @@
import type { FC, TeactNode } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact';
import type { CustomPeer } from '../../types';
import buildClassName from '../../util/buildClassName';
import Checkbox from './Checkbox';
import ListItem from './ListItem';
import Radio from './Radio';
type OwnProps = {
key: string;
isChecked?: boolean;
disabled?: boolean;
inactive?: boolean;
isChatItem?: boolean;
ripple?: boolean;
shouldRenderLockIcon?: boolean;
category?: CustomPeer;
handleItemClick: (id: string) => void;
renderCategory?: (category: CustomPeer) => TeactNode;
renderChatInfo?: (id: string) => TeactNode;
allowDisabledClick?: boolean;
label?: TeactNode;
subLabel?: string;
type?: 'checkbox' | 'radio';
};
const ListItemWithOptions: FC<OwnProps> = ({
key,
isChecked,
disabled,
inactive,
isChatItem,
shouldRenderLockIcon,
category,
handleItemClick,
ripple,
renderCategory,
renderChatInfo,
allowDisabledClick,
label,
subLabel,
type,
}) => {
function renderInput() {
if (inactive || disabled) {
return undefined;
}
return type === 'checkbox' ? (
<Checkbox
label={label || ''}
subLabel={subLabel}
disabled={disabled}
checked={isChecked}
/>
) : (
<Radio
label=""
disabled={disabled}
checked={isChecked}
/>
);
}
return (
<ListItem
key={key}
className={buildClassName('chat-item-clickable picker-list-item', isChatItem && 'chat-item')}
disabled={disabled}
inactive={inactive}
allowDisabledClick={allowDisabledClick}
secondaryIcon={shouldRenderLockIcon ? 'lock-badge' : undefined}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleItemClick(key)}
ripple={ripple}
>
{!isChatItem ? renderInput() : undefined}
{category ? renderCategory?.(category) : renderChatInfo?.(key)}
{isChatItem ? renderInput() : undefined}
</ListItem>
);
};
export default memo(ListItemWithOptions);

View File

@ -24,10 +24,11 @@ import './Modal.scss';
export const ANIMATION_DURATION = 200;
type OwnProps = {
export type OwnProps = {
title?: string | TextPart[];
className?: string;
contentClassName?: string;
headerClassName?: string;
isOpen?: boolean;
header?: TeactNode;
isSlim?: boolean;
@ -37,10 +38,10 @@ type OwnProps = {
noBackdropClose?: boolean;
children: React.ReactNode;
style?: string;
dialogRef?: React.RefObject<HTMLDivElement>;
onClose: () => void;
onCloseAnimationEnd?: () => void;
onEnter?: () => void;
dialogRef?: React.RefObject<HTMLDivElement>;
};
type StateProps = {
@ -52,6 +53,7 @@ const Modal: FC<OwnProps & StateProps> = ({
title,
className,
contentClassName,
headerClassName,
isOpen,
isSlim,
header,
@ -61,10 +63,10 @@ const Modal: FC<OwnProps & StateProps> = ({
noBackdropClose,
children,
style,
shouldSkipHistoryAnimations,
onClose,
onCloseAnimationEnd,
onEnter,
shouldSkipHistoryAnimations,
}) => {
const {
shouldRender,
@ -132,7 +134,7 @@ const Modal: FC<OwnProps & StateProps> = ({
if (!title && !withCloseButton) return undefined;
return (
<div className="modal-header">
<div className={buildClassName('modal-header', headerClassName)}>
{withCloseButton && (
<Button
className={buildClassName(hasAbsoluteCloseButton && 'modal-absolute-close-button')}

View File

@ -1,7 +1,7 @@
.Radio {
display: block;
position: relative;
padding-left: 4.5rem;
padding-inline-start: 3.5rem;
text-align: left;
margin-bottom: 1.5rem;
line-height: 1.5rem;
@ -12,6 +12,21 @@
opacity: 0.5;
}
&.with-icon {
padding-inline-start: 1rem;
.Radio-main {
&::before,
&::after {
left: auto;
right: 1.1875rem;
}
&::after {
right: 1.5rem;
}
}
}
&.hidden-widget {
cursor: var(--custom-cursor, default);
.Radio-main {
@ -34,7 +49,7 @@
content: "";
display: block;
position: absolute;
left: 1.0625rem;
left: 1rem;
top: 50%;
width: 1.25rem;
height: 1.25rem;
@ -50,7 +65,7 @@
}
&::after {
left: 1.375rem;
left: 1.3125rem;
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
@ -97,7 +112,7 @@
.Spinner {
position: absolute;
left: 1.0625rem;
left: 1rem;
top: 50%;
transform: translateY(-50%);
opacity: 0;
@ -107,9 +122,6 @@
}
&[dir="rtl"] {
padding-left: 0;
padding-right: 4.5rem;
.Radio-main {
text-align: right;
@ -133,6 +145,37 @@
left: auto;
right: 1.0625rem;
}
&.onlyInput .Radio-main::after {
right: 0.3125rem;
left: auto;
}
}
&.onlyInput {
margin-bottom: 1.25rem;
line-height: 1.25rem;
padding-inline-start: 1.25rem;
.Radio-main {
&::before {
left: 0;
right: 0;
top: 0;
transform: none;
}
&::after {
left: 0.3125rem;
top: 0;
transform: translateY(50%);
}
}
.Spinner {
left: 0;
top: 0;
transform: none;
}
}
}

View File

@ -12,18 +12,20 @@ import './Radio.scss';
type OwnProps = {
id?: string;
name: string;
label: TeactNode;
name?: string;
label?: TeactNode;
subLabel?: TeactNode;
value: string;
checked: boolean;
value?: string;
checked?: boolean;
disabled?: boolean;
isLink?: boolean;
hidden?: boolean;
isLoading?: boolean;
withIcon?: boolean;
className?: string;
onlyInput?: boolean;
subLabelClassName?: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
onSubLabelClick?: MouseEventHandler<HTMLSpanElement> | undefined;
};
@ -39,8 +41,10 @@ const Radio: FC<OwnProps> = ({
hidden,
isLoading,
className,
onChange,
onlyInput,
withIcon,
isLink,
onChange,
onSubLabelClick,
}) => {
const lang = useOldLang();
@ -49,7 +53,9 @@ const Radio: FC<OwnProps> = ({
className,
disabled && 'disabled',
hidden && 'hidden-widget',
withIcon && 'with-icon',
isLoading && 'loading',
onlyInput && 'onlyInput',
);
return (

View File

@ -24,6 +24,7 @@ type OwnProps = {
onChange: (value: string, event: ChangeEvent<HTMLInputElement>) => void;
onClickAction?: (value: string) => void;
isLink?: boolean;
withIcon?: boolean;
subLabelClassName?: string;
subLabel?: TeactNode;
};
@ -39,6 +40,7 @@ const RadioGroup: FC<OwnProps> = ({
onClickAction,
subLabelClassName,
isLink,
withIcon,
subLabel,
}) => {
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
@ -62,6 +64,7 @@ const RadioGroup: FC<OwnProps> = ({
checked={option.value === selected}
hidden={option.hidden}
disabled={disabled}
withIcon={withIcon}
isLoading={loadingOption ? loadingOption === option.value : undefined}
className={option.className}
onChange={handleChange}

View File

@ -39,7 +39,7 @@
.mark {
font-size: 0.8125rem;
font-weight: 700;
font-weight: 600;
color: var(--color-text-secondary);
position: relative;
display: flex;

View File

@ -1123,12 +1123,12 @@ export function deleteMessages<T extends GlobalState>(
// Common box update
const chatsIdsToUpdate: string[] = [];
const chatIdsToUpdate: string[] = [];
ids.forEach((id) => {
const commonBoxChatId = selectCommonBoxChatId(global, id);
if (commonBoxChatId) {
chatsIdsToUpdate.push(commonBoxChatId);
chatIdsToUpdate.push(commonBoxChatId);
global = updateChatMessage(global, commonBoxChatId, id, {
isDeleting: true,
@ -1165,7 +1165,7 @@ export function deleteMessages<T extends GlobalState>(
setGlobal(global);
unique(chatsIdsToUpdate).forEach((id) => {
unique(chatIdsToUpdate).forEach((id) => {
actions.requestChatUpdate({ chatId: id });
});
}

View File

@ -43,10 +43,6 @@ export function toChannelId(mtpId: string) {
return `-1${mtpId.padStart(CHANNEL_ID_LENGTH - 2, '0')}`;
}
export function isApiPeerChat(peer: ApiPeer): peer is ApiChat {
return 'title' in peer;
}
export function isChatGroup(chat: ApiChat) {
return isChatBasicGroup(chat) || isChatSuperGroup(chat);
}
@ -466,3 +462,20 @@ export function getPeerColorCount(peer: ApiPeer) {
export function getIsSavedDialog(chatId: string, threadId: ThreadId | undefined, currentUserId: string | undefined) {
return chatId === currentUserId && threadId !== MAIN_THREAD_ID;
}
export 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');
}

View File

@ -0,0 +1,47 @@
import type { ApiChat, ApiPeer, ApiUser } from '../../api/types';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../config';
export function isApiPeerChat(peer: ApiPeer): peer is ApiChat {
return 'title' in peer;
}
export function isApiPeerUser(peer: ApiPeer): peer is ApiUser {
return !isApiPeerChat(peer);
}
export function getPeerTypeKey(peer: ApiPeer) {
if (isApiPeerChat(peer)) {
if (peer.type === 'chatTypeBasicGroup' || peer.type === 'chatTypeSuperGroup') {
return 'ChatList.PeerTypeGroup';
}
if (peer.type === 'chatTypeChannel') {
return 'ChatList.PeerTypeChannel';
}
if (peer.type === 'chatTypePrivate') {
return 'ChatList.PeerTypeNonContact';
}
return undefined;
}
if (peer.id === SERVICE_NOTIFICATIONS_USER_ID) {
return 'ServiceNotifications';
}
if (peer.isSupport) {
return 'SupportStatus';
}
if (peer.type && peer.type === 'userTypeBot') {
return 'ChatList.PeerTypeBot';
}
if (peer.isContact) {
return 'ChatList.PeerTypeContact';
}
return 'ChatList.PeerTypeNonContactUser';
}

View File

@ -45,6 +45,7 @@ $color-text-meta-apple: #8c8c91;
$color-borders: #dadce0;
$color-dividers: #c8c6cc;
$color-dividers-android: #E7E7E7;
$color-item-hover: #f4f4f5;
$color-item-active: #ededed;
$color-chat-hover: #f4f4f5;
$color-chat-active: #3390ec;
@ -164,6 +165,7 @@ $color-message-story-mention-to: #74bcff;
--color-chat-username: #3C7EB0;
--color-chat-hover: #{$color-chat-hover};
--color-chat-active: #{$color-chat-active};
--color-item-hover: #{$color-item-hover};
--color-item-active: #{$color-item-active};
--color-selection-highlight: #{$color-selection};
@ -299,6 +301,8 @@ $color-message-story-mention-to: #74bcff;
--safe-area-bottom: env(safe-area-inset-bottom);
--safe-area-left: env(safe-area-inset-left);
--picker-title-shift: 1rem;
body.is-ios {
--layer-transition: 650ms cubic-bezier(0.22, 1, 0.36, 1);
--layer-transition-behind: 650ms cubic-bezier(0.33, 1, 0.68, 1);

View File

@ -347,6 +347,7 @@ body:not(.is-ios) {
--color-chat-hover: rgb(44, 44, 44);
--color-chat-active: rgb(118, 106, 200);
--color-chat-active-greyed: rgb(146, 136, 211);
--color-item-hover: rgb(44, 44, 44);
--color-item-active: rgb(41, 41, 41);
--color-text: rgb(255, 255, 255);
--color-text-rgb: 255, 255, 255;

View File

@ -17,6 +17,7 @@
"--color-chat-hover": ["#F4F4F5", "#2C2C2C"],
"--color-chat-active": ["#3390EC", "#766AC8"],
"--color-chat-active-greyed": ["#60a7f0", "#9288d3"],
"--color-item-hover": ["#f4f4f5", "#2c2c2c"],
"--color-item-active": ["#ededed", "#292929"],
"--color-text": ["#000000", "#FFFFFF"],
"--color-text-secondary": ["#707579", "#AAAAAA"],