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

Ask to refresh timeline when historical messages are imported (MSC2716) #8303

Conversation

MadLittleMods
Copy link
Contributor

@MadLittleMods MadLittleMods commented Apr 13, 2022

Ask to refresh timeline when historical messages are imported (MSC2716)

Associated matrix-js-sdk PR: matrix-org/matrix-js-sdk#2299

Beware: edge case

There is one edge case where the "History import detected" banner can re-appear after you have refreshed the timeline. matrix-js-sdk only persists /sync data to IndexDB every 5 minutes which means it can have a stale next_batch pagination token compared to the latest events you might be seeing on screen. So if you receive a marker event, it will show the banner to refresh the timeline. You can then use the button to refresh your timeline and the banner will disappear as expected. Then if you restart your entire Element client (like refreshing the entire browser), Element will syncFromCache with the /sync response from 5 minutes ago and query /sync with that old pagination token which will fetch the marker event again and show the banner again.

Dev notes

loadTimeline
refreshTimeline
onRoomTimelineReset

onRoomTimeline
onMessageListFillRequest
onMessageListUnfillRequest

Todo


Here's what your changelog entry will look like:

✨ Features

Preview: https://pr8303--matrix-react-sdk.netlify.app
⚠️ Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts.

@MadLittleMods MadLittleMods added the T-Enhancement New features, changes in functionality, performance boosts, user-facing improvements label Apr 13, 2022
@MadLittleMods MadLittleMods requested a review from a team as a code owner April 13, 2022 01:08
@MadLittleMods MadLittleMods marked this pull request as draft April 13, 2022 01:08
Comment on lines 152 to 156
// TODO: What's the best way to refresh the timeline? Something like
// `room.resetLiveTimeline(null, null);` although this just seems to
// clear the timeline. I also tried to split out
// `scrollbackFromPaginationToken` from the `scrollback` method in to
// paginate from the beginning of the room but it's just not right.
Copy link
Contributor Author

@MadLittleMods MadLittleMods Apr 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the best way to refresh the timeline? Something like room.resetLiveTimeline(null, null); although this just seems to clear the timeline.

I also tried to split out the logic from scrollback into a new scrollbackFromPaginationToken method in order to paginate from the latest in the room (instead of from the current paginationToken) and fill in the timeline again after it goes blank but the room just stays blank.

