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`] = ` + +
+