Remember scroll position in chats

This commit is contained in:
ayumi-signal
2025-10-20 07:02:53 -07:00
committed by GitHub
parent 7f5af1a698
commit dc95d1b460
6 changed files with 125 additions and 10 deletions

View File

@@ -274,6 +274,7 @@ const actions = () => ({
clearInvitedServiceIdsForNewlyCreatedGroup: action(
'clearInvitedServiceIdsForNewlyCreatedGroup'
),
setCenterMessage: action('setCenterMessage'),
setIsNearBottom: action('setIsNearBottom'),
loadOlderMessages: action('loadOlderMessages'),
loadNewerMessages: action('loadNewerMessages'),

View File

@@ -162,7 +162,11 @@ export type PropsActionsType = {
) => unknown;
markMessageRead: (conversationId: string, messageId: string) => unknown;
targetMessage: (messageId: string, conversationId: string) => unknown;
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
setCenterMessage: (
conversationId: string,
messageId: string | undefined
) => void;
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => void;
peekGroupCallForTheFirstTime: (conversationId: string) => unknown;
peekGroupCallIfItHasMembers: (conversationId: string) => unknown;
reviewConversationNameCollision: () => void;
@@ -203,6 +207,7 @@ export class Timeline extends React.Component<
readonly #atBottomDetectorRef = React.createRef<HTMLDivElement>();
readonly #lastSeenIndicatorRef = React.createRef<HTMLDivElement>();
#intersectionObserver?: IntersectionObserver;
#intersectionRatios: Map<Element, number> = new Map();
// This is a best guess. It will likely be overridden when the timeline is measured.
#maxVisibleRows = Math.ceil(window.innerHeight / MIN_ROW_HEIGHT);
@@ -385,7 +390,7 @@ export class Timeline extends React.Component<
// this another way, but this approach works.)
this.#intersectionObserver?.disconnect();
const intersectionRatios = new Map<Element, number>();
this.#intersectionRatios = new Map();
this.props.updateVisibleMessages?.([]);
const intersectionObserverCallback: IntersectionObserverCallback =
@@ -394,7 +399,7 @@ export class Timeline extends React.Component<
// (which should match DOM order). We don't want to delete anything from our map
// because we don't want the order to change at all.
entries.forEach(entry => {
intersectionRatios.set(entry.target, entry.intersectionRatio);
this.#intersectionRatios.set(entry.target, entry.intersectionRatio);
});
let newIsNearBottom = false;
@@ -402,7 +407,7 @@ export class Timeline extends React.Component<
let newestPartiallyVisible: undefined | Element;
let newestFullyVisible: undefined | Element;
const visibleMessageIds: Array<string> = [];
for (const [element, intersectionRatio] of intersectionRatios) {
for (const [element, intersectionRatio] of this.#intersectionRatios) {
if (intersectionRatio === 0) {
continue;
}
@@ -516,6 +521,41 @@ export class Timeline extends React.Component<
this.#intersectionObserver.observe(atBottomDetectorEl);
}
#getCenterMessageId(): string | undefined {
const containerEl = this.#containerRef.current;
if (!containerEl) {
return;
}
const containerElRectTop = containerEl.getBoundingClientRect().top;
const containerElMidline = containerEl.clientHeight / 2;
const atBottomDetectorEl = this.#atBottomDetectorRef.current;
let centerMessageId: undefined | string;
for (const [element, intersectionRatio] of this.#intersectionRatios) {
if (intersectionRatio === 0) {
continue;
}
if (element === atBottomDetectorEl) {
return;
}
const messageId = getMessageIdFromElement(element);
if (!messageId) {
continue;
}
const relativeTop =
element.getBoundingClientRect().top - containerElRectTop;
if (!centerMessageId || relativeTop < containerElMidline) {
centerMessageId = messageId;
}
}
return centerMessageId;
}
#markNewestBottomVisibleMessageRead = throttle((messageId?: string): void => {
const { id, markMessageRead } = this.props;
const messageIdToMarkRead =
@@ -586,6 +626,8 @@ export class Timeline extends React.Component<
}
public override componentWillUnmount(): void {
const { id, setCenterMessage, updateVisibleMessages } = this.props;
window.SignalContext.activeWindowService.unregisterForActive(
this.#markNewestBottomVisibleMessageReadAfterDelay
);
@@ -593,7 +635,8 @@ export class Timeline extends React.Component<
this.#markNewestBottomVisibleMessageRead.cancel();
this.#intersectionObserver?.disconnect();
this.#cleanupGroupCallPeekTimeouts();
this.props.updateVisibleMessages?.([]);
updateVisibleMessages?.([]);
setCenterMessage(id, this.#getCenterMessageId());
}
public override getSnapshotBeforeUpdate(

View File

@@ -507,6 +507,10 @@ export type MessagesByConversationType = ReadonlyDeep<{
[key: string]: ConversationMessageType | undefined;
}>;
export type LastCenterMessageByConversationType = ReadonlyDeep<{
[key: string]: string;
}>;
export type PreJoinConversationType = ReadonlyDeep<{
avatar?: {
loading?: boolean;
@@ -618,6 +622,8 @@ export type ConversationsStateType = ReadonlyDeep<{
messagesLookup: MessageLookupType;
messagesByConversation: MessagesByConversationType;
lastCenterMessageByConversation: LastCenterMessageByConversationType;
// Map of conversation IDs to a boolean indicating whether an avatar download
// was requested
pendingRequestedAvatarDownload: Record<string, boolean>;
@@ -941,6 +947,13 @@ export type SetMessageLoadingStateActionType = ReadonlyDeep<{
messageLoadingState: undefined | TimelineMessageLoadingState;
};
}>;
export type SetCenterMessageActionType = ReadonlyDeep<{
type: 'SET_CENTER_MESSAGE';
payload: {
conversationId: string;
messageId: string | undefined;
};
}>;
export type SetIsNearBottomActionType = ReadonlyDeep<{
type: 'SET_NEAR_BOTTOM';
payload: {
@@ -1129,6 +1142,7 @@ export type ConversationActionType =
| SetPendingRequestedAvatarDownloadActionType
| SetProfileUpdateErrorActionType
| TargetedConversationChangedActionType
| SetCenterMessageActionType
| SetComposeGroupAvatarActionType
| SetComposeGroupExpireTimerActionType
| SetComposeGroupNameActionType
@@ -1252,6 +1266,7 @@ export const actions = {
setAccessControlAttributesSetting,
setAccessControlMembersSetting,
setAnnouncementsOnly,
setCenterMessage,
setComposeGroupAvatar,
setComposeGroupExpireTimer,
setComposeGroupName,
@@ -3398,6 +3413,18 @@ function setMessageLoadingState(
},
};
}
function setCenterMessage(
conversationId: string,
messageId: string | undefined
): SetCenterMessageActionType {
return {
type: 'SET_CENTER_MESSAGE',
payload: {
conversationId,
messageId,
},
};
}
function setIsNearBottom(
conversationId: string,
isNearBottom: boolean
@@ -5044,6 +5071,7 @@ export function getEmptyState(): ConversationsStateType {
conversationsByGroupId: {},
conversationsByUsername: {},
verificationDataByConversation: {},
lastCenterMessageByConversation: {},
messagesByConversation: {},
messagesLookup: {},
targetedMessage: undefined,
@@ -6294,6 +6322,30 @@ export function reducer(
},
};
}
if (action.type === 'SET_CENTER_MESSAGE') {
const { payload } = action;
const { conversationId, messageId } = payload;
const { lastCenterMessageByConversation } = state;
const existingCenterMessageId: string | undefined =
lastCenterMessageByConversation[conversationId];
if (existingCenterMessageId === messageId) {
return state;
}
const nextLastCenterMessageByConversation: LastCenterMessageByConversationType =
messageId
? {
...lastCenterMessageByConversation,
[conversationId]: messageId,
}
: omit(lastCenterMessageByConversation, conversationId);
return {
...state,
lastCenterMessageByConversation: nextLastCenterMessageByConversation,
};
}
if (action.type === 'SET_NEAR_BOTTOM') {
const { payload } = action;
const { conversationId, isNearBottom } = payload;
@@ -6659,6 +6711,7 @@ export function reducer(
const { conversationId, messageId, switchToAssociatedView } = payload;
let conversation: ConversationType | undefined;
let lastCenterMessageId: string | undefined;
if (conversationId) {
conversation = getOwn(state.conversationLookup, conversationId);
@@ -6666,6 +6719,12 @@ export function reducer(
log.error(`Unknown conversation selected, id: [${conversationId}]`);
return state;
}
// Restore scroll position if there are no unread messages.
if (conversation.unreadCount === 0) {
lastCenterMessageId =
state.lastCenterMessageByConversation[conversationId];
}
}
const nextState = {
@@ -6676,8 +6735,10 @@ export function reducer(
: undefined,
hasContactSpoofingReview: false,
selectedConversationId: conversationId,
targetedMessage: messageId,
targetedMessageSource: TargetedMessageSource.NavigateToMessage,
targetedMessage: messageId ?? lastCenterMessageId,
targetedMessageSource: messageId
? TargetedMessageSource.NavigateToMessage
: TargetedMessageSource.Reset,
};
if (switchToAssociatedView && conversation) {

View File

@@ -25,6 +25,7 @@ import {
ComposerStep,
OneTimeModalState,
ConversationVerificationState,
type TargetedMessageSource,
} from '../ducks/conversationsEnums.std.js';
import { getOwn } from '../../util/getOwn.std.js';
import type { UUIDFetchStateType } from '../../util/uuidFetchState.std.js';
@@ -223,7 +224,7 @@ export const getTargetedMessage = createSelector(
);
export const getTargetedMessageSource = createSelector(
getConversations,
(state: ConversationsStateType): string | undefined => {
(state: ConversationsStateType): TargetedMessageSource | undefined => {
return state.targetedMessageSource;
}
);

View File

@@ -197,6 +197,7 @@ export const SmartTimeline = memo(function SmartTimeline({
markMessageRead,
reviewConversationNameCollision,
scrollToOldestUnreadMention,
setCenterMessage,
setIsNearBottom,
targetMessage,
} = useConversationsActions();
@@ -290,6 +291,7 @@ export const SmartTimeline = memo(function SmartTimeline({
scrollToIndex={scrollToIndex}
scrollToIndexCounter={scrollToIndexCounter}
scrollToOldestUnreadMention={scrollToOldestUnreadMention}
setCenterMessage={setCenterMessage}
setIsNearBottom={setIsNearBottom}
shouldShowMiniPlayer={shouldShowMiniPlayer}
targetedMessageId={targetedMessageId}

View File

@@ -21,7 +21,10 @@ import {
getTheme,
getPlatform,
} from '../selectors/user.std.js';
import { getTargetedMessage } from '../selectors/conversations.dom.js';
import {
getTargetedMessage,
getTargetedMessageSource,
} from '../selectors/conversations.dom.js';
import { useTimelineItem } from '../selectors/timeline.preload.js';
import {
areMessagesInSameGroup,
@@ -35,6 +38,7 @@ import { isSameDay } from '../../util/timestamp.std.js';
import { renderAudioAttachment } from './renderAudioAttachment.preload.js';
import { renderReactionPicker } from './renderReactionPicker.dom.js';
import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation.dom.js';
import { TargetedMessageSource } from '../ducks/conversationsEnums.std.js';
export type SmartTimelineItemProps = {
containerElementRef: RefObject<HTMLElement>;
@@ -82,8 +86,11 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
const previousItem = useTimelineItem(previousMessageId, conversationId);
const nextItem = useTimelineItem(nextMessageId, conversationId);
const targetedMessage = useSelector(getTargetedMessage);
const targetedMessageSource = useSelector(getTargetedMessageSource);
const isTargeted = Boolean(
targetedMessage && messageId === targetedMessage.id
targetedMessage &&
messageId === targetedMessage.id &&
targetedMessageSource !== TargetedMessageSource.Reset
);
const isNextItemCallingNotification = nextItem?.type === 'callHistory';