diff --git a/cypress/e2e/x-msc2716-historical-import/historical-import.spec.ts b/cypress/e2e/x-msc2716-historical-import/historical-import.spec.ts new file mode 100644 index 00000000000..94a1d17322b --- /dev/null +++ b/cypress/e2e/x-msc2716-historical-import/historical-import.spec.ts @@ -0,0 +1,819 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import { MatrixClient } from "../../global"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; + +/** + * Create the join state events necessary for the given virtual user IDs to send + * messages in the room. Usuable in a /batch_send requests. + * @param {string[]} virtualUserIDs A list of virtualUserIds to create join events for + * @param {number} insertTimestamp A unix timestamp when the join events should be marked as sent + */ +function createJoinStateEventsForBatchSendRequest( + virtualUserIDs: string[], + insertTimestamp: number, +) { + return virtualUserIDs.map((virtualUserID, index) => { + return { + "content": { + "displayname": `some-display-name-for-${virtualUserID}`, + "membership": "join", + }, + "origin_server_ts": insertTimestamp + index, + "sender": virtualUserID, + "state_key": virtualUserID, + "type": "m.room.member", + }; + }); +} + +let batchCount = 0; +/** + * Create a number of message events that are usuable in a /batch_send request + * @param {string[]} virtualUserIDs A list of virtualUserIds to send historical messages from + * @param {number} insertTimestamp A unix timestamp when the messages should start from + * @param {number} count The number of messages to create + */ +function createMessageEventsForBatchSendRequest( + virtualUserIDs: string[], + insertTimestamp: number, + count: number, +) { + const messageEvents = [...Array(count).keys()].map((i) => { + const virtualUserID = virtualUserIDs[i % virtualUserIDs.length]; + + return { + "content": { + "body": `Historical ${i} (batch=${batchCount})`, + "msgtype": "m.text", + "org.matrix.msc2716.historical": true, + }, + "origin_server_ts": insertTimestamp + i, + "sender": virtualUserID, + "type": "m.room.message", + }; + }); + + batchCount++; + + return messageEvents; +} + +/** + * Wait for the given event IDs to show up in the UI + * @param {string[]} eventIds The event IDs we ensure are visible in the UI + */ +function waitForEventIdsInClient(eventIds: string[]) { + eventIds.forEach((eventId) => { + // Wait for the messages to be visible + cy.get(`[data-event-id="${eventId}"]`); + }); +} + +interface IBatchSendResponse { + /** List of state event ID's we inserted */ + state_event_ids: string[]; + /** List of historical event ID's we inserted */ + event_ids: string[]; + next_batch_id: string; + insertion_event_id: string; + batch_event_id: string; + /** When `?batch_id` isn't provided, the homeserver automatically creates an + * insertion event as a starting place to hang the history off of. This + * automatic insertion event ID is returned in this field. + * + * When `?batch_id` is provided, this field is not present because we can + * hang the history off the insertion event specified and associated by the + * batch ID. + */ + base_insertion_event_id?: string; +} + +/** + * Import a batch of historical events (MSC2716) + * @param {object} opts + * @param {SynapseInstance} opts.synapse The given Synapse instance to `/batch_send` against + * @param {string} opts.applicationServiceToken The given application service token to register + * the virtual users from + * @param {string} opts.roomId The room to import the historical messages in + * @param {string} opts.prevEventId The event ID to import the history next to + * @param {string} opts.batchId The batch ID from a previous `/batch_send` request to connect + * them all together into one big chain of history. + * @param {object} opts.payload The events and state to send in the batch + */ +function batchSend({ + synapse, + applicationServiceToken, + roomId, + prevEventId, + batchId, + payload, +}: { + synapse: SynapseInstance; + applicationServiceToken: string; + roomId: string; + prevEventId: string; + batchId: string | null; + payload: { state_events_at_start?: any[], events: any[] }; +}): Cypress.Chainable> { + const prefix = '/_matrix/client/unstable/org.matrix.msc2716'; + const batchSendUrl = new URL(`${synapse.baseUrl}${prefix}/rooms/${roomId}/batch_send`); + batchSendUrl.searchParams.set('prev_event_id', prevEventId); + if (batchId !== null) { + batchSendUrl.searchParams.set('batch_id', batchId); + } + + return cy.request({ + url: batchSendUrl.toString(), + method: "POST", + headers: { + 'Authorization': `Bearer ${applicationServiceToken}`, + }, + body: payload, + }); +} + +/** + * Make sure all of the given virtual user IDs are registered and ready to be + * used in a `/batch_send` request. + * @param {SynapseInstance} synapse The given Synapse instance to `/batch_send` against + * @param {string} applicationServiceToken The given application service token to register + * the virtual users from + * @param {string[]} virtualUserIDs A list of virtualUserIds to send historical messages from + */ +function ensureVirtualUsersRegistered( + synapse: SynapseInstance, + applicationServiceToken: string, + virtualUserIds: string[], +) { + const url = `${synapse.baseUrl}/_matrix/client/r0/register`; + + const virtualUserLocalparts = virtualUserIds.map((virtualUserId) => { + const userIdWithoutServer = virtualUserId.split(':')[0]; + const localpart = userIdWithoutServer.replace(/^@/, ''); + return localpart; + }); + + virtualUserLocalparts.forEach((virtualUserLocalpart) => { + cy.request<{ error?: string, errcode?: string }>({ + url, + method: "POST", + body: { + type: "m.login.application_service", + username: virtualUserLocalpart, + }, + headers: { + 'Authorization': `Bearer ${applicationServiceToken}`, + }, + // We'll handle the errors ourselves below + failOnStatusCode: false, + }) + .then((res) => { + // Registration success + if (res.status === 200) { + return; + } + + const errcode = res.body.errcode; + + // User already registered and good to go + if (res.status == 400 && errcode === "M_USER_IN_USE") { + return; + } + + const errorMessage = res.body.error; + throw new Error( + `ensureVirtualUserRegistered failed to register ` + + `${virtualUserLocalpart}: (${errcode}) ${errorMessage}`, + ); + }); + }); +} + +/** + * Send a marker event and ensure that the "History import detected" status bar is shown + * which indicates that the client received the event. + * @param {MatrixClient} asMatrixClient The given application service client to send + * marker event from. + */ +function sendMarkerEventAndEnsureHistoryDetectedStatusBar(asMatrixClient) { + // Send the marker event which lets the client know there are + // some historical messages back at the given insertion event. + cy.all([ + cy.get("@roomId"), + cy.get("@baseInsertionEventId"), + ]).then(async ([roomId, baseInsertionEventId]) => { + const { event_id: markerEventId } = await asMatrixClient.sendStateEvent( + roomId, + 'org.matrix.msc2716.marker', { + "org.matrix.msc2716.marker.insertion": baseInsertionEventId, + }, + Cypress._.uniqueId("marker_state_key_"), + ); + + cy.wrap(markerEventId).as('markerEventId'); + + // Wait for the message to show up for the logged in user + waitForEventIdsInClient([markerEventId]); + }); + + // Ensure the "History import detected" notice is shown + cy.get(`[data-testid="historical-import-detected-status-bar"]`).should("exist"); +} + +/** + * Bootstrap a room with some messages and a historically imported batch that is + * ready to be seen after refreshing the timeline. + * @param {object} opts + * @param {SynapseInstance} opts.synapse The given Synapse instance to `/batch_send` against + * @param {MatrixClient} opts.asMatrixClient The given application service client to create + * the room and messages. + * @param {string[]} opts.virtualUserIDs A list of virtualUserIds to send historical messages from + */ +function setupRoomWithHistoricalMessagesAndMarker({ + synapse, + asMatrixClient, + virtualUserIDs, +}: { + synapse: SynapseInstance; + asMatrixClient: MatrixClient; + virtualUserIDs: string[]; +}) { + // As the application service, create the room so it is the room creator + // and proper power_levels to send MSC2716 events. Then join the logged + // in user to the room. + cy.window().then(async (win) => { + const resp = await asMatrixClient.createRoom({ + preset: win.matrixcs.Preset.PublicChat, + name: "test-msc2716", + room_version: "org.matrix.msc2716v4", + }); + cy.wrap(resp.room_id).as('roomId'); + }); + + cy.get("@roomId").then((roomId) => { + // Join the logged in user to the room + cy.joinRoom(roomId); + + // Then visit the room + cy.visit("/#/room/" + roomId); + }); + + // Send 3 live messages as the application service. + // Then make sure they are visible from the perspective of the logged in user. + cy.get("@roomId").then(async (roomId) => { + // Send 3 messages and wait for them to be sent + const liveMessageEventIds = []; + for (let i = 0; i < 3; i++) { + // Send the messages sequentially waiting for each request + // to finish before we move onto the next so we don't end up + // with a pile of live messages at the same depth. This + // gives more of an expected order at the end. + const { event_id: eventId } = await asMatrixClient.sendMessage(roomId, null, { + body: `live_event${i}`, + msgtype: "m.text", + }); + liveMessageEventIds.push(eventId); + } + + // Make this available for later chains + cy.wrap(liveMessageEventIds).as('liveMessageEventIds'); + + // Wait for the messages to show up for the logged in user + waitForEventIdsInClient(liveMessageEventIds); + }); + + cy.all([ + cy.get("@roomId"), + cy.get("@liveMessageEventIds"), + ]).then(([roomId, liveMessageEventIds]) => { + // Make sure the right thing was yielded + expect(liveMessageEventIds).to.have.lengthOf(3); + + // Send a batch of historical messages + const insertTimestamp = Date.now(); + batchSend({ + synapse, + applicationServiceToken: asMatrixClient.getAccessToken(), + roomId: roomId, + prevEventId: liveMessageEventIds[1], + batchId: null, + payload: { + state_events_at_start: createJoinStateEventsForBatchSendRequest(virtualUserIDs, insertTimestamp), + events: createMessageEventsForBatchSendRequest( + virtualUserIDs, + insertTimestamp, + 3, + ), + }, + }).then((res) => { + assert.exists(res.body.event_ids); + assert.exists(res.body.base_insertion_event_id); + + // Make this available for later chains + cy.wrap(res.body.event_ids).as('historicalEventIds'); + cy.wrap(res.body.base_insertion_event_id).as('baseInsertionEventId'); + }); + }); + + cy.get("@roomId").then(async (roomId) => { + // Ensure historical messages do not appear yet. We can do this by + // sending another live event and wait for it to sync back to us. If + // we're able to see eventIdAfterHistoricalImport without any the + // historicalEventIds/historicalStateEventIds in between, we're + // probably safe to assume it won't sync. + const { event_id: eventIdAfterHistoricalImport } = await asMatrixClient.sendMessage(roomId, null, { + body: `live_event after historical import`, + msgtype: "m.text", + }); + + // Wait for the message to show up for the logged in user + waitForEventIdsInClient([eventIdAfterHistoricalImport]); + }); + + sendMarkerEventAndEnsureHistoryDetectedStatusBar(asMatrixClient); +} + +describe("MSC2716: Historical Import", () => { + let synapse: SynapseInstance; + let asMatrixClient: MatrixClient; + const virtualUserIDs = ['@maria-01234:localhost']; + + // This corresponds to the application service registration defined in the + // "msc2716-historical-import" Synapse configuration + const AS_TOKEN = 'as_token123'; + + beforeEach(() => { + // Default threads to ON for this spec + cy.enableLabsFeature("feature_thread"); + + cy.window().then(win => { + // Collapse left panel for these tests (get more space in the area we care about) + win.localStorage.setItem("mx_lhs_size", "0"); + }); + // Start Synapse with msc2716_enabled and an application service configured + cy.startSynapse("msc2716-historical-import").then(data => { + synapse = data; + + // This is the person we're logged in as + cy.initTestUser(synapse, "Grace"); + + // After the page is loaded from initializing the logged in user so + // the mxSettingsStore is available + cy.window().then(win => { + // Disable the sound notifications so you're not startled and + // confused where the sound is coming from + win.mxSettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, false); + // Show hidden events to make debugging easier. And the state + // events will show up in the timeline to make it the same + // assert as other events. + win.mxSettingsStore.setValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); + }); + + // Get a Matrix Client for the application service + cy.newMatrixClient(synapse, { + baseUrl: synapse.baseUrl, + userId: '@gitter-badger:localhost', + accessToken: AS_TOKEN, + }).then(matrixClient => { + asMatrixClient = matrixClient; + }); + + ensureVirtualUsersRegistered(synapse, AS_TOKEN, virtualUserIDs); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("Shows historical messages after refreshing the timeline", () => { + setupRoomWithHistoricalMessagesAndMarker({ + synapse, + asMatrixClient, + virtualUserIDs, + }); + + // Press "Refresh timeline" + cy.get(`[data-testid="refresh-timeline-button"]`).click(); + + // Ensure historical messages are now shown + cy.all([ + cy.get("@liveMessageEventIds"), + cy.get("@historicalEventIds"), + ]).then(([liveMessageEventIds, historicalEventIds]) => { + // FIXME: Assert that they appear in the correct order + waitForEventIdsInClient([ + liveMessageEventIds[0], + liveMessageEventIds[1], + ...historicalEventIds, + liveMessageEventIds[2], + ]); + }); + }); + + it("Able to refresh the timeline multiple times", () => { + setupRoomWithHistoricalMessagesAndMarker({ + synapse, + asMatrixClient, + virtualUserIDs, + }); + + // Press "Refresh timeline" + cy.get(`[data-testid="refresh-timeline-button"]`).click(); + + // Ensure historical messages are now shown + cy.all([ + cy.get("@liveMessageEventIds"), + cy.get("@historicalEventIds"), + ]).then(([liveMessageEventIds, historicalEventIds]) => { + // FIXME: Assert that they appear in the correct order + waitForEventIdsInClient([ + liveMessageEventIds[0], + liveMessageEventIds[1], + ...historicalEventIds, + liveMessageEventIds[2], + ]); + }); + + // Send another marker event. We're making sure that the history status + // bar appears again and works (this is the special differentiator we're + // testing in this test) + sendMarkerEventAndEnsureHistoryDetectedStatusBar(asMatrixClient); + + // Press "Refresh timeline" + cy.get(`[data-testid="refresh-timeline-button"]`).click(); + + // Ensure all of the messages still show afterwards + cy.all([ + cy.get("@liveMessageEventIds"), + cy.get("@historicalEventIds"), + ]).then(([liveMessageEventIds, historicalEventIds]) => { + // FIXME: Assert that they appear in the correct order + waitForEventIdsInClient([ + liveMessageEventIds[0], + liveMessageEventIds[1], + ...historicalEventIds, + liveMessageEventIds[2], + ]); + }); + }); + + it("Perfectly merges timelines if a sync finishes while refreshing the timeline", () => { + setupRoomWithHistoricalMessagesAndMarker({ + synapse, + asMatrixClient, + virtualUserIDs, + }); + + // 1. Pause the `/context` request from `getEventTimeline` that happens + // when we refresh the timeline. + // 2. Make sure a /sync happens in the middle (simulate a sync racing + // with us). + // 3. Then resume the `/context` request. + let resolveReq; + cy.all([ + cy.get("@roomId"), + cy.get("@markerEventId"), + ]).then(([roomId, markerEventId]) => { + // We're using `markerEventId` here because it's the latest event in the room + const prefix = '/_matrix/client/r0'; + const path = `/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent(markerEventId)}`; + const contextUrl = `${synapse.baseUrl}${prefix}${path}*`; + cy.intercept(contextUrl, async (req) => { + return new Cypress.Promise(resolve => { + // Later, we only resolve this after we detect that the + // timeline was reset(when it goes blank) and force a sync + // to happen in the middle of all of this refresh timeline + // logic. We want to make sure the sync pagination still + // works as expected after messing the refresh timline logic + // messes with the pagination tokens. + resolveReq = resolve; + }).then(req.reply); + }).as('contextRequestThatWillMakeNewTimeline'); + }); + + // Press "Refresh timeline" + cy.get(`[data-testid="refresh-timeline-button"]`).click(); + + // Wait for the timeline to go blank (meaning it was reset) + // and in the middle of the refrsehing timeline function. + cy.get('[data-test-id="message-list"] [data-event-id]') + .should('not.exist'); + + // Then make a `/sync` happen by sending a message and seeing that it + // shows up (simulate a /sync naturally racing with us). + cy.get("@roomId").then(async (roomId) => { + const { event_id: eventIdWhileRefreshingTimeline } = await asMatrixClient.sendMessage( + roomId, + null, { + body: `live_event while trying to refresh timeline`, + msgtype: "m.text", + }, + ); + + // Wait for the message to show up for the logged in user + // indicating that a sync happened in the middle of us + // refreshing the timeline. We want to make sure the sync + // pagination still works as expected after messing the refresh + // timeline logic messes with the pagination tokens. + waitForEventIdsInClient([eventIdWhileRefreshingTimeline]); + + cy.wrap(eventIdWhileRefreshingTimeline).as('eventIdWhileRefreshingTimeline'); + }).then(() => { + // Now we can resume the `/context` request + resolveReq(); + }); + + // Make sure the `/context` request was intercepted + cy.wait('@contextRequestThatWillMakeNewTimeline').its('response.statusCode').should('eq', 200); + + // Make sure sync pagination still works by seeing a new message show up + cy.get("@roomId").then(async (roomId) => { + const { event_id: eventIdAfterRefresh } = await asMatrixClient.sendMessage(roomId, null, { + body: `live_event after refresh`, + msgtype: "m.text", + }); + + // Wait for the message to show up for the logged in user + waitForEventIdsInClient([eventIdAfterRefresh]); + + cy.wrap(eventIdAfterRefresh).as('eventIdAfterRefresh'); + }); + + // Ensure historical messages are now shown + cy.all([ + cy.get("@liveMessageEventIds"), + cy.get("@historicalEventIds"), + cy.get("@eventIdWhileRefreshingTimeline"), + cy.get("@eventIdAfterRefresh"), + ]).then(async ([ + liveMessageEventIds, + historicalEventIds, + eventIdWhileRefreshingTimeline, + eventIdAfterRefresh, + ]) => { + // FIXME: Assert that they appear in the correct order + waitForEventIdsInClient([ + liveMessageEventIds[0], + liveMessageEventIds[1], + ...historicalEventIds, + liveMessageEventIds[2], + eventIdWhileRefreshingTimeline, + eventIdAfterRefresh, + ]); + }); + }); + + it("Timeline recovers after `/context` request to generate new timeline fails", () => { + setupRoomWithHistoricalMessagesAndMarker({ + synapse, + asMatrixClient, + virtualUserIDs, + }); + + // Make the `/context` fail when we try to refresh the timeline. We want + // to make sure that we are resilient to this type of failure and can + // retry and recover. + cy.all([ + cy.get("@roomId"), + cy.get("@markerEventId"), + ]).then(async ([roomId, markerEventId]) => { + // We're using `this.markerEventId` here because it's the latest event in the room + const prefix = '/_matrix/client/r0'; + const path = `/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent(markerEventId)}`; + const contextUrl = `${synapse.baseUrl}${prefix}${path}*`; + cy.intercept(contextUrl, { + statusCode: 500, + body: { + errcode: 'CYPRESS_FAKE_ERROR', + error: 'We purposely intercepted this /context request to make it fail ' + + 'in order to test whether the refresh timeline code is resilient', + }, + }).as('contextRequestThatWillTryToMakeNewTimeline'); + }); + + // Press "Refresh timeline" + cy.get(`[data-testid="refresh-timeline-button"]`).click(); + + // Make sure the request was intercepted and thew an error + cy.wait('@contextRequestThatWillTryToMakeNewTimeline').its('response.statusCode').should('eq', 500); + + // Make sure we tell the user that an error happened + cy.get(`[data-testid="historical-import-detected-error-content"]`).should("exist"); + + // Allow the requests to succeed now + cy.all([ + cy.get("@roomId"), + cy.get("@markerEventId"), + ]).then(async ([roomId, markerEventId]) => { + // We're using `this.markerEventId` here because it's the latest event in the room + const prefix = '/_matrix/client/r0'; + const path = `/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent(markerEventId)}`; + const contextUrl = `${synapse.baseUrl}${prefix}${path}*`; + cy.intercept(contextUrl, async (req) => { + // Passthrough. We can't just omit this callback because the + // other intercept will take precedent for some reason. + req.reply(); + }).as('contextRequestThatWillMakeNewTimeline'); + }); + + // Press "Refresh timeline" again, this time the network request should succeed + cy.get(`[data-testid="refresh-timeline-button"]`).click(); + + // Make sure the request was intercepted and succeeded + cy.wait('@contextRequestThatWillMakeNewTimeline').its('response.statusCode').should('eq', 200); + + // Make sure sync pagination still works by seeing a new message show up + cy.get("@roomId").then(async (roomId) => { + const { event_id: eventIdAfterRefresh } = await asMatrixClient.sendMessage( + roomId, + null, { + body: `live_event after refresh`, + msgtype: "m.text", + }, + ); + + // Wait for the message to show up for the logged in user + waitForEventIdsInClient([eventIdAfterRefresh]); + + cy.wrap(eventIdAfterRefresh).as('eventIdAfterRefresh'); + }); + + // Ensure historical messages are now shown + cy.all([ + cy.get("@liveMessageEventIds"), + cy.get("@historicalEventIds"), + cy.get("@eventIdAfterRefresh"), + ]).then(async ([ + liveMessageEventIds, + historicalEventIds, + eventIdAfterRefresh, + ]) => { + // FIXME: Assert that they appear in the correct order + waitForEventIdsInClient([ + liveMessageEventIds[0], + liveMessageEventIds[1], + ...historicalEventIds, + liveMessageEventIds[2], + eventIdAfterRefresh, + ]); + }); + }); + + it("Perfectly resolves timelines when refresh fails and then another refresh causes `fetchLatestLiveTimeline()` " + + "finds a threaded event", () => { + setupRoomWithHistoricalMessagesAndMarker({ + synapse, + asMatrixClient, + virtualUserIDs, + }); + + // Send a threaded message so it's the latest message in the room + cy.get("@roomId").then(async (roomId) => { + const { event_id: eventIdToThreadFrom } = await asMatrixClient.sendMessage(roomId, null, { + body: `event to thread from (root)`, + msgtype: "m.text", + }); + const { event_id: eventIdThreadedMessage } = await asMatrixClient.sendMessage(roomId, null, { + "body": `threaded message1`, + "msgtype": "m.text", + "m.relates_to": { + "rel_type": "m.thread", + "event_id": eventIdToThreadFrom, + "is_falling_back": true, + "m.in_reply_to": { + "event_id": eventIdToThreadFrom, + }, + }, + }); + + // Wait for the message to show up for the logged in user + waitForEventIdsInClient([eventIdToThreadFrom]); + cy.wrap(eventIdToThreadFrom).as('eventIdToThreadFrom'); + // We don't wait for this event in the client because it will be + // hidden away in a thread. + cy.wrap(eventIdThreadedMessage).as('eventIdThreadedMessage'); + + // Wait for the thread summary to appear which indicates that + // `eventIdThreadedMessage` made it to the client + cy.get(`[data-event-id="${eventIdToThreadFrom}"] [data-test-id="thread-summary"]`); + }); + + // Make the `/context` fail when we try to refresh the timeline. We want + // to make sure that we are resilient to this type of failure and can + // retry and recover. + cy.all([ + cy.get("@roomId"), + cy.get("@eventIdToThreadFrom"), + ]).then(async ([roomId, eventIdToThreadFrom]) => { + // We're using `eventIdToThreadFrom` here because it's the latest + // event in the rooms main timeline which the refresh timeline logic + // will use if available. + const prefix = '/_matrix/client/r0'; + const path = `/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent(eventIdToThreadFrom)}`; + const contextUrl = `${synapse.baseUrl}${prefix}${path}*`; + cy.intercept(contextUrl, { + statusCode: 500, + body: { + errcode: 'CYPRESS_FAKE_ERROR', + error: 'We purposely intercepted this /context request to make it fail ' + + 'in order to test whether the refresh timeline code is resilient', + }, + }).as('contextRequestThatWillTryToMakeNewTimeline'); + }); + + // Press "Refresh timeline" + cy.get(`[data-testid="refresh-timeline-button"]`).click(); + + // Make sure the request was intercepted and thew an error + cy.wait('@contextRequestThatWillTryToMakeNewTimeline').its('response.statusCode').should('eq', 500); + + // Wait for the timeline to go blank (meaning it was reset) + // and refreshing the timeline failed. + cy.get('[data-test-id="message-list"] [data-event-id]') + .should('not.exist'); + + // Allow the requests to succeed now + cy.all([ + cy.get("@roomId"), + cy.get("@eventIdThreadedMessage"), + ]).then(async ([roomId, eventIdThreadedMessage]) => { + // We're using `eventIdThreadedMessage` here because it's the latest event in + // the room which `/messages?dir=b` will find from the refresh timeline -> + // `client.fetchLatestLiveTimeline(...)` logic. + const prefix = '/_matrix/client/r0'; + const path = `/rooms/${encodeURIComponent(roomId)}/context/${encodeURIComponent(eventIdThreadedMessage)}`; + const contextUrl = `${synapse.baseUrl}${prefix}${path}*`; + cy.intercept(contextUrl, async (req) => { + // Passthrough. We can't just omit this callback because the + // other intercept will take precedent for some reason. + req.reply(); + }).as('contextRequestThatWillMakeNewTimeline'); + }); + + // Press "Refresh timeline" again, this time the network request should succeed. + // + // Since the timeline is now blank, we have no most recent event to + // draw from locally. So `MatrixClient::fetchLatestLiveTimeline()` will + // fetch the latest from `/messages?dir=b` which will return + // `eventIdThreadedMessage` as the latest event in the room. + cy.get(`[data-testid="refresh-timeline-button"]`).click(); + + // Make sure the request was intercepted and succeeded + cy.wait('@contextRequestThatWillMakeNewTimeline').its('response.statusCode').should('eq', 200); + + // Make sure sync pagination still works by seeing a new message show up + cy.get("@roomId").then(async (roomId) => { + const { event_id: eventIdAfterRefresh } = await asMatrixClient.sendMessage(roomId, null, { + body: `live_event after refresh`, + msgtype: "m.text", + }); + + // Wait for the message to show up for the logged in user + waitForEventIdsInClient([eventIdAfterRefresh]); + + cy.wrap(eventIdAfterRefresh).as('eventIdAfterRefresh'); + }); + + // Ensure historical messages are now shown + cy.all([ + cy.get("@liveMessageEventIds"), + cy.get("@historicalEventIds"), + cy.get("@eventIdToThreadFrom"), + cy.get("@eventIdAfterRefresh"), + ]).then(async ([ + liveMessageEventIds, + historicalEventIds, + eventIdToThreadFrom, + eventIdAfterRefresh, + ]) => { + // FIXME: Assert that they appear in the correct order + waitForEventIdsInClient([ + liveMessageEventIds[0], + liveMessageEventIds[1], + ...historicalEventIds, + liveMessageEventIds[2], + eventIdToThreadFrom, + eventIdAfterRefresh, + ]); + }); + }); +}); diff --git a/cypress/plugins/synapsedocker/templates/COPYME/homeserver.yaml b/cypress/plugins/synapsedocker/templates/COPYME/homeserver.yaml index fab1bc1c451..f616297d9b9 100644 --- a/cypress/plugins/synapsedocker/templates/COPYME/homeserver.yaml +++ b/cypress/plugins/synapsedocker/templates/COPYME/homeserver.yaml @@ -12,7 +12,7 @@ listeners: x_forwarded: true resources: - - names: [client, federation, consent] + - names: [client, federation] compress: false # An sqlite in-memory database is fast & automatically wipes each time diff --git a/cypress/plugins/synapsedocker/templates/msc2716-historical-import/README.md b/cypress/plugins/synapsedocker/templates/msc2716-historical-import/README.md new file mode 100644 index 00000000000..c511585a4cb --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/msc2716-historical-import/README.md @@ -0,0 +1 @@ +Start Synapse with `msc2716_enabled` and an application service configured diff --git a/cypress/plugins/synapsedocker/templates/msc2716-historical-import/as-registration.yaml b/cypress/plugins/synapsedocker/templates/msc2716-historical-import/as-registration.yaml new file mode 100644 index 00000000000..7febb972953 --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/msc2716-historical-import/as-registration.yaml @@ -0,0 +1,16 @@ +id: my_application_service_id +hs_token: hs_token123 +as_token: as_token123 +url: 'http://localhost:0000' +sender_localpart: gitter-badger +namespaces: + users: + - exclusive: false + regex: .* + aliases: + - exclusive: false + regex: .* + rooms: + - exclusive: false + regex: .* +rate_limited: false diff --git a/cypress/plugins/synapsedocker/templates/msc2716-historical-import/homeserver.yaml b/cypress/plugins/synapsedocker/templates/msc2716-historical-import/homeserver.yaml new file mode 100644 index 00000000000..e6cf07dd138 --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/msc2716-historical-import/homeserver.yaml @@ -0,0 +1,80 @@ +server_name: "localhost" +pid_file: /data/homeserver.pid +# XXX: This won't actually be right: it lets docker allocate an ephemeral port, +# so we have a chicken-and-egg problem +public_baseurl: "{{PUBLIC_BASEURL}}" +# Listener is always port 8008 (configured in the container) +listeners: + - port: 8008 + tls: false + bind_addresses: ['::'] + type: http + x_forwarded: true + + resources: + - names: [client, federation] + compress: false + +# An sqlite in-memory database is fast & automatically wipes each time +database: + name: "sqlite3" + args: + database: ":memory:" + +# Needs to be configured to log to the console like a good docker process +log_config: "/data/log.config" + +rc_messages_per_second: 10000 +rc_message_burst_count: 10000 +rc_registration: + per_second: 10000 + burst_count: 10000 + +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +media_store_path: "/data/media_store" +uploads_path: "/data/uploads" +enable_registration: true +enable_registration_without_verification: true +disable_msisdn_registration: false +# These placeholders will be be replaced with values generated at start +registration_shared_secret: "{{REGISTRATION_SECRET}}" +report_stats: false +macaroon_secret_key: "{{MACAROON_SECRET_KEY}}" +form_secret: "{{FORM_SECRET}}" +# Signing key must be here: it will be generated to this file +signing_key_path: "/data/localhost.signing.key" +email: + enable_notifs: false + smtp_host: "localhost" + smtp_port: 25 + smtp_user: "exampleusername" + smtp_pass: "examplepassword" + require_transport_security: False + notif_from: "Your Friendly %(app)s homeserver " + app_name: Matrix + notif_template_html: notif_mail.html + notif_template_text: notif_mail.txt + notif_for_new_users: True + client_base_url: "http://localhost/element" + +trusted_key_servers: + - server_name: "matrix.org" +suppress_key_server_warning: true + +# A list of application service config files to use +# +app_service_config_files: + - /data/as-registration.yaml + +experimental_features: + msc2716_enabled: true diff --git a/cypress/plugins/synapsedocker/templates/msc2716-historical-import/log.config b/cypress/plugins/synapsedocker/templates/msc2716-historical-import/log.config new file mode 100644 index 00000000000..ac232762da3 --- /dev/null +++ b/cypress/plugins/synapsedocker/templates/msc2716-historical-import/log.config @@ -0,0 +1,50 @@ +# Log configuration for Synapse. +# +# This is a YAML file containing a standard Python logging configuration +# dictionary. See [1] for details on the valid settings. +# +# Synapse also supports structured logging for machine readable logs which can +# be ingested by ELK stacks. See [2] for details. +# +# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema +# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html + +version: 1 + +formatters: + precise: + format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' + +handlers: + # A handler that writes logs to stderr. Unused by default, but can be used + # instead of "buffer" and "file" in the logger handlers. + console: + class: logging.StreamHandler + formatter: precise + +loggers: + synapse.storage.SQL: + # beware: increasing this to DEBUG will make synapse log sensitive + # information such as access tokens. + level: INFO + + twisted: + # We send the twisted logging directly to the file handler, + # to work around https://github.com/matrix-org/synapse/issues/3471 + # when using "buffer" logger. Use "console" to log to stderr instead. + handlers: [console] + propagate: false + +root: + level: INFO + + # Write logs to the `buffer` handler, which will buffer them together in memory, + # then write them to a file. + # + # Replace "buffer" with "console" to log to stderr instead. (Note that you'll + # also need to update the configuration for the `twisted` logger above, in + # this case.) + # + handlers: [console] + +disable_existing_loggers: false diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index 26f0aa497e4..15603254926 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import type { ISendEventResponse, MatrixClient, Room, IMatrixClientCreateOpts } from "matrix-js-sdk/src/matrix"; import { SynapseInstance } from "../plugins/synapsedocker"; import Chainable = Cypress.Chainable; @@ -50,6 +50,14 @@ declare global { * @param opts create bot options */ getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable; + /** + * Create a new Matrix client to interact with the API as user + * separate from the one logged in + * @param synapse the instance on which to register the bot user + * @param opts Options to pass when creating a new Matrix client + * like `userId` and `accessToken` + */ + newMatrixClient(synapse: SynapseInstance, opts: IMatrixClientCreateOpts): Chainable; /** * Let a bot join a room * @param cli The bot's MatrixClient @@ -73,6 +81,21 @@ declare global { } } +Cypress.Commands.add("newMatrixClient", ( + synapse: SynapseInstance, + opts: IMatrixClientCreateOpts, +): Chainable => { + return cy.window({ log: false }).then(win => { + const cli = new win.matrixcs.MatrixClient({ + ...opts, + }); + + cli.startClient(); + + return cli; + }); +}); + Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable => { opts = Object.assign({}, defaultCreateBotOptions, opts); const username = Cypress._.uniqueId("userId_"); diff --git a/cypress/support/client.ts b/cypress/support/client.ts index e20c08a8139..9a94a3807f7 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -20,6 +20,7 @@ import type { FileType, Upload, UploadOpts } from "matrix-js-sdk/src/http-api"; import type { ICreateRoomOpts, ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { Room } from "matrix-js-sdk/src/models/room"; +import type { IJoinRoomOpts } from "matrix-js-sdk/src/@types/requests"; import type { IContent } from "matrix-js-sdk/src/models/event"; import Chainable = Cypress.Chainable; @@ -49,6 +50,17 @@ declare global { * @param userId the id of the user to invite */ inviteUser(roomId: string, userId: string): Chainable<{}>; + /** + * Joins the current user to the given room. + * @param roomIdOrAlias the id or alias of the room to join + * @param opts the options when joining a room + */ + joinRoom(roomIdOrAlias: string, opts?: IJoinRoomOpts): Chainable<{}>; + /** + * Waits for the given room to be synced locally + * @param roomId the id of the room to wait for locally + */ + waitForRoom(roomId: string): Chainable; /** * Sets account data for the user. * @param type The type of account data. @@ -123,11 +135,6 @@ declare global { * Boostraps cross-signing. */ bootstrapCrossSigning(): Chainable; - /** - * Joins the given room by alias or ID - * @param roomIdOrAlias the id or alias of the room to join - */ - joinRoom(roomIdOrAlias: string): Chainable; } } } @@ -143,11 +150,40 @@ Cypress.Commands.add("getDmRooms", (userId: string): Chainable => { }); Cypress.Commands.add("createRoom", (options: ICreateRoomOpts): Chainable => { + cy.window({ log: false }) + .then(async win => { + const cli = win.mxMatrixClientPeg.matrixClient; + const resp = await cli.createRoom(options); + const roomId = resp.room_id; + return roomId; + }) + .as('roomId'); + + return cy.get("@roomId").then(roomId => { + return cy.waitForRoom(roomId); + }); +}); + +Cypress.Commands.add("inviteUser", (roomId: string, userId: string): Chainable<{}> => { + return cy.getClient().then(async (cli: MatrixClient) => { + return cli.invite(roomId, userId); + }); +}); + +Cypress.Commands.add("joinRoom", (roomIdOrAlias: string, opts?: IJoinRoomOpts): Chainable<{}> => { + cy.getClient().then(async (cli: MatrixClient) => { + return cli.joinRoom(roomIdOrAlias, opts); + }); + + // Wait for the room to be available locally + return cy.waitForRoom(roomIdOrAlias); +}); + +Cypress.Commands.add("waitForRoom", (roomId: string): Chainable => { return cy.window({ log: false }).then(async win => { const cli = win.mxMatrixClientPeg.matrixClient; - const resp = await cli.createRoom(options); - const roomId = resp.room_id; + // Wait for the room to be available locally if (!cli.getRoom(roomId)) { await new Promise(resolve => { const onRoom = (room: Room) => { @@ -221,7 +257,3 @@ Cypress.Commands.add("bootstrapCrossSigning", () => { }); }); }); - -Cypress.Commands.add("joinRoom", (roomIdOrAlias: string): Chainable => { - return cy.getClient().then(cli => cli.joinRoom(roomIdOrAlias)); -}); diff --git a/res/css/structures/_RoomStatusBar.pcss b/res/css/structures/_RoomStatusBar.pcss index c401301921b..6849f1e9673 100644 --- a/res/css/structures/_RoomStatusBar.pcss +++ b/res/css/structures/_RoomStatusBar.pcss @@ -76,6 +76,8 @@ limitations under the License. min-height: 70px; margin: 12px; padding-left: 16px; + padding-top: $spacing-4; + padding-bottom: $spacing-4; background-color: $header-panel-bg-color; border-radius: 4px; } @@ -145,6 +147,12 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/retry.svg'); } } + + &.mx_RoomStatusBar_refreshTimelineBtn { + &::before { + mask-image: url('$(res)/img/element-icons/retry.svg'); + } + } } .mx_InlineSpinner { diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index 85953fe1dd3..e2cc11f9b1b 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -130,7 +130,7 @@ limitations under the License. } .mx_RoomView_statusArea_expanded { - max-height: 100px; + max-height: 140px; } .mx_RoomView_statusAreaBox { diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index d2f53f51702..a7017920d72 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -104,7 +104,9 @@ class FilePanel extends React.Component { } if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) { - this.state.timelineSet.addEventToTimeline(ev, timeline, false); + this.state.timelineSet.addEventToTimeline(ev, timeline, { + toStartOfTimeline: false, + }); } } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index f359f091f16..1861289b605 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -351,8 +351,8 @@ class LoggedInView extends React.Component { const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM); for (const eventId of pinnedEventIds) { - const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId); - const event = timeline.getEvents().find(ev => ev.getId() === eventId); + await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId); + const event = room.findEventById(eventId); if (event) events.push(event); } } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 26190a24fc9..5579c09b558 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -144,7 +144,7 @@ interface IProps { // the userid of our user. This is used to suppress the read marker // for pending messages. - ourUserId?: string; + ourUserId?: string | null | undefined; // whether the timeline can visually go back any further canBackPaginate?: boolean; @@ -158,7 +158,7 @@ interface IProps { stickyBottom?: boolean; // className for the panel - className: string; + className?: string; // show twelve hour timestamps isTwelveHour?: boolean; @@ -172,7 +172,7 @@ interface IProps { // which layout to use layout?: Layout; - resizeNotifier: ResizeNotifier; + resizeNotifier?: ResizeNotifier; permalinkCreator?: RoomPermalinkCreator; editState?: EditorStateTransfer; diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index e7032525460..6b8db9b1084 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -17,7 +17,9 @@ limitations under the License. import React from 'react'; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { SyncState, ISyncStateData } from "matrix-js-sdk/src/sync"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { logger } from 'matrix-js-sdk/src/logger'; +import { MatrixError } from "matrix-js-sdk/src/http-api"; import { _t, _td } from '../../languageHandler'; import Resend from '../../Resend'; @@ -28,6 +30,8 @@ import { StaticNotificationState } from "../../stores/notifications/StaticNotifi import AccessibleButton from "../views/elements/AccessibleButton"; import InlineSpinner from "../views/elements/InlineSpinner"; import MatrixClientContext from "../../contexts/MatrixClientContext"; +import Modal from '../../Modal'; +import BugReportDialog from '../views/dialogs/BugReportDialog'; import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; const STATUS_BAR_HIDDEN = 0; @@ -81,6 +85,9 @@ interface IState { syncStateData: ISyncStateData; unsentMessages: MatrixEvent[]; isResending: boolean; + timelineNeedsRefresh: boolean; + isRefreshing: boolean; + refreshError?: Error; } export default class RoomStatusBar extends React.PureComponent { @@ -95,6 +102,9 @@ export default class RoomStatusBar extends React.PureComponent { syncStateData: this.context.getSyncStateData(), unsentMessages: getUnsentMessages(this.props.room), isResending: false, + timelineNeedsRefresh: this.props.room.getTimelineNeedsRefresh(), + isRefreshing: false, + refreshError: undefined, }; } @@ -102,12 +112,22 @@ export default class RoomStatusBar extends React.PureComponent { const client = this.context; client.on("sync", this.onSyncStateChange); client.on("Room.localEchoUpdated", this.onRoomLocalEchoUpdated); + this.props.room.on(RoomEvent.HistoryImportedWithinTimeline, this.onRoomHistoryImportedWithinTimeline); this.checkSize(); } - public componentDidUpdate(): void { + public componentDidUpdate(prevProps: IProps): void { this.checkSize(); + + // When the room changes, setup the new listener + if (prevProps.room !== this.props.room) { + prevProps.room.removeListener( + RoomEvent.HistoryImportedWithinTimeline, + this.onRoomHistoryImportedWithinTimeline, + ); + this.props.room.on(RoomEvent.HistoryImportedWithinTimeline, this.onRoomHistoryImportedWithinTimeline); + } } public componentWillUnmount(): void { @@ -118,6 +138,11 @@ export default class RoomStatusBar extends React.PureComponent { client.removeListener("sync", this.onSyncStateChange); client.removeListener("Room.localEchoUpdated", this.onRoomLocalEchoUpdated); } + + this.props.room.removeListener( + RoomEvent.HistoryImportedWithinTimeline, + this.onRoomHistoryImportedWithinTimeline, + ); } private onSyncStateChange = (state: SyncState, prevState: SyncState, data: ISyncStateData): void => { @@ -144,6 +169,30 @@ export default class RoomStatusBar extends React.PureComponent { dis.fire(Action.FocusSendMessageComposer); }; + private onRefreshTimelineClick = async (): Promise => { + this.setState({ + isRefreshing: true, + refreshError: undefined, + }); + try { + // Empty out the current timeline and re-request it + await this.props.room.refreshLiveTimeline(); + + this.setState({ + timelineNeedsRefresh: false, + }); + } catch (err) { + logger.error('Error while refresing the timeline:', err); + this.setState({ + refreshError: err as Error, + }); + } finally { + this.setState({ + isRefreshing: false, + }); + } + }; + private onRoomLocalEchoUpdated = (ev: MatrixEvent, room: Room) => { if (room.roomId !== this.props.room.roomId) return; const messages = getUnsentMessages(this.props.room); @@ -153,6 +202,14 @@ export default class RoomStatusBar extends React.PureComponent { }); }; + private onRoomHistoryImportedWithinTimeline = (markerEv: MatrixEvent, room: Room) => { + if (room.roomId !== this.props.room.roomId) return; + + this.setState({ + timelineNeedsRefresh: room.getTimelineNeedsRefresh(), + }); + }; + // Check whether current size is greater than 0, if yes call props.onVisible private checkSize(): void { if (this.getSize()) { @@ -168,7 +225,11 @@ export default class RoomStatusBar extends React.PureComponent { private getSize(): number { if (this.shouldShowConnectionError()) { return STATUS_BAR_EXPANDED; - } else if (this.state.unsentMessages.length > 0 || this.state.isResending) { + } else if ( + this.state.unsentMessages.length > 0 || + this.state.isResending || + this.state.timelineNeedsRefresh + ) { return STATUS_BAR_EXPANDED_LARGE; } return STATUS_BAR_HIDDEN; @@ -192,8 +253,8 @@ export default class RoomStatusBar extends React.PureComponent { let title; - let consentError = null; - let resourceLimitError = null; + let consentError: MatrixError | undefined; + let resourceLimitError: MatrixError | undefined; for (const m of unsentMessages) { if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') { consentError = m.error; @@ -204,36 +265,55 @@ export default class RoomStatusBar extends React.PureComponent { } } if (consentError) { - title = _t( - "You can't send any messages until you review and agree to " + - "our terms and conditions.", - {}, - { - 'consentLink': (sub) => - - { sub } - , - }, - ); + const consentUri = consentError.data.consent_uri; + if (consentUri) { + title = _t( + "You can't send any messages until you review and agree to " + + "the terms and conditions.", + {}, + { + 'consentLink': (sub) => + + { sub } + , + }, + ); + } else { + title = _t( + "You can't send any messages until you review and agree to " + + "the terms and conditions but the homeserver did not provide " + + "a link to consent. Contact your homeserver administrator.", + ); + } } else if (resourceLimitError) { - title = messageForResourceLimitError( - resourceLimitError.data.limit_type, - resourceLimitError.data.admin_contact, - { - 'monthly_active_user': _td( - "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " + - "Please contact your service administrator to continue using the service.", - ), - 'hs_disabled': _td( - "Your message wasn't sent because this homeserver has been blocked by its administrator. " + - "Please contact your service administrator to continue using the service.", - ), - '': _td( - "Your message wasn't sent because this homeserver has exceeded a resource limit. " + - "Please contact your service administrator to continue using the service.", - ), - }, - ); + const limitType = resourceLimitError.data.limit_type; + if (limitType) { + const adminContact = resourceLimitError.data.admin_contact; + title = messageForResourceLimitError( + limitType, + adminContact, + { + 'monthly_active_user': _td( + "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " + + "Please contact your service administrator to continue using the service.", + ), + 'hs_disabled': _td( + "Your message wasn't sent because this homeserver has been blocked by its administrator. " + + "Please contact your service administrator to continue using the service.", + ), + '': _td( + "Your message wasn't sent because this homeserver has exceeded a resource limit. " + + "Please contact your service administrator to continue using the service.", + ), + }, + ); + } else { + title = _t( + "Your message wasn't sent because it ran into a M_RESOURCE_LIMIT_EXCEEDED error. " + + "We were unable to determine the exact type of error because the response did not include it. " + + "Contact your homeserver administrator.", + ); + } } else { title = _t('Some of your messages have not been sent'); } @@ -262,7 +342,98 @@ export default class RoomStatusBar extends React.PureComponent { />; } - public render(): JSX.Element { + private getRefreshTimelineContent(): JSX.Element { + let buttonRow = <> + + { _t("Refresh timeline") } + + ; + if (this.state.isRefreshing) { + buttonRow = <> + + { /* span for css */ } + { _t("Refreshing") } + ; + } + + let errorContent: JSX.Element = <>; + if (this.state.refreshError) { + let errorTextContent; + let submitDebugLogsTextContent; + if (this.state.refreshError.name === "ConnectionError") { + errorTextContent = <> + { _t("A network error occurred while trying to refresh the timeline. " + + "Your homeserver might be down or was just a temporary problem with your " + + "internet connection.") } + ; + } else { + errorTextContent = <> + { _t("An error occurred while trying to refresh the timeline.") } + ; + + // We only give the option to submit logs for actual errors, not network problems. + submitDebugLogsTextContent = <> + { _t("Please submit debug logs to help us " + + "track down the problem.", {}, { + debugLogsLink: sub => ( + + { sub } + + ), + }) } + ; + } + + errorContent = <> +
+
+ { errorTextContent } + { " " } + { submitDebugLogsTextContent } +
+ ; + } + + return ( +
+
+
+ +
+
+
+ { _t("History import detected.") } +
+
+ { _t("History was just imported somewhere in the room. " + + "In order to see the historical messages, refresh your timeline.") } +
+ { errorContent } +
+
+ { buttonRow } +
+
+
+ ); + } + + private onBugReport = (): void => { + Modal.createDialog(BugReportDialog, { + error: this.state.refreshError, + initialText: 'Error occured while refreshing the timeline', + }); + }; + + public render(): JSX.Element | null { if (this.shouldShowConnectionError()) { return (
@@ -272,8 +443,7 @@ export default class RoomStatusBar extends React.PureComponent { src={require("../../../res/img/feather-customised/warning-triangle.svg").default} width="24" height="24" - title="/!\ " - alt="/!\ " /> + alt="" />
{ _t('Connectivity to the server has been lost.') } @@ -292,6 +462,10 @@ export default class RoomStatusBar extends React.PureComponent { return this.getUnsentMessageContent(); } + if (this.state.timelineNeedsRefresh) { + return this.getRefreshTimelineContent(); + } + return null; } } diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index 202966434f7..a8676685c09 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -937,7 +937,7 @@ export default class ScrollPanel extends React.Component { > { this.props.fixedChildren }
-
    +
      { this.props.children }
diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 06b866a49e7..7ca91108d04 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -186,7 +186,7 @@ interface IState { forwardPaginating: boolean; // cache of matrixClient.getSyncState() (but from the 'sync' event) - clientSyncState: SyncState; + clientSyncState: SyncState | null; // should the event tiles have twelve hour times isTwelveHour: boolean; @@ -237,8 +237,8 @@ class TimelinePanel extends React.Component { private readonly dispatcherRef: string; private timelineWindow?: TimelineWindow; private unmounted = false; - private readReceiptActivityTimer: Timer; - private readMarkerActivityTimer: Timer; + private readReceiptActivityTimer: Timer | null = null; + private readMarkerActivityTimer: Timer | null = null; // A map of private callEventGroupers = new Map(); @@ -253,7 +253,7 @@ class TimelinePanel extends React.Component { // but for now we just do it per room for simplicity. let initialReadMarker: string | null = null; if (this.props.manageReadMarkers) { - const readmarker = this.props.timelineSet.room.getAccountData('m.fully_read'); + const readmarker = this.props.timelineSet.room?.getAccountData('m.fully_read'); if (readmarker) { initialReadMarker = readmarker.getContent().event_id; } else { @@ -283,6 +283,10 @@ class TimelinePanel extends React.Component { const cli = MatrixClientPeg.get(); cli.on(RoomEvent.Timeline, this.onRoomTimeline); cli.on(RoomEvent.TimelineReset, this.onRoomTimelineReset); + const room = this.props.timelineSet.room; + if (room) { + room.on(RoomEvent.TimelineRefresh, this.onRoomTimelineRefresh); + } cli.on(RoomEvent.Redaction, this.onRoomRedaction); if (SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation")) { // Make sure that events are re-rendered when their visibility-pending-moderation changes. @@ -340,6 +344,20 @@ class TimelinePanel extends React.Component { } } + public componentDidUpdate(prevProps: IProps): void { + // When the room changes, setup the new listener + if (prevProps.timelineSet.room !== this.props.timelineSet.room) { + const prevRoom = prevProps.timelineSet.room; + if (prevRoom) { + prevRoom.removeListener(RoomEvent.TimelineRefresh, this.onRoomTimelineRefresh); + } + const newRoom = this.props.timelineSet.room; + if (newRoom) { + newRoom.on(RoomEvent.TimelineRefresh, this.onRoomTimelineRefresh); + } + } + } + componentWillUnmount() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. @@ -373,6 +391,11 @@ class TimelinePanel extends React.Component { client.removeListener(ClientEvent.Sync, this.onSync); this.props.timelineSet.room?.removeListener(ThreadEvent.Update, this.onThreadUpdate); } + + const room = this.props.timelineSet.room; + if (room) { + room.removeListener(RoomEvent.TimelineRefresh, this.onRoomTimelineRefresh); + } } /** @@ -393,7 +416,7 @@ class TimelinePanel extends React.Component { // Get the list of actually rendered events seen in the DOM. // This is useful to know for sure what's being shown on screen. // And we can suss out any corrupted React `key` problems. - let renderedEventIds: string[]; + let renderedEventIds: string[] = []; try { const messagePanel = this.messagePanel.current; if (messagePanel) { @@ -401,7 +424,7 @@ class TimelinePanel extends React.Component { if (messagePanelNode) { const actuallyRenderedEvents = messagePanelNode.querySelectorAll('[data-event-id]'); renderedEventIds = [...actuallyRenderedEvents].map((renderedEvent) => { - return renderedEvent.getAttribute('data-event-id'); + return renderedEvent.getAttribute('data-event-id')!; }); } } @@ -411,8 +434,8 @@ class TimelinePanel extends React.Component { // Get the list of events and threads for the room as seen by the // matrix-js-sdk. - let serializedEventIdsFromTimelineSets: { [key: string]: string[] }[]; - let serializedEventIdsFromThreadsTimelineSets: { [key: string]: string[] }[]; + let serializedEventIdsFromTimelineSets: { [key: string]: string[] }[] = []; + let serializedEventIdsFromThreadsTimelineSets: { [key: string]: string[] }[] = []; const serializedThreadsMap: { [key: string]: any } = {}; if (room) { const timelineSets = room.getTimelineSets(); @@ -444,15 +467,15 @@ class TimelinePanel extends React.Component { } } - let timelineWindowEventIds: string[]; + let timelineWindowEventIds: string[] = []; try { - timelineWindowEventIds = this.timelineWindow.getEvents().map(ev => ev.getId()); + timelineWindowEventIds = this.timelineWindow?.getEvents().map(ev => ev.getId()!) ?? []; } catch (err) { logger.error(`onDumpDebugLogs: Failed to get event IDs from the timelineWindow`, err); } - let pendingEventIds: string[]; + let pendingEventIds: string[] = []; try { - pendingEventIds = this.props.timelineSet.getPendingEvents().map(ev => ev.getId()); + pendingEventIds = this.props.timelineSet.getPendingEvents().map(ev => ev.getId()!); } catch (err) { logger.error(`onDumpDebugLogs: Failed to get pending event IDs`, err); } @@ -727,15 +750,22 @@ class TimelinePanel extends React.Component { }); }; + private onRoomTimelineRefresh = (room: Room, timelineSet: EventTimelineSet): void => { + debuglog(`onRoomTimelineRefresh skipping=${timelineSet !== this.props.timelineSet}`); + if (timelineSet !== this.props.timelineSet) return; + + this.refreshTimeline(); + }; + private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet): void => { if (timelineSet !== this.props.timelineSet) return; - if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) { + if (this.canResetTimeline()) { this.loadTimeline(); } }; - public canResetTimeline = () => this.messagePanel?.current.isAtBottom(); + public canResetTimeline = () => !!this.messagePanel?.current?.isAtBottom(); private onRoomRedaction = (ev: MatrixEvent, room: Room): void => { if (this.unmounted) return; @@ -780,9 +810,14 @@ class TimelinePanel extends React.Component { return; } + const eventId = ev.getId(); + if (!eventId) { + return; + } + // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. - const tile = this.messagePanel.current?.getTileForEventId(ev.getId()); + const tile = this.messagePanel.current?.getTileForEventId(eventId); if (tile) { tile.forceUpdate(); } @@ -800,7 +835,12 @@ class TimelinePanel extends React.Component { // We could skip an update if the power level change didn't cross the // threshold for `VISIBILITY_CHANGE_TYPE`. for (const event of this.state.events) { - const tile = this.messagePanel.current?.getTileForEventId(event.getId()); + const stateEventId = event.getId(); + if (!stateEventId) { + continue; + } + + const tile = this.messagePanel.current?.getTileForEventId(stateEventId); if (!tile) { // The event is not visible, nothing to re-render. continue; @@ -1789,7 +1829,7 @@ class TimelinePanel extends React.Component { // the HS and fetch the latest events, so we are effectively forward paginating. const forwardPaginating = ( this.state.forwardPaginating || - ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState) + ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState!) ); const events = this.state.firstVisibleEventIndex ? this.state.events.slice(this.state.firstVisibleEventIndex) diff --git a/src/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx index 83dbf0d45a8..c943dd9fb68 100644 --- a/src/components/views/rooms/ThreadSummary.tsx +++ b/src/components/views/rooms/ThreadSummary.tsx @@ -52,6 +52,7 @@ const ThreadSummary = ({ mxEvent, thread, ...props }: IProps) => { { defaultDispatcher.dispatch({ action: Action.ShowThread, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 376133905dd..051b13a0245 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3307,14 +3307,23 @@ "Search failed": "Search failed", "Server may be unavailable, overloaded, or search timed out :(": "Server may be unavailable, overloaded, or search timed out :(", "No more results": "No more results", - "You can't send any messages until you review and agree to our terms and conditions.": "You can't send any messages until you review and agree to our terms and conditions.", + "You can't send any messages until you review and agree to the terms and conditions.": "You can't send any messages until you review and agree to the terms and conditions.", + "You can't send any messages until you review and agree to the terms and conditions but the homeserver did not provide a link to consent. Contact your homeserver administrator.": "You can't send any messages until you review and agree to the terms and conditions but the homeserver did not provide a link to consent. Contact your homeserver administrator.", "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.", "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by its administrator. Please contact your service administrator to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.", + "Your message wasn't sent because it ran into a M_RESOURCE_LIMIT_EXCEEDED error. We were unable to determine the exact type of error because the response did not include it. Contact your homeserver administrator.": "Your message wasn't sent because it ran into a M_RESOURCE_LIMIT_EXCEEDED error. We were unable to determine the exact type of error because the response did not include it. Contact your homeserver administrator.", "Some of your messages have not been sent": "Some of your messages have not been sent", "Delete all": "Delete all", "Retry all": "Retry all", "You can select all or individual messages to retry or delete": "You can select all or individual messages to retry or delete", + "Refresh timeline": "Refresh timeline", + "Refreshing": "Refreshing", + "A network error occurred while trying to refresh the timeline. Your homeserver might be down or was just a temporary problem with your internet connection.": "A network error occurred while trying to refresh the timeline. Your homeserver might be down or was just a temporary problem with your internet connection.", + "An error occurred while trying to refresh the timeline.": "An error occurred while trying to refresh the timeline.", + "Please submit debug logs to help us track down the problem.": "Please submit debug logs to help us track down the problem.", + "History import detected.": "History import detected.", + "History was just imported somewhere in the room. In order to see the historical messages, refresh your timeline.": "History was just imported somewhere in the room. In order to see the historical messages, refresh your timeline.", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "We're creating a room with %(names)s": "We're creating a room with %(names)s", diff --git a/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts index 060bea34b66..d21b1c369cf 100644 --- a/src/indexing/EventIndex.ts +++ b/src/indexing/EventIndex.ts @@ -806,7 +806,9 @@ export default class EventIndex extends EventEmitter { // Add the events to the timeline of the file panel. matrixEvents.forEach(e => { if (!timelineSet.eventIdToTimeline(e.getId())) { - timelineSet.addEventToTimeline(e, timeline, direction == EventTimeline.BACKWARDS); + timelineSet.addEventToTimeline(e, timeline, { + toStartOfTimeline: direction == EventTimeline.BACKWARDS, + }); } }); diff --git a/src/utils/ErrorUtils.tsx b/src/utils/ErrorUtils.tsx index ff78fe076c4..3c2dae317aa 100644 --- a/src/utils/ErrorUtils.tsx +++ b/src/utils/ErrorUtils.tsx @@ -35,7 +35,7 @@ import { _t, _td, Tags, TranslatedString } from '../languageHandler'; */ export function messageForResourceLimitError( limitType: string, - adminContact: string, + adminContact: string | null | undefined, strings: Record, extraTranslations?: Tags, ): TranslatedString { diff --git a/test/components/structures/RoomStatusBar-test.tsx b/test/components/structures/RoomStatusBar-test.tsx index db8b0e03ffd..432f8729909 100644 --- a/test/components/structures/RoomStatusBar-test.tsx +++ b/test/components/structures/RoomStatusBar-test.tsx @@ -14,40 +14,73 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; -import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import React from "react"; +import { render, fireEvent, waitFor } from "@testing-library/react"; +import { act } from 'react-dom/test-utils'; import { Room } from "matrix-js-sdk/src/models/room"; +import { PendingEventOrdering } from 'matrix-js-sdk/src/client'; +import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { + ISyncStateData, SyncState, +} from 'matrix-js-sdk/src/sync'; +import { MatrixError } from "matrix-js-sdk/src/http-api"; +import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; +import RoomStatusBar from "../../../src/components/structures/RoomStatusBar"; import { getUnsentMessages } from "../../../src/components/structures/RoomStatusBar"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { mkEvent, stubClient } from "../../test-utils/test-utils"; import { mkThread } from "../../test-utils/threads"; -describe("RoomStatusBar", () => { - const ROOM_ID = "!roomId:example.org"; - let room: Room; - let client: MatrixClient; - let event: MatrixEvent; +// Fake date to give a predictable snapshot +const realDateNow = Date.now; +const realDateToISOString = Date.prototype.toISOString; +Date.now = jest.fn(() => 2345678901234); +// eslint-disable-next-line no-extend-native +Date.prototype.toISOString = jest.fn(() => "2021-11-23T14:35:14.240Z"); - beforeEach(() => { - jest.clearAllMocks(); +afterAll(() => { + Date.now = realDateNow; + // eslint-disable-next-line no-extend-native + Date.prototype.toISOString = realDateToISOString; +}); +describe("RoomStatusBar", () => { + let client; + beforeEach(() => { stubClient(); client = MatrixClientPeg.get(); - room = new Room(ROOM_ID, client, client.getUserId(), { - pendingEventOrdering: PendingEventOrdering.Detached, - }); - event = mkEvent({ - event: true, - type: "m.room.message", - user: "@user1:server", - room: "!room1:server", - content: {}, - }); - event.status = EventStatus.NOT_SENT; }); + const customRender = (room: Room) => { + return render( + + + , + ); + }; + describe("getUnsentMessages", () => { + const ROOM_ID = "!roomId:example.org"; + let room: Room; + let event: MatrixEvent; + + beforeEach(() => { + jest.clearAllMocks(); + + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + event = mkEvent({ + event: true, + type: "m.room.message", + user: "@user1:server", + room: "!room1:server", + content: {}, + }); + event.status = EventStatus.NOT_SENT; + }); + it("returns no unsent messages", () => { expect(getUnsentMessages(room)).toHaveLength(0); }); @@ -88,4 +121,139 @@ describe("RoomStatusBar", () => { expect(pendingEvents.every(ev => ev.getId() !== event.getId())).toBe(true); }); }); + + it('does not show anything when no sync error or other status', () => { + const r1 = new Room("r1", client, "@name:example.com", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const wrapper = customRender(r1); + expect(wrapper.asFragment()).toMatchSnapshot(); + }); + + describe('connectivity lost bar', () => { + it('should show connection lost bar when sync has an error', () => { + client.getSyncState = (): SyncState => SyncState.Error, + client.getSyncStateData = (): ISyncStateData => ({ + error: new MatrixError({ + errcode: 'FAKE_ERROR', + error: "Fake sync error", + }), + }); + + const r1 = new Room("r1", client, "@name:example.com", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const wrapper = customRender(r1); + expect(wrapper.asFragment()).toMatchSnapshot(); + }); + + it('connectivity lost bar has priority over the timeline refresh bar', () => { + // Show connectivity lost bar + client.getSyncState = (): SyncState => SyncState.Error, + client.getSyncStateData = (): ISyncStateData => ({ + error: new MatrixError({ + errcode: 'FAKE_ERROR', + error: "Fake sync error", + }), + }); + + const r1 = new Room("r1", client, "@name:example.com", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + // Show timeline needs refresh bar + r1.setTimelineNeedsRefresh(true); + + const wrapper = customRender(r1); + expect(wrapper.asFragment()).toMatchSnapshot(); + }); + }); + + describe('timeline needs refresh bar (history import)', () => { + it('should show timeline refresh bar when history import detected', () => { + const r1 = new Room("r1", client, "@name:example.com", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + // Show timeline needs refresh bar + r1.setTimelineNeedsRefresh(true); + + const wrapper = customRender(r1); + expect(wrapper.asFragment()).toMatchSnapshot(); + }); + + it('should refresh timeline for room when button clicked', () => { + const r1 = new Room("r1", client, "@name:example.com", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + // Show timeline needs refresh bar + r1.setTimelineNeedsRefresh(true); + + r1.refreshLiveTimeline = jest.fn(); + + const wrapper = customRender(r1); + + act(() => { + fireEvent.click(wrapper.getByTestId('refresh-timeline-button')); + }); + + // Make sure that the SDK was called to refresh the timeline + expect(r1.refreshLiveTimeline).toHaveBeenCalled(); + }); + + it('should show error state with option to submit debug logs ' + + 'in timeline refresh bar when something went wrong while refreshing', async () => { + const r1 = new Room("r1", client, "@name:example.com", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + // Show timeline needs refresh bar + r1.setTimelineNeedsRefresh(true); + + const wrapper = customRender(r1); + + r1.refreshLiveTimeline = jest.fn().mockRejectedValue(new Error('Fake error in test')); + act(() => { + fireEvent.click(wrapper.getByTestId('refresh-timeline-button')); + }); + + // Make sure that the SDK was called to refresh the timeline + expect(r1.refreshLiveTimeline).toHaveBeenCalled(); + + // Expect error to be shown. We have to wait for the UI to transition. + await waitFor(() => expect(wrapper.getByTestId('historical-import-detected-error-content')).toBeDefined()); + + // Expect an option to submit debug logs to be shown + expect(wrapper.getByTestId('historical-import-detected-error-submit-debug-logs-button')).toBeDefined(); + }); + + it('should show error state without submit debug logs option ' + + 'in timeline refresh bar when ConnectionError while refreshing', async () => { + const r1 = new Room("r1", client, "@name:example.com", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + // Show timeline needs refresh bar + r1.setTimelineNeedsRefresh(true); + + const wrapper = customRender(r1); + + const connectionError = new Error('Fake connection error in test'); + connectionError.name = "ConnectionError"; + r1.refreshLiveTimeline = jest.fn().mockRejectedValue(connectionError); + act(() => { + fireEvent.click(wrapper.getByTestId('refresh-timeline-button')); + }); + + // Make sure that the SDK was called to refresh the timeline + expect(r1.refreshLiveTimeline).toHaveBeenCalled(); + + // Expect error to be shown + await waitFor(() => expect(wrapper.getByTestId('historical-import-detected-error-content')).toBeDefined()); + + // The submit debug logs option should *NOT* be shown. + // + // We have to use `queryBy` so that it can return `null` for something that does not exist. + expect(wrapper.queryByTestId('historical-import-detected-error-submit-debug-logs-button]')).toBeNull(); + }); + }); }); diff --git a/test/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap b/test/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap new file mode 100644 index 00000000000..65bbf66ad1e --- /dev/null +++ b/test/components/structures/__snapshots__/RoomStatusBar-test.tsx.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RoomStatusBar connectivity lost bar connectivity lost bar has priority over the timeline refresh bar 1`] = ` + +
+
+
+ +
+
+ Connectivity to the server has been lost. +
+
+ Sent messages will be stored until your connection has returned. +
+
+
+
+
+
+`; + +exports[`RoomStatusBar connectivity lost bar should show connection lost bar when sync has an error 1`] = ` + +
+
+
+ +
+
+ Connectivity to the server has been lost. +
+
+ Sent messages will be stored until your connection has returned. +
+
+
+
+
+
+`; + +exports[`RoomStatusBar does not show anything when no sync error or other status 1`] = ``; + +exports[`RoomStatusBar timeline needs refresh bar (history import) should show timeline refresh bar when history import detected 1`] = ` + +
+
+
+ +
+
+
+ History import detected. +
+
+ History was just imported somewhere in the room. In order to see the historical messages, refresh your timeline. +
+
+
+
+ Refresh timeline +
+
+
+
+
+`; diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index 52804a51361..2730bc2faa5 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -2,8 +2,8 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
@user:example.com
We're creating a room with @user:example.com
"`; -exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.

    @user:example.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; +exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.

    @user:example.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; -exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; -exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. @user:example.com

    Send your first message to invite @user:example.com to chat


"`; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index e2186295472..4e0ab35c8dd 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -34,6 +34,9 @@ import { RoomType, KNOWN_SAFE_ROOM_VERSION, } from 'matrix-js-sdk/src/matrix'; +import { + SyncState, +} from 'matrix-js-sdk/src/sync'; import { normalize } from "matrix-js-sdk/src/utils"; import { ReEmitter } from "matrix-js-sdk/src/ReEmitter"; import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; @@ -147,7 +150,8 @@ export function createTestClient(): MatrixClient { sendTyping: jest.fn().mockResolvedValue({}), sendMessage: jest.fn().mockResolvedValue({}), sendStateEvent: jest.fn().mockResolvedValue(undefined), - getSyncState: jest.fn().mockReturnValue("SYNCING"), + getSyncState: jest.fn().mockReturnValue(SyncState.Syncing), + getSyncStateData: jest.fn().mockReturnValue({}), generateClientSecret: () => "t35tcl1Ent5ECr3T", isGuest: jest.fn().mockReturnValue(false), getRoomHierarchy: jest.fn().mockReturnValue({