Message List: Save viewport position between reloads (#6754)

This commit is contained in:
zubiden 2026-03-31 11:28:49 +02:00 committed by Alexander Zinchuk
parent ac06cb6e80
commit 214e8a3c75
7 changed files with 206 additions and 25 deletions

View File

@ -1323,6 +1323,12 @@ export async function markMessageListRead({
chatId: chat.id,
topicId: Number(threadId),
});
} else {
sendApiUpdate({
'@type': 'updateDiscussion',
chatId: chat.id,
threadId: Number(threadId),
});
}
}

View File

@ -753,6 +753,12 @@ export type ApiUpdateTopics = {
chatId: string;
};
export type ApiUpdateDiscussion = {
'@type': 'updateDiscussion';
chatId: string;
threadId: number;
};
export type ApiUpdateViewForumAsMessages = {
'@type': 'updateViewForumAsMessages';
chatId: string;
@ -931,7 +937,7 @@ export type ApiUpdate = (
ApiUpdateRecentStickers | ApiUpdateSavedGifs | ApiUpdateNewScheduledMessage | ApiUpdateMoveStickerSetToTop |
ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage | ApiUpdateStarPaymentStateCompleted |
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages | ApiUpdateMessageTranslations |
ApiUpdateFailedMessageTranslations | ApiUpdateWebPage | ApiUpdateChatTypingDraft |
ApiUpdateFailedMessageTranslations | ApiUpdateWebPage | ApiUpdateChatTypingDraft | ApiUpdateDiscussion |
ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode | ApiUpdateWebViewResultSent |
ApiUpdateDefaultNotifySettings | ApiUpdatePeerNotifySettings | ApiUpdatePeerBlocked | ApiUpdatePrivacy |
ApiUpdateServerTimeOffset | ApiUpdateMessageReactions | ApiUpdateSavedReactionTags |

View File

@ -500,9 +500,10 @@ const Profile = ({
}, [profileTab, activeTabIndex]);
const tabType = tabs[activeTabIndex].type;
const handleLoadCommonChats = useCallback(() => {
const handleLoadCommonChats = useLastCallback(() => {
if (!isSynced) return;
loadCommonChats({ userId: chatId });
}, [chatId]);
});
const handleLoadPeerStories = useCallback(({ offsetId }: { offsetId: number }) => {
loadPeerProfileStories({ peerId: chatId, offsetId });
}, [chatId]);
@ -513,9 +514,10 @@ const Profile = ({
loadPeerSavedGifts({ peerId: chatId });
}, [chatId]);
const handleLoadMoreMembers = useCallback(() => {
const handleLoadMoreMembers = useLastCallback(() => {
if (!isSynced) return;
loadMoreMembers({ chatId });
}, [chatId, loadMoreMembers]);
});
useEffectWithPrevDeps(([prevGifts]) => {
if (areDeepEqual(gifts, prevGifts)) {

View File

@ -3297,6 +3297,29 @@ addActionHandler('requestCollectibleInfo', async (global, actions, payload): Pro
setGlobal(global);
});
addActionHandler('loadDiscussion', async (global, actions, payload): Promise<void> => {
const { chatId, threadId } = payload;
const chat = selectChat(global, chatId);
if (!chat) return;
const result = await callApi('fetchDiscussionMessage', {
chat,
messageId: threadId,
});
if (!result) {
return;
}
global = getGlobal();
global = addMessages(global, result.messages);
global = updateThreadInfo(global, result.threadInfo);
global = updateThreadReadState(global, chatId, result.threadId, result.threadReadState);
global = updateThreadInfoLastMessageId(global, chatId, result.threadId, result.lastMessageId);
global = replaceThreadLocalStateParam(global, chatId, threadId, 'firstMessageId', result.firstMessageId);
setGlobal(global);
});
async function loadChats(
listType: ChatListType,
isFullDraftSync?: boolean,

View File

@ -6,6 +6,7 @@ import type { ActionReturnType, GlobalState } from '../../types';
import { MAIN_THREAD_ID } from '../../../api/types';
import { DEBUG, MESSAGE_LIST_SLICE, SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { init as initFolderManager } from '../../../util/folderManager';
import {
buildCollectionByKey, omitUndefined, pick, unique,
@ -36,6 +37,7 @@ import {
selectCurrentMessageList,
selectTabState,
selectTopics,
selectViewportIds,
} from '../../selectors';
import {
selectDraft,
@ -108,6 +110,8 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
global = getGlobal();
let wasReset = false;
const preservedTabThreadsByTabId = preserveCurrentTabThreads(global);
const preservedCurrentThreadsByChatId = preserveCurrentThreads(global);
// Memoize drafts
const draftChatIds = Object.keys(global.messages.byChatId);
@ -127,24 +131,45 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
return acc;
}, {});
for (const { id: tabId } of Object.values(global.byTabId)) {
const currentTabId = getCurrentTabId();
const tabs = Object.values(global.byTabId)
.sort(({ id: leftId }, { id: rightId }) => {
if (leftId === currentTabId) return -1;
if (rightId === currentTabId) return 1;
return 0;
});
for (const { id: tabId } of tabs) {
global = getGlobal();
const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global, tabId) || {};
const activeThreadId = currentThreadId || MAIN_THREAD_ID;
const currentChat = currentChatId ? global.chats.byId[currentChatId] : undefined;
const currentViewportIds = currentChatId
? selectViewportIds(global, currentChatId, activeThreadId, tabId)
: undefined;
const isSavedDialog = currentChatId
? getIsSavedDialog(currentChatId, activeThreadId, global.currentUserId)
: false;
if (currentChatId && currentChat) {
const [result, resultDiscussion] = await Promise.all([
const discussionChat = resolveDiscussionChat(global, currentChatId, activeThreadId);
const [result, resultDiscussion, refreshedViewportMessages] = await Promise.all([
loadTopMessages(
global,
currentChatId,
activeThreadId,
currentViewportIds,
),
activeThreadId !== MAIN_THREAD_ID && !currentChat.isForum
&& !getIsSavedDialog(currentChat.id, activeThreadId, global.currentUserId)
discussionChat
? callApi('fetchDiscussionMessage', {
chat: currentChat,
messageId: Number(activeThreadId),
chat: discussionChat.chat,
messageId: discussionChat.messageId,
}) : undefined,
currentViewportIds?.length && !isSavedDialog
? callApi('fetchMessagesById', {
chat: currentChat,
messageIds: currentViewportIds,
}).catch(() => undefined)
: undefined,
]);
global = getGlobal();
const { chatId: newCurrentChatId } = selectCurrentMessageList(global, tabId) || {};
@ -167,17 +192,18 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
const isDiscussionStartLoaded = !result.messages.length
|| result.messages.some(({ id }) => id === resultDiscussion?.firstMessageId);
const threadStartMessages = (isDiscussionStartLoaded && resultDiscussion?.topMessages) || [];
const allMessages = threadStartMessages.concat(result.messages, localMessages);
const refreshedViewportIds = refreshedViewportMessages?.map(({ id }) => id) || [];
const allMessages = threadStartMessages.concat(result.messages, refreshedViewportMessages || [], localMessages);
const allMessagesWithTopicLastMessages = allMessages.concat(topicLastMessages);
const byId = buildCollectionByKey(allMessagesWithTopicLastMessages, 'id');
const listedIds = unique(allMessages.map(({ id }) => id));
const listedIds = unique(refreshedViewportIds.concat(allMessages.map(({ id }) => id)));
if (!wasReset) {
global = resetMessages(global);
global = resetMessages(global, preservedCurrentThreadsByChatId);
Object.values(global.byTabId).forEach(({ id: otherTabId }) => {
global = updateTabState(global, {
tabThreads: {},
tabThreads: preservedTabThreadsByTabId[otherTabId] || {},
}, otherTabId);
});
wasReset = true;
@ -202,7 +228,18 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
Object.values(global.byTabId).forEach(({ id: otherTabId }) => {
const { chatId: otherChatId, threadId: otherThreadId } = selectCurrentMessageList(global, otherTabId) || {};
if (otherChatId === currentChatId && otherThreadId === activeThreadId) {
global = safeReplaceViewportIds(global, currentChatId, activeThreadId, listedIds, otherTabId);
const preservedViewportIds = preservedTabThreadsByTabId[otherTabId]
?.[currentChatId]?.[activeThreadId]?.viewportIds;
const mergedMessagesById = selectChatMessages(global, currentChatId) || {};
const nextViewportIds = preservedViewportIds?.filter((id) => Boolean(mergedMessagesById[id]));
global = safeReplaceViewportIds(
global,
currentChatId,
activeThreadId,
nextViewportIds?.length ? nextViewportIds : listedIds,
otherTabId,
);
}
});
global = updateChats(global, buildCollectionByKey(result.chats, 'id'));
@ -227,11 +264,11 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
global = getGlobal();
if (!areMessagesLoaded) {
global = resetMessages(global);
global = resetMessages(global, preservedCurrentThreadsByChatId);
Object.values(global.byTabId).forEach(({ id: otherTabId }) => {
global = updateTabState(global, {
tabThreads: {},
tabThreads: preservedTabThreadsByTabId[otherTabId] || {},
}, otherTabId);
});
}
@ -254,30 +291,124 @@ async function loadAndReplaceMessages<T extends GlobalState>(global: T, actions:
});
}
function resetMessages<T extends GlobalState>(global: T) {
function resetMessages<T extends GlobalState>(
global: T,
preservedByChatId: GlobalState['messages']['byChatId'] = {},
) {
return {
...global,
messages: {
...global.messages,
byChatId: {},
byChatId: preservedByChatId,
},
};
}
function loadTopMessages<T extends GlobalState>(global: T, chatId: string, threadId: ThreadId) {
function preserveCurrentTabThreads<T extends GlobalState>(global: T) {
return Object.values(global.byTabId).reduce<Record<number, GlobalState['byTabId'][number]['tabThreads']>>(
(acc, { id: tabId }) => {
const currentMessageList = selectCurrentMessageList(global, tabId);
if (!currentMessageList) {
return acc;
}
const { chatId, threadId = MAIN_THREAD_ID } = currentMessageList;
const currentTabThread = selectTabState(global, tabId).tabThreads[chatId]?.[threadId];
if (!currentTabThread) {
return acc;
}
acc[tabId] = {
[chatId]: {
[threadId]: currentTabThread,
},
};
return acc;
},
{},
);
}
function preserveCurrentThreads<T extends GlobalState>(global: T) {
return Object.values(global.byTabId).reduce<GlobalState['messages']['byChatId']>((acc, { id: tabId }) => {
const currentMessageList = selectCurrentMessageList(global, tabId);
if (!currentMessageList) {
return acc;
}
const { chatId, threadId = MAIN_THREAD_ID } = currentMessageList;
const currentThread = global.messages.byChatId[chatId]?.threadsById[threadId];
if (!currentThread) {
return acc;
}
acc[chatId] = {
byId: {},
summaryById: {},
threadsById: {
...acc[chatId]?.threadsById,
[threadId]: {
...currentThread,
localState: {
...currentThread.localState,
listedIds: undefined,
outlyingLists: undefined,
},
},
},
};
return acc;
}, {});
}
function resolveDiscussionChat<T extends GlobalState>(
global: T,
chatId: string,
threadId: ThreadId,
) {
if (threadId === MAIN_THREAD_ID) return undefined;
const chat = selectChat(global, chatId);
if (!chat || chat.isForum || getIsSavedDialog(chatId, threadId, global.currentUserId)) return undefined;
const threadInfo = selectThreadInfo(global, chatId, threadId);
if (threadInfo?.isCommentsInfo === false && threadInfo.fromChannelId) {
const originChannel = selectChat(global, threadInfo.fromChannelId);
if (originChannel && threadInfo.fromMessageId) {
return { chat: originChannel, messageId: threadInfo.fromMessageId };
}
}
return { chat, messageId: Number(threadId) };
}
function loadTopMessages<T extends GlobalState>(
global: T,
chatId: string,
threadId: ThreadId,
viewportIds?: number[],
) {
const currentUserId = global.currentUserId!;
const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId);
const realChatId = isSavedDialog ? String(threadId) : chatId;
const chat = selectChat(global, realChatId)!;
const chat = selectChat(global, realChatId);
if (!chat) return undefined;
const readState = selectThreadReadState(global, chatId, threadId);
const viewportAnchorId = viewportIds?.[0];
const shouldRestoreViewport = Boolean(viewportAnchorId && !getIsSavedDialog(chatId, threadId, currentUserId));
return callApi('fetchMessages', {
chat,
threadId,
offsetId: !isSavedDialog ? readState?.lastReadInboxMessageId : undefined,
addOffset: -(Math.round(MESSAGE_LIST_SLICE / 2) + 1),
limit: MESSAGE_LIST_SLICE,
offsetId: shouldRestoreViewport ? viewportAnchorId
: (!isSavedDialog ? readState?.lastReadInboxMessageId : undefined),
addOffset: shouldRestoreViewport ? -(MESSAGE_LIST_SLICE + 1) : -(Math.round(MESSAGE_LIST_SLICE / 2) + 1),
limit: shouldRestoreViewport ? (MESSAGE_LIST_SLICE + 1) : MESSAGE_LIST_SLICE,
isSavedDialog,
});
}

View File

@ -524,6 +524,14 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
return undefined;
}
case 'updateDiscussion': {
const { chatId, threadId } = update;
actions.loadDiscussion({ chatId, threadId });
return undefined;
}
case 'updateViewForumAsMessages': {
const { chatId, isEnabled } = update;

View File

@ -3028,6 +3028,11 @@ export interface ActionPayloads {
} & WithTabId;
closeEditTopicPanel: WithTabId | undefined;
loadDiscussion: {
chatId: string;
threadId: number;
};
uploadContactProfilePhoto: {
userId: string;
file?: File;