Error handling if broadcast events could not be sent (#9885)

This commit is contained in:
Michael Weimann 2023-01-17 08:57:59 +01:00 committed by GitHub
parent 7af4891cb7
commit fe0d3a7668
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 436 additions and 84 deletions

View file

@ -379,5 +379,6 @@
@import "./voice-broadcast/atoms/_PlaybackControlButton.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss";
@import "./voice-broadcast/molecules/_VoiceBroadcastBody.pcss";

View file

@ -0,0 +1,26 @@
/*
Copyright 2023 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.
*/
.mx_VoiceBroadcastRecordingConnectionError {
align-items: center;
color: $alert;
display: flex;
gap: $spacing-12;
svg path {
fill: $alert;
}
}

View file

@ -675,6 +675,7 @@
"Voice broadcast": "Voice broadcast",
"Buffering…": "Buffering…",
"play voice broadcast": "play voice broadcast",
"Connection error - Recording paused": "Connection error - Recording paused",
"Cannot reach homeserver": "Cannot reach homeserver",
"Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin",
"Your %(brand)s is misconfigured": "Your %(brand)s is misconfigured",

View file

@ -25,7 +25,7 @@ import { IContent, IEncryptedFile, MsgType } from "matrix-js-sdk/src/matrix";
* @param {IEncryptedFile} [file] Encrypted file
*/
export const createVoiceMessageContent = (
mxc: string,
mxc: string | undefined,
mimetype: string,
duration: number,
size: number,

View file

@ -0,0 +1,29 @@
/*
Copyright 2023 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 { Icon as WarningIcon } from "../../../../res/img/element-icons/warning.svg";
import { _t } from "../../../languageHandler";
export const VoiceBroadcastRecordingConnectionError: React.FC = () => {
return (
<div className="mx_VoiceBroadcastRecordingConnectionError">
<WarningIcon className="mx_Icon mx_Icon_16" />
{_t("Connection error - Recording paused")}
</div>
);
};

View file

@ -13,18 +13,24 @@ limitations under the License.
import React from "react";
import { useVoiceBroadcastRecording, VoiceBroadcastHeader, VoiceBroadcastRecording } from "../..";
import {
useVoiceBroadcastRecording,
VoiceBroadcastHeader,
VoiceBroadcastRecording,
VoiceBroadcastRecordingConnectionError,
} from "../..";
interface VoiceBroadcastRecordingBodyProps {
recording: VoiceBroadcastRecording;
}
export const VoiceBroadcastRecordingBody: React.FC<VoiceBroadcastRecordingBodyProps> = ({ recording }) => {
const { live, room, sender } = useVoiceBroadcastRecording(recording);
const { live, room, sender, recordingState } = useVoiceBroadcastRecording(recording);
return (
<div className="mx_VoiceBroadcastBody">
<VoiceBroadcastHeader live={live ? "live" : "grey"} microphoneLabel={sender?.name} room={room} />
{recordingState === "connection_error" && <VoiceBroadcastRecordingConnectionError />}
</div>
);
};

View file

@ -16,7 +16,13 @@ limitations under the License.
import React, { useRef, useState } from "react";
import { VoiceBroadcastControl, VoiceBroadcastInfoState, VoiceBroadcastRecording } from "../..";
import {
VoiceBroadcastControl,
VoiceBroadcastInfoState,
VoiceBroadcastRecording,
VoiceBroadcastRecordingConnectionError,
VoiceBroadcastRecordingState,
} from "../..";
import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording";
import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader";
import { Icon as StopIcon } from "../../../../res/img/element-icons/Stop.svg";
@ -41,14 +47,18 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
const onDeviceSelect = async (device: MediaDeviceInfo): Promise<void> => {
setShowDeviceSelect(false);
if (currentDevice.deviceId === device.deviceId) {
if (currentDevice?.deviceId === device.deviceId) {
// device unchanged
return;
}
setDevice(device);
if ([VoiceBroadcastInfoState.Paused, VoiceBroadcastInfoState.Stopped].includes(recordingState)) {
if (
(
[VoiceBroadcastInfoState.Paused, VoiceBroadcastInfoState.Stopped] as VoiceBroadcastRecordingState[]
).includes(recordingState)
) {
// Nothing to do in these cases. Resume will use the selected device.
return;
}
@ -72,10 +82,10 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
<VoiceBroadcastControl onClick={toggleRecording} icon={PauseIcon} label={_t("pause voice broadcast")} />
);
return (
<div className="mx_VoiceBroadcastBody mx_VoiceBroadcastBody--pip" ref={pipRef}>
<VoiceBroadcastHeader linkToRoom={true} live={live ? "live" : "grey"} room={room} timeLeft={timeLeft} />
<hr className="mx_VoiceBroadcastBody_divider" />
const controls =
recordingState === "connection_error" ? (
<VoiceBroadcastRecordingConnectionError />
) : (
<div className="mx_VoiceBroadcastBody_controls">
{toggleControl}
<AccessibleTooltipButton
@ -86,6 +96,13 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
</AccessibleTooltipButton>
<VoiceBroadcastControl icon={StopIcon} label="Stop Recording" onClick={stopRecording} />
</div>
);
return (
<div className="mx_VoiceBroadcastBody mx_VoiceBroadcastBody--pip" ref={pipRef}>
<VoiceBroadcastHeader linkToRoom={true} live={live ? "live" : "grey"} room={room} timeLeft={timeLeft} />
<hr className="mx_VoiceBroadcastBody_divider" />
{controls}
{showDeviceSelect && (
<DevicesContextMenu
containerRef={pipRef}

View file

@ -18,7 +18,12 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import React, { useState } from "react";
import { VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingEvent } from "..";
import {
VoiceBroadcastInfoState,
VoiceBroadcastRecording,
VoiceBroadcastRecordingEvent,
VoiceBroadcastRecordingState,
} from "..";
import QuestionDialog from "../../components/views/dialogs/QuestionDialog";
import { useTypedEventEmitter } from "../../hooks/useEventEmitter";
import { _t } from "../../languageHandler";
@ -47,7 +52,7 @@ export const useVoiceBroadcastRecording = (
): {
live: boolean;
timeLeft: number;
recordingState: VoiceBroadcastInfoState;
recordingState: VoiceBroadcastRecordingState;
room: Room;
sender: RoomMember;
stopRecording(): void;
@ -81,7 +86,9 @@ export const useVoiceBroadcastRecording = (
const [timeLeft, setTimeLeft] = useState(recording.getTimeLeft());
useTypedEventEmitter(recording, VoiceBroadcastRecordingEvent.TimeLeftChanged, setTimeLeft);
const live = [VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed].includes(recordingState);
const live = (
[VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed] as VoiceBroadcastRecordingState[]
).includes(recordingState);
return {
live,

View file

@ -29,6 +29,7 @@ export * from "./components/atoms/LiveBadge";
export * from "./components/atoms/VoiceBroadcastControl";
export * from "./components/atoms/VoiceBroadcastHeader";
export * from "./components/atoms/VoiceBroadcastPlaybackControl";
export * from "./components/atoms/VoiceBroadcastRecordingConnectionError";
export * from "./components/atoms/VoiceBroadcastRoomSubtitle";
export * from "./components/molecules/ConfirmListeBroadcastStopCurrent";
export * from "./components/molecules/VoiceBroadcastPlaybackBody";

View file

@ -16,6 +16,8 @@ limitations under the License.
import { logger } from "matrix-js-sdk/src/logger";
import {
ClientEvent,
ClientEventHandlerMap,
EventType,
MatrixClient,
MatrixEvent,
@ -43,14 +45,17 @@ import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents";
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
import { createReconnectedListener } from "../../utils/connection";
export enum VoiceBroadcastRecordingEvent {
StateChanged = "liveness_changed",
TimeLeftChanged = "time_left_changed",
}
export type VoiceBroadcastRecordingState = VoiceBroadcastInfoState | "connection_error";
interface EventMap {
[VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastInfoState) => void;
[VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastRecordingState) => void;
[VoiceBroadcastRecordingEvent.TimeLeftChanged]: (timeLeft: number) => void;
}
@ -58,13 +63,17 @@ export class VoiceBroadcastRecording
extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap>
implements IDestroyable
{
private state: VoiceBroadcastInfoState;
private recorder: VoiceBroadcastRecorder;
private state: VoiceBroadcastRecordingState;
private recorder: VoiceBroadcastRecorder | null = null;
private dispatcherRef: string;
private chunkEvents = new VoiceBroadcastChunkEvents();
private chunkRelationHelper: RelationsHelper;
private maxLength: number;
private timeLeft: number;
private toRetry: Array<() => Promise<void>> = [];
private reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync];
private roomId: string;
private infoEventId: string;
/**
* Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing.
@ -82,11 +91,13 @@ export class VoiceBroadcastRecording
super();
this.maxLength = getMaxBroadcastLength();
this.timeLeft = this.maxLength;
this.infoEventId = this.determineEventIdFromInfoEvent();
this.roomId = this.determineRoomIdFromInfoEvent();
if (initialState) {
this.state = initialState;
} else {
this.setInitialStateFromInfoEvent();
this.state = this.determineInitialStateFromInfoEvent();
}
// TODO Michael W: listen for state updates
@ -94,6 +105,8 @@ export class VoiceBroadcastRecording
this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
this.dispatcherRef = dis.register(this.onAction);
this.chunkRelationHelper = this.initialiseChunkEventRelation();
this.reconnectedListener = createReconnectedListener(this.onReconnect);
this.client.on(ClientEvent.Sync, this.reconnectedListener);
}
private initialiseChunkEventRelation(): RelationsHelper {
@ -125,17 +138,37 @@ export class VoiceBroadcastRecording
this.chunkEvents.addEvent(event);
};
private setInitialStateFromInfoEvent(): void {
const room = this.client.getRoom(this.infoEvent.getRoomId());
private determineEventIdFromInfoEvent(): string {
const infoEventId = this.infoEvent.getId();
if (!infoEventId) {
throw new Error("Cannot create broadcast for info event without Id.");
}
return infoEventId;
}
private determineRoomIdFromInfoEvent(): string {
const roomId = this.infoEvent.getRoomId();
if (!roomId) {
throw new Error(`Cannot create broadcast for unknown room (info event ${this.infoEventId})`);
}
return roomId;
}
/**
* Determines the initial broadcast state.
* Checks all related events. If one has the "stopped" state stopped, else started.
*/
private determineInitialStateFromInfoEvent(): VoiceBroadcastRecordingState {
const room = this.client.getRoom(this.roomId);
const relations = room
?.getUnfilteredTimelineSet()
?.relations?.getChildEventsForEvent(
this.infoEvent.getId(),
RelationType.Reference,
VoiceBroadcastInfoEventType,
);
?.relations?.getChildEventsForEvent(this.infoEventId, RelationType.Reference, VoiceBroadcastInfoEventType);
const relatedEvents = relations?.getRelations();
this.state = !relatedEvents?.find((event: MatrixEvent) => {
return !relatedEvents?.find((event: MatrixEvent) => {
return event.getContent()?.state === VoiceBroadcastInfoState.Stopped;
})
? VoiceBroadcastInfoState.Started
@ -146,6 +179,35 @@ export class VoiceBroadcastRecording
return this.timeLeft;
}
/**
* Retries failed actions on reconnect.
*/
private onReconnect = async (): Promise<void> => {
// Do nothing if not in connection_error state.
if (this.state !== "connection_error") return;
// Copy the array, so that it is possible to remove elements from it while iterating over the original.
const toRetryCopy = [...this.toRetry];
for (const retryFn of this.toRetry) {
try {
await retryFn();
// Successfully retried. Remove from array copy.
toRetryCopy.splice(toRetryCopy.indexOf(retryFn), 1);
} catch {
// The current retry callback failed. Stop the loop.
break;
}
}
this.toRetry = toRetryCopy;
if (this.toRetry.length === 0) {
// Everything has been successfully retried. Recover from error state to paused.
await this.pause();
}
};
private async setTimeLeft(timeLeft: number): Promise<void> {
if (timeLeft <= 0) {
// time is up - stop the recording
@ -173,7 +235,12 @@ export class VoiceBroadcastRecording
public async pause(): Promise<void> {
// stopped or already paused recordings cannot be paused
if ([VoiceBroadcastInfoState.Stopped, VoiceBroadcastInfoState.Paused].includes(this.state)) return;
if (
(
[VoiceBroadcastInfoState.Stopped, VoiceBroadcastInfoState.Paused] as VoiceBroadcastRecordingState[]
).includes(this.state)
)
return;
this.setState(VoiceBroadcastInfoState.Paused);
await this.stopRecorder();
@ -191,12 +258,16 @@ export class VoiceBroadcastRecording
public toggle = async (): Promise<void> => {
if (this.getState() === VoiceBroadcastInfoState.Paused) return this.resume();
if ([VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed].includes(this.getState())) {
if (
(
[VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed] as VoiceBroadcastRecordingState[]
).includes(this.getState())
) {
return this.pause();
}
};
public getState(): VoiceBroadcastInfoState {
public getState(): VoiceBroadcastRecordingState {
return this.state;
}
@ -221,6 +292,7 @@ export class VoiceBroadcastRecording
dis.unregister(this.dispatcherRef);
this.chunkEvents = new VoiceBroadcastChunkEvents();
this.chunkRelationHelper.destroy();
this.client.off(ClientEvent.Sync, this.reconnectedListener);
}
private onBeforeRedaction = (): void => {
@ -238,7 +310,7 @@ export class VoiceBroadcastRecording
this.pause();
};
private setState(state: VoiceBroadcastInfoState): void {
private setState(state: VoiceBroadcastRecordingState): void {
this.state = state;
this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state);
}
@ -248,56 +320,102 @@ export class VoiceBroadcastRecording
};
private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise<void> => {
const { url, file } = await this.uploadFile(chunk);
await this.sendVoiceMessage(chunk, url, file);
const uploadAndSendFn = async (): Promise<void> => {
const { url, file } = await this.uploadFile(chunk);
await this.sendVoiceMessage(chunk, url, file);
};
await this.callWithRetry(uploadAndSendFn);
};
private uploadFile(chunk: ChunkRecordedPayload): ReturnType<typeof uploadFile> {
/**
* This function is called on connection errors.
* It sets the connection error state and stops the recorder.
*/
private async onConnectionError(): Promise<void> {
await this.stopRecorder();
this.setState("connection_error");
}
private async uploadFile(chunk: ChunkRecordedPayload): ReturnType<typeof uploadFile> {
return uploadFile(
this.client,
this.infoEvent.getRoomId(),
this.roomId,
new Blob([chunk.buffer], {
type: this.getRecorder().contentType,
}),
);
}
private async sendVoiceMessage(chunk: ChunkRecordedPayload, url: string, file: IEncryptedFile): Promise<void> {
const content = createVoiceMessageContent(
url,
this.getRecorder().contentType,
Math.round(chunk.length * 1000),
chunk.buffer.length,
file,
);
content["m.relates_to"] = {
rel_type: RelationType.Reference,
event_id: this.infoEvent.getId(),
};
content["io.element.voice_broadcast_chunk"] = {
/** Increment the last sequence number and use it for this message. Also see {@link sequence}. */
sequence: ++this.sequence,
private async sendVoiceMessage(chunk: ChunkRecordedPayload, url?: string, file?: IEncryptedFile): Promise<void> {
/**
* Increment the last sequence number and use it for this message.
* Done outside of the sendMessageFn to get a scoped value.
* Also see {@link VoiceBroadcastRecording.sequence}.
*/
const sequence = ++this.sequence;
const sendMessageFn = async (): Promise<void> => {
const content = createVoiceMessageContent(
url,
this.getRecorder().contentType,
Math.round(chunk.length * 1000),
chunk.buffer.length,
file,
);
content["m.relates_to"] = {
rel_type: RelationType.Reference,
event_id: this.infoEventId,
};
content["io.element.voice_broadcast_chunk"] = {
sequence,
};
await this.client.sendMessage(this.roomId, content);
};
await this.client.sendMessage(this.infoEvent.getRoomId(), content);
await this.callWithRetry(sendMessageFn);
}
/**
* Sends an info state event with given state.
* On error stores a resend function and setState(state) in {@link toRetry} and
* sets the broadcast state to connection_error.
*/
private async sendInfoStateEvent(state: VoiceBroadcastInfoState): Promise<void> {
// TODO Michael W: add error handling for state event
await this.client.sendStateEvent(
this.infoEvent.getRoomId(),
VoiceBroadcastInfoEventType,
{
device_id: this.client.getDeviceId(),
state,
last_chunk_sequence: this.sequence,
["m.relates_to"]: {
rel_type: RelationType.Reference,
event_id: this.infoEvent.getId(),
},
} as VoiceBroadcastInfoEventContent,
this.client.getUserId(),
);
const sendEventFn = async (): Promise<void> => {
await this.client.sendStateEvent(
this.roomId,
VoiceBroadcastInfoEventType,
{
device_id: this.client.getDeviceId(),
state,
last_chunk_sequence: this.sequence,
["m.relates_to"]: {
rel_type: RelationType.Reference,
event_id: this.infoEventId,
},
} as VoiceBroadcastInfoEventContent,
this.client.getSafeUserId(),
);
};
await this.callWithRetry(sendEventFn);
}
/**
* Calls the function.
* On failure adds it to the retry list and triggers connection error.
* {@link toRetry}
* {@link onConnectionError}
*/
private async callWithRetry(retryAbleFn: () => Promise<void>): Promise<void> {
try {
await retryAbleFn();
} catch {
this.toRetry.push(retryAbleFn);
this.onConnectionError();
}
}
private async stopRecorder(): Promise<void> {

View file

@ -18,9 +18,10 @@ limitations under the License.
import React from "react";
import { act, render, RenderResult, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { ClientEvent, MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
import { mocked } from "jest-mock";
import { SyncState } from "matrix-js-sdk/src/sync";
import {
VoiceBroadcastInfoState,
@ -182,6 +183,30 @@ describe("VoiceBroadcastRecordingPip", () => {
});
});
});
describe("and there is no connection and clicking the pause button", () => {
beforeEach(async () => {
mocked(client.sendStateEvent).mockImplementation(() => {
throw new Error();
});
await userEvent.click(screen.getByLabelText("pause voice broadcast"));
});
it("should show a connection error info", () => {
expect(screen.getByText("Connection error - Recording paused")).toBeInTheDocument();
});
describe("and the connection is back", () => {
beforeEach(() => {
mocked(client.sendStateEvent).mockResolvedValue({ event_id: "e1" });
client.emit(ClientEvent.Sync, SyncState.Catchup, SyncState.Error);
});
it("should render a paused recording", () => {
expect(screen.getByLabelText("resume voice broadcast")).toBeInTheDocument();
});
});
});
});
describe("when rendering a paused recording", () => {

View file

@ -16,6 +16,7 @@ limitations under the License.
import { mocked } from "jest-mock";
import {
ClientEvent,
EventTimelineSet,
EventType,
MatrixClient,
@ -26,6 +27,7 @@ import {
Room,
} from "matrix-js-sdk/src/matrix";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { SyncState } from "matrix-js-sdk/src/sync";
import { uploadFile } from "../../../src/ContentMessages";
import { IEncryptedFile } from "../../../src/customisations/models/IMediaEventContent";
@ -41,6 +43,7 @@ import {
VoiceBroadcastRecorderEvent,
VoiceBroadcastRecording,
VoiceBroadcastRecordingEvent,
VoiceBroadcastRecordingState,
} from "../../../src/voice-broadcast";
import { mkEvent, mkStubRoom, stubClient } from "../../test-utils";
import dis from "../../../src/dispatcher/dispatcher";
@ -84,14 +87,14 @@ describe("VoiceBroadcastRecording", () => {
let client: MatrixClient;
let infoEvent: MatrixEvent;
let voiceBroadcastRecording: VoiceBroadcastRecording;
let onStateChanged: (state: VoiceBroadcastInfoState) => void;
let onStateChanged: (state: VoiceBroadcastRecordingState) => void;
let voiceBroadcastRecorder: VoiceBroadcastRecorder;
const mkVoiceBroadcastInfoEvent = (content: VoiceBroadcastInfoEventContent) => {
return mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
user: client.getUserId(),
user: client.getSafeUserId(),
room: roomId,
content,
});
@ -105,12 +108,19 @@ describe("VoiceBroadcastRecording", () => {
jest.spyOn(voiceBroadcastRecording, "removeAllListeners");
};
const itShouldBeInState = (state: VoiceBroadcastInfoState) => {
const itShouldBeInState = (state: VoiceBroadcastRecordingState) => {
it(`should be in state stopped ${state}`, () => {
expect(voiceBroadcastRecording.getState()).toBe(state);
});
};
const emitFirsChunkRecorded = () => {
voiceBroadcastRecorder.emit(VoiceBroadcastRecorderEvent.ChunkRecorded, {
buffer: new Uint8Array([1, 2, 3]),
length: 23,
});
};
const itShouldSendAnInfoEvent = (state: VoiceBroadcastInfoState, lastChunkSequence: number) => {
it(`should send a ${state} info event`, () => {
expect(client.sendStateEvent).toHaveBeenCalledWith(
@ -179,13 +189,22 @@ describe("VoiceBroadcastRecording", () => {
});
};
const setUpUploadFileMock = () => {
mocked(uploadFile).mockResolvedValue({
url: uploadedUrl,
file: uploadedFile,
});
};
beforeEach(() => {
client = stubClient();
room = mkStubRoom(roomId, "Test Room", client);
mocked(client.getRoom).mockImplementation((getRoomId: string) => {
mocked(client.getRoom).mockImplementation((getRoomId: string | undefined): Room | null => {
if (getRoomId === roomId) {
return room;
}
return null;
});
onStateChanged = jest.fn();
voiceBroadcastRecorder = new VoiceBroadcastRecorder(new VoiceRecording(), getChunkLength());
@ -194,14 +213,11 @@ describe("VoiceBroadcastRecording", () => {
jest.spyOn(voiceBroadcastRecorder, "destroy");
mocked(createVoiceBroadcastRecorder).mockReturnValue(voiceBroadcastRecorder);
mocked(uploadFile).mockResolvedValue({
url: uploadedUrl,
file: uploadedFile,
});
setUpUploadFileMock();
mocked(createVoiceMessageContent).mockImplementation(
(
mxc: string,
mxc: string | undefined,
mimetype: string,
duration: number,
size: number,
@ -238,13 +254,45 @@ describe("VoiceBroadcastRecording", () => {
});
afterEach(() => {
voiceBroadcastRecording.off(VoiceBroadcastRecordingEvent.StateChanged, onStateChanged);
voiceBroadcastRecording?.off(VoiceBroadcastRecordingEvent.StateChanged, onStateChanged);
});
describe("when there is an info event without id", () => {
beforeEach(() => {
infoEvent = mkVoiceBroadcastInfoEvent({
device_id: client.getDeviceId()!,
state: VoiceBroadcastInfoState.Started,
});
jest.spyOn(infoEvent, "getId").mockReturnValue(undefined);
});
it("should raise an error when creating a broadcast", () => {
expect(() => {
setUpVoiceBroadcastRecording();
}).toThrowError("Cannot create broadcast for info event without Id.");
});
});
describe("when there is an info event without room", () => {
beforeEach(() => {
infoEvent = mkVoiceBroadcastInfoEvent({
device_id: client.getDeviceId()!,
state: VoiceBroadcastInfoState.Started,
});
jest.spyOn(infoEvent, "getRoomId").mockReturnValue(undefined);
});
it("should raise an error when creating a broadcast", () => {
expect(() => {
setUpVoiceBroadcastRecording();
}).toThrowError(`Cannot create broadcast for unknown room (info event ${infoEvent.getId()})`);
});
});
describe("when created for a Voice Broadcast Info without relations", () => {
beforeEach(() => {
infoEvent = mkVoiceBroadcastInfoEvent({
device_id: client.getDeviceId(),
device_id: client.getDeviceId()!,
state: VoiceBroadcastInfoState.Started,
});
setUpVoiceBroadcastRecording();
@ -278,7 +326,16 @@ describe("VoiceBroadcastRecording", () => {
describe("and the info event is redacted", () => {
beforeEach(() => {
infoEvent.emit(MatrixEventEvent.BeforeRedaction, null, null);
infoEvent.emit(
MatrixEventEvent.BeforeRedaction,
infoEvent,
mkEvent({
event: true,
type: EventType.RoomRedaction,
user: client.getSafeUserId(),
content: {},
}),
);
});
itShouldBeInState(VoiceBroadcastInfoState.Stopped);
@ -329,10 +386,7 @@ describe("VoiceBroadcastRecording", () => {
describe("and a chunk has been recorded", () => {
beforeEach(async () => {
voiceBroadcastRecorder.emit(VoiceBroadcastRecorderEvent.ChunkRecorded, {
buffer: new Uint8Array([1, 2, 3]),
length: 23,
});
emitFirsChunkRecorded();
});
itShouldSendAVoiceMessage([1, 2, 3], 3, 23, 1);
@ -388,6 +442,34 @@ describe("VoiceBroadcastRecording", () => {
});
});
describe("and there is no connection", () => {
beforeEach(() => {
mocked(client.sendStateEvent).mockImplementation(() => {
throw new Error();
});
});
describe.each([
["pause", async () => voiceBroadcastRecording.pause()],
["toggle", async () => voiceBroadcastRecording.toggle()],
])("and calling %s", (_case: string, action: Function) => {
beforeEach(async () => {
await action();
});
itShouldBeInState("connection_error");
describe("and the connection is back", () => {
beforeEach(() => {
mocked(client.sendStateEvent).mockResolvedValue({ event_id: "e1" });
client.emit(ClientEvent.Sync, SyncState.Catchup, SyncState.Error);
});
itShouldBeInState(VoiceBroadcastInfoState.Paused);
});
});
});
describe("and calling destroy", () => {
beforeEach(() => {
voiceBroadcastRecording.destroy();
@ -399,6 +481,45 @@ describe("VoiceBroadcastRecording", () => {
expect(mocked(voiceBroadcastRecording.removeAllListeners)).toHaveBeenCalled();
});
});
describe("and a chunk has been recorded and the upload fails", () => {
beforeEach(() => {
mocked(uploadFile).mockRejectedValue("Error");
emitFirsChunkRecorded();
});
itShouldBeInState("connection_error");
describe("and the connection is back", () => {
beforeEach(() => {
setUpUploadFileMock();
client.emit(ClientEvent.Sync, SyncState.Catchup, SyncState.Error);
});
itShouldBeInState(VoiceBroadcastInfoState.Paused);
itShouldSendAVoiceMessage([1, 2, 3], 3, 23, 1);
});
});
describe("and a chunk has been recorded and sending the voice message fails", () => {
beforeEach(() => {
mocked(client.sendMessage).mockRejectedValue("Error");
emitFirsChunkRecorded();
});
itShouldBeInState("connection_error");
describe("and the connection is back", () => {
beforeEach(() => {
mocked(client.sendMessage).mockClear();
mocked(client.sendMessage).mockResolvedValue({ event_id: "e23" });
client.emit(ClientEvent.Sync, SyncState.Catchup, SyncState.Error);
});
itShouldBeInState(VoiceBroadcastInfoState.Paused);
itShouldSendAVoiceMessage([1, 2, 3], 3, 23, 1);
});
});
});
describe("and it is in paused state", () => {
@ -431,7 +552,7 @@ describe("VoiceBroadcastRecording", () => {
describe("when created for a Voice Broadcast Info with a Stopped relation", () => {
beforeEach(() => {
infoEvent = mkVoiceBroadcastInfoEvent({
device_id: client.getDeviceId(),
device_id: client.getDeviceId()!,
state: VoiceBroadcastInfoState.Started,
chunk_length: 120,
});
@ -441,11 +562,11 @@ describe("VoiceBroadcastRecording", () => {
} as unknown as Relations;
mocked(relationsContainer.getRelations).mockReturnValue([
mkVoiceBroadcastInfoEvent({
device_id: client.getDeviceId(),
device_id: client.getDeviceId()!,
state: VoiceBroadcastInfoState.Stopped,
["m.relates_to"]: {
rel_type: RelationType.Reference,
event_id: infoEvent.getId(),
event_id: infoEvent.getId()!,
},
}),
]);