Generalise VoiceRecording (#9304)
This commit is contained in:
parent
71cf9bf932
commit
c182c1c706
11 changed files with 422 additions and 103 deletions
166
src/audio/VoiceMessageRecording.ts
Normal file
166
src/audio/VoiceMessageRecording.ts
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { IEncryptedFile, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { SimpleObservable } from "matrix-widget-api";
|
||||||
|
|
||||||
|
import { uploadFile } from "../ContentMessages";
|
||||||
|
import { IDestroyable } from "../utils/IDestroyable";
|
||||||
|
import { Singleflight } from "../utils/Singleflight";
|
||||||
|
import { Playback } from "./Playback";
|
||||||
|
import { IRecordingUpdate, RecordingState, VoiceRecording } from "./VoiceRecording";
|
||||||
|
|
||||||
|
export interface IUpload {
|
||||||
|
mxc?: string; // for unencrypted uploads
|
||||||
|
encrypted?: IEncryptedFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class can be used to record a single voice message.
|
||||||
|
*/
|
||||||
|
export class VoiceMessageRecording implements IDestroyable {
|
||||||
|
private lastUpload: IUpload;
|
||||||
|
private buffer = new Uint8Array(0); // use this.audioBuffer to access
|
||||||
|
private playback: Playback;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private matrixClient: MatrixClient,
|
||||||
|
private voiceRecording: VoiceRecording,
|
||||||
|
) {
|
||||||
|
this.voiceRecording.onDataAvailable = this.onDataAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.lastUpload || this.hasRecording) {
|
||||||
|
throw new Error("Recording already prepared");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.voiceRecording.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<Uint8Array> {
|
||||||
|
await this.voiceRecording.stop();
|
||||||
|
return this.audioBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public on(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||||
|
this.voiceRecording.on(event, listener);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public off(event: string | symbol, listener: (...args: any[]) => void): this {
|
||||||
|
this.voiceRecording.off(event, listener);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public emit(event: string, ...args: any[]): boolean {
|
||||||
|
return this.voiceRecording.emit(event, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get hasRecording(): boolean {
|
||||||
|
return this.buffer.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isRecording(): boolean {
|
||||||
|
return this.voiceRecording.isRecording;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a playback instance for this voice recording. Note that the playback will not
|
||||||
|
* have been prepared fully, meaning the `prepare()` function needs to be called on it.
|
||||||
|
*
|
||||||
|
* The same playback instance is returned each time.
|
||||||
|
*
|
||||||
|
* @returns {Playback} The playback instance.
|
||||||
|
*/
|
||||||
|
public getPlayback(): Playback {
|
||||||
|
this.playback = Singleflight.for(this, "playback").do(() => {
|
||||||
|
return new Playback(this.audioBuffer.buffer, this.voiceRecording.amplitudes); // cast to ArrayBuffer proper;
|
||||||
|
});
|
||||||
|
return this.playback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upload(inRoomId: string): Promise<IUpload> {
|
||||||
|
if (!this.hasRecording) {
|
||||||
|
throw new Error("No recording available to upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lastUpload) return this.lastUpload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.emit(RecordingState.Uploading);
|
||||||
|
const { url: mxc, file: encrypted } = await uploadFile(
|
||||||
|
this.matrixClient,
|
||||||
|
inRoomId,
|
||||||
|
new Blob(
|
||||||
|
[this.audioBuffer],
|
||||||
|
{
|
||||||
|
type: this.contentType,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.lastUpload = { mxc, encrypted };
|
||||||
|
this.emit(RecordingState.Uploaded);
|
||||||
|
} catch (e) {
|
||||||
|
this.emit(RecordingState.Ended);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return this.lastUpload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get durationSeconds(): number {
|
||||||
|
return this.voiceRecording.durationSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get contentType(): string {
|
||||||
|
return this.voiceRecording.contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get contentLength(): number {
|
||||||
|
return this.buffer.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get liveData(): SimpleObservable<IRecordingUpdate> {
|
||||||
|
return this.voiceRecording.liveData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get isSupported(): boolean {
|
||||||
|
return this.voiceRecording.isSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.playback?.destroy();
|
||||||
|
this.voiceRecording.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDataAvailable = (data: ArrayBuffer) => {
|
||||||
|
const buf = new Uint8Array(data);
|
||||||
|
const newBuf = new Uint8Array(this.buffer.length + buf.length);
|
||||||
|
newBuf.set(this.buffer, 0);
|
||||||
|
newBuf.set(buf, this.buffer.length);
|
||||||
|
this.buffer = newBuf;
|
||||||
|
};
|
||||||
|
|
||||||
|
private get audioBuffer(): Uint8Array {
|
||||||
|
// We need a clone of the buffer to avoid accidentally changing the position
|
||||||
|
// on the real thing.
|
||||||
|
return this.buffer.slice(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createVoiceMessageRecording = (matrixClient: MatrixClient) => {
|
||||||
|
return new VoiceMessageRecording(matrixClient, new VoiceRecording());
|
||||||
|
};
|
|
@ -16,10 +16,8 @@ limitations under the License.
|
||||||
|
|
||||||
import * as Recorder from 'opus-recorder';
|
import * as Recorder from 'opus-recorder';
|
||||||
import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
|
import encoderPath from 'opus-recorder/dist/encoderWorker.min.js';
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
|
||||||
import { SimpleObservable } from "matrix-widget-api";
|
import { SimpleObservable } from "matrix-widget-api";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
import { IEncryptedFile } from "matrix-js-sdk/src/@types/event";
|
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
|
|
||||||
import MediaDeviceHandler from "../MediaDeviceHandler";
|
import MediaDeviceHandler from "../MediaDeviceHandler";
|
||||||
|
@ -27,9 +25,7 @@ import { IDestroyable } from "../utils/IDestroyable";
|
||||||
import { Singleflight } from "../utils/Singleflight";
|
import { Singleflight } from "../utils/Singleflight";
|
||||||
import { PayloadEvent, WORKLET_NAME } from "./consts";
|
import { PayloadEvent, WORKLET_NAME } from "./consts";
|
||||||
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../stores/AsyncStore";
|
||||||
import { Playback } from "./Playback";
|
|
||||||
import { createAudioContext } from "./compat";
|
import { createAudioContext } from "./compat";
|
||||||
import { uploadFile } from "../ContentMessages";
|
|
||||||
import { FixedRollingArray } from "../utils/FixedRollingArray";
|
import { FixedRollingArray } from "../utils/FixedRollingArray";
|
||||||
import { clamp } from "../utils/numbers";
|
import { clamp } from "../utils/numbers";
|
||||||
import mxRecorderWorkletPath from "./RecorderWorklet";
|
import mxRecorderWorkletPath from "./RecorderWorklet";
|
||||||
|
@ -55,11 +51,6 @@ export enum RecordingState {
|
||||||
Uploaded = "uploaded",
|
Uploaded = "uploaded",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUpload {
|
|
||||||
mxc?: string; // for unencrypted uploads
|
|
||||||
encrypted?: IEncryptedFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class VoiceRecording extends EventEmitter implements IDestroyable {
|
export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
private recorder: Recorder;
|
private recorder: Recorder;
|
||||||
private recorderContext: AudioContext;
|
private recorderContext: AudioContext;
|
||||||
|
@ -67,26 +58,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
private recorderStream: MediaStream;
|
private recorderStream: MediaStream;
|
||||||
private recorderWorklet: AudioWorkletNode;
|
private recorderWorklet: AudioWorkletNode;
|
||||||
private recorderProcessor: ScriptProcessorNode;
|
private recorderProcessor: ScriptProcessorNode;
|
||||||
private buffer = new Uint8Array(0); // use this.audioBuffer to access
|
|
||||||
private lastUpload: IUpload;
|
|
||||||
private recording = false;
|
private recording = false;
|
||||||
private observable: SimpleObservable<IRecordingUpdate>;
|
private observable: SimpleObservable<IRecordingUpdate>;
|
||||||
private amplitudes: number[] = []; // at each second mark, generated
|
public amplitudes: number[] = []; // at each second mark, generated
|
||||||
private playback: Playback;
|
|
||||||
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
|
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
|
||||||
|
public onDataAvailable: (data: ArrayBuffer) => void;
|
||||||
public constructor(private client: MatrixClient) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
public get contentType(): string {
|
public get contentType(): string {
|
||||||
return "audio/ogg";
|
return "audio/ogg";
|
||||||
}
|
}
|
||||||
|
|
||||||
public get contentLength(): number {
|
|
||||||
return this.buffer.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get durationSeconds(): number {
|
public get durationSeconds(): number {
|
||||||
if (!this.recorder) throw new Error("Duration not available without a recording");
|
if (!this.recorder) throw new Error("Duration not available without a recording");
|
||||||
return this.recorderContext.currentTime;
|
return this.recorderContext.currentTime;
|
||||||
|
@ -165,13 +146,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
encoderComplexity: 3, // 0-10, 10 is slow and high quality.
|
encoderComplexity: 3, // 0-10, 10 is slow and high quality.
|
||||||
resampleQuality: 3, // 0-10, 10 is slow and high quality
|
resampleQuality: 3, // 0-10, 10 is slow and high quality
|
||||||
});
|
});
|
||||||
this.recorder.ondataavailable = (a: ArrayBuffer) => {
|
|
||||||
const buf = new Uint8Array(a);
|
// not using EventEmitter here because it leads to detached bufferes
|
||||||
const newBuf = new Uint8Array(this.buffer.length + buf.length);
|
this.recorder.ondataavailable = (data: ArrayBuffer) => this?.onDataAvailable(data);
|
||||||
newBuf.set(this.buffer, 0);
|
|
||||||
newBuf.set(buf, this.buffer.length);
|
|
||||||
this.buffer = newBuf;
|
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error("Error starting recording: ", e);
|
logger.error("Error starting recording: ", e);
|
||||||
if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely
|
if (e instanceof DOMException) { // Unhelpful DOMExceptions are common - parse them sanely
|
||||||
|
@ -191,12 +168,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private get audioBuffer(): Uint8Array {
|
|
||||||
// We need a clone of the buffer to avoid accidentally changing the position
|
|
||||||
// on the real thing.
|
|
||||||
return this.buffer.slice(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get liveData(): SimpleObservable<IRecordingUpdate> {
|
public get liveData(): SimpleObservable<IRecordingUpdate> {
|
||||||
if (!this.recording) throw new Error("No observable when not recording");
|
if (!this.recording) throw new Error("No observable when not recording");
|
||||||
return this.observable;
|
return this.observable;
|
||||||
|
@ -206,10 +177,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
return !!Recorder.isRecordingSupported();
|
return !!Recorder.isRecordingSupported();
|
||||||
}
|
}
|
||||||
|
|
||||||
public get hasRecording(): boolean {
|
|
||||||
return this.buffer.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private onAudioProcess = (ev: AudioProcessingEvent) => {
|
private onAudioProcess = (ev: AudioProcessingEvent) => {
|
||||||
this.processAudioUpdate(ev.playbackTime);
|
this.processAudioUpdate(ev.playbackTime);
|
||||||
|
|
||||||
|
@ -251,9 +218,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
};
|
};
|
||||||
|
|
||||||
public async start(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
if (this.lastUpload || this.hasRecording) {
|
|
||||||
throw new Error("Recording already prepared");
|
|
||||||
}
|
|
||||||
if (this.recording) {
|
if (this.recording) {
|
||||||
throw new Error("Recording already in progress");
|
throw new Error("Recording already in progress");
|
||||||
}
|
}
|
||||||
|
@ -267,7 +231,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
this.emit(RecordingState.Started);
|
this.emit(RecordingState.Started);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop(): Promise<Uint8Array> {
|
public async stop(): Promise<void> {
|
||||||
return Singleflight.for(this, "stop").do(async () => {
|
return Singleflight.for(this, "stop").do(async () => {
|
||||||
if (!this.recording) {
|
if (!this.recording) {
|
||||||
throw new Error("No recording to stop");
|
throw new Error("No recording to stop");
|
||||||
|
@ -293,54 +257,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
||||||
this.recording = false;
|
this.recording = false;
|
||||||
await this.recorder.close();
|
await this.recorder.close();
|
||||||
this.emit(RecordingState.Ended);
|
this.emit(RecordingState.Ended);
|
||||||
|
|
||||||
return this.audioBuffer;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a playback instance for this voice recording. Note that the playback will not
|
|
||||||
* have been prepared fully, meaning the `prepare()` function needs to be called on it.
|
|
||||||
*
|
|
||||||
* The same playback instance is returned each time.
|
|
||||||
*
|
|
||||||
* @returns {Playback} The playback instance.
|
|
||||||
*/
|
|
||||||
public getPlayback(): Playback {
|
|
||||||
this.playback = Singleflight.for(this, "playback").do(() => {
|
|
||||||
return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper;
|
|
||||||
});
|
|
||||||
return this.playback;
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
// noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
|
// noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here
|
||||||
this.stop();
|
this.stop();
|
||||||
this.removeAllListeners();
|
this.removeAllListeners();
|
||||||
|
this.onDataAvailable = undefined;
|
||||||
Singleflight.forgetAllFor(this);
|
Singleflight.forgetAllFor(this);
|
||||||
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
|
// noinspection JSIgnoredPromiseFromCall - not concerned about being called async here
|
||||||
this.playback?.destroy();
|
|
||||||
this.observable.close();
|
this.observable.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async upload(inRoomId: string): Promise<IUpload> {
|
|
||||||
if (!this.hasRecording) {
|
|
||||||
throw new Error("No recording available to upload");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.lastUpload) return this.lastUpload;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.emit(RecordingState.Uploading);
|
|
||||||
const { url: mxc, file: encrypted } = await uploadFile(this.client, inRoomId, new Blob([this.audioBuffer], {
|
|
||||||
type: this.contentType,
|
|
||||||
}));
|
|
||||||
this.lastUpload = { mxc, encrypted };
|
|
||||||
this.emit(RecordingState.Uploaded);
|
|
||||||
} catch (e) {
|
|
||||||
this.emit(RecordingState.Ended);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return this.lastUpload;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,12 +16,13 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { IRecordingUpdate, VoiceRecording } from "../../../audio/VoiceRecording";
|
import { IRecordingUpdate } from "../../../audio/VoiceRecording";
|
||||||
import Clock from "./Clock";
|
import Clock from "./Clock";
|
||||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||||
|
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
recorder: VoiceRecording;
|
recorder: VoiceMessageRecording;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
|
|
|
@ -16,13 +16,14 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../audio/VoiceRecording";
|
import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES } from "../../../audio/VoiceRecording";
|
||||||
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
|
import { arrayFastResample, arraySeed } from "../../../utils/arrays";
|
||||||
import Waveform from "./Waveform";
|
import Waveform from "./Waveform";
|
||||||
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
import { MarkedExecution } from "../../../utils/MarkedExecution";
|
||||||
|
import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
recorder: VoiceRecording;
|
recorder: VoiceMessageRecording;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
|
|
|
@ -37,7 +37,7 @@ import ReplyPreview from "./ReplyPreview";
|
||||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||||
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
|
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
|
||||||
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
|
import { VoiceRecordingStore } from "../../../stores/VoiceRecordingStore";
|
||||||
import { RecordingState, VoiceRecording } from "../../../audio/VoiceRecording";
|
import { RecordingState } from "../../../audio/VoiceRecording";
|
||||||
import Tooltip, { Alignment } from "../elements/Tooltip";
|
import Tooltip, { Alignment } from "../elements/Tooltip";
|
||||||
import ResizeNotifier from "../../../utils/ResizeNotifier";
|
import ResizeNotifier from "../../../utils/ResizeNotifier";
|
||||||
import { E2EStatus } from '../../../utils/ShieldUtils';
|
import { E2EStatus } from '../../../utils/ShieldUtils';
|
||||||
|
@ -53,6 +53,7 @@ import { ButtonEvent } from '../elements/AccessibleButton';
|
||||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||||
import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom';
|
import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom';
|
||||||
import { Features } from '../../../settings/Settings';
|
import { Features } from '../../../settings/Settings';
|
||||||
|
import { VoiceMessageRecording } from '../../../audio/VoiceMessageRecording';
|
||||||
|
|
||||||
let instanceCount = 0;
|
let instanceCount = 0;
|
||||||
|
|
||||||
|
@ -101,7 +102,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||||
private instanceId: number;
|
private instanceId: number;
|
||||||
|
|
||||||
private _voiceRecording: Optional<VoiceRecording>;
|
private _voiceRecording: Optional<VoiceMessageRecording>;
|
||||||
|
|
||||||
public static contextType = RoomContext;
|
public static contextType = RoomContext;
|
||||||
public context!: React.ContextType<typeof RoomContext>;
|
public context!: React.ContextType<typeof RoomContext>;
|
||||||
|
@ -133,11 +134,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
||||||
SettingsStore.monitorSetting(Features.VoiceBroadcast, null);
|
SettingsStore.monitorSetting(Features.VoiceBroadcast, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get voiceRecording(): Optional<VoiceRecording> {
|
private get voiceRecording(): Optional<VoiceMessageRecording> {
|
||||||
return this._voiceRecording;
|
return this._voiceRecording;
|
||||||
}
|
}
|
||||||
|
|
||||||
private set voiceRecording(rec: Optional<VoiceRecording>) {
|
private set voiceRecording(rec: Optional<VoiceMessageRecording>) {
|
||||||
if (this._voiceRecording) {
|
if (this._voiceRecording) {
|
||||||
this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted);
|
this._voiceRecording.off(RecordingState.Started, this.onRecordingStarted);
|
||||||
this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon);
|
this._voiceRecording.off(RecordingState.EndingSoon, this.onRecordingEndingSoon);
|
||||||
|
|
|
@ -23,7 +23,7 @@ import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { IUpload, RecordingState, VoiceRecording } from "../../../audio/VoiceRecording";
|
import { RecordingState } from "../../../audio/VoiceRecording";
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform";
|
import LiveRecordingWaveform from "../audio_messages/LiveRecordingWaveform";
|
||||||
import LiveRecordingClock from "../audio_messages/LiveRecordingClock";
|
import LiveRecordingClock from "../audio_messages/LiveRecordingClock";
|
||||||
|
@ -44,6 +44,7 @@ import { attachRelation } from "./SendMessageComposer";
|
||||||
import { addReplyToMessageContent } from "../../../utils/Reply";
|
import { addReplyToMessageContent } from "../../../utils/Reply";
|
||||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||||
import RoomContext from "../../../contexts/RoomContext";
|
import RoomContext from "../../../contexts/RoomContext";
|
||||||
|
import { IUpload, VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
@ -53,7 +54,7 @@ interface IProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
recorder?: VoiceRecording;
|
recorder?: VoiceMessageRecording;
|
||||||
recordingPhase?: RecordingState;
|
recordingPhase?: RecordingState;
|
||||||
didUploadFail?: boolean;
|
didUploadFail?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -250,7 +251,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private bindNewRecorder(recorder: Optional<VoiceRecording>) {
|
private bindNewRecorder(recorder: Optional<VoiceMessageRecording>) {
|
||||||
if (this.state.recorder) {
|
if (this.state.recorder) {
|
||||||
this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate);
|
this.state.recorder.off(UPDATE_EVENT, this.onRecordingUpdate);
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,12 +22,12 @@ import { IEventRelation } from "matrix-js-sdk/src/models/event";
|
||||||
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
|
||||||
import defaultDispatcher from "../dispatcher/dispatcher";
|
import defaultDispatcher from "../dispatcher/dispatcher";
|
||||||
import { ActionPayload } from "../dispatcher/payloads";
|
import { ActionPayload } from "../dispatcher/payloads";
|
||||||
import { VoiceRecording } from "../audio/VoiceRecording";
|
import { createVoiceMessageRecording, VoiceMessageRecording } from "../audio/VoiceMessageRecording";
|
||||||
|
|
||||||
const SEPARATOR = "|";
|
const SEPARATOR = "|";
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
[voiceRecordingId: string]: Optional<VoiceRecording>;
|
[voiceRecordingId: string]: Optional<VoiceMessageRecording>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
|
export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
|
||||||
|
@ -63,7 +63,7 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
|
||||||
* @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to get the recording in.
|
* @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to get the recording in.
|
||||||
* @returns {Optional<VoiceRecording>} The recording, if any.
|
* @returns {Optional<VoiceRecording>} The recording, if any.
|
||||||
*/
|
*/
|
||||||
public getActiveRecording(voiceRecordingId: string): Optional<VoiceRecording> {
|
public getActiveRecording(voiceRecordingId: string): Optional<VoiceMessageRecording> {
|
||||||
return this.state[voiceRecordingId];
|
return this.state[voiceRecordingId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,12 +74,12 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
|
||||||
* @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to start recording in.
|
* @param {string} voiceRecordingId The room ID (with optionally the thread ID if in one) to start recording in.
|
||||||
* @returns {VoiceRecording} The recording.
|
* @returns {VoiceRecording} The recording.
|
||||||
*/
|
*/
|
||||||
public startRecording(voiceRecordingId: string): VoiceRecording {
|
public startRecording(voiceRecordingId: string): VoiceMessageRecording {
|
||||||
if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient");
|
if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient");
|
||||||
if (!voiceRecordingId) throw new Error("Recording must be associated with a room");
|
if (!voiceRecordingId) throw new Error("Recording must be associated with a room");
|
||||||
if (this.state[voiceRecordingId]) throw new Error("A recording is already in progress");
|
if (this.state[voiceRecordingId]) throw new Error("A recording is already in progress");
|
||||||
|
|
||||||
const recording = new VoiceRecording(this.matrixClient);
|
const recording = createVoiceMessageRecording(this.matrixClient);
|
||||||
|
|
||||||
// noinspection JSIgnoredPromiseFromCall - we can safely run this async
|
// noinspection JSIgnoredPromiseFromCall - we can safely run this async
|
||||||
this.updateState({ ...this.state, [voiceRecordingId]: recording });
|
this.updateState({ ...this.state, [voiceRecordingId]: recording });
|
||||||
|
|
221
test/audio/VoiceMessageRecording-test.ts
Normal file
221
test/audio/VoiceMessageRecording-test.ts
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mocked } from "jest-mock";
|
||||||
|
import { IAbortablePromise, IEncryptedFile, IUploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
|
import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording";
|
||||||
|
import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording";
|
||||||
|
import { uploadFile } from "../../src/ContentMessages";
|
||||||
|
import { stubClient } from "../test-utils";
|
||||||
|
import { Playback } from "../../src/audio/Playback";
|
||||||
|
|
||||||
|
jest.mock("../../src/ContentMessages", () => ({
|
||||||
|
uploadFile: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../src/audio/Playback", () => ({
|
||||||
|
Playback: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("VoiceMessageRecording", () => {
|
||||||
|
const roomId = "!room:example.com";
|
||||||
|
const contentType = "test content type";
|
||||||
|
const durationSeconds = 23;
|
||||||
|
const testBuf = new Uint8Array([1, 2, 3]);
|
||||||
|
const testAmplitudes = [4, 5, 6];
|
||||||
|
|
||||||
|
let voiceRecording: VoiceRecording;
|
||||||
|
let voiceMessageRecording: VoiceMessageRecording;
|
||||||
|
let client: MatrixClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = stubClient();
|
||||||
|
voiceRecording = {
|
||||||
|
contentType,
|
||||||
|
durationSeconds,
|
||||||
|
start: jest.fn().mockResolvedValue(undefined),
|
||||||
|
stop: jest.fn().mockResolvedValue(undefined),
|
||||||
|
on: jest.fn(),
|
||||||
|
off: jest.fn(),
|
||||||
|
emit: jest.fn(),
|
||||||
|
isRecording: true,
|
||||||
|
isSupported: true,
|
||||||
|
liveData: jest.fn(),
|
||||||
|
amplitudes: testAmplitudes,
|
||||||
|
} as unknown as VoiceRecording;
|
||||||
|
voiceMessageRecording = new VoiceMessageRecording(
|
||||||
|
client,
|
||||||
|
voiceRecording,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasRecording should return false", () => {
|
||||||
|
expect(voiceMessageRecording.hasRecording).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createVoiceMessageRecording should return a VoiceMessageRecording", () => {
|
||||||
|
expect(createVoiceMessageRecording(client)).toBeInstanceOf(VoiceMessageRecording);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("durationSeconds should return the VoiceRecording value", () => {
|
||||||
|
expect(voiceMessageRecording.durationSeconds).toBe(durationSeconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contentType should return the VoiceRecording value", () => {
|
||||||
|
expect(voiceMessageRecording.contentType).toBe(contentType);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("isRecording should return %s from VoiceRecording", (value: boolean) => {
|
||||||
|
// @ts-ignore
|
||||||
|
voiceRecording.isRecording = value;
|
||||||
|
expect(voiceMessageRecording.isRecording).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("isSupported should return %s from VoiceRecording", (value: boolean) => {
|
||||||
|
// @ts-ignore
|
||||||
|
voiceRecording.isSupported = value;
|
||||||
|
expect(voiceMessageRecording.isSupported).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return liveData from VoiceRecording", () => {
|
||||||
|
expect(voiceMessageRecording.liveData).toBe(voiceRecording.liveData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("start should forward the call to VoiceRecording.start", async () => {
|
||||||
|
await voiceMessageRecording.start();
|
||||||
|
expect(voiceRecording.start).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("on should forward the call to VoiceRecording", () => {
|
||||||
|
const callback = () => {};
|
||||||
|
const result = voiceMessageRecording.on("test on", callback);
|
||||||
|
expect(voiceRecording.on).toHaveBeenCalledWith("test on", callback);
|
||||||
|
expect(result).toBe(voiceMessageRecording);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("off should forward the call to VoiceRecording", () => {
|
||||||
|
const callback = () => {};
|
||||||
|
const result = voiceMessageRecording.off("test off", callback);
|
||||||
|
expect(voiceRecording.off).toHaveBeenCalledWith("test off", callback);
|
||||||
|
expect(result).toBe(voiceMessageRecording);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emit should forward the call to VoiceRecording", () => {
|
||||||
|
voiceMessageRecording.emit("test emit", 42);
|
||||||
|
expect(voiceRecording.emit).toHaveBeenCalledWith("test emit", 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upload should raise an error", async () => {
|
||||||
|
await expect(voiceMessageRecording.upload(roomId))
|
||||||
|
.rejects
|
||||||
|
.toThrow("No recording available to upload");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the first data has been received", () => {
|
||||||
|
const uploadUrl = "https://example.com/content123";
|
||||||
|
const encryptedFile = {} as unknown as IEncryptedFile;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
voiceRecording.onDataAvailable(testBuf);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contentLength should return the buffer length", () => {
|
||||||
|
expect(voiceMessageRecording.contentLength).toBe(testBuf.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stop should return a copy of the data buffer", async () => {
|
||||||
|
const result = await voiceMessageRecording.stop();
|
||||||
|
expect(voiceRecording.stop).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(testBuf);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasRecording should return true", () => {
|
||||||
|
expect(voiceMessageRecording.hasRecording).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upload", () => {
|
||||||
|
let uploadFileClient: MatrixClient;
|
||||||
|
let uploadFileRoomId: string;
|
||||||
|
let uploadBlob: Blob;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
uploadFileClient = null;
|
||||||
|
uploadFileRoomId = null;
|
||||||
|
uploadBlob = null;
|
||||||
|
|
||||||
|
mocked(uploadFile).mockImplementation((
|
||||||
|
matrixClient: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
file: File | Blob,
|
||||||
|
_progressHandler?: IUploadOpts["progressHandler"],
|
||||||
|
): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> => {
|
||||||
|
uploadFileClient = matrixClient;
|
||||||
|
uploadFileRoomId = roomId;
|
||||||
|
uploadBlob = file;
|
||||||
|
// @ts-ignore
|
||||||
|
return Promise.resolve({
|
||||||
|
url: uploadUrl,
|
||||||
|
file: encryptedFile,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should upload the file and trigger the upload events", async () => {
|
||||||
|
const result = await voiceMessageRecording.upload(roomId);
|
||||||
|
expect(voiceRecording.emit).toHaveBeenNthCalledWith(1, RecordingState.Uploading);
|
||||||
|
expect(voiceRecording.emit).toHaveBeenNthCalledWith(2, RecordingState.Uploaded);
|
||||||
|
|
||||||
|
expect(result.mxc).toBe(uploadUrl);
|
||||||
|
expect(result.encrypted).toBe(encryptedFile);
|
||||||
|
|
||||||
|
expect(mocked(uploadFile)).toHaveBeenCalled();
|
||||||
|
expect(uploadFileClient).toBe(client);
|
||||||
|
expect(uploadFileRoomId).toBe(roomId);
|
||||||
|
expect(uploadBlob.type).toBe(contentType);
|
||||||
|
const blobArray = await uploadBlob.arrayBuffer();
|
||||||
|
expect(new Uint8Array(blobArray)).toEqual(testBuf);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reuse the result", async () => {
|
||||||
|
const result1 = await voiceMessageRecording.upload(roomId);
|
||||||
|
const result2 = await voiceMessageRecording.upload(roomId);
|
||||||
|
expect(result1).toBe(result2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getPlayback", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocked(Playback).mockImplementation((buf: ArrayBuffer, seedWaveform) => {
|
||||||
|
expect(new Uint8Array(buf)).toEqual(testBuf);
|
||||||
|
expect(seedWaveform).toEqual(testAmplitudes);
|
||||||
|
return {} as Playback;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return a Playback with the data", () => {
|
||||||
|
voiceMessageRecording.getPlayback();
|
||||||
|
expect(mocked(Playback)).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reuse the result", () => {
|
||||||
|
const playback1 = voiceMessageRecording.getPlayback();
|
||||||
|
const playback2 = voiceMessageRecording.getPlayback();
|
||||||
|
expect(playback1).toBe(playback2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -21,9 +21,10 @@ import { ISendEventResponse, MatrixClient, MsgType, Room } from "matrix-js-sdk/s
|
||||||
import { mocked } from "jest-mock";
|
import { mocked } from "jest-mock";
|
||||||
|
|
||||||
import VoiceRecordComposerTile from "../../../../src/components/views/rooms/VoiceRecordComposerTile";
|
import VoiceRecordComposerTile from "../../../../src/components/views/rooms/VoiceRecordComposerTile";
|
||||||
import { IUpload, VoiceRecording } from "../../../../src/audio/VoiceRecording";
|
import { VoiceRecording } from "../../../../src/audio/VoiceRecording";
|
||||||
import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room";
|
import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room";
|
||||||
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
|
||||||
|
import { IUpload } from "../../../../src/audio/VoiceMessageRecording";
|
||||||
|
|
||||||
jest.mock("../../../../src/utils/local-room", () => ({
|
jest.mock("../../../../src/utils/local-room", () => ({
|
||||||
doMaybeLocalRoomAction: jest.fn(),
|
doMaybeLocalRoomAction: jest.fn(),
|
||||||
|
|
|
@ -17,10 +17,10 @@ limitations under the License.
|
||||||
|
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { VoiceRecording } from '../../src/audio/VoiceRecording';
|
|
||||||
import { VoiceRecordingStore } from '../../src/stores/VoiceRecordingStore';
|
import { VoiceRecordingStore } from '../../src/stores/VoiceRecordingStore';
|
||||||
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
|
||||||
import { flushPromises } from "../test-utils";
|
import { flushPromises } from "../test-utils";
|
||||||
|
import { VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording";
|
||||||
|
|
||||||
const stubClient = {} as undefined as MatrixClient;
|
const stubClient = {} as undefined as MatrixClient;
|
||||||
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(stubClient);
|
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(stubClient);
|
||||||
|
@ -29,8 +29,8 @@ describe('VoiceRecordingStore', () => {
|
||||||
const room1Id = '!room1:server.org';
|
const room1Id = '!room1:server.org';
|
||||||
const room2Id = '!room2:server.org';
|
const room2Id = '!room2:server.org';
|
||||||
const room3Id = '!room3:server.org';
|
const room3Id = '!room3:server.org';
|
||||||
const room1Recording = { destroy: jest.fn() } as unknown as VoiceRecording;
|
const room1Recording = { destroy: jest.fn() } as unknown as VoiceMessageRecording;
|
||||||
const room2Recording = { destroy: jest.fn() } as unknown as VoiceRecording;
|
const room2Recording = { destroy: jest.fn() } as unknown as VoiceMessageRecording;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
[room1Id]: room1Recording,
|
[room1Id]: room1Recording,
|
||||||
|
@ -63,7 +63,7 @@ describe('VoiceRecordingStore', () => {
|
||||||
|
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(result).toBeInstanceOf(VoiceRecording);
|
expect(result).toBeInstanceOf(VoiceMessageRecording);
|
||||||
expect(store.getActiveRecording(room2Id)).toEqual(result);
|
expect(store.getActiveRecording(room2Id)).toEqual(result);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -49,7 +49,7 @@ import MatrixClientBackedSettingsHandler from "../../src/settings/handlers/Matri
|
||||||
* the react context, we can get rid of this and just inject a test client
|
* the react context, we can get rid of this and just inject a test client
|
||||||
* via the context instead.
|
* via the context instead.
|
||||||
*/
|
*/
|
||||||
export function stubClient() {
|
export function stubClient(): MatrixClient {
|
||||||
const client = createTestClient();
|
const client = createTestClient();
|
||||||
|
|
||||||
// stub out the methods in MatrixClientPeg
|
// stub out the methods in MatrixClientPeg
|
||||||
|
@ -63,6 +63,7 @@ export function stubClient() {
|
||||||
// fast stub function rather than a sinon stub
|
// fast stub function rather than a sinon stub
|
||||||
peg.get = function() { return client; };
|
peg.get = function() { return client; };
|
||||||
MatrixClientBackedSettingsHandler.matrixClient = client;
|
MatrixClientBackedSettingsHandler.matrixClient = client;
|
||||||
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in a new issue