Block contacts and ban chat members; Auto-focus in Sticker and Gif Search (#1502)

This commit is contained in:
Alexander Zinchuk 2021-10-22 02:24:36 +03:00
parent a3668e8b3d
commit f32195571e
10 changed files with 440 additions and 126 deletions

View File

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

View File

@ -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<HTMLInputElement>;
filterPlaceholder: string;
filter: string;
onFilterChange: (filter: string) => void;
loadMore: NoneToVoidFunction;
onSelectChatOrUser: (chatOrUserId: number) => void;
onClose: NoneToVoidFunction;
};
const ChatOrUserPicker: FC<OwnProps> = ({
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<HTMLDivElement>(null);
const handleFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
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 = (
<div className="modal-header" dir={lang.isRtl ? 'rtl' : undefined}>
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('Close')}
onClick={onClose}
>
<i className="icon-close" />
</Button>
<InputText
ref={filterRef}
value={filter}
onChange={handleFilterChange}
onKeyDown={handleKeyDown}
placeholder={filterPlaceholder}
/>
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
className="ChatOrUserPicker"
header={modalHeader}
>
{viewportIds?.length ? (
<InfiniteScroll
className="picker-list custom-scroll"
items={viewportIds}
onLoadMore={getMore}
noScrollRestore={Boolean(filter)}
ref={containerRef}
onKeyDown={handleKeyDown}
>
{viewportIds.map((id) => (
<ListItem
key={id}
className="chat-item-clickable force-rounded-corners"
onClick={() => onSelectChatOrUser(id)}
>
{isChatPrivate(id) ? (
<PrivateChatInfo status={id === currentUserId ? lang('SavedMessagesInfo') : undefined} userId={id} />
) : (
<GroupChatInfo chatId={id} />
)}
</ListItem>
))}
</InfiniteScroll>
) : viewportIds && !viewportIds.length ? (
<p className="no-results">{lang('lng_blocked_list_not_found')}</p>
) : (
<Loading />
)}
</Modal>
);
};
export default memo(ChatOrUserPicker);

View File

@ -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<number, ApiUser>;
blockedIds: number[];
contactIds?: number[];
localContactIds?: number[];
currentUserId?: number;
};
type DispatchProps = Pick<GlobalActions, 'loadContactList' | 'setUserSearchQuery' | 'blockContact'>;
const BlockUserModal: FC<OwnProps & StateProps & DispatchProps> = ({
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<HTMLInputElement>(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 (
<ChatOrUserPicker
isOpen={isOpen}
chatOrUserIds={filteredContactsId}
filterRef={filterRef}
filterPlaceholder={lang('BlockedUsers.BlockUser')}
filter={filter}
onFilterChange={setFilter}
loadMore={loadContactList}
onSelectChatOrUser={handleRemoveUser}
onClose={onClose}
/>
);
};
export default memo(withGlobal<OwnProps>(
(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));

View File

@ -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<OwnProps & StateProps & DispatchProps> = (
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<OwnProps & StateProps & DispatchProps> = (
{blockedIds!.map((contactId, i) => renderContact(contactId, i, 0))}
</div>
) : blockedIds && !blockedIds.length ? (
<div className="no-results" dir="auto">
List is empty
</div>
<div className="no-results" dir="auto">{lang('NoBlocked')}</div>
) : (
<Loading key="loading" />
)}
@ -121,13 +121,15 @@ const SettingsPrivacyBlockedUsers: FC<OwnProps & StateProps & DispatchProps> = (
<FloatingActionButton
isShown
onClick={() => {
}}
className="not-implemented"
ariaLabel="Add a blocked user"
onClick={openBlockUserModal}
ariaLabel={lang('BlockContact')}
>
<i className="icon-add" />
</FloatingActionButton>
<BlockUserModal
isOpen={isBlockUserModalOpen}
onClose={closeBlockUserModal}
/>
</div>
);
};

View File

@ -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<GlobalActions, 'setForwardChatId' | 'exitForwardMode' | 'loadMoreChats'>;
// 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<OwnProps & StateProps & DispatchProps> = ({
chatsById,
pinnedIds,
@ -57,33 +39,10 @@ const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({
exitForwardMode,
loadMoreChats,
}) => {
const lang = useLang();
const [filter, setFilter] = useState('');
// eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>(null);
const chatIds = useMemo(() => {
const listIds = [
@ -116,77 +75,23 @@ const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({
], chatsById, undefined, priorityIds);
}, [activeListIds, archivedListIds, chatsById, currentUserId, filter, lang, pinnedIds]);
const [viewportIds, getMore] = useInfiniteScroll(loadMoreChats, chatIds, Boolean(filter));
const handleFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.currentTarget.value);
}, []);
// eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useKeyboardListNavigation(containerRef, isOpen, (index) => {
if (viewportIds && viewportIds.length > 0) {
setForwardChatId({ id: viewportIds[index === -1 ? 0 : index] });
}
}, '.ListItem-button', true);
const modalHeader = (
<div className="modal-header" dir={lang.isRtl ? 'rtl' : undefined}>
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('Close')}
onClick={exitForwardMode}
>
<i className="icon-close" />
</Button>
<InputText
ref={inputRef}
value={filter}
onChange={handleFilterChange}
onKeyDown={handleKeyDown}
placeholder={lang('ForwardTo')}
/>
</div>
);
const handleSelectUser = useCallback((userId: number) => {
setForwardChatId({ id: userId });
}, [setForwardChatId]);
return (
<Modal
<ChatOrUserPicker
currentUserId={currentUserId}
isOpen={isOpen}
chatOrUserIds={chatIds}
filterRef={filterRef}
filterPlaceholder={lang('ForwardTo')}
filter={filter}
onFilterChange={setFilter}
loadMore={loadMoreChats}
onSelectChatOrUser={handleSelectUser}
onClose={exitForwardMode}
className="ForwardPicker"
header={modalHeader}
>
{viewportIds?.length ? (
<InfiniteScroll
className="picker-list custom-scroll"
items={viewportIds}
onLoadMore={getMore}
noScrollRestore={Boolean(filter)}
ref={containerRef}
onKeyDown={handleKeyDown}
>
{viewportIds.map((id) => (
<ListItem
key={id}
className="chat-item-clickable force-rounded-corners"
onClick={() => setForwardChatId({ id })}
>
{isChatPrivate(id) ? (
<PrivateChatInfo status={id === currentUserId ? lang('SavedMessagesInfo') : undefined} userId={id} />
) : (
<GroupChatInfo chatId={id} />
)}
</ListItem>
))}
</InfiniteScroll>
) : viewportIds && !viewportIds.length ? (
<p className="no-results">Sorry, nothing found.</p>
) : (
<Loading />
)}
</Modal>
/>
);
};

