From 8b54be6f48631083cb853cda5def60d438daa14f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 12 Oct 2022 18:59:07 +0100 Subject: [PATCH] Move from `browser-request` to `fetch` (#9345) --- __mocks__/browser-request.js | 81 ------ cypress/e2e/spotlight/spotlight.spec.ts | 23 +- cypress/e2e/timeline/timeline.spec.ts | 6 +- cypress/support/bot.ts | 3 - cypress/support/client.ts | 15 +- package.json | 5 +- src/AddThreepid.ts | 4 +- src/ContentMessages.ts | 267 +++++++----------- src/Lifecycle.ts | 2 +- src/Login.ts | 2 +- src/ScalarAuthClient.ts | 164 +++++------ src/components/structures/EmbeddedPage.tsx | 60 ++-- src/components/structures/TimelinePanel.tsx | 2 +- src/components/structures/UploadBar.tsx | 15 +- src/components/structures/auth/Login.tsx | 4 +- .../views/dialogs/ChangelogDialog.tsx | 30 +- src/components/views/dialogs/InviteDialog.tsx | 7 +- .../dialogs/SlidingSyncOptionsDialog.tsx | 15 +- .../views/elements/MiniAvatarUploader.tsx | 2 +- .../room_settings/RoomProfileSettings.tsx | 2 +- src/components/views/rooms/RoomPreviewBar.tsx | 1 - .../views/settings/ChangeAvatar.tsx | 4 +- .../views/settings/ProfileSettings.tsx | 2 +- src/createRoom.ts | 2 +- src/customisations/Media.ts | 2 +- .../models/IMediaEventContent.ts | 3 +- src/dispatcher/payloads/UploadPayload.ts | 4 +- src/i18n/strings/en_EN.json | 2 +- src/languageHandler.tsx | 73 ++--- src/models/IUpload.ts | 28 -- src/models/RoomUpload.ts | 53 ++++ src/utils/MultiInviter.ts | 2 +- .../models/VoiceBroadcastRecording.ts | 4 +- test/ContentMessages-test.ts | 245 +++++++++++++++- test/ScalarAuthClient-test.ts | 196 +++++++++++-- test/audio/VoiceMessageRecording-test.ts | 6 +- .../views/context_menus/EmbeddedPage-test.tsx | 58 ++++ .../__snapshots__/EmbeddedPage-test.tsx.snap | 43 +++ .../views/dialogs/ChangelogDialog-test.tsx | 104 +++++++ .../views/dialogs/InviteDialog-test.tsx | 61 +++- .../ChangelogDialog-test.tsx.snap | 135 +++++++++ .../views/rooms/RoomPreviewBar-test.tsx | 2 +- test/createRoom-test.ts | 14 + test/i18n-test/languageHandler-test.tsx | 5 +- test/setup/setupLanguage.ts | 63 +++++ test/setup/setupManualMocks.ts | 1 + test/setupTests.js | 3 +- test/test-utils/test-utils.ts | 3 +- test/utils/MultiInviter-test.ts | 14 +- yarn.lock | 219 +++++++++++--- 50 files changed, 1464 insertions(+), 597 deletions(-) delete mode 100644 __mocks__/browser-request.js delete mode 100644 src/models/IUpload.ts create mode 100644 src/models/RoomUpload.ts create mode 100644 test/components/views/context_menus/EmbeddedPage-test.tsx create mode 100644 test/components/views/context_menus/__snapshots__/EmbeddedPage-test.tsx.snap create mode 100644 test/components/views/dialogs/ChangelogDialog-test.tsx create mode 100644 test/components/views/dialogs/__snapshots__/ChangelogDialog-test.tsx.snap diff --git a/__mocks__/browser-request.js b/__mocks__/browser-request.js deleted file mode 100644 index 7029f1c1909..00000000000 --- a/__mocks__/browser-request.js +++ /dev/null @@ -1,81 +0,0 @@ -/* -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. -*/ - -const en = require("../src/i18n/strings/en_EN"); -const de = require("../src/i18n/strings/de_DE"); -const lv = { - "Save": "Saglabāt", - "Uploading %(filename)s and %(count)s others|one": "Качване на %(filename)s и %(count)s друг", -}; - -function weblateToCounterpart(inTrs) { - const outTrs = {}; - - for (const key of Object.keys(inTrs)) { - const keyParts = key.split('|', 2); - if (keyParts.length === 2) { - let obj = outTrs[keyParts[0]]; - if (obj === undefined) { - obj = outTrs[keyParts[0]] = {}; - } else if (typeof obj === "string") { - // This is a transitional edge case if a string went from singular to pluralised and both still remain - // in the translation json file. Use the singular translation as `other` and merge pluralisation atop. - obj = outTrs[keyParts[0]] = { - "other": inTrs[key], - }; - console.warn("Found entry in i18n file in both singular and pluralised form", keyParts[0]); - } - obj[keyParts[1]] = inTrs[key]; - } else { - outTrs[key] = inTrs[key]; - } - } - - return outTrs; -} - -// Mock the browser-request for the languageHandler tests to return -// Fake languages.json containing references to en_EN, de_DE and lv -// en_EN.json -// de_DE.json -// lv.json - mock version with few translations, used to test fallback translation -module.exports = jest.fn((opts, cb) => { - const url = opts.url || opts.uri; - if (url && url.endsWith("languages.json")) { - cb(undefined, { status: 200 }, JSON.stringify({ - "en": { - "fileName": "en_EN.json", - "label": "English", - }, - "de": { - "fileName": "de_DE.json", - "label": "German", - }, - "lv": { - "fileName": "lv.json", - "label": "Latvian", - }, - })); - } else if (url && url.endsWith("en_EN.json")) { - cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(en))); - } else if (url && url.endsWith("de_DE.json")) { - cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(de))); - } else if (url && url.endsWith("lv.json")) { - cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(lv))); - } else { - cb(true, { status: 404 }, ""); - } -}); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index 71340d44f66..adcc141deb6 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -124,7 +124,8 @@ Cypress.Commands.add("startDM", (name: string) => { cy.get(".mx_BasicMessageComposer_input") .should("have.focus") .type("Hey!{enter}"); - cy.contains(".mx_EventTile_body", "Hey!"); + // The DM room is created at this point, this can take a little bit of time + cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 }); cy.contains(".mx_RoomSublist[aria-label=People]", name); }); @@ -217,7 +218,7 @@ describe("Spotlight", () => { it("should find joined rooms", () => { cy.openSpotlightDialog().within(() => { cy.spotlightSearch().clear().type(room1Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).click(); @@ -231,7 +232,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room1Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).should("contain", "View"); @@ -246,7 +247,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room2Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room2Name); cy.spotlightResults().eq(0).should("contain", "Join"); @@ -262,7 +263,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.PublicRooms); cy.spotlightSearch().clear().type(room3Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", room3Name); cy.spotlightResults().eq(0).should("contain", "View"); @@ -301,7 +302,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot1Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot1Name); cy.spotlightResults().eq(0).click(); @@ -314,7 +315,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); @@ -331,7 +332,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); @@ -345,7 +346,7 @@ describe("Spotlight", () => { .type("Hey!{enter}"); // Assert DM exists by checking for the first message and the room being in the room list - cy.contains(".mx_EventTile_body", "Hey!"); + cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 }); cy.get(".mx_RoomSublist[aria-label=People]").should("contain", bot2Name); // Invite BotBob into existing DM with ByteBot @@ -409,7 +410,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot2Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.get(".mx_SpotlightDialog_startGroupChat").should("contain", "Start a group chat"); @@ -431,7 +432,7 @@ describe("Spotlight", () => { cy.openSpotlightDialog().within(() => { cy.spotlightFilter(Filter.People); cy.spotlightSearch().clear().type(bot1Name); - cy.wait(1000); // wait for the dialog code to settle + cy.wait(3000); // wait for the dialog code to settle cy.get(".mx_Spinner").should("not.exist"); cy.spotlightResults().should("have.length", 1); }); diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 94b6ffaa425..6cebbfd1814 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -91,11 +91,11 @@ describe("Timeline", () => { describe("useOnlyCurrentProfiles", () => { beforeEach(() => { - cy.uploadContent(OLD_AVATAR).then((url) => { + cy.uploadContent(OLD_AVATAR).then(({ content_uri: url }) => { oldAvatarUrl = url; cy.setAvatarUrl(url); }); - cy.uploadContent(NEW_AVATAR).then((url) => { + cy.uploadContent(NEW_AVATAR).then(({ content_uri: url }) => { newAvatarUrl = url; }); }); @@ -271,7 +271,7 @@ describe("Timeline", () => { cy.get(".mx_RoomHeader_searchButton").click(); cy.get(".mx_SearchBar_input input").type("Message{enter}"); - cy.get(".mx_EventTile:not(.mx_EventTile_contextual)").find(".mx_EventTile_searchHighlight").should("exist"); + cy.get(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight").should("exist"); cy.get(".mx_RoomView_searchResultsPanel").percySnapshotElement("Highlighted search results"); }); diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index 35da14ebd73..26f0aa497e4 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -16,8 +16,6 @@ limitations under the License. /// -import request from "browser-request"; - import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { SynapseInstance } from "../plugins/synapsedocker"; import Chainable = Cypress.Chainable; @@ -86,7 +84,6 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): userId: credentials.userId, deviceId: credentials.deviceId, accessToken: credentials.accessToken, - request, store: new win.matrixcs.MemoryStore(), scheduler: new win.matrixcs.MatrixScheduler(), cryptoStore: new win.matrixcs.MemoryCryptoStore(), diff --git a/cypress/support/client.ts b/cypress/support/client.ts index c3f3aab0eb6..e20c08a8139 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -16,9 +16,8 @@ limitations under the License. /// -import type { FileType, UploadContentResponseType } from "matrix-js-sdk/src/http-api"; -import type { IAbortablePromise } from "matrix-js-sdk/src/@types/partials"; -import type { ICreateRoomOpts, ISendEventResponse, IUploadOpts } from "matrix-js-sdk/src/@types/requests"; +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 { IContent } from "matrix-js-sdk/src/models/event"; @@ -90,10 +89,10 @@ declare global { * can be sent to XMLHttpRequest.send (typically a File). Under node.js, * a a Buffer, String or ReadStream. */ - uploadContent( + uploadContent( file: FileType, - opts?: O, - ): IAbortablePromise>; + opts?: UploadOpts, + ): Chainable>; /** * Turn an MXC URL into an HTTP one. This method is experimental and * may change. @@ -203,9 +202,9 @@ Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => { }); }); -Cypress.Commands.add("uploadContent", (file: FileType): Chainable<{}> => { +Cypress.Commands.add("uploadContent", (file: FileType, opts?: UploadOpts): Chainable> => { return cy.getClient().then(async (cli: MatrixClient) => { - return cli.uploadContent(file); + return cli.uploadContent(file, opts); }); }); diff --git a/package.json b/package.json index 3df9a77d864..402e6e13da3 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,6 @@ "@types/ua-parser-js": "^0.7.36", "await-lock": "^2.1.0", "blurhash": "^1.1.3", - "browser-request": "^0.3.3", "cheerio": "^1.0.0-rc.9", "classnames": "^2.2.6", "commonmark": "^0.29.3", @@ -190,16 +189,16 @@ "eslint-plugin-matrix-org": "^0.6.1", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", + "fetch-mock-jest": "^1.5.1", "fs-extra": "^10.0.1", "glob": "^7.1.6", "jest": "^27.4.0", "jest-canvas-mock": "^2.3.0", "jest-environment-jsdom": "^27.0.6", - "jest-fetch-mock": "^3.0.3", "jest-mock": "^27.5.1", "jest-raw-loader": "^1.0.1", "jest-sonar-reporter": "^2.0.0", - "matrix-mock-request": "^2.0.0", + "matrix-mock-request": "^2.5.0", "matrix-react-test-utils": "^0.2.3", "matrix-web-i18n": "^1.3.0", "postcss-scss": "^4.0.4", diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index 415d6d7ad0e..95ebbec0f57 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -85,7 +85,7 @@ export default class AddThreepid { const identityAccessToken = await authClient.getAccessToken(); return MatrixClientPeg.get().requestEmailToken( emailAddress, this.clientSecret, 1, - undefined, undefined, identityAccessToken, + undefined, identityAccessToken, ).then((res) => { this.sessionId = res.sid; return res; @@ -142,7 +142,7 @@ export default class AddThreepid { const identityAccessToken = await authClient.getAccessToken(); return MatrixClientPeg.get().requestMsisdnToken( phoneCountry, phoneNumber, this.clientSecret, 1, - undefined, undefined, identityAccessToken, + undefined, identityAccessToken, ).then((res) => { this.sessionId = res.sid; return res; diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 1da1bfe1d6f..d4cf3cc0ab5 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -17,16 +17,16 @@ limitations under the License. */ import { MatrixClient } from "matrix-js-sdk/src/client"; -import { IUploadOpts } from "matrix-js-sdk/src/@types/requests"; import { MsgType } from "matrix-js-sdk/src/@types/event"; import encrypt from "matrix-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; -import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials"; +import { IImageInfo } from "matrix-js-sdk/src/@types/partials"; import { logger } from "matrix-js-sdk/src/logger"; -import { IEventRelation, ISendEventResponse, MatrixError, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { IEventRelation, ISendEventResponse, MatrixEvent, UploadOpts, UploadProgress } from "matrix-js-sdk/src/matrix"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; +import { removeElement } from "matrix-js-sdk/src/utils"; -import { IEncryptedFile, IMediaEventInfo } from "./customisations/models/IMediaEventContent"; +import { IEncryptedFile, IMediaEventContent, IMediaEventInfo } from "./customisations/models/IMediaEventContent"; import dis from './dispatcher/dispatcher'; import { _t } from './languageHandler'; import Modal from './Modal'; @@ -39,7 +39,7 @@ import { UploadProgressPayload, UploadStartedPayload, } from "./dispatcher/payloads/UploadPayload"; -import { IUpload } from "./models/IUpload"; +import { RoomUpload } from "./models/RoomUpload"; import SettingsStore from "./settings/SettingsStore"; import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics"; import { TimelineRenderingType } from "./contexts/RoomContext"; @@ -62,14 +62,6 @@ interface IMediaConfig { "m.upload.size"?: number; } -interface IContent { - body: string; - msgtype: string; - info: IMediaEventInfo; - file?: string; - url?: string; -} - /** * Load a file into a newly created image element. * @@ -78,7 +70,7 @@ interface IContent { */ async function loadImageElement(imageFile: File) { // Load the file into an html element - const img = document.createElement("img"); + const img = new Image(); const objectUrl = URL.createObjectURL(imageFile); const imgPromise = new Promise((resolve, reject) => { img.onload = function() { @@ -93,7 +85,7 @@ async function loadImageElement(imageFile: File) { // check for hi-dpi PNGs and fudge display resolution as needed. // this is mainly needed for macOS screencaps - let parsePromise; + let parsePromise: Promise; if (imageFile.type === "image/png") { // in practice macOS happens to order the chunks so they fall in // the first 0x1000 bytes (thanks to a massive ICC header). @@ -277,71 +269,58 @@ function readFileAsArrayBuffer(file: File | Blob): Promise { * @param {File} file The file to upload. * @param {Function?} progressHandler optional callback to be called when a chunk of * data is uploaded. + * @param {AbortController?} controller optional abortController to use for this upload. * @return {Promise} A promise that resolves with an object. * If the file is unencrypted then the object will have a "url" key. * If the file is encrypted then the object will have a "file" key. */ -export function uploadFile( +export async function uploadFile( matrixClient: MatrixClient, roomId: string, file: File | Blob, - progressHandler?: IUploadOpts["progressHandler"], -): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> { - let canceled = false; + progressHandler?: UploadOpts["progressHandler"], + controller?: AbortController, +): Promise<{ url?: string, file?: IEncryptedFile }> { + const abortController = controller ?? new AbortController(); + + // If the room is encrypted then encrypt the file before uploading it. if (matrixClient.isRoomEncrypted(roomId)) { - // If the room is encrypted then encrypt the file before uploading it. // First read the file into memory. - let uploadPromise: IAbortablePromise; - const prom = readFileAsArrayBuffer(file).then(function(data) { - if (canceled) throw new UploadCanceledError(); - // Then encrypt the file. - return encrypt.encryptAttachment(data); - }).then(function(encryptResult) { - if (canceled) throw new UploadCanceledError(); - - // Pass the encrypted data as a Blob to the uploader. - const blob = new Blob([encryptResult.data]); - uploadPromise = matrixClient.uploadContent(blob, { - progressHandler, - includeFilename: false, - }); + const data = await readFileAsArrayBuffer(file); + if (abortController.signal.aborted) throw new UploadCanceledError(); - return uploadPromise.then(url => { - if (canceled) throw new UploadCanceledError(); - - // If the attachment is encrypted then bundle the URL along - // with the information needed to decrypt the attachment and - // add it under a file key. - return { - file: { - ...encryptResult.info, - url, - }, - }; - }); - }) as IAbortablePromise<{ file: IEncryptedFile }>; - prom.abort = () => { - canceled = true; - if (uploadPromise) matrixClient.cancelUpload(uploadPromise); + // Then encrypt the file. + const encryptResult = await encrypt.encryptAttachment(data); + if (abortController.signal.aborted) throw new UploadCanceledError(); + + // Pass the encrypted data as a Blob to the uploader. + const blob = new Blob([encryptResult.data]); + + const { content_uri: url } = await matrixClient.uploadContent(blob, { + progressHandler, + abortController, + includeFilename: false, + }); + if (abortController.signal.aborted) throw new UploadCanceledError(); + + // If the attachment is encrypted then bundle the URL along with the information + // needed to decrypt the attachment and add it under a file key. + return { + file: { + ...encryptResult.info, + url, + } as IEncryptedFile, }; - return prom; } else { - const basePromise = matrixClient.uploadContent(file, { progressHandler }); - const promise1 = basePromise.then(function(url) { - if (canceled) throw new UploadCanceledError(); - // If the attachment isn't encrypted then include the URL directly. - return { url }; - }) as IAbortablePromise<{ url: string }>; - promise1.abort = () => { - canceled = true; - matrixClient.cancelUpload(basePromise); - }; - return promise1; + const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController }); + if (abortController.signal.aborted) throw new UploadCanceledError(); + // If the attachment isn't encrypted then include the URL directly. + return { url }; } } export default class ContentMessages { - private inprogress: IUpload[] = []; + private inprogress: RoomUpload[] = []; private mediaConfig: IMediaConfig = null; public sendStickerContentToRoom( @@ -460,36 +439,33 @@ export default class ContentMessages { }); } - public getCurrentUploads(relation?: IEventRelation): IUpload[] { - return this.inprogress.filter(upload => { - const noRelation = !relation && !upload.relation; - const matchingRelation = relation && upload.relation - && relation.rel_type === upload.relation.rel_type - && relation.event_id === upload.relation.event_id; + public getCurrentUploads(relation?: IEventRelation): RoomUpload[] { + return this.inprogress.filter(roomUpload => { + const noRelation = !relation && !roomUpload.relation; + const matchingRelation = relation && roomUpload.relation + && relation.rel_type === roomUpload.relation.rel_type + && relation.event_id === roomUpload.relation.event_id; - return (noRelation || matchingRelation) && !upload.canceled; + return (noRelation || matchingRelation) && !roomUpload.cancelled; }); } - public cancelUpload(promise: IAbortablePromise, matrixClient: MatrixClient): void { - const upload = this.inprogress.find(item => item.promise === promise); - if (upload) { - upload.canceled = true; - matrixClient.cancelUpload(upload.promise); - dis.dispatch({ action: Action.UploadCanceled, upload }); - } + public cancelUpload(upload: RoomUpload): void { + upload.abort(); + dis.dispatch({ action: Action.UploadCanceled, upload }); } - private sendContentToRoom( + public async sendContentToRoom( file: File, roomId: string, relation: IEventRelation | undefined, matrixClient: MatrixClient, replyToEvent: MatrixEvent | undefined, - promBefore: Promise, + promBefore?: Promise, ) { - const content: Omit & { info: Partial } = { - body: file.name || 'Attachment', + const fileName = file.name || _t("Attachment"); + const content: Omit & { info: Partial } = { + body: fileName, info: { size: file.size, }, @@ -512,91 +488,72 @@ export default class ContentMessages { content.info.mimetype = file.type; } - const prom = new Promise((resolve) => { - if (file.type.indexOf('image/') === 0) { + const upload = new RoomUpload(roomId, fileName, relation, file.size); + this.inprogress.push(upload); + dis.dispatch({ action: Action.UploadStarted, upload }); + + function onProgress(progress: UploadProgress) { + upload.onProgress(progress); + dis.dispatch({ action: Action.UploadProgress, upload }); + } + + try { + if (file.type.startsWith('image/')) { content.msgtype = MsgType.Image; - infoForImageFile(matrixClient, roomId, file).then((imageInfo) => { + try { + const imageInfo = await infoForImageFile(matrixClient, roomId, file); Object.assign(content.info, imageInfo); - resolve(); - }, (e) => { + } catch (e) { // Failed to thumbnail, fall back to uploading an m.file logger.error(e); content.msgtype = MsgType.File; - resolve(); - }); + } } else if (file.type.indexOf('audio/') === 0) { content.msgtype = MsgType.Audio; - resolve(); } else if (file.type.indexOf('video/') === 0) { content.msgtype = MsgType.Video; - infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => { + try { + const videoInfo = await infoForVideoFile(matrixClient, roomId, file); Object.assign(content.info, videoInfo); - resolve(); - }, (e) => { + } catch (e) { // Failed to thumbnail, fall back to uploading an m.file logger.error(e); content.msgtype = MsgType.File; - resolve(); - }); + } } else { content.msgtype = MsgType.File; - resolve(); } - }) as IAbortablePromise; - // create temporary abort handler for before the actual upload gets passed off to js-sdk - prom.abort = () => { - upload.canceled = true; - }; + if (upload.cancelled) throw new UploadCanceledError(); + const result = await uploadFile(matrixClient, roomId, file, onProgress, upload.abortController); + content.file = result.file; + content.url = result.url; - const upload: IUpload = { - fileName: file.name || 'Attachment', - roomId, - relation, - total: file.size, - loaded: 0, - promise: prom, - }; - this.inprogress.push(upload); - dis.dispatch({ action: Action.UploadStarted, upload }); + if (upload.cancelled) throw new UploadCanceledError(); + // Await previous message being sent into the room + if (promBefore) await promBefore; - function onProgress(ev) { - upload.total = ev.total; - upload.loaded = ev.loaded; - dis.dispatch({ action: Action.UploadProgress, upload }); - } + if (upload.cancelled) throw new UploadCanceledError(); + const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; + + const response = await matrixClient.sendMessage(roomId, threadId, content); - let error: MatrixError; - return prom.then(() => { - if (upload.canceled) throw new UploadCanceledError(); - // XXX: upload.promise must be the promise that - // is returned by uploadFile as it has an abort() - // method hacked onto it. - upload.promise = uploadFile(matrixClient, roomId, file, onProgress); - return upload.promise.then(function(result) { - content.file = result.file; - content.url = result.url; - }); - }).then(() => { - // Await previous message being sent into the room - return promBefore; - }).then(function() { - if (upload.canceled) throw new UploadCanceledError(); - const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name - ? relation.event_id - : null; - const prom = matrixClient.sendMessage(roomId, threadId, content); if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { - prom.then(resp => { - sendRoundTripMetric(matrixClient, roomId, resp.event_id); - }); + sendRoundTripMetric(matrixClient, roomId, response.event_id); } - return prom; - }, function(err: MatrixError) { - error = err; - if (!upload.canceled) { + + dis.dispatch({ action: Action.UploadFinished, upload }); + dis.dispatch({ action: 'message_sent' }); + } catch (error) { + // 413: File was too big or upset the server in some way: + // clear the media size limit so we fetch it again next time we try to upload + if (error?.httpStatus === 413) { + this.mediaConfig = null; + } + + if (!upload.cancelled) { let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName }); - if (err.httpStatus === 413) { + if (error.httpStatus === 413) { desc = _t( "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", { fileName: upload.fileName }, @@ -606,27 +563,11 @@ export default class ContentMessages { title: _t('Upload Failed'), description: desc, }); - } - }).finally(() => { - for (let i = 0; i < this.inprogress.length; ++i) { - if (this.inprogress[i].promise === upload.promise) { - this.inprogress.splice(i, 1); - break; - } - } - if (error) { - // 413: File was too big or upset the server in some way: - // clear the media size limit so we fetch it again next time - // we try to upload - if (error?.httpStatus === 413) { - this.mediaConfig = null; - } dis.dispatch({ action: Action.UploadFailed, upload, error }); - } else { - dis.dispatch({ action: Action.UploadFinished, upload }); - dis.dispatch({ action: 'message_sent' }); } - }); + } finally { + removeElement(this.inprogress, e => e.promise === upload.promise); + } } private isFileSizeAcceptable(file: File) { diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 1e7fae8136e..cc1143ebba8 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -739,7 +739,7 @@ export function logout(): void { _isLoggingOut = true; const client = MatrixClientPeg.get(); PlatformPeg.get().destroyPickleKey(client.getUserId(), client.getDeviceId()); - client.logout(undefined, true).then(onLoggedOut, (err) => { + client.logout(true).then(onLoggedOut, (err) => { // Just throwing an error here is going to be very unhelpful // if you're trying to log out because your server's down and // you want to log into a different server, so just forget the diff --git a/src/Login.ts b/src/Login.ts index a6104dfdaff..c36f5770b92 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -169,7 +169,7 @@ export default class Login { * @param {string} loginType the type of login to do * @param {ILoginParams} loginParams the parameters for the login * - * @returns {MatrixClientCreds} + * @returns {IMatrixClientCreds} */ export async function sendLoginRequest( hsUrl: string, diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index b1fb6e44f4d..5dacd079734 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -15,10 +15,10 @@ limitations under the License. */ import url from 'url'; -import request from "browser-request"; import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; import { Room } from "matrix-js-sdk/src/models/room"; import { logger } from "matrix-js-sdk/src/logger"; +import { IOpenIDToken } from 'matrix-js-sdk/src/matrix'; import SettingsStore from "./settings/SettingsStore"; import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; @@ -103,29 +103,29 @@ export default class ScalarAuthClient { } } - private getAccountName(token: string): Promise { - const url = this.apiUrl + "/account"; - - return new Promise(function(resolve, reject) { - request({ - method: "GET", - uri: url, - qs: { scalar_token: token, v: imApiVersion }, - json: true, - }, (err, response, body) => { - if (err) { - reject(err); - } else if (body && body.errcode === 'M_TERMS_NOT_SIGNED') { - reject(new TermsNotSignedError()); - } else if (response.statusCode / 100 !== 2) { - reject(body); - } else if (!body || !body.user_id) { - reject(new Error("Missing user_id in response")); - } else { - resolve(body.user_id); - } - }); + private async getAccountName(token: string): Promise { + const url = new URL(this.apiUrl + "/account"); + url.searchParams.set("scalar_token", token); + url.searchParams.set("v", imApiVersion); + + const res = await fetch(url, { + method: "GET", }); + + const body = await res.json(); + if (body?.errcode === "M_TERMS_NOT_SIGNED") { + throw new TermsNotSignedError(); + } + + if (!res.ok) { + throw body; + } + + if (!body?.user_id) { + throw new Error("Missing user_id in response"); + } + + return body.user_id; } private checkToken(token: string): Promise { @@ -183,56 +183,41 @@ export default class ScalarAuthClient { }); } - exchangeForScalarToken(openidTokenObject: any): Promise { - const scalarRestUrl = this.apiUrl; - - return new Promise(function(resolve, reject) { - request({ - method: 'POST', - uri: scalarRestUrl + '/register', - qs: { v: imApiVersion }, - body: openidTokenObject, - json: true, - }, (err, response, body) => { - if (err) { - reject(err); - } else if (response.statusCode / 100 !== 2) { - reject(new Error(`Scalar request failed: ${response.statusCode}`)); - } else if (!body || !body.scalar_token) { - reject(new Error("Missing scalar_token in response")); - } else { - resolve(body.scalar_token); - } - }); + public async exchangeForScalarToken(openidTokenObject: IOpenIDToken): Promise { + const scalarRestUrl = new URL(this.apiUrl + "/register"); + scalarRestUrl.searchParams.set("v", imApiVersion); + + const res = await fetch(scalarRestUrl, { + method: "POST", + body: JSON.stringify(openidTokenObject), }); + + if (!res.ok) { + throw new Error(`Scalar request failed: ${res.status}`); + } + + const body = await res.json(); + if (!body?.scalar_token) { + throw new Error("Missing scalar_token in response"); + } + + return body.scalar_token; } - getScalarPageTitle(url: string): Promise { - let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup'; - scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); - scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); - - return new Promise(function(resolve, reject) { - request({ - method: 'GET', - uri: scalarPageLookupUrl, - json: true, - }, (err, response, body) => { - if (err) { - reject(err); - } else if (response.statusCode / 100 !== 2) { - reject(new Error(`Scalar request failed: ${response.statusCode}`)); - } else if (!body) { - reject(new Error("Missing page title in response")); - } else { - let title = ""; - if (body.page_title_cache_item && body.page_title_cache_item.cached_title) { - title = body.page_title_cache_item.cached_title; - } - resolve(title); - } - }); + public async getScalarPageTitle(url: string): Promise { + const scalarPageLookupUrl = new URL(this.getStarterLink(this.apiUrl + '/widgets/title_lookup')); + scalarPageLookupUrl.searchParams.set("curl", encodeURIComponent(url)); + + const res = await fetch(scalarPageLookupUrl, { + method: "GET", }); + + if (!res.ok) { + throw new Error(`Scalar request failed: ${res.status}`); + } + + const body = await res.json(); + return body?.page_title_cache_item?.cached_title; } /** @@ -243,31 +228,24 @@ export default class ScalarAuthClient { * @param {string} widgetId The widget ID to disable assets for * @return {Promise} Resolves on completion */ - disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise { - let url = this.apiUrl + '/widgets/set_assets_state'; - url = this.getStarterLink(url); - return new Promise((resolve, reject) => { - request({ - method: 'GET', // XXX: Actions shouldn't be GET requests - uri: url, - json: true, - qs: { - 'widget_type': widgetType.preferred, - 'widget_id': widgetId, - 'state': 'disable', - }, - }, (err, response, body) => { - if (err) { - reject(err); - } else if (response.statusCode / 100 !== 2) { - reject(new Error(`Scalar request failed: ${response.statusCode}`)); - } else if (!body) { - reject(new Error("Failed to set widget assets state")); - } else { - resolve(); - } - }); + public async disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise { + const url = new URL(this.getStarterLink(this.apiUrl + "/widgets/set_assets_state")); + url.searchParams.set("widget_type", widgetType.preferred); + url.searchParams.set("widget_id", widgetId); + url.searchParams.set("state", "disable"); + + const res = await fetch(url, { + method: "GET", // XXX: Actions shouldn't be GET requests }); + + if (!res.ok) { + throw new Error(`Scalar request failed: ${res.status}`); + } + + const body = await res.text(); + if (!body) { + throw new Error("Failed to set widget assets state"); + } } getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string { diff --git a/src/components/structures/EmbeddedPage.tsx b/src/components/structures/EmbeddedPage.tsx index 2053140ba43..11f286edc24 100644 --- a/src/components/structures/EmbeddedPage.tsx +++ b/src/components/structures/EmbeddedPage.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import request from 'browser-request'; import sanitizeHtml from 'sanitize-html'; import classnames from 'classnames'; import { logger } from "matrix-js-sdk/src/logger"; @@ -61,6 +60,37 @@ export default class EmbeddedPage extends React.PureComponent { return sanitizeHtml(_t(s)); } + private async fetchEmbed() { + let res: Response; + + try { + res = await fetch(this.props.url, { method: "GET" }); + } catch (err) { + if (this.unmounted) return; + logger.warn(`Error loading page: ${err}`); + this.setState({ page: _t("Couldn't load page") }); + return; + } + + if (this.unmounted) return; + + if (!res.ok) { + logger.warn(`Error loading page: ${res.status}`); + this.setState({ page: _t("Couldn't load page") }); + return; + } + + let body = (await res.text()).replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1) => this.translate(g1)); + + if (this.props.replaceMap) { + Object.keys(this.props.replaceMap).forEach(key => { + body = body.split(key).join(this.props.replaceMap[key]); + }); + } + + this.setState({ page: body }); + } + public componentDidMount(): void { this.unmounted = false; @@ -68,34 +98,10 @@ export default class EmbeddedPage extends React.PureComponent { return; } - // we use request() to inline the page into the react component + // We use fetch to inline the page into the react component // so that it can inherit CSS and theming easily rather than mess around // with iframes and trying to synchronise document.stylesheets. - - request( - { method: "GET", url: this.props.url }, - (err, response, body) => { - if (this.unmounted) { - return; - } - - if (err || response.status < 200 || response.status >= 300) { - logger.warn(`Error loading page: ${err}`); - this.setState({ page: _t("Couldn't load page") }); - return; - } - - body = body.replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1) => this.translate(g1)); - - if (this.props.replaceMap) { - Object.keys(this.props.replaceMap).forEach(key => { - body = body.split(key).join(this.props.replaceMap[key]); - }); - } - - this.setState({ page: body }); - }, - ); + this.fetchEmbed(); this.dispatcherRef = dis.register(this.onAction); } diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 1e262014c2d..7ddeca11bc5 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1362,7 +1362,7 @@ class TimelinePanel extends React.Component { if (this.unmounted) return; this.setState({ timelineLoading: false }); - logger.error(`Error loading timeline panel at ${this.props.timelineSet.room?.roomId}/${eventId}: ${error}`); + logger.error(`Error loading timeline panel at ${this.props.timelineSet.room?.roomId}/${eventId}`, error); let onFinished: () => void; diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx index b2c7544f1fd..673afb1b134 100644 --- a/src/components/structures/UploadBar.tsx +++ b/src/components/structures/UploadBar.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import { Room } from "matrix-js-sdk/src/models/room"; import filesize from "filesize"; -import { IAbortablePromise, IEventRelation } from 'matrix-js-sdk/src/matrix'; +import { IEventRelation } from 'matrix-js-sdk/src/matrix'; import { Optional } from "matrix-events-sdk"; import ContentMessages from '../../ContentMessages'; @@ -26,8 +26,7 @@ import { _t } from '../../languageHandler'; import { Action } from "../../dispatcher/actions"; import ProgressBar from "../views/elements/ProgressBar"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; -import { IUpload } from "../../models/IUpload"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { RoomUpload } from "../../models/RoomUpload"; import { ActionPayload } from '../../dispatcher/payloads'; import { UploadPayload } from "../../dispatcher/payloads/UploadPayload"; @@ -38,7 +37,7 @@ interface IProps { interface IState { currentFile?: string; - currentPromise?: IAbortablePromise; + currentUpload?: RoomUpload; currentLoaded?: number; currentTotal?: number; countFiles: number; @@ -55,8 +54,6 @@ function isUploadPayload(payload: ActionPayload): payload is UploadPayload { } export default class UploadBar extends React.PureComponent { - static contextType = MatrixClientContext; - private dispatcherRef: Optional; private mounted = false; @@ -78,7 +75,7 @@ export default class UploadBar extends React.PureComponent { dis.unregister(this.dispatcherRef!); } - private getUploadsInRoom(): IUpload[] { + private getUploadsInRoom(): RoomUpload[] { const uploads = ContentMessages.sharedInstance().getCurrentUploads(this.props.relation); return uploads.filter(u => u.roomId === this.props.room.roomId); } @@ -86,8 +83,8 @@ export default class UploadBar extends React.PureComponent { private calculateState(): IState { const [currentUpload, ...otherUploads] = this.getUploadsInRoom(); return { + currentUpload, currentFile: currentUpload?.fileName, - currentPromise: currentUpload?.promise, currentLoaded: currentUpload?.loaded, currentTotal: currentUpload?.total, countFiles: otherUploads.length + 1, @@ -103,7 +100,7 @@ export default class UploadBar extends React.PureComponent { private onCancelClick = (ev: ButtonEvent) => { ev.preventDefault(); - ContentMessages.sharedInstance().cancelUpload(this.state.currentPromise!, this.context); + ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload!); }; render() { diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index c00aa909d22..c9fc7e001d9 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { ReactNode } from 'react'; -import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; @@ -453,7 +453,7 @@ export default class LoginComponent extends React.PureComponent let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " + "please try again later.") + (errCode ? " (" + errCode + ")" : ""); - if (err["cors"] === 'rejected') { // browser-request specific error field + if (err instanceof ConnectionError) { if (window.location.protocol === 'https:' && (this.props.serverConfig.hsUrl.startsWith("http:") || !this.props.serverConfig.hsUrl.startsWith("http")) diff --git a/src/components/views/dialogs/ChangelogDialog.tsx b/src/components/views/dialogs/ChangelogDialog.tsx index f759f043005..da5ea5d4902 100644 --- a/src/components/views/dialogs/ChangelogDialog.tsx +++ b/src/components/views/dialogs/ChangelogDialog.tsx @@ -16,7 +16,6 @@ Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> */ import React from 'react'; -import request from 'browser-request'; import { _t } from '../../../languageHandler'; import QuestionDialog from "./QuestionDialog"; @@ -37,22 +36,33 @@ export default class ChangelogDialog extends React.Component { this.state = {}; } + private async fetchChanges(repo: string, oldVersion: string, newVersion: string): Promise { + const url = `https://riot.im/github/repos/${repo}/compare/${oldVersion}...${newVersion}`; + + try { + const res = await fetch(url); + + if (!res.ok) { + this.setState({ [repo]: res.statusText }); + return; + } + + const body = await res.json(); + this.setState({ [repo]: body.commits }); + } catch (err) { + this.setState({ [repo]: err.message }); + } + } + public componentDidMount() { const version = this.props.newVersion.split('-'); const version2 = this.props.version.split('-'); if (version == null || version2 == null) return; // parse versions of form: [vectorversion]-react-[react-sdk-version]-js-[js-sdk-version] - for (let i=0; i { - if (response.statusCode < 200 || response.statusCode >= 300) { - this.setState({ [REPOS[i]]: response.statusText }); - return; - } - this.setState({ [REPOS[i]]: JSON.parse(body).commits }); - }); + this.fetchChanges(REPOS[i], oldVersion, newVersion); } } diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 178c521bdcb..30c1f4d1544 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -654,12 +654,7 @@ export default class InviteDialog extends React.PureComponent { - const controller = new AbortController(); - const id = setTimeout(() => controller.abort(), 10 * 1000); // 10s - const url = cli.http.getUrl("/sync", {}, "/_matrix/client/unstable/org.matrix.msc3575"); - const res = await fetch(url, { - signal: controller.signal, - method: "POST", + await cli.http.authedRequest(Method.Post, "/sync", undefined, undefined, { + localTimeoutMs: 10 * 1000, // 10s + prefix: "/_matrix/client/unstable/org.matrix.msc3575", }); - clearTimeout(id); - if (res.status != 200) { - throw new Error(`syncHealthCheck: server returned HTTP ${res.status}`); - } logger.info("server natively support sliding sync OK"); } diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 7e744591ec3..2e19a616e59 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -74,7 +74,7 @@ const MiniAvatarUploader: React.FC = ({ if (!ev.target.files?.length) return; setBusy(true); const file = ev.target.files[0]; - const uri = await cli.uploadContent(file); + const { content_uri: uri } = await cli.uploadContent(file); await setAvatarUrl(uri); setBusy(false); }} diff --git a/src/components/views/room_settings/RoomProfileSettings.tsx b/src/components/views/room_settings/RoomProfileSettings.tsx index 86e266bc351..1c7b8d6e949 100644 --- a/src/components/views/room_settings/RoomProfileSettings.tsx +++ b/src/components/views/room_settings/RoomProfileSettings.tsx @@ -134,7 +134,7 @@ export default class RoomProfileSettings extends React.Component } if (this.state.avatarFile) { - const uri = await client.uploadContent(this.state.avatarFile); + const { content_uri: uri } = await client.uploadContent(this.state.avatarFile); await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, ''); newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.originalAvatarUrl = newState.avatarUrl; diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index 6c724440cfa..39c82e4eeaa 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -148,7 +148,6 @@ export default class RoomPreviewBar extends React.Component { const result = await MatrixClientPeg.get().lookupThreePid( 'email', this.props.invitedEmail, - undefined /* callback */, identityAccessToken, ); this.setState({ invitedEmailMxid: result.mxid }); diff --git a/src/components/views/settings/ChangeAvatar.tsx b/src/components/views/settings/ChangeAvatar.tsx index b0645ac51b4..680291db4ce 100644 --- a/src/components/views/settings/ChangeAvatar.tsx +++ b/src/components/views/settings/ChangeAvatar.tsx @@ -115,13 +115,13 @@ export default class ChangeAvatar extends React.Component { this.setState({ phase: Phases.Uploading, }); - const httpPromise = MatrixClientPeg.get().uploadContent(file).then((url) => { + const httpPromise = MatrixClientPeg.get().uploadContent(file).then(({ content_uri: url }) => { newUrl = url; if (this.props.room) { return MatrixClientPeg.get().sendStateEvent( this.props.room.roomId, 'm.room.avatar', - { url: url }, + { url }, '', ); } else { diff --git a/src/components/views/settings/ProfileSettings.tsx b/src/components/views/settings/ProfileSettings.tsx index a820b28d40a..fd3ed6c99d1 100644 --- a/src/components/views/settings/ProfileSettings.tsx +++ b/src/components/views/settings/ProfileSettings.tsx @@ -111,7 +111,7 @@ export default class ProfileSettings extends React.Component<{}, IState> { logger.log( `Uploading new avatar, ${this.state.avatarFile.name} of type ${this.state.avatarFile.type},` + ` (${this.state.avatarFile.size}) bytes`); - const uri = await client.uploadContent(this.state.avatarFile); + const { content_uri: uri } = await client.uploadContent(this.state.avatarFile); await client.setAvatarUrl(uri); newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.originalAvatarUrl = newState.avatarUrl; diff --git a/src/createRoom.ts b/src/createRoom.ts index 88e3f8ef9f6..cc3cbc5373d 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -246,7 +246,7 @@ export default async function createRoom(opts: IOpts): Promise { if (opts.avatar) { let url = opts.avatar; if (opts.avatar instanceof File) { - url = await client.uploadContent(opts.avatar); + ({ content_uri: url } = await client.uploadContent(opts.avatar)); } createOpts.initial_state.push({ diff --git a/src/customisations/Media.ts b/src/customisations/Media.ts index 6d9e9a8b62c..ae0daa53c51 100644 --- a/src/customisations/Media.ts +++ b/src/customisations/Media.ts @@ -151,7 +151,7 @@ export class Media { * @param {MatrixClient} client? Optional client to use. * @returns {Media} The media object. */ -export function mediaFromContent(content: IMediaEventContent, client?: MatrixClient): Media { +export function mediaFromContent(content: Partial, client?: MatrixClient): Media { return new Media(prepEventContentAsMedia(content), client); } diff --git a/src/customisations/models/IMediaEventContent.ts b/src/customisations/models/IMediaEventContent.ts index d911a7cc3c1..a8dacd84aad 100644 --- a/src/customisations/models/IMediaEventContent.ts +++ b/src/customisations/models/IMediaEventContent.ts @@ -46,6 +46,7 @@ export interface IMediaEventInfo { } export interface IMediaEventContent { + msgtype: string; body?: string; filename?: string; // `m.file` optional field url?: string; // required on unencrypted media @@ -69,7 +70,7 @@ export interface IMediaObject { * @returns {IPreparedMedia} A prepared media object. * @throws Throws if the given content cannot be packaged into a prepared media object. */ -export function prepEventContentAsMedia(content: IMediaEventContent): IPreparedMedia { +export function prepEventContentAsMedia(content: Partial): IPreparedMedia { let thumbnail: IMediaObject = null; if (content?.info?.thumbnail_url) { thumbnail = { diff --git a/src/dispatcher/payloads/UploadPayload.ts b/src/dispatcher/payloads/UploadPayload.ts index 7db4a4a4d74..ac47596e55c 100644 --- a/src/dispatcher/payloads/UploadPayload.ts +++ b/src/dispatcher/payloads/UploadPayload.ts @@ -16,13 +16,13 @@ limitations under the License. import { ActionPayload } from "../payloads"; import { Action } from "../actions"; -import { IUpload } from "../../models/IUpload"; +import { RoomUpload } from "../../models/RoomUpload"; export interface UploadPayload extends ActionPayload { /** * The upload with fields representing the new upload state. */ - upload: IUpload; + upload: RoomUpload; } export interface UploadStartedPayload extends UploadPayload { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4b759ca21ec..0913b46bc5e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -16,6 +16,7 @@ "Error": "Error", "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", "Dismiss": "Dismiss", + "Attachment": "Attachment", "The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", "Upload Failed": "Upload Failed", @@ -654,7 +655,6 @@ "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", "Please contact your service administrator to continue using the service.": "Please contact your service administrator to continue using the service.", "Unable to connect to Homeserver. Retrying...": "Unable to connect to Homeserver. Retrying...", - "Attachment": "Attachment", "%(items)s and %(count)s others|other": "%(items)s and %(count)s others", "%(items)s and %(count)s others|one": "%(items)s and one other", "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx index 2caf5c1639e..323b279049e 100644 --- a/src/languageHandler.tsx +++ b/src/languageHandler.tsx @@ -17,7 +17,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import request from 'browser-request'; import counterpart from 'counterpart'; import React from 'react'; import { logger } from "matrix-js-sdk/src/logger"; @@ -386,6 +385,11 @@ export function setMissingEntryGenerator(f: (value: string) => void) { counterpart.setMissingEntryGenerator(f); } +type Language = { + fileName: string; + label: string; +}; + export function setLanguage(preferredLangs: string | string[]) { if (!Array.isArray(preferredLangs)) { preferredLangs = [preferredLangs]; @@ -396,8 +400,8 @@ export function setLanguage(preferredLangs: string | string[]) { plaf.setLanguage(preferredLangs); } - let langToUse; - let availLangs; + let langToUse: string; + let availLangs: { [lang: string]: Language }; return getLangsJson().then((result) => { availLangs = result; @@ -532,29 +536,21 @@ export function pickBestLanguage(langs: string[]): string { return langs[0]; } -function getLangsJson(): Promise { - return new Promise((resolve, reject) => { - let url; - if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through - url = webpackLangJsonUrl; - } else { - url = i18nFolder + 'languages.json'; - } - request( - { method: "GET", url }, - (err, response, body) => { - if (err) { - reject(err); - return; - } - if (response.status < 200 || response.status >= 300) { - reject(new Error(`Failed to load ${url}, got ${response.status}`)); - return; - } - resolve(JSON.parse(body)); - }, - ); - }); +async function getLangsJson(): Promise<{ [lang: string]: Language }> { + let url: string; + if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through + url = webpackLangJsonUrl; + } else { + url = i18nFolder + 'languages.json'; + } + + const res = await fetch(url, { method: "GET" }); + + if (!res.ok) { + throw new Error(`Failed to load ${url}, got ${res.status}`); + } + + return res.json(); } interface ICounterpartTranslation { @@ -571,23 +567,14 @@ async function getLanguageRetry(langPath: string, num = 3): Promise { - return new Promise((resolve, reject) => { - request( - { method: "GET", url: langPath }, - (err, response, body) => { - if (err) { - reject(err); - return; - } - if (response.status < 200 || response.status >= 300) { - reject(new Error(`Failed to load ${langPath}, got ${response.status}`)); - return; - } - resolve(JSON.parse(body)); - }, - ); - }); +async function getLanguage(langPath: string): Promise { + const res = await fetch(langPath, { method: "GET" }); + + if (!res.ok) { + throw new Error(`Failed to load ${langPath}, got ${res.status}`); + } + + return res.json(); } export interface ICustomTranslations { diff --git a/src/models/IUpload.ts b/src/models/IUpload.ts deleted file mode 100644 index 715a71037f0..00000000000 --- a/src/models/IUpload.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2021 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 { IEventRelation } from "matrix-js-sdk/src/matrix"; -import { IAbortablePromise } from "matrix-js-sdk/src/@types/partials"; - -export interface IUpload { - fileName: string; - roomId: string; - relation?: IEventRelation; - total: number; - loaded: number; - promise: IAbortablePromise; - canceled?: boolean; -} diff --git a/src/models/RoomUpload.ts b/src/models/RoomUpload.ts new file mode 100644 index 00000000000..aa4d33d2eaa --- /dev/null +++ b/src/models/RoomUpload.ts @@ -0,0 +1,53 @@ +/* +Copyright 2021 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 { IEventRelation, UploadProgress } from "matrix-js-sdk/src/matrix"; + +import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; + +export class RoomUpload { + public readonly abortController = new AbortController(); + public promise: Promise<{ url?: string, file?: IEncryptedFile }>; + private uploaded = 0; + + constructor( + public readonly roomId: string, + public readonly fileName: string, + public readonly relation?: IEventRelation, + public fileSize = 0, + ) {} + + public onProgress(progress: UploadProgress) { + this.uploaded = progress.loaded; + this.fileSize = progress.total; + } + + public abort(): void { + this.abortController.abort(); + } + + public get cancelled(): boolean { + return this.abortController.signal.aborted; + } + + public get total(): number { + return this.fileSize; + } + + public get loaded(): number { + return this.uploaded; + } +} diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts index 3f565c079bd..3c539f7bf0c 100644 --- a/src/utils/MultiInviter.ts +++ b/src/utils/MultiInviter.ts @@ -184,7 +184,7 @@ export default class MultiInviter { } } - return this.matrixClient.invite(roomId, addr, undefined, this.reason); + return this.matrixClient.invite(roomId, addr, this.reason); } else { throw new Error('Unsupported address'); } diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts index bea71cc2749..74b6f2e1289 100644 --- a/src/voice-broadcast/models/VoiceBroadcastRecording.ts +++ b/src/voice-broadcast/models/VoiceBroadcastRecording.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { logger } from "matrix-js-sdk/src/logger"; -import { IAbortablePromise, MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { @@ -108,7 +108,7 @@ export class VoiceBroadcastRecording await this.sendVoiceMessage(chunk, url, file); }; - private uploadFile(chunk: ChunkRecordedPayload): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> { + private uploadFile(chunk: ChunkRecordedPayload): ReturnType { return uploadFile( this.client, this.infoEvent.getRoomId(), diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index 8572fbe8790..1d03cfae892 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -15,15 +15,31 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { IImageInfo, ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { IImageInfo, ISendEventResponse, MatrixClient, RelationType, UploadResponse } from "matrix-js-sdk/src/matrix"; +import { defer } from "matrix-js-sdk/src/utils"; +import encrypt, { IEncryptedFile } from "matrix-encrypt-attachment"; -import ContentMessages from "../src/ContentMessages"; +import ContentMessages, { UploadCanceledError, uploadFile } from "../src/ContentMessages"; import { doMaybeLocalRoomAction } from "../src/utils/local-room"; +import { createTestClient } from "./test-utils"; +import { BlurhashEncoder } from "../src/BlurhashEncoder"; + +jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) })); + +jest.mock("../src/BlurhashEncoder", () => ({ + BlurhashEncoder: { + instance: { + getBlurhash: jest.fn(), + }, + }, +})); jest.mock("../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), })); +const createElement = document.createElement.bind(document); + describe("ContentMessages", () => { const stickerUrl = "https://example.com/sticker"; const roomId = "!room:example.com"; @@ -36,6 +52,9 @@ describe("ContentMessages", () => { beforeEach(() => { client = { sendStickerMessage: jest.fn(), + sendMessage: jest.fn(), + isRoomEncrypted: jest.fn().mockReturnValue(false), + uploadContent: jest.fn().mockResolvedValue({ content_uri: "mxc://server/file" }), } as unknown as MatrixClient; contentMessages = new ContentMessages(); prom = Promise.resolve(null); @@ -65,4 +84,226 @@ describe("ContentMessages", () => { expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text); }); }); + + describe("sendContentToRoom", () => { + const roomId = "!roomId:server"; + beforeEach(() => { + Object.defineProperty(global.Image.prototype, 'src', { + // Define the property setter + set(src) { + setTimeout(() => this.onload()); + }, + }); + Object.defineProperty(global.Image.prototype, 'height', { + get() { return 600; }, + }); + Object.defineProperty(global.Image.prototype, 'width', { + get() { return 800; }, + }); + mocked(doMaybeLocalRoomAction).mockImplementation(( + roomId: string, + fn: (actualRoomId: string) => Promise, + ) => fn(roomId)); + mocked(BlurhashEncoder.instance.getBlurhash).mockResolvedValue(undefined); + }); + + it("should use m.image for image files", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "fileName", { type: "image/jpeg" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.image", + })); + }); + + it("should fall back to m.file for invalid image files", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "fileName", { type: "image/png" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.file", + })); + }); + + it("should use m.video for video files", async () => { + jest.spyOn(document, "createElement").mockImplementation(tagName => { + const element = createElement(tagName); + if (tagName === "video") { + element.load = jest.fn(); + element.play = () => element.onloadeddata(new Event("loadeddata")); + element.pause = jest.fn(); + Object.defineProperty(element, 'videoHeight', { + get() { return 600; }, + }); + Object.defineProperty(element, 'videoWidth', { + get() { return 800; }, + }); + } + return element; + }); + + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "fileName", { type: "video/mp4" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.video", + })); + }); + + it("should use m.audio for audio files", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "fileName", { type: "audio/mp3" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.audio", + })); + }); + + it("should default to name 'Attachment' if file doesn't have a name", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "", { type: "text/plain" }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({ + url: "mxc://server/file", + msgtype: "m.file", + body: "Attachment", + })); + }); + + it("should keep RoomUpload's total and loaded values up to date", async () => { + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "", { type: "text/plain" }); + const prom = contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined); + const [upload] = contentMessages.getCurrentUploads(); + + expect(upload.loaded).toBe(0); + expect(upload.total).toBe(file.size); + const { progressHandler } = mocked(client.uploadContent).mock.calls[0][1]; + progressHandler({ loaded: 123, total: 1234 }); + expect(upload.loaded).toBe(123); + expect(upload.total).toBe(1234); + await prom; + }); + }); + + describe("getCurrentUploads", () => { + const file1 = new File([], "file1"); + const file2 = new File([], "file2"); + const roomId = "!roomId:server"; + + beforeEach(() => { + mocked(doMaybeLocalRoomAction).mockImplementation(( + roomId: string, + fn: (actualRoomId: string) => Promise, + ) => fn(roomId)); + }); + + it("should return only uploads for the given relation", async () => { + const relation = { + rel_type: RelationType.Thread, + event_id: "!threadId:server", + }; + const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined); + const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined); + + const uploads = contentMessages.getCurrentUploads(relation); + expect(uploads).toHaveLength(1); + expect(uploads[0].relation).toEqual(relation); + expect(uploads[0].fileName).toEqual("file1"); + await Promise.all([p1, p2]); + }); + + it("should return only uploads for no relation when not passed one", async () => { + const relation = { + rel_type: RelationType.Thread, + event_id: "!threadId:server", + }; + const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined); + const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined); + + const uploads = contentMessages.getCurrentUploads(); + expect(uploads).toHaveLength(1); + expect(uploads[0].relation).toEqual(undefined); + expect(uploads[0].fileName).toEqual("file2"); + await Promise.all([p1, p2]); + }); + }); + + describe("cancelUpload", () => { + it("should cancel in-flight upload", async () => { + const deferred = defer(); + mocked(client.uploadContent).mockReturnValue(deferred.promise); + const file1 = new File([], "file1"); + const prom = contentMessages.sendContentToRoom(file1, roomId, undefined, client, undefined); + const { abortController } = mocked(client.uploadContent).mock.calls[0][1]; + expect(abortController.signal.aborted).toBeFalsy(); + const [upload] = contentMessages.getCurrentUploads(); + contentMessages.cancelUpload(upload); + expect(abortController.signal.aborted).toBeTruthy(); + deferred.resolve({} as UploadResponse); + await prom; + }); + }); +}); + +describe("uploadFile", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const client = createTestClient(); + + it("should not encrypt the file if the room isn't encrypted", async () => { + mocked(client.isRoomEncrypted).mockReturnValue(false); + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const progressHandler = jest.fn(); + const file = new Blob([]); + + const res = await uploadFile(client, "!roomId:server", file, progressHandler); + + expect(res.url).toBe("mxc://server/file"); + expect(res.file).toBeFalsy(); + expect(encrypt.encryptAttachment).not.toHaveBeenCalled(); + expect(client.uploadContent).toHaveBeenCalledWith(file, expect.objectContaining({ progressHandler })); + }); + + it("should encrypt the file if the room is encrypted", async () => { + mocked(client.isRoomEncrypted).mockReturnValue(true); + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + mocked(encrypt.encryptAttachment).mockResolvedValue({ + data: new ArrayBuffer(123), + info: {} as IEncryptedFile, + }); + const progressHandler = jest.fn(); + const file = new Blob(["123"]); + + const res = await uploadFile(client, "!roomId:server", file, progressHandler); + + expect(res.url).toBeFalsy(); + expect(res.file).toEqual(expect.objectContaining({ + url: "mxc://server/file", + })); + expect(encrypt.encryptAttachment).toHaveBeenCalled(); + expect(client.uploadContent).toHaveBeenCalledWith(expect.any(Blob), expect.objectContaining({ + progressHandler, + includeFilename: false, + })); + expect(mocked(client.uploadContent).mock.calls[0][0]).not.toBe(file); + }); + + it("should throw UploadCanceledError upon aborting the upload", async () => { + mocked(client.isRoomEncrypted).mockReturnValue(false); + const deferred = defer(); + mocked(client.uploadContent).mockReturnValue(deferred.promise); + const file = new Blob([]); + + const prom = uploadFile(client, "!roomId:server", file); + mocked(client.uploadContent).mock.calls[0][1].abortController.abort(); + deferred.resolve({ content_uri: "mxc://foo/bar" }); + await expect(prom).rejects.toThrowError(UploadCanceledError); + }); }); diff --git a/test/ScalarAuthClient-test.ts b/test/ScalarAuthClient-test.ts index 3b6fcf77b2b..02edc2bd98c 100644 --- a/test/ScalarAuthClient-test.ts +++ b/test/ScalarAuthClient-test.ts @@ -14,47 +14,199 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { mocked } from "jest-mock"; +import fetchMock from "fetch-mock-jest"; + import ScalarAuthClient from '../src/ScalarAuthClient'; -import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { stubClient } from './test-utils'; +import SdkConfig from "../src/SdkConfig"; +import { WidgetType } from "../src/widgets/WidgetType"; describe('ScalarAuthClient', function() { - const apiUrl = 'test.com/api'; - const uiUrl = 'test.com/app'; + const apiUrl = 'https://test.com/api'; + const uiUrl = 'https:/test.com/app'; + const tokenObject = { + access_token: "token", + token_type: "Bearer", + matrix_server_name: "localhost", + expires_in: 999, + }; + + let client; beforeEach(function() { - window.localStorage.getItem = jest.fn((arg) => { - if (arg === "mx_scalar_token") return "brokentoken"; - }); - stubClient(); + jest.clearAllMocks(); + client = stubClient(); }); it('should request a new token if the old one fails', async function() { - const sac = new ScalarAuthClient(apiUrl, uiUrl); + const sac = new ScalarAuthClient(apiUrl + 0, uiUrl); - // @ts-ignore unhappy with Promise calls - jest.spyOn(sac, 'getAccountName').mockImplementation((arg: string) => { - switch (arg) { - case "brokentoken": - return Promise.reject({ - message: "Invalid token", - }); - case "wokentoken": - default: - return Promise.resolve(MatrixClientPeg.get().getUserId()); - } + fetchMock.get("https://test.com/api0/account?scalar_token=brokentoken&v=1.1", { + body: { message: "Invalid token" }, }); - MatrixClientPeg.get().getOpenIdToken = jest.fn().mockResolvedValue('this is your openid token'); + fetchMock.get("https://test.com/api0/account?scalar_token=wokentoken&v=1.1", { + body: { user_id: client.getUserId() }, + }); + + client.getOpenIdToken = jest.fn().mockResolvedValue(tokenObject); sac.exchangeForScalarToken = jest.fn((arg) => { - if (arg === "this is your openid token") return Promise.resolve("wokentoken"); + if (arg === tokenObject) return Promise.resolve("wokentoken"); }); await sac.connect(); - expect(sac.exchangeForScalarToken).toBeCalledWith('this is your openid token'); + expect(sac.exchangeForScalarToken).toBeCalledWith(tokenObject); expect(sac.hasCredentials).toBeTruthy(); // @ts-ignore private property expect(sac.scalarToken).toEqual('wokentoken'); }); + + describe("exchangeForScalarToken", () => { + it("should return `scalar_token` from API /register", async () => { + const sac = new ScalarAuthClient(apiUrl + 1, uiUrl); + + fetchMock.postOnce("https://test.com/api1/register?v=1.1", { + body: { scalar_token: "stoken" }, + }); + + await expect(sac.exchangeForScalarToken(tokenObject)).resolves.toBe("stoken"); + }); + + it("should throw upon non-20x code", async () => { + const sac = new ScalarAuthClient(apiUrl + 2, uiUrl); + + fetchMock.postOnce("https://test.com/api2/register?v=1.1", { + status: 500, + }); + + await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Scalar request failed: 500"); + }); + + it("should throw if scalar_token is missing in response", async () => { + const sac = new ScalarAuthClient(apiUrl + 3, uiUrl); + + fetchMock.postOnce("https://test.com/api3/register?v=1.1", { + body: {}, + }); + + await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Missing scalar_token in response"); + }); + }); + + describe("registerForToken", () => { + it("should call `termsInteractionCallback` upon M_TERMS_NOT_SIGNED error", async () => { + const sac = new ScalarAuthClient(apiUrl + 4, uiUrl); + const termsInteractionCallback = jest.fn(); + sac.setTermsInteractionCallback(termsInteractionCallback); + fetchMock.get("https://test.com/api4/account?scalar_token=testtoken1&v=1.1", { + body: { errcode: "M_TERMS_NOT_SIGNED" }, + }); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken1")); + mocked(client.getTerms).mockResolvedValue({ policies: [] }); + + await expect(sac.registerForToken()).resolves.toBe("testtoken1"); + }); + + it("should throw upon non-20x code", async () => { + const sac = new ScalarAuthClient(apiUrl + 5, uiUrl); + fetchMock.get("https://test.com/api5/account?scalar_token=testtoken2&v=1.1", { + body: { errcode: "SERVER_IS_SAD" }, + status: 500, + }); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken2")); + + await expect(sac.registerForToken()).rejects.toBeTruthy(); + }); + + it("should throw if user_id is missing from response", async () => { + const sac = new ScalarAuthClient(apiUrl + 6, uiUrl); + fetchMock.get("https://test.com/api6/account?scalar_token=testtoken3&v=1.1", { + body: {}, + }); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken3")); + + await expect(sac.registerForToken()).rejects.toThrow("Missing user_id in response"); + }); + }); + + describe("getScalarPageTitle", () => { + let sac: ScalarAuthClient; + + beforeEach(async () => { + SdkConfig.put({ + integrations_rest_url: apiUrl + 7, + integrations_ui_url: uiUrl, + }); + + window.localStorage.setItem("mx_scalar_token_at_https://test.com/api7", "wokentoken1"); + fetchMock.get("https://test.com/api7/account?scalar_token=wokentoken1&v=1.1", { + body: { user_id: client.getUserId() }, + }); + + sac = new ScalarAuthClient(apiUrl + 7, uiUrl); + await sac.connect(); + }); + + it("should return `cached_title` from API /widgets/title_lookup", async () => { + const url = "google.com"; + fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, { + body: { + page_title_cache_item: { + cached_title: "Google", + }, + }, + }); + + await expect(sac.getScalarPageTitle(url)).resolves.toBe("Google"); + }); + + it("should throw upon non-20x code", async () => { + const url = "yahoo.com"; + fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, { + status: 500, + }); + + await expect(sac.getScalarPageTitle(url)).rejects.toThrow("Scalar request failed: 500"); + }); + }); + + describe("disableWidgetAssets", () => { + let sac: ScalarAuthClient; + + beforeEach(async () => { + SdkConfig.put({ + integrations_rest_url: apiUrl + 8, + integrations_ui_url: uiUrl, + }); + + window.localStorage.setItem("mx_scalar_token_at_https://test.com/api8", "wokentoken1"); + fetchMock.get("https://test.com/api8/account?scalar_token=wokentoken1&v=1.1", { + body: { user_id: client.getUserId() }, + }); + + sac = new ScalarAuthClient(apiUrl + 8, uiUrl); + await sac.connect(); + }); + + it("should send state=disable to API /widgets/set_assets_state", async () => { + fetchMock.get("https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" + + "&widget_type=m.custom&widget_id=id1&state=disable", { + body: "OK", + }); + + await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id1")).resolves.toBeUndefined(); + }); + + it("should throw upon non-20x code", async () => { + fetchMock.get("https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" + + "&widget_type=m.custom&widget_id=id2&state=disable", { + status: 500, + }); + + await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id2")) + .rejects.toThrow("Scalar request failed: 500"); + }); + }); }); diff --git a/test/audio/VoiceMessageRecording-test.ts b/test/audio/VoiceMessageRecording-test.ts index 5114045c471..a49a480306f 100644 --- a/test/audio/VoiceMessageRecording-test.ts +++ b/test/audio/VoiceMessageRecording-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { IAbortablePromise, IEncryptedFile, IUploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { IEncryptedFile, UploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording"; import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording"; @@ -161,8 +161,8 @@ describe("VoiceMessageRecording", () => { matrixClient: MatrixClient, roomId: string, file: File | Blob, - _progressHandler?: IUploadOpts["progressHandler"], - ): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> => { + _progressHandler?: UploadOpts["progressHandler"], + ): Promise<{ url?: string, file?: IEncryptedFile }> => { uploadFileClient = matrixClient; uploadFileRoomId = roomId; uploadBlob = file; diff --git a/test/components/views/context_menus/EmbeddedPage-test.tsx b/test/components/views/context_menus/EmbeddedPage-test.tsx new file mode 100644 index 00000000000..c550823113e --- /dev/null +++ b/test/components/views/context_menus/EmbeddedPage-test.tsx @@ -0,0 +1,58 @@ +/* +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 React from "react"; +import fetchMock from "fetch-mock-jest"; +import { render, screen } from "@testing-library/react"; +import { mocked } from "jest-mock"; + +import { _t } from "../../../../src/languageHandler"; +import EmbeddedPage from "../../../../src/components/structures/EmbeddedPage"; + +jest.mock("../../../../src/languageHandler", () => ({ + _t: jest.fn(), +})); + +describe("", () => { + it("should translate _t strings", async () => { + mocked(_t).mockReturnValue("Przeglądaj pokoje"); + fetchMock.get("https://home.page", { + body: '_t("Explore rooms")', + }); + + const { asFragment } = render(); + await screen.findByText("Przeglądaj pokoje"); + expect(_t).toHaveBeenCalledWith("Explore rooms"); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should show error if unable to load", async () => { + mocked(_t).mockReturnValue("Couldn't load page"); + fetchMock.get("https://other.page", { + status: 404, + }); + + const { asFragment } = render(); + await screen.findByText("Couldn't load page"); + expect(_t).toHaveBeenCalledWith("Couldn't load page"); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should render nothing if no url given", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/context_menus/__snapshots__/EmbeddedPage-test.tsx.snap b/test/components/views/context_menus/__snapshots__/EmbeddedPage-test.tsx.snap new file mode 100644 index 00000000000..f5f874e2754 --- /dev/null +++ b/test/components/views/context_menus/__snapshots__/EmbeddedPage-test.tsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render nothing if no url given 1`] = ` + + + + + +`; + +exports[` should show error if unable to load 1`] = ` + + + + Couldn't load page + + + +`; + +exports[` should translate _t strings 1`] = ` + + + + + Przeglądaj pokoje + + + + +`; diff --git a/test/components/views/dialogs/ChangelogDialog-test.tsx b/test/components/views/dialogs/ChangelogDialog-test.tsx new file mode 100644 index 00000000000..f1c7800db5f --- /dev/null +++ b/test/components/views/dialogs/ChangelogDialog-test.tsx @@ -0,0 +1,104 @@ +/* +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 React from "react"; +import fetchMock from "fetch-mock-jest"; +import { render, screen, waitForElementToBeRemoved } from "@testing-library/react"; + +import ChangelogDialog from "../../../../src/components/views/dialogs/ChangelogDialog"; + +describe("", () => { + it("should fetch github proxy url for each repo with old and new version strings", async () => { + const webUrl = "https://riot.im/github/repos/vector-im/element-web/compare/oldsha1...newsha1"; + fetchMock.get(webUrl, { + url: "https://api.github.com/repos/vector-im/element-web/compare/master...develop", + html_url: "https://github.com/vector-im/element-web/compare/master...develop", + permalink_url: "https://github.com/vector-im/element-web/compare/vector-im:72ca95e...vector-im:8891698", + diff_url: "https://github.com/vector-im/element-web/compare/master...develop.diff", + patch_url: "https://github.com/vector-im/element-web/compare/master...develop.patch", + base_commit: {}, + merge_base_commit: {}, + status: "ahead", + ahead_by: 24, + behind_by: 0, + total_commits: 24, + commits: [{ + sha: "commit-sha", + html_url: "https://api.github.com/repos/vector-im/element-web/commit/commit-sha", + commit: { message: "This is the first commit message" }, + }], + files: [], + }); + const reactUrl = "https://riot.im/github/repos/matrix-org/matrix-react-sdk/compare/oldsha2...newsha2"; + fetchMock.get(reactUrl, { + url: "https://api.github.com/repos/matrix-org/matrix-react-sdk/compare/master...develop", + html_url: "https://github.com/matrix-org/matrix-react-sdk/compare/master...develop", + permalink_url: "https://github.com/matrix-org/matrix-react-sdk/compare/matrix-org:cdb00...matrix-org:4a926", + diff_url: "https://github.com/matrix-org/matrix-react-sdk/compare/master...develop.diff", + patch_url: "https://github.com/matrix-org/matrix-react-sdk/compare/master...develop.patch", + base_commit: {}, + merge_base_commit: {}, + status: "ahead", + ahead_by: 83, + behind_by: 0, + total_commits: 83, + commits: [{ + sha: "commit-sha0", + html_url: "https://api.github.com/repos/matrix-org/matrix-react-sdk/commit/commit-sha", + commit: { message: "This is a commit message" }, + }], + files: [], + }); + const jsUrl = "https://riot.im/github/repos/matrix-org/matrix-js-sdk/compare/oldsha3...newsha3"; + fetchMock.get(jsUrl, { + url: "https://api.github.com/repos/matrix-org/matrix-js-sdk/compare/master...develop", + html_url: "https://github.com/matrix-org/matrix-js-sdk/compare/master...develop", + permalink_url: "https://github.com/matrix-org/matrix-js-sdk/compare/matrix-org:6166a8f...matrix-org:fec350", + diff_url: "https://github.com/matrix-org/matrix-js-sdk/compare/master...develop.diff", + patch_url: "https://github.com/matrix-org/matrix-js-sdk/compare/master...develop.patch", + base_commit: {}, + merge_base_commit: {}, + status: "ahead", + ahead_by: 48, + behind_by: 0, + total_commits: 48, + commits: [{ + sha: "commit-sha1", + html_url: "https://api.github.com/repos/matrix-org/matrix-js-sdk/commit/commit-sha1", + commit: { message: "This is a commit message" }, + }, { + sha: "commit-sha2", + html_url: "https://api.github.com/repos/matrix-org/matrix-js-sdk/commit/commit-sha2", + commit: { message: "This is another commit message" }, + }], + files: [], + }); + + const newVersion = "newsha1-react-newsha2-js-newsha3"; + const oldVersion = "oldsha1-react-oldsha2-js-oldsha3"; + const { asFragment } = render(( + + )); + + // Wait for spinners to go away + await waitForElementToBeRemoved(screen.getAllByRole("progressbar")); + + expect(fetchMock).toHaveFetched(webUrl); + expect(fetchMock).toHaveFetched(reactUrl); + expect(fetchMock).toHaveFetched(jsUrl); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/dialogs/InviteDialog-test.tsx b/test/components/views/dialogs/InviteDialog-test.tsx index 8d10bb2357d..469cbde96b3 100644 --- a/test/components/views/dialogs/InviteDialog-test.tsx +++ b/test/components/views/dialogs/InviteDialog-test.tsx @@ -26,6 +26,11 @@ import SdkConfig from "../../../../src/SdkConfig"; import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig"; import { IConfigOptions } from "../../../../src/IConfigOptions"; +const mockGetAccessToken = jest.fn().mockResolvedValue("getAccessToken"); +jest.mock("../../../../src/IdentityAuthClient", () => jest.fn().mockImplementation(() => ({ + getAccessToken: mockGetAccessToken, +}))); + describe("InviteDialog", () => { const roomId = "!111111111111111111:example.org"; const aliceId = "@alice:example.org"; @@ -42,6 +47,14 @@ describe("InviteDialog", () => { getProfileInfo: jest.fn().mockRejectedValue({ errcode: "" }), getIdentityServerUrl: jest.fn(), searchUserDirectory: jest.fn().mockResolvedValue({}), + lookupThreePid: jest.fn(), + registerWithIdentityServer: jest.fn().mockResolvedValue({ + access_token: "access_token", + token: "token", + }), + getOpenIdToken: jest.fn().mockResolvedValue({}), + getIdentityAccount: jest.fn().mockResolvedValue({}), + getTerms: jest.fn().mockResolvedValue({ policies: [] }), }); beforeEach(() => { @@ -85,7 +98,7 @@ describe("InviteDialog", () => { expect(screen.queryByText("Invite to Room")).toBeTruthy(); }); - it("should suggest valid MXIDs even if unknown", () => { + it("should suggest valid MXIDs even if unknown", async () => { render(( { /> )); - expect(screen.queryByText("@localpart:server.tld")).toBeFalsy(); + await screen.findAllByText("@localpart:server.tld"); // Using findAllByText as the MXID is used for name too }); it("should not suggest invalid MXIDs", () => { @@ -110,4 +123,48 @@ describe("InviteDialog", () => { expect(screen.queryByText("@localpart:server:tld")).toBeFalsy(); }); + + it("should lookup inputs which look like email addresses", async () => { + mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server"); + mockClient.lookupThreePid.mockResolvedValue({ + address: "foobar@email.com", + medium: "email", + mxid: "@foobar:server", + }); + mockClient.getProfileInfo.mockResolvedValue({ + displayname: "Mr. Foo", + avatar_url: "mxc://foo/bar", + }); + + render(( + + )); + + await screen.findByText("Mr. Foo"); + await screen.findByText("@foobar:server"); + expect(mockClient.lookupThreePid).toHaveBeenCalledWith("email", "foobar@email.com", expect.anything()); + expect(mockClient.getProfileInfo).toHaveBeenCalledWith("@foobar:server"); + }); + + it("should suggest e-mail even if lookup fails", async () => { + mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server"); + mockClient.lookupThreePid.mockResolvedValue({}); + + render(( + + )); + + await screen.findByText("foobar@email.com"); + await screen.findByText("Invite by email"); + }); }); diff --git a/test/components/views/dialogs/__snapshots__/ChangelogDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/ChangelogDialog-test.tsx.snap new file mode 100644 index 00000000000..af044252bbc --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/ChangelogDialog-test.tsx.snap @@ -0,0 +1,135 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should fetch github proxy url for each repo with old and new version strings 1`] = ` + + + + + + Changelog + + + + + + + + vector-im/element-web + + + + + This is the first commit message + + + + + + + matrix-org/matrix-react-sdk + + + + + This is a commit message + + + + + + + matrix-org/matrix-js-sdk + + + + + This is a commit message + + + + + This is another commit message + + + + + + + + + + Cancel + + + Update + + + + + + +`; diff --git a/test/components/views/rooms/RoomPreviewBar-test.tsx b/test/components/views/rooms/RoomPreviewBar-test.tsx index 8f43e4cdf02..d785f5b0240 100644 --- a/test/components/views/rooms/RoomPreviewBar-test.tsx +++ b/test/components/views/rooms/RoomPreviewBar-test.tsx @@ -364,7 +364,7 @@ describe('', () => { expect(getMessage(component)).toMatchSnapshot(); expect(MatrixClientPeg.get().lookupThreePid).toHaveBeenCalledWith( - 'email', invitedEmail, undefined, 'mock-token', + 'email', invitedEmail, 'mock-token', ); await testJoinButton({ inviterName, invitedEmail })(); }); diff --git a/test/createRoom-test.ts b/test/createRoom-test.ts index 842449c6875..ec38f6f3c0a 100644 --- a/test/createRoom-test.ts +++ b/test/createRoom-test.ts @@ -130,6 +130,20 @@ describe("createRoom", () => { expect(callPower).toBe(100); expect(callMemberPower).toBe(100); }); + + it("should upload avatar if one is passed", async () => { + client.uploadContent.mockResolvedValue({ content_uri: "mxc://foobar" }); + const avatar = new File([], "avatar.png"); + await createRoom({ avatar }); + expect(client.createRoom).toHaveBeenCalledWith(expect.objectContaining({ + initial_state: expect.arrayContaining([{ + content: { + url: "mxc://foobar", + }, + type: "m.room.avatar", + }]), + })); + }); }); describe("canEncryptToAllUsers", () => { diff --git a/test/i18n-test/languageHandler-test.tsx b/test/i18n-test/languageHandler-test.tsx index 9c15bfd3feb..d4920110324 100644 --- a/test/i18n-test/languageHandler-test.tsx +++ b/test/i18n-test/languageHandler-test.tsx @@ -27,10 +27,7 @@ import { import { stubClient } from '../test-utils'; describe('languageHandler', function() { - /* - See /__mocks__/browser-request.js/ for how we are stubbing out translations - to provide fixture data for these tests - */ + // See setupLanguage.ts for how we are stubbing out translations to provide fixture data for these tests const basicString = 'Rooms'; const selfClosingTagSub = 'Accept to continue:'; const textInTagSub = 'Upgrade to your own domain'; diff --git a/test/setup/setupLanguage.ts b/test/setup/setupLanguage.ts index 5c6834d0123..5efd8786cdd 100644 --- a/test/setup/setupLanguage.ts +++ b/test/setup/setupLanguage.ts @@ -14,7 +14,70 @@ See the License for the specific language governing permissions and limitations under the License. */ +import fetchMock from "fetch-mock-jest"; + import * as languageHandler from "../../src/languageHandler"; +import en from "../../src/i18n/strings/en_EN.json"; +import de from "../../src/i18n/strings/de_DE.json"; + +fetchMock.config.overwriteRoutes = false; +fetchMock.catch(""); +window.fetch = fetchMock.sandbox(); + +const lv = { + "Save": "Saglabāt", + "Uploading %(filename)s and %(count)s others|one": "Качване на %(filename)s и %(count)s друг", +}; + +// Fake languages.json containing references to en_EN, de_DE and lv +// en_EN.json +// de_DE.json +// lv.json - mock version with few translations, used to test fallback translation + +function weblateToCounterpart(inTrs: object): object { + const outTrs = {}; + + for (const key of Object.keys(inTrs)) { + const keyParts = key.split('|', 2); + if (keyParts.length === 2) { + let obj = outTrs[keyParts[0]]; + if (obj === undefined) { + obj = outTrs[keyParts[0]] = {}; + } else if (typeof obj === "string") { + // This is a transitional edge case if a string went from singular to pluralised and both still remain + // in the translation json file. Use the singular translation as `other` and merge pluralisation atop. + obj = outTrs[keyParts[0]] = { + "other": inTrs[key], + }; + console.warn("Found entry in i18n file in both singular and pluralised form", keyParts[0]); + } + obj[keyParts[1]] = inTrs[key]; + } else { + outTrs[key] = inTrs[key]; + } + } + + return outTrs; +} + +fetchMock + .get("/i18n/languages.json", { + "en": { + "fileName": "en_EN.json", + "label": "English", + }, + "de": { + "fileName": "de_DE.json", + "label": "German", + }, + "lv": { + "fileName": "lv.json", + "label": "Latvian", + }, + }) + .get("end:en_EN.json", weblateToCounterpart(en)) + .get("end:de_DE.json", weblateToCounterpart(de)) + .get("end:lv.json", weblateToCounterpart(lv)); languageHandler.setLanguage('en'); languageHandler.setMissingEntryGenerator(key => key.split("|", 2)[1]); diff --git a/test/setup/setupManualMocks.ts b/test/setup/setupManualMocks.ts index 162529ef641..31c716e375f 100644 --- a/test/setup/setupManualMocks.ts +++ b/test/setup/setupManualMocks.ts @@ -45,6 +45,7 @@ global.matchMedia = mockMatchMedia; // maplibre requires a createObjectURL mock global.URL.createObjectURL = jest.fn(); +global.URL.revokeObjectURL = jest.fn(); // polyfilling TextEncoder as it is not available on JSDOM // view https://github.com/facebook/jest/issues/9983 diff --git a/test/setupTests.js b/test/setupTests.js index a84c7bef786..48625ae52ff 100644 --- a/test/setupTests.js +++ b/test/setupTests.js @@ -20,8 +20,7 @@ import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { configure } from "enzyme"; import "blob-polyfill"; // https://github.com/jsdom/jsdom/issues/2555 -// Enable the jest & enzyme mocks -require('jest-fetch-mock').enableMocks(); +// Enable the enzyme mocks configure({ adapter: new Adapter() }); // Very carefully enable the mocks for everything else in diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 2e01f3a4d8a..60091be2a79 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -158,7 +158,7 @@ export function createTestClient(): MatrixClient { getOpenIdToken: jest.fn().mockResolvedValue(undefined), registerWithIdentityServer: jest.fn().mockResolvedValue({}), getIdentityAccount: jest.fn().mockResolvedValue({}), - getTerms: jest.fn().mockResolvedValueOnce(undefined), + getTerms: jest.fn().mockResolvedValue({ policies: [] }), doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(undefined), isVersionSupported: jest.fn().mockResolvedValue(undefined), getPushRules: jest.fn().mockResolvedValue(undefined), @@ -182,6 +182,7 @@ export function createTestClient(): MatrixClient { setVideoInput: jest.fn(), setAudioInput: jest.fn(), } as unknown as MediaHandler), + uploadContent: jest.fn(), } as unknown as MatrixClient; client.reEmitter = new ReEmitter(client); diff --git a/test/utils/MultiInviter-test.ts b/test/utils/MultiInviter-test.ts index 5663efe42fa..0e87e8d6d61 100644 --- a/test/utils/MultiInviter-test.ts +++ b/test/utils/MultiInviter-test.ts @@ -98,9 +98,9 @@ describe('MultiInviter', () => { const result = await inviter.invite([MXID1, MXID2, MXID3]); expect(client.invite).toHaveBeenCalledTimes(3); - expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined, undefined); - expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined, undefined); - expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined, undefined); + expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined); + expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined); + expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined); expectAllInvitedResult(result); }); @@ -116,9 +116,9 @@ describe('MultiInviter', () => { const result = await inviter.invite([MXID1, MXID2, MXID3]); expect(client.invite).toHaveBeenCalledTimes(3); - expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined, undefined); - expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined, undefined); - expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined, undefined); + expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined); + expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined); + expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined); expectAllInvitedResult(result); }); @@ -131,7 +131,7 @@ describe('MultiInviter', () => { const result = await inviter.invite([MXID1, MXID2, MXID3]); expect(client.invite).toHaveBeenCalledTimes(1); - expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined, undefined); + expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined); // The resolved state is 'invited' for all users. // With the above client expectations, the test ensures that only the first user is invited. diff --git a/yarn.lock b/yarn.lock index 145de861793..9ca41fe8a3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -68,6 +68,32 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.13.tgz#6aff7b350a1e8c3e40b029e46cbe78e24a913483" integrity sha512-5yUzC5LqyTFp2HLmDoxGQelcdYgSpP9xsnMWBphAscOdFrHSAVbLNzWiy32sVNDqJRDiJK6klfDnAgu6PAGSHw== +"@babel/compat-data@^7.19.3": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.3.tgz#707b939793f867f5a73b2666e6d9a3396eb03151" + integrity sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw== + +"@babel/core@^7.0.0": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.3.tgz#2519f62a51458f43b682d61583c3810e7dcee64c" + integrity sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ== + dependencies: + "@ampproject/remapping" "^2.1.0" + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.19.3" + "@babel/helper-compilation-targets" "^7.19.3" + "@babel/helper-module-transforms" "^7.19.0" + "@babel/helpers" "^7.19.0" + "@babel/parser" "^7.19.3" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.3" + "@babel/types" "^7.19.3" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.1" + semver "^6.3.0" + "@babel/core@^7.1.0", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.7.2", "@babel/core@^7.8.0": version "7.18.13" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.13.tgz#9be8c44512751b05094a4d3ab05fc53a47ce00ac" @@ -114,6 +140,15 @@ "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" +"@babel/generator@^7.19.3": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.3.tgz#d7f4d1300485b4547cb6f94b27d10d237b42bf59" + integrity sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ== + dependencies: + "@babel/types" "^7.19.3" + "@jridgewell/gen-mapping" "^0.3.2" + jsesc "^2.5.1" + "@babel/helper-annotate-as-pure@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" @@ -139,6 +174,16 @@ browserslist "^4.20.2" semver "^6.3.0" +"@babel/helper-compilation-targets@^7.19.3": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz#a10a04588125675d7c7ae299af86fa1b2ee038ca" + integrity sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg== + dependencies: + "@babel/compat-data" "^7.19.3" + "@babel/helper-validator-option" "^7.18.6" + browserslist "^4.21.3" + semver "^6.3.0" + "@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.18.9": version "7.18.13" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.13.tgz#63e771187bd06d234f95fdf8bd5f8b6429de6298" @@ -192,6 +237,14 @@ "@babel/template" "^7.18.6" "@babel/types" "^7.18.9" +"@babel/helper-function-name@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" + integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w== + dependencies: + "@babel/template" "^7.18.10" + "@babel/types" "^7.19.0" + "@babel/helper-hoist-variables@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" @@ -227,6 +280,20 @@ "@babel/traverse" "^7.18.9" "@babel/types" "^7.18.9" +"@babel/helper-module-transforms@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz#309b230f04e22c58c6a2c0c0c7e50b216d350c30" + integrity sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ== + dependencies: + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-module-imports" "^7.18.6" + "@babel/helper-simple-access" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/helper-validator-identifier" "^7.18.6" + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" + "@babel/helper-optimise-call-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe" @@ -291,6 +358,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== +"@babel/helper-validator-identifier@^7.19.1": + version "7.19.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" + integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== + "@babel/helper-validator-option@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" @@ -315,6 +387,15 @@ "@babel/traverse" "^7.18.9" "@babel/types" "^7.18.9" +"@babel/helpers@^7.19.0": + version "7.19.0" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.0.tgz#f30534657faf246ae96551d88dd31e9d1fa1fc18" + integrity sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg== + dependencies: + "@babel/template" "^7.18.10" + "@babel/traverse" "^7.19.0" + "@babel/types" "^7.19.0" + "@babel/highlight@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" @@ -329,6 +410,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.13.tgz#5b2dd21cae4a2c5145f1fbd8ca103f9313d3b7e4" integrity sha512-dgXcIfMuQ0kgzLB2b9tRZs7TTFFaGM2AbtA4fJgUUYukzGH4jwsS7hzQHEGs67jdehpm22vkgKwvbU+aEflgwg== +"@babel/parser@^7.19.3": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.3.tgz#8dd36d17c53ff347f9e55c328710321b49479a9a" + integrity sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2" @@ -1092,6 +1178,22 @@ debug "^4.1.0" globals "^11.1.0" +"@babel/traverse@^7.19.0", "@babel/traverse@^7.19.3": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.3.tgz#3a3c5348d4988ba60884e8494b0592b2f15a04b4" + integrity sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.19.3" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/parser" "^7.19.3" + "@babel/types" "^7.19.3" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.18.10", "@babel/types@^7.18.13", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": version "7.18.13" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.13.tgz#30aeb9e514f4100f7c1cb6e5ba472b30e48f519a" @@ -1101,6 +1203,15 @@ "@babel/helper-validator-identifier" "^7.18.6" to-fast-properties "^2.0.0" +"@babel/types@^7.19.0", "@babel/types@^7.19.3": + version "7.19.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.3.tgz#fc420e6bbe54880bce6779ffaf315f5e43ec9624" + integrity sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw== + dependencies: + "@babel/helper-string-parser" "^7.18.10" + "@babel/helper-validator-identifier" "^7.19.1" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -3539,6 +3650,11 @@ core-js@^1.0.0: resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" integrity sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA== +core-js@^3.0.0: + version "3.25.5" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.25.5.tgz#e86f651a2ca8a0237a5f064c2fe56cef89646e27" + integrity sha512-nbm6eZSjm+ZuBQxCUPQKQCoUEfFOXjUZ8dTTyikyKaWrTYmAVbykQfwsKE5dBK88u3QCkCrzsx/PPlKfhsvgpw== + core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -3576,13 +3692,6 @@ crc-32@^0.3.0: resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-0.3.0.tgz#6a3d3687f5baec41f7e9b99fe1953a2e5d19775e" integrity sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA== -cross-fetch@^3.0.4: - version "3.1.5" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" - integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== - dependencies: - node-fetch "2.6.7" - cross-spawn@^6.0.0: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" @@ -4772,6 +4881,29 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +fetch-mock-jest@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/fetch-mock-jest/-/fetch-mock-jest-1.5.1.tgz#0e13df990d286d9239e284f12b279ed509bf53cd" + integrity sha512-+utwzP8C+Pax1GSka3nFXILWMY3Er2L+s090FOgqVNrNCPp0fDqgXnAHAJf12PLHi0z4PhcTaZNTz8e7K3fjqQ== + dependencies: + fetch-mock "^9.11.0" + +fetch-mock@^9.11.0: + version "9.11.0" + resolved "https://registry.yarnpkg.com/fetch-mock/-/fetch-mock-9.11.0.tgz#371c6fb7d45584d2ae4a18ee6824e7ad4b637a3f" + integrity sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q== + dependencies: + "@babel/core" "^7.0.0" + "@babel/runtime" "^7.0.0" + core-js "^3.0.0" + debug "^4.1.1" + glob-to-regexp "^0.4.0" + is-subset "^0.1.1" + lodash.isequal "^4.5.0" + path-to-regexp "^2.2.1" + querystring "^0.2.0" + whatwg-url "^6.5.0" + fflate@^0.4.1: version "0.4.8" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" @@ -5074,7 +5206,7 @@ glob-parent@^6.0.1: dependencies: is-glob "^4.0.3" -glob-to-regexp@^0.4.1: +glob-to-regexp@^0.4.0, glob-to-regexp@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== @@ -6018,14 +6150,6 @@ jest-environment-node@^27.5.1: jest-mock "^27.5.1" jest-util "^27.5.1" -jest-fetch-mock@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" - integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== - dependencies: - cross-fetch "^3.0.4" - promise-polyfill "^8.1.3" - jest-get-type@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" @@ -6788,6 +6912,11 @@ lodash.once@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== + lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" @@ -6951,10 +7080,10 @@ matrix-events-sdk@^0.0.1-beta.7: request "^2.88.2" unhomoglyph "^1.0.6" -matrix-mock-request@^2.0.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-2.1.2.tgz#11e38ed1233dced88a6f2bfba1684d5c5b3aa2c2" - integrity sha512-/OXCIzDGSLPJ3fs+uzDrtaOHI/Sqp4iEuniRn31U8S06mPXbvAnXknHqJ4c6A/KVwJj/nPFbGXpK4wPM038I6A== +matrix-mock-request@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/matrix-mock-request/-/matrix-mock-request-2.5.0.tgz#78da2590e82be2e31edcf9814833af5e5f8d2f1a" + integrity sha512-7T3gklpW+4rfHsTnp/FDML7aWoBrXhAh8+1ltinQfAh9TDj6y382z/RUMR7i03d1WDzt/ed1UTihqO5GDoOq9Q== dependencies: expect "^28.1.0" @@ -7189,13 +7318,6 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -node-fetch@2.6.7, node-fetch@^2.6.7: - version "2.6.7" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" - integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== - dependencies: - whatwg-url "^5.0.0" - node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" @@ -7204,6 +7326,13 @@ node-fetch@^1.0.1: encoding "^0.1.11" is-stream "^1.0.1" +node-fetch@^2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -7549,6 +7678,11 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-to-regexp@^2.2.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.4.0.tgz#35ce7f333d5616f1c1e1bfe266c3aba2e5b2e704" + integrity sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w== + path-to-regexp@^6.2.0: version "6.2.1" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" @@ -7750,11 +7884,6 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -promise-polyfill@^8.1.3: - version "8.2.3" - resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.2.3.tgz#2edc7e4b81aff781c88a0d577e5fe9da822107c6" - integrity sha512-Og0+jCRQetV84U8wVjMNccfGCnMQ9mGs9Hv78QFe+pSDD3gWTpz0y+1QCuxy5d/vBFuZ3iwP2eycAkvqIMPmWg== - promise@^7.0.3, promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -7854,6 +7983,11 @@ querystring@0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g== +querystring@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.1.tgz#40d77615bb09d16902a85c3e38aa8b5ed761c2dd" + integrity sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg== + querystringify@^2.1.1: version "2.2.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" @@ -9075,6 +9209,13 @@ tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA== + dependencies: + punycode "^2.1.0" + tr46@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240" @@ -9448,6 +9589,11 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + webidl-conversions@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" @@ -9493,6 +9639,15 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" +whatwg-url@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8" + integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + whatwg-url@^8.0.0, whatwg-url@^8.5.0: version "8.7.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77"