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: action(
'clearInvitedServiceIdsForNewlyCreatedGroup' 'clearInvitedServiceIdsForNewlyCreatedGroup'
), ),
setCenterMessage: action('setCenterMessage'),
setIsNearBottom: action('setIsNearBottom'), setIsNearBottom: action('setIsNearBottom'),
loadOlderMessages: action('loadOlderMessages'), loadOlderMessages: action('loadOlderMessages'),
loadNewerMessages: action('loadNewerMessages'), loadNewerMessages: action('loadNewerMessages'),

View File

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

View File

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

View File

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

View File

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

View File

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