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/_PlaybackControlButton.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastRecordingConnectionError.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss"; @import "./voice-broadcast/atoms/_VoiceBroadcastRoomSubtitle.pcss";
@import "./voice-broadcast/molecules/_VoiceBroadcastBody.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", "Voice broadcast": "Voice broadcast",
"Buffering…": "Buffering…", "Buffering…": "Buffering…",
"play voice broadcast": "play voice broadcast", "play voice broadcast": "play voice broadcast",
"Connection error - Recording paused": "Connection error - Recording paused",
"Cannot reach homeserver": "Cannot reach homeserver", "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", "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", "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 * @param {IEncryptedFile} [file] Encrypted file
*/ */
export const createVoiceMessageContent = ( export const createVoiceMessageContent = (
mxc: string, mxc: string | undefined,
mimetype: string, mimetype: string,
duration: number, duration: number,
size: 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 React from "react";
import { useVoiceBroadcastRecording, VoiceBroadcastHeader, VoiceBroadcastRecording } from "../.."; import {
useVoiceBroadcastRecording,
VoiceBroadcastHeader,
VoiceBroadcastRecording,
VoiceBroadcastRecordingConnectionError,
} from "../..";
interface VoiceBroadcastRecordingBodyProps { interface VoiceBroadcastRecordingBodyProps {
recording: VoiceBroadcastRecording; recording: VoiceBroadcastRecording;
} }
export const VoiceBroadcastRecordingBody: React.FC<VoiceBroadcastRecordingBodyProps> = ({ recording }) => { export const VoiceBroadcastRecordingBody: React.FC<VoiceBroadcastRecordingBodyProps> = ({ recording }) => {
const { live, room, sender } = useVoiceBroadcastRecording(recording); const { live, room, sender, recordingState } = useVoiceBroadcastRecording(recording);
return ( return (
<div className="mx_VoiceBroadcastBody"> <div className="mx_VoiceBroadcastBody">
<VoiceBroadcastHeader live={live ? "live" : "grey"} microphoneLabel={sender?.name} room={room} /> <VoiceBroadcastHeader live={live ? "live" : "grey"} microphoneLabel={sender?.name} room={room} />
{recordingState === "connection_error" && <VoiceBroadcastRecordingConnectionError />}
</div> </div>
); );
}; };

View file

@ -16,7 +16,13 @@ limitations under the License.
import React, { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { VoiceBroadcastControl, VoiceBroadcastInfoState, VoiceBroadcastRecording } from "../.."; import {
VoiceBroadcastControl,
VoiceBroadcastInfoState,
VoiceBroadcastRecording,
VoiceBroadcastRecordingConnectionError,
VoiceBroadcastRecordingState,
} from "../..";
import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording"; import { useVoiceBroadcastRecording } from "../../hooks/useVoiceBroadcastRecording";
import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader"; import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader";
import { Icon as StopIcon } from "../../../../res/img/element-icons/Stop.svg"; 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> => { const onDeviceSelect = async (device: MediaDeviceInfo): Promise<void> => {
setShowDeviceSelect(false); setShowDeviceSelect(false);
if (currentDevice.deviceId === device.deviceId) { if (currentDevice?.deviceId === device.deviceId) {
// device unchanged // device unchanged
return; return;
} }
setDevice(device); 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. // Nothing to do in these cases. Resume will use the selected device.
return; return;
} }
@ -72,10 +82,10 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
<VoiceBroadcastControl onClick={toggleRecording} icon={PauseIcon} label={_t("pause voice broadcast")} /> <VoiceBroadcastControl onClick={toggleRecording} icon={PauseIcon} label={_t("pause voice broadcast")} />
); );
return ( const controls =
<div className="mx_VoiceBroadcastBody mx_VoiceBroadcastBody--pip" ref={pipRef}> recordingState === "connection_error" ? (
<VoiceBroadcastHeader linkToRoom={true} live={live ? "live" : "grey"} room={room} timeLeft={timeLeft} /> <VoiceBroadcastRecordingConnectionError />
<hr className="mx_VoiceBroadcastBody_divider" /> ) : (
<div className="mx_VoiceBroadcastBody_controls"> <div className="mx_VoiceBroadcastBody_controls">
{toggleControl} {toggleControl}
<AccessibleTooltipButton <AccessibleTooltipButton
@ -86,6 +96,13 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
</AccessibleTooltipButton> </AccessibleTooltipButton>
<VoiceBroadcastControl icon={StopIcon} label="Stop Recording" onClick={stopRecording} /> <VoiceBroadcastControl icon={StopIcon} label="Stop Recording" onClick={stopRecording} />
</div> </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 && ( {showDeviceSelect && (
<DevicesContextMenu <DevicesContextMenu
containerRef={pipRef} 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 { RoomMember } from "matrix-js-sdk/src/models/room-member";
import React, { useState } from "react"; import React, { useState } from "react";
import { VoiceBroadcastInfoState, VoiceBroadcastRecording, VoiceBroadcastRecordingEvent } from ".."; import {
VoiceBroadcastInfoState,
VoiceBroadcastRecording,
VoiceBroadcastRecordingEvent,
VoiceBroadcastRecordingState,
} from "..";
import QuestionDialog from "../../components/views/dialogs/QuestionDialog"; import QuestionDialog from "../../components/views/dialogs/QuestionDialog";
import { useTypedEventEmitter } from "../../hooks/useEventEmitter"; import { useTypedEventEmitter } from "../../hooks/useEventEmitter";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
@ -47,7 +52,7 @@ export const useVoiceBroadcastRecording = (
): { ): {
live: boolean; live: boolean;
timeLeft: number; timeLeft: number;
recordingState: VoiceBroadcastInfoState; recordingState: VoiceBroadcastRecordingState;
room: Room; room: Room;
sender: RoomMember; sender: RoomMember;
stopRecording(): void; stopRecording(): void;
@ -81,7 +86,9 @@ export const useVoiceBroadcastRecording = (
const [timeLeft, setTimeLeft] = useState(recording.getTimeLeft()); const [timeLeft, setTimeLeft] = useState(recording.getTimeLeft());
useTypedEventEmitter(recording, VoiceBroadcastRecordingEvent.TimeLeftChanged, setTimeLeft); useTypedEventEmitter(recording, VoiceBroadcastRecordingEvent.TimeLeftChanged, setTimeLeft);
const live = [VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed].includes(recordingState); const live = (
[VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed] as VoiceBroadcastRecordingState[]
).includes(recordingState);
return { return {
live, live,

View file

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

View file

@ -16,6 +16,8 @@ limitations under the License.
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { import {
ClientEvent,
ClientEventHandlerMap,
EventType, EventType,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
@ -43,14 +45,17 @@ import dis from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents"; import { VoiceBroadcastChunkEvents } from "../utils/VoiceBroadcastChunkEvents";
import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper"; import { RelationsHelper, RelationsHelperEvent } from "../../events/RelationsHelper";
import { createReconnectedListener } from "../../utils/connection";
export enum VoiceBroadcastRecordingEvent { export enum VoiceBroadcastRecordingEvent {
StateChanged = "liveness_changed", StateChanged = "liveness_changed",
TimeLeftChanged = "time_left_changed", TimeLeftChanged = "time_left_changed",
} }
export type VoiceBroadcastRecordingState = VoiceBroadcastInfoState | "connection_error";
interface EventMap { interface EventMap {
[VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastInfoState) => void; [VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastRecordingState) => void;
[VoiceBroadcastRecordingEvent.TimeLeftChanged]: (timeLeft: number) => void; [VoiceBroadcastRecordingEvent.TimeLeftChanged]: (timeLeft: number) => void;
} }
@ -58,13 +63,17 @@ export class VoiceBroadcastRecording
extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap> extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap>
implements IDestroyable implements IDestroyable
{ {
private state: VoiceBroadcastInfoState; private state: VoiceBroadcastRecordingState;
private recorder: VoiceBroadcastRecorder; private recorder: VoiceBroadcastRecorder | null = null;
private dispatcherRef: string; private dispatcherRef: string;
private chunkEvents = new VoiceBroadcastChunkEvents(); private chunkEvents = new VoiceBroadcastChunkEvents();
private chunkRelationHelper: RelationsHelper; private chunkRelationHelper: RelationsHelper;
private maxLength: number; private maxLength: number;
private timeLeft: 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. * 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(); super();
this.maxLength = getMaxBroadcastLength(); this.maxLength = getMaxBroadcastLength();
this.timeLeft = this.maxLength; this.timeLeft = this.maxLength;
this.infoEventId = this.determineEventIdFromInfoEvent();
this.roomId = this.determineRoomIdFromInfoEvent();
if (initialState) { if (initialState) {
this.state = initialState; this.state = initialState;
} else { } else {
this.setInitialStateFromInfoEvent(); this.state = this.determineInitialStateFromInfoEvent();
} }
// TODO Michael W: listen for state updates // TODO Michael W: listen for state updates
@ -94,6 +105,8 @@ export class VoiceBroadcastRecording
this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction); this.infoEvent.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
this.chunkRelationHelper = this.initialiseChunkEventRelation(); this.chunkRelationHelper = this.initialiseChunkEventRelation();
this.reconnectedListener = createReconnectedListener(this.onReconnect);
this.client.on(ClientEvent.Sync, this.reconnectedListener);
} }
private initialiseChunkEventRelation(): RelationsHelper { private initialiseChunkEventRelation(): RelationsHelper {
@ -125,17 +138,37 @@ export class VoiceBroadcastRecording
this.chunkEvents.addEvent(event); this.chunkEvents.addEvent(event);
}; };
private setInitialStateFromInfoEvent(): void { private determineEventIdFromInfoEvent(): string {
const room = this.client.getRoom(this.infoEvent.getRoomId()); 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 const relations = room
?.getUnfilteredTimelineSet() ?.getUnfilteredTimelineSet()
?.relations?.getChildEventsForEvent( ?.relations?.getChildEventsForEvent(this.infoEventId, RelationType.Reference, VoiceBroadcastInfoEventType);
this.infoEvent.getId(),
RelationType.Reference,
VoiceBroadcastInfoEventType,
);
const relatedEvents = relations?.getRelations(); const relatedEvents = relations?.getRelations();
this.state = !relatedEvents?.find((event: MatrixEvent) => { return !relatedEvents?.find((event: MatrixEvent) => {
return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; return event.getContent()?.state === VoiceBroadcastInfoState.Stopped;
}) })
? VoiceBroadcastInfoState.Started ? VoiceBroadcastInfoState.Started
@ -146,6 +179,35 @@ export class VoiceBroadcastRecording
return this.timeLeft; 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> { private async setTimeLeft(timeLeft: number): Promise<void> {
if (timeLeft <= 0) { if (timeLeft <= 0) {
// time is up - stop the recording // time is up - stop the recording
@ -173,7 +235,12 @@ export class VoiceBroadcastRecording
public async pause(): Promise<void> { public async pause(): Promise<void> {
// stopped or already paused recordings cannot be paused // 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); this.setState(VoiceBroadcastInfoState.Paused);
await this.stopRecorder(); await this.stopRecorder();
@ -191,12 +258,16 @@ export class VoiceBroadcastRecording
public toggle = async (): Promise<void> => { public toggle = async (): Promise<void> => {
if (this.getState() === VoiceBroadcastInfoState.Paused) return this.resume(); 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(); return this.pause();
} }
}; };
public getState(): VoiceBroadcastInfoState { public getState(): VoiceBroadcastRecordingState {
return this.state; return this.state;
} }
@ -221,6 +292,7 @@ export class VoiceBroadcastRecording
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
this.chunkEvents = new VoiceBroadcastChunkEvents(); this.chunkEvents = new VoiceBroadcastChunkEvents();
this.chunkRelationHelper.destroy(); this.chunkRelationHelper.destroy();
this.client.off(ClientEvent.Sync, this.reconnectedListener);
} }
private onBeforeRedaction = (): void => { private onBeforeRedaction = (): void => {
@ -238,7 +310,7 @@ export class VoiceBroadcastRecording
this.pause(); this.pause();
}; };
private setState(state: VoiceBroadcastInfoState): void { private setState(state: VoiceBroadcastRecordingState): void {
this.state = state; this.state = state;
this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state); this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state);
} }
@ -248,56 +320,102 @@ export class VoiceBroadcastRecording
}; };
private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise<void> => { private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise<void> => {
const { url, file } = await this.uploadFile(chunk); const uploadAndSendFn = async (): Promise<void> => {
await this.sendVoiceMessage(chunk, url, file); 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( return uploadFile(
this.client, this.client,
this.infoEvent.getRoomId(), this.roomId,
new Blob([chunk.buffer], { new Blob([chunk.buffer], {
type: this.getRecorder().contentType, type: this.getRecorder().contentType,
}), }),
); );
} }
private async sendVoiceMessage(chunk: ChunkRecordedPayload, url: string, file: IEncryptedFile): Promise<void> { private async sendVoiceMessage(chunk: ChunkRecordedPayload, url?: string, file?: IEncryptedFile): Promise<void> {
const content = createVoiceMessageContent( /**
url, * Increment the last sequence number and use it for this message.
this.getRecorder().contentType, * Done outside of the sendMessageFn to get a scoped value.
Math.round(chunk.length * 1000), * Also see {@link VoiceBroadcastRecording.sequence}.
chunk.buffer.length, */
file, const sequence = ++this.sequence;
);
content["m.relates_to"] = { const sendMessageFn = async (): Promise<void> => {
rel_type: RelationType.Reference, const content = createVoiceMessageContent(
event_id: this.infoEvent.getId(), url,
}; this.getRecorder().contentType,
content["io.element.voice_broadcast_chunk"] = { Math.round(chunk.length * 1000),
/** Increment the last sequence number and use it for this message. Also see {@link sequence}. */ chunk.buffer.length,
sequence: ++this.sequence, 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> { private async sendInfoStateEvent(state: VoiceBroadcastInfoState): Promise<void> {
// TODO Michael W: add error handling for state event const sendEventFn = async (): Promise<void> => {
await this.client.sendStateEvent( await this.client.sendStateEvent(
this.infoEvent.getRoomId(), this.roomId,
VoiceBroadcastInfoEventType, VoiceBroadcastInfoEventType,
{ {
device_id: this.client.getDeviceId(), device_id: this.client.getDeviceId(),
state, state,
last_chunk_sequence: this.sequence, last_chunk_sequence: this.sequence,
["m.relates_to"]: { ["m.relates_to"]: {
rel_type: RelationType.Reference, rel_type: RelationType.Reference,
event_id: this.infoEvent.getId(), event_id: this.infoEventId,
}, },
} as VoiceBroadcastInfoEventContent, } as VoiceBroadcastInfoEventContent,
this.client.getUserId(), 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> { private async stopRecorder(): Promise<void> {

View file

@ -18,9 +18,10 @@ limitations under the License.
import React from "react"; import React from "react";
import { act, render, RenderResult, screen } from "@testing-library/react"; import { act, render, RenderResult, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; 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 { sleep } from "matrix-js-sdk/src/utils";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { SyncState } from "matrix-js-sdk/src/sync";
import { import {
VoiceBroadcastInfoState, 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", () => { describe("when rendering a paused recording", () => {

View file

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