Skip to content

Commit 8a36e22

Browse files
authored
Fix jumping to arbitrary events (#759)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description <!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. --> Allows jumping to state events/reactions/edits/etc. #### Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. -->
2 parents 224623f + a84e073 commit 8a36e22

5 files changed

Lines changed: 98 additions & 15 deletions

File tree

.changeset/fix-jump-to-events.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: patch
3+
---
4+
5+
Fixed jumpting to arbitrary events (e.g. reactions, edits, pins, leaves/joins).

src/app/features/room/RoomTimeline.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
factoryRenderLinkifyWithMention,
4646
} from '$plugins/react-custom-html-parser';
4747
import { today, yesterday, timeDayMonthYear } from '$utils/time';
48+
import { unwrapRelationJumpTarget } from '$utils/room';
4849
import { useMemberEventParser } from '$hooks/useMemberEventParser';
4950
import { usePowerLevelsContext } from '$hooks/usePowerLevels';
5051
import { useRoomCreators } from '$hooks/useRoomCreators';
@@ -77,8 +78,11 @@ import {
7778
} from '$utils/timeline';
7879
import { useTimelineSync } from '$hooks/timeline/useTimelineSync';
7980
import { useTimelineActions } from '$hooks/timeline/useTimelineActions';
80-
import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline';
81-
import { useProcessedTimeline } from '$hooks/timeline/useProcessedTimeline';
81+
import {
82+
useProcessedTimeline,
83+
getProcessedRowIndexForRawTimelineIndex,
84+
type ProcessedEvent,
85+
} from '$hooks/timeline/useProcessedTimeline';
8286
import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer';
8387
import * as css from './RoomTimeline.css';
8488

@@ -494,19 +498,40 @@ export function RoomTimeline({
494498
setOpenThread: setOpenThread as unknown as (threadId: string | undefined) => void,
495499
handleEdit,
496500
handleOpenEvent: (id) => {
497-
const evtTimeline = getEventTimeline(room, id);
501+
const anchorId = unwrapRelationJumpTarget(room, id);
502+
let evtTimeline = getEventTimeline(room, anchorId);
503+
let resolvedForIndex = anchorId;
504+
if (!evtTimeline && anchorId !== id) {
505+
evtTimeline = getEventTimeline(room, id);
506+
resolvedForIndex = id;
507+
}
498508
const absoluteIndex = evtTimeline
499-
? getEventIdAbsoluteIndex(timelineSync.timeline.linkedTimelines, evtTimeline, id)
509+
? getEventIdAbsoluteIndex(
510+
timelineSync.timeline.linkedTimelines,
511+
evtTimeline,
512+
resolvedForIndex
513+
)
500514
: undefined;
501515

502516
if (typeof absoluteIndex === 'number') {
503-
const processedIndex = getRawIndexToProcessedIndex(absoluteIndex);
517+
let processedIndex = getRawIndexToProcessedIndex(absoluteIndex);
518+
let focusRawIndex = absoluteIndex;
519+
if (processedIndex === undefined) {
520+
const nearest = getProcessedRowIndexForRawTimelineIndex(
521+
processedEventsRef.current,
522+
absoluteIndex
523+
);
524+
if (nearest) {
525+
processedIndex = nearest.rowIndex;
526+
focusRawIndex = nearest.focusRawIndex;
527+
}
528+
}
504529
if (vListRef.current && processedIndex !== undefined) {
505530
vListRef.current.scrollToIndex(processedIndex, { align: 'center' });
506531
}
507-
timelineSync.setFocusItem({ index: absoluteIndex, scrollTo: false, highlight: true });
532+
timelineSync.setFocusItem({ index: focusRawIndex, scrollTo: false, highlight: true });
508533
} else {
509-
timelineSync.loadEventTimeline(id);
534+
timelineSync.loadEventTimeline(anchorId);
510535
}
511536
},
512537
});

