mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Remember scroll position in chats
This commit is contained in:
@@ -274,6 +274,7 @@ const actions = () => ({
|
||||
clearInvitedServiceIdsForNewlyCreatedGroup: action(
|
||||
'clearInvitedServiceIdsForNewlyCreatedGroup'
|
||||
),
|
||||
setCenterMessage: action('setCenterMessage'),
|
||||
setIsNearBottom: action('setIsNearBottom'),
|
||||
loadOlderMessages: action('loadOlderMessages'),
|
||||
loadNewerMessages: action('loadNewerMessages'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user