diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index cfa5868ee4..90cf5bf46e 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -274,6 +274,7 @@ const actions = () => ({ clearInvitedServiceIdsForNewlyCreatedGroup: action( 'clearInvitedServiceIdsForNewlyCreatedGroup' ), + setCenterMessage: action('setCenterMessage'), setIsNearBottom: action('setIsNearBottom'), loadOlderMessages: action('loadOlderMessages'), loadNewerMessages: action('loadNewerMessages'), diff --git a/ts/components/conversation/Timeline.dom.tsx b/ts/components/conversation/Timeline.dom.tsx index c27b17296f..6b4753b454 100644 --- a/ts/components/conversation/Timeline.dom.tsx +++ b/ts/components/conversation/Timeline.dom.tsx @@ -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(); readonly #lastSeenIndicatorRef = React.createRef(); #intersectionObserver?: IntersectionObserver; + #intersectionRatios: Map = 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(); + 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 = []; - 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( diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index 78869defa4..568091f0dc 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -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; @@ -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) { diff --git a/ts/state/selectors/conversations.dom.ts b/ts/state/selectors/conversations.dom.ts index dafcb79daf..20a96d1943 100644 --- a/ts/state/selectors/conversations.dom.ts +++ b/ts/state/selectors/conversations.dom.ts @@ -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; } ); diff --git a/ts/state/smart/Timeline.preload.tsx b/ts/state/smart/Timeline.preload.tsx index b847f2d887..9f995647e0 100644 --- a/ts/state/smart/Timeline.preload.tsx +++ b/ts/state/smart/Timeline.preload.tsx @@ -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} diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index 3559bda603..c4847ceb7b 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -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; @@ -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';