diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 65fc4f79a..b8f4114f6 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -100,6 +100,9 @@ type ChatListData = { totalChatCount: number; messages: ApiMessage[]; lastMessageByChatId: Record; + nextOffsetId?: number; + nextOffsetPeerId?: string; + nextOffsetDate?: number; }; let onUpdate: OnApiUpdate; @@ -111,18 +114,24 @@ export function init(_onUpdate: OnApiUpdate) { export async function fetchChats({ limit, offsetDate, + offsetPeer, + offsetId, archived, withPinned, lastLocalServiceMessageId, }: { limit: number; offsetDate?: number; + offsetPeer?: ApiPeer; + offsetId?: number; archived?: boolean; withPinned?: boolean; lastLocalServiceMessageId?: number; }): Promise { + const peer = (offsetPeer && buildInputPeer(offsetPeer.id, offsetPeer.accessHash)) || new GramJs.InputPeerEmpty(); const result = await invokeRequest(new GramJs.messages.GetDialogs({ - offsetPeer: new GramJs.InputPeerEmpty(), + offsetPeer: peer, + offsetId, limit, offsetDate, ...(withPinned && { excludePinned: true }), @@ -217,6 +226,13 @@ export async function fetchChats({ totalChatCount = chatIds.length; } + const lastDialog = chats[chats.length - 1]; + const lastMessageId = lastMessageByChatId[lastDialog?.id]; + const nextOffsetId = lastMessageId; + const nextOffsetPeerId = lastDialog?.id; + const nextOffsetDate = messages.reverse() + .find((message) => message.chatId === lastDialog?.id && message.id === lastMessageId)?.date; + return { chatIds, chats, @@ -227,20 +243,29 @@ export async function fetchChats({ totalChatCount, lastMessageByChatId, messages, + nextOffsetId, + nextOffsetPeerId, + nextOffsetDate, }; } export async function fetchSavedChats({ limit, offsetDate, + offsetPeer, + offsetId, withPinned, }: { limit: number; offsetDate?: number; + offsetPeer?: ApiPeer; + offsetId?: number; withPinned?: boolean; }): Promise { + const peer = (offsetPeer && buildInputPeer(offsetPeer.id, offsetPeer.accessHash)) || new GramJs.InputPeerEmpty(); const result = await invokeRequest(new GramJs.messages.GetSavedDialogs({ - offsetPeer: new GramJs.InputPeerEmpty(), + offsetPeer: peer, + offsetId, limit, offsetDate, ...(withPinned && { excludePinned: true }), @@ -305,6 +330,13 @@ export async function fetchSavedChats({ totalChatCount = chatIds.length; } + const lastDialog = chats[chats.length - 1]; + const lastMessageId = lastMessageByChatId[lastDialog?.id]; + const nextOffsetId = lastMessageId; + const nextOffsetPeerId = lastDialog?.id; + const nextOffsetDate = messages.reverse() + .find((message) => message.chatId === lastDialog?.id && message.id === lastMessageId)?.date; + return { chatIds, chats, @@ -315,6 +347,9 @@ export async function fetchSavedChats({ lastMessageByChatId, messages, draftsById: {}, + nextOffsetId, + nextOffsetPeerId, + nextOffsetDate, }; } diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 5de53a808..c87bb4413 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -27,7 +27,7 @@ export { export { fetchMessages, fetchMessage, sendMessage, pinMessage, unpinAllMessages, deleteMessages, deleteHistory, - markMessageListRead, markMessagesRead, searchMessagesLocal, searchMessagesGlobal, + markMessageListRead, markMessagesRead, searchMessagesInChat, searchMessagesGlobal, searchHashtagPosts, fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate, fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages, reportMessages, sendMessageAction, fetchSeenBy, fetchSponsoredMessages, viewSponsoredMessage, fetchSendAs, diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index a0d7a1e3f..eeee84dba 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -22,6 +22,7 @@ import type { ApiSticker, ApiStory, ApiStorySkipped, + ApiUser, ApiVideo, MediaContent, OnApiUpdate, @@ -43,7 +44,7 @@ import { import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; import { fetchFile } from '../../../util/files'; import { compact, split } from '../../../util/iteratees'; -import { getMessageKey } from '../../../util/messageKey'; +import { getMessageKey } from '../../../util/keys/messageKey'; import { getServerTimeOffset } from '../../../util/serverTime'; import { interpolateArray } from '../../../util/waveform'; import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats'; @@ -83,6 +84,7 @@ import { addEntitiesToLocalDb, addMessageToLocalDb, deserializeBytes, + resolveMessageApiChatId, } from '../helpers'; import { processAffectedHistory, updateChannelState } from '../updates/updateManager'; import { dispatchThreadInfoUpdates } from '../updates/updater'; @@ -101,6 +103,16 @@ type TranslateTextParams = ({ toLanguageCode: string; }; +type SearchResults = { + messages: ApiMessage[]; + users: ApiUser[]; + chats: ApiChat[]; + totalCount: number; + nextOffsetRate?: number; + nextOffsetPeerId?: string; + nextOffsetId?: number; +}; + let onUpdate: OnApiUpdate; export function init(_onUpdate: OnApiUpdate) { @@ -1135,8 +1147,8 @@ export async function fetchDiscussionMessage({ }; } -export async function searchMessagesLocal({ - chat, isSavedDialog, savedTag, type, query, threadId, minDate, maxDate, ...pagination +export async function searchMessagesInChat({ + chat, isSavedDialog, savedTag, type, query = '', threadId, minDate, maxDate, ...pagination }: { chat: ApiChat; isSavedDialog?: boolean; @@ -1149,7 +1161,7 @@ export async function searchMessagesLocal({ limit: number; minDate?: number; maxDate?: number; -}) { +}): Promise { let filter; switch (type) { case 'media': @@ -1184,7 +1196,7 @@ export async function searchMessagesLocal({ savedReaction: savedTag && [buildInputReaction(savedTag)], topMsgId: threadId !== MAIN_THREAD_ID && !isSavedDialog ? Number(threadId) : undefined, filter, - q: query || '', + q: query, minDate, maxDate, ...pagination, @@ -1228,15 +1240,17 @@ export async function searchMessagesLocal({ } export async function searchMessagesGlobal({ - query, offsetRate = 0, limit, type = 'text', minDate, maxDate, + query, offsetRate = 0, offsetPeer, offsetId, limit, type = 'text', minDate, maxDate, }: { query: string; offsetRate?: number; + offsetPeer?: ApiPeer; + offsetId?: number; limit: number; type?: ApiGlobalMessageSearchType; minDate?: number; maxDate?: number; -}) { +}): Promise { let filter; switch (type) { case 'media': @@ -1264,10 +1278,13 @@ export async function searchMessagesGlobal({ } } + const peer = (offsetPeer && buildInputPeer(offsetPeer.id, offsetPeer.accessHash)) || new GramJs.InputPeerEmpty(); + const result = await invokeRequest(new GramJs.messages.SearchGlobal({ q: query, offsetRate, - offsetPeer: new GramJs.InputPeerEmpty(), + offsetPeer: peer, + offsetId, broadcastsOnly: type === 'channels' || undefined, limit, filter, @@ -1284,11 +1301,7 @@ export async function searchMessagesGlobal({ return undefined; } - updateLocalDb({ - chats: result.chats, - users: result.users, - messages: result.messages, - } as GramJs.messages.Messages); + updateLocalDb(result); const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); const users = result.users.map(buildApiUser).filter(Boolean); @@ -1296,21 +1309,77 @@ export async function searchMessagesGlobal({ dispatchThreadInfoUpdates(result.messages); let totalCount = messages.length; - let nextRate: number | undefined; if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) { totalCount = result.count; - - if (messages.length) { - nextRate = messages[messages.length - 1].id; - } + } else { + totalCount = result.messages.length; } + const lastMessage = result.messages[result.messages.length - 1]; + const nextOffsetPeerId = resolveMessageApiChatId(lastMessage); + const nextOffsetRate = 'nextRate' in result && result.nextRate ? result.nextRate : undefined; + const nextOffsetId = lastMessage?.id; + return { messages, users, chats, totalCount, - nextRate: 'nextRate' in result && result.nextRate ? result.nextRate : nextRate, + nextOffsetRate, + nextOffsetPeerId, + nextOffsetId, + }; +} + +export async function searchHashtagPosts({ + hashtag, offsetRate, offsetPeer, offsetId, limit, +}: { + hashtag: string; + offsetRate?: number; + offsetPeer?: ApiPeer; + offsetId?: number; + limit?: number; +}): Promise { + const peer = (offsetPeer && buildInputPeer(offsetPeer.id, offsetPeer.accessHash)) || new GramJs.InputPeerEmpty(); + const result = await invokeRequest(new GramJs.channels.SearchPosts({ + hashtag, + offsetRate, + offsetId, + offsetPeer: peer, + limit, + })); + + if (!result || result instanceof GramJs.messages.MessagesNotModified) { + return undefined; + } + + updateLocalDb(result); + + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean); + const users = result.users.map(buildApiUser).filter(Boolean); + const messages = result.messages.map(buildApiMessage).filter(Boolean); + dispatchThreadInfoUpdates(result.messages); + + let totalCount = messages.length; + if (result instanceof GramJs.messages.MessagesSlice || result instanceof GramJs.messages.ChannelMessages) { + totalCount = result.count; + } else { + totalCount = result.messages.length; + } + + const lastMessage = result.messages[result.messages.length - 1]; + const nextOffsetPeerId = resolveMessageApiChatId(lastMessage); + const nextOffsetRate = 'nextRate' in result && result.nextRate ? result.nextRate : undefined; + const nextOffsetId = lastMessage?.id; + + return { + messages, + users, + chats, + totalCount, + nextOffsetRate, + nextOffsetPeerId, + nextOffsetId, }; } diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 6283d195b..ff657f0ff 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -22,7 +22,7 @@ import { import { addEntitiesToLocalDb, addPhotoToLocalDb, addUserToLocalDb } from '../helpers'; import localDb from '../localDb'; import { invokeRequest } from './client'; -import { searchMessagesLocal } from './messages'; +import { searchMessagesInChat } from './messages'; let onUpdate: OnApiUpdate; @@ -139,7 +139,7 @@ export async function fetchTopUsers() { return undefined; } - const users = topPeers.users.map(buildApiUser).filter((user) => Boolean(user) && !user.isSelf) as ApiUser[]; + const users = topPeers.users.map(buildApiUser).filter((user): user is ApiUser => Boolean(user) && !user.isSelf); const ids = users.map(({ id }) => id); return { @@ -295,7 +295,7 @@ export async function fetchProfilePhotos({ if (chat?.isRestricted) return undefined; - const result = await searchMessagesLocal({ + const result = await searchMessagesInChat({ chat: chat!, type: 'profilePhoto', limit, diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index b1ba1a30d..990dbf4ea 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1271,3 +1271,5 @@ "MenuInstallApp" = "Install App"; "RemoveEffect" = "Remove effect"; "ReplyInPrivateMessage" = "Reply In Private Message"; +"AriaSearchOlderResult" = "Focus next result"; +"AriaSearchNewerResult" = "Focus previous result"; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 147d0c9fe..7700b5912 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -57,7 +57,7 @@ export { default as SponsoredMessageContextMenuContainer } export { default as StickerSetModal } from '../components/common/StickerSetModal'; export { default as CustomEmojiSetsModal } from '../components/common/CustomEmojiSetsModal'; export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer'; -export { default as MobileSearch } from '../components/middle/MobileSearch'; +export { default as MiddleSearch } from '../components/middle/search/MiddleSearch'; export { default as ReactionPicker } from '../components/middle/message/reactions/ReactionPicker'; export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal'; @@ -75,7 +75,6 @@ export { default as EmojiTooltip } from '../components/middle/composer/EmojiTool export { default as InlineBotTooltip } from '../components/middle/composer/InlineBotTooltip'; export { default as SendAsMenu } from '../components/middle/composer/SendAsMenu'; -export { default as RightSearch } from '../components/right/RightSearch'; export { default as StickerSearch } from '../components/right/StickerSearch'; export { default as GifSearch } from '../components/right/GifSearch'; export { default as Statistics } from '../components/right/statistics/Statistics'; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index 5b7cf5e74..f5c8da813 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -4,8 +4,7 @@ import React, { memo, useMemo, useRef } from '../../lib/teact/teact'; import { getActions } from '../../global'; import type { - ApiChat, ApiPeer, ApiPhoto, ApiUser, - ApiWebDocument, + ApiPeer, ApiPhoto, ApiWebDocument, } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { CustomPeer, StoryViewerOrigin } from '../../types'; @@ -22,6 +21,8 @@ import { isAnonymousForwardsChat, isChatWithRepliesBot, isDeletedUser, + isPeerChat, + isPeerUser, isUserId, } from '../../global/helpers'; import buildClassName, { createClassNameBuilder } from '../../util/buildClassName'; @@ -102,9 +103,8 @@ const Avatar: FC = ({ const videoLoopCountRef = useRef(0); const isCustomPeer = peer && 'isCustomPeer' in peer; const realPeer = peer && !isCustomPeer ? peer : undefined; - const isPeerChat = realPeer && 'title' in realPeer; - const user = peer && !isPeerChat ? peer as ApiUser : undefined; - const chat = peer && isPeerChat ? peer as ApiChat : undefined; + const user = realPeer && isPeerUser(realPeer) ? realPeer : undefined; + const chat = realPeer && isPeerChat(realPeer) ? realPeer : undefined; const isDeleted = user && isDeletedUser(user); const isReplies = realPeer && isChatWithRepliesBot(realPeer.id); const isAnonymousForwards = realPeer && isAnonymousForwardsChat(realPeer.id); diff --git a/src/components/common/FullNameTitle.tsx b/src/components/common/FullNameTitle.tsx index ffc672201..6ff417bcc 100644 --- a/src/components/common/FullNameTitle.tsx +++ b/src/components/common/FullNameTitle.tsx @@ -3,14 +3,14 @@ import React, { memo, useMemo } from '../../lib/teact/teact'; import { getActions } from '../../global'; import type { - ApiChat, ApiPeer, ApiUser, + ApiPeer, } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { CustomPeer } from '../../types'; import { EMOJI_STATUS_LOOP_LIMIT } from '../../config'; import { - getChatTitle, getUserFullName, isAnonymousForwardsChat, isChatWithRepliesBot, isUserId, + getChatTitle, getUserFullName, isAnonymousForwardsChat, isChatWithRepliesBot, isPeerUser, } from '../../global/helpers'; import buildClassName from '../../util/buildClassName'; import { copyTextToClipboard } from '../../util/clipboard'; @@ -62,9 +62,9 @@ const FullNameTitle: FC = ({ const { showNotification } = getActions(); const realPeer = 'id' in peer ? peer : undefined; const customPeer = 'isCustomPeer' in peer ? peer : undefined; - const isUser = realPeer && isUserId(realPeer.id); - const title = realPeer && (isUser ? getUserFullName(realPeer as ApiUser) : getChatTitle(lang, realPeer as ApiChat)); - const isPremium = isUser && (peer as ApiUser).isPremium; + const isUser = realPeer && isPeerUser(realPeer); + const title = realPeer && (isUser ? getUserFullName(realPeer) : getChatTitle(lang, realPeer)); + const isPremium = isUser && realPeer.isPremium; const handleTitleClick = useLastCallback((e) => { if (!title || !canCopyTitle) { diff --git a/src/components/common/LastMessageMeta.tsx b/src/components/common/LastMessageMeta.tsx index 55f74b87b..82798d43d 100644 --- a/src/components/common/LastMessageMeta.tsx +++ b/src/components/common/LastMessageMeta.tsx @@ -1,8 +1,8 @@ -import type { FC } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact'; import type { ApiMessage, ApiMessageOutgoingStatus } from '../../api/types'; +import buildClassName from '../../util/buildClassName'; import { formatPastTimeShort } from '../../util/dates/dateFormat'; import useOldLang from '../../hooks/useOldLang'; @@ -12,17 +12,20 @@ import MessageOutgoingStatus from './MessageOutgoingStatus'; import './LastMessageMeta.scss'; type OwnProps = { + className?: string; message: ApiMessage; outgoingStatus?: ApiMessageOutgoingStatus; draftDate?: number; }; -const LastMessageMeta: FC = ({ message, outgoingStatus, draftDate }) => { +const LastMessageMeta = ({ + className, message, outgoingStatus, draftDate, +}: OwnProps) => { const lang = useOldLang(); const shouldUseDraft = draftDate && draftDate > message.date; return ( -
+
{outgoingStatus && !shouldUseDraft && ( )} diff --git a/src/components/common/MessageSummary.tsx b/src/components/common/MessageSummary.tsx index ea7aebc0f..eed2648d6 100644 --- a/src/components/common/MessageSummary.tsx +++ b/src/components/common/MessageSummary.tsx @@ -2,7 +2,6 @@ import React, { memo } from '../../lib/teact/teact'; import type { ApiFormattedText, ApiMessage } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; -import type { LangFn } from '../../hooks/useOldLang'; import { ApiMessageEntityTypes } from '../../api/types'; import { @@ -18,42 +17,43 @@ import { import trimText from '../../util/trimText'; import renderText from './helpers/renderText'; +import useOldLang from '../../hooks/useOldLang'; + import MessageText from './MessageText'; interface OwnProps { - lang: LangFn; message: ApiMessage; translatedText?: ApiFormattedText; noEmoji?: boolean; highlight?: string; truncateLength?: number; - observeIntersectionForLoading?: ObserveFn; - observeIntersectionForPlaying?: ObserveFn; withTranslucentThumbs?: boolean; inChatList?: boolean; emojiSize?: number; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; } function MessageSummary({ - lang, message, translatedText, noEmoji = false, highlight, truncateLength = TRUNCATED_SUMMARY_LENGTH, - observeIntersectionForLoading, - observeIntersectionForPlaying, withTranslucentThumbs = false, inChatList = false, emojiSize, + observeIntersectionForLoading, + observeIntersectionForPlaying, }: OwnProps) { + const lang = useOldLang(); const { text, entities } = extractMessageText(message, inChatList) || {}; const hasSpoilers = entities?.some((e) => e.type === ApiMessageEntityTypes.Spoiler); const hasCustomEmoji = entities?.some((e) => e.type === ApiMessageEntityTypes.CustomEmoji); const hasPoll = Boolean(getMessagePoll(message)); if ((!text || (!hasSpoilers && !hasCustomEmoji)) && !hasPoll) { - const summaryText = translatedText?.text || getMessageSummaryText(lang, message, noEmoji); + const summaryText = translatedText?.text || getMessageSummaryText(lang, message, noEmoji, truncateLength); const trimmedText = trimText(summaryText, truncateLength); return ( diff --git a/src/components/common/PickerSelectedItem.scss b/src/components/common/PickerSelectedItem.scss index 671b219aa..317ae762e 100644 --- a/src/components/common/PickerSelectedItem.scss +++ b/src/components/common/PickerSelectedItem.scss @@ -4,8 +4,8 @@ background: var(--color-chat-hover); height: 2rem; min-width: 2rem; - margin-left: 0.5rem; - margin-bottom: 0.5rem; + margin-left: 0.25rem; + margin-right: 0.25rem; padding-right: 1rem; border-radius: 1rem; cursor: var(--custom-cursor, pointer); @@ -55,15 +55,6 @@ max-width: unset; } - .SearchInput & { - flex: 1 0 auto; - position: relative; - top: 0.25rem; - left: -0.125rem; - - color: var(--color-text-secondary); - } - .Avatar, .item-icon { width: 2rem; diff --git a/src/components/common/PickerSelectedItem.tsx b/src/components/common/PickerSelectedItem.tsx index d63b985c8..b800e21db 100644 --- a/src/components/common/PickerSelectedItem.tsx +++ b/src/components/common/PickerSelectedItem.tsx @@ -1,4 +1,4 @@ -import type { FC, TeactNode } from '../../lib/teact/teact'; +import type { TeactNode } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact'; import { withGlobal } from '../../global'; @@ -19,19 +19,21 @@ import Icon from './icons/Icon'; import './PickerSelectedItem.scss'; -type OwnProps = { +type OwnProps = { + // eslint-disable-next-line react/no-unused-prop-types peerId?: string; + // eslint-disable-next-line react/no-unused-prop-types + forceShowSelf?: boolean; customPeer?: CustomPeer; icon?: IconName; title?: string; isMinimized?: boolean; canClose?: boolean; - forceShowSelf?: boolean; - clickArg?: any; className?: string; fluid?: boolean; withPeerColors?: boolean; - onClick: (arg: any) => void; + clickArg: T; + onClick: (arg: T) => void; }; type StateProps = { @@ -40,7 +42,8 @@ type StateProps = { isSavedMessages?: boolean; }; -const PickerSelectedItem: FC = ({ +// eslint-disable-next-line @typescript-eslint/comma-dangle +const PickerSelectedItem = ({ icon, title, isMinimized, @@ -54,7 +57,7 @@ const PickerSelectedItem: FC = ({ isSavedMessages, withPeerColors, onClick, -}) => { +}: OwnProps & StateProps) => { const lang = useOldLang(); let iconElement: TeactNode | undefined; @@ -82,7 +85,7 @@ const PickerSelectedItem: FC = ({ ? getUserFirstOrLastName(user) : getChatTitle(lang, chat, isSavedMessages)); - titleText = name ? renderText(name) : undefined; + titleText = title || (name ? renderText(name) : undefined); } const fullClassName = buildClassName( @@ -133,4 +136,4 @@ export default memo(withGlobal( isSavedMessages, }; }, -)(PickerSelectedItem)); +)(PickerSelectedItem)) as typeof PickerSelectedItem; diff --git a/src/components/common/embedded/EmbeddedMessage.tsx b/src/components/common/embedded/EmbeddedMessage.tsx index ef26e17d4..a6dc6f794 100644 --- a/src/components/common/embedded/EmbeddedMessage.tsx +++ b/src/components/common/embedded/EmbeddedMessage.tsx @@ -152,7 +152,6 @@ const EmbeddedMessage: FC = ({ return ( ) { } function handleHashtagClick(e: React.MouseEvent) { - getActions().setLocalTextSearchQuery({ query: e.currentTarget.innerText }); - getActions().searchTextMessagesLocal(); + getActions().searchHashtag({ hashtag: e.currentTarget.innerText }); } function handleCodeClick(e: React.MouseEvent) { diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index 1344855b8..51776fa09 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -562,7 +562,7 @@ export default memo(withGlobal( const { globalSearch: { query, - date, + minDate, }, shouldSkipHistoryAnimations, activeChatFolder, @@ -589,7 +589,7 @@ export default memo(withGlobal( return { searchQuery: query, - searchDate: date, + searchDate: minDate, isFirstChatFolderActive: activeChatFolder === 0, shouldSkipHistoryAnimations, currentUserId, diff --git a/src/components/left/main/LeftMainHeader.scss b/src/components/left/main/LeftMainHeader.scss index aebeadb93..eb3d47d15 100644 --- a/src/components/left/main/LeftMainHeader.scss +++ b/src/components/left/main/LeftMainHeader.scss @@ -141,4 +141,10 @@ pointer-events: none; } } + + .left-search-picker-item { + color: var(--color-text-secondary); + font-weight: 500; + padding-right: 0; + } } diff --git a/src/components/left/main/LeftMainHeader.tsx b/src/components/left/main/LeftMainHeader.tsx index 99594a78b..98d92679b 100644 --- a/src/components/left/main/LeftMainHeader.tsx +++ b/src/components/left/main/LeftMainHeader.tsx @@ -222,18 +222,22 @@ const LeftMainHeader: FC = ({ )} {globalSearchChatId && ( )} @@ -269,7 +273,7 @@ const LeftMainHeader: FC = ({ ( (global): StateProps => { const tabState = selectTabState(global); const { - query: searchQuery, fetchingStatus, chatId, date, + query: searchQuery, fetchingStatus, chatId, minDate, } = tabState.globalSearch; const { connectionState, isSyncing, isFetchingDifference, @@ -335,7 +339,7 @@ export default memo(withGlobal( searchQuery, isLoading: fetchingStatus ? Boolean(fetchingStatus.chats || fetchingStatus.messages) : false, globalSearchChatId: chatId, - searchDate: date, + searchDate: minDate, theme: selectTheme(global), connectionState, isSyncing, diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index 6e92fbe44..7f65aeb86 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -8,7 +8,6 @@ import type { } from '../../../../api/types'; import type { ApiDraft } from '../../../../global/types'; import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; -import type { LangFn } from '../../../../hooks/useOldLang'; import { ANIMATION_END_DELAY, CHAT_HEIGHT_PX } from '../../../../config'; import { requestMutation } from '../../../../lib/fasterdom/fasterdom'; @@ -177,7 +176,7 @@ export default function useChatListEntry({ )} {!isSavedDialog && lastMessage.forwardInfo && ()} {lastMessage.replyInfo?.type === 'story' && ()} - {renderSummary(lang, lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)} + {renderSummary(lastMessage, observeIntersection, mediaBlobUrl || mediaThumbnail, isRoundVideo)}

); }, [ @@ -243,11 +242,10 @@ export default function useChatListEntry({ } function renderSummary( - lang: LangFn, message: ApiMessage, observeIntersection?: ObserveFn, blobUrl?: string, isRoundVideo?: boolean, + message: ApiMessage, observeIntersection?: ObserveFn, blobUrl?: string, isRoundVideo?: boolean, ) { const messageSummary = ( = ({ } return foundIds.map((id) => { - const [chatId, messageId] = id.split('_'); + const [chatId, messageId] = parseSearchResultKey(id); - return globalMessagesByChatId[chatId]?.byId[Number(messageId)]; + return globalMessagesByChatId[chatId]?.byId[messageId]; }).filter(Boolean); }, [globalMessagesByChatId, foundIds]); diff --git a/src/components/left/search/ChatMessageResults.tsx b/src/components/left/search/ChatMessageResults.tsx index c4fcbba65..81f3cd8e2 100644 --- a/src/components/left/search/ChatMessageResults.tsx +++ b/src/components/left/search/ChatMessageResults.tsx @@ -6,6 +6,7 @@ import type { ApiChat, ApiMessage } from '../../../api/types'; import { LoadMoreDirection } from '../../../types'; import { selectTabState } from '../../../global/selectors'; +import { parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { throttle } from '../../../util/schedulers'; import { renderMessageSummary } from '../../common/helpers/renderMessageText'; @@ -28,7 +29,7 @@ export type OwnProps = { type StateProps = { currentUserId?: string; - foundIds?: string[]; + foundIds?: SearchResultKey[]; globalMessagesByChatId?: Record }>; chatsById: Record; fetchingStatus?: { chats?: boolean; messages?: boolean }; @@ -85,9 +86,9 @@ const ChatMessageResults: FC = ({ return foundIds .map((id) => { - const [chatId, messageId] = id.split('_'); + const [chatId, messageId] = parseSearchResultKey(id); - return globalMessagesByChatId?.[chatId]?.byId[Number(messageId)]; + return globalMessagesByChatId?.[chatId]?.byId[messageId]; }) .filter(Boolean) .sort((a, b) => b.date - a.date); diff --git a/src/components/left/search/ChatResults.tsx b/src/components/left/search/ChatResults.tsx index 965246ed3..ec78d6c74 100644 --- a/src/components/left/search/ChatResults.tsx +++ b/src/components/left/search/ChatResults.tsx @@ -16,6 +16,7 @@ import { import { selectSimilarChannelIds, selectTabState } from '../../../global/selectors'; import { getOrderedIds } from '../../../util/folderManager'; import { unique } from '../../../util/iteratees'; +import { parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { throttle } from '../../../util/schedulers'; import { renderMessageSummary } from '../../common/helpers/renderMessageText'; @@ -49,7 +50,7 @@ type StateProps = { contactIds?: string[]; accountPeerIds?: string[]; globalPeerIds?: string[]; - foundIds?: string[]; + foundIds?: SearchResultKey[]; globalMessagesByChatId?: Record }>; fetchingStatus?: { chats?: boolean; messages?: boolean }; suggestedChannelIds?: string[]; @@ -191,12 +192,12 @@ const ChatResults: FC = ({ return foundIds .map((id) => { - const [chatId, messageId] = id.split('_'); + const [chatId, messageId] = parseSearchResultKey(id); const chat = chatsById[chatId]; if (!chat) return undefined; if (isChannelList && !isChatChannel(chat)) return undefined; - return globalMessagesByChatId?.[chatId]?.byId[Number(messageId)]; + return globalMessagesByChatId?.[chatId]?.byId[messageId]; }) .filter(Boolean); }, [searchQuery, searchDate, foundIds, isChannelList, globalMessagesByChatId]); diff --git a/src/components/left/search/DateSuggest.scss b/src/components/left/search/DateSuggest.scss index ce5d30c52..a46de6d4f 100644 --- a/src/components/left/search/DateSuggest.scss +++ b/src/components/left/search/DateSuggest.scss @@ -4,7 +4,6 @@ flex-direction: row; justify-content: space-between; margin-left: 0.5rem; - margin-bottom: 0.5rem; .date-item { display: flex; diff --git a/src/components/left/search/FileResults.tsx b/src/components/left/search/FileResults.tsx index fca1f3446..ebca1ea07 100644 --- a/src/components/left/search/FileResults.tsx +++ b/src/components/left/search/FileResults.tsx @@ -12,6 +12,7 @@ import { SLIDE_TRANSITION_DURATION } from '../../../config'; import { getIsDownloading, getMessageDocument } from '../../../global/helpers'; import buildClassName from '../../../util/buildClassName'; import { formatMonthAndYear, toYearMonth } from '../../../util/dates/dateFormat'; +import { parseSearchResultKey } from '../../../util/keys/searchResultKey'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { throttle } from '../../../util/schedulers'; import { createMapStateToProps } from './helpers/createMapStateToProps'; @@ -77,8 +78,8 @@ const FileResults: FC = ({ } return foundIds.map((id) => { - const [chatId, messageId] = id.split('_'); - const message = globalMessagesByChatId[chatId]?.byId[Number(messageId)]; + const [chatId, messageId] = parseSearchResultKey(id); + const message = globalMessagesByChatId[chatId]?.byId[messageId]; return message && getMessageDocument(message) ? message : undefined; }).filter(Boolean) as ApiMessage[]; diff --git a/src/components/left/search/LeftSearch.scss b/src/components/left/search/LeftSearch.scss index 841fbc67c..989f5c0eb 100644 --- a/src/components/left/search/LeftSearch.scss +++ b/src/components/left/search/LeftSearch.scss @@ -24,9 +24,9 @@ .section-heading { position: relative; - padding-top: 1.25rem; + padding-top: 0.25rem; padding-left: 1.25rem; - margin: 0 0 1rem -1.25rem !important; + margin: 0 0 0.5rem -1.25rem !important; font-weight: 500; font-size: 0.9375rem; @@ -177,17 +177,6 @@ } } - .ListItem.search-result-message { - .sender-name { - color: var(--color-text); - - &::after { - content: ": "; - white-space: pre; - } - } - } - @media (max-width: 600px) { .ListItem { margin: 0 -0.125rem 0 -0.5rem; @@ -233,7 +222,7 @@ } .chat-selection { - padding-top: 0.5rem; + padding-block: 0.5rem; display: flex; flex-shrink: 0; flex-wrap: nowrap; @@ -246,18 +235,7 @@ overflow-y: hidden; > .PickerSelectedItem { - flex: 0 0 auto; - - &:last-child { - margin-right: auto; - } - } - - &[dir="rtl"] { - > .PickerSelectedItem:last-child { - margin-left: auto; - margin-right: 0; - } + flex-shrink: 0; } } diff --git a/src/components/left/search/LinkResults.tsx b/src/components/left/search/LinkResults.tsx index 7695180f6..a0bd3cb40 100644 --- a/src/components/left/search/LinkResults.tsx +++ b/src/components/left/search/LinkResults.tsx @@ -11,6 +11,7 @@ import { LoadMoreDirection } from '../../../types'; import { SLIDE_TRANSITION_DURATION } from '../../../config'; import buildClassName from '../../../util/buildClassName'; import { formatMonthAndYear, toYearMonth } from '../../../util/dates/dateFormat'; +import { parseSearchResultKey } from '../../../util/keys/searchResultKey'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { throttle } from '../../../util/schedulers'; import { createMapStateToProps } from './helpers/createMapStateToProps'; @@ -75,9 +76,9 @@ const LinkResults: FC = ({ } return foundIds.map((id) => { - const [chatId, messageId] = id.split('_'); + const [chatId, messageId] = parseSearchResultKey(id); - return globalMessagesByChatId[chatId]?.byId[Number(messageId)]; + return globalMessagesByChatId[chatId]?.byId[messageId]; }).filter(Boolean); }, [globalMessagesByChatId, foundIds]); diff --git a/src/components/left/search/MediaResults.tsx b/src/components/left/search/MediaResults.tsx index 05f08ce23..4b649ef2c 100644 --- a/src/components/left/search/MediaResults.tsx +++ b/src/components/left/search/MediaResults.tsx @@ -9,6 +9,7 @@ import { LoadMoreDirection, MediaViewerOrigin } from '../../../types'; import { SLIDE_TRANSITION_DURATION } from '../../../config'; import buildClassName from '../../../util/buildClassName'; +import { parseSearchResultKey } from '../../../util/keys/searchResultKey'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { throttle } from '../../../util/schedulers'; import { createMapStateToProps } from './helpers/createMapStateToProps'; @@ -71,9 +72,9 @@ const MediaResults: FC = ({ } return foundIds.map((id) => { - const [chatId, messageId] = id.split('_'); + const [chatId, messageId] = parseSearchResultKey(id); - return globalMessagesByChatId[chatId]?.byId[Number(messageId)]; + return globalMessagesByChatId[chatId]?.byId[messageId]; }).filter(Boolean); }, [globalMessagesByChatId, foundIds]); diff --git a/src/components/left/search/helpers/createMapStateToProps.ts b/src/components/left/search/helpers/createMapStateToProps.ts index b0880e7c5..a4e5e8149 100644 --- a/src/components/left/search/helpers/createMapStateToProps.ts +++ b/src/components/left/search/helpers/createMapStateToProps.ts @@ -3,6 +3,7 @@ import type { } from '../../../../api/types'; import type { GlobalState, TabState } from '../../../../global/types'; import type { ISettings } from '../../../../types'; +import type { SearchResultKey } from '../../../../util/keys/searchResultKey'; import { selectChat, selectTabState, selectTheme } from '../../../../global/selectors'; @@ -12,7 +13,7 @@ export type StateProps = { chatsById: Record; usersById: Record; globalMessagesByChatId?: Record }>; - foundIds?: string[]; + foundIds?: SearchResultKey[]; searchChatId?: string; activeDownloads: TabState['activeDownloads']; isChatProtected?: boolean; diff --git a/src/components/main/HistoryCalendar.tsx b/src/components/main/HistoryCalendar.tsx index a9b16889e..6ddc06fb4 100644 --- a/src/components/main/HistoryCalendar.tsx +++ b/src/components/main/HistoryCalendar.tsx @@ -22,7 +22,7 @@ const HistoryCalendar: FC = ({ const { searchMessagesByDate, closeHistoryCalendar } = getActions(); const handleJumpToDate = useCallback((date: Date) => { - searchMessagesByDate({ timestamp: date.valueOf() / 1000 }); + searchMessagesByDate({ timestamp: date.getTime() / 1000 }); closeHistoryCalendar(); }, [closeHistoryCalendar, searchMessagesByDate]); diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index e5321ea01..be90e5658 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -10,6 +10,7 @@ import type { import { type MediaViewerMedia, MediaViewerOrigin, type ThreadId } from '../../types'; import { ANIMATION_END_DELAY } from '../../config'; +import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { getChatMediaMessageIds, getMessagePaidMedia, isChatAdmin, isUserId, } from '../../global/helpers'; @@ -180,7 +181,9 @@ const MediaViewer = ({ useEffect(() => { if (isMobile) { - document.body.classList.toggle('is-media-viewer-open', isOpen); + requestMutation(() => { + document.body.classList.toggle('is-media-viewer-open', isOpen); + }); } }, [isMobile, isOpen]); diff --git a/src/components/middle/FloatingActionButtons.tsx b/src/components/middle/FloatingActionButtons.tsx index ccbbdde0a..2a269a645 100644 --- a/src/components/middle/FloatingActionButtons.tsx +++ b/src/components/middle/FloatingActionButtons.tsx @@ -5,7 +5,7 @@ import { getActions, withGlobal } from '../../global'; import type { MessageListType } from '../../global/types'; import { MAIN_THREAD_ID } from '../../api/types'; -import { selectChat, selectCurrentMessageList } from '../../global/selectors'; +import { selectChat, selectCurrentMessageList, selectCurrentMiddleSearch } from '../../global/selectors'; import animateScroll from '../../util/animateScroll'; import buildClassName from '../../util/buildClassName'; @@ -153,8 +153,10 @@ export default memo(withGlobal( const { chatId, threadId, type: messageListType } = currentMessageList; const chat = selectChat(global, chatId); + const hasActiveMiddleSearch = Boolean(selectCurrentMiddleSearch(global)); - const shouldShowCount = chat && threadId === MAIN_THREAD_ID && messageListType === 'thread'; + const shouldShowCount = chat && threadId === MAIN_THREAD_ID && messageListType === 'thread' + && !hasActiveMiddleSearch; return { messageListType, diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index 36b54d3da..31644355e 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -123,7 +123,7 @@ const HeaderActions: FC = ({ const { joinChannel, sendBotCommand, - openLocalTextSearch, + openMiddleSearch, restartBot, requestMasterAndRequestCall, requestNextManagementScreen, @@ -196,12 +196,11 @@ const HeaderActions: FC = ({ return; } - openLocalTextSearch(); + openMiddleSearch(); if (isMobile) { // iOS requires synchronous focus on user event. - const searchInput = document.querySelector('#MobileSearch input')!; - searchInput.focus(); + setFocusInSearchInput(); } else if (noAnimation) { // The second RAF is necessary because Teact must update the state and render the async component requestMeasure(() => { @@ -543,6 +542,6 @@ export default memo(withGlobal( )(HeaderActions)); function setFocusInSearchInput() { - const searchInput = document.querySelector('.RightHeader .SearchInput input'); + const searchInput = document.querySelector('#MiddleSearch input'); searchInput?.focus(); } diff --git a/src/components/middle/HeaderPinnedMessage.tsx b/src/components/middle/HeaderPinnedMessage.tsx index f5210f1cf..61e67df38 100644 --- a/src/components/middle/HeaderPinnedMessage.tsx +++ b/src/components/middle/HeaderPinnedMessage.tsx @@ -184,7 +184,6 @@ const HeaderPinnedMessage: FC = ({

(); const [isUnpinModalOpen, setIsUnpinModalOpen] = useState(false); @@ -250,7 +250,6 @@ function MiddleColumn({ getForceNextPinnedInHeader, } = usePinnedMessage(chatId, threadId, pinnedIds, topMessageId); - const isMobileSearchActive = isMobile && hasCurrentTextSearch; const closeAnimationDuration = isMobile ? LAYER_ANIMATION_DURATION_MS : undefined; const hasTools = hasPinned && ( windowWidth < MOBILE_SCREEN_MAX_WIDTH @@ -480,11 +479,6 @@ function MiddleColumn({ onBack: exitMessageSelectMode, }); - useHistoryBack({ - isActive: isMobileSearchActive, - onBack: closeLocalTextSearch, - }); - const isMessagingDisabled = Boolean( !isPinnedMessageList && !isSavedDialog && !renderingCanPost && !renderingCanRestartBot && !renderingCanStartBot && !renderingCanSubscribe && composerRestrictionMessage, @@ -700,7 +694,7 @@ function MiddleColumn({ withExtraShift={withExtraShift} />

- {isMobile && } + )} {chatId && ( @@ -749,7 +743,7 @@ export default memo(withGlobal( isLeftColumnShown, isRightColumnShown: selectIsRightColumnShown(global, isMobile), isBackgroundBlurred, - hasCurrentTextSearch: Boolean(selectCurrentTextSearch(global)), + hasActiveMiddleSearch: Boolean(selectCurrentMiddleSearch(global)), isSelectModeActive: selectIsInSelectMode(global), isSeenByModalOpen: Boolean(seenByModal), isPrivacySettingsNoticeModalOpen: Boolean(privacySettingsNoticeModal), diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index 69a5d5c67..b27cc6a0f 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -35,6 +35,7 @@ import { selectChat, selectChatMessage, selectChatMessages, + selectCurrentMiddleSearch, selectForwardedSender, selectIsChatBotNotStarted, selectIsChatWithBot, @@ -50,7 +51,7 @@ import { } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import cycleRestrict from '../../util/cycleRestrict'; -import { getMessageKey } from '../../util/messageKey'; +import { getMessageKey } from '../../util/keys/messageKey'; import useAppLayout from '../../hooks/useAppLayout'; import useConnectionStatus from '../../hooks/useConnectionStatus'; @@ -58,8 +59,8 @@ import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; import useDerivedState from '../../hooks/useDerivedState'; import useElectronDrag from '../../hooks/useElectronDrag'; import useEnsureMessage from '../../hooks/useEnsureMessage'; -import { useFastClick } from '../../hooks/useFastClick'; import useLastCallback from '../../hooks/useLastCallback'; +import useLongPress from '../../hooks/useLongPress'; import useOldLang from '../../hooks/useOldLang'; import usePrevious from '../../hooks/usePrevious'; import useShowTransition from '../../hooks/useShowTransition'; @@ -81,6 +82,7 @@ import './MiddleHeader.scss'; const ANIMATION_DURATION = 350; const BACK_BUTTON_INACTIVE_TIME = 450; const EMOJI_STATUS_SIZE = 22; +const SEARCH_LONGTAP_THRESHOLD = 500; type OwnProps = { chatId: string; @@ -116,6 +118,7 @@ type StateProps = { isSynced?: boolean; isFetchingDifference?: boolean; emojiStatusSticker?: ApiSticker; + isMiddleSearchOpen?: boolean; }; const MiddleHeader: FC = ({ @@ -148,6 +151,7 @@ const MiddleHeader: FC = ({ getLoadingPinnedId, emojiStatusSticker, isSavedDialog, + isMiddleSearchOpen, onFocusPinnedMessage, }) => { const { @@ -162,6 +166,7 @@ const MiddleHeader: FC = ({ openPremiumModal, openThread, openStickerSet, + updateMiddleSearch, } = getActions(); const lang = useOldLang(); @@ -197,15 +202,28 @@ const MiddleHeader: FC = ({ const componentRef = useRef(null); const shouldAnimateTools = useRef(true); - const { - handleClick: handleHeaderClick, - handleMouseDown: handleHeaderMouseDown, - } = useFastClick((e: React.MouseEvent) => { - if (e.type === 'mousedown' && (e.target as Element).closest('.title > .custom-emoji')) return; + const handleOpenSearch = useLastCallback(() => { + updateMiddleSearch({ chatId, threadId, update: {} }); + }); + + const handleOpenChat = useLastCallback((event: React.MouseEvent | React.TouchEvent) => { + if ((event.target as Element).closest('.title > .custom-emoji')) return; openThreadWithInfo({ chatId, threadId }); }); + const { + onMouseDown: handleLongPressMouseDown, + onMouseUp: handleLongPressMouseUp, + onMouseLeave: handleLongPressMouseLeave, + onTouchStart: handleLongPressTouchStart, + onTouchEnd: handleLongPressTouchEnd, + } = useLongPress({ + onStart: handleOpenSearch, + onClick: handleOpenChat, + threshold: SEARCH_LONGTAP_THRESHOLD, + }); + const handleUnpinMessage = useLastCallback((messageId: number) => { pinMessage({ messageId, isUnpin: true }); }); @@ -305,7 +323,7 @@ const MiddleHeader: FC = ({ const { shouldRender: shouldRenderPinnedMessage, transitionClassNames: pinnedMessageClassNames, - } = useShowTransition(Boolean(pinnedMessage), undefined, true); + } = useShowTransition(Boolean(pinnedMessage) && !isMiddleSearchOpen, undefined, true); const renderingPinnedMessage = useCurrentOrPrev(pinnedMessage, true); const renderingPinnedMessagesCount = useCurrentOrPrev(pinnedMessagesCount, true); @@ -389,8 +407,11 @@ const MiddleHeader: FC = ({ {(isLeftColumnHideable || currentTransitionKey > 0) && renderBackButton(shouldShowCloseButton, !isSavedDialog)}
{isUserId(realChatId) ? ( ( const emojiStatusSticker = emojiStatus && global.customEmojis.byId[emojiStatus.documentId]; const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + const isMiddleSearchOpen = Boolean(selectCurrentMiddleSearch(global)); const state: StateProps = { typingStatus, @@ -581,6 +603,7 @@ export default memo(withGlobal( emojiStatusSticker, hasButtonInHeader: canStartBot || canRestartBot || canSubscribe || shouldSendJoinRequest, isSavedDialog, + isMiddleSearchOpen, }; const messagesById = selectChatMessages(global, chatId); diff --git a/src/components/middle/MobileSearch.async.tsx b/src/components/middle/MobileSearch.async.tsx deleted file mode 100644 index 844077253..000000000 --- a/src/components/middle/MobileSearch.async.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; -import React from '../../lib/teact/teact'; - -import type { OwnProps } from './MobileSearch'; - -import { Bundles } from '../../util/moduleLoader'; - -import useModuleLoader from '../../hooks/useModuleLoader'; - -const MobileSearchAsync: FC = (props) => { - const { isActive } = props; - const MobileSearch = useModuleLoader(Bundles.Extra, 'MobileSearch', !isActive, true); - - // eslint-disable-next-line react/jsx-props-no-spreading - return MobileSearch ? : undefined; -}; - -export default MobileSearchAsync; diff --git a/src/components/middle/MobileSearch.scss b/src/components/middle/MobileSearch.scss deleted file mode 100644 index 979644758..000000000 --- a/src/components/middle/MobileSearch.scss +++ /dev/null @@ -1,84 +0,0 @@ -#MobileSearch > .header { - position: absolute; - top: 0; - left: 0; - z-index: var(--z-mobile-search); - width: 100%; - height: 3.5rem; - background: var(--color-background); - display: flex; - align-items: center; - padding-left: max(0.25rem, env(safe-area-inset-left)); - padding-right: max(0.5rem, env(safe-area-inset-right)); - - > .SearchInput { - margin-left: 0.25rem; - flex: 1; - } - - body.is-electron.is-macos & { - padding-left: 4.5rem; - } -} - -#MobileSearch > .tags-subheader { - --color-reaction: var(--color-background-secondary); - --hover-color-reaction: var(--color-background-secondary-accent); - --text-color-reaction: var(--color-text-secondary); - --color-reaction-chosen: var(--color-primary); - --text-color-reaction-chosen: #FFFFFF; - --hover-color-reaction-chosen: var(--color-primary-shade); - - position: absolute; - top: 3.5rem; - left: 0; - z-index: var(--z-mobile-search); - width: 100%; - height: 3rem; - background: var(--color-background); - display: flex; - align-items: center; - gap: 0.375rem; - padding-left: max(0.25rem, env(safe-area-inset-left)); - padding-right: max(0.5rem, env(safe-area-inset-right)); - - overflow-x: scroll; -} - -#MobileSearch > .footer { - position: absolute; - bottom: 0; - left: 0; - z-index: var(--z-mobile-search); - width: 100%; - height: 3.5rem; - background: var(--color-background); - display: flex; - align-items: center; - padding-left: max(1rem, env(safe-area-inset-left)); - padding-right: max(0.5rem, env(safe-area-inset-right)); - - body:not(.keyboard-visible) & { - padding-bottom: 0; - height: 3.5rem; - } - - @media (max-width: 600px) { - body:not(.keyboard-visible) & { - padding-bottom: env(safe-area-inset-bottom); - height: calc(3.5rem + env(safe-area-inset-bottom)); - } - } - - > .counter { - flex: 1; - color: var(--color-text-secondary); - } -} - -#MobileSearch:not(.active) { - .header, .tags-subheader, .footer { - // `display: none` will prevent synchronous focus on iOS - transform: translateX(-999rem); - } -} diff --git a/src/components/middle/MobileSearch.tsx b/src/components/middle/MobileSearch.tsx deleted file mode 100644 index 71775e3aa..000000000 --- a/src/components/middle/MobileSearch.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { - memo, useEffect, useLayoutEffect, - useMemo, - useRef, useState, -} from '../../lib/teact/teact'; -import { getActions, withGlobal } from '../../global'; - -import type { - ApiChat, ApiReaction, ApiReactionKey, ApiSavedReactionTag, -} from '../../api/types'; -import type { ThreadId } from '../../types'; - -import { requestMutation } from '../../lib/fasterdom/fasterdom'; -import { getIsSavedDialog, getReactionKey, isSameReaction } from '../../global/helpers'; -import { - selectChat, - selectCurrentMessageList, - selectCurrentTextSearch, - selectIsChatWithSelf, - selectIsCurrentUserPremium, - selectTabState, -} from '../../global/selectors'; -import { getDayStartAt } from '../../util/dates/dateFormat'; -import { debounce } from '../../util/schedulers'; -import { IS_IOS } from '../../util/windowEnvironment'; - -import useHorizontalScroll from '../../hooks/useHorizontalScroll'; -import useLastCallback from '../../hooks/useLastCallback'; - -import Button from '../ui/Button'; -import SearchInput from '../ui/SearchInput'; -import SavedTagButton from './message/reactions/SavedTagButton'; - -import './MobileSearch.scss'; - -export type OwnProps = { - isActive: boolean; -}; - -type StateProps = { - isActive?: boolean; - chat?: ApiChat; - threadId?: ThreadId; - query?: string; - savedTags?: Record; - searchTag?: ApiReaction; - totalCount?: number; - foundIds?: number[]; - isHistoryCalendarOpen?: boolean; - isCurrentUserPremium?: boolean; -}; - -const runDebouncedForSearch = debounce((cb) => cb(), 200, false); - -const MobileSearchFooter: FC = ({ - isActive, - chat, - threadId, - query, - savedTags, - searchTag, - totalCount, - foundIds, - isHistoryCalendarOpen, - isCurrentUserPremium, -}) => { - const { - setLocalTextSearchQuery, - setLocalTextSearchTag, - searchTextMessagesLocal, - focusMessage, - closeLocalTextSearch, - openHistoryCalendar, - openPremiumModal, - loadSavedReactionTags, - } = getActions(); - - // eslint-disable-next-line no-null/no-null - const inputRef = useRef(null); - // eslint-disable-next-line no-null/no-null - const tagsRef = useRef(null); - - const [focusedIndex, setFocusedIndex] = useState(0); - - const hasQueryData = Boolean(query || searchTag); - - // Fix for iOS keyboard - useEffect(() => { - const { visualViewport } = window as any; - if (!visualViewport) { - return undefined; - } - - const mainEl = document.getElementById('Main') as HTMLDivElement; - const handleResize = () => { - const { activeElement } = document; - if (activeElement && (activeElement === inputRef.current)) { - const { pageTop, height } = visualViewport; - - requestMutation(() => { - mainEl.style.transform = `translateY(${pageTop}px)`; - mainEl.style.height = `${height}px`; - document.documentElement.scrollTop = pageTop; - }); - } else { - requestMutation(() => { - mainEl.style.transform = ''; - mainEl.style.height = ''; - }); - } - }; - - visualViewport.addEventListener('resize', handleResize); - - return () => { - visualViewport.removeEventListener('resize', handleResize); - }; - }, []); - - // Focus message - useEffect(() => { - if (chat?.id && foundIds?.length) { - focusMessage({ chatId: chat.id, messageId: foundIds[0], threadId }); - setFocusedIndex(0); - } else { - setFocusedIndex(-1); - } - }, [chat?.id, focusMessage, foundIds, threadId]); - - // Disable native up/down buttons on iOS - useLayoutEffect(() => { - if (!IS_IOS) return; - - Array.from(document.querySelectorAll('input')).forEach((input) => { - input.disabled = Boolean(isActive && input !== inputRef.current); - }); - }, [isActive]); - - // Blur on exit - useEffect(() => { - if (!isActive) { - inputRef.current!.blur(); - } - }, [isActive]); - - useEffect(() => { - const searchInput = document.querySelector('#MobileSearch input')!; - searchInput.blur(); - }, [isHistoryCalendarOpen]); - - const tags = useMemo(() => { - if (!savedTags) return undefined; - return Object.values(savedTags); - }, [savedTags]); - - const hasTags = Boolean(tags?.length); - const areTagsDisabled = hasTags && !isCurrentUserPremium; - - useHorizontalScroll(tagsRef, !hasTags); - - useEffect(() => { - if (isActive) loadSavedReactionTags(); - }, [hasTags, isActive]); - - const handleMessageSearchQueryChange = useLastCallback((newQuery: string) => { - setLocalTextSearchQuery({ query: newQuery }); - - if (hasQueryData) { - runDebouncedForSearch(searchTextMessagesLocal); - } - }); - - const handleTagClick = useLastCallback((tag: ApiReaction) => { - if (areTagsDisabled) { - openPremiumModal({ - initialSection: 'saved_tags', - }); - return; - } - - setLocalTextSearchTag({ tag }); - - runDebouncedForSearch(searchTextMessagesLocal); - }); - - const handleUp = useLastCallback(() => { - if (chat && foundIds) { - const newFocusIndex = focusedIndex + 1; - focusMessage({ chatId: chat.id, messageId: foundIds[newFocusIndex], threadId }); - setFocusedIndex(newFocusIndex); - } - }); - - const handleDown = useLastCallback(() => { - if (chat && foundIds) { - const newFocusIndex = focusedIndex - 1; - focusMessage({ chatId: chat.id, messageId: foundIds[newFocusIndex], threadId }); - setFocusedIndex(newFocusIndex); - } - }); - - const handleCloseLocalTextSearch = useLastCallback(() => { - closeLocalTextSearch(); - }); - - return ( -
-
- - -
- {hasTags && ( -
- {tags.map((tag) => ( - - ))} -
- )} -
-
- {hasQueryData ? ( - foundIds?.length ? ( - `${focusedIndex + 1} of ${totalCount}` - ) : foundIds && !foundIds.length ? ( - 'No results' - ) : ( - '' - ) - ) : ( - - )} -
- - -
-
- ); -}; - -export default memo(withGlobal( - (global): StateProps => { - const currentMessageList = selectCurrentMessageList(global); - if (!currentMessageList) { - return {}; - } - const { chatId, threadId } = currentMessageList; - - const chat = selectChat(global, chatId); - if (!chat) { - return {}; - } - - const { query, savedTag, results } = selectCurrentTextSearch(global) || {}; - const { totalCount, foundIds } = results || {}; - - const isSavedMessages = selectIsChatWithSelf(global, chatId); - const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); - - const savedTags = isSavedMessages && !isSavedDialog ? global.savedReactionTags?.byKey : undefined; - - return { - chat, - query, - totalCount, - threadId, - foundIds, - isHistoryCalendarOpen: Boolean(selectTabState(global).historyCalendarSelectedAt), - savedTags, - searchTag: savedTag, - isCurrentUserPremium: selectIsCurrentUserPremium(global), - }; - }, -)(MobileSearchFooter)); diff --git a/src/components/middle/message/Album.tsx b/src/components/middle/message/Album.tsx index 82e0ca2c0..0606a1881 100644 --- a/src/components/middle/message/Album.tsx +++ b/src/components/middle/message/Album.tsx @@ -17,7 +17,7 @@ import { selectCanAutoPlayMedia, selectTheme, } from '../../../global/selectors'; -import { getMessageKey } from '../../../util/messageKey'; +import { getMessageKey } from '../../../util/keys/messageKey'; import { AlbumRectPart } from './helpers/calculateAlbumLayout'; import withSelectControl from './hocs/withSelectControl'; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 8643e2e93..3a6301fa8 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -72,7 +72,7 @@ import { selectChatFullInfo, selectChatMessage, selectChatTranslations, - selectCurrentTextSearch, + selectCurrentMiddleSearch, selectDefaultReaction, selectForwardedSender, selectIsChatProtected, @@ -105,7 +105,7 @@ import { import { isAnimatingScroll } from '../../../util/animateScroll'; import buildClassName from '../../../util/buildClassName'; import { isElementInViewport } from '../../../util/isElementInViewport'; -import { getMessageKey } from '../../../util/messageKey'; +import { getMessageKey } from '../../../util/keys/messageKey'; import stopEvent from '../../../util/stopEvent'; import { IS_ANDROID, IS_ELECTRON, IS_TRANSLATION_SUPPORTED } from '../../../util/windowEnvironment'; import { @@ -1038,6 +1038,7 @@ const Message: FC = ({ return ( = ({ {reactionsPosition === 'outside' && !isStoryMention && ( ( quote: focusedQuote, scrollTargetPosition, } = (isFocused && focusedMessage) || {}; - const { query: highlight } = selectCurrentTextSearch(global) || {}; + const middleSearch = selectCurrentMiddleSearch(global); + const highlight = middleSearch?.results?.query + && `${middleSearch.isHashtag ? '#' : ''}${middleSearch.results.query}`; const singleEmoji = getMessageSingleRegularEmoji(message); const animatedEmoji = singleEmoji && selectAnimatedEmoji(global, singleEmoji) ? singleEmoji : undefined; diff --git a/src/components/middle/message/_message-content.scss b/src/components/middle/message/_message-content.scss index c9453a1fa..81771b743 100644 --- a/src/components/middle/message/_message-content.scss +++ b/src/components/middle/message/_message-content.scss @@ -173,6 +173,7 @@ line-height: 1; height: calc(var(--message-meta-height, 1rem)); margin-left: auto; + margin-top: -0.5rem; margin-right: -0.5rem; align-self: flex-end; diff --git a/src/components/middle/message/reactions/ReactionButton.module.scss b/src/components/middle/message/reactions/ReactionButton.module.scss index 1bb383465..73897d0c3 100644 --- a/src/components/middle/message/reactions/ReactionButton.module.scss +++ b/src/components/middle/message/reactions/ReactionButton.module.scss @@ -9,9 +9,6 @@ --reaction-background: var(--color-reaction-chosen); --reaction-background-hover: var(--hover-color-reaction-chosen); --reaction-text-color: var(--text-color-reaction-chosen); - - position: relative; - z-index: 1; } display: flex; @@ -27,7 +24,9 @@ text-transform: none; color: var(--reaction-text-color); overflow: visible; + position: relative; line-height: 1.75rem; + z-index: 1; gap: 0.125rem; diff --git a/src/components/middle/message/reactions/Reactions.tsx b/src/components/middle/message/reactions/Reactions.tsx index f38f460a6..dba295b3d 100644 --- a/src/components/middle/message/reactions/Reactions.tsx +++ b/src/components/middle/message/reactions/Reactions.tsx @@ -10,12 +10,13 @@ import type { ApiSavedReactionTag, } from '../../../../api/types'; import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; +import type { ThreadId } from '../../../../types'; import type { Signal } from '../../../../util/signals'; import { getReactionKey, isReactionChosen } from '../../../../global/helpers'; import { selectPeer } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; -import { getMessageKey } from '../../../../util/messageKey'; +import { getMessageKey } from '../../../../util/keys/messageKey'; import useDerivedState from '../../../../hooks/useDerivedState'; import useLastCallback from '../../../../hooks/useLastCallback'; @@ -28,6 +29,7 @@ import './Reactions.scss'; type OwnProps = { message: ApiMessage; + threadId?: ThreadId; isOutside?: boolean; maxWidth?: number; metaChildren?: React.ReactNode; @@ -42,6 +44,7 @@ const MAX_RECENT_AVATARS = 3; const Reactions: FC = ({ message, + threadId, isOutside, maxWidth, metaChildren, @@ -53,8 +56,8 @@ const Reactions: FC = ({ }) => { const { toggleReaction, - setLocalTextSearchTag, - searchTextMessagesLocal, + updateMiddleSearch, + performMiddleSearch, openPremiumModal, } = getActions(); const lang = useOldLang(); @@ -112,8 +115,8 @@ const Reactions: FC = ({ return; } - setLocalTextSearchTag({ tag: reaction }); - searchTextMessagesLocal(); + updateMiddleSearch({ chatId: message.chatId, threadId, update: { savedTag: reaction } }); + performMiddleSearch({ chatId: message.chatId, threadId }); return; } diff --git a/src/components/middle/search/MiddleSearch.async.tsx b/src/components/middle/search/MiddleSearch.async.tsx new file mode 100644 index 000000000..92ac6c03a --- /dev/null +++ b/src/components/middle/search/MiddleSearch.async.tsx @@ -0,0 +1,18 @@ +import type { FC } from '../../../lib/teact/teact'; +import React from '../../../lib/teact/teact'; + +import type { OwnProps } from './MiddleSearch'; + +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const MiddleSearchAsync: FC = (props) => { + const { isActive } = props; + const MiddleSearch = useModuleLoader(Bundles.Extra, 'MiddleSearch', !isActive, true); + + // eslint-disable-next-line react/jsx-props-no-spreading + return MiddleSearch ? : undefined; +}; + +export default MiddleSearchAsync; diff --git a/src/components/middle/search/MiddleSearch.module.scss b/src/components/middle/search/MiddleSearch.module.scss new file mode 100644 index 000000000..20269f082 --- /dev/null +++ b/src/components/middle/search/MiddleSearch.module.scss @@ -0,0 +1,300 @@ +@use "../../../styles/mixins"; + +.root { + --color-reaction: var(--color-background-secondary); + --hover-color-reaction: var(--color-background-secondary-accent); + --text-color-reaction: var(--color-text-secondary); + --color-reaction-chosen: var(--color-primary); + --text-color-reaction-chosen: #FFFFFF; + --hover-color-reaction-chosen: var(--color-primary-shade); + + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + z-index: var(--z-local-search); + + @media (min-width: 1276px) { + :global(#Main.right-column-open) & { + width: calc(100% - var(--right-column-width)); + } + } +} + +.header { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 3.5rem; + background-color: var(--color-background); + display: flex; + align-items: center; + padding-left: max(1.5rem, env(safe-area-inset-left)); + padding-right: max(0.875rem, env(safe-area-inset-right)); + + pointer-events: auto; + + opacity: 0; + transition: opacity 200ms ease-in-out; + + .active & { + opacity: 1; + } + + @media (max-width: 600px) { + padding-left: max(0.5rem, env(safe-area-inset-left)); + padding-right: max(0.5rem, env(safe-area-inset-right)); + } + + :global(body.is-electron.is-macos) & { + padding-left: 4.5rem; + } +} + +// Same as in MiddleHeader.scss +.avatar { + width: 2.5rem !important; + height: 2.5rem !important; + margin-right: 0.625rem; +} + +.input { + border: none; + margin-left: 0.25rem; + margin-right: 0.75rem; + flex: 1; + + transition-property: background-color, box-shadow, border-radius; + transition-duration: 200ms; + transition-timing-function: ease-in-out; + + .mobile & { + margin: 0; + } +} + +.focused .input { + box-shadow: 0 0 0.625rem 0 var(--color-default-shadow); +} + +.withDropdown { + background-color: var(--color-background); + box-shadow: 0 0 0.625rem 0 var(--color-default-shadow); +} + +.adaptSearchBorders { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.dropdown { + position: absolute; + bottom: 0; + left: 0; + right: 0; + transform: translateY(100%); + max-height: min(24rem, 80vh); + pointer-events: all; + + display: flex; + flex-direction: column; + + background-color: var(--color-background); + + overflow: hidden; + box-shadow: 0 0 0.625rem 0 var(--color-default-shadow); + clip-path: inset(0 -0.625rem -0.625rem -0.625rem); // Hide top shadow + + border-bottom-left-radius: 1.375rem; + border-bottom-right-radius: 1.375rem; + + transition-behavior: allow-discrete; + /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ + transition-property: display, opacity; + transition-duration: 200ms; + + .mobile & { + position: absolute; + top: 3.375rem; // Subpixel rendering can leave 1px gap otherwise + right: 0; + bottom: 3.375rem; + left: 0; + + max-height: none; + padding: 0; + + transform: none; + border-radius: 0; + } + + @starting-style { + opacity: 0; + } +} + +.dropdownHidden { + display: none; + opacity: 0; +} + +.results { + display: flex; + flex-direction: column; + overflow-y: scroll; + padding: 0.5rem; + + @include mixins.adapt-padding-to-scrollbar(0.5rem); +} + +.placeholder { + color: var(--color-text-secondary); + text-align: center; + margin: 0.5rem; +} + +.separator { + margin-inline: 1rem; + border-top: 1px solid var(--color-borders); +} + +.savedTags { + display: flex; + align-items: center; + gap: 0.375rem; + flex-shrink: 0; + + padding-block: 1rem; + margin-inline: 1rem; + + border-bottom: 1px solid var(--color-borders); + + overflow-x: scroll; +} + +.wrap { + flex-wrap: wrap; +} + +.searchTags { + display: flex; + gap: 0.125rem; + align-items: center; +} + +.savedSearchTag { + margin-inline: 0.5rem; +} + +.hash { + margin-inline-end: -0.5rem; + margin-inline-start: 0.5rem; + + display: grid; + place-items: center; + font-size: 1.5rem; + color: var(--color-text-secondary); +} + +.searchTypes { + display: flex; + flex-shrink: 0; + + padding-block: 1rem; + margin-inline: 1rem; + + border-bottom: 1px solid var(--color-borders); + + overflow-x: scroll; +} + +.searchType { + --accent-color: var(--color-primary); + + flex-grow: 0 !important; + flex-shrink: 0; + + color: var(--color-text-secondary); + background-color: var(--color-item-active); + font-weight: 500; +} + +.selectedType { + background-color: var(--color-primary); + color: var(--color-white) !important; + + &:hover { + background-color: var(--color-primary-shade); + } +} + +.footer { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 3.5rem; + background-color: var(--color-background); + display: flex; + align-items: center; + padding-left: max(1rem, env(safe-area-inset-left)); + padding-right: max(0.5rem, env(safe-area-inset-right)); + + pointer-events: auto; + + box-shadow: 0 -2px 2px var(--color-light-shadow); + + transform: translateY(100%); + transition: transform 200ms ease-in-out; + + .active & { + transform: translateY(0); + } + + :global { + body:not(.keyboard-visible) & { + padding-bottom: 0; + height: 3.5rem; + } + + @media (max-width: 600px) { + body:not(.keyboard-visible) & { + padding-bottom: env(safe-area-inset-bottom); + height: calc(3.5rem + env(safe-area-inset-bottom)); + } + } + } +} + +.counter { + flex: 1; + color: var(--color-text-secondary); +} + +.mobileNavigation { + position: absolute; + right: 0.5rem; + bottom: 4rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.navigationButton { + transition-property: background-color, color, filter; +} + +.navigationDisabled { + filter: brightness(0.9); +} + +@keyframes jumpIn { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} diff --git a/src/components/middle/search/MiddleSearch.tsx b/src/components/middle/search/MiddleSearch.tsx new file mode 100644 index 000000000..1c6dc68f0 --- /dev/null +++ b/src/components/middle/search/MiddleSearch.tsx @@ -0,0 +1,818 @@ +import type { FC } from '../../../lib/teact/teact'; +import React, { + memo, useEffect, useLayoutEffect, + useMemo, + useRef, useState, +} from '../../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../../global'; + +import type { + ApiChat, ApiMessage, ApiReaction, ApiReactionKey, ApiSavedReactionTag, +} from '../../../api/types'; +import type { + CustomPeer, MiddleSearchParams, MiddleSearchType, ThreadId, +} from '../../../types'; + +import { ANONYMOUS_USER_ID, REPLIES_USER_ID } from '../../../config'; +import { requestMeasure, requestMutation, requestNextMutation } from '../../../lib/fasterdom/fasterdom'; +import { getIsSavedDialog, getReactionKey, isSameReaction } from '../../../global/helpers'; +import { + selectChat, + selectChatMessage, + selectCurrentMessageList, + selectCurrentMiddleSearch, + selectForwardedSender, + selectIsChatWithSelf, + selectIsCurrentUserPremium, + selectSender, + selectTabState, +} from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import captureEscKeyListener from '../../../util/captureEscKeyListener'; +import { getDayStartAt } from '../../../util/dates/dateFormat'; +import focusEditableElement from '../../../util/focusEditableElement'; +import { getSearchResultKey, parseSearchResultKey, type SearchResultKey } from '../../../util/keys/searchResultKey'; +import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; +import { debounce, fastRaf } from '../../../util/schedulers'; +import { IS_IOS } from '../../../util/windowEnvironment'; + +import { useClickOutside } from '../../../hooks/events/useOutsideClick'; +import useAppLayout from '../../../hooks/useAppLayout'; +import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; +import useFlag from '../../../hooks/useFlag'; +import useHistoryBack from '../../../hooks/useHistoryBack'; +import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; +import useKeyboardListNavigation from '../../../hooks/useKeyboardListNavigation'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import Avatar from '../../common/Avatar'; +import Icon from '../../common/icons/Icon'; +import PickerSelectedItem from '../../common/PickerSelectedItem'; +import Button from '../../ui/Button'; +import InfiniteScroll from '../../ui/InfiniteScroll'; +import SearchInput from '../../ui/SearchInput'; +import SavedTagButton from '../message/reactions/SavedTagButton'; +import MiddleSearchResult from './MiddleSearchResult'; + +import styles from './MiddleSearch.module.scss'; + +export type OwnProps = { + isActive: boolean; +}; + +type StateProps = { + isActive?: boolean; + chat?: ApiChat; + threadId?: ThreadId; + requestedQuery?: string; + savedTags?: Record; + savedTag?: ApiReaction; + totalCount?: number; + lastSearchQuery?: string; + foundIds?: SearchResultKey[]; + isHistoryCalendarOpen?: boolean; + isCurrentUserPremium?: boolean; + isSavedMessages?: boolean; + fetchingQuery?: string; + isHashtagQuery?: boolean; + searchType?: MiddleSearchType; + currentUserId?: string; +}; + +const CHANNELS_PEER: CustomPeer = { + isCustomPeer: true, + avatarIcon: 'channel-filled', + titleKey: 'SearchPublicPosts', +}; +const FOCUSED_SEARCH_TRIGGER_OFFSET = 5; +const HIDE_TIMEOUT = 200; +const RESULT_ITEM_CLASS_NAME = 'MiddleSearchResult'; + +const runDebouncedForSearch = debounce((cb) => cb(), 200, false); + +const MiddleSearch: FC = ({ + isActive, + chat, + threadId, + requestedQuery, + savedTags, + savedTag, + totalCount, + lastSearchQuery, + foundIds, + isHistoryCalendarOpen, + isCurrentUserPremium, + isSavedMessages, + fetchingQuery, + isHashtagQuery, + searchType = 'chat', + currentUserId, +}) => { + const { + updateMiddleSearch, + resetMiddleSearch, + performMiddleSearch, + focusMessage, + closeMiddleSearch, + openHistoryCalendar, + openPremiumModal, + loadSavedReactionTags, + } = getActions(); + + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + + const { isMobile } = useAppLayout(); + const oldLang = useOldLang(); + const lang = useLang(); + + const [query, setQuery] = useState(requestedQuery || ''); + const [focusedIndex, setFocusedIndex] = useState(0); + const canFocusNewer = foundIds && focusedIndex > 0; + const canFocusOlder = foundIds && focusedIndex < foundIds.length - 1; + + const [isFullyHidden, setIsFullyHidden] = useState(!isActive); + const hiddenTimerRef = useRef(); + const maybeLongPressActiveRef = useRef(true); + + const [isFocused, markFocused, markBlurred] = useFlag(); + const [isViewAsList, setIsViewAsList] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const handleClickOutside = useLastCallback((event: MouseEvent) => { + if (maybeLongPressActiveRef.current) return; + // Ignore clicks inside modals + if ((event.target as HTMLElement).closest('.Modal')) return; + markBlurred(); + }); + useClickOutside([ref], handleClickOutside); + + const hasResultsContainer = Boolean((query && foundIds) || isHashtagQuery); + const isOnlyHash = isHashtagQuery && !query; + const areResultsEmpty = Boolean(query && foundIds && !foundIds.length && !isLoading && !isOnlyHash); + const hasResultsPlaceholder = areResultsEmpty || isOnlyHash; + const isNonFocusedDropdownForced = searchType === 'myChats' || searchType === 'channels'; + const hasResultsDropdown = isActive && (isViewAsList || !isMobile) && (isFocused || isNonFocusedDropdownForced) + && Boolean( + hasResultsContainer || hasResultsPlaceholder || savedTags, + ); + + const hasQueryData = Boolean((query && !isOnlyHash) || savedTag); + const hasNavigationButtons = searchType === 'chat' && Boolean(foundIds?.length); + + const handleClose = useLastCallback(() => { + closeMiddleSearch(); + }); + + const focusInput = useLastCallback(() => { + requestMeasure(() => { + inputRef.current?.focus(); + }); + }); + + const blurInput = useLastCallback(() => { + inputRef.current?.blur(); + }); + + // Fix for iOS keyboard + useEffect(() => { + const { visualViewport } = window; + if (!visualViewport) { + return undefined; + } + + const mainEl = document.getElementById('Main') as HTMLDivElement; + const handleResize = () => { + const { activeElement } = document; + if (activeElement && (activeElement === inputRef.current)) { + const { pageTop, height } = visualViewport; + + requestMutation(() => { + mainEl.style.transform = `translateY(${pageTop}px)`; + mainEl.style.height = `${height}px`; + document.documentElement.scrollTop = pageTop; + }); + } else { + requestMutation(() => { + mainEl.style.transform = ''; + mainEl.style.height = ''; + }); + } + }; + + visualViewport.addEventListener('resize', handleResize); + + return () => { + visualViewport.removeEventListener('resize', handleResize); + }; + }, []); + + // Focus message + useEffect(() => { + if (foundIds?.length) { + if (searchType === 'chat') { + const [chatId, messageId] = parseSearchResultKey(foundIds[0]); + focusMessage({ chatId, messageId, threadId }); + } + setFocusedIndex(0); + } else { + setFocusedIndex(-1); + } + }, [searchType, focusMessage, foundIds, threadId]); + + // Disable native up/down buttons on iOS + useLayoutEffect(() => { + if (!IS_IOS) return; + + Array.from(document.querySelectorAll('input')).forEach((input) => { + input.disabled = Boolean(isActive && input !== inputRef.current); + }); + }, [isActive]); + + // Blur on exit + useEffect(() => { + if (!isActive) { + inputRef.current!.blur(); + setIsViewAsList(true); + setFocusedIndex(0); + setQuery(''); + hiddenTimerRef.current = window.setTimeout(() => setIsFullyHidden(true), HIDE_TIMEOUT); + } else { + setIsFullyHidden(false); + clearTimeout(hiddenTimerRef.current); + } + }, [isActive]); + + useEffect(() => { + if (!requestedQuery || !chat?.id) return; + setQuery(requestedQuery); + updateMiddleSearch({ chatId: chat.id, threadId, update: { requestedQuery: undefined } }); + setIsLoading(true); + + requestNextMutation(() => { + const input = inputRef.current; + if (!input) return; + focusEditableElement(input, true, true); + markFocused(); + }); + }, [chat?.id, requestedQuery, threadId]); + + useEffectWithPrevDeps(([prevIsActive]) => { + if (isActive !== prevIsActive && !query && lastSearchQuery) { + setQuery(lastSearchQuery); // Restore query when returning back + } + }, [isActive, lastSearchQuery, query]); + + useEffectWithPrevDeps(([prevIsCalendarOpen]) => { + if (!isActive || prevIsCalendarOpen === isHistoryCalendarOpen) return; + if (isHistoryCalendarOpen) { + blurInput(); + markBlurred(); + } else { + focusInput(); + } + }, [isHistoryCalendarOpen, isActive]); + + const handleReset = useLastCallback(() => { + if (!query?.length && !savedTag) { + handleClose(); + return; + } + + setQuery(''); + setIsLoading(false); + resetMiddleSearch(); + focusInput(); + }); + + useEffect(() => (isActive ? captureEscKeyListener(handleReset) : undefined), [isActive, handleClose]); + + const savedTagsArray = useMemo(() => { + if (!savedTags) return undefined; + return Object.values(savedTags); + }, [savedTags]); + + const hasSavedTags = Boolean(savedTagsArray?.length); + const areSavedTagsDisabled = hasSavedTags && !isCurrentUserPremium; + + useEffect(() => { + if (isSavedMessages && isActive) loadSavedReactionTags(); + }, [isSavedMessages, isActive]); + + const handleSearch = useLastCallback(() => { + const chatId = chat?.id; + if (!chatId) { + return; + } + + runDebouncedForSearch(() => performMiddleSearch({ chatId, threadId, query })); + }); + + const handleQueryChange = useLastCallback((newQuery: string) => { + if (newQuery.startsWith('#') && !isHashtagQuery) { + updateMiddleSearch({ chatId: chat!.id, threadId, update: { isHashtag: true } }); + setQuery(newQuery.slice(1)); + handleSearch(); + return; + } + + setQuery(newQuery); + + if (!newQuery) { + setIsLoading(false); + resetMiddleSearch(); + } + }); + + useEffect(() => { + if (query) { + handleSearch(); + } + }, [query]); + + useEffect(() => { + setIsLoading(Boolean(fetchingQuery)); + }, [fetchingQuery]); + + useEffect(() => { + if (!foundIds?.length) return; + const isClose = foundIds.length - focusedIndex < FOCUSED_SEARCH_TRIGGER_OFFSET; + if (isClose) { + handleSearch(); + } + }, [focusedIndex, foundIds?.length]); + + useEffect(() => { + if (!isActive) return undefined; + + maybeLongPressActiveRef.current = true; + + function focus() { + inputRef.current?.focus(); + markFocused(); + + fastRaf(() => { + maybeLongPressActiveRef.current = false; + }); + } + + function removeListeners() { + window.removeEventListener('touchend', focus); + window.removeEventListener('mouseup', focus); + + fastRaf(() => { + maybeLongPressActiveRef.current = false; + }); + } + + window.addEventListener('touchend', focus); + window.addEventListener('mouseup', focus); + + window.addEventListener('touchstart', removeListeners); + window.addEventListener('mousedown', removeListeners); + + return () => { + removeListeners(); + window.removeEventListener('touchstart', removeListeners); + window.removeEventListener('mousedown', removeListeners); + }; + }, [isActive]); + + useHistoryBack({ + isActive, + onBack: handleClose, + }); + + const [viewportIds, getMore, viewportOffset = 0] = useInfiniteScroll(handleSearch, foundIds); + + const viewportResults = useMemo(() => { + if ((!query && !savedTag) || !viewportIds?.length) { + return MEMO_EMPTY_ARRAY; + } + const global = getGlobal(); + + return viewportIds.map((searchResultKey) => { + const [chatId, id] = parseSearchResultKey(searchResultKey); + const message = selectChatMessage(global, chatId, id); + if (!message) { + return undefined; + } + + const originalSender = (isSavedMessages || chatId === REPLIES_USER_ID || chatId === ANONYMOUS_USER_ID) + ? selectForwardedSender(global, message) : undefined; + const messageSender = selectSender(global, message); + const messageChat = selectChat(global, message.chatId); + + const senderPeer = originalSender || messageSender; + + return { + searchResultKey, + message, + messageChat, + senderPeer, + }; + }).filter(Boolean); + }, [query, savedTag, viewportIds, isSavedMessages]); + + const handleMessageClick = useLastCallback((message: ApiMessage) => { + const searchResultKey = getSearchResultKey(message); + const index = foundIds?.indexOf(searchResultKey) || 0; + const realIndex = index + viewportOffset; + setFocusedIndex(realIndex); + + if (searchType === 'chat') { + setIsViewAsList(false); + } + + focusMessage({ + chatId: message.chatId, + messageId: message.id, + threadId: !isHashtagQuery ? threadId : undefined, + }); + + markBlurred(); + }); + + const handleTriggerViewStyle = useLastCallback(() => { + setIsViewAsList((prev) => !prev); + markFocused(); + }); + + const handleKeyDown = useKeyboardListNavigation(containerRef, hasResultsContainer, (index) => { + const foundResult = viewportResults?.[index === -1 ? 0 : index]; + if (foundResult) { + handleMessageClick(foundResult.message); + setFocusedIndex(index + viewportOffset); + } + }, `.${RESULT_ITEM_CLASS_NAME}`, true); + + const updateSearchParams = useLastCallback((update: Partial) => { + updateMiddleSearch({ chatId: chat!.id, threadId, update }); + + handleSearch(); + }); + + const activateSearchTag = useLastCallback((tag: ApiReaction) => { + if (areSavedTagsDisabled) { + openPremiumModal({ + initialSection: 'saved_tags', + }); + return; + } + + updateSearchParams({ savedTag: tag }); + }); + + const removeSearchSavedTag = useLastCallback(() => { + updateSearchParams({ savedTag: undefined }); + }); + + const handleDeleteTag = useLastCallback(() => { + if (isHashtagQuery) { + updateSearchParams({ isHashtag: false }); + return; + } + + if (savedTag) { + removeSearchSavedTag(); + } + }); + + const handleChangeSearchType = useLastCallback((type: MiddleSearchType) => { + updateSearchParams({ type }); + setIsViewAsList(true); + }); + + const handleFocusOlder = useLastCallback(() => { + if (searchType !== 'chat') return; + markBlurred(); + blurInput(); + if (foundIds) { + const newFocusIndex = focusedIndex + 1; + const [chatId, messageId] = parseSearchResultKey(foundIds[newFocusIndex]); + focusMessage({ chatId, messageId, threadId }); + setFocusedIndex(newFocusIndex); + } + }); + + const handleFocusNewer = useLastCallback(() => { + if (searchType !== 'chat') return; + markBlurred(); + blurInput(); + if (foundIds) { + const newFocusIndex = focusedIndex - 1; + const [chatId, messageId] = parseSearchResultKey(foundIds[newFocusIndex]); + focusMessage({ chatId, messageId, threadId }); + setFocusedIndex(newFocusIndex); + } + }); + + function renderTypeTag(type: MiddleSearchType, isForTag?: boolean) { + const isSelected = !isForTag && searchType === type; + switch (type) { + case 'chat': + return ( + + ); + case 'myChats': + return ( + + ); + case 'channels': + return ( + + ); + } + return undefined; + } + + function renderDropdown() { + return ( +
+ {!isMobile &&
} + {hasSavedTags && !isHashtagQuery && ( +
+ {savedTagsArray.map((tag) => { + const isChosen = isSameReaction(tag.reaction, savedTag); + return ( + + ); + })} +
+ )} + {isHashtagQuery && ( +
+ {renderTypeTag('chat')} + {renderTypeTag('myChats')} + {renderTypeTag('channels')} +
+ )} + {hasResultsContainer && ( + + {areResultsEmpty && ( + + {oldLang('NoResultFoundFor', query)} + + )} + {isOnlyHash && ( + + {oldLang('HashtagSearchPlaceholder')} + + )} + {viewportResults?.map(({ + message, senderPeer, messageChat, searchResultKey, + }, i) => ( + + ))} + + )} +
+ ); + } + + return ( +
+
+ {!isMobile && ( + + )} + +
+ {savedTag && ( + + )} + {isHashtagQuery &&
#
} +
+ {!isMobile && renderDropdown()} +
+ {!isMobile && ( +
+ +
+ )} +
+ {isMobile && renderDropdown()} + {isMobile && ( +
+ +
+ {hasQueryData && ( + foundIds?.length ? ( + oldLang('Of', [focusedIndex + 1, totalCount]) + ) : foundIds && !foundIds.length && ( + oldLang('NoResult') + ) + )} +
+ {searchType === 'chat' && Boolean(foundIds?.length) && ( + + )} + {hasNavigationButtons && !hasResultsDropdown && ( +
+ + +
+ )} +
+ )} +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const currentMessageList = selectCurrentMessageList(global); + if (!currentMessageList) { + return {}; + } + const { chatId, threadId } = currentMessageList; + + const chat = selectChat(global, chatId); + if (!chat) { + return {}; + } + + const { + requestedQuery, savedTag, results, fetchingQuery, isHashtag, type, + } = selectCurrentMiddleSearch(global) || {}; + const { totalCount, foundIds, query: lastSearchQuery } = results || {}; + + const currentUserId = global.currentUserId; + const isSavedMessages = selectIsChatWithSelf(global, chatId); + const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); + + const savedTags = isSavedMessages && !isSavedDialog ? global.savedReactionTags?.byKey : undefined; + + return { + chat, + requestedQuery, + totalCount, + threadId, + foundIds, + isHistoryCalendarOpen: Boolean(selectTabState(global).historyCalendarSelectedAt), + savedTags, + savedTag, + isCurrentUserPremium: selectIsCurrentUserPremium(global), + isSavedMessages, + fetchingQuery, + isHashtagQuery: isHashtag, + currentUserId, + searchType: type, + lastSearchQuery, + }; + }, +)(MiddleSearch)); diff --git a/src/components/middle/search/MiddleSearchResult.module.scss b/src/components/middle/search/MiddleSearchResult.module.scss new file mode 100644 index 000000000..ea9483fdf --- /dev/null +++ b/src/components/middle/search/MiddleSearchResult.module.scss @@ -0,0 +1,57 @@ +.root { + display: flex; + gap: 0.75rem; + padding: 0.375rem 0.75rem; + border-radius: 0.625rem; + outline: none; + + color: var(--color-text); + font-size: 0.875rem; + cursor: var(--custom-cursor, pointer); + + @media (hover: hover) { + &:hover, + &:focus-visible, + &.active { + background-color: var(--color-chat-hover); + } + } + + :global { + .matching-text-highlight { + color: var(--color-text); + background: #cae3f7; + border-radius: 0.25rem; + padding: 0 0.125rem; + display: inline-block; + + :global(.theme-dark) & { + --color-text: #000; + } + } + } +} + +.info { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.topRow { + display: flex; +} + +.meta { + margin-inline-start: auto; + padding-inline-start: 0.5rem; +} + +.subtitle { + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25; +} diff --git a/src/components/middle/search/MiddleSearchResult.tsx b/src/components/middle/search/MiddleSearchResult.tsx new file mode 100644 index 000000000..4575f52d2 --- /dev/null +++ b/src/components/middle/search/MiddleSearchResult.tsx @@ -0,0 +1,85 @@ +import React, { memo } from '../../../lib/teact/teact'; + +import type { ApiChat, ApiMessage, ApiPeer } from '../../../api/types'; + +import { getMessageSenderName } from '../../../global/helpers'; +import buildClassName from '../../../util/buildClassName'; +import renderText from '../../common/helpers/renderText'; + +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import Avatar from '../../common/Avatar'; +import FullNameTitle from '../../common/FullNameTitle'; +import LastMessageMeta from '../../common/LastMessageMeta'; +import MessageSummary from '../../common/MessageSummary'; + +import styles from './MiddleSearchResult.module.scss'; + +type OwnProps = { + isActive?: boolean; + message: ApiMessage; + senderPeer?: ApiPeer; + messageChat?: ApiChat; + shouldShowChat?: boolean; + query?: string; + className?: string; + onClick: (message: ApiMessage) => void; +}; + +const TRUNCATE_LENGTH = 200; + +const MiddleSearchResult = ({ + isActive, + message, + senderPeer, + messageChat, + shouldShowChat, + query, + className, + onClick, +}: OwnProps) => { + const lang = useOldLang(); + const hiddenForwardTitle = message.forwardInfo?.hiddenUserName; + + const peer = shouldShowChat ? messageChat : senderPeer; + + const senderName = shouldShowChat ? getMessageSenderName(lang, message.chatId, senderPeer) : undefined; + + const handleClick = useLastCallback(() => { + onClick(message); + }); + + return ( +
+ +
+
+ {(peer && ) || hiddenForwardTitle} + +
+
+ {senderName && ( + <> + {renderText(senderName)} + : + + )} + +
+
+
+ ); +}; + +export default memo(MiddleSearchResult); diff --git a/src/components/modals/collectible/CollectibleInfoModal.tsx b/src/components/modals/collectible/CollectibleInfoModal.tsx index e1f0bc8c3..eab1dbc18 100644 --- a/src/components/modals/collectible/CollectibleInfoModal.tsx +++ b/src/components/modals/collectible/CollectibleInfoModal.tsx @@ -128,6 +128,7 @@ const CollectibleInfoModal: FC = ({ className={styles.chip} peerId={modal?.peerId} forceShowSelf + clickArg={modal?.peerId} onClick={handleOpenChat} />

diff --git a/src/components/right/RightColumn.tsx b/src/components/right/RightColumn.tsx index 17cebf126..eb1b3cb93 100644 --- a/src/components/right/RightColumn.tsx +++ b/src/components/right/RightColumn.tsx @@ -33,7 +33,6 @@ import Management from './management/Management.async'; import PollResults from './PollResults.async'; import Profile from './Profile'; import RightHeader from './RightHeader'; -import RightSearch from './RightSearch.async'; import BoostStatistics from './statistics/BoostStatistics'; import MessageStatistics from './statistics/MessageStatistics.async'; import Statistics from './statistics/Statistics.async'; @@ -87,7 +86,6 @@ const RightColumn: FC = ({ const { toggleChatInfo, toggleManagement, - closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery, closePollResults, @@ -117,7 +115,6 @@ const RightColumn: FC = ({ const isOpen = contentKey !== undefined; const isProfile = contentKey === RightColumnContent.ChatInfo; - const isSearch = contentKey === RightColumnContent.Search; const isManagement = contentKey === RightColumnContent.Management; const isStatistics = contentKey === RightColumnContent.Statistics; const isMessageStatistics = contentKey === RightColumnContent.MessageStatistics; @@ -200,11 +197,6 @@ const RightColumn: FC = ({ case RightColumnContent.BoostStatistics: closeBoostStatistics(); break; - case RightColumnContent.Search: { - blurSearchInput(); - closeLocalTextSearch(); - break; - } case RightColumnContent.StickerSearch: blurSearchInput(); setStickerSearchQuery({ query: undefined }); @@ -318,16 +310,6 @@ const RightColumn: FC = ({ onProfileStateChange={setProfileState} /> ); - case RightColumnContent.Search: - return ( - - ); case RightColumnContent.Management: return ( = ({ threadId={threadId} isColumnOpen={isOpen} isProfile={isProfile} - isSearch={isSearch} isManagement={isManagement} isStatistics={isStatistics} isBoostStatistics={isBoostStatistics} diff --git a/src/components/right/RightHeader.tsx b/src/components/right/RightHeader.tsx index cd3ade538..abd7947a6 100644 --- a/src/components/right/RightHeader.tsx +++ b/src/components/right/RightHeader.tsx @@ -16,14 +16,11 @@ import { selectChatFullInfo, selectCurrentGifSearch, selectCurrentStickerSearch, - selectCurrentTextSearch, selectIsChatWithSelf, selectTabState, selectUser, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; -import { getDayStartAt } from '../../util/dates/dateFormat'; -import { debounce } from '../../util/schedulers'; import useAppLayout from '../../hooks/useAppLayout'; import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; @@ -46,7 +43,6 @@ type OwnProps = { threadId?: ThreadId; isColumnOpen?: boolean; isProfile?: boolean; - isSearch?: boolean; isManagement?: boolean; isStatistics?: boolean; isBoostStatistics?: boolean; @@ -71,7 +67,6 @@ type StateProps = { isChannel?: boolean; userId?: string; isSelf?: boolean; - messageSearchQuery?: string; stickerSearchQuery?: string; gifSearchQuery?: string; isEditingInvite?: boolean; @@ -85,7 +80,6 @@ type StateProps = { }; const COLUMN_ANIMATION_DURATION = 450 + ANIMATION_END_DELAY; -const runDebouncedForSearch = debounce((cb) => cb(), 200, false); enum HeaderContent { Profile, @@ -132,7 +126,6 @@ const RightHeader: FC = ({ threadId, isColumnOpen, isProfile, - isSearch, isManagement, isStatistics, isMessageStatistics, @@ -151,7 +144,6 @@ const RightHeader: FC = ({ isSelf, canManage, isChannel, - messageSearchQuery, stickerSearchQuery, gifSearchQuery, isEditingInvite, @@ -167,12 +159,9 @@ const RightHeader: FC = ({ canEditBot, }) => { const { - setLocalTextSearchQuery, setStickerSearchQuery, setGifSearchQuery, - searchTextMessagesLocal, toggleManagement, - openHistoryCalendar, openAddContactDialog, toggleStatistics, setEditingExportedInvite, @@ -196,14 +185,6 @@ const RightHeader: FC = ({ closeDeleteDialog(); }); - const handleMessageSearchQueryChange = useLastCallback((query: string) => { - setLocalTextSearchQuery({ query }); - - if (query.length) { - runDebouncedForSearch(searchTextMessagesLocal); - } - }); - const handleStickerSearchQueryChange = useLastCallback((query: string) => { setStickerSearchQuery({ query }); }); @@ -254,8 +235,6 @@ const RightHeader: FC = ({ ) : profileState === ProfileState.SavedDialogs ? ( HeaderContent.SavedDialogs ) : -1 // Never reached - ) : isSearch ? ( - HeaderContent.Search ) : isPollResults ? ( HeaderContent.PollResults ) : isStickerSearch ? ( @@ -350,26 +329,6 @@ const RightHeader: FC = ({ switch (renderingContentKey) { case HeaderContent.PollResults: return

{lang('PollResults')}

; - case HeaderContent.Search: - return ( - <> - - - - ); case HeaderContent.AddingMembers: return

{lang(isChannel ? 'ChannelAddSubscribers' : 'GroupAddMembers')}

; case HeaderContent.ManageInitial: @@ -610,7 +569,6 @@ export default withGlobal( chatId, isProfile, isManagement, threadId, }): StateProps => { const tabState = selectTabState(global); - const { query: messageSearchQuery } = selectCurrentTextSearch(global) || {}; const { query: stickerSearchQuery } = selectCurrentStickerSearch(global) || {}; const { query: gifSearchQuery } = selectCurrentGifSearch(global) || {}; const chat = chatId ? selectChat(global, chatId) : undefined; @@ -643,7 +601,6 @@ export default withGlobal( canEditTopic, userId: user?.id, isSelf: user?.isSelf, - messageSearchQuery, stickerSearchQuery, gifSearchQuery, isEditingInvite, diff --git a/src/components/right/RightSearch.async.tsx b/src/components/right/RightSearch.async.tsx deleted file mode 100644 index 9fa2f60ce..000000000 --- a/src/components/right/RightSearch.async.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; -import React from '../../lib/teact/teact'; - -import type { OwnProps } from './RightSearch'; - -import { Bundles } from '../../util/moduleLoader'; - -import useModuleLoader from '../../hooks/useModuleLoader'; - -import Loading from '../ui/Loading'; - -const RightSearchAsync: FC = (props) => { - const RightSearch = useModuleLoader(Bundles.Extra, 'RightSearch'); - - // eslint-disable-next-line react/jsx-props-no-spreading - return RightSearch ? : ; -}; - -export default RightSearchAsync; diff --git a/src/components/right/RightSearch.scss b/src/components/right/RightSearch.scss deleted file mode 100644 index a3616a2a4..000000000 --- a/src/components/right/RightSearch.scss +++ /dev/null @@ -1,29 +0,0 @@ -.RightSearch { - height: 100%; - padding: 0 0.5rem; - overflow-y: auto; - - .helper-text { - padding: 1rem; - margin-bottom: 0.125rem; - font-weight: 500; - color: var(--color-text-secondary); - unicode-bidi: plaintext; - text-align: initial; - } - - .search-tags { - --color-reaction: var(--color-background-secondary); - --hover-color-reaction: var(--color-background-secondary-accent); - --text-color-reaction: var(--color-text-secondary); - --color-reaction-chosen: var(--color-primary); - --text-color-reaction-chosen: #FFFFFF; - --hover-color-reaction-chosen: var(--color-primary-shade); - - display: flex; - overflow-x: scroll; - - margin-top: 0.25rem; - gap: 0.375rem; - } -} diff --git a/src/components/right/RightSearch.tsx b/src/components/right/RightSearch.tsx deleted file mode 100644 index eccc6f549..000000000 --- a/src/components/right/RightSearch.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { - memo, useEffect, useMemo, useRef, -} from '../../lib/teact/teact'; -import { getActions, getGlobal, withGlobal } from '../../global'; - -import type { - ApiMessage, ApiPeer, ApiReaction, ApiReactionKey, ApiSavedReactionTag, -} from '../../api/types'; -import type { ThreadId } from '../../types'; - -import { ANONYMOUS_USER_ID, REPLIES_USER_ID } from '../../config'; -import { getIsSavedDialog, getReactionKey, isSameReaction } from '../../global/helpers'; -import { - selectChatMessages, - selectCurrentTextSearch, - selectForwardedSender, - selectIsChatWithSelf, - selectIsCurrentUserPremium, - selectSender, -} from '../../global/selectors'; -import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager'; -import { MEMO_EMPTY_ARRAY } from '../../util/memo'; -import { debounce } from '../../util/schedulers'; -import { renderMessageSummary } from '../common/helpers/renderMessageText'; - -import useHistoryBack from '../../hooks/useHistoryBack'; -import useHorizontalScroll from '../../hooks/useHorizontalScroll'; -import useInfiniteScroll from '../../hooks/useInfiniteScroll'; -import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; -import useLastCallback from '../../hooks/useLastCallback'; -import useOldLang from '../../hooks/useOldLang'; - -import Avatar from '../common/Avatar'; -import FullNameTitle from '../common/FullNameTitle'; -import LastMessageMeta from '../common/LastMessageMeta'; -import SavedTagButton from '../middle/message/reactions/SavedTagButton'; -import InfiniteScroll from '../ui/InfiniteScroll'; -import ListItem from '../ui/ListItem'; - -import './RightSearch.scss'; - -export type OwnProps = { - chatId: string; - threadId: ThreadId; - onClose: NoneToVoidFunction; - isActive: boolean; -}; - -type StateProps = { - messagesById?: Record; - query?: string; - savedTags?: Record; - searchTag?: ApiReaction; - totalCount?: number; - foundIds?: number[]; - isSavedMessages?: boolean; - isCurrentUserPremium?: boolean; -}; - -const runDebouncedForSearch = debounce((cb) => cb(), 200, false); - -const RightSearch: FC = ({ - chatId, - threadId, - isActive, - messagesById, - query, - totalCount, - foundIds, - savedTags, - searchTag, - isSavedMessages, - isCurrentUserPremium, - onClose, -}) => { - const { - searchTextMessagesLocal, - setLocalTextSearchTag, - focusMessage, - openPremiumModal, - loadSavedReactionTags, - } = getActions(); - - // eslint-disable-next-line no-null/no-null - const containerRef = useRef(null); - // eslint-disable-next-line no-null/no-null - const tagsRef = useRef(null); - - const lang = useOldLang(); - useHistoryBack({ - isActive, - onBack: onClose, - }); - - useEffect(() => { - if (!isActive) { - return undefined; - } - - disableDirectTextInput(); - - return enableDirectTextInput; - }, [isActive]); - - const tags = useMemo(() => { - if (!savedTags) return undefined; - return Object.values(savedTags); - }, [savedTags]); - - const hasTags = Boolean(tags?.length); - const areTagsDisabled = hasTags && !isCurrentUserPremium; - - useHorizontalScroll(tagsRef, !hasTags); - - useEffect(() => { - if (isActive) loadSavedReactionTags(); - }, [hasTags, isActive]); - - const handleSearchTextMessagesLocal = useLastCallback(() => { - runDebouncedForSearch(searchTextMessagesLocal); - }); - - const handleTagClick = useLastCallback((tag: ApiReaction) => { - if (areTagsDisabled) { - openPremiumModal({ - initialSection: 'saved_tags', - }); - return; - } - - if (isSameReaction(tag, searchTag)) { - setLocalTextSearchTag({ tag: undefined }); - return; - } - - setLocalTextSearchTag({ tag }); - handleSearchTextMessagesLocal(); - }); - - const [viewportIds, getMore] = useInfiniteScroll(handleSearchTextMessagesLocal, foundIds); - - const viewportResults = useMemo(() => { - if ((!query && !searchTag) || !viewportIds?.length || !messagesById) { - return MEMO_EMPTY_ARRAY; - } - - return viewportIds.map((id) => { - const message = messagesById[id]; - if (!message) { - return undefined; - } - - const global = getGlobal(); - - const originalSender = (isSavedMessages || chatId === REPLIES_USER_ID || chatId === ANONYMOUS_USER_ID) - ? selectForwardedSender(global, message) : undefined; - const messageSender = selectSender(global, message); - - const senderPeer = originalSender || messageSender; - - const hiddenForwardTitle = message.forwardInfo?.hiddenUserName; - - return { - message, - senderPeer, - hiddenForwardTitle, - onClick: () => focusMessage({ chatId, threadId, messageId: id }), - }; - }).filter(Boolean); - }, [query, searchTag, viewportIds, messagesById, isSavedMessages, chatId, threadId]); - - const handleKeyDown = useKeyboardListNavigation(containerRef, true, (index) => { - const foundResult = viewportResults?.[index === -1 ? 0 : index]; - if (foundResult) { - foundResult.onClick(); - } - }, '.ListItem-button', true); - - const renderSearchResult = ({ - message, senderPeer, hiddenForwardTitle, onClick, - }: { - message: ApiMessage; - senderPeer?: ApiPeer; - hiddenForwardTitle?: string; - onClick: NoneToVoidFunction; - }) => { - const text = renderMessageSummary(lang, message, undefined, query); - - return ( - - -
-
- {senderPeer && } - {!senderPeer && hiddenForwardTitle} - -
-
- {text} -
-
-
- ); - }; - - const isOnTop = viewportIds?.[0] === foundIds?.[0]; - - return ( - - {hasTags && ( -
- {tags.map((tag) => ( - - ))} -
- )} - {isOnTop && ( -

- {!query ? ( - lang('lng_dlg_search_for_messages') - ) : (totalCount === 0 || !viewportResults.length) ? ( - lang('lng_search_no_results') - ) : totalCount === 1 ? ( - '1 message found' - ) : ( - `${(viewportResults.length && (totalCount || viewportResults.length))} messages found` - )} -

- )} - {viewportResults.map(renderSearchResult)} -
- ); -}; - -export default memo(withGlobal( - (global, { chatId, threadId }): StateProps => { - const messagesById = selectChatMessages(global, chatId); - if (!messagesById) { - return {}; - } - - const { query, savedTag, results } = selectCurrentTextSearch(global) || {}; - const { totalCount, foundIds } = results || {}; - - const isSavedMessages = selectIsChatWithSelf(global, chatId); - const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); - - const savedTags = isSavedMessages && !isSavedDialog ? global.savedReactionTags?.byKey : undefined; - - return { - messagesById, - query, - totalCount, - foundIds, - isSavedMessages, - savedTags, - searchTag: savedTag, - isCurrentUserPremium: selectIsCurrentUserPremium(global), - }; - }, -)(RightSearch)); diff --git a/src/components/story/Story.tsx b/src/components/story/Story.tsx index 626dba56e..198e2e494 100644 --- a/src/components/story/Story.tsx +++ b/src/components/story/Story.tsx @@ -324,7 +324,10 @@ function Story({ onMouseLeave: handleLongPressMouseLeave, onTouchStart: handleLongPressTouchStart, onTouchEnd: handleLongPressTouchEnd, - } = useLongPress(handleLongPressStart, handleLongPressEnd); + } = useLongPress({ + onStart: handleLongPressStart, + onEnd: handleLongPressEnd, + }); const isUnsupported = useUnsupportedMedia( videoRef, diff --git a/src/components/ui/Button.scss b/src/components/ui/Button.scss index ff65d111b..85f32440f 100644 --- a/src/components/ui/Button.scss +++ b/src/components/ui/Button.scss @@ -71,15 +71,6 @@ } } - &.round { - width: 3.5rem; - border-radius: 50%; - - .icon { - font-size: 1.5rem; - } - } - &.primary { background-color: var(--color-primary); color: var(--color-white); @@ -357,6 +348,15 @@ } } + &.round { + width: 3.5rem; + border-radius: 50%; + + .icon { + font-size: 1.5rem; + } + } + &.fluid { padding-left: 1.75rem; padding-right: 1.75rem; diff --git a/src/components/ui/InfiniteScroll.tsx b/src/components/ui/InfiniteScroll.tsx index 7138704e8..a84d3d266 100644 --- a/src/components/ui/InfiniteScroll.tsx +++ b/src/components/ui/InfiniteScroll.tsx @@ -100,7 +100,8 @@ const InfiniteScroll: FC = ({ // Initial preload useEffect(() => { - if (!loadMoreBackwards) { + const container = containerRef.current; + if (!loadMoreBackwards || !container) { return; } @@ -109,7 +110,7 @@ const InfiniteScroll: FC = ({ return; } - const { scrollHeight, clientHeight } = containerRef.current!; + const { scrollHeight, clientHeight } = container; if (clientHeight && scrollHeight < clientHeight) { loadMoreBackwards(); } diff --git a/src/components/ui/ListItem.scss b/src/components/ui/ListItem.scss index ee8f85d54..089dc4549 100644 --- a/src/components/ui/ListItem.scss +++ b/src/components/ui/ListItem.scss @@ -378,60 +378,6 @@ } } - &.search-result-message { - .title { - flex-grow: 1; - padding-right: 0.125rem; - } - - .search-result-message-top { - display: flex; - } - - h3 { - max-width: 80%; - } - - h3, - .subtitle { - font-size: 1rem; - line-height: 1.5rem; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - text-align: left; - display: block; - } - - .subtitle { - color: var(--color-text-secondary); - - .matching-text-highlight { - color: var(--color-text); - background: #cae3f7; - border-radius: 0.25rem; - padding: 0 0.125rem; - display: inline-block; - - .theme-dark & { - --color-text: #000; - } - } - } - - &[dir="rtl"] { - .LastMessageMeta { - margin-left: 0; - margin-right: auto; - } - - .subtitle { - margin-right: 0; - display: block; - } - } - } - &.picker-list-item { margin: 0; diff --git a/src/components/ui/SearchInput.scss b/src/components/ui/SearchInput.scss index da11be6e7..df97d0ac8 100644 --- a/src/components/ui/SearchInput.scss +++ b/src/components/ui/SearchInput.scss @@ -6,11 +6,15 @@ border: 2px solid var(--color-chat-hover); border-radius: 1.375rem; transition: border-color 0.15s ease; + display: flex; + align-items: center; + + padding-inline-end: 0.1875rem; &.with-picker-item { display: flex; - .icon-search { + .icon-container-left { display: none; } @@ -44,34 +48,46 @@ background-color: transparent !important; box-shadow: none !important; padding: - calc(0.4375rem - var(--border-width)) calc(2.625rem - var(--border-width)) - calc(0.5rem - var(--border-width)) calc(2.75rem - var(--border-width)); + calc(0.4375rem - var(--border-width)) calc(0.625rem - var(--border-width)) + calc(0.5rem - var(--border-width)) calc(0.75rem - var(--border-width)); &::placeholder { color: var(--color-placeholders); } } - .icon-container { - position: absolute; - top: 0; - left: 0; - pointer-events: none; + .icon-container-left { + width: 1.5rem; + flex-shrink: 0; + margin-inline-start: 0.75rem; } - .search-icon { - position: absolute; - top: 50%; - left: 0.75rem; - transform: translateY(-50%); - font-size: 1.375rem; + .icon-container-right { + width: 2.5rem; + flex-shrink: 0; + margin-inline-start: 0.5rem; + } + + .icon-container-slide { + display: flex; + align-items: center; + justify-content: center; + } + + .search-icon, .back-icon { + font-size: 1.5rem; line-height: 1; } + .back-icon { + color: var(--color-text-secondary); + } + .Loading { position: absolute; - top: 0; - left: 0.1875rem; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); height: 2.5rem; width: 2.5rem; @@ -81,9 +97,6 @@ } .Button { - position: absolute; - top: 0.125rem; - right: 0.125rem; font-size: 1rem; } @@ -91,7 +104,7 @@ input { height: 2.5rem; border-radius: 1.25rem; - padding-left: calc(2.75rem - var(--border-width)); + padding-left: calc(0.75rem - var(--border-width)); } } @@ -99,20 +112,5 @@ input { direction: rtl; } - - .search-icon { - left: auto; - right: 0.75rem; - } - - .Loading { - right: 0.1875rem; - left: auto; - } - - .Button { - left: 0.125rem; - right: auto; - } } } diff --git a/src/components/ui/SearchInput.tsx b/src/components/ui/SearchInput.tsx index 026ab1c86..4636a1067 100644 --- a/src/components/ui/SearchInput.tsx +++ b/src/components/ui/SearchInput.tsx @@ -1,15 +1,18 @@ import type { RefObject } from 'react'; import type { FC } from '../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useRef, + memo, useEffect, useRef, } from '../../lib/teact/teact'; import buildClassName from '../../util/buildClassName'; import useFlag from '../../hooks/useFlag'; import useInputFocusOnOpen from '../../hooks/useInputFocusOnOpen'; +import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; +import Icon from '../common/icons/Icon'; import Button from './Button'; import Loading from './Loading'; import Transition from './Transition'; @@ -19,7 +22,7 @@ import './SearchInput.scss'; type OwnProps = { ref?: RefObject; children?: React.ReactNode; - parentContainerClassName?: string; + resultsItemSelector?: string; className?: string; inputId?: string; value?: string; @@ -32,17 +35,25 @@ type OwnProps = { autoComplete?: string; canClose?: boolean; autoFocusSearch?: boolean; + hasUpButton?: boolean; + hasDownButton?: boolean; + teactExperimentControlled?: boolean; + withBackIcon?: boolean; onChange: (value: string) => void; + onStartBackspace?: NoneToVoidFunction; onReset?: NoneToVoidFunction; onFocus?: NoneToVoidFunction; onBlur?: NoneToVoidFunction; + onClick?: NoneToVoidFunction; + onUpClick?: (event: React.MouseEvent) => void; + onDownClick?: (event: React.MouseEvent) => void; onSpinnerClick?: NoneToVoidFunction; }; const SearchInput: FC = ({ ref, children, - parentContainerClassName, + resultsItemSelector, value, inputId, className, @@ -55,10 +66,18 @@ const SearchInput: FC = ({ autoComplete, canClose, autoFocusSearch, + hasUpButton, + hasDownButton, + teactExperimentControlled, + withBackIcon, onChange, + onStartBackspace, onReset, onFocus, onBlur, + onClick, + onUpClick, + onDownClick, onSpinnerClick, }) => { // eslint-disable-next-line no-null/no-null @@ -83,48 +102,70 @@ const SearchInput: FC = ({ } }, [focused, placeholder]); // Trick for setting focus when selecting a contact to search for - const lang = useOldLang(); + const oldLang = useOldLang(); + const lang = useLang(); function handleChange(event: React.ChangeEvent) { const { currentTarget } = event; onChange(currentTarget.value); + + if (!isInputFocused) { + handleFocus(); + } } function handleFocus() { markInputFocused(); - if (onFocus) { - onFocus(); - } + onFocus?.(); } function handleBlur() { unmarkInputFocused(); - if (onBlur) { - onBlur(); - } + onBlur?.(); } - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + const handleKeyDown = useLastCallback((e: React.KeyboardEvent) => { + if (!resultsItemSelector) return; if (e.key === 'ArrowDown' || e.key === 'Enter') { - const element = document.querySelector(`.${parentContainerClassName} .ListItem-button`) as HTMLElement; + const element = document.querySelector(resultsItemSelector) as HTMLElement; if (element) { element.focus(); } } - }, [parentContainerClassName]); + + if (e.key === 'Backspace' && e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === 0) { + onStartBackspace?.(); + } + }); return (
- {children} + + {isLoading && !withBackIcon ? ( + + ) : withBackIcon ? ( + + ) : ( + + )} + +
{children}
= ({ onFocus={handleFocus} onBlur={handleBlur} onKeyDown={handleKeyDown} + teactExperimentControlled={teactExperimentControlled} /> - - {isLoading ? ( - - ) : ( - - )} - - {!isLoading && (value || canClose) && onReset && ( + {hasUpButton && ( )} + {hasDownButton && ( + + )} + + {withBackIcon && isLoading ? ( + + ) : ( + (value || canClose) && onReset && ( + + ) + )} +
); }; diff --git a/src/global/actions/all.ts b/src/global/actions/all.ts index eaf9c5f6a..d342311c8 100644 --- a/src/global/actions/all.ts +++ b/src/global/actions/all.ts @@ -3,7 +3,7 @@ import './api/chats'; import './api/messages'; import './api/symbols'; import './api/globalSearch'; -import './api/localSearch'; +import './api/middleSearch'; import './api/management'; import './api/sync'; import './api/accounts'; @@ -19,7 +19,7 @@ import './ui/initial'; import './ui/chats'; import './ui/messages'; import './ui/globalSearch'; -import './ui/localSearch'; +import './ui/middleSearch'; import './ui/stickerSearch'; import './ui/users'; import './ui/settings'; diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 5fdba0781..985336e9b 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -38,7 +38,7 @@ import { isDeepLink } from '../../../util/deepLinkParser'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getOrderedIds } from '../../../util/folderManager'; import { buildCollectionByKey, omit, pick } from '../../../util/iteratees'; -import { isLocalMessageId } from '../../../util/messageKey'; +import { isLocalMessageId } from '../../../util/keys/messageKey'; import * as langProvider from '../../../util/oldLangProvider'; import { debounce, pause, throttle } from '../../../util/schedulers'; import { extractCurrentThemeParams } from '../../../util/themeStyle'; @@ -69,6 +69,7 @@ import { removeChatFromChatLists, replaceChatFullInfo, replaceChatListIds, + replaceChatListLoadingParameters, replaceChats, replaceThreadParam, replaceUsers, @@ -98,8 +99,8 @@ import { selectChatByUsername, selectChatFolder, selectChatFullInfo, - selectChatLastMessage, selectChatLastMessageId, + selectChatListLoadingParameters, selectChatListType, selectChatMessages, selectCurrentChat, @@ -108,6 +109,7 @@ import { selectIsChatPinned, selectIsChatWithSelf, selectLastServiceNotification, + selectPeer, selectSimilarChannelIds, selectStickerSet, selectSupportChat, @@ -512,10 +514,6 @@ addActionHandler('loadAllChats', async (global, actions, payload): Promise let { shouldReplace } = payload; let i = 0; - const getOrderDate = (chat: ApiChat) => { - return selectChatLastMessage(global, chat.id, listType === 'saved' ? 'saved' : 'all')?.date || chat.creationDate; - }; - while (shouldReplace || !global.chats.isFullyLoaded[listType]) { if (i++ >= INFINITE_LOOP_MARKER) { if (DEBUG) { @@ -532,24 +530,8 @@ addActionHandler('loadAllChats', async (global, actions, payload): Promise return; } - const listIds = !shouldReplace && global.chats.listIds[listType]; - const oldestChat = listIds - ? listIds - /* eslint-disable @typescript-eslint/no-loop-func */ - .map((id) => global.chats.byId[id]) - .filter((chat) => ( - Boolean(chat && getOrderDate(chat)) - && chat.id !== SERVICE_NOTIFICATIONS_USER_ID - && !selectIsChatPinned(global, chat.id) - )) - /* eslint-enable @typescript-eslint/no-loop-func */ - .sort((chat1, chat2) => getOrderDate(chat1)! - getOrderDate(chat2)!)[0] - : undefined; - await loadChats( listType, - oldestChat?.id, - oldestChat ? getOrderDate(oldestChat) : undefined, shouldReplace, true, ); @@ -2709,21 +2691,30 @@ addActionHandler('requestCollectibleInfo', async (global, actions, payload): Pro async function loadChats( listType: ChatListType, - offsetId?: string, - offsetDate?: number, shouldReplace = false, isFullDraftSync?: boolean, ) { // eslint-disable-next-line eslint-multitab-tt/no-immediate-global let global = getGlobal(); let lastLocalServiceMessageId = selectLastServiceNotification(global)?.id; + + const params = selectChatListLoadingParameters(global, listType); + const offsetPeer = !shouldReplace && params.nextOffsetPeerId + ? selectPeer(global, params.nextOffsetPeerId) : undefined; + const offsetDate = !shouldReplace ? params.nextOffsetDate : undefined; + const offsetId = !shouldReplace ? params.nextOffsetId : undefined; + const result = listType === 'saved' ? await callApi('fetchSavedChats', { limit: CHAT_LIST_LOAD_SLICE, offsetDate, + offsetId, + offsetPeer, withPinned: shouldReplace, }) : await callApi('fetchChats', { limit: CHAT_LIST_LOAD_SLICE, offsetDate, + offsetId, + offsetPeer, archived: listType === 'archived', withPinned: shouldReplace, lastLocalServiceMessageId, @@ -2735,10 +2726,6 @@ async function loadChats( const { chatIds } = result; - if (chatIds.length > 0 && chatIds[0] === offsetId) { - chatIds.shift(); - } - global = getGlobal(); lastLocalServiceMessageId = selectLastServiceNotification(global)?.id; @@ -2806,6 +2793,10 @@ async function loadChats( global = addMessages(global, result.messages); global = updateChatsLastMessageId(global, result.lastMessageByChatId, listType); + global = replaceChatListLoadingParameters( + global, listType, result.nextOffsetId, result.nextOffsetPeerId, result.nextOffsetDate, + ); + const idsToUpdateDraft = isFullDraftSync ? result.chatIds : Object.keys(result.draftsById); idsToUpdateDraft.forEach((chatId) => { const draft = result.draftsById[chatId]; diff --git a/src/global/actions/api/globalSearch.ts b/src/global/actions/api/globalSearch.ts index 14174a1a2..d1b8f6247 100644 --- a/src/global/actions/api/globalSearch.ts +++ b/src/global/actions/api/globalSearch.ts @@ -72,7 +72,8 @@ addActionHandler('setGlobalSearchDate', (global, actions, payload): ActionReturn const maxDate = date ? timestampPlusDay(date) : date; global = updateGlobalSearch(global, { - date, + minDate: date, + maxDate, query: '', resultsByType: { ...selectTabState(global, tabId).globalSearch.resultsByType, @@ -85,29 +86,46 @@ addActionHandler('setGlobalSearchDate', (global, actions, payload): ActionReturn }, tabId); setGlobal(global); - const { chatId } = selectTabState(global, tabId).globalSearch; - const chat = chatId ? selectChat(global, chatId) : undefined; - searchMessagesGlobal(global, '', 'text', undefined, chat, maxDate, date, tabId); + actions.searchMessagesGlobal({ type: 'text', tabId }); }); addActionHandler('searchMessagesGlobal', (global, actions, payload): ActionReturnType => { const { type, tabId = getCurrentTabId() } = payload; const { - query, resultsByType, chatId, date, + query, resultsByType, chatId, } = selectTabState(global, tabId).globalSearch; - const maxDate = date ? timestampPlusDay(date) : date; - const nextOffsetId = (resultsByType?.[type as ApiGlobalMessageSearchType])?.nextOffsetId; + const offsetId = (resultsByType?.[type])?.nextOffsetId; + const offsetRate = (resultsByType?.[type])?.nextOffsetRate; + const offsetPeerId = (resultsByType?.[type])?.nextOffsetPeerId; const chat = chatId ? selectChat(global, chatId) : undefined; + const offsetPeer = offsetPeerId ? selectChat(global, offsetPeerId) : undefined; - searchMessagesGlobal(global, query, type, nextOffsetId, chat, maxDate, date, tabId); + searchMessagesGlobal(global, { + query, + type, + offsetRate, + offsetId, + offsetPeer, + chat, + tabId, + }); }); -async function searchMessagesGlobal( - global: T, - query = '', type: ApiGlobalMessageSearchType, offsetRate?: number, chat?: ApiChat, maxDate?: number, minDate?: number, - ...[tabId = getCurrentTabId()]: TabArgs -) { +async function searchMessagesGlobal(global: T, params: { + query?: string; + type: ApiGlobalMessageSearchType; + offsetRate?: number; + offsetId?: number; + offsetPeer?: ApiChat; + chat?: ApiChat; + maxDate?: number; + minDate?: number; + tabId: TabArgs[0]; +}) { + const { + query = '', type, offsetRate, offsetId, offsetPeer, chat, maxDate, minDate, tabId = getCurrentTabId(), + } = params; let result: { messages: ApiMessage[]; users: ApiUser[]; @@ -115,18 +133,20 @@ async function searchMessagesGlobal( topics?: ApiTopic[]; totalTopicsCount?: number; totalCount: number; - nextRate: number | undefined; + nextOffsetRate?: number; + nextOffsetId?: number; + nextOffsetPeerId?: string; } | undefined; let messageLink: ApiMessage | undefined; if (chat) { - const localResultRequest = callApi('searchMessagesLocal', { + const inChatResultRequest = callApi('searchMessagesInChat', { chat, query, type, limit: GLOBAL_SEARCH_SLICE, - offsetId: offsetRate, + offsetId, minDate, maxDate, }); @@ -136,12 +156,12 @@ async function searchMessagesGlobal( limit: GLOBAL_TOPIC_SEARCH_SLICE, }) : undefined; - const [localResult, topics] = await Promise.all([localResultRequest, topicsRequest]); + const [inChatResult, topics] = await Promise.all([inChatResultRequest, topicsRequest]); - if (localResult) { + if (inChatResult) { const { messages, users, totalCount, nextOffsetId, - } = localResult; + } = inChatResult; const { topics: localTopics, count } = topics || {}; @@ -152,13 +172,15 @@ async function searchMessagesGlobal( users, chats: [], totalCount, - nextRate: nextOffsetId, + nextOffsetId, }; } } else { result = await callApi('searchMessagesGlobal', { query, offsetRate, + offsetId, + offsetPeer, limit: GLOBAL_SEARCH_SLICE, type, maxDate, @@ -187,7 +209,7 @@ async function searchMessagesGlobal( } const { - messages, users, chats, totalCount, nextRate, + messages, users, chats, totalCount, nextOffsetRate, nextOffsetId, nextOffsetPeerId, } = result; if (chats.length) { @@ -207,7 +229,9 @@ async function searchMessagesGlobal( messages, totalCount, type, - nextRate, + nextOffsetRate, + nextOffsetId, + nextOffsetPeerId, tabId, ); diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index a1d2a79a6..098bdde0f 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -16,7 +16,7 @@ import type { ApiUser, ApiVideo, } from '../../../api/types'; -import type { MessageKey } from '../../../util/messageKey'; +import type { MessageKey } from '../../../util/keys/messageKey'; import type { RequiredGlobalActions } from '../../index'; import type { ActionReturnType, ApiDraft, GlobalState, TabArgs, @@ -46,7 +46,7 @@ import { split, unique, } from '../../../util/iteratees'; -import { getMessageKey, isLocalMessageId } from '../../../util/messageKey'; +import { getMessageKey, isLocalMessageId } from '../../../util/keys/messageKey'; import { oldTranslate } from '../../../util/oldLangProvider'; import { debounce, onTickEnd, rafPromise } from '../../../util/schedulers'; import { IS_IOS } from '../../../util/windowEnvironment'; diff --git a/src/global/actions/api/localSearch.ts b/src/global/actions/api/middleSearch.ts similarity index 75% rename from src/global/actions/api/localSearch.ts rename to src/global/actions/api/middleSearch.ts index b83030107..780775ea2 100644 --- a/src/global/actions/api/localSearch.ts +++ b/src/global/actions/api/middleSearch.ts @@ -1,8 +1,8 @@ -import type { ApiChat } from '../../../api/types'; import type { ChatMediaSearchParams, ChatMediaSearchSegment, LoadingState, SharedMediaType, ThreadId, } from '../../../types'; import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; +import { type ApiChat, MAIN_THREAD_ID } from '../../../api/types'; import { LoadMoreDirection } from '../../../types'; import { @@ -10,6 +10,7 @@ import { } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { buildCollectionByKey, isInsideSortedArrayRange } from '../../../util/iteratees'; +import { getSearchResultKey } from '../../../util/keys/searchResultKey'; import { callApi } from '../../../api/gramjs'; import { getChatMediaMessageIds, getIsSavedDialog, isSameReaction } from '../../helpers'; import { @@ -18,27 +19,30 @@ import { import { addChatMessagesById, addChats, + addMessages, addUsers, initializeChatMediaSearchResults, mergeWithChatMediaSearchSegment, setChatMediaSearchLoading, updateChatMediaSearchResults, - updateLocalTextSearchResults, + updateMiddleSearch, + updateMiddleSearchResults, updateSharedMediaSearchResults, } from '../../reducers'; import { selectChat, selectCurrentChatMediaSearch, selectCurrentMessageList, + selectCurrentMiddleSearch, selectCurrentSharedMediaSearch, - selectCurrentTextSearch, } from '../../selectors'; const MEDIA_PRELOAD_OFFSET = 9; -addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Promise => { - const { tabId = getCurrentTabId() } = payload || {}; - const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; +addActionHandler('performMiddleSearch', async (global, actions, payload): Promise => { + const { + query, chatId, threadId = MAIN_THREAD_ID, tabId = getCurrentTabId(), + } = payload || {}; if (!chatId) return; @@ -47,45 +51,91 @@ addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Pr const realChatId = isSavedDialog ? String(threadId) : chatId; const chat = realChatId ? selectChat(global, realChatId) : undefined; - let currentSearch = selectCurrentTextSearch(global, tabId); - if (!chat || !threadId || !currentSearch) { + let currentSearch = selectCurrentMiddleSearch(global, tabId); + if (!chat) { return; } - const { query, results, savedTag } = currentSearch; + if (!currentSearch) { + global = updateMiddleSearch(global, realChatId, threadId, {}, tabId); + setGlobal(global); + global = getGlobal(); + } + currentSearch = selectCurrentMiddleSearch(global, tabId)!; + + const { + results, savedTag, type, isHashtag, + } = currentSearch; const offsetId = results?.nextOffsetId; + const offsetRate = results?.nextOffsetRate; + const offsetPeerId = results?.nextOffsetPeerId; + const offsetPeer = offsetPeerId ? selectChat(global, offsetPeerId) : undefined; - if (!query && !savedTag) { + const shouldHaveQuery = isHashtag || !savedTag; + if (shouldHaveQuery && !query) { + global = updateMiddleSearch(global, realChatId, threadId, { + fetchingQuery: undefined, + }, tabId); + setGlobal(global); return; } - const result = await callApi('searchMessagesLocal', { - chat, - type: 'text', - query, - threadId, - limit: MESSAGE_SEARCH_SLICE, - offsetId, - isSavedDialog, - savedTag, - }); + global = updateMiddleSearch(global, realChatId, threadId, { + fetchingQuery: query, + }, tabId); + setGlobal(global); + + let result; + if (type === 'chat') { + result = await callApi('searchMessagesInChat', { + chat, + type: 'text', + query: isHashtag ? `#${query}` : query, + threadId, + limit: MESSAGE_SEARCH_SLICE, + offsetId, + isSavedDialog, + savedTag, + }); + } + + if (type === 'myChats') { + result = await callApi('searchMessagesGlobal', { + type: 'text', + query: isHashtag ? `#${query}` : query!, + limit: MESSAGE_SEARCH_SLICE, + offsetId, + offsetRate, + offsetPeer, + }); + } + + if (type === 'channels') { + result = await callApi('searchHashtagPosts', { + hashtag: query!, + limit: MESSAGE_SEARCH_SLICE, + offsetId, + offsetPeer, + offsetRate, + }); + } if (!result) { return; } const { - chats, users, messages, totalCount, nextOffsetId, + chats, users, messages, totalCount, nextOffsetId, nextOffsetRate, nextOffsetPeerId, } = result; - const byId = buildCollectionByKey(messages, 'id'); - const newFoundIds = Object.keys(byId).map(Number); + const newFoundIds = messages.map(getSearchResultKey); global = getGlobal(); - currentSearch = selectCurrentTextSearch(global, tabId); - const hasTagChanged = !isSameReaction(savedTag, currentSearch?.savedTag); - if (!currentSearch || query !== currentSearch.query || hasTagChanged) { + currentSearch = selectCurrentMiddleSearch(global, tabId); + const hasTagChanged = currentSearch?.savedTag && !isSameReaction(savedTag, currentSearch.savedTag); + const hasSearchChanged = currentSearch?.fetchingQuery && currentSearch.fetchingQuery !== query; + if (!currentSearch || hasSearchChanged || hasTagChanged) { return; } @@ -93,11 +143,42 @@ addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Pr global = addChats(global, buildCollectionByKey(chats, 'id')); global = addUsers(global, buildCollectionByKey(users, 'id')); - global = addChatMessagesById(global, resultChatId, byId); - global = updateLocalTextSearchResults(global, resultChatId, threadId, newFoundIds, totalCount, nextOffsetId, tabId); + global = addMessages(global, messages); + global = updateMiddleSearch(global, resultChatId, threadId, { + fetchingQuery: undefined, + }, tabId); + global = updateMiddleSearchResults(global, resultChatId, threadId, { + foundIds: newFoundIds, + totalCount, + nextOffsetId, + nextOffsetRate, + nextOffsetPeerId, + query: query || '', + }, tabId); setGlobal(global); }); +addActionHandler('searchHashtag', (global, actions, payload): ActionReturnType => { + const { hashtag, tabId = getCurrentTabId() } = payload; + + const messageList = selectCurrentMessageList(global, tabId); + if (!messageList) { + return; + } + + const cleanQuery = hashtag.replace(/^#/, ''); + + actions.updateMiddleSearch({ + chatId: messageList.chatId, + threadId: messageList.threadId, + update: { + isHashtag: true, + requestedQuery: cleanQuery, + }, + tabId, + }); +}); + addActionHandler('searchSharedMediaMessages', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; @@ -203,7 +284,7 @@ async function searchSharedMedia( ) { const resultChatId = isSavedDialog ? global.currentUserId! : chat.id; - const result = await callApi('searchMessagesLocal', { + const result = await callApi('searchMessagesInChat', { chat, type, limit: SHARED_MEDIA_SLICE * 2, @@ -367,7 +448,7 @@ async function searchChatMedia( global = setChatMediaSearchLoading(global, resultChatId, threadId, true, tabId); setGlobal(global); - const result = await callApi('searchMessagesLocal', { + const result = await callApi('searchMessagesInChat', { chat, type: 'media', limit, diff --git a/src/global/actions/api/reactions.ts b/src/global/actions/api/reactions.ts index 5704d6e63..bb1ef79cf 100644 --- a/src/global/actions/api/reactions.ts +++ b/src/global/actions/api/reactions.ts @@ -7,8 +7,8 @@ import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { buildCollectionByCallback, buildCollectionByKey, omit, unique, } from '../../../util/iteratees'; +import { getMessageKey } from '../../../util/keys/messageKey'; import * as mediaLoader from '../../../util/mediaLoader'; -import { getMessageKey } from '../../../util/messageKey'; import requestActionTimeout from '../../../util/requestActionTimeout'; import { callApi } from '../../../api/gramjs'; import { diff --git a/src/global/actions/apiUpdaters/chats.ts b/src/global/actions/apiUpdaters/chats.ts index a79ff19c8..00794acdb 100644 --- a/src/global/actions/apiUpdaters/chats.ts +++ b/src/global/actions/apiUpdaters/chats.ts @@ -4,7 +4,7 @@ import { MAIN_THREAD_ID } from '../../../api/types'; import { ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS } from '../../../config'; import { buildCollectionByKey, omit } from '../../../util/iteratees'; -import { isLocalMessageId } from '../../../util/messageKey'; +import { isLocalMessageId } from '../../../util/keys/messageKey'; import { closeMessageNotifications, notifyAboutMessage } from '../../../util/notifications'; import { buildLocalMessage } from '../../../api/gramjs/apiBuilders/messages'; import { checkIfHasUnreadReactions, isChatChannel } from '../../helpers'; diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 1eb67b0d0..26dba885b 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -15,7 +15,7 @@ import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { buildCollectionByKey, omit, pickTruthy, unique, } from '../../../util/iteratees'; -import { getMessageKey, isLocalMessageId } from '../../../util/messageKey'; +import { getMessageKey, isLocalMessageId } from '../../../util/keys/messageKey'; import { notifyAboutMessage } from '../../../util/notifications'; import { onTickEnd } from '../../../util/schedulers'; import { diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index 5ba0ebf21..146a769c7 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -6,13 +6,13 @@ import { createMessageHashUrl } from '../../../util/routing'; import { IS_ELECTRON } from '../../../util/windowEnvironment'; import { addActionHandler, setGlobal } from '../../index'; import { + closeMiddleSearch, exitMessageSelectMode, replaceTabThreadParam, updateCurrentMessageList, updateRequestedChatTranslation, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; import { selectChat, selectCurrentMessageList, selectTabState, } from '../../selectors'; -import { closeLocalTextSearch } from './localSearch'; addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionReturnType => { const { @@ -50,10 +50,11 @@ addActionHandler('processOpenChatOrThread', (global, actions, payload): ActionRe activeReactions: {}, shouldPreventComposerAnimation: true, }, tabId); + + global = closeMiddleSearch(global, chatId, threadId, tabId); } global = exitMessageSelectMode(global, tabId); - global = closeLocalTextSearch(global, tabId); global = updateTabState(global, { isStatisticsShown: false, diff --git a/src/global/actions/ui/localSearch.ts b/src/global/actions/ui/localSearch.ts deleted file mode 100644 index 7aa415796..000000000 --- a/src/global/actions/ui/localSearch.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; - -import { getCurrentTabId } from '../../../util/establishMultitabRole'; -import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; -import { buildChatThreadKey, isSameReaction } from '../../helpers'; -import { addActionHandler } from '../../index'; -import { - replaceLocalTextSearchResults, - updateLocalTextSearch, - updateLocalTextSearchTag, - updateSharedMediaSearchType, -} from '../../reducers'; -import { selectCurrentMessageList, selectTabState } from '../../selectors'; - -addActionHandler('openLocalTextSearch', (global, actions, payload): ActionReturnType => { - const { tabId = getCurrentTabId() } = payload || {}; - const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; - if (!chatId || !threadId) { - return undefined; - } - - return updateLocalTextSearch(global, chatId, threadId, '', tabId); -}); - -addActionHandler('closeLocalTextSearch', (global, actions, payload): ActionReturnType => { - const { tabId = getCurrentTabId() } = payload || {}; - return closeLocalTextSearch(global, tabId); -}); - -addActionHandler('setLocalTextSearchQuery', (global, actions, payload): ActionReturnType => { - const { query, tabId = getCurrentTabId() } = payload!; - - const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; - if (!chatId || !threadId) { - return undefined; - } - - const chatThreadKey = buildChatThreadKey(chatId, threadId); - const { query: currentQuery } = selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey] || {}; - - if (query !== currentQuery) { - global = replaceLocalTextSearchResults(global, chatId, threadId, MEMO_EMPTY_ARRAY, undefined, undefined, tabId); - } - - global = updateLocalTextSearch(global, chatId, threadId, query, tabId); - - return global; -}); - -addActionHandler('setLocalTextSearchTag', (global, actions, payload): ActionReturnType => { - const { tag, tabId = getCurrentTabId() } = payload!; - - const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; - if (!chatId || !threadId) { - return undefined; - } - - const chatThreadKey = buildChatThreadKey(chatId, threadId); - const { savedTag } = selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey] || {}; - - if (!isSameReaction(tag, savedTag)) { - global = replaceLocalTextSearchResults(global, chatId, threadId, MEMO_EMPTY_ARRAY, undefined, undefined, tabId); - } - - global = updateLocalTextSearchTag(global, chatId, threadId, tag, tabId); - - return global; -}); - -addActionHandler('setSharedMediaSearchType', (global, actions, payload): ActionReturnType => { - const { mediaType, tabId = getCurrentTabId() } = payload; - const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; - if (!chatId || !threadId) { - return undefined; - } - - return updateSharedMediaSearchType(global, chatId, threadId, mediaType, tabId); -}); - -export function closeLocalTextSearch( - global: T, - ...[tabId = getCurrentTabId()]: TabArgs -): T { - const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; - if (!chatId || !threadId) { - return global; - } - - global = updateLocalTextSearchTag(global, chatId, threadId, undefined, tabId); - global = updateLocalTextSearch(global, chatId, threadId, undefined, tabId); - global = replaceLocalTextSearchResults(global, chatId, threadId, undefined, undefined, undefined, tabId); - return global; -} diff --git a/src/global/actions/ui/middleSearch.ts b/src/global/actions/ui/middleSearch.ts new file mode 100644 index 000000000..14069ce92 --- /dev/null +++ b/src/global/actions/ui/middleSearch.ts @@ -0,0 +1,76 @@ +import type { ActionReturnType } from '../../types'; +import { MAIN_THREAD_ID } from '../../../api/types'; + +import { getCurrentTabId } from '../../../util/establishMultitabRole'; +import { addActionHandler } from '../../index'; +import { + closeMiddleSearch, + resetMiddleSearch, + updateMiddleSearch, + updateSharedMediaSearchType, +} from '../../reducers'; +import { selectCurrentMessageList } from '../../selectors'; + +addActionHandler('openMiddleSearch', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; + if (!chatId || !threadId) { + return undefined; + } + + return updateMiddleSearch(global, chatId, threadId, {}, tabId); +}); + +addActionHandler('closeMiddleSearch', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; + if (!chatId || !threadId) { + return undefined; + } + + return closeMiddleSearch(global, chatId, threadId, tabId); +}); + +addActionHandler('updateMiddleSearch', (global, actions, payload): ActionReturnType => { + const { + update, tabId = getCurrentTabId(), + } = payload; + + let chatId; + let threadId; + if (payload.chatId) { + chatId = payload.chatId; + threadId = payload.threadId || MAIN_THREAD_ID; + } else { + const currentMessageList = selectCurrentMessageList(global, tabId); + if (!currentMessageList) { + return undefined; + } + chatId = currentMessageList.chatId; + threadId = currentMessageList.threadId; + } + + global = updateMiddleSearch(global, chatId, threadId, update, tabId); + + return global; +}); + +addActionHandler('resetMiddleSearch', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; + if (!chatId || !threadId) { + return undefined; + } + + return resetMiddleSearch(global, chatId, threadId, tabId); +}); + +addActionHandler('setSharedMediaSearchType', (global, actions, payload): ActionReturnType => { + const { mediaType, tabId = getCurrentTabId() } = payload; + const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; + if (!chatId || !threadId) { + return undefined; + } + + return updateSharedMediaSearchType(global, chatId, threadId, mediaType, tabId); +}); diff --git a/src/global/cache.ts b/src/global/cache.ts index bb2cd8354..505b0a8f1 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -252,6 +252,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { if (!cached.quickReplies) { cached.quickReplies = initialState.quickReplies; } + + if (!cached.chats.loadingParameters) { + cached.chats.loadingParameters = initialState.chats.loadingParameters; + } } function updateCache(force?: boolean) { diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index e505b9673..ba0311d3f 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -27,6 +27,14 @@ export function isUserId(entityId: string) { return !entityId.startsWith('-'); } +export function isPeerChat(entity: ApiPeer): entity is ApiChat { + return 'title' in entity; +} + +export function isPeerUser(entity: ApiPeer): entity is ApiUser { + return !isPeerChat(entity); +} + export function isChannelId(entityId: string) { return entityId.length === CHANNEL_ID_LENGTH && entityId.startsWith('-1'); } @@ -365,14 +373,12 @@ export function getMessageSenderName(lang: LangFn, chatId: string, sender?: ApiP return undefined; } - if (!isUserId(sender.id)) { + if (isPeerChat(sender)) { if (chatId === sender.id) return undefined; - return (sender as ApiChat).title; + return sender.title; } - sender = sender as ApiUser; - if (sender.isSelf) { return lang('FromYou'); } diff --git a/src/global/helpers/index.ts b/src/global/helpers/index.ts index b1e1c98b9..e178ecf2a 100644 --- a/src/global/helpers/index.ts +++ b/src/global/helpers/index.ts @@ -2,7 +2,7 @@ export * from './users'; export * from './chats'; export * from './messages'; export * from './messageMedia'; -export * from './localSearch'; +export * from './middleSearch'; export * from './reactions'; export * from './bots'; export * from './media'; diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 721bef923..09201eed7 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -1,11 +1,9 @@ import type { ApiAttachment, - ApiChat, ApiMessage, ApiMessageEntityTextUrl, ApiPeer, ApiStory, - ApiUser, } from '../../api/types'; import type { MediaContent } from '../../api/types/messages'; import type { LangFn } from '../../hooks/useOldLang'; @@ -24,10 +22,15 @@ import { VIDEO_STICKER_MIME_TYPE, } from '../../config'; import { areSortedArraysIntersecting, unique } from '../../util/iteratees'; -import { isLocalMessageId } from '../../util/messageKey'; +import { isLocalMessageId } from '../../util/keys/messageKey'; import { getServerTime } from '../../util/serverTime'; import { getGlobal } from '../index'; -import { getChatTitle, getCleanPeerId, isUserId } from './chats'; +import { + getChatTitle, + getCleanPeerId, + isPeerUser, + isUserId, +} from './chats'; import { getMainUsername, getUserFullName } from './users'; const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); @@ -182,7 +185,7 @@ export function isAnonymousOwnMessage(message: ApiMessage) { } export function getSenderTitle(lang: LangFn, sender: ApiPeer) { - return isUserId(sender.id) ? getUserFullName(sender as ApiUser) : getChatTitle(lang, sender as ApiChat); + return isPeerUser(sender) ? getUserFullName(sender) : getChatTitle(lang, sender); } export function getSendingState(message: ApiMessage) { diff --git a/src/global/helpers/localSearch.ts b/src/global/helpers/middleSearch.ts similarity index 100% rename from src/global/helpers/localSearch.ts rename to src/global/helpers/middleSearch.ts diff --git a/src/global/init.ts b/src/global/init.ts index 719b335ae..975193e56 100644 --- a/src/global/init.ts +++ b/src/global/init.ts @@ -6,12 +6,12 @@ import { isCacheApiSupported } from '../util/cacheApi'; import { getCurrentTabId, reestablishMasterToSelf } from '../util/establishMultitabRole'; import { initGlobal } from '../util/init'; import { cloneDeep } from '../util/iteratees'; -import { isLocalMessageId } from '../util/messageKey'; +import { isLocalMessageId } from '../util/keys/messageKey'; import { Bundles, loadBundle } from '../util/moduleLoader'; import { parseLocationHash } from '../util/routing'; import { updatePeerColors } from '../util/theme'; import { IS_MULTITAB_SUPPORTED } from '../util/windowEnvironment'; -import { initializeChatMediaSearchResults } from './reducers/localSearch'; +import { initializeChatMediaSearchResults } from './reducers/middleSearch'; import { updateTabState } from './reducers/tabs'; import { initCache } from './cache'; import { diff --git a/src/global/initialState.ts b/src/global/initialState.ts index f668e7685..13fa816e5 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -108,6 +108,11 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { byId: {}, fullInfoById: {}, similarChannelsById: {}, + loadingParameters: { + active: {}, + archived: {}, + saved: {}, + }, }, messages: { @@ -317,7 +322,7 @@ export const INITIAL_TAB_STATE: TabState = { userSearch: {}, - localTextSearch: { + middleSearch: { byChatThreadKey: {}, }, diff --git a/src/global/reducers/chats.ts b/src/global/reducers/chats.ts index 87845e6c4..42c86d6dd 100644 --- a/src/global/reducers/chats.ts +++ b/src/global/reducers/chats.ts @@ -30,6 +30,25 @@ export function replaceChatListIds( }; } +export function replaceChatListLoadingParameters( + global: T, type: ChatListType, nextOffsetId?: number, nextOffsetPeerId?: string, nextOffsetDate?: number, +): T { + return { + ...global, + chats: { + ...global.chats, + loadingParameters: { + ...global.chats.loadingParameters, + [type]: { + nextOffsetId, + nextOffsetPeerId, + nextOffsetDate, + }, + }, + }, + }; +} + export function updateChatLastMessageId( global: T, chatId: string, lastMessageId: number, listType?: ChatListType, ): T { diff --git a/src/global/reducers/globalSearch.ts b/src/global/reducers/globalSearch.ts index ca56ba8b8..483b652a8 100644 --- a/src/global/reducers/globalSearch.ts +++ b/src/global/reducers/globalSearch.ts @@ -4,11 +4,10 @@ import type { GlobalState, TabArgs, TabState } from '../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; import { areSortedArraysEqual } from '../../util/iteratees'; +import { getSearchResultKey } from '../../util/keys/searchResultKey'; import { selectTabState } from '../selectors'; import { updateTabState } from './tabs'; -const getComplexKey = (message: ApiMessage) => `${message.chatId}_${message.id}`; - export function updateGlobalSearch( global: T, searchStatePartial: Partial, @@ -35,12 +34,14 @@ export function updateGlobalSearchResults( newFoundMessages: ApiMessage[], totalCount: number, type: ApiGlobalMessageSearchType, - nextRate?: number, + nextOffsetRate?: number, + nextOffsetId?: number, + nextOffsetPeerId?: string, ...[tabId = getCurrentTabId()]: TabArgs ): T { const { resultsByType } = selectTabState(global, tabId).globalSearch || {}; const newFoundMessagesById = newFoundMessages.reduce((result, message) => { - result[getComplexKey(message)] = message; + result[getSearchResultKey(message)] = message; return result; }, {} as Record); @@ -48,7 +49,7 @@ export function updateGlobalSearchResults( if (foundIdsForType !== undefined && Object.keys(newFoundMessagesById).every( - (newId) => foundIdsForType.includes(getComplexKey(newFoundMessagesById[newId])), + (newId) => foundIdsForType.includes(getSearchResultKey(newFoundMessagesById[newId])), ) ) { return updateGlobalSearchFetchingStatus(global, { messages: false }, tabId); @@ -56,7 +57,7 @@ export function updateGlobalSearchResults( const prevFoundIds = foundIdsForType || []; const newFoundIds = newFoundMessages - .map((message) => getComplexKey(message)) + .map((message) => getSearchResultKey(message)) .filter((id) => !prevFoundIds.includes(id)); const foundIds = Array.prototype.concat(prevFoundIds, newFoundIds); const foundOrPrevFoundIds = areSortedArraysEqual(prevFoundIds, foundIds) ? prevFoundIds : foundIds; @@ -68,7 +69,9 @@ export function updateGlobalSearchResults( ...(selectTabState(global, tabId).globalSearch || {}).resultsByType, [type]: { totalCount, - nextOffsetId: nextRate, + nextOffsetId, + nextOffsetRate, + nextOffsetPeerId, foundIds: foundOrPrevFoundIds, }, }, diff --git a/src/global/reducers/index.ts b/src/global/reducers/index.ts index 14813ff81..45afe7d14 100644 --- a/src/global/reducers/index.ts +++ b/src/global/reducers/index.ts @@ -3,7 +3,7 @@ export * from './messages'; export * from './symbols'; export * from './users'; export * from './globalSearch'; -export * from './localSearch'; +export * from './middleSearch'; export * from './management'; export * from './settings'; export * from './twoFaSettings'; diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index 87f1432c4..8b8748677 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -14,7 +14,7 @@ import { getCurrentTabId } from '../../util/establishMultitabRole'; import { areSortedArraysEqual, excludeSortedArray, omit, pick, pickTruthy, unique, } from '../../util/iteratees'; -import { isLocalMessageId, type MessageKey } from '../../util/messageKey'; +import { isLocalMessageId, type MessageKey } from '../../util/keys/messageKey'; import { hasMessageTtl, isMediaLoadableInViewer, mergeIdRanges, orderHistoryIds, orderPinnedIds, @@ -38,7 +38,7 @@ import { selectThreadInfo, selectViewportIds, } from '../selectors'; -import { removeIdFromSearchResults } from './localSearch'; +import { removeIdFromSearchResults } from './middleSearch'; import { updateTabState } from './tabs'; import { clearMessageTranslation } from './translations'; diff --git a/src/global/reducers/localSearch.ts b/src/global/reducers/middleSearch.ts similarity index 78% rename from src/global/reducers/localSearch.ts rename to src/global/reducers/middleSearch.ts index 5d4b4998f..9b33adf01 100644 --- a/src/global/reducers/localSearch.ts +++ b/src/global/reducers/middleSearch.ts @@ -1,27 +1,24 @@ -import type { ApiMessage, ApiMessageSearchType, ApiReaction } from '../../api/types'; +import type { ApiMessage, ApiMessageSearchType } from '../../api/types'; import type { - ChatMediaSearchParams, ChatMediaSearchSegment, LoadingState, - SharedMediaType, ThreadId, + ChatMediaSearchParams, + ChatMediaSearchSegment, + LoadingState, + MiddleSearchParams, + MiddleSearchResults, + SharedMediaType, + ThreadId, } from '../../types'; import type { GlobalState, TabArgs } from '../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; -import { areSortedArraysEqual, areSortedArraysIntersecting, unique } from '../../util/iteratees'; +import { + areSortedArraysEqual, areSortedArraysIntersecting, omit, unique, +} from '../../util/iteratees'; import { buildChatThreadKey, isMediaLoadableInViewer } from '../helpers'; import { selectTabState } from '../selectors'; -import { selectChatMediaSearch } from '../selectors/localSearch'; +import { selectChatMediaSearch } from '../selectors/middleSearch'; import { updateTabState } from './tabs'; -interface TextSearchParams { - query?: string; - savedTag?: ApiReaction; - results?: { - totalCount?: number; - nextOffsetId?: number; - foundIds?: number[]; - }; -} - interface SharedMediaSearchParams { currentType?: SharedMediaType; resultsByType?: Partial>; } -function replaceLocalTextSearch( +function replaceMiddleSearch( global: T, chatThreadKey: string, - searchParams: TextSearchParams, + searchParams?: MiddleSearchParams, ...[tabId = getCurrentTabId()]: TabArgs ): T { + const current = selectTabState(global, tabId).middleSearch.byChatThreadKey; + if (!searchParams) { + return updateTabState(global, { + middleSearch: { + byChatThreadKey: omit(current, [chatThreadKey]), + }, + }, tabId); + } + + const { type = 'chat', ...rest } = searchParams; return updateTabState(global, { - localTextSearch: { + middleSearch: { byChatThreadKey: { - ...selectTabState(global, tabId).localTextSearch.byChatThreadKey, - [chatThreadKey]: searchParams, + ...selectTabState(global, tabId).middleSearch.byChatThreadKey, + [chatThreadKey]: { + type, + ...rest, + }, }, }, }, tabId); } -export function updateLocalTextSearch( +export function updateMiddleSearch( global: T, chatId: string, threadId: ThreadId, - query?: string, + update: Partial, ...[tabId = getCurrentTabId()]: TabArgs ): T { const chatThreadKey = buildChatThreadKey(chatId, threadId); + const currentSearch = selectTabState(global, tabId).middleSearch.byChatThreadKey[chatThreadKey]; - return replaceLocalTextSearch(global, chatThreadKey, { - ...selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey], - query, - }, tabId); -} - -export function updateLocalTextSearchTag( - global: T, - chatId: string, - threadId: ThreadId, - tag?: ApiReaction, - ...[tabId = getCurrentTabId()]: TabArgs -): T { - const chatThreadKey = buildChatThreadKey(chatId, threadId); - - const currentSearch = selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey]; - const query = currentSearch?.query || ''; - - return replaceLocalTextSearch(global, chatThreadKey, { + const updated = { + type: 'chat', ...currentSearch, - query, - savedTag: tag, - }, tabId); + ...update, + } satisfies MiddleSearchParams; + + if (!updated.isHashtag) { + updated.type = 'chat'; + } + + if (currentSearch && (currentSearch.type !== updated.type || currentSearch.savedTag !== updated.savedTag)) { + updated.results = undefined; + } + + return replaceMiddleSearch(global, chatThreadKey, updated, tabId); } -export function replaceLocalTextSearchResults( +export function resetMiddleSearch( global: T, chatId: string, threadId: ThreadId, - foundIds?: number[], - totalCount?: number, - nextOffsetId?: number, ...[tabId = getCurrentTabId()]: TabArgs ): T { - const chatThreadKey = buildChatThreadKey(chatId, threadId); - - return replaceLocalTextSearch(global, chatThreadKey, { - ...selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey], - results: { - foundIds, - totalCount, - nextOffsetId, - }, + return replaceMiddleSearch(global, buildChatThreadKey(chatId, threadId), { + type: 'chat', }, tabId); } -export function updateLocalTextSearchResults( +function replaceMiddleSearchResults( global: T, chatId: string, threadId: ThreadId, - newFoundIds: number[], - totalCount?: number, - nextOffsetId?: number, + results: MiddleSearchResults, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + return updateMiddleSearch(global, chatId, threadId, { + results, + fetchingQuery: undefined, + }, tabId); +} + +export function updateMiddleSearchResults( + global: T, + chatId: string, + threadId: ThreadId, + update: MiddleSearchResults, ...[tabId = getCurrentTabId()]: TabArgs ): T { const chatThreadKey = buildChatThreadKey(chatId, threadId); - const { results } = selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey] || {}; + const { results } = selectTabState(global, tabId).middleSearch.byChatThreadKey[chatThreadKey] || {}; + const prevQuery = (results?.query) || ''; + if (update.query !== prevQuery) { + return replaceMiddleSearchResults(global, chatId, threadId, update, tabId); + } + const prevFoundIds = (results?.foundIds) || []; - const foundIds = orderFoundIdsByDescending(unique(Array.prototype.concat(prevFoundIds, newFoundIds))); + const { + query, foundIds: newFoundIds, totalCount, nextOffsetId, nextOffsetPeerId, nextOffsetRate, + } = update; + const foundIds = unique(Array.prototype.concat(prevFoundIds, newFoundIds)); const foundOrPrevFoundIds = areSortedArraysEqual(prevFoundIds, foundIds) ? prevFoundIds : foundIds; - return replaceLocalTextSearchResults(global, chatId, threadId, foundOrPrevFoundIds, totalCount, nextOffsetId, tabId); + return replaceMiddleSearchResults( + global, chatId, threadId, { + query, + foundIds: foundOrPrevFoundIds, + totalCount, + nextOffsetId, + nextOffsetRate, + nextOffsetPeerId, + }, tabId, + ); +} + +export function closeMiddleSearch( + global: T, + chatId: string, + threadId: ThreadId, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + return replaceMiddleSearch(global, chatThreadKey, undefined, tabId); } function replaceSharedMediaSearch( diff --git a/src/global/selectors/chats.ts b/src/global/selectors/chats.ts index 32f73526f..ed0d107fa 100644 --- a/src/global/selectors/chats.ts +++ b/src/global/selectors/chats.ts @@ -41,6 +41,12 @@ export function selectPeerFullInfo(global: T, peerId: str return selectChatFullInfo(global, peerId); } +export function selectChatListLoadingParameters( + global: T, listType: ChatListType, +) { + return global.chats.loadingParameters[listType]; +} + export function selectChatUser(global: T, chat: ApiChat) { const userId = getPrivateChatUserId(chat); if (!userId) { diff --git a/src/global/selectors/index.ts b/src/global/selectors/index.ts index 31e8ec383..cffe277d3 100644 --- a/src/global/selectors/index.ts +++ b/src/global/selectors/index.ts @@ -3,7 +3,7 @@ export * from './users'; export * from './chats'; export * from './messages'; export * from './globalSearch'; -export * from './localSearch'; +export * from './middleSearch'; export * from './management'; export * from './symbols'; export * from './payments'; diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index 0e4092102..0f6253806 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -22,8 +22,8 @@ import { } from '../../config'; import { getCurrentTabId } from '../../util/establishMultitabRole'; import { findLast } from '../../util/iteratees'; +import { getMessageKey, isLocalMessageId } from '../../util/keys/messageKey'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; -import { getMessageKey, isLocalMessageId } from '../../util/messageKey'; import { getServerTime } from '../../util/serverTime'; import { IS_TRANSLATION_SUPPORTED } from '../../util/windowEnvironment'; import { diff --git a/src/global/selectors/localSearch.ts b/src/global/selectors/middleSearch.ts similarity index 83% rename from src/global/selectors/localSearch.ts rename to src/global/selectors/middleSearch.ts index 9000398ba..87fee85e3 100644 --- a/src/global/selectors/localSearch.ts +++ b/src/global/selectors/middleSearch.ts @@ -2,11 +2,11 @@ import type { ThreadId } from '../../types'; import type { GlobalState, TabArgs } from '../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; -import { buildChatThreadKey } from '../helpers/localSearch'; +import { buildChatThreadKey } from '../helpers/middleSearch'; import { selectCurrentMessageList } from './messages'; import { selectTabState } from './tabs'; -export function selectCurrentTextSearch( +export function selectCurrentMiddleSearch( global: T, ...[tabId = getCurrentTabId()]: TabArgs ) { @@ -16,12 +16,8 @@ export function selectCurrentTextSearch( } const chatThreadKey = buildChatThreadKey(chatId, threadId); - const currentSearch = selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey]; - if (!currentSearch || currentSearch.query === undefined) { - return undefined; - } - return currentSearch; + return selectTabState(global, tabId).middleSearch.byChatThreadKey[chatThreadKey]; } export function selectCurrentSharedMediaSearch( diff --git a/src/global/selectors/ui.ts b/src/global/selectors/ui.ts index e406e9c31..022acbfa9 100644 --- a/src/global/selectors/ui.ts +++ b/src/global/selectors/ui.ts @@ -5,7 +5,6 @@ import { NewChatMembersProgress, RightColumnContent } from '../../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getMessageVideo, getMessageWebPageVideo } from '../helpers/messageMedia'; -import { selectCurrentTextSearch } from './localSearch'; import { selectCurrentManagement } from './management'; import { selectIsStatisticsShown } from './statistics'; import { selectTabState } from './tabs'; @@ -38,8 +37,6 @@ export function selectRightColumnContentKey( RightColumnContent.CreateTopic ) : tabState.pollResults.messageId ? ( RightColumnContent.PollResults - ) : !isMobile && selectCurrentTextSearch(global, tabId) ? ( - RightColumnContent.Search ) : selectCurrentManagement(global, tabId) ? ( RightColumnContent.Management ) : tabState.isStatisticsShown && tabState.statistics.currentMessageId ? ( diff --git a/src/global/types.ts b/src/global/types.ts index ca38b204f..baaaa2471 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -112,6 +112,7 @@ import type { ManagementState, MediaViewerMedia, MediaViewerOrigin, + MiddleSearchParams, NewChatMembersProgress, NotifyException, PaymentStep, @@ -127,6 +128,7 @@ import type { ThemeKey, ThreadId, } from '../types'; +import type { SearchResultKey } from '../util/keys/searchResultKey'; import type { DownloadableMedia } from './helpers'; export type MessageListType = @@ -365,7 +367,8 @@ export type TabState = { globalSearch: { query?: string; - date?: number; + minDate?: number; + maxDate?: number; currentContent?: GlobalSearchContent; chatId?: string; foundTopicIds?: number[]; @@ -382,8 +385,10 @@ export type TabState = { }; resultsByType?: Partial>; }; @@ -397,16 +402,8 @@ export type TabState = { activeEmojiInteractions?: ActiveEmojiInteraction[]; activeReactions: Record; - localTextSearch: { - byChatThreadKey: Record; + middleSearch: { + byChatThreadKey: Record; }; sharedMediaSearch: { @@ -930,6 +927,11 @@ export type GlobalState = { all?: Record; saved?: Record; }; + loadingParameters: Record; forDiscussionIds?: string[]; // Obtained from GetFullChat / GetFullChannel fullInfoById: Record; @@ -1342,19 +1344,26 @@ export interface ActionPayloads { userIds: string[]; }; - // message search - openLocalTextSearch: WithTabId | undefined; - closeLocalTextSearch: WithTabId | undefined; - setLocalTextSearchQuery: { + // Message search + openMiddleSearch: WithTabId | undefined; + closeMiddleSearch: WithTabId | undefined; + updateMiddleSearch: { + chatId: string; + threadId?: ThreadId; + update: Partial>; + } & WithTabId; + resetMiddleSearch: WithTabId | undefined; + performMiddleSearch: { + chatId: string; + threadId?: ThreadId; query?: string; } & WithTabId; - setLocalTextSearchTag: { - tag: ApiReaction | undefined; + searchHashtag: { + hashtag: string; } & WithTabId; setSharedMediaSearchType: { mediaType: SharedMediaType; } & WithTabId; - searchTextMessagesLocal: WithTabId | undefined; searchSharedMediaMessages: WithTabId | undefined; searchChatMediaMessages: { currentMediaMessageId: number; diff --git a/src/hooks/events/useOutsideClick.ts b/src/hooks/events/useOutsideClick.ts new file mode 100644 index 000000000..d0cd908b2 --- /dev/null +++ b/src/hooks/events/useOutsideClick.ts @@ -0,0 +1,22 @@ +import { useEffect } from '../../lib/teact/teact'; + +import useLastCallback from '../useLastCallback'; + +export function useClickOutside( + refs: React.RefObject[], callback: (event: MouseEvent) => void, +) { + const handleClickOutside = useLastCallback((event: MouseEvent) => { + const clickedOutside = refs.every((ref) => { + return ref.current && !ref.current.contains(event.target as Node); + }); + + if (clickedOutside) callback(event); + }); + + useEffect(() => { + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + }; + }, [handleClickOutside]); +} diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index b2c667ae6..2221ba7f0 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -17,19 +17,20 @@ const useInfiniteScroll = ( listIds?: ListId[], isDisabled = false, listSlice = DEFAULT_LIST_SLICE, -): [ListId[]?, GetMore?] => { +): [ListId[]?, GetMore?, number?] => { const requestParamsRef = useRef<{ direction?: LoadMoreDirection; offsetId?: ListId; }>(); - const currentStateRef = useRef<{ viewportIds: ListId[]; isOnTop: boolean } | undefined>(); + const currentStateRef = useRef<{ viewportIds: ListId[]; isOnTop: boolean; offset: number } | undefined>(); if (!currentStateRef.current && listIds && !isDisabled) { const { newViewportIds, newIsOnTop, + fromOffset, } = getViewportSlice(listIds, LoadMoreDirection.Forwards, listSlice, listIds[0]); - currentStateRef.current = { viewportIds: newViewportIds, isOnTop: newIsOnTop }; + currentStateRef.current = { viewportIds: newViewportIds, isOnTop: newIsOnTop, offset: fromOffset }; } const forceUpdate = useForceUpdate(); @@ -45,12 +46,12 @@ const useInfiniteScroll = ( const currentMiddleId = viewportIds && !isOnTop ? viewportIds[Math.round(viewportIds.length / 2)] : undefined; const defaultOffsetId = currentMiddleId && listIds.includes(currentMiddleId) ? currentMiddleId : listIds[0]; const { offsetId = defaultOffsetId, direction = LoadMoreDirection.Forwards } = requestParamsRef.current || {}; - const { newViewportIds, newIsOnTop } = getViewportSlice(listIds, direction, listSlice, offsetId); + const { newViewportIds, newIsOnTop, fromOffset } = getViewportSlice(listIds, direction, listSlice, offsetId); requestParamsRef.current = {}; if (!viewportIds || !areSortedArraysEqual(viewportIds, newViewportIds)) { - currentStateRef.current = { viewportIds: newViewportIds, isOnTop: newIsOnTop }; + currentStateRef.current = { viewportIds: newViewportIds, isOnTop: newIsOnTop, offset: fromOffset }; } } else if (!listIds) { currentStateRef.current = undefined; @@ -75,11 +76,11 @@ const useInfiniteScroll = ( } const { - newViewportIds, areSomeLocal, areAllLocal, newIsOnTop, + newViewportIds, areSomeLocal, areAllLocal, newIsOnTop, fromOffset, } = getViewportSlice(listIds, direction, listSlice, offsetId); if (areSomeLocal && !(viewportIds && areSortedArraysEqual(viewportIds, newViewportIds))) { - currentStateRef.current = { viewportIds: newViewportIds, isOnTop: newIsOnTop }; + currentStateRef.current = { viewportIds: newViewportIds, isOnTop: newIsOnTop, offset: fromOffset }; forceUpdate(); } @@ -92,7 +93,7 @@ const useInfiniteScroll = ( } }); - return isDisabled ? [listIds] : [currentStateRef.current?.viewportIds, getMore]; + return isDisabled ? [listIds] : [currentStateRef.current?.viewportIds, getMore, currentStateRef.current?.offset]; }; function getViewportSlice( @@ -127,6 +128,7 @@ function getViewportSlice( areSomeLocal, areAllLocal, newIsOnTop: newViewportIds[0] === sourceIds[0], + fromOffset: from, }; } diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts index 1512705aa..100619c99 100644 --- a/src/hooks/useLongPress.ts +++ b/src/hooks/useLongPress.ts @@ -2,10 +2,14 @@ import { useCallback, useEffect, useRef } from '../lib/teact/teact'; const DEFAULT_THRESHOLD = 250; -function useLongPress( - onStart: NoneToVoidFunction, - onEnd: NoneToVoidFunction, -) { +function useLongPress({ + onClick, onStart, onEnd, threshold = DEFAULT_THRESHOLD, +}: { + onStart?: NoneToVoidFunction; + onClick?: (event: React.MouseEvent | React.TouchEvent) => void; + onEnd?: NoneToVoidFunction; + threshold?: number; +}) { const isLongPressActive = useRef(false); const isPressed = useRef(false); const timerId = useRef(undefined); @@ -18,20 +22,24 @@ function useLongPress( isPressed.current = true; timerId.current = window.setTimeout(() => { - onStart(); + onStart?.(); isLongPressActive.current = true; - }, DEFAULT_THRESHOLD); - }, [onStart]); + }, threshold); + }, [onStart, threshold]); + + const cancel = useCallback((e: React.MouseEvent | React.TouchEvent) => { + if (!isPressed.current) return; - const cancel = useCallback(() => { if (isLongPressActive.current) { - onEnd(); + onEnd?.(); + } else { + onClick?.(e); } isLongPressActive.current = false; isPressed.current = false; window.clearTimeout(timerId.current); - }, [onEnd]); + }, [onEnd, onClick]); useEffect(() => { return () => { diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index fe5adb48c..a2f2cbe5d 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1612,6 +1612,7 @@ channels.clickSponsoredMessage#18afbc93 channel:InputChannel random_id:bytes = B channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates; channels.getChannelRecommendations#25a71742 flags:# channel:flags.0?InputChannel = messages.Chats; channels.reportSponsoredMessage#af8ff6b9 channel:InputChannel random_id:bytes option:bytes = channels.SponsoredMessageReportResult; +channels.searchPosts#d19f987b hashtag:string offset_rate:int offset_peer:InputPeer offset_id:int limit:int = messages.Messages; bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool; bots.canSendMessage#1359f4e6 bot:InputUser = Bool; bots.allowSendMessage#f132e3ef bot:InputUser = Updates; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 54773448f..da987f947 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -224,6 +224,7 @@ "channels.getSponsoredMessages", "channels.getChannelRecommendations", "channels.reportSponsoredMessage", + "channels.searchPosts", "bots.canSendMessage", "bots.allowSendMessage", "bots.invokeWebViewCustomMethod", diff --git a/src/lib/teact/teact-dom.ts b/src/lib/teact/teact-dom.ts index 95b7c1881..2a1bd839d 100644 --- a/src/lib/teact/teact-dom.ts +++ b/src/lib/teact/teact-dom.ts @@ -915,7 +915,7 @@ function DEBUG_checkKeyUniqueness(children: VirtualElementChildren) { if (keys.length !== unique(keys).length) { // eslint-disable-next-line no-console - console.warn('[Teact] Duplicated keys:', keys.filter((e, i, a) => a.indexOf(e) !== i)); + console.warn('[Teact] Duplicated keys:', keys.filter((e, i, a) => a.indexOf(e) !== i), children); throw new Error('[Teact] Children keys are not unique'); } } diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index cab4e8323..1acc6ac10 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -256,7 +256,7 @@ $color-message-story-mention-to: #74bcff; --z-forum-panel: 13; --z-message-context-menu: 13; --z-scroll-down-button: 12; - --z-mobile-search: 12; + --z-local-search: 12; --z-left-header: 11; --z-middle-header: 11; --z-middle-footer: 11; diff --git a/src/types/index.ts b/src/types/index.ts index 52fd4d330..663af499c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,6 +17,7 @@ import type { ApiUser, ApiVideo, } from '../api/types'; +import type { SearchResultKey } from '../util/keys/searchResultKey'; import type { IconName } from './icons'; export type TextPart = TeactNode; @@ -299,7 +300,6 @@ export enum GlobalSearchContent { export enum RightColumnContent { ChatInfo, - Search, Management, Statistics, BoostStatistics, @@ -399,6 +399,24 @@ export type ProfileTabType = | 'similarChannels' | 'dialogs'; export type SharedMediaType = 'media' | 'documents' | 'links' | 'audio' | 'voice'; +export type MiddleSearchType = 'chat' | 'myChats' | 'channels'; +export type MiddleSearchParams = { + requestedQuery?: string; + savedTag?: ApiReaction; + isHashtag?: boolean; + fetchingQuery?: string; + type: MiddleSearchType; + results?: MiddleSearchResults; +}; +export type MiddleSearchResults = { + query: string; + totalCount?: number; + nextOffsetId?: number; + nextOffsetPeerId?: string; + nextOffsetRate?: number; + foundIds?: SearchResultKey[]; +}; + export type ApiPrivacyKey = 'phoneNumber' | 'addByPhone' | 'lastSeen' | 'profilePhoto' | 'voiceMessages' | 'forwards' | 'chatInvite' | 'phoneCall' | 'phoneP2P' | 'bio' | 'birthday'; export type PrivacyVisibility = 'everybody' | 'contacts' | 'closeFriends' | 'nonContacts' | 'nobody'; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 985841aed..5428b1a0e 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -1185,7 +1185,6 @@ export interface LangPair { 'AttachSticker': undefined; 'AttachMusic': undefined; 'AttachContact': undefined; - 'PaymentInvoice': undefined; 'MessageLocation': undefined; 'MessageLiveLocation': undefined; 'ServiceNotifications': undefined; @@ -1511,8 +1510,11 @@ export interface LangPair { 'MenuBetaChangelog': undefined; 'MenuSwitchToK': undefined; 'MenuInstallApp': undefined; - 'RemoveEffect' : undefined; + 'RemoveEffect': undefined; 'ReplyInPrivateMessage': undefined; + 'AriaSearchOlderResult': undefined; + 'AriaSearchNewerResult': undefined; + } export type LangKey = keyof LangPair; diff --git a/src/util/audioPlayer.ts b/src/util/audioPlayer.ts index 288decdb6..ef619bf7c 100644 --- a/src/util/audioPlayer.ts +++ b/src/util/audioPlayer.ts @@ -1,12 +1,12 @@ import { getActions, getGlobal } from '../global'; import type { ApiMessage } from '../api/types'; -import type { MessageKey } from './messageKey'; +import type { MessageKey } from './keys/messageKey'; import { AudioOrigin, GlobalSearchContent } from '../types'; import { requestNextMutation } from '../lib/fasterdom/fasterdom'; import { selectCurrentMessageList, selectTabState } from '../global/selectors'; -import { getMessageServerKey, parseMessageKey } from './messageKey'; +import { getMessageServerKey, parseMessageKey } from './keys/messageKey'; import { isSafariPatchInProgress, patchSafariProgressiveAudio } from './patchSafariProgressiveAudio'; import safePlay from './safePlay'; import { IS_SAFARI } from './windowEnvironment'; diff --git a/src/util/messageKey.ts b/src/util/keys/messageKey.ts similarity index 94% rename from src/util/messageKey.ts rename to src/util/keys/messageKey.ts index 14a8e2103..4388d51fc 100644 --- a/src/util/messageKey.ts +++ b/src/util/keys/messageKey.ts @@ -1,4 +1,4 @@ -import type { ApiMessage } from '../api/types'; +import type { ApiMessage } from '../../api/types'; export type MessageKey = `msg${string}-${number}`; diff --git a/src/util/keys/searchResultKey.ts b/src/util/keys/searchResultKey.ts new file mode 100644 index 000000000..48f38cc4f --- /dev/null +++ b/src/util/keys/searchResultKey.ts @@ -0,0 +1,15 @@ +import type { ApiMessage } from '../../api/types'; + +export type SearchResultKey = `${string}_${number}`; + +export function getSearchResultKey(message: ApiMessage): SearchResultKey { + const { chatId, id } = message; + + return `${chatId}_${id}`; +} + +export function parseSearchResultKey(key: SearchResultKey) { + const [chatId, messageId] = key.split('_'); + + return [chatId, Number(messageId)] as const; +}