Merge branch 'develop' into unread-title-indicator
						commit
						4c7945552c
					
				|  | @ -1,6 +1,9 @@ | |||
| module.exports = { | ||||
|     plugins: ["matrix-org"], | ||||
|     extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], | ||||
|     parserOptions: { | ||||
|         project: ["./tsconfig.json"], | ||||
|     }, | ||||
|     env: { | ||||
|         browser: true, | ||||
|         node: true, | ||||
|  | @ -168,6 +171,12 @@ module.exports = { | |||
|                 "@typescript-eslint/explicit-member-accessibility": "off", | ||||
|             }, | ||||
|         }, | ||||
|         { | ||||
|             files: ["cypress/**/*.ts"], | ||||
|             parserOptions: { | ||||
|                 project: ["./cypress/tsconfig.json"], | ||||
|             }, | ||||
|         }, | ||||
|     ], | ||||
|     settings: { | ||||
|         react: { | ||||
|  |  | |||
|  | @ -52,6 +52,8 @@ const handleVerificationRequest = (request: VerificationRequest): Chainable<Emoj | |||
|             verifier.on("show_sas", onShowSas); | ||||
|             verifier.verify(); | ||||
|         }), | ||||
|         // extra timeout, as this sometimes takes a while
 | ||||
|         { timeout: 30_000 }, | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
|  | @ -111,9 +113,8 @@ describe("Decryption Failure Bar", () => { | |||
|                 }) | ||||
|                 .then(() => { | ||||
|                     cy.botSendMessage(bot, roomId, "test"); | ||||
|                     cy.wait(5000); | ||||
|                     cy.get(".mx_DecryptionFailureBar .mx_DecryptionFailureBar_message_headline").should( | ||||
|                         "have.text", | ||||
|                     cy.contains( | ||||
|                         ".mx_DecryptionFailureBar .mx_DecryptionFailureBar_message_headline", | ||||
|                         "Verify this device to access all messages", | ||||
|                     ); | ||||
| 
 | ||||
|  | @ -124,6 +125,7 @@ describe("Decryption Failure Bar", () => { | |||
| 
 | ||||
|                     const verificationRequestPromise = waitForVerificationRequest(otherDevice); | ||||
|                     cy.get(".mx_CompleteSecurity_actionRow .mx_AccessibleButton").click(); | ||||
|                     cy.contains("To proceed, please accept the verification request on your other device."); | ||||
|                     cy.wrap(verificationRequestPromise).then((verificationRequest: VerificationRequest) => { | ||||
|                         cy.wrap(verificationRequest.accept()); | ||||
|                         handleVerificationRequest(verificationRequest).then((emojis) => { | ||||
|  | @ -170,9 +172,8 @@ describe("Decryption Failure Bar", () => { | |||
|             ); | ||||
| 
 | ||||
|             cy.botSendMessage(bot, roomId, "test"); | ||||
|             cy.wait(5000); | ||||
|             cy.get(".mx_DecryptionFailureBar .mx_DecryptionFailureBar_message_headline").should( | ||||
|                 "have.text", | ||||
|             cy.contains( | ||||
|                 ".mx_DecryptionFailureBar .mx_DecryptionFailureBar_message_headline", | ||||
|                 "Reset your keys to prevent future decryption errors", | ||||
|             ); | ||||
| 
 | ||||
|  |  | |||
|  | @ -163,6 +163,8 @@ function setupBotClient( | |||
|                     } | ||||
|                 }) | ||||
|                 .then(() => cli), | ||||
|             // extra timeout, as this sometimes takes a while
 | ||||
|             { timeout: 30_000 }, | ||||
|         ); | ||||
|     }); | ||||
| } | ||||
|  |  | |||
|  | @ -190,7 +190,7 @@ | |||
|         "eslint-plugin-deprecate": "^0.7.0", | ||||
|         "eslint-plugin-import": "^2.25.4", | ||||
|         "eslint-plugin-jsx-a11y": "^6.5.1", | ||||
|         "eslint-plugin-matrix-org": "0.9.0", | ||||
|         "eslint-plugin-matrix-org": "0.10.0", | ||||
|         "eslint-plugin-react": "^7.28.0", | ||||
|         "eslint-plugin-react-hooks": "^4.3.0", | ||||
|         "eslint-plugin-unicorn": "^45.0.0", | ||||
|  |  | |||
|  | @ -38,6 +38,8 @@ limitations under the License. | |||
|     } | ||||
| 
 | ||||
|     .mx_AddExistingToSpace_section { | ||||
|         margin-right: 12px; // provides space for scrollbar so that checkbox and scrollbar do not collide | ||||
| 
 | ||||
|         &:not(:first-child) { | ||||
|             margin-top: 24px; | ||||
|         } | ||||
|  |  | |||
|  | @ -258,17 +258,16 @@ class PipContainerInner extends React.Component<IProps, IState> { | |||
|     } | ||||
| 
 | ||||
|     private createVoiceBroadcastPlaybackPipContent(voiceBroadcastPlayback: VoiceBroadcastPlayback): CreatePipChildren { | ||||
|         if (this.state.viewedRoomId === voiceBroadcastPlayback.infoEvent.getRoomId()) { | ||||
|             return ({ onStartMoving }) => ( | ||||
|                 <div onMouseDown={onStartMoving}> | ||||
|                     <VoiceBroadcastPlaybackBody playback={voiceBroadcastPlayback} pip={true} /> | ||||
|                 </div> | ||||
|         const content = | ||||
|             this.state.viewedRoomId === voiceBroadcastPlayback.infoEvent.getRoomId() ? ( | ||||
|                 <VoiceBroadcastPlaybackBody playback={voiceBroadcastPlayback} pip={true} /> | ||||
|             ) : ( | ||||
|                 <VoiceBroadcastSmallPlaybackBody playback={voiceBroadcastPlayback} /> | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
|         return ({ onStartMoving }) => ( | ||||
|             <div onMouseDown={onStartMoving}> | ||||
|                 <VoiceBroadcastSmallPlaybackBody playback={voiceBroadcastPlayback} /> | ||||
|             <div key={voiceBroadcastPlayback.infoEvent.getId()} onMouseDown={onStartMoving}> | ||||
|                 {content} | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
|  |  | |||
|  | @ -48,7 +48,8 @@ export default class ServerOfflineDialog extends React.PureComponent<IProps> { | |||
|     private renderTimeline(): React.ReactElement[] { | ||||
|         return EchoStore.instance.contexts.map((c, i) => { | ||||
|             if (!c.firstFailedTime) return null; // not useful
 | ||||
|             if (!(c instanceof RoomEchoContext)) throw new Error("Cannot render unknown context: " + c); | ||||
|             if (!(c instanceof RoomEchoContext)) | ||||
|                 throw new Error("Cannot render unknown context: " + c.constructor.name); | ||||
|             const header = ( | ||||
|                 <div className="mx_ServerOfflineDialog_content_context_timeline_header"> | ||||
|                     <RoomAvatar width={24} height={24} room={c.room} /> | ||||
|  |  | |||
|  | @ -507,39 +507,36 @@ export default class EventListSummary extends React.Component<IProps> { | |||
|         eventsToRender.forEach((e, index) => { | ||||
|             const type = e.getType(); | ||||
| 
 | ||||
|             let userId = e.getSender(); | ||||
|             if (type === EventType.RoomMember) { | ||||
|                 userId = e.getStateKey(); | ||||
|             let userKey = e.getSender()!; | ||||
|             if (type === EventType.RoomThirdPartyInvite) { | ||||
|                 userKey = e.getContent().display_name; | ||||
|             } else if (type === EventType.RoomMember) { | ||||
|                 userKey = e.getStateKey(); | ||||
|             } else if (e.isRedacted()) { | ||||
|                 userId = e.getUnsigned()?.redacted_because?.sender; | ||||
|                 userKey = e.getUnsigned()?.redacted_because?.sender; | ||||
|             } | ||||
| 
 | ||||
|             // Initialise a user's events
 | ||||
|             if (!userEvents[userId]) { | ||||
|                 userEvents[userId] = []; | ||||
|             if (!userEvents[userKey]) { | ||||
|                 userEvents[userKey] = []; | ||||
|             } | ||||
| 
 | ||||
|             let displayName = userId; | ||||
|             if (type === EventType.RoomThirdPartyInvite) { | ||||
|                 displayName = e.getContent().display_name; | ||||
|                 if (e.sender) { | ||||
|                     latestUserAvatarMember.set(userId, e.sender); | ||||
|                 } | ||||
|             } else if (e.isRedacted()) { | ||||
|                 const sender = this.context?.room.getMember(userId); | ||||
|             let displayName = userKey; | ||||
|             if (e.isRedacted()) { | ||||
|                 const sender = this.context?.room?.getMember(userKey); | ||||
|                 if (sender) { | ||||
|                     displayName = sender.name; | ||||
|                     latestUserAvatarMember.set(userId, sender); | ||||
|                     latestUserAvatarMember.set(userKey, sender); | ||||
|                 } | ||||
|             } else if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { | ||||
|                 displayName = e.target.name; | ||||
|                 latestUserAvatarMember.set(userId, e.target); | ||||
|             } else if (e.sender) { | ||||
|                 latestUserAvatarMember.set(userKey, e.target); | ||||
|             } else if (e.sender && type !== EventType.RoomThirdPartyInvite) { | ||||
|                 displayName = e.sender.name; | ||||
|                 latestUserAvatarMember.set(userId, e.sender); | ||||
|                 latestUserAvatarMember.set(userKey, e.sender); | ||||
|             } | ||||
| 
 | ||||
|             userEvents[userId].push({ | ||||
|             userEvents[userKey].push({ | ||||
|                 mxEvent: e, | ||||
|                 displayName, | ||||
|                 index: index, | ||||
|  |  | |||
|  | @ -116,7 +116,7 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton | |||
|         joinButtons = ( | ||||
|             <> | ||||
|                 <AccessibleButton | ||||
|                     kind="secondary" | ||||
|                     kind="primary_outline" | ||||
|                     onClick={() => { | ||||
|                         setBusy(true); | ||||
|                         onRejectButtonClicked(); | ||||
|  |  | |||
|  | @ -185,6 +185,10 @@ export default class ProfileSettings extends React.Component<{}, IState> { | |||
|             withDisplayName: true, | ||||
|         }); | ||||
| 
 | ||||
|         // False negative result from no-base-to-string rule, doesn't seem to account for Symbol.toStringTag
 | ||||
|         // eslint-disable-next-line @typescript-eslint/no-base-to-string
 | ||||
|         const avatarUrl = this.state.avatarUrl?.toString(); | ||||
| 
 | ||||
|         return ( | ||||
|             <form onSubmit={this.saveProfile} autoComplete="off" noValidate={true} className="mx_ProfileSettings"> | ||||
|                 <input | ||||
|  | @ -216,7 +220,7 @@ export default class ProfileSettings extends React.Component<{}, IState> { | |||
|                         </p> | ||||
|                     </div> | ||||
|                     <AvatarSetting | ||||
|                         avatarUrl={this.state.avatarUrl?.toString()} | ||||
|                         avatarUrl={avatarUrl} | ||||
|                         avatarName={this.state.displayName || this.state.userId} | ||||
|                         avatarAltText={_t("Profile picture")} | ||||
|                         uploadAvatar={this.uploadAvatar} | ||||
|  |  | |||
|  | @ -662,7 +662,7 @@ | |||
|     "Unable to decrypt voice broadcast": "Unable to decrypt voice broadcast", | ||||
|     "Unable to play this voice broadcast": "Unable to play this voice broadcast", | ||||
|     "Stop live broadcasting?": "Stop live broadcasting?", | ||||
|     "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.": "Are you sure you want to stop your live broadcast?This will end the broadcast and the full recording will be available in the room.", | ||||
|     "Are you sure you want to stop your live broadcast? This will end the broadcast and the full recording will be available in the room.": "Are you sure you want to stop your live broadcast? This will end the broadcast and the full recording will be available in the room.", | ||||
|     "Yes, stop broadcast": "Yes, stop broadcast", | ||||
|     "Listen to live broadcast?": "Listen to live broadcast?", | ||||
|     "If you start listening to this live broadcast, your current live broadcast recording will be ended.": "If you start listening to this live broadcast, your current live broadcast recording will be ended.", | ||||
|  |  | |||
|  | @ -84,7 +84,8 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true): Promise<Form | |||
|         body.append("user_id", client.credentials.userId); | ||||
|         body.append("device_id", client.deviceId); | ||||
| 
 | ||||
|         if (client.isCryptoEnabled()) { | ||||
|         // TODO: make this work with rust crypto
 | ||||
|         if (client.isCryptoEnabled() && client.crypto) { | ||||
|             const keys = [`ed25519:${client.getDeviceEd25519Key()}`]; | ||||
|             if (client.getDeviceCurve25519Key) { | ||||
|                 keys.push(`curve25519:${client.getDeviceCurve25519Key()}`); | ||||
|  | @ -259,7 +260,7 @@ export async function downloadBugReport(opts: IOpts = {}): Promise<void> { | |||
|                 reader.readAsArrayBuffer(value as Blob); | ||||
|             }); | ||||
|         } else { | ||||
|             metadata += `${key} = ${value}\n`; | ||||
|             metadata += `${key} = ${value as string}\n`; | ||||
|         } | ||||
|     } | ||||
|     tape.append("issue.txt", metadata); | ||||
|  |  | |||
|  | @ -116,7 +116,8 @@ function getEnabledLabs(): string { | |||
| } | ||||
| 
 | ||||
| async function getCryptoContext(client: MatrixClient): Promise<CryptoContext> { | ||||
|     if (!client.isCryptoEnabled()) { | ||||
|     // TODO: make this work with rust crypto
 | ||||
|     if (!client.isCryptoEnabled() || !client.crypto) { | ||||
|         return {}; | ||||
|     } | ||||
|     const keys = [`ed25519:${client.getDeviceEd25519Key()}`]; | ||||
|  |  | |||
|  | @ -389,7 +389,7 @@ export class StopGapWidget extends EventEmitter { | |||
|                     // Now open the integration manager
 | ||||
|                     // TODO: Spec this interaction.
 | ||||
|                     const data = ev.detail.data; | ||||
|                     const integType = data?.integType; | ||||
|                     const integType = data?.integType as string; | ||||
|                     const integId = <string>data?.integId; | ||||
| 
 | ||||
|                     // noinspection JSIgnoredPromiseFromCall
 | ||||
|  |  | |||
|  | @ -69,7 +69,7 @@ export function presentableTextForFile( | |||
|         // it since it is "ugly", users generally aren't aware what it
 | ||||
|         // means and the type of the attachment can usually be inferred
 | ||||
|         // from the file extension.
 | ||||
|         text += " (" + filesize(content.info.size) + ")"; | ||||
|         text += " (" + <string>filesize(content.info.size) + ")"; | ||||
|     } | ||||
|     return text; | ||||
| } | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ import { logger } from "matrix-js-sdk/src/logger"; | |||
| import { IDestroyable } from "./IDestroyable"; | ||||
| import { arrayFastClone } from "./arrays"; | ||||
| 
 | ||||
| export type WhenFn<T> = (w: Whenable<T>) => void; | ||||
| export type WhenFn<T extends string | number> = (w: Whenable<T>) => void; | ||||
| 
 | ||||
| /** | ||||
|  * Whenables are a cheap way to have Observable patterns mixed with typical | ||||
|  | @ -27,7 +27,7 @@ export type WhenFn<T> = (w: Whenable<T>) => void; | |||
|  * are intended to be used when a condition will be met multiple times and | ||||
|  * the consumer needs to know *when* that happens. | ||||
|  */ | ||||
| export abstract class Whenable<T> implements IDestroyable { | ||||
| export abstract class Whenable<T extends string | number> implements IDestroyable { | ||||
|     private listeners: { condition: T | null; fn: WhenFn<T> }[] = []; | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -14,7 +14,7 @@ See the License for the specific language governing permissions and | |||
| limitations under the License. | ||||
| */ | ||||
| 
 | ||||
| import React, { ReactNode } from "react"; | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom"; | ||||
| import { Room } from "matrix-js-sdk/src/models/room"; | ||||
| import { MatrixEvent } from "matrix-js-sdk/src/models/event"; | ||||
|  | @ -65,7 +65,7 @@ export default class HTMLExporter extends Exporter { | |||
|         this.threadsEnabled = SettingsStore.getValue("feature_threadenabled"); | ||||
|     } | ||||
| 
 | ||||
|     protected async getRoomAvatar(): Promise<ReactNode> { | ||||
|     protected async getRoomAvatar(): Promise<string> { | ||||
|         let blob: Blob | undefined = undefined; | ||||
|         const avatarUrl = Avatar.avatarUrlForRoom(this.room, 32, 32, "crop"); | ||||
|         const avatarPath = "room.png"; | ||||
|  |  | |||
|  | @ -36,7 +36,7 @@ const showStopBroadcastingDialog = async (): Promise<boolean> => { | |||
|         description: ( | ||||
|             <p> | ||||
|                 {_t( | ||||
|                     "Are you sure you want to stop your live broadcast?" + | ||||
|                     "Are you sure you want to stop your live broadcast? " + | ||||
|                         "This will end the broadcast and the full recording will be available in the room.", | ||||
|                 )} | ||||
|             </p> | ||||
|  |  | |||
|  | @ -396,7 +396,11 @@ export class VoiceBroadcastPlayback | |||
|         } | ||||
| 
 | ||||
|         if (!this.playbacks.has(eventId)) { | ||||
|             // set to buffering while loading the chunk data
 | ||||
|             const currentState = this.getState(); | ||||
|             this.setState(VoiceBroadcastPlaybackState.Buffering); | ||||
|             await this.loadPlayback(event); | ||||
|             this.setState(currentState); | ||||
|         } | ||||
| 
 | ||||
|         const playback = this.playbacks.get(eventId); | ||||
|  |  | |||
|  | @ -21,6 +21,7 @@ import { MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix"; | |||
| 
 | ||||
| import { | ||||
|     getMockClientWithEventEmitter, | ||||
|     mkEvent, | ||||
|     mkMembership, | ||||
|     mockClientMethodsUser, | ||||
|     unmockClientPeg, | ||||
|  | @ -100,7 +101,7 @@ describe("EventListSummary", function () { | |||
|     // is created by replacing the first "$" in userIdTemplate with `i` for
 | ||||
|     // `i = 0 .. n`.
 | ||||
|     const generateEventsForUsers = (userIdTemplate, n, events) => { | ||||
|         let eventsForUsers = []; | ||||
|         let eventsForUsers: MatrixEvent[] = []; | ||||
|         let userId = ""; | ||||
|         for (let i = 0; i < n; i++) { | ||||
|             userId = userIdTemplate.replace("$", i); | ||||
|  | @ -656,4 +657,56 @@ describe("EventListSummary", function () { | |||
| 
 | ||||
|         expect(summaryText).toBe("user_0, user_1 and 18 others joined"); | ||||
|     }); | ||||
| 
 | ||||
|     it("should not blindly group 3pid invites and treat them as distinct users instead", () => { | ||||
|         const events = [ | ||||
|             mkEvent({ | ||||
|                 event: true, | ||||
|                 skey: "randomstring1", | ||||
|                 user: "@user1:server", | ||||
|                 type: "m.room.third_party_invite", | ||||
|                 content: { | ||||
|                     display_name: "n...@d...", | ||||
|                     key_validity_url: "https://blah", | ||||
|                     public_key: "public_key", | ||||
|                 }, | ||||
|             }), | ||||
|             mkEvent({ | ||||
|                 event: true, | ||||
|                 skey: "randomstring2", | ||||
|                 user: "@user1:server", | ||||
|                 type: "m.room.third_party_invite", | ||||
|                 content: { | ||||
|                     display_name: "n...@d...", | ||||
|                     key_validity_url: "https://blah", | ||||
|                     public_key: "public_key", | ||||
|                 }, | ||||
|             }), | ||||
|             mkEvent({ | ||||
|                 event: true, | ||||
|                 skey: "randomstring3", | ||||
|                 user: "@user1:server", | ||||
|                 type: "m.room.third_party_invite", | ||||
|                 content: { | ||||
|                     display_name: "d...@w...", | ||||
|                     key_validity_url: "https://blah", | ||||
|                     public_key: "public_key", | ||||
|                 }, | ||||
|             }), | ||||
|         ]; | ||||
| 
 | ||||
|         const props = { | ||||
|             events: events, | ||||
|             children: generateTiles(events), | ||||
|             summaryLength: 2, | ||||
|             avatarsMaxLength: 5, | ||||
|             threshold: 3, | ||||
|         }; | ||||
| 
 | ||||
|         const wrapper = renderComponent(props); | ||||
|         const summary = wrapper.find(".mx_GenericEventListSummary_summary"); | ||||
|         const summaryText = summary.text(); | ||||
| 
 | ||||
|         expect(summaryText).toBe("n...@d... was invited 2 times, d...@w... was invited"); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
|  | @ -18,6 +18,7 @@ import { mocked } from "jest-mock"; | |||
| import { screen } from "@testing-library/react"; | ||||
| import userEvent from "@testing-library/user-event"; | ||||
| import { MatrixClient, MatrixEvent, MatrixEventEvent, Room } from "matrix-js-sdk/src/matrix"; | ||||
| import { defer } from "matrix-js-sdk/src/utils"; | ||||
| 
 | ||||
| import { Playback, PlaybackState } from "../../../src/audio/Playback"; | ||||
| import { PlaybackManager } from "../../../src/audio/PlaybackManager"; | ||||
|  | @ -31,9 +32,10 @@ import { | |||
|     VoiceBroadcastPlaybackState, | ||||
|     VoiceBroadcastRecording, | ||||
| } from "../../../src/voice-broadcast"; | ||||
| import { filterConsole, flushPromises, stubClient } from "../../test-utils"; | ||||
| import { filterConsole, flushPromises, flushPromisesWithFakeTimers, stubClient } from "../../test-utils"; | ||||
| import { createTestPlayback } from "../../test-utils/audio"; | ||||
| import { mkVoiceBroadcastChunkEvent, mkVoiceBroadcastInfoStateEvent } from "../utils/test-utils"; | ||||
| import { LazyValue } from "../../../src/utils/LazyValue"; | ||||
| 
 | ||||
| jest.mock("../../../src/utils/MediaEventHelper", () => ({ | ||||
|     MediaEventHelper: jest.fn(), | ||||
|  | @ -49,6 +51,7 @@ describe("VoiceBroadcastPlayback", () => { | |||
|     let playback: VoiceBroadcastPlayback; | ||||
|     let onStateChanged: (state: VoiceBroadcastPlaybackState) => void; | ||||
|     let chunk1Event: MatrixEvent; | ||||
|     let deplayedChunk1Event: MatrixEvent; | ||||
|     let chunk2Event: MatrixEvent; | ||||
|     let chunk2BEvent: MatrixEvent; | ||||
|     let chunk3Event: MatrixEvent; | ||||
|  | @ -58,6 +61,7 @@ describe("VoiceBroadcastPlayback", () => { | |||
|     const chunk1Data = new ArrayBuffer(2); | ||||
|     const chunk2Data = new ArrayBuffer(3); | ||||
|     const chunk3Data = new ArrayBuffer(3); | ||||
|     let delayedChunk1Helper: MediaEventHelper; | ||||
|     let chunk1Helper: MediaEventHelper; | ||||
|     let chunk2Helper: MediaEventHelper; | ||||
|     let chunk3Helper: MediaEventHelper; | ||||
|  | @ -97,8 +101,8 @@ describe("VoiceBroadcastPlayback", () => { | |||
|     }; | ||||
| 
 | ||||
|     const startPlayback = () => { | ||||
|         beforeEach(async () => { | ||||
|             await playback.start(); | ||||
|         beforeEach(() => { | ||||
|             playback.start(); | ||||
|         }); | ||||
|     }; | ||||
| 
 | ||||
|  | @ -127,11 +131,36 @@ describe("VoiceBroadcastPlayback", () => { | |||
|         }; | ||||
|     }; | ||||
| 
 | ||||
|     const mkDeplayedChunkHelper = (data: ArrayBuffer): MediaEventHelper => { | ||||
|         const deferred = defer<LazyValue<Blob>>(); | ||||
| 
 | ||||
|         setTimeout(() => { | ||||
|             deferred.resolve({ | ||||
|                 // @ts-ignore
 | ||||
|                 arrayBuffer: jest.fn().mockResolvedValue(data), | ||||
|             }); | ||||
|         }, 7500); | ||||
| 
 | ||||
|         return { | ||||
|             sourceBlob: { | ||||
|                 cachedValue: new Blob(), | ||||
|                 done: false, | ||||
|                 // @ts-ignore
 | ||||
|                 value: deferred.promise, | ||||
|             }, | ||||
|         }; | ||||
|     }; | ||||
| 
 | ||||
|     const simulateFirstChunkArrived = async (): Promise<void> => { | ||||
|         jest.advanceTimersByTime(10000); | ||||
|         await flushPromisesWithFakeTimers(); | ||||
|     }; | ||||
| 
 | ||||
|     const mkInfoEvent = (state: VoiceBroadcastInfoState) => { | ||||
|         return mkVoiceBroadcastInfoStateEvent(roomId, state, userId, deviceId); | ||||
|     }; | ||||
| 
 | ||||
|     const mkPlayback = async () => { | ||||
|     const mkPlayback = async (fakeTimers = false): Promise<VoiceBroadcastPlayback> => { | ||||
|         const playback = new VoiceBroadcastPlayback( | ||||
|             infoEvent, | ||||
|             client, | ||||
|  | @ -140,7 +169,7 @@ describe("VoiceBroadcastPlayback", () => { | |||
|         jest.spyOn(playback, "removeAllListeners"); | ||||
|         jest.spyOn(playback, "destroy"); | ||||
|         playback.on(VoiceBroadcastPlaybackEvent.StateChanged, onStateChanged); | ||||
|         await flushPromises(); | ||||
|         fakeTimers ? await flushPromisesWithFakeTimers() : await flushPromises(); | ||||
|         return playback; | ||||
|     }; | ||||
| 
 | ||||
|  | @ -152,6 +181,7 @@ describe("VoiceBroadcastPlayback", () => { | |||
| 
 | ||||
|     const createChunkEvents = () => { | ||||
|         chunk1Event = mkVoiceBroadcastChunkEvent(infoEvent.getId()!, userId, roomId, chunk1Length, 1); | ||||
|         deplayedChunk1Event = mkVoiceBroadcastChunkEvent(infoEvent.getId()!, userId, roomId, chunk1Length, 1); | ||||
|         chunk2Event = mkVoiceBroadcastChunkEvent(infoEvent.getId()!, userId, roomId, chunk2Length, 2); | ||||
|         chunk2Event.setTxnId("tx-id-1"); | ||||
|         chunk2BEvent = mkVoiceBroadcastChunkEvent(infoEvent.getId()!, userId, roomId, chunk2Length, 2); | ||||
|  | @ -159,6 +189,7 @@ describe("VoiceBroadcastPlayback", () => { | |||
|         chunk3Event = mkVoiceBroadcastChunkEvent(infoEvent.getId()!, userId, roomId, chunk3Length, 3); | ||||
| 
 | ||||
|         chunk1Helper = mkChunkHelper(chunk1Data); | ||||
|         delayedChunk1Helper = mkDeplayedChunkHelper(chunk1Data); | ||||
|         chunk2Helper = mkChunkHelper(chunk2Data); | ||||
|         chunk3Helper = mkChunkHelper(chunk3Data); | ||||
| 
 | ||||
|  | @ -181,6 +212,7 @@ describe("VoiceBroadcastPlayback", () => { | |||
| 
 | ||||
|         mocked(MediaEventHelper).mockImplementation((event: MatrixEvent): any => { | ||||
|             if (event === chunk1Event) return chunk1Helper; | ||||
|             if (event === deplayedChunk1Event) return delayedChunk1Helper; | ||||
|             if (event === chunk2Event) return chunk2Helper; | ||||
|             if (event === chunk3Event) return chunk3Helper; | ||||
|         }); | ||||
|  | @ -488,11 +520,17 @@ describe("VoiceBroadcastPlayback", () => { | |||
| 
 | ||||
|     describe("when there is a stopped voice broadcast", () => { | ||||
|         beforeEach(async () => { | ||||
|             jest.useFakeTimers(); | ||||
|             infoEvent = mkInfoEvent(VoiceBroadcastInfoState.Stopped); | ||||
|             createChunkEvents(); | ||||
|             setUpChunkEvents([chunk2Event, chunk1Event, chunk3Event]); | ||||
|             room.addLiveEvents([infoEvent, chunk1Event, chunk2Event, chunk3Event]); | ||||
|             playback = await mkPlayback(); | ||||
|             // use delayed first chunk here to simulate loading time
 | ||||
|             setUpChunkEvents([chunk2Event, deplayedChunk1Event, chunk3Event]); | ||||
|             room.addLiveEvents([infoEvent, deplayedChunk1Event, chunk2Event, chunk3Event]); | ||||
|             playback = await mkPlayback(true); | ||||
|         }); | ||||
| 
 | ||||
|         afterEach(() => { | ||||
|             jest.useRealTimers(); | ||||
|         }); | ||||
| 
 | ||||
|         it("should expose the info event", () => { | ||||
|  | @ -504,166 +542,174 @@ describe("VoiceBroadcastPlayback", () => { | |||
|         describe("and calling start", () => { | ||||
|             startPlayback(); | ||||
| 
 | ||||
|             itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); | ||||
| 
 | ||||
|             it("should play the chunks beginning with the first one", () => { | ||||
|                 // assert that the first chunk is being played
 | ||||
|                 expect(chunk1Playback.play).toHaveBeenCalled(); | ||||
|                 expect(chunk2Playback.play).not.toHaveBeenCalled(); | ||||
|             }); | ||||
| 
 | ||||
|             describe("and calling start again", () => { | ||||
|                 it("should not play the first chunk a second time", () => { | ||||
|                     expect(chunk1Playback.play).toHaveBeenCalledTimes(1); | ||||
|                 }); | ||||
|             }); | ||||
| 
 | ||||
|             describe("and the chunk playback progresses", () => { | ||||
|                 beforeEach(() => { | ||||
|                     chunk1Playback.clockInfo.liveData.update([11]); | ||||
|                 }); | ||||
| 
 | ||||
|                 it("should update the time", () => { | ||||
|                     expect(playback.timeSeconds).toBe(11); | ||||
|                     expect(playback.timeLeftSeconds).toBe(2); | ||||
|                 }); | ||||
|             }); | ||||
| 
 | ||||
|             describe("and the chunk playback progresses across the actual time", () => { | ||||
|                 // This can be the case if the meta data is out of sync with the actual audio data.
 | ||||
| 
 | ||||
|                 beforeEach(() => { | ||||
|                     chunk1Playback.clockInfo.liveData.update([15]); | ||||
|                 }); | ||||
| 
 | ||||
|                 it("should update the time", () => { | ||||
|                     expect(playback.timeSeconds).toBe(15); | ||||
|                     expect(playback.timeLeftSeconds).toBe(0); | ||||
|                 }); | ||||
|             }); | ||||
| 
 | ||||
|             describe("and skipping to the middle of the second chunk", () => { | ||||
|                 const middleOfSecondChunk = (chunk1Length + chunk2Length / 2) / 1000; | ||||
|             itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Buffering); | ||||
| 
 | ||||
|             describe("and the first chunk data has been loaded", () => { | ||||
|                 beforeEach(async () => { | ||||
|                     await playback.skipTo(middleOfSecondChunk); | ||||
|                     await simulateFirstChunkArrived(); | ||||
|                 }); | ||||
| 
 | ||||
|                 it("should play the second chunk", () => { | ||||
|                     expect(chunk1Playback.stop).toHaveBeenCalled(); | ||||
|                     expect(chunk1Playback.destroy).toHaveBeenCalled(); | ||||
|                     expect(chunk2Playback.play).toHaveBeenCalled(); | ||||
|                 itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); | ||||
| 
 | ||||
|                 it("should play the chunks beginning with the first one", () => { | ||||
|                     // assert that the first chunk is being played
 | ||||
|                     expect(chunk1Playback.play).toHaveBeenCalled(); | ||||
|                     expect(chunk2Playback.play).not.toHaveBeenCalled(); | ||||
|                 }); | ||||
| 
 | ||||
|                 it("should update the time", () => { | ||||
|                     expect(playback.timeSeconds).toBe(middleOfSecondChunk); | ||||
|                 describe("and calling start again", () => { | ||||
|                     it("should not play the first chunk a second time", () => { | ||||
|                         expect(chunk1Playback.play).toHaveBeenCalledTimes(1); | ||||
|                     }); | ||||
|                 }); | ||||
| 
 | ||||
|                 describe("and skipping to the start", () => { | ||||
|                     beforeEach(async () => { | ||||
|                         await playback.skipTo(0); | ||||
|                 describe("and the chunk playback progresses", () => { | ||||
|                     beforeEach(() => { | ||||
|                         chunk1Playback.clockInfo.liveData.update([11]); | ||||
|                     }); | ||||
| 
 | ||||
|                     it("should play the first chunk", () => { | ||||
|                     it("should update the time", () => { | ||||
|                         expect(playback.timeSeconds).toBe(11); | ||||
|                     }); | ||||
|                 }); | ||||
| 
 | ||||
|                 describe("and the chunk playback progresses across the actual time", () => { | ||||
|                     // This can be the case if the meta data is out of sync with the actual audio data.
 | ||||
| 
 | ||||
|                     beforeEach(() => { | ||||
|                         chunk1Playback.clockInfo.liveData.update([15]); | ||||
|                     }); | ||||
| 
 | ||||
|                     it("should update the time", () => { | ||||
|                         expect(playback.timeSeconds).toBe(15); | ||||
|                         expect(playback.timeLeftSeconds).toBe(0); | ||||
|                     }); | ||||
|                 }); | ||||
| 
 | ||||
|                 describe("and skipping to the middle of the second chunk", () => { | ||||
|                     const middleOfSecondChunk = (chunk1Length + chunk2Length / 2) / 1000; | ||||
| 
 | ||||
|                     beforeEach(async () => { | ||||
|                         await playback.skipTo(middleOfSecondChunk); | ||||
|                     }); | ||||
| 
 | ||||
|                     it("should play the second chunk", () => { | ||||
|                         expect(chunk1Playback.stop).toHaveBeenCalled(); | ||||
|                         expect(chunk1Playback.destroy).toHaveBeenCalled(); | ||||
|                         expect(chunk2Playback.play).toHaveBeenCalled(); | ||||
|                     }); | ||||
| 
 | ||||
|                     it("should update the time", () => { | ||||
|                         expect(playback.timeSeconds).toBe(middleOfSecondChunk); | ||||
|                     }); | ||||
| 
 | ||||
|                     describe("and skipping to the start", () => { | ||||
|                         beforeEach(async () => { | ||||
|                             await playback.skipTo(0); | ||||
|                         }); | ||||
| 
 | ||||
|                         it("should play the first chunk", () => { | ||||
|                             expect(chunk2Playback.stop).toHaveBeenCalled(); | ||||
|                             expect(chunk2Playback.destroy).toHaveBeenCalled(); | ||||
|                             expect(chunk1Playback.play).toHaveBeenCalled(); | ||||
|                         }); | ||||
| 
 | ||||
|                         it("should update the time", () => { | ||||
|                             expect(playback.timeSeconds).toBe(0); | ||||
|                         }); | ||||
|                     }); | ||||
|                 }); | ||||
| 
 | ||||
|                 describe("and skipping multiple times", () => { | ||||
|                     beforeEach(async () => { | ||||
|                         return Promise.all([ | ||||
|                             playback.skipTo(middleOfSecondChunk), | ||||
|                             playback.skipTo(middleOfThirdChunk), | ||||
|                             playback.skipTo(0), | ||||
|                         ]); | ||||
|                     }); | ||||
| 
 | ||||
|                     it("should only skip to the first and last position", () => { | ||||
|                         expect(chunk1Playback.stop).toHaveBeenCalled(); | ||||
|                         expect(chunk1Playback.destroy).toHaveBeenCalled(); | ||||
|                         expect(chunk2Playback.play).toHaveBeenCalled(); | ||||
| 
 | ||||
|                         expect(chunk3Playback.play).not.toHaveBeenCalled(); | ||||
| 
 | ||||
|                         expect(chunk2Playback.stop).toHaveBeenCalled(); | ||||
|                         expect(chunk2Playback.destroy).toHaveBeenCalled(); | ||||
|                         expect(chunk1Playback.play).toHaveBeenCalled(); | ||||
|                     }); | ||||
| 
 | ||||
|                     it("should update the time", () => { | ||||
|                         expect(playback.timeSeconds).toBe(0); | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
| 
 | ||||
|             describe("and skipping multiple times", () => { | ||||
|                 beforeEach(async () => { | ||||
|                     return Promise.all([ | ||||
|                         playback.skipTo(middleOfSecondChunk), | ||||
|                         playback.skipTo(middleOfThirdChunk), | ||||
|                         playback.skipTo(0), | ||||
|                     ]); | ||||
|                 }); | ||||
| 
 | ||||
|                 it("should only skip to the first and last position", () => { | ||||
|                     expect(chunk1Playback.stop).toHaveBeenCalled(); | ||||
|                     expect(chunk1Playback.destroy).toHaveBeenCalled(); | ||||
|                     expect(chunk2Playback.play).toHaveBeenCalled(); | ||||
| 
 | ||||
|                     expect(chunk3Playback.play).not.toHaveBeenCalled(); | ||||
| 
 | ||||
|                     expect(chunk2Playback.stop).toHaveBeenCalled(); | ||||
|                     expect(chunk2Playback.destroy).toHaveBeenCalled(); | ||||
|                     expect(chunk1Playback.play).toHaveBeenCalled(); | ||||
|                 }); | ||||
|             }); | ||||
| 
 | ||||
|             describe("and the first chunk ends", () => { | ||||
|                 beforeEach(() => { | ||||
|                     chunk1Playback.emit(PlaybackState.Stopped); | ||||
|                 }); | ||||
| 
 | ||||
|                 it("should play until the end", () => { | ||||
|                     // assert first chunk was unloaded
 | ||||
|                     expect(chunk1Playback.destroy).toHaveBeenCalled(); | ||||
| 
 | ||||
|                     // assert that the second chunk is being played
 | ||||
|                     expect(chunk2Playback.play).toHaveBeenCalled(); | ||||
| 
 | ||||
|                     // simulate end of second and third chunk
 | ||||
|                     chunk2Playback.emit(PlaybackState.Stopped); | ||||
|                     chunk3Playback.emit(PlaybackState.Stopped); | ||||
| 
 | ||||
|                     // assert that the entire playback is now in stopped state
 | ||||
|                     expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped); | ||||
|                 }); | ||||
|             }); | ||||
| 
 | ||||
|             describe("and calling pause", () => { | ||||
|                 pausePlayback(); | ||||
|                 itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); | ||||
|                 itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused); | ||||
|             }); | ||||
| 
 | ||||
|             describe("and calling stop", () => { | ||||
|                 stopPlayback(); | ||||
|                 itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); | ||||
| 
 | ||||
|                 it("should stop the playback", () => { | ||||
|                     expect(chunk1Playback.stop).toHaveBeenCalled(); | ||||
|                 }); | ||||
| 
 | ||||
|                 describe("and skipping to somewhere in the middle of the first chunk", () => { | ||||
|                     beforeEach(async () => { | ||||
|                         mocked(chunk1Playback.play).mockClear(); | ||||
|                         await playback.skipTo(1); | ||||
|                 describe("and the first chunk ends", () => { | ||||
|                     beforeEach(() => { | ||||
|                         chunk1Playback.emit(PlaybackState.Stopped); | ||||
|                     }); | ||||
| 
 | ||||
|                     it("should not start the playback", () => { | ||||
|                         expect(chunk1Playback.play).not.toHaveBeenCalled(); | ||||
|                     it("should play until the end", () => { | ||||
|                         // assert first chunk was unloaded
 | ||||
|                         expect(chunk1Playback.destroy).toHaveBeenCalled(); | ||||
| 
 | ||||
|                         // assert that the second chunk is being played
 | ||||
|                         expect(chunk2Playback.play).toHaveBeenCalled(); | ||||
| 
 | ||||
|                         // simulate end of second and third chunk
 | ||||
|                         chunk2Playback.emit(PlaybackState.Stopped); | ||||
|                         chunk3Playback.emit(PlaybackState.Stopped); | ||||
| 
 | ||||
|                         // assert that the entire playback is now in stopped state
 | ||||
|                         expect(playback.getState()).toBe(VoiceBroadcastPlaybackState.Stopped); | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
| 
 | ||||
|             describe("and calling destroy", () => { | ||||
|                 beforeEach(() => { | ||||
|                     playback.destroy(); | ||||
|                 describe("and calling pause", () => { | ||||
|                     pausePlayback(); | ||||
|                     itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused); | ||||
|                     itShouldEmitAStateChangedEvent(VoiceBroadcastPlaybackState.Paused); | ||||
|                 }); | ||||
| 
 | ||||
|                 it("should call removeAllListeners", () => { | ||||
|                     expect(playback.removeAllListeners).toHaveBeenCalled(); | ||||
|                 describe("and calling stop", () => { | ||||
|                     stopPlayback(); | ||||
|                     itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Stopped); | ||||
| 
 | ||||
|                     it("should stop the playback", () => { | ||||
|                         expect(chunk1Playback.stop).toHaveBeenCalled(); | ||||
|                     }); | ||||
| 
 | ||||
|                     describe("and skipping to somewhere in the middle of the first chunk", () => { | ||||
|                         beforeEach(async () => { | ||||
|                             mocked(chunk1Playback.play).mockClear(); | ||||
|                             await playback.skipTo(1); | ||||
|                         }); | ||||
| 
 | ||||
|                         it("should not start the playback", () => { | ||||
|                             expect(chunk1Playback.play).not.toHaveBeenCalled(); | ||||
|                         }); | ||||
|                     }); | ||||
|                 }); | ||||
| 
 | ||||
|                 it("should call destroy on the playbacks", () => { | ||||
|                     expect(chunk1Playback.destroy).toHaveBeenCalled(); | ||||
|                     expect(chunk2Playback.destroy).toHaveBeenCalled(); | ||||
|                 describe("and calling destroy", () => { | ||||
|                     beforeEach(() => { | ||||
|                         playback.destroy(); | ||||
|                     }); | ||||
| 
 | ||||
|                     it("should call removeAllListeners", () => { | ||||
|                         expect(playback.removeAllListeners).toHaveBeenCalled(); | ||||
|                     }); | ||||
| 
 | ||||
|                     it("should call destroy on the playbacks", () => { | ||||
|                         expect(chunk1Playback.destroy).toHaveBeenCalled(); | ||||
|                         expect(chunk2Playback.destroy).toHaveBeenCalled(); | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         describe("and calling toggle for the first time", () => { | ||||
|             beforeEach(async () => { | ||||
|                 await playback.toggle(); | ||||
|                 playback.toggle(); | ||||
|                 await simulateFirstChunkArrived(); | ||||
|             }); | ||||
| 
 | ||||
|             itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); | ||||
|  | @ -693,7 +739,8 @@ describe("VoiceBroadcastPlayback", () => { | |||
|             describe("and calling toggle", () => { | ||||
|                 beforeEach(async () => { | ||||
|                     mocked(onStateChanged).mockReset(); | ||||
|                     await playback.toggle(); | ||||
|                     playback.toggle(); | ||||
|                     await simulateFirstChunkArrived(); | ||||
|                 }); | ||||
| 
 | ||||
|                 itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Playing); | ||||
|  |  | |||
|  | @ -4226,10 +4226,10 @@ eslint-plugin-jsx-a11y@^6.5.1: | |||
|     minimatch "^3.1.2" | ||||
|     semver "^6.3.0" | ||||
| 
 | ||||
| eslint-plugin-matrix-org@0.9.0: | ||||
|   version "0.9.0" | ||||
|   resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-0.9.0.tgz#b2a5186052ddbfa7dc9878779bafa5d68681c7b4" | ||||
|   integrity sha512-+j6JuMnFH421Z2vOxc+0YMt5Su5vD76RSatviy3zHBaZpgd+sOeAWoCLBHD5E7mMz5oKae3Y3wewCt9LRzq2Nw== | ||||
| eslint-plugin-matrix-org@0.10.0: | ||||
|   version "0.10.0" | ||||
|   resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-0.10.0.tgz#8d0998641a4d276343cae2abf253a01bb4d4cc60" | ||||
|   integrity sha512-L7ail0x1yUlF006kn4mHc+OT8/aYZI++i852YXPHxCbM1EY7jeg/fYAQ8tCx5+x08LyqXeS7inAVSL784m0C6Q== | ||||
| 
 | ||||
| eslint-plugin-react-hooks@^4.3.0: | ||||
|   version "4.6.0" | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Florian Duros
						Florian Duros