[Perf] Forward Modal: Fix missing contacts; Optimize text filtering in large lists

This commit is contained in:
Alexander Zinchuk 2021-12-24 01:26:06 +01:00
parent dac6c3a101
commit f6cd826e7f
15 changed files with 206 additions and 227 deletions

View File

@ -6,7 +6,7 @@ import { withGlobal } from '../../lib/teact/teactn';
import { ApiCountryCode } from '../../api/types'; import { ApiCountryCode } from '../../api/types';
import { ANIMATION_END_DELAY } from '../../config'; import { ANIMATION_END_DELAY } from '../../config';
import searchWords from '../../util/searchWords'; import { prepareSearchWordsForNeedle } from '../../util/searchWords';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
@ -159,11 +159,15 @@ const CountryCodeInput: FC<OwnProps & StateProps> = ({
}; };
function getFilteredList(countryList: ApiCountryCode[], filter = ''): ApiCountryCode[] { function getFilteredList(countryList: ApiCountryCode[], filter = ''): ApiCountryCode[] {
const filtered = filter.length if (!filter.length) {
? countryList.filter((country) => ( return countryList;
searchWords(country.defaultName, filter) || (country.name && searchWords(country.name, filter)) }
)) : countryList;
return filtered; const searchWords = prepareSearchWordsForNeedle(filter);
return countryList.filter((country) => (
searchWords(country.defaultName) || (country.name && searchWords(country.name))
));
} }
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(

View File

@ -8,9 +8,8 @@ import { ApiUser, ApiUserStatus } from '../../../api/types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import { throttle } from '../../../util/schedulers'; import { throttle } from '../../../util/schedulers';
import searchWords from '../../../util/searchWords';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
import { getUserFullName, sortUserIds } from '../../../modules/helpers'; import { filterUsersByName, sortUserIds } from '../../../modules/helpers';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
import useHistoryBack from '../../../hooks/useHistoryBack'; import useHistoryBack from '../../../hooks/useHistoryBack';
@ -66,16 +65,9 @@ const ContactList: FC<OwnProps & StateProps & DispatchProps> = ({
return undefined; return undefined;
} }
const resultIds = filter ? contactIds.filter((id) => { const filteredIds = filterUsersByName(contactIds, usersById, filter);
const user = usersById[id];
if (!user) {
return false;
}
const fullName = getUserFullName(user);
return fullName && searchWords(fullName, filter);
}) : contactIds;
return sortUserIds(resultIds, usersById, userStatusesById, undefined, serverTimeOffset); return sortUserIds(filteredIds, usersById, userStatusesById, undefined, serverTimeOffset);
}, [contactIds, filter, usersById, userStatusesById, serverTimeOffset]); }, [contactIds, filter, usersById, userStatusesById, serverTimeOffset]);
const [viewportIds, getMore] = useInfiniteScroll(undefined, listIds, Boolean(filter)); const [viewportIds, getMore] = useInfiniteScroll(undefined, listIds, Boolean(filter));

View File

