Error handling if broadcast events could not be sent (#9885)
This commit is contained in:
parent
7af4891cb7
commit
fe0d3a7668
12 changed files with 436 additions and 84 deletions
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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", () => {
|
||||||
|
|
|
@ -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()!,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue