From f32195571ebd7bae3fa3657ac2c3fe2d3484a0c5 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 22 Oct 2021 02:24:36 +0300 Subject: [PATCH] Block contacts and ban chat members; Auto-focus in Sticker and Gif Search (#1502) --- .../ChatOrUserPicker.scss} | 4 +- src/components/common/ChatOrUserPicker.tsx | 124 +++++++++++++++++ .../left/settings/BlockUserModal.tsx | 125 +++++++++++++++++ .../settings/SettingsPrivacyBlockedUsers.tsx | 20 +-- src/components/main/ForwardPicker.tsx | 131 +++--------------- src/components/right/RightHeader.tsx | 2 + .../management/ManageGroupRemovedUsers.tsx | 27 +++- .../right/management/RemoveGroupUserModal.tsx | 93 +++++++++++++ src/components/ui/SearchInput.tsx | 5 + src/hooks/useInputFocusOnOpen.ts | 35 +++++ 10 files changed, 440 insertions(+), 126 deletions(-) rename src/components/{main/ForwardPicker.scss => common/ChatOrUserPicker.scss} (97%) create mode 100644 src/components/common/ChatOrUserPicker.tsx create mode 100644 src/components/left/settings/BlockUserModal.tsx create mode 100644 src/components/right/management/RemoveGroupUserModal.tsx create mode 100644 src/hooks/useInputFocusOnOpen.ts diff --git a/src/components/main/ForwardPicker.scss b/src/components/common/ChatOrUserPicker.scss similarity index 97% rename from src/components/main/ForwardPicker.scss rename to src/components/common/ChatOrUserPicker.scss index 70a6e0241..5cd7fbfa3 100644 --- a/src/components/main/ForwardPicker.scss +++ b/src/components/common/ChatOrUserPicker.scss @@ -1,4 +1,4 @@ -.ForwardPicker { +.ChatOrUserPicker { z-index: var(--z-media-viewer); .modal-dialog { @@ -21,6 +21,7 @@ .input-group { margin: 0; + flex: 1; } .form-control { @@ -69,6 +70,5 @@ } } } - } } diff --git a/src/components/common/ChatOrUserPicker.tsx b/src/components/common/ChatOrUserPicker.tsx new file mode 100644 index 000000000..eea951b9f --- /dev/null +++ b/src/components/common/ChatOrUserPicker.tsx @@ -0,0 +1,124 @@ +import { RefObject } from 'react'; +import React, { + FC, memo, useRef, useCallback, +} from '../../lib/teact/teact'; + +import useInfiniteScroll from '../../hooks/useInfiniteScroll'; +import useLang from '../../hooks/useLang'; +import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; +import useInputFocusOnOpen from '../../hooks/useInputFocusOnOpen'; +import { isChatPrivate } from '../../modules/helpers'; + +import Loading from '../ui/Loading'; +import Modal from '../ui/Modal'; +import InputText from '../ui/InputText'; +import Button from '../ui/Button'; +import InfiniteScroll from '../ui/InfiniteScroll'; +import ListItem from '../ui/ListItem'; +import GroupChatInfo from './GroupChatInfo'; +import PrivateChatInfo from './PrivateChatInfo'; + +import './ChatOrUserPicker.scss'; + +export type OwnProps = { + currentUserId?: number; + chatOrUserIds: number[]; + isOpen: boolean; + filterRef: RefObject; + filterPlaceholder: string; + filter: string; + onFilterChange: (filter: string) => void; + loadMore: NoneToVoidFunction; + onSelectChatOrUser: (chatOrUserId: number) => void; + onClose: NoneToVoidFunction; +}; + +const ChatOrUserPicker: FC = ({ + isOpen, + currentUserId, + chatOrUserIds, + filterRef, + filter, + filterPlaceholder, + onFilterChange, + onClose, + loadMore, + onSelectChatOrUser, +}) => { + const lang = useLang(); + const [viewportIds, getMore] = useInfiniteScroll(loadMore, chatOrUserIds, Boolean(filter)); + + useInputFocusOnOpen(filterRef, isOpen, () => { onFilterChange(''); }); + + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + const handleFilterChange = useCallback((e: React.ChangeEvent) => { + onFilterChange(e.currentTarget.value); + }, [onFilterChange]); + const handleKeyDown = useKeyboardListNavigation(containerRef, isOpen, (index) => { + if (viewportIds && viewportIds.length > 0) { + onSelectChatOrUser(viewportIds[index === -1 ? 0 : index]); + } + }, '.ListItem-button', true); + + const modalHeader = ( +
+ + +
+ ); + + return ( + + {viewportIds?.length ? ( + + {viewportIds.map((id) => ( + onSelectChatOrUser(id)} + > + {isChatPrivate(id) ? ( + + ) : ( + + )} + + ))} + + ) : viewportIds && !viewportIds.length ? ( +

{lang('lng_blocked_list_not_found')}

+ ) : ( + + )} +
+ ); +}; + +export default memo(ChatOrUserPicker); diff --git a/src/components/left/settings/BlockUserModal.tsx b/src/components/left/settings/BlockUserModal.tsx new file mode 100644 index 000000000..9900f1ad6 --- /dev/null +++ b/src/components/left/settings/BlockUserModal.tsx @@ -0,0 +1,125 @@ +import React, { + FC, useMemo, useState, memo, useRef, useCallback, useEffect, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; + +import { GlobalActions } from '../../../global/types'; +import { ApiUser } from '../../../api/types'; + +import { getUserFullName } from '../../../modules/helpers'; +import searchWords from '../../../util/searchWords'; +import { pick, unique } from '../../../util/iteratees'; +import useLang from '../../../hooks/useLang'; + +import ChatOrUserPicker from '../../common/ChatOrUserPicker'; + +export type OwnProps = { + isOpen: boolean; + onClose: NoneToVoidFunction; +}; + +type StateProps = { + usersById: Record; + blockedIds: number[]; + contactIds?: number[]; + localContactIds?: number[]; + currentUserId?: number; +}; + +type DispatchProps = Pick; + +const BlockUserModal: FC = ({ + usersById, + blockedIds, + contactIds, + localContactIds, + currentUserId, + isOpen, + onClose, + loadContactList, + setUserSearchQuery, + blockContact, +}) => { + const lang = useLang(); + const [filter, setFilter] = useState(''); + // eslint-disable-next-line no-null/no-null + const filterRef = useRef(null); + + useEffect(() => { + setUserSearchQuery({ query: filter }); + }, [filter, setUserSearchQuery]); + + const filteredContactsId = useMemo(() => { + const availableContactsId = (contactIds || []).concat(localContactIds || []).filter((contactId) => { + return !blockedIds.includes(contactId) && contactId !== currentUserId; + }); + + return unique(availableContactsId).reduce((acc, contactId) => { + if ( + !filter + || !usersById[contactId] + || searchWords(getUserFullName(usersById[contactId]) || '', filter) + || usersById[contactId]?.username.toLowerCase().includes(filter) + ) { + acc.push(contactId); + } + + return acc; + }, [] as number[]) + .sort((firstId, secondId) => { + const firstName = getUserFullName(usersById[firstId]) || ''; + const secondName = getUserFullName(usersById[secondId]) || ''; + + return firstName.localeCompare(secondName); + }); + }, [blockedIds, contactIds, currentUserId, filter, localContactIds, usersById]); + + const handleRemoveUser = useCallback((userId: number) => { + const { id: contactId, accessHash } = usersById[userId] || {}; + if (!contactId || !accessHash) { + return; + } + blockContact({ contactId, accessHash }); + onClose(); + }, [blockContact, onClose, usersById]); + + return ( + + ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { + users: { + byId: usersById, + }, + blocked: { + ids: blockedIds, + }, + contactList, + currentUserId, + } = global; + + return { + usersById, + blockedIds, + contactIds: contactList?.userIds, + localContactIds: global.userSearch.localUserIds, + currentUserId, + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, [ + 'loadContactList', 'setUserSearchQuery', 'blockContact', + ]), +)(BlockUserModal)); diff --git a/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx b/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx index 4b8504f1b..8b838c962 100644 --- a/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx +++ b/src/components/left/settings/SettingsPrivacyBlockedUsers.tsx @@ -17,11 +17,13 @@ import renderText from '../../common/helpers/renderText'; import buildClassName from '../../../util/buildClassName'; import useLang from '../../../hooks/useLang'; import useHistoryBack from '../../../hooks/useHistoryBack'; +import useFlag from '../../../hooks/useFlag'; import ListItem from '../../ui/ListItem'; import FloatingActionButton from '../../ui/FloatingActionButton'; import Avatar from '../../common/Avatar'; import Loading from '../../ui/Loading'; +import BlockUserModal from './BlockUserModal'; type OwnProps = { isActive?: boolean; @@ -48,12 +50,12 @@ const SettingsPrivacyBlockedUsers: FC = ( phoneCodeList, unblockContact, }) => { + const lang = useLang(); + const [isBlockUserModalOpen, openBlockUserModal, closeBlockUserModal] = useFlag(); const handleUnblockClick = useCallback((contactId: number) => { unblockContact({ contactId }); }, [unblockContact]); - const lang = useLang(); - useHistoryBack(isActive, onReset, onScreenSelect, SettingsScreens.PrivacyBlockedUsers); function renderContact(contactId: number, i: number, viewportOffset: number) { @@ -110,9 +112,7 @@ const SettingsPrivacyBlockedUsers: FC = ( {blockedIds!.map((contactId, i) => renderContact(contactId, i, 0))} ) : blockedIds && !blockedIds.length ? ( -
- List is empty -
+
{lang('NoBlocked')}
) : ( )} @@ -121,13 +121,15 @@ const SettingsPrivacyBlockedUsers: FC = ( { - }} - className="not-implemented" - ariaLabel="Add a blocked user" + onClick={openBlockUserModal} + ariaLabel={lang('BlockContact')} > + ); }; diff --git a/src/components/main/ForwardPicker.tsx b/src/components/main/ForwardPicker.tsx index 19cc87d50..e8f7fe5b2 100644 --- a/src/components/main/ForwardPicker.tsx +++ b/src/components/main/ForwardPicker.tsx @@ -1,31 +1,17 @@ import React, { - FC, useMemo, useState, memo, useRef, useEffect, useCallback, + FC, useMemo, useState, memo, useRef, useCallback, } from '../../lib/teact/teact'; import { withGlobal } from '../../lib/teact/teactn'; import { GlobalActions } from '../../global/types'; import { ApiChat, MAIN_THREAD_ID } from '../../api/types'; -import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; -import { - getCanPostInChat, getChatTitle, isChatPrivate, sortChatIds, -} from '../../modules/helpers'; +import { getCanPostInChat, getChatTitle, sortChatIds } from '../../modules/helpers'; import searchWords from '../../util/searchWords'; import { pick, unique } from '../../util/iteratees'; -import useInfiniteScroll from '../../hooks/useInfiniteScroll'; import useLang from '../../hooks/useLang'; -import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; -import Loading from '../ui/Loading'; -import Modal from '../ui/Modal'; -import InputText from '../ui/InputText'; -import Button from '../ui/Button'; -import InfiniteScroll from '../ui/InfiniteScroll'; -import ListItem from '../ui/ListItem'; -import PrivateChatInfo from '../common/PrivateChatInfo'; -import GroupChatInfo from '../common/GroupChatInfo'; - -import './ForwardPicker.scss'; +import ChatOrUserPicker from '../common/ChatOrUserPicker'; export type OwnProps = { isOpen: boolean; @@ -42,10 +28,6 @@ type StateProps = { type DispatchProps = Pick; -// Focus slows down animation, also it breaks transition layout in Chrome -const FOCUS_DELAY_MS = 500; -const MODAL_HIDE_DELAY_MS = 300; - const ForwardPicker: FC = ({ chatsById, pinnedIds, @@ -57,33 +39,10 @@ const ForwardPicker: FC = ({ exitForwardMode, loadMoreChats, }) => { + const lang = useLang(); const [filter, setFilter] = useState(''); // eslint-disable-next-line no-null/no-null - const inputRef = useRef(null); - - const lang = useLang(); - - useEffect(() => { - if (isOpen) { - if (!IS_SINGLE_COLUMN_LAYOUT) { - setTimeout(() => { - requestAnimationFrame(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }); - }, FOCUS_DELAY_MS); - } - } else { - if (inputRef.current) { - inputRef.current.blur(); - } - - setTimeout(() => { - setFilter(''); - }, MODAL_HIDE_DELAY_MS); - } - }, [isOpen]); + const filterRef = useRef(null); const chatIds = useMemo(() => { const listIds = [ @@ -116,77 +75,23 @@ const ForwardPicker: FC = ({ ], chatsById, undefined, priorityIds); }, [activeListIds, archivedListIds, chatsById, currentUserId, filter, lang, pinnedIds]); - const [viewportIds, getMore] = useInfiniteScroll(loadMoreChats, chatIds, Boolean(filter)); - - const handleFilterChange = useCallback((e: React.ChangeEvent) => { - setFilter(e.currentTarget.value); - }, []); - - // eslint-disable-next-line no-null/no-null - const containerRef = useRef(null); - const handleKeyDown = useKeyboardListNavigation(containerRef, isOpen, (index) => { - if (viewportIds && viewportIds.length > 0) { - setForwardChatId({ id: viewportIds[index === -1 ? 0 : index] }); - } - }, '.ListItem-button', true); - - const modalHeader = ( -
- - -
- ); + const handleSelectUser = useCallback((userId: number) => { + setForwardChatId({ id: userId }); + }, [setForwardChatId]); return ( - - {viewportIds?.length ? ( - - {viewportIds.map((id) => ( - setForwardChatId({ id })} - > - {isChatPrivate(id) ? ( - - ) : ( - - )} - - ))} - - ) : viewportIds && !viewportIds.length ? ( -

Sorry, nothing found.

- ) : ( - - )} -
+ /> ); }; diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index a192f3247..1a7ca7ec7 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -238,6 +238,7 @@ const RightHeader: FC = ({ ); @@ -246,6 +247,7 @@ const RightHeader: FC = ({ ); diff --git a/src/components/right/management/ManageGroupRemovedUsers.tsx b/src/components/right/management/ManageGroupRemovedUsers.tsx index 68ed0249d..7106f8d23 100644 --- a/src/components/right/management/ManageGroupRemovedUsers.tsx +++ b/src/components/right/management/ManageGroupRemovedUsers.tsx @@ -7,13 +7,16 @@ import { ApiChat, ApiChatMember, ApiUser } from '../../../api/types'; import { GlobalActions } from '../../../global/types'; import { selectChat } from '../../../modules/selectors'; -import { getUserFullName } from '../../../modules/helpers'; +import { getHasAdminRight, getUserFullName } from '../../../modules/helpers'; import { pick } from '../../../util/iteratees'; import useLang from '../../../hooks/useLang'; import useHistoryBack from '../../../hooks/useHistoryBack'; +import useFlag from '../../../hooks/useFlag'; import PrivateChatInfo from '../../common/PrivateChatInfo'; import ListItem from '../../ui/ListItem'; +import FloatingActionButton from '../../ui/FloatingActionButton'; +import RemoveGroupUserModal from './RemoveGroupUserModal'; type OwnProps = { chatId: number; @@ -24,6 +27,7 @@ type OwnProps = { type StateProps = { chat?: ApiChat; usersById: Record; + canDeleteMembers?: boolean; }; type DispatchProps = Pick; @@ -31,11 +35,13 @@ type DispatchProps = Pick; const ManageGroupRemovedUsers: FC = ({ chat, usersById, + canDeleteMembers, updateChatMemberBannedRights, onClose, isActive, }) => { const lang = useLang(); + const [isRemoveUserModalOpen, openRemoveUserModal, closeRemoveUserModal] = useFlag(); useHistoryBack(isActive, onClose); @@ -96,6 +102,22 @@ const ManageGroupRemovedUsers: FC = ({ /> ))} + {canDeleteMembers && ( + + + + )} + {chat && canDeleteMembers && ( + + )} @@ -106,8 +128,9 @@ export default memo(withGlobal( (global, { chatId }): StateProps => { const chat = selectChat(global, chatId); const { byId: usersById } = global.users; + const canDeleteMembers = chat && (getHasAdminRight(chat, 'banUsers') || chat.isCreator); - return { chat, usersById }; + return { chat, usersById, canDeleteMembers }; }, (setGlobal, actions): DispatchProps => pick(actions, ['updateChatMemberBannedRights']), )(ManageGroupRemovedUsers)); diff --git a/src/components/right/management/RemoveGroupUserModal.tsx b/src/components/right/management/RemoveGroupUserModal.tsx new file mode 100644 index 000000000..f2ec9a08e --- /dev/null +++ b/src/components/right/management/RemoveGroupUserModal.tsx @@ -0,0 +1,93 @@ +import React, { + FC, useMemo, useState, memo, useRef, useCallback, +} from '../../../lib/teact/teact'; +import { withGlobal } from '../../../lib/teact/teactn'; + +import { GlobalActions } from '../../../global/types'; +import { ApiChat, ApiUser } from '../../../api/types'; + +import { getUserFullName } from '../../../modules/helpers'; +import searchWords from '../../../util/searchWords'; +import { pick } from '../../../util/iteratees'; +import useLang from '../../../hooks/useLang'; + +import ChatOrUserPicker from '../../common/ChatOrUserPicker'; + +export type OwnProps = { + chat: ApiChat; + isOpen: boolean; + onClose: NoneToVoidFunction; +}; + +type StateProps = { + usersById: Record; + currentUserId?: number; +}; + +type DispatchProps = Pick; + +const RemoveGroupUserModal: FC = ({ + chat, + usersById, + currentUserId, + isOpen, + onClose, + loadMoreMembers, + deleteChatMember, +}) => { + const lang = useLang(); + const [filter, setFilter] = useState(''); + // eslint-disable-next-line no-null/no-null + const filterRef = useRef(null); + + const usersId = useMemo(() => { + const availableMembers = (chat.fullInfo?.members || []).filter((member) => { + return !member.isAdmin && !member.isOwner && member.userId !== currentUserId; + }); + + return availableMembers.reduce((acc, member) => { + if ( + !filter + || !usersById[member.userId] + || searchWords(getUserFullName(usersById[member.userId]) || '', filter) + ) { + acc.push(member.userId); + } + + return acc; + }, [] as number[]); + }, [chat.fullInfo?.members, currentUserId, filter, usersById]); + + const handleRemoveUser = useCallback((userId: number) => { + deleteChatMember({ chatId: chat.id, userId }); + onClose(); + }, [chat.id, deleteChatMember, onClose]); + + return ( + + ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { + users: { + byId: usersById, + }, + currentUserId, + } = global; + + return { usersById, currentUserId }; + }, + (setGlobal, actions): DispatchProps => pick(actions, ['loadMoreMembers', 'deleteChatMember']), +)(RemoveGroupUserModal)); diff --git a/src/components/ui/SearchInput.tsx b/src/components/ui/SearchInput.tsx index 1187285f9..04c392866 100644 --- a/src/components/ui/SearchInput.tsx +++ b/src/components/ui/SearchInput.tsx @@ -6,6 +6,7 @@ import React, { import buildClassName from '../../util/buildClassName'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; +import useInputFocusOnOpen from '../../hooks/useInputFocusOnOpen'; import Loading from './Loading'; import Button from './Button'; @@ -25,6 +26,7 @@ type OwnProps = { disabled?: boolean; autoComplete?: string; canClose?: boolean; + autoFocusSearch?: boolean; onChange: (value: string) => void; onReset?: NoneToVoidFunction; onFocus?: NoneToVoidFunction; @@ -44,6 +46,7 @@ const SearchInput: FC = ({ disabled, autoComplete, canClose, + autoFocusSearch, onChange, onReset, onFocus, @@ -57,6 +60,8 @@ const SearchInput: FC = ({ const [isInputFocused, markInputFocused, unmarkInputFocused] = useFlag(focused); + useInputFocusOnOpen(inputRef, autoFocusSearch, unmarkInputFocused); + useEffect(() => { if (!inputRef.current) { return; diff --git a/src/hooks/useInputFocusOnOpen.ts b/src/hooks/useInputFocusOnOpen.ts new file mode 100644 index 000000000..4da1cb311 --- /dev/null +++ b/src/hooks/useInputFocusOnOpen.ts @@ -0,0 +1,35 @@ +import { RefObject } from 'react'; +import { useEffect } from '../lib/teact/teact'; +import { IS_SINGLE_COLUMN_LAYOUT } from '../util/environment'; + +// Focus slows down animation, also it breaks transition layout in Chrome +const FOCUS_DELAY_MS = 500; +const MODAL_HIDE_DELAY_MS = 300; + +export default function useInputFocusOnOpen( + inputRef: RefObject, + isOpen?: boolean, + onClose?: NoneToVoidFunction, +) { + useEffect(() => { + if (isOpen) { + if (!IS_SINGLE_COLUMN_LAYOUT) { + setTimeout(() => { + requestAnimationFrame(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }); + }, FOCUS_DELAY_MS); + } + } else { + if (inputRef.current) { + inputRef.current.blur(); + } + + if (onClose) { + setTimeout(onClose, MODAL_HIDE_DELAY_MS); + } + } + }, [inputRef, isOpen, onClose]); +}