@ -1,15 +1,14 @@
import React, { import React, {
FC, useCallback, useEffect, useMemo, memo, FC, useCallback, useEffect, useMemo, memo,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn'; import { getGlobal, withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions } from '../../../global/types'; import { GlobalActions } from '../../../global/types';
import { ApiChat, ApiUser } from '../../../api/types'; import { ApiChat } from '../../../api/types';
import { pick, unique } from '../../../util/iteratees'; import { pick, unique } from '../../../util/iteratees';
import { throttle } from '../../../util/schedulers'; import { throttle } from '../../../util/schedulers';
import searchWords from '../../../util/searchWords'; import { filterUsersByName, isUserBot, sortChatIds } from '../../../modules/helpers';
import { getUserFullName, isUserBot, sortChatIds } from '../../../modules/helpers';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useHistoryBack from '../../../hooks/useHistoryBack'; import useHistoryBack from '../../../hooks/useHistoryBack';
@ -27,8 +26,6 @@ export type OwnProps = {
}; };
type StateProps = { type StateProps = {
currentUserId?: string;
usersById: Record<string, ApiUser>;
chatsById: Record<string, ApiChat>; chatsById: Record<string, ApiChat>;
localContactIds?: string[]; localContactIds?: string[];
searchQuery?: string; searchQuery?: string;
@ -48,8 +45,6 @@ const NewChatStep1: FC<OwnProps & StateProps & DispatchProps> = ({
onSelectedMemberIdsChange, onSelectedMemberIdsChange,
onNextStep, onNextStep,
onReset, onReset,
currentUserId,
usersById,
chatsById, chatsById,
localContactIds, localContactIds,
searchQuery, searchQuery,
@ -76,22 +71,9 @@ const NewChatStep1: FC<OwnProps & StateProps & DispatchProps> = ({
}, [setGlobalSearchQuery]); }, [setGlobalSearchQuery]);
const displayedIds = useMemo(() => { const displayedIds = useMemo(() => {
const contactIds = localContactIds // No need for expensive global updates on users, so we avoid them
? sortChatIds(localContactIds.filter((id) => id !== currentUserId), chatsById) const usersById = getGlobal().users.byId;
: []; const foundContactIds = localContactIds ? filterUsersByName(localContactIds, usersById, searchQuery) : [];
if (!searchQuery) {
return contactIds;
}
const foundContactIds = contactIds.filter((id) => {
const user = usersById[id];
if (!user) {
return false;
}
const fullName = getUserFullName(user);
return fullName && searchWords(fullName, searchQuery);
});
return sortChatIds( return sortChatIds(
unique([ unique([
@ -100,17 +82,17 @@ const NewChatStep1: FC<OwnProps & StateProps & DispatchProps> = ({
...(globalUserIds || []), ...(globalUserIds || []),
]).filter((contactId) => { ]).filter((contactId) => {
const user = usersById[contactId]; const user = usersById[contactId];
if (!user) {
return true;
}
return !user || !isUserBot(user) || user.canBeInvitedToGroup; return user.canBeInvitedToGroup && !user.isSelf && !isUserBot(user);
}), }),
chatsById, chatsById,
false, false,
selectedMemberIds, selectedMemberIds,
); );
}, [ }, [localContactIds, chatsById, searchQuery, localUserIds, globalUserIds, selectedMemberIds]);
localContactIds, chatsById, searchQuery, localUserIds, globalUserIds, selectedMemberIds,
currentUserId, usersById,
]);
const handleNextStep = useCallback(() => { const handleNextStep = useCallback(() => {
if (selectedMemberIds.length || isChannel) { if (selectedMemberIds.length || isChannel) {
@ -160,9 +142,7 @@ const NewChatStep1: FC<OwnProps & StateProps & DispatchProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
const { userIds: localContactIds } = global.contactList || {}; const { userIds: localContactIds } = global.contactList || {};
const { byId: usersById } = global.users;
const { byId: chatsById } = global.chats; const { byId: chatsById } = global.chats;
const { currentUserId } = global;
const { const {
query: searchQuery, query: searchQuery,
@ -174,8 +154,6 @@ export default memo(withGlobal<OwnProps>(
const { userIds: localUserIds } = localResults || {}; const { userIds: localUserIds } = localResults || {};
return { return {
currentUserId,
usersById,
chatsById, chatsById,
localContactIds, localContactIds,
searchQuery, searchQuery,

View File

@ -1,16 +1,15 @@
import React, { import React, {
FC, memo, useCallback, useMemo, useState, FC, memo, useCallback, useMemo, useState,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn'; import { getGlobal, withGlobal } from '../../../lib/teact/teactn';
import { ApiUser, ApiChat, ApiMessage } from '../../../api/types'; import { ApiChat, ApiMessage } from '../../../api/types';
import { GlobalActions } from '../../../global/types'; import { GlobalActions } from '../../../global/types';
import { LoadMoreDirection } from '../../../types'; import { LoadMoreDirection } from '../../../types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment';
import searchWords from '../../../util/searchWords';
import { unique, pick } from '../../../util/iteratees'; import { unique, pick } from '../../../util/iteratees';
import { getUserFullName, getMessageSummaryText, sortChatIds } from '../../../modules/helpers'; import { getMessageSummaryText, sortChatIds, filterUsersByName } from '../../../modules/helpers';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
import { throttle } from '../../../util/schedulers'; import { throttle } from '../../../util/schedulers';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
@ -42,7 +41,6 @@ type StateProps = {
foundIds?: string[]; foundIds?: string[];
globalMessagesByChatId?: Record<string, { byId: Record<number, ApiMessage> }>; globalMessagesByChatId?: Record<string, { byId: Record<number, ApiMessage> }>;
chatsById: Record<string, ApiChat>; chatsById: Record<string, ApiChat>;
usersById: Record<string, ApiUser>;
fetchingStatus?: { chats?: boolean; messages?: boolean }; fetchingStatus?: { chats?: boolean; messages?: boolean };
lastSyncTime?: number; lastSyncTime?: number;
}; };
@ -52,14 +50,14 @@ type DispatchProps = Pick<GlobalActions, (
)>; )>;
const MIN_QUERY_LENGTH_FOR_GLOBAL_SEARCH = 4; const MIN_QUERY_LENGTH_FOR_GLOBAL_SEARCH = 4;
const LESS_LIST_ITEMS_AMOUNT = 3; const LESS_LIST_ITEMS_AMOUNT = 5;
const runThrottled = throttle((cb) => cb(), 500, true); const runThrottled = throttle((cb) => cb(), 500, true);
const ChatResults: FC<OwnProps & StateProps & DispatchProps> = ({ const ChatResults: FC<OwnProps & StateProps & DispatchProps> = ({
searchQuery, searchDate, dateSearchQuery, currentUserId, searchQuery, searchDate, dateSearchQuery, currentUserId,
localContactIds, localChatIds, localUserIds, globalChatIds, globalUserIds, localContactIds, localChatIds, localUserIds, globalChatIds, globalUserIds,
foundIds, globalMessagesByChatId, chatsById, usersById, fetchingStatus, lastSyncTime, foundIds, globalMessagesByChatId, chatsById, fetchingStatus, lastSyncTime,
onReset, onSearchDateSelect, openChat, addRecentlyFoundChatId, searchMessagesGlobal, setGlobalSearchChatId, onReset, onSearchDateSelect, openChat, addRecentlyFoundChatId, searchMessagesGlobal, setGlobalSearchChatId,
}) => { }) => {
const lang = useLang(); const lang = useLang();
@ -102,37 +100,33 @@ const ChatResults: FC<OwnProps & StateProps & DispatchProps> = ({
return MEMO_EMPTY_ARRAY; return MEMO_EMPTY_ARRAY;
} }
const foundContactIds = localContactIds const contactIdsWithMe = [
? localContactIds.filter((id) => { ...(currentUserId ? [currentUserId] : []),
const user = usersById[id]; ...(localContactIds || []),
if (!user) { ];
return false; // No need for expensive global updates on users, so we avoid them
} const usersById = getGlobal().users.byId;
const foundContactIds = filterUsersByName(contactIdsWithMe, usersById, searchQuery);
const fullName = getUserFullName(user);
return (fullName && searchWords(fullName, searchQuery)) || searchWords(user.username, searchQuery);
})
: [];
return [ return [
...(currentUserId && searchWords(lang('SavedMessages'), searchQuery) ? [currentUserId] : []),
...sortChatIds(unique([ ...sortChatIds(unique([
...foundContactIds, ...(foundContactIds || []),
...(localChatIds || []), ...(localChatIds || []),
...(localUserIds || []), ...(localUserIds || []),
]), chatsById), ]), chatsById, undefined, currentUserId ? [currentUserId] : undefined),
]; ];
}, [ }, [searchQuery, localContactIds, currentUserId, localChatIds, localUserIds, chatsById]);
searchQuery, localContactIds, currentUserId, lang, localChatIds, localUserIds, chatsById, usersById,
]);
const globalResults = useMemo(() => { const globalResults = useMemo(() => {
if (!searchQuery || searchQuery.length < MIN_QUERY_LENGTH_FOR_GLOBAL_SEARCH || !globalChatIds || !globalUserIds) { if (!searchQuery || searchQuery.length < MIN_QUERY_LENGTH_FOR_GLOBAL_SEARCH || !globalChatIds || !globalUserIds) {
return MEMO_EMPTY_ARRAY; return MEMO_EMPTY_ARRAY;
} }
return sortChatIds(unique([...globalChatIds, ...globalUserIds]), return sortChatIds(
chatsById, true); unique([...globalChatIds, ...globalUserIds]),
chatsById,
true,
);
}, [chatsById, globalChatIds, globalUserIds, searchQuery]); }, [chatsById, globalChatIds, globalUserIds, searchQuery]);
const foundMessages = useMemo(() => { const foundMessages = useMemo(() => {
@ -278,14 +272,12 @@ const ChatResults: FC<OwnProps & StateProps & DispatchProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
const { byId: chatsById } = global.chats; const { byId: chatsById } = global.chats;
const { byId: usersById } = global.users;
const { userIds: localContactIds } = global.contactList || {}; const { userIds: localContactIds } = global.contactList || {};
if (!localContactIds) { if (!localContactIds) {
return { return {
chatsById, chatsById,
usersById,
}; };
} }
@ -310,7 +302,6 @@ export default memo(withGlobal<OwnProps>(
foundIds, foundIds,
globalMessagesByChatId, globalMessagesByChatId,
chatsById, chatsById,
usersById,
fetchingStatus, fetchingStatus,
lastSyncTime, lastSyncTime,
}; };

View File

@ -6,8 +6,7 @@ import { withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions } from '../../../global/types'; import { GlobalActions } from '../../../global/types';
import { ApiUser } from '../../../api/types'; import { ApiUser } from '../../../api/types';
import { getUserFullName } from '../../../modules/helpers'; import { filterUsersByName, getUserFullName } from '../../../modules/helpers';
import searchWords from '../../../util/searchWords';
import { pick, unique } from '../../../util/iteratees'; import { pick, unique } from '../../../util/iteratees';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
@ -49,23 +48,15 @@ const BlockUserModal: FC<OwnProps & StateProps & DispatchProps> = ({
setUserSearchQuery({ query: filter }); setUserSearchQuery({ query: filter });
}, [filter, setUserSearchQuery]); }, [filter, setUserSearchQuery]);
const filteredContactsId = useMemo(() => { const filteredContactIds = useMemo(() => {
const availableContactsId = (contactIds || []).concat(localContactIds || []).filter((contactId) => { const availableContactIds = unique([
return !blockedIds.includes(contactId) && contactId !== currentUserId; ...(contactIds || []),
}); ...(localContactIds || []),
].filter((contactId) => {
return contactId !== currentUserId && !blockedIds.includes(contactId);
}));
return unique(availableContactsId).reduce<string[]>((acc, contactId) => { return filterUsersByName(availableContactIds, usersById, filter)
if (
!filter
|| !usersById[contactId]
|| searchWords(getUserFullName(usersById[contactId]) || '', filter)
|| usersById[contactId]?.username.toLowerCase().includes(filter)
) {
acc.push(contactId);
}
return acc;
}, [])
.sort((firstId, secondId) => { .sort((firstId, secondId) => {
const firstName = getUserFullName(usersById[firstId]) || ''; const firstName = getUserFullName(usersById[firstId]) || '';
const secondName = getUserFullName(usersById[secondId]) || ''; const secondName = getUserFullName(usersById[secondId]) || '';
@ -86,7 +77,7 @@ const BlockUserModal: FC<OwnProps & StateProps & DispatchProps> = ({
return ( return (
<ChatOrUserPicker <ChatOrUserPicker
isOpen={isOpen} isOpen={isOpen}
chatOrUserIds={filteredContactsId} chatOrUserIds={filteredContactIds}
filterRef={filterRef} filterRef={filterRef}
filterPlaceholder={lang('BlockedUsers.BlockUser')} filterPlaceholder={lang('BlockedUsers.BlockUser')}
filter={filter} filter={filter}

View File

@ -1,13 +1,17 @@
import React, { import React, {
FC, useMemo, useState, memo, useRef, useCallback, FC, useMemo, useState, memo, useRef, useCallback,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn'; import { getGlobal, withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types'; import { GlobalActions } from '../../global/types';
import { ApiChat, MAIN_THREAD_ID } from '../../api/types'; import { ApiChat, MAIN_THREAD_ID } from '../../api/types';
import { getCanPostInChat, getChatTitle, sortChatIds } from '../../modules/helpers'; import {
import searchWords from '../../util/searchWords'; filterChatsByName,
filterUsersByName,
getCanPostInChat,
sortChatIds,
} from '../../modules/helpers';
import { pick, unique } from '../../util/iteratees'; import { pick, unique } from '../../util/iteratees';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
@ -20,10 +24,10 @@ export type OwnProps = {
type StateProps = { type StateProps = {
chatsById: Record<string, ApiChat>; chatsById: Record<string, ApiChat>;
pinnedIds?: string[];
activeListIds?: string[]; activeListIds?: string[];
archivedListIds?: string[]; archivedListIds?: string[];
orderedPinnedIds?: string[]; pinnedIds?: string[];
contactIds?: string[];
currentUserId?: string; currentUserId?: string;
}; };
@ -31,9 +35,10 @@ type DispatchProps = Pick<GlobalActions, 'setForwardChatId' | 'exitForwardMode'
const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({ const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({
chatsById, chatsById,
pinnedIds,
activeListIds, activeListIds,
archivedListIds, archivedListIds,
pinnedIds,
contactIds,
currentUserId, currentUserId,
isOpen, isOpen,
setForwardChatId, setForwardChatId,
@ -45,47 +50,45 @@ const ForwardPicker: FC<OwnProps & StateProps & DispatchProps> = ({
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const filterRef = useRef<HTMLInputElement>(null); const filterRef = useRef<HTMLInputElement>(null);
const chatIds = useMemo(() => { const chatAndContactIds = useMemo(() => {
if (!isOpen) { if (!isOpen) {
return undefined; return undefined;
} }
const listIds = [...(activeListIds || []), ...(archivedListIds || [])];
let priorityIds = pinnedIds || []; let priorityIds = pinnedIds || [];
if (currentUserId) { if (currentUserId) {
priorityIds = unique([currentUserId, ...priorityIds]); priorityIds = unique([currentUserId, ...priorityIds]);
} }
return sortChatIds(listIds.filter((id) => { const chatIds = [
...(activeListIds || []),
...(archivedListIds || []),
].filter((id) => {
const chat = chatsById[id]; const chat = chatsById[id];
if (!chat) {
return true;
}
if (!getCanPostInChat(chat, MAIN_THREAD_ID)) { return chat && getCanPostInChat(chat, MAIN_THREAD_ID);
return false; });
}
if (!filter) { // No need for expensive global updates on users, so we avoid them
return true; const usersById = getGlobal().users.byId;
}
return searchWords(getChatTitle(lang, chatsById[id], undefined, id === currentUserId), filter); return sortChatIds(unique([
}), chatsById, undefined, priorityIds); ...filterChatsByName(lang, chatIds, chatsById, filter, currentUserId),
}, [activeListIds, archivedListIds, chatsById, currentUserId, filter, isOpen, lang, pinnedIds]); ...(contactIds ? filterUsersByName(contactIds, usersById, filter) : []),
]), chatsById, undefined, priorityIds);
}, [activeListIds, archivedListIds, chatsById, contactIds, currentUserId, filter, isOpen, lang, pinnedIds]);
const handleSelectUser = useCallback((userId: string) => { const handleSelectUser = useCallback((userId: string) => {
setForwardChatId({ id: userId }); setForwardChatId({ id: userId });
}, [setForwardChatId]); }, [setForwardChatId]);
const renderingChatIds = useCurrentOrPrev(chatIds)!; const renderingChatAndContactIds = useCurrentOrPrev(chatAndContactIds)!;
return ( return (
<ChatOrUserPicker <ChatOrUserPicker
currentUserId={currentUserId} currentUserId={currentUserId}
isOpen={isOpen} isOpen={isOpen}
chatOrUserIds={renderingChatIds} chatOrUserIds={renderingChatAndContactIds}
filterRef={filterRef} filterRef={filterRef}
filterPlaceholder={lang('ForwardTo')} filterPlaceholder={lang('ForwardTo')}
filter={filter} filter={filter}
@ -110,9 +113,10 @@ export default memo(withGlobal<OwnProps>(
return { return {
chatsById, chatsById,
pinnedIds: orderedPinnedIds.active,
activeListIds: listIds.active, activeListIds: listIds.active,
archivedListIds: listIds.archived, archivedListIds: listIds.archived,
pinnedIds: orderedPinnedIds.active,
contactIds: global.contactList?.userIds,
currentUserId, currentUserId,
}; };
}, },

View File

@ -85,7 +85,6 @@ const AttachmentModal: FC<OwnProps> = ({
groupChatMembers, groupChatMembers,
undefined, undefined,
currentUserId, currentUserId,
usersById,
); );
const { const {
isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji, isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji,

View File

@ -299,7 +299,6 @@ const Composer: FC<OwnProps & StateProps & DispatchProps> = ({
groupChatMembers, groupChatMembers,
topInlineBotIds, topInlineBotIds,
currentUserId, currentUserId,
usersById,
); );
const { const {

View File

@ -1,15 +0,0 @@
import { ApiUser } from '../../../../api/types';
import { getUserFullName } from '../../../../modules/helpers';
import searchWords from '../../../../util/searchWords';
// TODO: Support cyrillic translit search
export default function searchUserName(filter: string, user: ApiUser) {
const usernameLowered = user.username.toLowerCase();
const fullName = getUserFullName(user);
const fullNameLowered = fullName && fullName.toLowerCase();
const filterLowered = filter.toLowerCase();
return usernameLowered.startsWith(filterLowered) || (
fullNameLowered && searchWords(fullNameLowered, filterLowered)
);
}

View File

@ -1,15 +1,15 @@
import { import {
useCallback, useEffect, useState, useMemo, useCallback, useEffect, useState,
} from '../../../../lib/teact/teact'; } from '../../../../lib/teact/teact';
import { getGlobal } from '../../../../lib/teact/teactn';
import { ApiMessageEntityTypes, ApiChatMember, ApiUser } from '../../../../api/types'; import { ApiMessageEntityTypes, ApiChatMember, ApiUser } from '../../../../api/types';
import { EDITABLE_INPUT_ID } from '../../../../config'; import { EDITABLE_INPUT_ID } from '../../../../config';
import { getUserFirstOrLastName } from '../../../../modules/helpers'; import { filterUsersByName, getUserFirstOrLastName } from '../../../../modules/helpers';
import searchUserName from '../helpers/searchUserName';
import { prepareForRegExp } from '../helpers/prepareForRegExp'; import { prepareForRegExp } from '../helpers/prepareForRegExp';
import focusEditableElement from '../../../../util/focusEditableElement'; import focusEditableElement from '../../../../util/focusEditableElement';
import useFlag from '../../../../hooks/useFlag'; import useFlag from '../../../../hooks/useFlag';
import { unique } from '../../../../util/iteratees'; import { pickTruthy, unique } from '../../../../util/iteratees';
import { throttle } from '../../../../util/schedulers'; import { throttle } from '../../../../util/schedulers';
const runThrottled = throttle((cb) => cb(), 500, true); const runThrottled = throttle((cb) => cb(), 500, true);
@ -30,39 +30,37 @@ export default function useMentionTooltip(
groupChatMembers?: ApiChatMember[], groupChatMembers?: ApiChatMember[],
topInlineBotIds?: string[], topInlineBotIds?: string[],
currentUserId?: string, currentUserId?: string,
usersById?: Record<string, ApiUser>,
) { ) {
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const [usersToMention, setUsersToMention] = useState<ApiUser[] | undefined>(); const [usersToMention, setUsersToMention] = useState<ApiUser[] | undefined>();
const topInlineBots = useMemo(() => { const updateFilteredUsers = useCallback((filter, withInlineBots: boolean) => {
return (topInlineBotIds || []).map((id) => usersById?.[id]).filter<ApiUser>(Boolean as any); // No need for expensive global updates on users, so we avoid them
}, [topInlineBotIds, usersById]); const usersById = getGlobal().users.byId;
const getFilteredUsers = useCallback((filter, withInlineBots: boolean) => {
if (!(groupChatMembers || topInlineBotIds) || !usersById) { if (!(groupChatMembers || topInlineBotIds) || !usersById) {
setUsersToMention(undefined); setUsersToMention(undefined);
return; return;
} }
runThrottled(() => { runThrottled(() => {
const inlineBots = (withInlineBots ? topInlineBots : []).filter((inlineBot) => { const memberIds = groupChatMembers?.reduce((acc: string[], member) => {
return !filter || searchUserName(filter, inlineBot); if (member.userId !== currentUserId) {
}); acc.push(member.userId);
}
const chatMembers = (groupChatMembers || []) return acc;
.map(({ userId }) => usersById[userId]) }, []);
.filter((user) => {
if (!user || user.id === currentUserId) {
return false;
}
return !filter || searchUserName(filter, user); const filteredIds = filterUsersByName(unique([
}); ...((withInlineBots && topInlineBotIds) || []),
...(memberIds || []),
]), usersById, filter);
setUsersToMention(unique(inlineBots.concat(chatMembers))); setUsersToMention(Object.values(pickTruthy(usersById, filteredIds)));
}); });
}, [currentUserId, groupChatMembers, topInlineBotIds, topInlineBots, usersById]); }, [currentUserId, groupChatMembers, topInlineBotIds]);
useEffect(() => { useEffect(() => {
if (!canSuggestMembers || !html.length) { if (!canSuggestMembers || !html.length) {
@ -74,11 +72,11 @@ export default function useMentionTooltip(
if (usernameFilter) { if (usernameFilter) {
const filter = usernameFilter ? usernameFilter.substr(1) : ''; const filter = usernameFilter ? usernameFilter.substr(1) : '';
getFilteredUsers(filter, canSuggestInlineBots(html)); updateFilteredUsers(filter, canSuggestInlineBots(html));
} else { } else {
unmarkIsOpen(); unmarkIsOpen();
} }
}, [canSuggestMembers, html, getFilteredUsers, markIsOpen, unmarkIsOpen]); }, [canSuggestMembers, html, updateFilteredUsers, markIsOpen, unmarkIsOpen]);
useEffect(() => { useEffect(() => {
if (usersToMention?.length) { if (usersToMention?.length) {

View File

@ -1,19 +1,18 @@
import React, { import React, {
FC, useCallback, useMemo, memo, useState, useEffect, FC, useCallback, useMemo, memo, useState, useEffect,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn'; import { getGlobal, withGlobal } from '../../lib/teact/teactn';
import { GlobalActions } from '../../global/types'; import { GlobalActions } from '../../global/types';
import { import {
ApiChat, ApiChatMember, ApiUpdateConnectionStateType, ApiUser, ApiChat, ApiChatMember, ApiUpdateConnectionStateType,
} from '../../api/types'; } from '../../api/types';
import { NewChatMembersProgress } from '../../types'; import { NewChatMembersProgress } from '../../types';
import { pick, unique } from '../../util/iteratees'; import { pick, unique } from '../../util/iteratees';
import { selectChat } from '../../modules/selectors'; import { selectChat } from '../../modules/selectors';
import searchWords from '../../util/searchWords';
import { import {
getUserFullName, isChatChannel, isUserBot, sortChatIds, filterUsersByName, isChatChannel, isUserBot, sortChatIds,
} from '../../modules/helpers'; } from '../../modules/helpers';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import usePrevious from '../../hooks/usePrevious'; import usePrevious from '../../hooks/usePrevious';
@ -37,7 +36,6 @@ type StateProps = {
isChannel?: boolean; isChannel?: boolean;
members?: ApiChatMember[]; members?: ApiChatMember[];
currentUserId?: string; currentUserId?: string;
usersById: Record<string, ApiUser>;
chatsById: Record<string, ApiChat>; chatsById: Record<string, ApiChat>;
localContactIds?: string[]; localContactIds?: string[];
searchQuery?: string; searchQuery?: string;
@ -55,7 +53,6 @@ const AddChatMembers: FC<OwnProps & StateProps & DispatchProps> = ({
members, members,
onNextStep, onNextStep,
currentUserId, currentUserId,
usersById,
chatsById, chatsById,
localContactIds, localContactIds,
isLoading, isLoading,
@ -90,43 +87,33 @@ const AddChatMembers: FC<OwnProps & StateProps & DispatchProps> = ({
}, [setUserSearchQuery]); }, [setUserSearchQuery]);
const displayedIds = useMemo(() => { const displayedIds = useMemo(() => {
const contactIds = localContactIds // No need for expensive global updates on users, so we avoid them
? sortChatIds(localContactIds.filter((id) => id !== currentUserId), chatsById) const usersById = getGlobal().users.byId;
: []; const filteredContactIds = localContactIds ? filterUsersByName(localContactIds, usersById, searchQuery) : [];
if (!searchQuery) {
return contactIds.filter((id) => !memberIds.includes(id));
}
const foundContactIds = contactIds.filter((id) => {
const user = usersById[id];
if (!user) {
return false;
}
const fullName = getUserFullName(user);
return fullName && searchWords(fullName, searchQuery);
});
return sortChatIds( return sortChatIds(
unique([ unique([
...foundContactIds, ...filteredContactIds,
...(localUserIds || []), ...(localUserIds || []),
...(globalUserIds || []), ...(globalUserIds || []),
]).filter((contactId) => { ]).filter((userId) => {
const user = usersById[contactId]; const user = usersById[userId];
// The user can be added to the chat if the following conditions are met: // The user can be added to the chat if the following conditions are met:
// the user has not yet been added to the current chat // the user has not yet been added to the current chat
// AND it is not the current user,
// AND (it is not found (user from global search) OR it is not a bot OR it is a bot, // AND (it is not found (user from global search) OR it is not a bot OR it is a bot,
// but the current chat is not a channel AND the appropriate permission is set). // but the current chat is not a channel AND the appropriate permission is set).
return !memberIds.includes(contactId) return (
&& (!user || !isUserBot(user) || (!isChannel && user.canBeInvitedToGroup)); !memberIds.includes(userId)
&& userId !== currentUserId
&& (!user || !isUserBot(user) || (!isChannel && user.canBeInvitedToGroup))
);
}), }),
chatsById, chatsById,
); );
}, [ }, [
localContactIds, chatsById, searchQuery, localUserIds, globalUserIds, localContactIds, chatsById, searchQuery, localUserIds, globalUserIds, currentUserId, memberIds, isChannel,
currentUserId, usersById, memberIds, isChannel,
]); ]);
const handleNextStep = useCallback(() => { const handleNextStep = useCallback(() => {
@ -172,7 +159,6 @@ export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => { (global, { chatId }): StateProps => {
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
const { userIds: localContactIds } = global.contactList || {}; const { userIds: localContactIds } = global.contactList || {};
const { byId: usersById } = global.users;
const { byId: chatsById } = global.chats; const { byId: chatsById } = global.chats;
const { currentUserId, newChatMembersProgress, connectionState } = global; const { currentUserId, newChatMembersProgress, connectionState } = global;
const isChannel = chat && isChatChannel(chat); const isChannel = chat && isChatChannel(chat);
@ -188,7 +174,6 @@ export default memo(withGlobal<OwnProps>(
isChannel, isChannel,
members: chat?.fullInfo?.members, members: chat?.fullInfo?.members,
currentUserId, currentUserId,
usersById,
chatsById, chatsById,
localContactIds, localContactIds,
searchQuery, searchQuery,

View File

@ -1,13 +1,12 @@
import React, { import React, {
FC, useMemo, useState, memo, useRef, useCallback, FC, useMemo, useState, memo, useRef, useCallback,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn'; import { getGlobal, withGlobal } from '../../../lib/teact/teactn';
import { GlobalActions } from '../../../global/types'; import { GlobalActions } from '../../../global/types';
import { ApiChat, ApiUser } from '../../../api/types'; import { ApiChat } from '../../../api/types';
import { getUserFullName } from '../../../modules/helpers'; import { filterUsersByName } from '../../../modules/helpers';
import searchWords from '../../../util/searchWords';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
@ -20,7 +19,6 @@ export type OwnProps = {
}; };
type StateProps = { type StateProps = {
usersById: Record<string, ApiUser>;
currentUserId?: string; currentUserId?: string;
}; };
@ -28,7 +26,6 @@ type DispatchProps = Pick<GlobalActions, 'loadMoreMembers' | 'deleteChatMember'>
const RemoveGroupUserModal: FC<OwnProps & StateProps & DispatchProps> = ({ const RemoveGroupUserModal: FC<OwnProps & StateProps & DispatchProps> = ({
chat, chat,
usersById,
currentUserId, currentUserId,
isOpen, isOpen,
onClose, onClose,
@ -41,22 +38,19 @@ const RemoveGroupUserModal: FC<OwnProps & StateProps & DispatchProps> = ({
const filterRef = useRef<HTMLInputElement>(null); const filterRef = useRef<HTMLInputElement>(null);
const usersId = useMemo(() => { const usersId = useMemo(() => {
const availableMembers = (chat.fullInfo?.members || []).filter((member) => { const availableMemberIds = (chat.fullInfo?.members || [])
return !member.isAdmin && !member.isOwner && member.userId !== currentUserId; .reduce((acc: string[], member) => {
}); if (!member.isAdmin && !member.isOwner && member.userId !== currentUserId) {
acc.push(member.userId);
}
return acc;
}, []);
return availableMembers.reduce<string[]>((acc, member) => { // No need for expensive global updates on users, so we avoid them
if ( const usersById = getGlobal().users.byId;
!filter
|| !usersById[member.userId]
|| searchWords(getUserFullName(usersById[member.userId]) || '', filter)
) {
acc.push(member.userId);
}
return acc; return filterUsersByName(availableMemberIds, usersById, filter);
}, []); }, [chat.fullInfo?.members, currentUserId, filter]);
}, [chat.fullInfo?.members, currentUserId, filter, usersById]);
const handleRemoveUser = useCallback((userId: string) => { const handleRemoveUser = useCallback((userId: string) => {
deleteChatMember({ chatId: chat.id, userId }); deleteChatMember({ chatId: chat.id, userId });
@ -80,14 +74,9 @@ const RemoveGroupUserModal: FC<OwnProps & StateProps & DispatchProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
const { const { currentUserId } = global;
users: {
byId: usersById,
},
currentUserId,
} = global;
return { usersById, currentUserId }; return { currentUserId };
}, },
(setGlobal, actions): DispatchProps => pick(actions, ['loadMoreMembers', 'deleteChatMember']), (setGlobal, actions): DispatchProps => pick(actions, ['loadMoreMembers', 'deleteChatMember']),
)(RemoveGroupUserModal)); )(RemoveGroupUserModal));

View File

@ -15,6 +15,7 @@ import { ARCHIVED_FOLDER_ID, REPLIES_USER_ID } from '../../config';
import { orderBy } from '../../util/iteratees'; import { orderBy } from '../../util/iteratees';
import { getUserFirstOrLastName } from './users'; import { getUserFirstOrLastName } from './users';
import { formatDateToString, formatTime } from '../../util/dateFormat'; import { formatDateToString, formatTime } from '../../util/dateFormat';
import { prepareSearchWordsForNeedle } from '../../util/searchWords';
const FOREVER_BANNED_DATE = Date.now() / 1000 + 31622400; // 366 days const FOREVER_BANNED_DATE = Date.now() / 1000 + 31622400; // 366 days
@ -560,3 +561,26 @@ export function sortChatIds(
return priority; return priority;
}, 'desc'); }, 'desc');
} }
export function filterChatsByName(
lang: LangFn,
chatIds: string[],
chatsById: Record<string, ApiChat>,
query?: string,
currentUserId?: string,
) {
if (!query) {
return chatIds;
}
const searchWords = prepareSearchWordsForNeedle(query);
return chatIds.filter((id) => {
const chat = chatsById[id];
if (!chat) {
return false;
}
return searchWords(getChatTitle(lang, chat, undefined, id === currentUserId));
});
}

View File

@ -5,6 +5,7 @@ import { formatFullDate, formatTime } from '../../util/dateFormat';
import { orderBy } from '../../util/iteratees'; import { orderBy } from '../../util/iteratees';
import { LangFn } from '../../hooks/useLang'; import { LangFn } from '../../hooks/useLang';
import { getServerTime } from '../../util/serverTime'; import { getServerTime } from '../../util/serverTime';
import { prepareSearchWordsForNeedle } from '../../util/searchWords';
const USER_COLOR_KEYS = [1, 8, 5, 2, 7, 4, 6]; const USER_COLOR_KEYS = [1, 8, 5, 2, 7, 4, 6];
@ -231,6 +232,24 @@ export function sortUserIds(
}, 'desc'); }, 'desc');
} }
export function filterUsersByName(userIds: string[], usersById: Record<string, ApiUser>, query?: string) {
if (!query) {
return userIds;
}
const searchWords = prepareSearchWordsForNeedle(query);
return userIds.filter((id) => {
const user = usersById[id];
if (!user) {
return false;
}
const name = getUserFullName(user);
return (name && searchWords(name)) || searchWords(user.username);
});
}
export function getUserIdDividend(userId: string) { export function getUserIdDividend(userId: string) {
// Workaround for old-fashioned IDs stored locally // Workaround for old-fashioned IDs stored locally
if (typeof userId === 'number') { if (typeof userId === 'number') {

View File

@ -7,15 +7,36 @@ try {
RE_NOT_LETTER = new RegExp('[^\\wа-яё]+', 'i'); RE_NOT_LETTER = new RegExp('[^\\wа-яё]+', 'i');
} }
export default function searchWords(haystack: string, needle: string) { export default function searchWords(haystack: string, needle: string | string[]) {
if (!haystack || !needle) { if (!haystack || !needle) {
return false; return false;
} }
const haystackWords = haystack.toLowerCase().split(RE_NOT_LETTER); const needleWords = typeof needle === 'string' ? needle.toLowerCase().split(RE_NOT_LETTER) : needle;
const haystackLower = haystack.toLowerCase();
// @optimization
if (needleWords.length === 1 && !haystackLower.includes(needleWords[0])) {
return false;
}
let haystackWords: string[];
return needleWords.every((needleWord) => {
if (!haystackLower.includes(needleWord)) {
return false;
}
if (!haystackWords) {
haystackWords = haystackLower.split(RE_NOT_LETTER);
}
return haystackWords.some((haystackWord) => haystackWord.startsWith(needleWord));
});
}
export function prepareSearchWordsForNeedle(needle: string) {
const needleWords = needle.toLowerCase().split(RE_NOT_LETTER); const needleWords = needle.toLowerCase().split(RE_NOT_LETTER);
return needleWords.every((needleWord) => ( return (haystack: string) => searchWords(haystack, needleWords);
haystackWords.some((haystackWord) => haystackWord.startsWith(needleWord))
));
} }