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: action(
|
||||||
'clearInvitedServiceIdsForNewlyCreatedGroup'
|
'clearInvitedServiceIdsForNewlyCreatedGroup'
|
||||||
),
|
),
|
||||||
|
setCenterMessage: action('setCenterMessage'),
|
||||||
setIsNearBottom: action('setIsNearBottom'),
|
setIsNearBottom: action('setIsNearBottom'),
|
||||||
loadOlderMessages: action('loadOlderMessages'),
|
loadOlderMessages: action('loadOlderMessages'),
|
||||||
loadNewerMessages: action('loadNewerMessages'),
|
loadNewerMessages: action('loadNewerMessages'),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user