src/app/features/room/ThreadDrawer.tsx

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ import {
2323
makeMentionCustomProps,
2424
renderMatrixMention,
2525
} from '$plugins/react-custom-html-parser';
26-
import { getEditedEvent, getMemberDisplayName, reactionOrEditEvent } from '$utils/room';
26+
import {
27+
getEditedEvent,
28+
getMemberDisplayName,
29+
reactionOrEditEvent,
30+
unwrapRelationJumpTarget,
31+
} from '$utils/room';
2732
import { getMxIdLocalPart, toggleReaction } from '$utils/matrix';
2833
import { useMatrixClient } from '$hooks/useMatrixClient';
2934
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
@@ -49,8 +54,11 @@ import { useIgnoredUsers } from '$hooks/useIgnoredUsers';
4954
import { useGetMemberPowerTag } from '$hooks/useMemberPowerTag';
5055
import { useMemberEventParser } from '$hooks/useMemberEventParser';
5156
import { useMessageEdit } from '$hooks/useMessageEdit';
52-
import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline';
53-
import { useProcessedTimeline } from '$hooks/timeline/useProcessedTimeline';
57+
import {
58+
useProcessedTimeline,
59+
getProcessedRowIndexForRawTimelineIndex,
60+
type ProcessedEvent,
61+
} from '$hooks/timeline/useProcessedTimeline';
5462
import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer';
5563
import { RoomInput } from './RoomInput';
5664
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
@@ -627,18 +635,32 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
627635
(evt) => {
628636
const targetId = evt.currentTarget.getAttribute('data-event-id');
629637
if (!targetId) return;
630-
const isRoot = targetId === threadRootId;
631-
const isInReplies = processedEventsRef.current.some((e) => e.id === targetId);
638+
let anchorId = unwrapRelationJumpTarget(room, targetId);
639+
const threadLive = thread?.timelineSet.getLiveTimeline();
640+
const threadEvents = threadLive?.getEvents();
641+
const rawIndex = threadEvents?.findIndex((e) => e.getId() === anchorId) ?? -1;
642+
if (rawIndex >= 0) {
643+
const nearest = getProcessedRowIndexForRawTimelineIndex(
644+
processedEventsRef.current,
645+
rawIndex
646+
);
647+
if (nearest) {
648+
const rowEv = processedEventsRef.current[nearest.rowIndex];
649+
if (rowEv) anchorId = rowEv.id;
650+
}
651+
}
652+
const isRoot = anchorId === threadRootId;
653+
const isInReplies = processedEventsRef.current.some((e) => e.id === anchorId);
632654
if (!isRoot && !isInReplies) return;
633-
setJumpToEventId(targetId);
655+
setJumpToEventId(anchorId);
634656
setTimeout(() => setJumpToEventId(undefined), 2500);
635657
const el = drawerRef.current;
636658
if (el) {
637-
const target = el.querySelector(`[data-message-id="${targetId}"]`);
659+
const target = el.querySelector(`[data-message-id="${anchorId}"]`);
638660
target?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
639661
}
640662
},
641-
[threadRootId]
663+
[threadRootId, room, thread]
642664
);
643665

644666
// Map jumpToEventId to a focusItem index for useTimelineEventRenderer highlighting

src/app/hooks/timeline/useProcessedTimeline.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ export interface ProcessedEvent {
3939
willRenderDayDivider: boolean;
4040
}
4141

42+
/** Raw timeline indices for skipped events (reactions, edits, …) have no row; walk backward to a visible one. */
43+
export function getProcessedRowIndexForRawTimelineIndex(
44+
processedEvents: ProcessedEvent[],
45+
startRawIndex: number
46+
): { rowIndex: number; focusRawIndex: number } | undefined {
47+
if (startRawIndex < 0) return undefined;
48+
for (let i = startRawIndex; i >= 0; i -= 1) {
49+
const rowIndex = processedEvents.findIndex((e) => e.itemIndex === i);
50+
if (rowIndex >= 0) return { rowIndex, focusRawIndex: i };
51+
}
52+
return undefined;
53+
}
54+
4255
const MESSAGE_EVENT_TYPES = new Set([
4356
'm.room.message',
4457
'm.room.message.encrypted',

src/app/utils/room.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,24 @@ export const reactionOrEditEvent = (mEvent: MatrixEvent): boolean => {
679679
return false;
680680
};
681681

682+
/**
683+
* Timeline rows skip reactions, edits, and other relation-only events. When jumping
684+
* to a reply target, unwrap to the event that is actually rendered (root of an
685+
* edit chain, message for a reaction annotation, etc.).
686+
*/
687+
export const unwrapRelationJumpTarget = (room: Room, eventId: string, maxHops = 24): string => {
688+
let current = eventId;
689+
for (let hop = 0; hop < maxHops; hop += 1) {
690+
const ev = room.findEventById(current);
691+
if (!ev) return current;
692+
if (!reactionOrEditEvent(ev)) return current;
693+
const related = ev.getRelation()?.event_id;
694+
if (typeof related !== 'string' || related === current) return current;
695+
current = related;
696+
}
697+
return current;
698+
};
699+
682700
export const getMentionContent = (userIds: string[], room: boolean): IMentions => {
683701
const mMentions: IMentions = {};
684702
if (userIds.length > 0) {

0 commit comments

Comments
 (0)