diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 5b3b2884e..dd3954682 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -16,7 +16,9 @@ import { selectChatMessage, selectChatMessages, selectChatScheduledMessages, - selectCurrentMediaSearch, selectIsChatWithSelf, + selectCurrentChatMediaSearch, + selectCurrentSharedMediaSearch, + selectIsChatWithSelf, selectListedIds, selectOutlyingListByMessageId, selectPerformanceSettingsValue, @@ -71,6 +73,9 @@ type StateProps = { isHidden?: boolean; withAnimation?: boolean; shouldSkipHistoryAnimations?: boolean; + withDynamicLoading?: boolean; + isLoadingMoreMedia?: boolean; + isSynced?: boolean; }; const ANIMATION_DURATION = 250; @@ -91,6 +96,9 @@ const MediaViewer: FC = ({ withAnimation, isHidden, shouldSkipHistoryAnimations, + withDynamicLoading, + isLoadingMoreMedia, + isSynced, }) => { const { openMediaViewer, @@ -98,6 +106,7 @@ const MediaViewer: FC = ({ openForwardMenu, focusMessage, toggleChatInfo, + searchChatMediaMessages, } = getActions(); const isOpen = Boolean(avatarOwner || mediaId); @@ -133,17 +142,14 @@ const MediaViewer: FC = ({ const isVisible = !isHidden && isOpen; /* Navigation */ - const singleMediaId = webPagePhoto || webPageVideo || actionPhoto ? mediaId : undefined; + const singleMediaId = webPagePhoto || webPageVideo || actionPhoto || isGif ? mediaId : undefined; const mediaIds = useMemo(() => { - if (singleMediaId) { - return [singleMediaId]; - } else if (avatarOwner) { - return avatarOwner.photos?.map((p, i) => i) || []; - } else { - return getChatMediaMessageIds(chatMessages || {}, collectionIds || [], isFromSharedMedia); - } - }, [singleMediaId, avatarOwner, chatMessages, collectionIds, isFromSharedMedia]); + if (singleMediaId) return [singleMediaId]; + if (avatarOwner) return avatarOwner.photos?.map((p, i) => i) || []; + if (withDynamicLoading) return collectionIds || []; + return getChatMediaMessageIds(chatMessages || {}, collectionIds || [], isFromSharedMedia); + }, [singleMediaId, avatarOwner, chatMessages, collectionIds, isFromSharedMedia, withDynamicLoading]); const selectedMediaIndex = mediaId ? mediaIds.indexOf(mediaId) : -1; @@ -252,6 +258,7 @@ const MediaViewer: FC = ({ mediaId: id, avatarOwnerId: avatarOwner?.id, origin: origin!, + withDynamicLoading, }, { forceOnHeavyAnimation: true, }); @@ -269,6 +276,11 @@ const MediaViewer: FC = ({ const mediaIdsRef = useStateRef(mediaIds); + const loadMoreMediaIfNeeded = useLastCallback((activeMediaId?: number) => { + if (!activeMediaId || !withDynamicLoading || isLoadingMoreMedia) return; + searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: activeMediaId }); + }); + const getMediaId = useLastCallback((fromId?: number, direction?: number): number | undefined => { if (fromId === undefined) return undefined; const mIds = mediaIdsRef.current; @@ -276,6 +288,8 @@ const MediaViewer: FC = ({ if ((direction === -1 && index > 0) || (direction === 1 && index < mIds.length - 1)) { return mIds[index + direction]; } + // Fallback + if (isVisible) loadMoreMediaIfNeeded(fromId); return undefined; }); @@ -359,6 +373,9 @@ const MediaViewer: FC = ({ void; + isLoadingMoreMedia?: boolean; + isSynced?: boolean; getMediaId: (fromId?: number, direction?: number) => number | undefined; isVideo?: boolean; isGif?: boolean; @@ -84,6 +87,7 @@ enum SwipeDirection { const MediaViewerSlides: FC = ({ mediaId, + loadMoreMediaIfNeeded, getMediaId, selectMedia, isVideo, @@ -91,6 +95,8 @@ const MediaViewerSlides: FC = ({ isOpen, withAnimation, isHidden, + isLoadingMoreMedia, + isSynced, ...rest }) => { // eslint-disable-next-line no-null/no-null @@ -156,6 +162,11 @@ const MediaViewerSlides: FC = ({ } }, [mediaId, setActiveMediaId, transformRef]); + useEffect(() => { + if (!isSynced || isLoadingMoreMedia) return; + loadMoreMediaIfNeeded(activeMediaId); + }, [activeMediaId, loadMoreMediaIfNeeded, isSynced, isLoadingMoreMedia]); + useLayoutEffect(() => { const { x, y, scale } = getTransform(); lockControls(scale !== 1); @@ -643,6 +654,7 @@ const MediaViewerSlides: FC = ({ [ onClose, setTransform, + loadMoreMediaIfNeeded, getMediaId, windowWidth, windowHeight, diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 6b5c46a6e..f63eb254c 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -581,6 +581,8 @@ const Message: FC = ({ handleMediaClick, handleAudioPlay, handleAlbumMediaClick, + handlePhotoMediaClick, + handleVideoMediaClick, handleMetaClick, handleTranslationClick, handleOpenThread, @@ -1105,7 +1107,7 @@ const Message: FC = ({ asForwarded={asForwarded} theme={theme} forcedWidth={contentWidth} - onClick={handleMediaClick} + onClick={handlePhotoMediaClick} onCancelUpload={handleCancelUpload} /> )} @@ -1131,7 +1133,7 @@ const Message: FC = ({ isDownloading={isDownloading} isProtected={isProtected} asForwarded={asForwarded} - onClick={handleMediaClick} + onClick={handleVideoMediaClick} onCancelUpload={handleCancelUpload} /> )} diff --git a/src/components/middle/message/Video.tsx b/src/components/middle/message/Video.tsx index 017ed0c9c..8d9996aff 100644 --- a/src/components/middle/message/Video.tsx +++ b/src/components/middle/message/Video.tsx @@ -51,7 +51,7 @@ export type OwnProps = { asForwarded?: boolean; isDownloading?: boolean; isProtected?: boolean; - onClick?: (id: number) => void; + onClick?: (id: number, isGif?: boolean) => void; onCancelUpload?: (message: ApiMessage) => void; }; @@ -191,7 +191,7 @@ const Video: FC = ({ return; } - onClick?.(message.id); + onClick?.(message.id, video?.isGif); }); const className = buildClassName( diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts index c3448992e..1bb8746b5 100644 --- a/src/components/middle/message/hooks/useInnerHandlers.ts +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -35,7 +35,7 @@ export default function useInnerHandlers( const { openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer, markMessagesRead, cancelUploadMedia, sendPollVote, openForwardMenu, - openChatLanguageModal, openThread, openStoryViewer, + openChatLanguageModal, openThread, openStoryViewer, searchChatMediaMessages, } = getActions(); const { @@ -102,17 +102,39 @@ export default function useInnerHandlers( origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, }); }); + const openMediaViewerWithPhotoOrVideo = useLastCallback((withDynamicLoading: boolean): void => { + if (withDynamicLoading) { + searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: messageId }); + } + openMediaViewer({ + chatId, + threadId, + mediaId: messageId, + origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, + withDynamicLoading, + }); + }); + const handlePhotoMediaClick = useLastCallback((): void => { + const withDynamicLoading = !isScheduled; + openMediaViewerWithPhotoOrVideo(withDynamicLoading); + }); + const handleVideoMediaClick = useLastCallback((id: number, isGif?: boolean): void => { + const withDynamicLoading = !isGif && !isScheduled; + openMediaViewerWithPhotoOrVideo(withDynamicLoading); + }); const handleAudioPlay = useLastCallback((): void => { openAudioPlayer({ chatId, messageId }); }); const handleAlbumMediaClick = useLastCallback((albumMessageId: number): void => { + searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: messageId }); openMediaViewer({ chatId, threadId, mediaId: albumMessageId, origin: isScheduled ? MediaViewerOrigin.ScheduledAlbum : MediaViewerOrigin.Album, + withDynamicLoading: true, }); }); @@ -213,6 +235,8 @@ export default function useInnerHandlers( handleMediaClick, handleAudioPlay, handleAlbumMediaClick, + handlePhotoMediaClick, + handleVideoMediaClick, handleMetaClick: selectWithGroupedId, handleTranslationClick, handleOpenThread, diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index b56f9a53a..50ae11a40 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -33,7 +33,7 @@ import { selectChat, selectChatFullInfo, selectChatMessages, - selectCurrentMediaSearch, + selectCurrentSharedMediaSearch, selectIsCurrentUserPremium, selectIsRightColumnShown, selectPeerFullInfo, @@ -186,11 +186,11 @@ const Profile: FC = ({ forceScrollProfileTab, }) => { const { - setLocalMediaSearchType, + setSharedMediaSearchType, loadMoreMembers, loadCommonChats, openChat, - searchMediaMessagesLocal, + searchSharedMediaMessages, openMediaViewer, openAudioPlayer, focusMessage, @@ -275,7 +275,7 @@ const Profile: FC = ({ const [resultType, viewportIds, getMore, noProfileInfo] = useProfileViewportIds( loadMoreMembers, loadCommonChats, - searchMediaMessagesLocal, + searchSharedMediaMessages, handleLoadPeerStories, handleLoadStoriesArchive, tabType, @@ -329,8 +329,8 @@ const Profile: FC = ({ // Update search type when switching tabs or forum topics useEffect(() => { - setLocalMediaSearchType({ mediaType: tabType as SharedMediaType }); - }, [setLocalMediaSearchType, tabType, threadId]); + setSharedMediaSearchType({ mediaType: tabType as SharedMediaType }); + }, [setSharedMediaSearchType, tabType, threadId]); useEffect(() => { loadProfilePhotos({ profileId }); @@ -683,7 +683,7 @@ export default memo(withGlobal( const chat = selectChat(global, chatId); const chatFullInfo = selectChatFullInfo(global, chatId); const messagesById = selectChatMessages(global, chatId); - const { currentType: mediaSearchType, resultsByType } = selectCurrentMediaSearch(global) || {}; + const { currentType: mediaSearchType, resultsByType } = selectCurrentSharedMediaSearch(global) || {}; const { foundIds } = (resultsByType && mediaSearchType && resultsByType[mediaSearchType]) || {}; const isTopicInfo = Boolean(chat?.isForum && threadId && threadId !== MAIN_THREAD_ID); diff --git a/src/config.ts b/src/config.ts index 49b2be81d..51f1b872c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -72,6 +72,7 @@ export const TOPIC_HEIGHT_PX = 65; export const CHAT_LIST_SLICE = isBigScreen ? 30 : 25; export const CHAT_LIST_LOAD_SLICE = 100; export const SHARED_MEDIA_SLICE = 42; +export const CHAT_MEDIA_SLICE = 42; export const MESSAGE_SEARCH_SLICE = 42; export const GLOBAL_SEARCH_SLICE = 20; export const GLOBAL_TOPIC_SEARCH_SLICE = 5; diff --git a/src/global/actions/api/localSearch.ts b/src/global/actions/api/localSearch.ts index 2660e3b71..b83030107 100644 --- a/src/global/actions/api/localSearch.ts +++ b/src/global/actions/api/localSearch.ts @@ -1,12 +1,17 @@ import type { ApiChat } from '../../../api/types'; -import type { SharedMediaType, ThreadId } from '../../../types'; +import type { + ChatMediaSearchParams, ChatMediaSearchSegment, LoadingState, SharedMediaType, ThreadId, +} from '../../../types'; import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; +import { LoadMoreDirection } from '../../../types'; -import { MESSAGE_SEARCH_SLICE, SHARED_MEDIA_SLICE } from '../../../config'; +import { + CHAT_MEDIA_SLICE, MESSAGE_SEARCH_SLICE, SHARED_MEDIA_SLICE, +} from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; -import { buildCollectionByKey } from '../../../util/iteratees'; +import { buildCollectionByKey, isInsideSortedArrayRange } from '../../../util/iteratees'; import { callApi } from '../../../api/gramjs'; -import { getIsSavedDialog, isSameReaction } from '../../helpers'; +import { getChatMediaMessageIds, getIsSavedDialog, isSameReaction } from '../../helpers'; import { addActionHandler, getGlobal, setGlobal, } from '../../index'; @@ -14,16 +19,23 @@ import { addChatMessagesById, addChats, addUsers, - updateLocalMediaSearchResults, + initializeChatMediaSearchResults, + mergeWithChatMediaSearchSegment, + setChatMediaSearchLoading, + updateChatMediaSearchResults, updateLocalTextSearchResults, + updateSharedMediaSearchResults, } from '../../reducers'; import { selectChat, - selectCurrentMediaSearch, + selectCurrentChatMediaSearch, selectCurrentMessageList, + 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) || {}; @@ -86,7 +98,7 @@ addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Pr setGlobal(global); }); -addActionHandler('searchMediaMessagesLocal', (global, actions, payload): ActionReturnType => { +addActionHandler('searchSharedMediaMessages', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; if (!chatId || !threadId) { @@ -97,7 +109,7 @@ addActionHandler('searchMediaMessagesLocal', (global, actions, payload): ActionR const realChatId = isSavedDialog ? String(threadId) : chatId; const chat = selectChat(global, realChatId); - const currentSearch = selectCurrentMediaSearch(global, tabId); + const currentSearch = selectCurrentSharedMediaSearch(global, tabId); if (!chat || !currentSearch) { return; @@ -113,6 +125,42 @@ addActionHandler('searchMediaMessagesLocal', (global, actions, payload): ActionR void searchSharedMedia(global, chat, threadId, type, offsetId, undefined, isSavedDialog, tabId); }); +addActionHandler('searchChatMediaMessages', (global, actions, payload): ActionReturnType => { + const { + chatId, threadId, currentMediaMessageId, limit, direction, tabId = getCurrentTabId(), + } = payload; + if (!chatId || !threadId || !currentMediaMessageId) { + return; + } + + const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + const realChatId = isSavedDialog ? String(threadId) : chatId; + + const chat = selectChat(global, realChatId); + if (!chat) { + return; + } + let currentSearch = selectCurrentChatMediaSearch(global, tabId); + + if (!currentSearch) { + global = initializeChatMediaSearchResults(global, chatId, threadId, tabId); + setGlobal(global); + currentSearch = selectCurrentChatMediaSearch(global, tabId); + if (!currentSearch) { + return; + } + } + + void searchChatMedia(global, + chat, + threadId, + currentMediaMessageId, + currentSearch, + direction, + isSavedDialog, + limit, + tabId); +}); addActionHandler('searchMessagesByDate', async (global, actions, payload): Promise => { const { timestamp, tabId = getCurrentTabId() } = payload; @@ -177,7 +225,7 @@ async function searchSharedMedia( global = getGlobal(); - const currentSearch = selectCurrentMediaSearch(global, tabId); + const currentSearch = selectCurrentSharedMediaSearch(global, tabId); if (!currentSearch) { return; } @@ -185,7 +233,7 @@ async function searchSharedMedia( global = addChats(global, buildCollectionByKey(chats, 'id')); global = addUsers(global, buildCollectionByKey(users, 'id')); global = addChatMessagesById(global, resultChatId, byId); - global = updateLocalMediaSearchResults( + global = updateSharedMediaSearchResults( global, resultChatId, threadId, type, newFoundIds, totalCount, nextOffsetId, tabId, ); setGlobal(global); @@ -194,3 +242,172 @@ async function searchSharedMedia( void searchSharedMedia(global, chat, threadId, type, nextOffsetId, true, isSavedDialog, tabId); } } + +function selectCurrentChatMediaSearchSegment( + params: ChatMediaSearchParams, + currentMediaMessageId: number, +): ChatMediaSearchSegment | undefined { + if (isInsideSortedArrayRange(currentMediaMessageId, params.currentSegment.foundIds)) { + return params.currentSegment; + } + const index = params.segments.findIndex( + (segment) => isInsideSortedArrayRange(currentMediaMessageId, segment.foundIds), + ); + + if (index === -1) { + if (params.currentSegment && params.currentSegment.foundIds.length) { + params.segments.push(params.currentSegment); + } + return undefined; + } + const result = params.segments.splice(index, 1)[0]; + params.segments.push(params.currentSegment); + return result; +} + +function calcChatMediaSearchAddOffset( + direction: LoadMoreDirection, + limit: number, +): number { + if (direction === LoadMoreDirection.Backwards) return 0; + if (direction === LoadMoreDirection.Forwards) return -(limit + 1); + return -(Math.round(limit / 2) + 1); +} + +function calcChatMediaSearchOffsetId( + direction: LoadMoreDirection, + currentMessageId: number, + segment?: ChatMediaSearchSegment, +) : number { + if (!segment) return currentMessageId; + if (direction === LoadMoreDirection.Backwards) return segment.foundIds[0]; + if (direction === LoadMoreDirection.Forwards) return segment.foundIds[segment.foundIds.length - 1]; + return currentMessageId; +} + +function calcLoadMoreDirection(currentMessageId: number, currentSegment?: ChatMediaSearchSegment) { + if (!currentSegment) return LoadMoreDirection.Around; + const currentSegmentFoundIdsCount = currentSegment.foundIds.length; + + const idIndexInSegment = currentSegment.foundIds.indexOf(currentMessageId); + if (idIndexInSegment === -1) return LoadMoreDirection.Around; + + if (currentSegment.loadingState.areAllItemsLoadedBackwards + && currentSegment.loadingState.areAllItemsLoadedForwards) { + return undefined; + } + + const halfMediaCount = Math.floor(currentSegmentFoundIdsCount / 2); + + const preloadOffset = MEDIA_PRELOAD_OFFSET > halfMediaCount ? 0 : MEDIA_PRELOAD_OFFSET; + const lastMediaIndex = currentSegmentFoundIdsCount - 1; + + if (idIndexInSegment <= preloadOffset) { + if (currentSegment.loadingState.areAllItemsLoadedBackwards) return undefined; + return LoadMoreDirection.Backwards; + } + if (idIndexInSegment >= lastMediaIndex - preloadOffset) { + if (currentSegment.loadingState.areAllItemsLoadedForwards) return undefined; + return LoadMoreDirection.Forwards; + } + return undefined; +} + +function calcLoadingState( + direction : LoadMoreDirection, + limit : number, newFoundIdsCount : number, + currentSegment?: ChatMediaSearchSegment, +) : LoadingState { + let areAllItemsLoadedForwards = Boolean(currentSegment?.loadingState.areAllItemsLoadedForwards); + let areAllItemsLoadedBackwards = Boolean(currentSegment?.loadingState.areAllItemsLoadedBackwards); + + if (newFoundIdsCount < limit) { + if (direction === LoadMoreDirection.Forwards) { + areAllItemsLoadedForwards = true; + } else if (direction === LoadMoreDirection.Backwards) { + areAllItemsLoadedBackwards = true; + } + } + return { + areAllItemsLoadedForwards, + areAllItemsLoadedBackwards, + }; +} + +async function searchChatMedia( + global: T, + chat: ApiChat, + threadId: ThreadId, + currentMediaMessageId: number, + chatMediaSearchParams: ChatMediaSearchParams, + direction?: LoadMoreDirection, + isSavedDialog?: boolean, + limit = CHAT_MEDIA_SLICE, + ...[tabId = getCurrentTabId()]: TabArgs +) { + const { isSynced } = global; + if (!isSynced || chatMediaSearchParams.isLoading) { + return; + } + let currentSegment = selectCurrentChatMediaSearchSegment(chatMediaSearchParams, currentMediaMessageId); + + if (direction === undefined) { + direction = calcLoadMoreDirection(currentMediaMessageId, currentSegment); + } + + if (direction === undefined) { + return; + } + + const offsetId = calcChatMediaSearchOffsetId(direction, currentMediaMessageId, currentSegment); + const addOffset = calcChatMediaSearchAddOffset(direction, limit); + + const resultChatId = isSavedDialog ? global.currentUserId! : chat.id; + + global = setChatMediaSearchLoading(global, resultChatId, threadId, true, tabId); + setGlobal(global); + + const result = await callApi('searchMessagesLocal', { + chat, + type: 'media', + limit, + threadId, + offsetId, + isSavedDialog, + addOffset, + }); + + global = getGlobal(); + + if (!result) { + global = setChatMediaSearchLoading(global, resultChatId, threadId, false, tabId); + setGlobal(global); + return; + } + + const { + chats, users, messages, + } = result; + + const byId = buildCollectionByKey(messages, 'id'); + const newFoundIds = Object.keys(byId).map(Number); + + global = addChats(global, buildCollectionByKey(chats, 'id')); + global = addUsers(global, buildCollectionByKey(users, 'id')); + global = addChatMessagesById(global, resultChatId, byId); + + const loadingState = calcLoadingState(direction, limit, newFoundIds.length, currentSegment); + + const filteredIds = getChatMediaMessageIds(byId, newFoundIds, false); + currentSegment = mergeWithChatMediaSearchSegment( + filteredIds, + loadingState, + currentSegment, + ); + + global = updateChatMediaSearchResults( + global, resultChatId, threadId, currentSegment, chatMediaSearchParams, tabId, + ); + global = setChatMediaSearchLoading(global, resultChatId, threadId, false, tabId); + setGlobal(global); +} diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 9b57dd208..1731c5ba0 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -36,6 +36,7 @@ import { replaceThreadParam, updateChat, updateChatLastMessageId, + updateChatMediaLoadingState, updateChatMessage, updateListedIds, updateMessageTranslations, @@ -109,6 +110,9 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { global = updateChatLastMessage(global, chatId, newMessage); } + const threadId = selectThreadIdFromMessage(global, newMessage); + global = updateChatMediaLoadingState(global, newMessage, chatId, threadId, tabId); + if (selectIsMessageInCurrentMessageList(global, chatId, message as ApiMessage, tabId)) { if (isLocal && message.isOutgoing && !(message.content?.action) && !storyReplyInfo?.storyId && !message.content?.storyData) { diff --git a/src/global/actions/ui/localSearch.ts b/src/global/actions/ui/localSearch.ts index 92dc1fd16..7aa415796 100644 --- a/src/global/actions/ui/localSearch.ts +++ b/src/global/actions/ui/localSearch.ts @@ -6,9 +6,9 @@ import { buildChatThreadKey, isSameReaction } from '../../helpers'; import { addActionHandler } from '../../index'; import { replaceLocalTextSearchResults, - updateLocalMediaSearchType, updateLocalTextSearch, updateLocalTextSearchTag, + updateSharedMediaSearchType, } from '../../reducers'; import { selectCurrentMessageList, selectTabState } from '../../selectors'; @@ -67,14 +67,14 @@ addActionHandler('setLocalTextSearchTag', (global, actions, payload): ActionRetu return global; }); -addActionHandler('setLocalMediaSearchType', (global, actions, payload): ActionReturnType => { +addActionHandler('setSharedMediaSearchType', (global, actions, payload): ActionReturnType => { const { mediaType, tabId = getCurrentTabId() } = payload; const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; if (!chatId || !threadId) { return undefined; } - return updateLocalMediaSearchType(global, chatId, threadId, mediaType, tabId); + return updateSharedMediaSearchType(global, chatId, threadId, mediaType, tabId); }); export function closeLocalTextSearch( diff --git a/src/global/actions/ui/mediaViewer.ts b/src/global/actions/ui/mediaViewer.ts index a1eb35b68..dfb288b1d 100644 --- a/src/global/actions/ui/mediaViewer.ts +++ b/src/global/actions/ui/mediaViewer.ts @@ -9,7 +9,7 @@ import { selectTabState } from '../../selectors'; addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType => { const { chatId, threadId, mediaId, avatarOwnerId, profilePhotoIndex, origin, volume, playbackRate, isMuted, - tabId = getCurrentTabId(), + withDynamicLoading, tabId = getCurrentTabId(), } = payload; const tabState = selectTabState(global, tabId); @@ -32,6 +32,7 @@ addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType || DEFAULT_PLAYBACK_RATE ), isMuted: isMuted || tabState.mediaViewer.isMuted, + withDynamicLoading, }, forwardMessages: {}, }, tabId); diff --git a/src/global/helpers/messageMedia.ts b/src/global/helpers/messageMedia.ts index 5719d6120..7a78e31aa 100644 --- a/src/global/helpers/messageMedia.ts +++ b/src/global/helpers/messageMedia.ts @@ -545,3 +545,10 @@ export function canReplaceMessageMedia(message: ApiMessage, attachment: ApiAttac || (isFile && (fileType === 'audio' || fileType === 'file')) ); } + +export function isMediaLoadableInViewer(newMessage: ApiMessage) { + if (!newMessage.content) return false; + if (newMessage.content.photo) return true; + if (newMessage.content.video && !newMessage.content.video.isRound && !newMessage.content.video.isGif) return true; + return false; +} diff --git a/src/global/init.ts b/src/global/init.ts index 2d3341f92..5a3782ee3 100644 --- a/src/global/init.ts +++ b/src/global/init.ts @@ -12,6 +12,7 @@ import { parseLocationHash } from '../util/routing'; import { clearStoredSession } from '../util/sessions'; import { updatePeerColors } from '../util/theme'; import { IS_MULTITAB_SUPPORTED } from '../util/windowEnvironment'; +import { initializeChatMediaSearchResults } from './reducers/localSearch'; import { updateTabState } from './reducers/tabs'; import { initCache, loadCache } from './cache'; import { @@ -79,6 +80,7 @@ addActionHandler('init', (global, actions, payload): ActionReturnType => { global = replaceThreadParam(global, chatId, threadId, 'lastViewportIds', undefined); return; } + global = initializeChatMediaSearchResults(global, chatId, threadId, tabId); global = replaceTabThreadParam( global, chatId, diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 41c98635e..91de6b242 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -313,7 +313,11 @@ export const INITIAL_TAB_STATE: TabState = { byChatThreadKey: {}, }, - localMediaSearch: { + sharedMediaSearch: { + byChatThreadKey: {}, + }, + + chatMediaSearch: { byChatThreadKey: {}, }, diff --git a/src/global/reducers/localSearch.ts b/src/global/reducers/localSearch.ts index 3d37e7783..5d4b4998f 100644 --- a/src/global/reducers/localSearch.ts +++ b/src/global/reducers/localSearch.ts @@ -1,11 +1,15 @@ -import type { ApiMessageSearchType, ApiReaction } from '../../api/types'; -import type { SharedMediaType, ThreadId } from '../../types'; +import type { ApiMessage, ApiMessageSearchType, ApiReaction } from '../../api/types'; +import type { + ChatMediaSearchParams, ChatMediaSearchSegment, LoadingState, + SharedMediaType, ThreadId, +} from '../../types'; import type { GlobalState, TabArgs } from '../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; -import { areSortedArraysEqual, unique } from '../../util/iteratees'; -import { buildChatThreadKey } from '../helpers'; +import { areSortedArraysEqual, areSortedArraysIntersecting, unique } from '../../util/iteratees'; +import { buildChatThreadKey, isMediaLoadableInViewer } from '../helpers'; import { selectTabState } from '../selectors'; +import { selectChatMediaSearch } from '../selectors/localSearch'; import { updateTabState } from './tabs'; interface TextSearchParams { @@ -18,7 +22,7 @@ interface TextSearchParams { }; } -interface MediaSearchParams { +interface SharedMediaSearchParams { currentType?: SharedMediaType; resultsByType?: Partial( const chatThreadKey = buildChatThreadKey(chatId, threadId); const { results } = selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey] || {}; const prevFoundIds = (results?.foundIds) || []; - const foundIds = orderFoundIds(unique(Array.prototype.concat(prevFoundIds, newFoundIds))); + const foundIds = orderFoundIdsByDescending(unique(Array.prototype.concat(prevFoundIds, newFoundIds))); const foundOrPrevFoundIds = areSortedArraysEqual(prevFoundIds, foundIds) ? prevFoundIds : foundIds; return replaceLocalTextSearchResults(global, chatId, threadId, foundOrPrevFoundIds, totalCount, nextOffsetId, tabId); } -function replaceLocalMediaSearch( +function replaceSharedMediaSearch( global: T, chatId: string, threadId: ThreadId, - searchParams: MediaSearchParams, + searchParams: SharedMediaSearchParams, ...[tabId = getCurrentTabId()]: TabArgs ): T { const chatThreadKey = buildChatThreadKey(chatId, threadId); return updateTabState(global, { - localMediaSearch: { + sharedMediaSearch: { byChatThreadKey: { - ...selectTabState(global, tabId).localMediaSearch.byChatThreadKey, + ...selectTabState(global, tabId).sharedMediaSearch.byChatThreadKey, [chatThreadKey]: searchParams, }, }, }, tabId); } -export function updateLocalMediaSearchType( +export function updateSharedMediaSearchType( global: T, chatId: string, threadId: ThreadId, @@ -144,13 +148,13 @@ export function updateLocalMediaSearchType( ): T { const chatThreadKey = buildChatThreadKey(chatId, threadId); - return replaceLocalMediaSearch(global, chatId, threadId, { - ...selectTabState(global, tabId).localMediaSearch.byChatThreadKey[chatThreadKey], + return replaceSharedMediaSearch(global, chatId, threadId, { + ...selectTabState(global, tabId).sharedMediaSearch.byChatThreadKey[chatThreadKey], currentType, }, tabId); } -export function replaceLocalMediaSearchResults( +export function replaceSharedMediaSearchResults( global: T, chatId: string, threadId: ThreadId, @@ -162,10 +166,10 @@ export function replaceLocalMediaSearchResults( ): T { const chatThreadKey = buildChatThreadKey(chatId, threadId); - return replaceLocalMediaSearch(global, chatId, threadId, { - ...selectTabState(global, tabId).localMediaSearch.byChatThreadKey[chatThreadKey], + return replaceSharedMediaSearch(global, chatId, threadId, { + ...selectTabState(global, tabId).sharedMediaSearch.byChatThreadKey[chatThreadKey], resultsByType: { - ...(selectTabState(global, tabId).localMediaSearch.byChatThreadKey[chatThreadKey] || {}).resultsByType, + ...(selectTabState(global, tabId).sharedMediaSearch.byChatThreadKey[chatThreadKey] || {}).resultsByType, [type]: { foundIds, totalCount, @@ -175,7 +179,7 @@ export function replaceLocalMediaSearchResults( }, tabId); } -export function updateLocalMediaSearchResults( +export function updateSharedMediaSearchResults( global: T, chatId: string, threadId: ThreadId, @@ -187,12 +191,12 @@ export function updateLocalMediaSearchResults( ): T { const chatThreadKey = buildChatThreadKey(chatId, threadId); - const { resultsByType } = selectTabState(global, tabId).localMediaSearch.byChatThreadKey[chatThreadKey] || {}; + const { resultsByType } = selectTabState(global, tabId).sharedMediaSearch.byChatThreadKey[chatThreadKey] || {}; const prevFoundIds = resultsByType?.[type] ? resultsByType[type]!.foundIds : []; - const foundIds = orderFoundIds(unique(Array.prototype.concat(prevFoundIds, newFoundIds))); + const foundIds = orderFoundIdsByDescending(unique(Array.prototype.concat(prevFoundIds, newFoundIds))); const foundOrPrevFoundIds = areSortedArraysEqual(prevFoundIds, foundIds) ? prevFoundIds : foundIds; - return replaceLocalMediaSearchResults( + return replaceSharedMediaSearchResults( global, chatId, threadId, @@ -204,6 +208,229 @@ export function updateLocalMediaSearchResults( ); } -function orderFoundIds(listedIds: number[]) { +function orderFoundIdsByDescending(listedIds: number[]) { return listedIds.sort((a, b) => b - a); } + +function orderFoundIdsByAscending(array: number[]) { + return array.sort((a, b) => a - b); +} + +export function mergeWithChatMediaSearchSegment( + foundIds: number[], + loadingState: LoadingState, + segment?: ChatMediaSearchSegment, +) + : ChatMediaSearchSegment { + if (!segment) { + return { + foundIds, + loadingState, + }; + } + const mergedFoundIds = orderFoundIdsByAscending(unique(Array.prototype.concat(segment.foundIds, foundIds))); + if (!areSortedArraysEqual(segment.foundIds, foundIds)) { + segment.foundIds = mergedFoundIds; + } + const mergedLoadingState : LoadingState = { + areAllItemsLoadedForwards: loadingState.areAllItemsLoadedForwards + || segment.loadingState.areAllItemsLoadedForwards, + areAllItemsLoadedBackwards: loadingState.areAllItemsLoadedBackwards + || segment.loadingState.areAllItemsLoadedBackwards, + }; + segment.loadingState = mergedLoadingState; + return segment; +} + +function mergeChatMediaSearchSegments(currentSegment: ChatMediaSearchSegment, segments: ChatMediaSearchSegment[]) { + return segments.reduce((acc, segment) => { + const hasIntersection = areSortedArraysIntersecting(segment.foundIds, currentSegment.foundIds); + if (hasIntersection) { + currentSegment = mergeWithChatMediaSearchSegment( + currentSegment.foundIds, + currentSegment.loadingState, + segment, + ); + } else { + acc.push(segment); + } + return acc; + }, [] as ChatMediaSearchSegment[]); +} + +export function updateChatMediaSearchResults( + global: T, + chatId: string, + threadId: ThreadId, + currentSegment: ChatMediaSearchSegment, + searchParams: ChatMediaSearchParams, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const segments = mergeChatMediaSearchSegments(currentSegment, searchParams.segments); + + return replaceChatMediaSearchResults( + global, + chatId, + threadId, + currentSegment, + segments, + tabId, + ); +} + +function removeIdFromSegment(id: number, segment: ChatMediaSearchSegment): ChatMediaSearchSegment { + const foundIds = segment.foundIds.filter((foundId) => foundId !== id); + + return { + ...segment, + foundIds, + }; +} + +function removeIdsFromChatMediaSearchParams( + id: number, + searchParams: ChatMediaSearchParams, +): ChatMediaSearchParams { + const currentSegment = removeIdFromSegment(id, searchParams.currentSegment); + const segments = searchParams.segments.map((segment) => removeIdFromSegment(id, segment)); + + return { + ...searchParams, + currentSegment, + segments, + }; +} + +export function removeIdFromSearchResults( + global: T, + chatId: string, + threadId: ThreadId, + id: number, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const searchParams = selectChatMediaSearch(global, chatId, threadId, tabId); + if (!searchParams) return global; + + const updatedSearchParams = removeIdsFromChatMediaSearchParams(id, searchParams); + + return replaceChatMediaSearch( + global, + chatId, + threadId, + updatedSearchParams, + tabId, + ); +} + +function resetForwardsLoadingStateInParams( + searchParams: ChatMediaSearchParams, +) { + searchParams.currentSegment.loadingState.areAllItemsLoadedForwards = false; + searchParams.segments.forEach((segment) => { + segment.loadingState.areAllItemsLoadedForwards = false; + }); +} + +export function updateChatMediaLoadingState( + global: T, + newMessage: ApiMessage, + chatId: string, + threadId: ThreadId, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + if (!isMediaLoadableInViewer(newMessage)) { + return global; + } + const searchParams = selectChatMediaSearch(global, chatId, threadId, tabId); + if (!searchParams) return global; + resetForwardsLoadingStateInParams(searchParams); + + return replaceChatMediaSearch( + global, + chatId, + threadId, + searchParams, + tabId, + ); +} + +export function initializeChatMediaSearchResults( + global: T, + chatId: string, + threadId: ThreadId, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const loadingState: LoadingState = { + areAllItemsLoadedForwards: false, + areAllItemsLoadedBackwards: false, + }; + const currentSegment: ChatMediaSearchSegment = { + foundIds: [], + loadingState, + }; + const segments: ChatMediaSearchSegment[] = []; + + const isLoading = false; + + return replaceChatMediaSearch(global, chatId, threadId, { + currentSegment, + segments, + isLoading, + }, tabId); +} + +export function setChatMediaSearchLoading( + global: T, + chatId: string, + threadId: ThreadId, + isLoading: boolean, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const chatThreadKey = buildChatThreadKey(chatId, threadId); + const searchParams = selectTabState(global, tabId).chatMediaSearch.byChatThreadKey[chatThreadKey]; + + if (!searchParams) { + return global; + } + + return replaceChatMediaSearch(global, chatId, threadId, { + ...searchParams, + isLoading, + }, tabId); +} + +export function replaceChatMediaSearchResults( + global: T, + chatId: string, + threadId: ThreadId, + currentSegment: ChatMediaSearchSegment, + segments: ChatMediaSearchSegment[], + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + return replaceChatMediaSearch(global, chatId, threadId, { + ...selectTabState(global, tabId).chatMediaSearch.byChatThreadKey[chatThreadKey], + currentSegment, + segments, + }, tabId); +} + +function replaceChatMediaSearch( + global: T, + chatId: string, + threadId: ThreadId, + searchParams: ChatMediaSearchParams, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + return updateTabState(global, { + chatMediaSearch: { + byChatThreadKey: { + ...selectTabState(global, tabId).chatMediaSearch.byChatThreadKey, + [chatThreadKey]: searchParams, + }, + }, + }, tabId); +} diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index d547170c0..75a9787ef 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -16,7 +16,8 @@ import { } from '../../util/iteratees'; import { isLocalMessageId, type MessageKey } from '../../util/messageKey'; import { - hasMessageTtl, mergeIdRanges, orderHistoryIds, orderPinnedIds, + hasMessageTtl, isMediaLoadableInViewer, + mergeIdRanges, orderHistoryIds, orderPinnedIds, } from '../helpers'; import { selectChat, @@ -37,6 +38,7 @@ import { selectThreadInfo, selectViewportIds, } from '../selectors'; +import { removeIdFromSearchResults } from './localSearch'; import { updateTabState } from './tabs'; import { clearMessageTranslation } from './translations'; @@ -296,9 +298,13 @@ export function deleteChatMessages( const updatedThreads = new Map(); updatedThreads.set(MAIN_THREAD_ID, messageIds); + const mediaIdsToRemove: number[] = []; messageIds.forEach((messageId) => { const message = byId[messageId]; if (!message) return; + if (isMediaLoadableInViewer(message)) { + mediaIdsToRemove.push(messageId); + } const threadId = selectThreadIdFromMessage(global, message); if (!threadId || threadId === MAIN_THREAD_ID) { return; @@ -339,6 +345,10 @@ export function deleteChatMessages( } Object.values(global.byTabId).forEach(({ id: tabId }) => { + mediaIdsToRemove.forEach((mediaId) => { + global = removeIdFromSearchResults(global, chatId, threadId, mediaId, tabId); + }); + const viewportIds = selectViewportIds(global, chatId, threadId, tabId); if (!viewportIds) return; diff --git a/src/global/selectors/localSearch.ts b/src/global/selectors/localSearch.ts index b108850bf..9000398ba 100644 --- a/src/global/selectors/localSearch.ts +++ b/src/global/selectors/localSearch.ts @@ -1,3 +1,4 @@ +import type { ThreadId } from '../../types'; import type { GlobalState, TabArgs } from '../types'; import { getCurrentTabId } from '../../util/establishMultitabRole'; @@ -23,7 +24,7 @@ export function selectCurrentTextSearch( return currentSearch; } -export function selectCurrentMediaSearch( +export function selectCurrentSharedMediaSearch( global: T, ...[tabId = getCurrentTabId()]: TabArgs ) { @@ -34,5 +35,32 @@ export function selectCurrentMediaSearch( const chatThreadKey = buildChatThreadKey(chatId, threadId); - return selectTabState(global, tabId).localMediaSearch.byChatThreadKey[chatThreadKey]; + return selectTabState(global, tabId).sharedMediaSearch.byChatThreadKey[chatThreadKey]; +} + +export function selectCurrentChatMediaSearch( + global: T, + ...[tabId = getCurrentTabId()]: TabArgs +) { + const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; + if (!chatId || !threadId) { + return undefined; + } + + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + return selectTabState(global, tabId).chatMediaSearch.byChatThreadKey[chatThreadKey]; +} + +export function selectChatMediaSearch( + global: T, chatId?: string, threadId?: ThreadId, + ...[tabId = getCurrentTabId()]: TabArgs +) { + if (!chatId || !threadId) { + return undefined; + } + + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + return selectTabState(global, tabId).chatMediaSearch.byChatThreadKey[chatThreadKey]; } diff --git a/src/global/types.ts b/src/global/types.ts index c83537e49..6b6a689e8 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -92,6 +92,7 @@ import type { ApiPrivacySettings, AudioOrigin, ChatCreationProgress, + ChatMediaSearchParams, EmojiKeywords, FocusDirection, GlobalSearchContent, @@ -375,7 +376,7 @@ export type TabState = { }>; }; - localMediaSearch: { + sharedMediaSearch: { byChatThreadKey: Record; }; + chatMediaSearch: { + byChatThreadKey: Record; + }; + management: { progress?: ManagementProgress; byChatId: Record; @@ -431,6 +436,7 @@ export type TabState = { playbackRate: number; isMuted: boolean; isHidden?: boolean; + withDynamicLoading?: boolean; }; audioPlayer: { @@ -1280,11 +1286,18 @@ export interface ActionPayloads { setLocalTextSearchTag: { tag: ApiReaction | undefined; } & WithTabId; - setLocalMediaSearchType: { + setSharedMediaSearchType: { mediaType: SharedMediaType; } & WithTabId; searchTextMessagesLocal: WithTabId | undefined; - searchMediaMessagesLocal: WithTabId | undefined; + searchSharedMediaMessages: WithTabId | undefined; + searchChatMediaMessages: { + currentMediaMessageId: number; + direction?: LoadMoreDirection; + chatId?: string; + threadId? : ThreadId; + limit?: number; + } & WithTabId; searchMessagesByDate: { timestamp: number; } & WithTabId; @@ -2414,6 +2427,7 @@ export interface ActionPayloads { volume?: number; playbackRate?: number; isMuted?: boolean; + withDynamicLoading?: boolean; } & WithTabId; closeMediaViewer: WithTabId | undefined; setMediaViewerVolume: { diff --git a/src/types/index.ts b/src/types/index.ts index 5011f6d75..0cfaf8c00 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -388,6 +388,22 @@ export type ApiPrivacyKey = 'phoneNumber' | 'addByPhone' | 'lastSeen' | 'profile 'forwards' | 'chatInvite' | 'phoneCall' | 'phoneP2P' | 'bio' | 'birthday'; export type PrivacyVisibility = 'everybody' | 'contacts' | 'closeFriends' | 'nonContacts' | 'nobody'; +export interface LoadingState { + areAllItemsLoadedForwards: boolean; + areAllItemsLoadedBackwards: boolean; +} + +export interface ChatMediaSearchSegment { + foundIds: number[]; + loadingState: LoadingState; +} + +export interface ChatMediaSearchParams { + currentSegment: ChatMediaSearchSegment; + segments: ChatMediaSearchSegment[]; + isLoading: boolean; +} + export enum ProfileState { Profile, SharedMedia, diff --git a/src/util/iteratees.ts b/src/util/iteratees.ts index 50df26fc5..2b49e046d 100644 --- a/src/util/iteratees.ts +++ b/src/util/iteratees.ts @@ -125,6 +125,9 @@ export function areSortedArraysEqual(array1: any[], array2: any[]) { export function areSortedArraysIntersecting(array1: any[], array2: any[]) { return array1[0] <= array2[array2.length - 1] && array1[array1.length - 1] >= array2[0]; } +export function isInsideSortedArrayRange(value:any, array: any[]) { + return array[0] <= value && value <= array[array.length - 1]; +} export function findIntersectionWithSet(array: T[], set: Set): T[] { return array.filter((a) => set.has(a));