View File

@ -238,6 +238,7 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
<SearchInput
value={stickerSearchQuery}
placeholder={lang('SearchStickersHint')}
autoFocusSearch
onChange={handleStickerSearchQueryChange}
/>
);
@ -246,6 +247,7 @@ const RightHeader: FC<OwnProps & StateProps & DispatchProps> = ({
<SearchInput
value={gifSearchQuery}
placeholder={lang('SearchGifsTitle')}
autoFocusSearch
onChange={handleGifSearchQueryChange}
/>
);

View File

@ -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<number, ApiUser>;
canDeleteMembers?: boolean;
};
type DispatchProps = Pick<GlobalActions, 'updateChatMemberBannedRights'>;
@ -31,11 +35,13 @@ type DispatchProps = Pick<GlobalActions, 'updateChatMemberBannedRights'>;
const ManageGroupRemovedUsers: FC<OwnProps & StateProps & DispatchProps> = ({
chat,
usersById,
canDeleteMembers,
updateChatMemberBannedRights,
onClose,
isActive,
}) => {
const lang = useLang();
const [isRemoveUserModalOpen, openRemoveUserModal, closeRemoveUserModal] = useFlag();
useHistoryBack(isActive, onClose);
@ -96,6 +102,22 @@ const ManageGroupRemovedUsers: FC<OwnProps & StateProps & DispatchProps> = ({
/>
</ListItem>
))}
{canDeleteMembers && (
<FloatingActionButton
isShown
onClick={openRemoveUserModal}
ariaLabel={lang('Channel.EditAdmin.Permission.BanUsers')}
>
<i className="icon-add-user-filled" />
</FloatingActionButton>
)}
{chat && canDeleteMembers && (
<RemoveGroupUserModal
chat={chat}
isOpen={isRemoveUserModalOpen}
onClose={closeRemoveUserModal}
/>
)}
</div>
</div>
</div>
@ -106,8 +128,9 @@ export default memo(withGlobal<OwnProps>(
(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));

View File

@ -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<number, ApiUser>;
currentUserId?: number;
};
type DispatchProps = Pick<GlobalActions, 'loadMoreMembers' | 'deleteChatMember'>;
const RemoveGroupUserModal: FC<OwnProps & StateProps & DispatchProps> = ({
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<HTMLInputElement>(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 (
<ChatOrUserPicker
isOpen={isOpen}
chatOrUserIds={usersId}
filterRef={filterRef}
filterPlaceholder={lang('ChannelBlockUser')}
filter={filter}
onFilterChange={setFilter}
loadMore={loadMoreMembers}
onSelectChatOrUser={handleRemoveUser}
onClose={onClose}
/>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const {
users: {
byId: usersById,
},
currentUserId,
} = global;
return { usersById, currentUserId };
},
(setGlobal, actions): DispatchProps => pick(actions, ['loadMoreMembers', 'deleteChatMember']),
)(RemoveGroupUserModal));

View File

@ -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<OwnProps> = ({
disabled,
autoComplete,
canClose,
autoFocusSearch,
onChange,
onReset,
onFocus,
@ -57,6 +60,8 @@ const SearchInput: FC<OwnProps> = ({
const [isInputFocused, markInputFocused, unmarkInputFocused] = useFlag(focused);
useInputFocusOnOpen(inputRef, autoFocusSearch, unmarkInputFocused);
useEffect(() => {
if (!inputRef.current) {
return;

View File

@ -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<HTMLInputElement | HTMLTextAreaElement>,
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]);
}