Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 55d9dbf

Browse files
authored
Fix thread list jumping back down while scrolling (#9606)
* Fix timeline jumping after every thread update * Add tests to prevent this from occuring again
1 parent 5c60211 commit 55d9dbf

File tree

4 files changed

+218
-25
lines changed

4 files changed

+218
-25
lines changed

src/components/structures/MessagePanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
347347
return this.eventTiles[eventId]?.ref?.current;
348348
}
349349

350-
public getTileForEventId(eventId: string): UnwrappedEventTile {
351-
if (!this.eventTiles) {
350+
public getTileForEventId(eventId?: string): UnwrappedEventTile | undefined {
351+
if (!this.eventTiles || !eventId) {
352352
return undefined;
353353
}
354354
return this.eventTiles[eventId];

src/components/structures/ThreadPanel.tsx

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ limitations under the License.
1616

1717
import React, { useContext, useEffect, useRef, useState } from 'react';
1818
import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set';
19-
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
19+
import { Thread } from 'matrix-js-sdk/src/models/thread';
2020
import { Room } from 'matrix-js-sdk/src/models/room';
2121

2222
import BaseCard from "../views/right_panel/BaseCard";
@@ -206,18 +206,6 @@ const ThreadPanel: React.FC<IProps> = ({
206206
});
207207
}, [mxClient, roomId]);
208208

209-
useEffect(() => {
210-
function refreshTimeline() {
211-
timelinePanel?.current.refreshTimeline();
212-
}
213-
214-
room?.on(ThreadEvent.Update, refreshTimeline);
215-
216-
return () => {
217-
room?.removeListener(ThreadEvent.Update, refreshTimeline);
218-
};
219-
}, [room, mxClient, timelineSet]);
220-
221209
useEffect(() => {
222210
if (room) {
223211
if (filterOption === ThreadFilterType.My) {

src/components/structures/TimelinePanel.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { RoomMember, RoomMemberEvent } from 'matrix-js-sdk/src/models/room-membe
2727
import { debounce, throttle } from 'lodash';
2828
import { logger } from "matrix-js-sdk/src/logger";
2929
import { ClientEvent } from "matrix-js-sdk/src/client";
30-
import { Thread } from 'matrix-js-sdk/src/models/thread';
30+
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
3131
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
3232
import { MatrixError } from 'matrix-js-sdk/src/http-api';
3333
import { ReadReceipt } from 'matrix-js-sdk/src/models/read-receipt';
@@ -297,6 +297,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
297297
cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
298298
cli.on(MatrixEventEvent.Replaced, this.onEventReplaced);
299299
cli.on(ClientEvent.Sync, this.onSync);
300+
301+
this.props.timelineSet.room?.on(ThreadEvent.Update, this.onThreadUpdate);
300302
}
301303

302304
public componentDidMount() {
@@ -325,6 +327,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
325327
logger.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue");
326328
}
327329

330+
this.props.timelineSet.room?.off(ThreadEvent.Update, this.onThreadUpdate);
331+
this.props.timelineSet.room?.on(ThreadEvent.Update, this.onThreadUpdate);
332+
328333
const differentEventId = prevProps.eventId != this.props.eventId;
329334
const differentHighlightedEventId = prevProps.highlightedEventId != this.props.highlightedEventId;
330335
const differentAvoidJump = prevProps.eventScrollIntoView && !this.props.eventScrollIntoView;
@@ -366,6 +371,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
366371
client.removeListener(MatrixEventEvent.Replaced, this.onEventReplaced);
367372
client.removeListener(MatrixEventEvent.VisibilityChange, this.onEventVisibilityChange);
368373
client.removeListener(ClientEvent.Sync, this.onSync);
374+
this.props.timelineSet.room?.removeListener(ThreadEvent.Update, this.onThreadUpdate);
369375
}
370376
}
371377

@@ -742,6 +748,25 @@ class TimelinePanel extends React.Component<IProps, IState> {
742748
this.forceUpdate();
743749
};
744750

751+
private onThreadUpdate = (thread: Thread): void => {
752+
if (this.unmounted) {
753+
return;
754+
}
755+
756+
// ignore events for other rooms
757+
const roomId = thread.roomId;
758+
if (roomId !== this.props.timelineSet.room?.roomId) {
759+
return;
760+
}
761+
762+
// we could skip an update if the event isn't in our timeline,
763+
// but that's probably an early optimisation.
764+
const tile = this.messagePanel.current?.getTileForEventId(thread.id);
765+
if (tile) {
766+
tile.forceUpdate();
767+
}
768+
};
769+
745770
// Called whenever the visibility of an event changes, as per
746771
// MSC3531. We typically need to re-render the tile.
747772
private onEventVisibilityChange = (ev: MatrixEvent): void => {

test/components/structures/TimelinePanel-test.tsx

Lines changed: 189 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,34 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import React from 'react';
17+
import { render, RenderResult } from "@testing-library/react";
1818
// eslint-disable-next-line deprecate/import
1919
import { mount, ReactWrapper } from "enzyme";
20-
import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
2120
import { MessageEvent } from 'matrix-events-sdk';
21+
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
2222
import {
2323
EventTimelineSet,
2424
EventType,
25+
MatrixClient,
2526
MatrixEvent,
2627
PendingEventOrdering,
2728
Room,
2829
} from 'matrix-js-sdk/src/matrix';
29-
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
30-
import { render, RenderResult } from "@testing-library/react";
30+
import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
31+
import {
32+
FeatureSupport,
33+
Thread,
34+
THREAD_RELATION_TYPE,
35+
ThreadEvent,
36+
ThreadFilterType,
37+
} from "matrix-js-sdk/src/models/thread";
38+
import React from 'react';
3139

32-
import { mkRoom, stubClient } from "../../test-utils";
3340
import TimelinePanel from '../../../src/components/structures/TimelinePanel';
41+
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
3442
import { MatrixClientPeg } from '../../../src/MatrixClientPeg';
3543
import SettingsStore from "../../../src/settings/SettingsStore";
44+
import { mkRoom, stubClient } from "../../test-utils";
3645

3746
const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent => {
3847
const receiptContent = {
@@ -52,7 +61,7 @@ const getProps = (room: Room, events: MatrixEvent[]): TimelinePanel["props"] =>
5261
timelineSet.getLiveTimeline = () => timeline;
5362
timelineSet.getTimelineForEvent = () => timeline;
5463
timelineSet.getPendingEvents = () => events;
55-
timelineSet.room.getEventReadUpTo = () => events[1].getId();
64+
timelineSet.room!.getEventReadUpTo = () => events[1].getId() ?? null;
5665

5766
return {
5867
timelineSet,
@@ -67,7 +76,7 @@ const renderPanel = (room: Room, events: MatrixEvent[]): RenderResult => {
6776
};
6877

6978
const mockEvents = (room: Room, count = 2): MatrixEvent[] => {
70-
const events = [];
79+
const events: MatrixEvent[] = [];
7180
for (let index = 0; index < count; index++) {
7281
events.push(new MatrixEvent({
7382
room_id: room.roomId,
@@ -89,7 +98,7 @@ describe('TimelinePanel', () => {
8998
describe('read receipts and markers', () => {
9099
it('should forget the read marker when asked to', () => {
91100
const cli = MatrixClientPeg.get();
92-
const readMarkersSent = [];
101+
const readMarkersSent: string[] = [];
93102

94103
// Track calls to setRoomReadMarkers
95104
cli.setRoomReadMarkers = (_roomId, rmEventId, _a, _b) => {
@@ -111,7 +120,7 @@ describe('TimelinePanel', () => {
111120
});
112121

113122
const roomId = "#room:example.com";
114-
const userId = cli.credentials.userId;
123+
const userId = cli.credentials.userId!;
115124
const room = new Room(
116125
roomId,
117126
cli,
@@ -192,4 +201,175 @@ describe('TimelinePanel', () => {
192201
rerender(<TimelinePanel {...props} />);
193202
expect(props.onEventScrolledIntoView).toHaveBeenCalledWith(events[1].getId());
194203
});
204+
205+
describe("when a thread updates", () => {
206+
let client: MatrixClient;
207+
let room: Room;
208+
let allThreads: EventTimelineSet;
209+
let root: MatrixEvent;
210+
let reply1: MatrixEvent;
211+
let reply2: MatrixEvent;
212+
213+
beforeEach(() => {
214+
client = MatrixClientPeg.get();
215+
216+
Thread.hasServerSideSupport = FeatureSupport.Stable;
217+
client.supportsExperimentalThreads = () => true;
218+
const getValueCopy = SettingsStore.getValue;
219+
SettingsStore.getValue = jest.fn().mockImplementation((name: string) => {
220+
if (name === "feature_thread") return true;
221+
return getValueCopy(name);
222+
});
223+
224+
room = new Room("roomId", client, "userId");
225+
allThreads = new EventTimelineSet(room, {
226+
pendingEvents: false,
227+
}, undefined, undefined, ThreadFilterType.All);
228+
const timeline = new EventTimeline(allThreads);
229+
allThreads.getLiveTimeline = () => timeline;
230+
allThreads.getTimelineForEvent = () => timeline;
231+
232+
reply1 = new MatrixEvent({
233+
room_id: room.roomId,
234+
event_id: 'event_reply_1',
235+
type: EventType.RoomMessage,
236+
user_id: "userId",
237+
content: MessageEvent.from(`ReplyEvent1`).serialize().content,
238+
});
239+
240+
reply2 = new MatrixEvent({
241+
room_id: room.roomId,
242+
event_id: 'event_reply_2',
243+
type: EventType.RoomMessage,
244+
user_id: "userId",
245+
content: MessageEvent.from(`ReplyEvent2`).serialize().content,
246+
});
247+
248+
root = new MatrixEvent({
249+
room_id: room.roomId,
250+
event_id: 'event_root_1',
251+
type: EventType.RoomMessage,
252+
user_id: "userId",
253+
content: MessageEvent.from(`RootEvent`).serialize().content,
254+
});
255+
256+
const eventMap: { [key: string]: MatrixEvent } = {
257+
[root.getId()!]: root,
258+
[reply1.getId()!]: reply1,
259+
[reply2.getId()!]: reply2,
260+
};
261+
262+
room.findEventById = (eventId: string) => eventMap[eventId];
263+
client.fetchRoomEvent = async (roomId: string, eventId: string) =>
264+
roomId === room.roomId ? eventMap[eventId]?.event : {};
265+
});
266+
267+
it('updates thread previews', async () => {
268+
root.setUnsigned({
269+
"m.relations": {
270+
[THREAD_RELATION_TYPE.name]: {
271+
"latest_event": reply1.event,
272+
"count": 1,
273+
"current_user_participated": true,
274+
},
275+
},
276+
});
277+
278+
const thread = room.createThread(root.getId()!, root, [], true);
279+
// So that we do not have to mock the thread loading
280+
thread.initialEventsFetched = true;
281+
// @ts-ignore
282+
thread.fetchEditsWhereNeeded = () => Promise.resolve();
283+
await thread.addEvent(reply1, true);
284+
await allThreads.getLiveTimeline().addEvent(thread.rootEvent!, true);
285+
const replyToEvent = jest.spyOn(thread, "replyToEvent", "get");
286+
287+
const dom = render(
288+
<MatrixClientContext.Provider value={client}>
289+
<TimelinePanel
290+
timelineSet={allThreads}
291+
manageReadReceipts
292+
sendReadReceiptOnLoad
293+
/>
294+
</MatrixClientContext.Provider>,
295+
);
296+
await dom.findByText("RootEvent");
297+
await dom.findByText("ReplyEvent1");
298+
expect(replyToEvent).toHaveBeenCalled();
299+
300+
root.setUnsigned({
301+
"m.relations": {
302+
[THREAD_RELATION_TYPE.name]: {
303+
"latest_event": reply2.event,
304+
"count": 2,
305+
"current_user_participated": true,
306+
},
307+
},
308+
});
309+
310+
replyToEvent.mockClear();
311+
await thread.addEvent(reply2, false, true);
312+
await dom.findByText("RootEvent");
313+
await dom.findByText("ReplyEvent2");
314+
expect(replyToEvent).toHaveBeenCalled();
315+
});
316+
317+
it('ignores thread updates for unknown threads', async () => {
318+
root.setUnsigned({
319+
"m.relations": {
320+
[THREAD_RELATION_TYPE.name]: {
321+
"latest_event": reply1.event,
322+
"count": 1,
323+
"current_user_participated": true,
324+
},
325+
},
326+
});
327+
328+
const realThread = room.createThread(root.getId()!, root, [], true);
329+
// So that we do not have to mock the thread loading
330+
realThread.initialEventsFetched = true;
331+
// @ts-ignore
332+
realThread.fetchEditsWhereNeeded = () => Promise.resolve();
333+
await realThread.addEvent(reply1, true);
334+
await allThreads.getLiveTimeline().addEvent(realThread.rootEvent!, true);
335+
const replyToEvent = jest.spyOn(realThread, "replyToEvent", "get");
336+
337+
// @ts-ignore
338+
const fakeThread1: Thread = {
339+
id: undefined!,
340+
get roomId(): string {
341+
return room.roomId;
342+
},
343+
};
344+
345+
const fakeRoom = new Room("thisroomdoesnotexist", client, "userId");
346+
// @ts-ignore
347+
const fakeThread2: Thread = {
348+
id: root.getId()!,
349+
get roomId(): string {
350+
return fakeRoom.roomId;
351+
},
352+
};
353+
354+
const dom = render(
355+
<MatrixClientContext.Provider value={client}>
356+
<TimelinePanel
357+
timelineSet={allThreads}
358+
manageReadReceipts
359+
sendReadReceiptOnLoad
360+
/>
361+
</MatrixClientContext.Provider>,
362+
);
363+
await dom.findByText("RootEvent");
364+
await dom.findByText("ReplyEvent1");
365+
expect(replyToEvent).toHaveBeenCalled();
366+
367+
replyToEvent.mockClear();
368+
room.emit(ThreadEvent.Update, fakeThread1);
369+
room.emit(ThreadEvent.Update, fakeThread2);
370+
await dom.findByText("ReplyEvent1");
371+
expect(replyToEvent).not.toHaveBeenCalled();
372+
replyToEvent.mockClear();
373+
});
374+
});
195375
});

0 commit comments

Comments
 (0)