From 60585c239bb2002a13bb6234b7b29fdf78924ae2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 4 Oct 2022 12:18:33 +0100 Subject: [PATCH 01/26] Update to new js-sdk upload signatures --- src/AddThreepid.ts | 4 +- src/ContentMessages.ts | 259 +++++++----------- src/components/structures/UploadBar.tsx | 13 +- src/components/views/dialogs/InviteDialog.tsx | 7 +- .../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/models/IUpload.ts | 28 -- src/models/RoomUpload.ts | 56 ++++ src/utils/MultiInviter.ts | 2 +- 16 files changed, 177 insertions(+), 214 deletions(-) delete mode 100644 src/models/IUpload.ts create mode 100644 src/models/RoomUpload.ts 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 ca8b68b5bbe..a022104350c 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, Upload, UploadOpts } 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"; @@ -61,14 +61,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. * @@ -280,67 +272,57 @@ function readFileAsArrayBuffer(file: File | Blob): Promise { * 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"], +): Promise<{ url?: string, file?: IEncryptedFile }> { + const abortController = new AbortController(); + + let upload: Upload; + + // 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(); + + // 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]); + upload = matrixClient.uploadContent(blob, { + progressHandler, + includeFilename: false, + }); - 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); + const { content_uri: url } = await upload.promise; + 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(); + const upload = matrixClient.uploadContent(file, { progressHandler }); + return upload.promise.then(function({ content_uri: url }) { + if (abortController.signal.aborted) 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; + }) as Promise<{ url: string }>; } } export default class ContentMessages { - private inprogress: IUpload[] = []; + private inprogress: RoomUpload[] = []; private mediaConfig: IMediaConfig = null; public sendStickerContentToRoom( @@ -444,27 +426,23 @@ 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( + private async sendContentToRoom( file: File, roomId: string, relation: IEventRelation | undefined, @@ -472,8 +450,9 @@ export default class ContentMessages { replyToEvent: MatrixEvent | undefined, 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, }, @@ -496,91 +475,71 @@ 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() { + 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); + 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 + 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 }, @@ -590,27 +549,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/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx index e3dc77a4665..f07f3ffd8fb 100644 --- a/src/components/structures/UploadBar.tsx +++ b/src/components/structures/UploadBar.tsx @@ -26,8 +26,7 @@ import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import ProgressBar from "../views/elements/ProgressBar"; import AccessibleButton from "../views/elements/AccessibleButton"; -import { IUpload } from "../../models/IUpload"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { RoomUpload } from "../../models/RoomUpload"; interface IProps { room: Room; @@ -35,13 +34,11 @@ interface IProps { } interface IState { - currentUpload?: IUpload; - uploadsHere: IUpload[]; + currentUpload?: RoomUpload; + uploadsHere: RoomUpload[]; } export default class UploadBar extends React.Component { - static contextType = MatrixClientContext; - private dispatcherRef: string; private mounted: boolean; @@ -64,7 +61,7 @@ export default class UploadBar extends React.Component { 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,7 +83,7 @@ export default class UploadBar extends React.Component { private onCancelClick = (ev) => { ev.preventDefault(); - ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise, this.context); + ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload); }; render() { diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 478a0f8d505..8776b98d4c4 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -632,12 +632,7 @@ export default class InviteDialog extends React.PureComponent = ({ 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).promise; 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..e8db7e0bb8c 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).promise; 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 e0e1f13f059..0a9e062a1a5 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -143,7 +143,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..4176775bb01 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).promise.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 4c63f6ff149..9871173ad96 100644 --- a/src/components/views/settings/ProfileSettings.tsx +++ b/src/components/views/settings/ProfileSettings.tsx @@ -110,7 +110,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).promise; 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 3e258d28cba..a05cef37e62 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -219,7 +219,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).promise); } 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 023bd5403ce..7503768391b 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"; 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/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..2786b5c6019 --- /dev/null +++ b/src/models/RoomUpload.ts @@ -0,0 +1,56 @@ +/* +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, Upload } from "matrix-js-sdk/src/matrix"; + +import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; + +export class RoomUpload { + private fileUpload: Upload; + private abortController = new AbortController(); + + constructor( + public readonly roomId: string, + public readonly fileName: string, + public readonly relation?: IEventRelation, + public readonly fileSize?: number, + ) {} + + public associate(upload: Upload): void { + this.fileUpload = upload; + if (this.cancelled) { + upload.abortController.abort(); + } + } + + public abort(): void { + this.abortController.abort(); + } + + public get cancelled(): boolean { + return this.fileUpload?.abortController.signal.aborted ?? this.abortController.signal.aborted; + } + + public get total(): number { + return this.fileUpload?.total ?? this.fileSize ?? 0; + } + + public get loaded(): number { + return this.fileUpload?.loaded ?? 0; + } + + promise: Promise<{ url?: string, file?: IEncryptedFile }>; +} 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'); } From 1647901c1070a387c2fef34108cc479ab1c48a1d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 4 Oct 2022 23:20:27 +0100 Subject: [PATCH 02/26] Iterate --- cypress/support/client.ts | 15 +++++++-------- src/Lifecycle.ts | 2 +- test/audio/VoiceMessageRecording-test.ts | 6 +++--- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/cypress/support/client.ts b/cypress/support/client.ts index c3f3aab0eb6..db5d4850c8b 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/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/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; From 9f0d1e9e0aa4d5679e138d5525e005421ae96098 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 5 Oct 2022 13:25:07 +0100 Subject: [PATCH 03/26] Remove browser-request some more --- __mocks__/browser-request.js | 81 ------- cypress/e2e/timeline/timeline.spec.ts | 4 +- cypress/support/bot.ts | 3 - cypress/support/client.ts | 6 +- package.json | 3 +- src/ScalarAuthClient.ts | 163 ++++++-------- src/components/structures/EmbeddedPage.tsx | 59 ++--- src/components/structures/auth/Login.tsx | 4 +- .../views/dialogs/ChangelogDialog.tsx | 30 ++- src/languageHandler.tsx | 73 +++--- .../views/rooms/RoomPreviewBar-test.tsx | 2 +- test/i18n-test/languageHandler-test.tsx | 5 +- test/setup/setupLanguage.ts | 63 ++++++ test/setupTests.js | 3 +- test/utils/MultiInviter-test.ts | 14 +- yarn.lock | 211 +++++++++++++++--- 16 files changed, 416 insertions(+), 308 deletions(-) delete mode 100644 __mocks__/browser-request.js 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/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 94b6ffaa425..82d7abe36cd 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; }); }); diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index f724d6b3d36..ba0d8b8a263 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 db5d4850c8b..c66bf4573bc 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -92,7 +92,7 @@ declare global { uploadContent( file: FileType, opts?: UploadOpts, - ): Chainable; + ): Chainable>; /** * Turn an MXC URL into an HTTP one. This method is experimental and * may change. @@ -202,9 +202,9 @@ Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => { }); }); -Cypress.Commands.add("uploadContent", (file: FileType, opts?: UploadOpts): Chainable => { +Cypress.Commands.add("uploadContent", (file: FileType, opts?: UploadOpts): Chainable> => { return cy.getClient().then(async (cli: MatrixClient) => { - return cli.uploadContent(file, opts); + return cli.uploadContent(file, opts).promise; }); }); diff --git a/package.json b/package.json index 82b26a93f53..7a6ca467ce1 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "@types/geojson": "^7946.0.8", "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", @@ -187,12 +186,12 @@ "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", diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index b1fb6e44f4d..3e47d8cbbd5 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -15,7 +15,6 @@ 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"; @@ -103,29 +102,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 +182,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: any): 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 +227,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..f135b9757d8 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,36 @@ 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") }); + } + + 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 +97,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/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/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/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/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/setupTests.js b/test/setupTests.js index dc6d4b9d5f5..0494f252d82 100644 --- a/test/setupTests.js +++ b/test/setupTests.js @@ -19,8 +19,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/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 965d66f09c2..f880bca576a 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" @@ -3529,6 +3640,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" @@ -3566,13 +3682,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" @@ -4762,6 +4871,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" @@ -5064,7 +5196,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== @@ -6008,14 +6140,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" @@ -6778,6 +6902,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" @@ -7179,13 +7308,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" @@ -7194,6 +7316,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" @@ -7539,6 +7668,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" @@ -7740,11 +7874,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" @@ -7844,6 +7973,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" @@ -9065,6 +9199,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" @@ -9433,6 +9574,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" @@ -9478,6 +9624,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" From 73bc3ec0aeb95a85e1ac5961b5f1938a87be9051 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 5 Oct 2022 14:55:07 +0100 Subject: [PATCH 04/26] Stash --- package.json | 2 +- src/components/structures/EmbeddedPage.tsx | 1 + src/i18n/strings/en_EN.json | 2 +- yarn.lock | 8 ++++---- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 7a6ca467ce1..5a6c910452c 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,7 @@ "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/components/structures/EmbeddedPage.tsx b/src/components/structures/EmbeddedPage.tsx index f135b9757d8..11f286edc24 100644 --- a/src/components/structures/EmbeddedPage.tsx +++ b/src/components/structures/EmbeddedPage.tsx @@ -69,6 +69,7 @@ export default class EmbeddedPage extends React.PureComponent { if (this.unmounted) return; logger.warn(`Error loading page: ${err}`); this.setState({ page: _t("Couldn't load page") }); + return; } if (this.unmounted) return; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 03d5517c84e..0129e149c62 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", @@ -653,7 +654,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/yarn.lock b/yarn.lock index f880bca576a..36122cdbfba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7070,10 +7070,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" From f0a74784b8b1f987b33f0364a0d5cf5190aaf170 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 6 Oct 2022 11:57:14 +0100 Subject: [PATCH 05/26] Fix login method --- src/Login.ts | 2 +- src/components/structures/TimelinePanel.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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; From 2045511d2775ca0fcd03dbdd783f6285b85bc375 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 6 Oct 2022 13:20:47 +0100 Subject: [PATCH 06/26] Iterate everything --- .../views/dialogs/SlidingSyncOptionsDialog.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx b/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx index ed793231b21..ea5c77d7f71 100644 --- a/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx +++ b/src/components/views/dialogs/SlidingSyncOptionsDialog.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import { MatrixClient } from 'matrix-js-sdk/src/matrix'; +import { MatrixClient, Method } from 'matrix-js-sdk/src/matrix'; import { logger } from 'matrix-js-sdk/src/logger'; import { _t } from '../../../languageHandler'; @@ -33,17 +33,10 @@ import { SettingLevel } from "../../../settings/SettingLevel"; * @throws if the proxy server is unreachable or not configured to the given homeserver */ async function syncHealthCheck(cli: MatrixClient): Promise { - 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"); } From fabd45e8ed150b8a2ceb5db57a2da5b2442eee27 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 7 Oct 2022 12:31:42 +0100 Subject: [PATCH 07/26] Update InviteDialog tests --- .../views/dialogs/InviteDialog-test.tsx | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/test/components/views/dialogs/InviteDialog-test.tsx b/test/components/views/dialogs/InviteDialog-test.tsx index 8d10bb2357d..b352e9e587a 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({}), }); 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"); + }); }); From c271631f3b8f054fde222770b54eb6a0e5c7a69f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 7 Oct 2022 12:32:02 +0100 Subject: [PATCH 08/26] Update createRoom tests --- test/createRoom-test.ts | 16 +++++++++++++++- test/test-utils/test-utils.ts | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/test/createRoom-test.ts b/test/createRoom-test.ts index 7dbd4a2a41c..31e74d8e94d 100644 --- a/test/createRoom-test.ts +++ b/test/createRoom-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { mocked, Mocked } from "jest-mock"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { MatrixClient, Upload } from "matrix-js-sdk/src/matrix"; import { IDevice } from "matrix-js-sdk/src/crypto/deviceinfo"; import { RoomType } from "matrix-js-sdk/src/@types/event"; @@ -109,6 +109,20 @@ describe("createRoom", () => { expect(createJitsiCallSpy).not.toHaveBeenCalled(); expect(createElementCallSpy).not.toHaveBeenCalled(); }); + + it("should upload avatar if one is passed", async () => { + client.uploadContent.mockReturnValue({ promise: Promise.resolve({ content_uri: "mxc://foobar" }) } as Upload); + 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/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 0647f2604df..6773d846a13 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -181,6 +181,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); From ea8018baa9ce54a11a7cec5346fb7593f403508d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 7 Oct 2022 13:25:04 +0100 Subject: [PATCH 09/26] Add tests for ContentMessages uploadFile --- test/ContentMessages-test.ts | 54 ++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index 8572fbe8790..249658bcd3e 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -15,10 +15,14 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { IImageInfo, ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { IImageInfo, ISendEventResponse, MatrixClient, Upload } from "matrix-js-sdk/src/matrix"; +import encrypt from "matrix-encrypt-attachment"; -import ContentMessages from "../src/ContentMessages"; +import ContentMessages, { uploadFile } from "../src/ContentMessages"; import { doMaybeLocalRoomAction } from "../src/utils/local-room"; +import { createTestClient } from "./test-utils"; + +jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) })); jest.mock("../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), @@ -66,3 +70,49 @@ describe("ContentMessages", () => { }); }); }); + +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).mockReturnValue({ + promise: Promise.resolve({ content_uri: "mxc://server/file" }), + } as Upload); + 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, { progressHandler }); + }); + + it("should encrypt the file if the room is encrypted", async () => { + mocked(client.isRoomEncrypted).mockReturnValue(true); + mocked(client.uploadContent).mockReturnValue({ + promise: Promise.resolve({ content_uri: "mxc://server/file" }), + } as Upload); + 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), { + progressHandler, + includeFilename: false, + }); + expect(mocked(client.uploadContent).mock.calls[0][0]).not.toBe(file); + }); +}); From 58ee10c1992f686f946a2e15a8f7a3fe31dacd8e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 7 Oct 2022 15:59:04 +0100 Subject: [PATCH 10/26] Iterate uploadContent signature --- src/ContentMessages.ts | 34 ++++++------- .../views/elements/MiniAvatarUploader.tsx | 2 +- .../room_settings/RoomProfileSettings.tsx | 2 +- .../views/settings/ChangeAvatar.tsx | 2 +- .../views/settings/ProfileSettings.tsx | 2 +- src/createRoom.ts | 2 +- src/models/RoomUpload.ts | 22 ++++----- test/ContentMessages-test.ts | 48 +++++++++++++++---- test/createRoom-test.ts | 4 +- 9 files changed, 71 insertions(+), 47 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 79fe1663db7..bea3ef76b99 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -22,7 +22,7 @@ import encrypt from "matrix-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; import { IImageInfo } from "matrix-js-sdk/src/@types/partials"; import { logger } from "matrix-js-sdk/src/logger"; -import { IEventRelation, ISendEventResponse, MatrixEvent, Upload, UploadOpts } from "matrix-js-sdk/src/matrix"; +import { IEventRelation, ISendEventResponse, MatrixEvent, UploadOpts } 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"; @@ -281,8 +281,6 @@ export async function uploadFile( ): Promise<{ url?: string, file?: IEncryptedFile }> { const abortController = new AbortController(); - let upload: Upload; - // If the room is encrypted then encrypt the file before uploading it. if (matrixClient.isRoomEncrypted(roomId)) { // First read the file into memory. @@ -295,17 +293,16 @@ export async function uploadFile( // Pass the encrypted data as a Blob to the uploader. const blob = new Blob([encryptResult.data]); - upload = matrixClient.uploadContent(blob, { + + const { content_uri: url } = await matrixClient.uploadContent(blob, { progressHandler, + abortController, includeFilename: false, }); - - const { content_uri: url } = await upload.promise; 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. + // 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, @@ -313,12 +310,10 @@ export async function uploadFile( } as IEncryptedFile, }; } else { - const upload = matrixClient.uploadContent(file, { progressHandler }); - return upload.promise.then(function({ content_uri: url }) { - if (abortController.signal.aborted) throw new UploadCanceledError(); - // If the attachment isn't encrypted then include the URL directly. - return { url }; - }) as Promise<{ url: string }>; + 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 }; } } @@ -458,13 +453,13 @@ export default class ContentMessages { dis.dispatch({ action: Action.UploadCanceled, upload }); } - private async sendContentToRoom( + public async sendContentToRoom( file: File, roomId: string, relation: IEventRelation | undefined, matrixClient: MatrixClient, replyToEvent: MatrixEvent | undefined, - promBefore: Promise, + promBefore?: Promise, ) { const fileName = file.name || _t("Attachment"); const content: Omit & { info: Partial } = { @@ -495,7 +490,8 @@ export default class ContentMessages { this.inprogress.push(upload); dis.dispatch({ action: Action.UploadStarted, upload }); - function onProgress() { + function onProgress(progress) { + upload.onProgress(progress); dis.dispatch({ action: Action.UploadProgress, upload }); } @@ -533,7 +529,7 @@ export default class ContentMessages { if (upload.cancelled) throw new UploadCanceledError(); // Await previous message being sent into the room - await promBefore; + if (promBefore) await promBefore; if (upload.cancelled) throw new UploadCanceledError(); const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 181bb3301f8..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 { content_uri: uri } = await cli.uploadContent(file).promise; + 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 e8db7e0bb8c..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 { content_uri: uri } = await client.uploadContent(this.state.avatarFile).promise; + 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/settings/ChangeAvatar.tsx b/src/components/views/settings/ChangeAvatar.tsx index 4176775bb01..680291db4ce 100644 --- a/src/components/views/settings/ChangeAvatar.tsx +++ b/src/components/views/settings/ChangeAvatar.tsx @@ -115,7 +115,7 @@ export default class ChangeAvatar extends React.Component { this.setState({ phase: Phases.Uploading, }); - const httpPromise = MatrixClientPeg.get().uploadContent(file).promise.then(({ content_uri: url }) => { + const httpPromise = MatrixClientPeg.get().uploadContent(file).then(({ content_uri: url }) => { newUrl = url; if (this.props.room) { return MatrixClientPeg.get().sendStateEvent( diff --git a/src/components/views/settings/ProfileSettings.tsx b/src/components/views/settings/ProfileSettings.tsx index 87e0a0d86f2..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 { content_uri: uri } = await client.uploadContent(this.state.avatarFile).promise; + 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 c54c0c4e732..5d22f162959 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -235,7 +235,7 @@ export default async function createRoom(opts: IOpts): Promise { if (opts.avatar) { let url = opts.avatar; if (opts.avatar instanceof File) { - ({ content_uri: url } = await client.uploadContent(opts.avatar).promise); + ({ content_uri: url } = await client.uploadContent(opts.avatar)); } createOpts.initial_state.push({ diff --git a/src/models/RoomUpload.ts b/src/models/RoomUpload.ts index 2786b5c6019..99921476244 100644 --- a/src/models/RoomUpload.ts +++ b/src/models/RoomUpload.ts @@ -14,26 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { IEventRelation, Upload } from "matrix-js-sdk/src/matrix"; +import { IEventRelation, UploadProgress } from "matrix-js-sdk/src/matrix"; import { IEncryptedFile } from "../customisations/models/IMediaEventContent"; export class RoomUpload { - private fileUpload: Upload; - private abortController = new AbortController(); + private uploaded = 0; + public readonly abortController = new AbortController(); constructor( public readonly roomId: string, public readonly fileName: string, public readonly relation?: IEventRelation, - public readonly fileSize?: number, + public fileSize = 0, ) {} - public associate(upload: Upload): void { - this.fileUpload = upload; - if (this.cancelled) { - upload.abortController.abort(); - } + public onProgress(progress: UploadProgress) { + this.uploaded = progress.loaded; + this.fileSize = progress.total; } public abort(): void { @@ -41,15 +39,15 @@ export class RoomUpload { } public get cancelled(): boolean { - return this.fileUpload?.abortController.signal.aborted ?? this.abortController.signal.aborted; + return this.abortController.signal.aborted; } public get total(): number { - return this.fileUpload?.total ?? this.fileSize ?? 0; + return this.fileSize; } public get loaded(): number { - return this.fileUpload?.loaded ?? 0; + return this.uploaded; } promise: Promise<{ url?: string, file?: IEncryptedFile }>; diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index 249658bcd3e..1f55e9b5fce 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -15,10 +15,11 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { IImageInfo, ISendEventResponse, MatrixClient, Upload } from "matrix-js-sdk/src/matrix"; -import encrypt from "matrix-encrypt-attachment"; +import { IImageInfo, ISendEventResponse, MatrixClient, UploadResponse } from "matrix-js-sdk/src/matrix"; +import { defer } from "matrix-js-sdk/src/utils"; +import encrypt, { IEncryptedFile } from "matrix-encrypt-attachment"; -import ContentMessages, { uploadFile } from "../src/ContentMessages"; +import ContentMessages, { UploadCanceledError, uploadFile } from "../src/ContentMessages"; import { doMaybeLocalRoomAction } from "../src/utils/local-room"; import { createTestClient } from "./test-utils"; @@ -69,6 +70,23 @@ describe("ContentMessages", () => { expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text); }); }); + + describe("getCurrentUploads", () => { + beforeEach(() => { + mocked(doMaybeLocalRoomAction).mockImplementation(( + roomId: string, + fn: (actualRoomId: string) => Promise, + ) => fn(roomId)); + }); + + it("should return only uploads for the given relation", async () => { + // await contentMessages.sendContentListToRoom([]); + }); + + it("should return only uploads for no relation when not passed one", () => { + + }); + }); }); describe("uploadFile", () => { @@ -80,9 +98,7 @@ describe("uploadFile", () => { it("should not encrypt the file if the room isn't encrypted", async () => { mocked(client.isRoomEncrypted).mockReturnValue(false); - mocked(client.uploadContent).mockReturnValue({ - promise: Promise.resolve({ content_uri: "mxc://server/file" }), - } as Upload); + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); const progressHandler = jest.fn(); const file = new Blob([]); @@ -96,9 +112,11 @@ describe("uploadFile", () => { it("should encrypt the file if the room is encrypted", async () => { mocked(client.isRoomEncrypted).mockReturnValue(true); - mocked(client.uploadContent).mockReturnValue({ - promise: Promise.resolve({ content_uri: "mxc://server/file" }), - } as Upload); + 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"]); @@ -111,8 +129,20 @@ describe("uploadFile", () => { expect(encrypt.encryptAttachment).toHaveBeenCalled(); expect(client.uploadContent).toHaveBeenCalledWith(expect.any(Blob), { progressHandler, + abortController: expect.any(AbortController), includeFilename: false, }); expect(mocked(client.uploadContent).mock.calls[0][0]).not.toBe(file); }); + + it("should throw UploadCanceledError upon aborting the upload", async () => { + 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/createRoom-test.ts b/test/createRoom-test.ts index 31e74d8e94d..712014c6647 100644 --- a/test/createRoom-test.ts +++ b/test/createRoom-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { mocked, Mocked } from "jest-mock"; -import { MatrixClient, Upload } from "matrix-js-sdk/src/matrix"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { IDevice } from "matrix-js-sdk/src/crypto/deviceinfo"; import { RoomType } from "matrix-js-sdk/src/@types/event"; @@ -111,7 +111,7 @@ describe("createRoom", () => { }); it("should upload avatar if one is passed", async () => { - client.uploadContent.mockReturnValue({ promise: Promise.resolve({ content_uri: "mxc://foobar" }) } as Upload); + client.uploadContent.mockResolvedValue({ content_uri: "mxc://foobar" }); const avatar = new File([], "avatar.png"); await createRoom({ avatar }); expect(client.createRoom).toHaveBeenCalledWith(expect.objectContaining({ From 88406e4dd585ce4c70420ebb1c15e651a4273474 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 7 Oct 2022 16:16:43 +0100 Subject: [PATCH 11/26] Fix tests --- test/ContentMessages-test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index 1f55e9b5fce..5a40a77feb1 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -107,7 +107,7 @@ describe("uploadFile", () => { expect(res.url).toBe("mxc://server/file"); expect(res.file).toBeFalsy(); expect(encrypt.encryptAttachment).not.toHaveBeenCalled(); - expect(client.uploadContent).toHaveBeenCalledWith(file, { progressHandler }); + expect(client.uploadContent).toHaveBeenCalledWith(file, expect.objectContaining({ progressHandler })); }); it("should encrypt the file if the room is encrypted", async () => { @@ -127,15 +127,15 @@ describe("uploadFile", () => { url: "mxc://server/file", })); expect(encrypt.encryptAttachment).toHaveBeenCalled(); - expect(client.uploadContent).toHaveBeenCalledWith(expect.any(Blob), { + expect(client.uploadContent).toHaveBeenCalledWith(expect.any(Blob), expect.objectContaining({ progressHandler, - abortController: expect.any(AbortController), 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([]); From e242be7cf609c40277dac38c9032d6d438047e16 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 7 Oct 2022 16:34:54 +0100 Subject: [PATCH 12/26] Fix uploadContent type --- cypress/support/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/support/client.ts b/cypress/support/client.ts index c66bf4573bc..e20c08a8139 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -204,7 +204,7 @@ Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => { Cypress.Commands.add("uploadContent", (file: FileType, opts?: UploadOpts): Chainable> => { return cy.getClient().then(async (cli: MatrixClient) => { - return cli.uploadContent(file, opts).promise; + return cli.uploadContent(file, opts); }); }); From 0bbcb69eb9ee9bfa3b2f765fb38b4f9f712b1318 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Oct 2022 12:23:16 +0100 Subject: [PATCH 13/26] More tests --- src/ContentMessages.ts | 8 +-- test/ContentMessages-test.ts | 125 ++++++++++++++++++++++++++++++++- test/setup/setupManualMocks.ts | 1 + 3 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index bea3ef76b99..05f98efe76c 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -22,7 +22,7 @@ import encrypt from "matrix-encrypt-attachment"; import extractPngChunks from "png-chunks-extract"; import { IImageInfo } from "matrix-js-sdk/src/@types/partials"; import { logger } from "matrix-js-sdk/src/logger"; -import { IEventRelation, ISendEventResponse, MatrixEvent, UploadOpts } 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"; @@ -70,7 +70,7 @@ interface IMediaConfig { */ 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() { @@ -85,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). @@ -490,7 +490,7 @@ export default class ContentMessages { this.inprogress.push(upload); dis.dispatch({ action: Action.UploadStarted, upload }); - function onProgress(progress) { + function onProgress(progress: UploadProgress) { upload.onProgress(progress); dis.dispatch({ action: Action.UploadProgress, upload }); } diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index 5a40a77feb1..a3a2af7f6f8 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -15,20 +15,31 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { IImageInfo, ISendEventResponse, MatrixClient, UploadResponse } 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, { 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"; @@ -41,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); @@ -71,7 +85,90 @@ describe("ContentMessages", () => { }); }); + 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", + })); + }); + }); + describe("getCurrentUploads", () => { + const file1 = new File([], "file1"); + const file2 = new File([], "file2"); + const roomId = "!roomId:server"; + beforeEach(() => { mocked(doMaybeLocalRoomAction).mockImplementation(( roomId: string, @@ -80,11 +177,33 @@ describe("ContentMessages", () => { }); it("should return only uploads for the given relation", async () => { - // await contentMessages.sendContentListToRoom([]); + 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", () => { + 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]); }); }); }); 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 From 592eb7973575f82389353ea25622505b23c45cc9 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Oct 2022 14:19:39 +0100 Subject: [PATCH 14/26] Increase test coverage --- src/ContentMessages.ts | 6 +++-- src/ScalarAuthClient.ts | 3 ++- test/ContentMessages-test.ts | 27 ++++++++++++++++++++ test/ScalarAuthClient-test.ts | 47 ++++++++++++++++++++++++----------- 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 05f98efe76c..d4cf3cc0ab5 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -269,6 +269,7 @@ 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. @@ -278,8 +279,9 @@ export async function uploadFile( roomId: string, file: File | Blob, progressHandler?: UploadOpts["progressHandler"], + controller?: AbortController, ): Promise<{ url?: string, file?: IEncryptedFile }> { - const abortController = new AbortController(); + const abortController = controller ?? new AbortController(); // If the room is encrypted then encrypt the file before uploading it. if (matrixClient.isRoomEncrypted(roomId)) { @@ -523,7 +525,7 @@ export default class ContentMessages { } if (upload.cancelled) throw new UploadCanceledError(); - const result = await uploadFile(matrixClient, roomId, file, onProgress); + const result = await uploadFile(matrixClient, roomId, file, onProgress, upload.abortController); content.file = result.file; content.url = result.url; diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index 3e47d8cbbd5..5dacd079734 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -18,6 +18,7 @@ import url from 'url'; 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'; @@ -182,7 +183,7 @@ export default class ScalarAuthClient { }); } - public async exchangeForScalarToken(openidTokenObject: any): Promise { + public async exchangeForScalarToken(openidTokenObject: IOpenIDToken): Promise { const scalarRestUrl = new URL(this.apiUrl + "/register"); scalarRestUrl.searchParams.set("v", imApiVersion); diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index a3a2af7f6f8..d2b8a1fecbc 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -162,6 +162,17 @@ describe("ContentMessages", () => { 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", + })); + }); }); describe("getCurrentUploads", () => { @@ -206,6 +217,22 @@ describe("ContentMessages", () => { 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", () => { diff --git a/test/ScalarAuthClient-test.ts b/test/ScalarAuthClient-test.ts index 3b6fcf77b2b..520a7e3f551 100644 --- a/test/ScalarAuthClient-test.ts +++ b/test/ScalarAuthClient-test.ts @@ -14,13 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ +import fetchMock from "fetch-mock-jest"; +import { MatrixError } from "matrix-js-sdk/src/matrix"; + import ScalarAuthClient from '../src/ScalarAuthClient'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { stubClient } from './test-utils'; 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, + }; + beforeEach(function() { window.localStorage.getItem = jest.fn((arg) => { if (arg === "mx_scalar_token") return "brokentoken"; @@ -31,23 +41,18 @@ describe('ScalarAuthClient', function() { it('should request a new token if the old one fails', async function() { const sac = new ScalarAuthClient(apiUrl, 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/api/account?scalar_token=brokentoken&v=1.1", { + throws: new MatrixError({ message: "Invalid token" }), }); - MatrixClientPeg.get().getOpenIdToken = jest.fn().mockResolvedValue('this is your openid token'); + fetchMock.get("https://test.com/api/account?scalar_token=wokentoken&v=1.1", { + body: { user_id: MatrixClientPeg.get().getUserId() }, + }); + + MatrixClientPeg.get().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(); @@ -57,4 +62,16 @@ describe('ScalarAuthClient', function() { // @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, uiUrl); + + fetchMock.post("https://test.com/api/register?v=1.1", { + body: { scalar_token: "stoken" }, + }); + + await expect(sac.exchangeForScalarToken(tokenObject)).resolves.toBe("stoken"); + }); + }); }); From 6243625aa87290286b13332ba0a441cbdd43cf48 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Oct 2022 14:33:33 +0100 Subject: [PATCH 15/26] Fix test --- test/ScalarAuthClient-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ScalarAuthClient-test.ts b/test/ScalarAuthClient-test.ts index 520a7e3f551..6194902ba7e 100644 --- a/test/ScalarAuthClient-test.ts +++ b/test/ScalarAuthClient-test.ts @@ -57,7 +57,7 @@ describe('ScalarAuthClient', function() { 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'); From 38aa3e5d3b81a99e69c440c4b1638cda692b992a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Oct 2022 16:23:47 +0100 Subject: [PATCH 16/26] Improve coverage --- test/ScalarAuthClient-test.ts | 114 +++++++++++++++++++++++++++++++--- test/test-utils/test-utils.ts | 2 +- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/test/ScalarAuthClient-test.ts b/test/ScalarAuthClient-test.ts index 6194902ba7e..0a9c34e98e4 100644 --- a/test/ScalarAuthClient-test.ts +++ b/test/ScalarAuthClient-test.ts @@ -14,12 +14,12 @@ 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 { MatrixError } from "matrix-js-sdk/src/matrix"; import ScalarAuthClient from '../src/ScalarAuthClient'; -import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { stubClient } from './test-utils'; +import SdkConfig from "../src/SdkConfig"; describe('ScalarAuthClient', function() { const apiUrl = 'https://test.com/api'; @@ -31,25 +31,24 @@ describe('ScalarAuthClient', function() { expires_in: 999, }; + let client; beforeEach(function() { - window.localStorage.getItem = jest.fn((arg) => { - if (arg === "mx_scalar_token") return "brokentoken"; - }); - stubClient(); + window.localStorage.setItem("mx_scalar_token", "brokentoken"); + client = stubClient(); }); it('should request a new token if the old one fails', async function() { const sac = new ScalarAuthClient(apiUrl, uiUrl); fetchMock.get("https://test.com/api/account?scalar_token=brokentoken&v=1.1", { - throws: new MatrixError({ message: "Invalid token" }), + body: { message: "Invalid token" }, }); fetchMock.get("https://test.com/api/account?scalar_token=wokentoken&v=1.1", { - body: { user_id: MatrixClientPeg.get().getUserId() }, + body: { user_id: client.getUserId() }, }); - MatrixClientPeg.get().getOpenIdToken = jest.fn().mockResolvedValue(tokenObject); + client.getOpenIdToken = jest.fn().mockResolvedValue(tokenObject); sac.exchangeForScalarToken = jest.fn((arg) => { if (arg === tokenObject) return Promise.resolve("wokentoken"); @@ -73,5 +72,102 @@ describe('ScalarAuthClient', function() { await expect(sac.exchangeForScalarToken(tokenObject)).resolves.toBe("stoken"); }); + + it("should throw upon non-20x code", async () => { + const sac = new ScalarAuthClient(apiUrl, uiUrl); + + fetchMock.post("https://test.com/api/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, uiUrl); + + fetchMock.post("https://test.com/api/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, uiUrl); + const termsInteractionCallback = jest.fn(); + sac.setTermsInteractionCallback(termsInteractionCallback); + fetchMock.get("https://test.com/api/account?scalar_token=testtoken&v=1.1", { + body: { errcode: "M_TERMS_NOT_SIGNED" }, + }); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken")); + mocked(client.getTerms).mockResolvedValue({ policies: [] }); + + await expect(sac.registerForToken()).resolves.toBe("testtoken"); + }); + + it("should throw upon non-20x code", async () => { + const sac = new ScalarAuthClient(apiUrl, uiUrl); + fetchMock.get("https://test.com/api/account?scalar_token=testtoken&v=1.1", { + body: { errcode: "SERVER_IS_SAD" }, + status: 500, + }); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken")); + + await expect(sac.registerForToken()).rejects.toBeTruthy(); + }); + + it("should throw if user_id is missing from response", async () => { + const sac = new ScalarAuthClient(apiUrl, uiUrl); + fetchMock.get("https://test.com/api/account?scalar_token=testtoken&v=1.1", { + body: {}, + }); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken")); + + await expect(sac.registerForToken()).rejects.toThrow("Missing user_id in response"); + }); + }); + + describe("getScalarPageTitle", () => { + let sac: ScalarAuthClient; + + beforeEach(async () => { + window.localStorage.setItem("mx_scalar_token", "wokentoken"); + SdkConfig.put({ + integrations_rest_url: apiUrl, + integrations_ui_url: uiUrl, + }); + + fetchMock.get("https://test.com/api/account?scalar_token=wokentoken&v=1.1", { + body: { user_id: client.getUserId() }, + }); + + sac = new ScalarAuthClient(apiUrl, uiUrl); + await sac.connect(); + }); + + it("should return `cached_title` from API /widgets/title_lookup", async () => { + const url = "google.com"; + fetchMock.get("https://test.com/api/widgets/title_lookup?scalar_token=wokentoken&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 = "google.com"; + fetchMock.get("https://test.com/api/widgets/title_lookup?scalar_token=wokentoken&curl=" + url, { + status: 500, + }); + + await expect(sac.getScalarPageTitle(url)).rejects.toThrow("Scalar request failed: 500"); + }); }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 132c50147cc..143734f9ab3 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({}), doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(undefined), isVersionSupported: jest.fn().mockResolvedValue(undefined), getPushRules: jest.fn().mockResolvedValue(undefined), From e914817c8305e367b59a8652f3065380ca9b90ef Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Oct 2022 16:28:00 +0100 Subject: [PATCH 17/26] Improve coverage s'more --- test/ContentMessages-test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index d2b8a1fecbc..1d03cfae892 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -173,6 +173,21 @@ describe("ContentMessages", () => { 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", () => { From 4ebf5873da88e9b0c7b87382ee81a8ac6d58985b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Oct 2022 17:09:00 +0100 Subject: [PATCH 18/26] Fix test parallelism --- test/ScalarAuthClient-test.ts | 54 +++++++++---------- .../views/dialogs/InviteDialog-test.tsx | 2 +- test/test-utils/test-utils.ts | 2 +- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/test/ScalarAuthClient-test.ts b/test/ScalarAuthClient-test.ts index 0a9c34e98e4..252c379969c 100644 --- a/test/ScalarAuthClient-test.ts +++ b/test/ScalarAuthClient-test.ts @@ -33,18 +33,18 @@ describe('ScalarAuthClient', function() { let client; beforeEach(function() { - window.localStorage.setItem("mx_scalar_token", "brokentoken"); + 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); - fetchMock.get("https://test.com/api/account?scalar_token=brokentoken&v=1.1", { + fetchMock.get("https://test.com/api0/account?scalar_token=brokentoken&v=1.1", { body: { message: "Invalid token" }, }); - fetchMock.get("https://test.com/api/account?scalar_token=wokentoken&v=1.1", { + fetchMock.get("https://test.com/api0/account?scalar_token=wokentoken&v=1.1", { body: { user_id: client.getUserId() }, }); @@ -64,9 +64,9 @@ describe('ScalarAuthClient', function() { describe("exchangeForScalarToken", () => { it("should return `scalar_token` from API /register", async () => { - const sac = new ScalarAuthClient(apiUrl, uiUrl); + const sac = new ScalarAuthClient(apiUrl + 1, uiUrl); - fetchMock.post("https://test.com/api/register?v=1.1", { + fetchMock.postOnce("https://test.com/api1/register?v=1.1", { body: { scalar_token: "stoken" }, }); @@ -74,9 +74,9 @@ describe('ScalarAuthClient', function() { }); it("should throw upon non-20x code", async () => { - const sac = new ScalarAuthClient(apiUrl, uiUrl); + const sac = new ScalarAuthClient(apiUrl + 2, uiUrl); - fetchMock.post("https://test.com/api/register?v=1.1", { + fetchMock.postOnce("https://test.com/api2/register?v=1.1", { status: 500, }); @@ -84,9 +84,9 @@ describe('ScalarAuthClient', function() { }); it("should throw if scalar_token is missing in response", async () => { - const sac = new ScalarAuthClient(apiUrl, uiUrl); + const sac = new ScalarAuthClient(apiUrl + 3, uiUrl); - fetchMock.post("https://test.com/api/register?v=1.1", { + fetchMock.postOnce("https://test.com/api3/register?v=1.1", { body: {}, }); @@ -96,35 +96,35 @@ describe('ScalarAuthClient', function() { describe("registerForToken", () => { it("should call `termsInteractionCallback` upon M_TERMS_NOT_SIGNED error", async () => { - const sac = new ScalarAuthClient(apiUrl, uiUrl); + const sac = new ScalarAuthClient(apiUrl + 4, uiUrl); const termsInteractionCallback = jest.fn(); sac.setTermsInteractionCallback(termsInteractionCallback); - fetchMock.get("https://test.com/api/account?scalar_token=testtoken&v=1.1", { + 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("testtoken")); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken1")); mocked(client.getTerms).mockResolvedValue({ policies: [] }); - await expect(sac.registerForToken()).resolves.toBe("testtoken"); + await expect(sac.registerForToken()).resolves.toBe("testtoken1"); }); it("should throw upon non-20x code", async () => { - const sac = new ScalarAuthClient(apiUrl, uiUrl); - fetchMock.get("https://test.com/api/account?scalar_token=testtoken&v=1.1", { + 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("testtoken")); + 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, uiUrl); - fetchMock.get("https://test.com/api/account?scalar_token=testtoken&v=1.1", { + 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("testtoken")); + sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken3")); await expect(sac.registerForToken()).rejects.toThrow("Missing user_id in response"); }); @@ -134,23 +134,23 @@ describe('ScalarAuthClient', function() { let sac: ScalarAuthClient; beforeEach(async () => { - window.localStorage.setItem("mx_scalar_token", "wokentoken"); SdkConfig.put({ - integrations_rest_url: apiUrl, + integrations_rest_url: apiUrl + 7, integrations_ui_url: uiUrl, }); - fetchMock.get("https://test.com/api/account?scalar_token=wokentoken&v=1.1", { + 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, uiUrl); + 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/api/widgets/title_lookup?scalar_token=wokentoken&curl=" + url, { + fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, { body: { page_title_cache_item: { cached_title: "Google", @@ -162,8 +162,8 @@ describe('ScalarAuthClient', function() { }); it("should throw upon non-20x code", async () => { - const url = "google.com"; - fetchMock.get("https://test.com/api/widgets/title_lookup?scalar_token=wokentoken&curl=" + url, { + const url = "yahoo.com"; + fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, { status: 500, }); diff --git a/test/components/views/dialogs/InviteDialog-test.tsx b/test/components/views/dialogs/InviteDialog-test.tsx index b352e9e587a..469cbde96b3 100644 --- a/test/components/views/dialogs/InviteDialog-test.tsx +++ b/test/components/views/dialogs/InviteDialog-test.tsx @@ -54,7 +54,7 @@ describe("InviteDialog", () => { }), getOpenIdToken: jest.fn().mockResolvedValue({}), getIdentityAccount: jest.fn().mockResolvedValue({}), - getTerms: jest.fn().mockResolvedValue({}), + getTerms: jest.fn().mockResolvedValue({ policies: [] }), }); beforeEach(() => { diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 143734f9ab3..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().mockResolvedValue({}), + getTerms: jest.fn().mockResolvedValue({ policies: [] }), doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(undefined), isVersionSupported: jest.fn().mockResolvedValue(undefined), getPushRules: jest.fn().mockResolvedValue(undefined), From 66e8008394d71a086916539450d03fe0fddc1192 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 10 Oct 2022 18:48:45 +0100 Subject: [PATCH 19/26] Add tests --- test/ScalarAuthClient-test.ts | 39 ++++++++++++++++++ .../views/context_menus/EmbeddedPage-test.tsx | 40 +++++++++++++++++++ .../__snapshots__/EmbeddedPage-test.tsx.snap | 17 ++++++++ 3 files changed, 96 insertions(+) create mode 100644 test/components/views/context_menus/EmbeddedPage-test.tsx create mode 100644 test/components/views/context_menus/__snapshots__/EmbeddedPage-test.tsx.snap diff --git a/test/ScalarAuthClient-test.ts b/test/ScalarAuthClient-test.ts index 252c379969c..02edc2bd98c 100644 --- a/test/ScalarAuthClient-test.ts +++ b/test/ScalarAuthClient-test.ts @@ -20,6 +20,7 @@ import fetchMock from "fetch-mock-jest"; import ScalarAuthClient from '../src/ScalarAuthClient'; import { stubClient } from './test-utils'; import SdkConfig from "../src/SdkConfig"; +import { WidgetType } from "../src/widgets/WidgetType"; describe('ScalarAuthClient', function() { const apiUrl = 'https://test.com/api'; @@ -170,4 +171,42 @@ describe('ScalarAuthClient', function() { 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/components/views/context_menus/EmbeddedPage-test.tsx b/test/components/views/context_menus/EmbeddedPage-test.tsx new file mode 100644 index 00000000000..589583ba1b6 --- /dev/null +++ b/test/components/views/context_menus/EmbeddedPage-test.tsx @@ -0,0 +1,40 @@ +/* +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(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..70452f16a64 --- /dev/null +++ b/test/components/views/context_menus/__snapshots__/EmbeddedPage-test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should translate _t strings 1`] = ` + +
+
+

+ Przeglądaj pokoje +

+
+
+
+`; From 0d706f97fd2e416bf788872005c2fd52ab0f6f16 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 11 Oct 2022 09:08:44 +0100 Subject: [PATCH 20/26] Add tests --- .../views/dialogs/ChangelogDialog-test.tsx | 104 ++++++++++++++ .../ChangelogDialog-test.tsx.snap | 135 ++++++++++++++++++ 2 files changed, 239 insertions(+) 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/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/__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`] = ` + +
+