diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index cb72e22d9b..3463bcd304 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -18,9 +18,6 @@ import React, { createRef, KeyboardEvent } from 'react'; import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; import { Room, RoomEvent } from 'matrix-js-sdk/src/models/room'; import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; -import { Direction } from 'matrix-js-sdk/src/models/event-timeline'; -import { IRelationsRequestOpts } from 'matrix-js-sdk/src/@types/requests'; import { logger } from 'matrix-js-sdk/src/logger'; import classNames from 'classnames'; @@ -236,10 +233,8 @@ export default class ThreadView extends React.Component { thread_id: thread.id, }); thread.emit(ThreadEvent.ViewThread); - await thread.fetchInitialEvents(); this.updateThreadRelation(); - this.nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward); - this.timelinePanel.current?.refreshTimeline(); + this.timelinePanel.current?.refreshTimeline(this.props.initialEvent?.getId()); } private setupThreadListeners(thread?: Thread | undefined, oldThread?: Thread | undefined): void { @@ -293,40 +288,6 @@ export default class ThreadView extends React.Component { } }; - private nextBatch: string | undefined | null = null; - - private onPaginationRequest = async ( - timelineWindow: TimelineWindow | null, - direction = Direction.Backward, - limit = 20, - ): Promise => { - if (!Thread.hasServerSideSupport && timelineWindow) { - timelineWindow.extend(direction, limit); - return true; - } - - const opts: IRelationsRequestOpts = { - limit, - }; - - if (this.nextBatch) { - opts.from = this.nextBatch; - } - - let nextBatch: string | null | undefined = null; - if (this.state.thread) { - const response = await this.state.thread.fetchEvents(opts); - nextBatch = response.nextBatch; - this.nextBatch = nextBatch; - } - - // Advances the marker on the TimelineWindow to define the correct - // window of events to display on screen - timelineWindow?.extend(direction, limit); - - return !!nextBatch; - }; - private onFileDrop = (dataTransfer: DataTransfer) => { const roomId = this.props.mxEvent.getRoomId(); if (roomId) { @@ -409,7 +370,6 @@ export default class ThreadView extends React.Component { highlightedEventId={highlightedEventId} eventScrollIntoView={this.props.initialEventScrollIntoView} onEventScrolledIntoView={this.resetJumpToEvent} - onPaginationRequest={this.onPaginationRequest} /> ; } else { diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 7ddeca11bc..fe7ecc8248 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1409,24 +1409,28 @@ class TimelinePanel extends React.Component { // quite slow. So we detect that situation and shortcut straight to // calling _reloadEvents and updating the state. - const timeline = this.props.timelineSet.getTimelineForEvent(eventId); - if (timeline) { - // This is a hot-path optimization by skipping a promise tick - // by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline - this.timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time + // This is a hot-path optimization by skipping a promise tick + // by repeating a no-op sync branch in + // TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline + if (this.props.timelineSet.getTimelineForEvent(eventId)) { + // if we've got an eventId, and the timeline exists, we can skip + // the promise tick. + this.timelineWindow.load(eventId, INITIAL_SIZE); + // in this branch this method will happen in sync time onLoaded(); - } else { - const prom = this.timelineWindow.load(eventId, INITIAL_SIZE); - this.buildLegacyCallEventGroupers(); - this.setState({ - events: [], - liveEvents: [], - canBackPaginate: false, - canForwardPaginate: false, - timelineLoading: true, - }); - prom.then(onLoaded, onError); + return; } + + const prom = this.timelineWindow.load(eventId, INITIAL_SIZE); + this.buildLegacyCallEventGroupers(); + this.setState({ + events: [], + liveEvents: [], + canBackPaginate: false, + canForwardPaginate: false, + timelineLoading: true, + }); + prom.then(onLoaded, onError); } // handle the completion of a timeline load or localEchoUpdate, by @@ -1443,8 +1447,8 @@ class TimelinePanel extends React.Component { } // Force refresh the timeline before threads support pending events - public refreshTimeline(): void { - this.loadTimeline(); + public refreshTimeline(eventId?: string): void { + this.loadTimeline(eventId, undefined, undefined, false); this.reloadEvents(); } diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index eca720412d..f6961b627e 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -382,7 +382,13 @@ export default class MessageContextMenu extends React.Component public render(): JSX.Element { const cli = MatrixClientPeg.get(); const me = cli.getUserId(); - const { mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain } = this.props; + const { + mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain, + ...other + } = this.props; + delete other.getRelationsForEvent; + delete other.permalinkCreator; + const eventStatus = mxEvent.status; const unsentReactionsCount = this.getUnsentReactions().length; const contentActionable = isContentActionable(mxEvent); @@ -747,7 +753,7 @@ export default class MessageContextMenu extends React.Component return ( private renderThreadInfo(): React.ReactNode { if (this.state.thread?.id === this.props.mxEvent.getId()) { - return ; + return ; } if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) { @@ -1528,9 +1532,11 @@ export class UnwrappedEventTile extends React.Component // Wrap all event tiles with the tile error boundary so that any throws even during construction are captured const SafeEventTile = forwardRef((props: EventTileProps, ref: RefObject) => { - return - - ; + return <> + + + + ; }); export default SafeEventTile; diff --git a/src/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx index c14a4cc9e1..83dbf0d45a 100644 --- a/src/components/views/rooms/ThreadSummary.tsx +++ b/src/components/views/rooms/ThreadSummary.tsx @@ -37,7 +37,7 @@ interface IProps { thread: Thread; } -const ThreadSummary = ({ mxEvent, thread }: IProps) => { +const ThreadSummary = ({ mxEvent, thread, ...props }: IProps) => { const roomContext = useContext(RoomContext); const cardContext = useContext(CardContext); const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length); @@ -50,6 +50,7 @@ const ThreadSummary = ({ mxEvent, thread }: IProps) => { return ( { defaultDispatcher.dispatch({ @@ -94,7 +95,9 @@ export const ThreadMessagePreview = ({ thread, showDisplayname = false }: IPrevi await cli.decryptEventIfNeeded(lastReply); return MessagePreviewStore.instance.generatePreviewForEvent(lastReply); }, [lastReply, content]); - if (!preview) return null; + if (!preview || !lastReply) { + return null; + } return <> e.getEffectiveEvent() as IRoomEvent), diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index d8cf66e557..69e322ac6d 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -238,8 +238,11 @@ export async function fetchInitialEvent( ) { const threadId = initialEvent.threadRootId; const room = client.getRoom(roomId); + const mapper = client.getEventMapper(); + const rootEvent = room.findEventById(threadId) + ?? mapper(await client.fetchRoomEvent(roomId, threadId)); try { - room.createThread(threadId, room.findEventById(threadId), [initialEvent], true); + room.createThread(threadId, rootEvent, [initialEvent], true); } catch (e) { logger.warn("Could not find root event: " + threadId); } diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index 6de3a262cd..dd0cda23b4 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -14,16 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { act, render } from "@testing-library/react"; +import React from "react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; -import React from "react"; import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { getRoomContext, mkMessage, stubClient } from "../../../test-utils"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { getRoomContext, mkEvent, mkMessage, stubClient } from "../../../test-utils"; import { mkThread } from "../../../test-utils/threads"; describe("EventTile", () => { @@ -52,9 +55,11 @@ describe("EventTile", () => { timelineRenderingType: renderingType, }); return render( - - - , + + + + , + , ); } @@ -69,6 +74,8 @@ describe("EventTile", () => { }); jest.spyOn(client, "getRoom").mockReturnValue(room); + jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue(); + jest.spyOn(SettingsStore, "getValue").mockImplementation(name => name === "feature_thread"); mxEvent = mkMessage({ room: room.roomId, @@ -78,6 +85,40 @@ describe("EventTile", () => { }); }); + describe("EventTile thread summary", () => { + beforeEach(() => { + jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true); + }); + + it("removes the thread summary when thread is deleted", async () => { + const { rootEvent, events: [, reply] } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@alice:example.org"], + length: 2, // root + 1 answer + }); + getComponent({ + mxEvent: rootEvent, + }, TimelineRenderingType.Room); + + await waitFor(() => expect(screen.queryByTestId("thread-summary")).not.toBeNull()); + + const redaction = mkEvent({ + event: true, + type: EventType.RoomRedaction, + user: "@alice:example.org", + room: room.roomId, + redacts: reply.getId(), + content: {}, + }); + + act(() => room.processThreadedEvents([redaction], false)); + + await waitFor(() => expect(screen.queryByTestId("thread-summary")).toBeNull()); + }); + }); + describe("EventTile renderingType: ThreadsList", () => { beforeEach(() => { const { rootEvent } = mkThread({ diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index ca07edb0bd..85045a6da8 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -212,6 +212,7 @@ type MakeEventPassThruProps = { }; type MakeEventProps = MakeEventPassThruProps & { type: string; + redacts?: string; content: IContent; room?: Room["roomId"]; // to-device messages are roomless // eslint-disable-next-line camelcase @@ -245,6 +246,7 @@ export function mkEvent(opts: MakeEventProps): MatrixEvent { event_id: "$" + Math.random() + "-" + Math.random(), origin_server_ts: opts.ts ?? 0, unsigned: opts.unsigned, + redacts: opts.redacts, }; if (opts.skey !== undefined) { event.state_key = opts.skey; diff --git a/test/test-utils/threads.ts b/test/test-utils/threads.ts index 2259527178..3b07c45051 100644 --- a/test/test-utils/threads.ts +++ b/test/test-utils/threads.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, MatrixEvent, MatrixEventEvent, RelationType, Room } from "matrix-js-sdk/src/matrix"; import { Thread } from "matrix-js-sdk/src/models/thread"; import { mkMessage, MessageEventProps } from "./test-utils"; @@ -115,10 +115,18 @@ export const mkThread = ({ ts, currentUserId: client.getUserId(), }); + expect(rootEvent).toBeTruthy(); + + for (const evt of events) { + room?.reEmitter.reEmit(evt, [ + MatrixEventEvent.BeforeRedaction, + ]); + } const thread = room.createThread(rootEvent.getId(), rootEvent, events, true); // So that we do not have to mock the thread loading thread.initialEventsFetched = true; + thread.addEvents(events, true); return { thread, rootEvent, events }; }; diff --git a/test/utils/EventUtils-test.ts b/test/utils/EventUtils-test.ts index 120d47aa1d..644f274c19 100644 --- a/test/utils/EventUtils-test.ts +++ b/test/utils/EventUtils-test.ts @@ -18,21 +18,27 @@ import { M_LOCATION } from "matrix-js-sdk/src/@types/location"; import { EventStatus, EventType, + IEvent, + MatrixClient, MatrixEvent, MsgType, + PendingEventOrdering, RelationType, + Room, } from "matrix-js-sdk/src/matrix"; +import { Thread } from "matrix-js-sdk/src/models/thread"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { canCancel, canEditContent, canEditOwnEvent, + fetchInitialEvent, isContentActionable, isLocationEvent, isVoiceMessage, } from "../../src/utils/EventUtils"; -import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent } from "../test-utils"; +import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../test-utils"; describe('EventUtils', () => { const userId = '@user:server'; @@ -336,4 +342,92 @@ describe('EventUtils', () => { expect(canCancel(status)).toBe(false); }); }); + + describe("fetchInitialEvent", () => { + const ROOM_ID = "!roomId:example.org"; + let room: Room; + let client: MatrixClient; + + const NORMAL_EVENT = "$normalEvent"; + const THREAD_ROOT = "$threadRoot"; + const THREAD_REPLY = "$threadReply"; + + const events: Record> = { + [NORMAL_EVENT]: { + event_id: NORMAL_EVENT, + type: EventType.RoomMessage, + content: { + "body": "Classic event", + "msgtype": MsgType.Text, + }, + }, + [THREAD_ROOT]: { + event_id: THREAD_ROOT, + type: EventType.RoomMessage, + content: { + "body": "Thread root", + "msgtype": "m.text", + }, + unsigned: { + "m.relations": { + [RelationType.Thread]: { + latest_event: { + event_id: THREAD_REPLY, + type: EventType.RoomMessage, + content: { + "body": "Thread reply", + "msgtype": MsgType.Text, + "m.relates_to": { + event_id: "$threadRoot", + rel_type: RelationType.Thread, + }, + }, + }, + count: 1, + current_user_participated: false, + }, + }, + }, + }, + [THREAD_REPLY]: { + event_id: THREAD_REPLY, + type: EventType.RoomMessage, + content: { + "body": "Thread reply", + "msgtype": MsgType.Text, + "m.relates_to": { + event_id: THREAD_ROOT, + rel_type: RelationType.Thread, + }, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + client = MatrixClientPeg.get(); + + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true); + jest.spyOn(client, "getRoom").mockReturnValue(room); + jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => { + return events[eventId] ?? Promise.reject(); + }); + }); + + it("returns null for unknown events", async () => { + expect(await fetchInitialEvent(client, room.roomId, "$UNKNOWN")).toBeNull(); + expect(await fetchInitialEvent(client, room.roomId, NORMAL_EVENT)).toBeInstanceOf(MatrixEvent); + }); + + it("creates a thread when needed", async () => { + await fetchInitialEvent(client, room.roomId, THREAD_REPLY); + expect(room.getThread(THREAD_ROOT)).toBeInstanceOf(Thread); + }); + }); });