diff --git a/.changeset/fix-jump-to-events.md b/.changeset/fix-jump-to-events.md new file mode 100644 index 000000000..cde123efd --- /dev/null +++ b/.changeset/fix-jump-to-events.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed jumpting to arbitrary events (e.g. reactions, edits, pins, leaves/joins). diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 8bc5d770c..812f2043c 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -45,6 +45,7 @@ import { factoryRenderLinkifyWithMention, } from '$plugins/react-custom-html-parser'; import { today, yesterday, timeDayMonthYear } from '$utils/time'; +import { unwrapRelationJumpTarget } from '$utils/room'; import { useMemberEventParser } from '$hooks/useMemberEventParser'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; @@ -77,8 +78,11 @@ import { } from '$utils/timeline'; import { useTimelineSync } from '$hooks/timeline/useTimelineSync'; import { useTimelineActions } from '$hooks/timeline/useTimelineActions'; -import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline'; -import { useProcessedTimeline } from '$hooks/timeline/useProcessedTimeline'; +import { + useProcessedTimeline, + getProcessedRowIndexForRawTimelineIndex, + type ProcessedEvent, +} from '$hooks/timeline/useProcessedTimeline'; import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer'; import * as css from './RoomTimeline.css'; @@ -494,19 +498,40 @@ export function RoomTimeline({ setOpenThread: setOpenThread as unknown as (threadId: string | undefined) => void, handleEdit, handleOpenEvent: (id) => { - const evtTimeline = getEventTimeline(room, id); + const anchorId = unwrapRelationJumpTarget(room, id); + let evtTimeline = getEventTimeline(room, anchorId); + let resolvedForIndex = anchorId; + if (!evtTimeline && anchorId !== id) { + evtTimeline = getEventTimeline(room, id); + resolvedForIndex = id; + } const absoluteIndex = evtTimeline - ? getEventIdAbsoluteIndex(timelineSync.timeline.linkedTimelines, evtTimeline, id) + ? getEventIdAbsoluteIndex( + timelineSync.timeline.linkedTimelines, + evtTimeline, + resolvedForIndex + ) : undefined; if (typeof absoluteIndex === 'number') { - const processedIndex = getRawIndexToProcessedIndex(absoluteIndex); + let processedIndex = getRawIndexToProcessedIndex(absoluteIndex); + let focusRawIndex = absoluteIndex; + if (processedIndex === undefined) { + const nearest = getProcessedRowIndexForRawTimelineIndex( + processedEventsRef.current, + absoluteIndex + ); + if (nearest) { + processedIndex = nearest.rowIndex; + focusRawIndex = nearest.focusRawIndex; + } + } if (vListRef.current && processedIndex !== undefined) { vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); } - timelineSync.setFocusItem({ index: absoluteIndex, scrollTo: false, highlight: true }); + timelineSync.setFocusItem({ index: focusRawIndex, scrollTo: false, highlight: true }); } else { - timelineSync.loadEventTimeline(id); + timelineSync.loadEventTimeline(anchorId); } }, }); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 3171e3ba4..39cc66728 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -23,7 +23,12 @@ import { makeMentionCustomProps, renderMatrixMention, } from '$plugins/react-custom-html-parser'; -import { getEditedEvent, getMemberDisplayName, reactionOrEditEvent } from '$utils/room'; +import { + getEditedEvent, + getMemberDisplayName, + reactionOrEditEvent, + unwrapRelationJumpTarget, +} from '$utils/room'; import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; @@ -49,8 +54,11 @@ import { useIgnoredUsers } from '$hooks/useIgnoredUsers'; import { useGetMemberPowerTag } from '$hooks/useMemberPowerTag'; import { useMemberEventParser } from '$hooks/useMemberEventParser'; import { useMessageEdit } from '$hooks/useMessageEdit'; -import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline'; -import { useProcessedTimeline } from '$hooks/timeline/useProcessedTimeline'; +import { + useProcessedTimeline, + getProcessedRowIndexForRawTimelineIndex, + type ProcessedEvent, +} from '$hooks/timeline/useProcessedTimeline'; import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer'; import { RoomInput } from './RoomInput'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; @@ -627,18 +635,32 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra (evt) => { const targetId = evt.currentTarget.getAttribute('data-event-id'); if (!targetId) return; - const isRoot = targetId === threadRootId; - const isInReplies = processedEventsRef.current.some((e) => e.id === targetId); + let anchorId = unwrapRelationJumpTarget(room, targetId); + const threadLive = thread?.timelineSet.getLiveTimeline(); + const threadEvents = threadLive?.getEvents(); + const rawIndex = threadEvents?.findIndex((e) => e.getId() === anchorId) ?? -1; + if (rawIndex >= 0) { + const nearest = getProcessedRowIndexForRawTimelineIndex( + processedEventsRef.current, + rawIndex + ); + if (nearest) { + const rowEv = processedEventsRef.current[nearest.rowIndex]; + if (rowEv) anchorId = rowEv.id; + } + } + const isRoot = anchorId === threadRootId; + const isInReplies = processedEventsRef.current.some((e) => e.id === anchorId); if (!isRoot && !isInReplies) return; - setJumpToEventId(targetId); + setJumpToEventId(anchorId); setTimeout(() => setJumpToEventId(undefined), 2500); const el = drawerRef.current; if (el) { - const target = el.querySelector(`[data-message-id="${targetId}"]`); + const target = el.querySelector(`[data-message-id="${anchorId}"]`); target?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } }, - [threadRootId] + [threadRootId, room, thread] ); // Map jumpToEventId to a focusItem index for useTimelineEventRenderer highlighting diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 1dd6d11c5..56f7145f2 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -39,6 +39,19 @@ export interface ProcessedEvent { willRenderDayDivider: boolean; } +/** Raw timeline indices for skipped events (reactions, edits, …) have no row; walk backward to a visible one. */ +export function getProcessedRowIndexForRawTimelineIndex( + processedEvents: ProcessedEvent[], + startRawIndex: number +): { rowIndex: number; focusRawIndex: number } | undefined { + if (startRawIndex < 0) return undefined; + for (let i = startRawIndex; i >= 0; i -= 1) { + const rowIndex = processedEvents.findIndex((e) => e.itemIndex === i); + if (rowIndex >= 0) return { rowIndex, focusRawIndex: i }; + } + return undefined; +} + const MESSAGE_EVENT_TYPES = new Set([ 'm.room.message', 'm.room.message.encrypted', diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index ffa0528ff..f252d95b5 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -679,6 +679,24 @@ export const reactionOrEditEvent = (mEvent: MatrixEvent): boolean => { return false; }; +/** + * Timeline rows skip reactions, edits, and other relation-only events. When jumping + * to a reply target, unwrap to the event that is actually rendered (root of an + * edit chain, message for a reaction annotation, etc.). + */ +export const unwrapRelationJumpTarget = (room: Room, eventId: string, maxHops = 24): string => { + let current = eventId; + for (let hop = 0; hop < maxHops; hop += 1) { + const ev = room.findEventById(current); + if (!ev) return current; + if (!reactionOrEditEvent(ev)) return current; + const related = ev.getRelation()?.event_id; + if (typeof related !== 'string' || related === current) return current; + current = related; + } + return current; +}; + export const getMentionContent = (userIds: string[], room: boolean): IMentions => { const mMentions: IMentions = {}; if (userIds.length > 0) {