scrollbackFromPaginationToken refactor
    /**
     * Retrieve older messages from the given room and put them in the timeline.
     *
     * If this is called multiple times whilst a request is ongoing, the <i>same</i>
     * Promise will be returned. If there was a problem requesting scrollback, there
     * will be a small delay before another request can be made (to prevent tight-looping
     * when there is no connection).
     *
     * @param {Room} room The room to get older messages in.
     * @param {Integer} limit Optional. The maximum number of previous events to
     * pull in. Default: 30.
     * @param {module:client.callback} callback Optional.
     * @return {Promise} Resolves: Room. If you are at the beginning
     * of the timeline, <code>Room.oldState.paginationToken</code> will be
     * <code>null</code>.
     * @return {module:http-api.MatrixError} Rejects: with an error response.
     */
    public scrollback(room: Room, limit = 30, callback?: Callback): Promise<Room> {
        if (utils.isFunction(limit)) {
            callback = limit as any as Callback; // legacy
            limit = undefined;
        }
        let timeToWaitMs = 0;

        let info = this.ongoingScrollbacks[room.roomId] || {};
        if (info.promise) {
            return info.promise;
        } else if (info.errorTs) {
            const timeWaitedMs = Date.now() - info.errorTs;
            timeToWaitMs = Math.max(SCROLLBACK_DELAY_MS - timeWaitedMs, 0);
        }

        if (room.oldState.paginationToken === null) {
            return Promise.resolve(room); // already at the start.
        }
        // attempt to grab more events from the store first
        const numAdded = this.store.scrollback(room, limit).length;
        if (numAdded === limit) {
            // store contained everything we needed.
            return Promise.resolve(room);
        }
        // reduce the required number of events appropriately
        limit = limit - numAdded;

        const prom = Promise.resolve().then(async () => {
            try {
                // wait for a time before doing this request
                // (which may be 0 in order not to special case the code paths)
                await sleep(timeToWaitMs)

                await this.scrollbackFromPaginationToken({
                    room,
                    fromToken: room.oldState.paginationToken,
                    direction: Direction.Backward,
                    limit,
                })
                this.ongoingScrollbacks[room.roomId] = null;
                callback?.(null, room);
                return room;
            } catch(err) {
                this.ongoingScrollbacks[room.roomId] = {
                    errorTs: Date.now(),
                };
                callback?.(err);
                throw err;
            }
        });

        info = {
            promise: prom,
            errorTs: null,
        };

        this.ongoingScrollbacks[room.roomId] = info;
        return prom;
    }

    public async scrollbackFromPaginationToken({
        room,
        fromToken,
        direction,
        limit,
    }: {
        room: Room,
        fromToken: string | null,
        direction: Direction,
        limit?: number,
    }) {
        const res: IMessagesResponse = await this.createMessagesRequest(
            room.roomId,
            fromToken,
            limit,
            direction,
        );

        const matrixEvents = res.chunk.map(this.getEventMapper());
        if (res.state) {
            const stateEvents = res.state.map(this.getEventMapper());
            room.currentState.setUnknownStateEvents(stateEvents);
        }

        const [timelineEvents, threadedEvents] = this.partitionThreadedEvents(room, matrixEvents);

        room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
        await this.processThreadEvents(room, threadedEvents, true);

        room.oldState.paginationToken = res.end;
        if (res.chunk.length === 0) {
            room.oldState.paginationToken = null;
        }
        this.store.storeEvents(room, matrixEvents, res.end, true);
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assigned review to get some advice for this question ^

Otherwise just need to add tests for this PR

Copy link
Member

@turt2live turt2live left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clearing code review - will need to re-request when not-draft.

@codecov
Copy link

codecov bot commented Apr 13, 2022

Codecov Report

Merging #8303 (ed910bb) into develop (a4d3da7) will decrease coverage by 0.00%.
The diff coverage is 0.00%.

❗ Current head ed910bb differs from pull request most recent head 8d61226. Consider uploading reports for the commit 8d61226 to get more accurate results

@@             Coverage Diff             @@
##           develop    #8303      +/-   ##
===========================================
- Coverage    29.79%   29.78%   -0.01%     
===========================================
  Files          872      872              
  Lines        50006    50019      +13     
  Branches     12723    12728       +5     
===========================================
  Hits         14897    14897              
- Misses       35109    35122      +13     
Impacted Files Coverage Δ
src/components/structures/FilePanel.tsx 1.12% <0.00%> (ø)
src/components/structures/RoomStatusBar.tsx 6.93% <0.00%> (-1.03%) ⬇️
src/indexing/EventIndex.ts 0.59% <0.00%> (ø)

@MadLittleMods MadLittleMods changed the title Draft: Ask to refresh timeline when historical messages are imported (MSC2716) Ask to refresh timeline when historical messages are imported (MSC2716) Apr 14, 2022
@MadLittleMods MadLittleMods marked this pull request as ready for review April 14, 2022 06:55
@MadLittleMods MadLittleMods requested a review from a team April 14, 2022 06:55
Comment on lines 102 to 105
const client = this.context;
client.on("sync", this.onSyncStateChange);
client.on("Room.localEchoUpdated", this.onRoomLocalEchoUpdated);
client.on("Room.historyImportedWithinTimeline", this.onRoomHistoryImportedWithinTimeline);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add the context type definition around line L87 as string emitter keys are no longer allowed in TypedEventEmitter, TS thinks this.context is any here though

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also not listen on the room directly, re-emitters suck

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also not listen on the room directly, re-emitters suck

So I don't guess, what should we do? 🙇

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.props.room.on(...); but with componentDidUpdate comparing this.props.room with prevProps.room to remove old listener and setup a new one. This is all a lot nicer in hooks where its a one-liner.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this.props.room.on(...); but with componentDidUpdate comparing this.props.room with prevProps.room to remove old listener and setup a new one. This is all a lot nicer in hooks where its a one-liner.

Updated 👍

Please add the context type definition around line L87 as string emitter keys are no longer allowed in TypedEventEmitter

I'm not sure what to do here exactly. I've stopped using string keys. Is there anything further?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conversation continued at #8354 (comment)

this.setState({
timelineNeedsRefresh: false,
});
};
Copy link
Contributor Author

@MadLittleMods MadLittleMods Apr 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the behavior of all of this would be best served by an end-to-end test. It looks like we just added Cypress recently but it's missing all of the utility necessary to complete something like this.

  • Needs a command to create a user behind the scenes and visit Element as the user already signed in
  • Needs commands to setup rooms and send events
  • Needs a way to enable experimental_features in the homeserver.yaml template
  • Needs a way to add an application service registration in the Synapse instance. The MSC2716 /batch_send endpoint is only accessible from a AS token. No extra AS server needed, just the AS token configured.
  • Needs a way to interact with the homserver directly from the application service token to call /batch_send (probably via matrix-js-sdk)

I can't find an overall issue describing the need/want to use Cypress so it's unclear how much we want to move forward with it. I've had many troubles using Cypress with Gitter.

It seems like we have other e2e tests using Puppeteer but I'm guessing we want to move all of these to Cypress. Better place I should be adding some e2e tests or approaching this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conversation continued at #8354 (comment)

@MadLittleMods
Copy link
Contributor Author

Closing in favor of #8354 which has the same branch name as matrix-org/matrix-js-sdk#2299

MadLittleMods added a commit to matrix-org/matrix-js-sdk that referenced this pull request Apr 19, 2022
if (timelineSet !== this.props.timelineSet) return;

if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) {
if (this.canResetTimeline()) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a missed refactor. canResetTimeline by name seems aplicable here and the logic appears pretty equivalent.

public canResetTimeline = () => {
if (!this.messagePanel) {
return true;
}
return this.messagePanel.canResetTimeline();
};

public canResetTimeline = () => this.messagePanel?.current.isAtBottom();

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
T-Enhancement New features, changes in functionality, performance boosts, user-facing improvements
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants