Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-jump-to-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fixed jumpting to arbitrary events (e.g. reactions, edits, pins, leaves/joins).
39 changes: 32 additions & 7 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
},
});
Expand Down
38 changes: 30 additions & 8 deletions src/app/features/room/ThreadDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/app/hooks/timeline/useProcessedTimeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
18 changes: 18 additions & 0 deletions src/app/utils/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading