Merge pull request #5870 from matrix-org/travis/voice/disable-composer

Properly disable composer access when recording a voice message
This commit is contained in:
Travis Ralston 2021-04-16 07:36:53 -06:00 committed by GitHub
commit 7878e1cd57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 112 additions and 23 deletions

View file

@ -68,8 +68,8 @@ limitations under the License.
} }
&.mx_BasicMessageComposer_input_disabled { &.mx_BasicMessageComposer_input_disabled {
// Ignore all user input to avoid accidentally triggering the composer
pointer-events: none; pointer-events: none;
cursor: not-allowed;
} }
} }

View file

@ -39,7 +39,7 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
import VoipUserMapper from "../VoipUserMapper"; import VoipUserMapper from "../VoipUserMapper";
import {SpaceStoreClass} from "../stores/SpaceStore"; import {SpaceStoreClass} from "../stores/SpaceStore";
import {VoiceRecorder} from "../voice/VoiceRecorder"; import {VoiceRecording} from "../voice/VoiceRecording";
declare global { declare global {
interface Window { interface Window {
@ -71,7 +71,7 @@ declare global {
mxModalWidgetStore: ModalWidgetStore; mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper; mxVoipUserMapper: VoipUserMapper;
mxSpaceStore: SpaceStoreClass; mxSpaceStore: SpaceStoreClass;
mxVoiceRecorder: typeof VoiceRecorder; mxVoiceRecorder: typeof VoiceRecording;
} }
interface Document { interface Document {

View file

@ -140,7 +140,12 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
public componentDidUpdate(prevProps: IProps) { public componentDidUpdate(prevProps: IProps) {
if (this.props.placeholder !== prevProps.placeholder && this.props.placeholder) { // We need to re-check the placeholder when the enabled state changes because it causes the
// placeholder element to remount, which gets rid of the `::before` class. Re-evaluating the
// placeholder means we get a proper `::before` with the placeholder.
const enabledChange = this.props.disabled !== prevProps.disabled;
const placeholderChanged = this.props.placeholder !== prevProps.placeholder;
if (this.props.placeholder && (placeholderChanged || enabledChange)) {
const {isEmpty} = this.props.model; const {isEmpty} = this.props.model;
if (isEmpty) { if (isEmpty) {
this.showPlaceholder(); this.showPlaceholder();
@ -670,8 +675,6 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}); });
const classes = classNames("mx_BasicMessageComposer_input", { const classes = classNames("mx_BasicMessageComposer_input", {
"mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar, "mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar,
// TODO: @@ TravisR: This doesn't work properly. The composer resets in a strange way.
"mx_BasicMessageComposer_input_disabled": this.props.disabled, "mx_BasicMessageComposer_input_disabled": this.props.disabled,
}); });

View file

@ -34,6 +34,7 @@ import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
function ComposerAvatar(props) { function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@ -180,6 +181,7 @@ export default class MessageComposer extends React.Component {
this.renderPlaceholderText = this.renderPlaceholderText.bind(this); this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate); WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.on('update', this._onActiveWidgetUpdate); ActiveWidgetStore.on('update', this._onActiveWidgetUpdate);
VoiceRecordingStore.instance.on(UPDATE_EVENT, this._onVoiceStoreUpdate);
this._dispatcherRef = null; this._dispatcherRef = null;
this.state = { this.state = {
@ -240,6 +242,7 @@ export default class MessageComposer extends React.Component {
} }
WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate); WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate); ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate);
VoiceRecordingStore.instance.off(UPDATE_EVENT, this._onVoiceStoreUpdate);
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
} }
@ -327,8 +330,8 @@ export default class MessageComposer extends React.Component {
}); });
} }
onVoiceUpdate = (haveRecording: boolean) => { _onVoiceStoreUpdate = () => {
this.setState({haveRecording}); this.setState({haveRecording: !!VoiceRecordingStore.instance.activeRecording});
}; };
render() { render() {
@ -352,7 +355,6 @@ export default class MessageComposer extends React.Component {
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
replyToEvent={this.props.replyToEvent} replyToEvent={this.props.replyToEvent}
onChange={this.onChange} onChange={this.onChange}
// TODO: @@ TravisR - Disabling the composer doesn't work
disabled={this.state.haveRecording} disabled={this.state.haveRecording}
/>, />,
); );
@ -373,8 +375,7 @@ export default class MessageComposer extends React.Component {
if (SettingsStore.getValue("feature_voice_messages")) { if (SettingsStore.getValue("feature_voice_messages")) {
controls.push(<VoiceRecordComposerTile controls.push(<VoiceRecordComposerTile
key="controls_voice_record" key="controls_voice_record"
room={this.props.room} room={this.props.room} />);
onRecording={this.onVoiceUpdate} />);
} }
if (!this.state.isComposerEmpty || this.state.haveRecording) { if (!this.state.isComposerEmpty || this.state.haveRecording) {

View file

@ -477,6 +477,10 @@ export default class SendMessageComposer extends React.Component {
} }
onAction = (payload) => { onAction = (payload) => {
// don't let the user into the composer if it is disabled - all of these branches lead
// to the cursor being in the composer
if (this.props.disabled) return;
switch (payload.action) { switch (payload.action) {
case 'reply_to_event': case 'reply_to_event':
case Action.FocusComposer: case Action.FocusComposer:

View file

@ -17,21 +17,21 @@ limitations under the License.
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import React from "react"; import React from "react";
import {VoiceRecorder} from "../../../voice/VoiceRecorder"; import {VoiceRecording} from "../../../voice/VoiceRecording";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import classNames from "classnames"; import classNames from "classnames";
import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform"; import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import LiveRecordingClock from "../voice_messages/LiveRecordingClock"; import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
interface IProps { interface IProps {
room: Room; room: Room;
onRecording: (haveRecording: boolean) => void;
} }
interface IState { interface IState {
recorder?: VoiceRecorder; recorder?: VoiceRecording;
} }
/** /**
@ -57,13 +57,12 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
msgtype: "org.matrix.msc2516.voice", msgtype: "org.matrix.msc2516.voice",
url: mxc, url: mxc,
}); });
await VoiceRecordingStore.instance.disposeRecording();
this.setState({recorder: null}); this.setState({recorder: null});
this.props.onRecording(false);
return; return;
} }
const recorder = new VoiceRecorder(MatrixClientPeg.get()); const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start(); await recorder.start();
this.props.onRecording(true);
this.setState({recorder}); this.setState({recorder});
}; };

View file

@ -15,12 +15,12 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import {IRecordingUpdate, VoiceRecorder} from "../../../voice/VoiceRecorder"; import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import Clock from "./Clock"; import Clock from "./Clock";
interface IProps { interface IProps {
recorder: VoiceRecorder; recorder: VoiceRecording;
} }
interface IState { interface IState {

View file

@ -15,14 +15,14 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import {IRecordingUpdate, VoiceRecorder} from "../../../voice/VoiceRecorder"; import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {arrayFastResample, arraySeed} from "../../../utils/arrays"; import {arrayFastResample, arraySeed} from "../../../utils/arrays";
import {percentageOf} from "../../../utils/numbers"; import {percentageOf} from "../../../utils/numbers";
import Waveform from "./Waveform"; import Waveform from "./Waveform";
interface IProps { interface IProps {
recorder: VoiceRecorder; recorder: VoiceRecording;
} }
interface IState { interface IState {

View file

@ -0,0 +1,82 @@
/*
Copyright 2021 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 {AsyncStoreWithClient} from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import {ActionPayload} from "../dispatcher/payloads";
import {VoiceRecording} from "../voice/VoiceRecording";
interface IState {
recording?: VoiceRecording;
}
export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
private static internalInstance: VoiceRecordingStore;
public constructor() {
super(defaultDispatcher, {});
}
/**
* Gets the active recording instance, if any.
*/
public get activeRecording(): VoiceRecording | null {
return this.state.recording;
}
public static get instance(): VoiceRecordingStore {
if (!VoiceRecordingStore.internalInstance) {
VoiceRecordingStore.internalInstance = new VoiceRecordingStore();
}
return VoiceRecordingStore.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<void> {
// Nothing to do, but we're required to override the function
return;
}
/**
* Starts a new recording if one isn't already in progress. Note that this simply
* creates a recording instance - whether or not recording is actively in progress
* can be seen via the VoiceRecording class.
* @returns {VoiceRecording} The recording.
*/
public startRecording(): VoiceRecording {
if (!this.matrixClient) throw new Error("Cannot start a recording without a MatrixClient");
if (this.state.recording) throw new Error("A recording is already in progress");
const recording = new VoiceRecording(this.matrixClient);
// noinspection JSIgnoredPromiseFromCall - we can safely run this async
this.updateState({recording});
return recording;
}
/**
* Disposes of the current recording, no matter the state of it.
* @returns {Promise<void>} Resolves when complete.
*/
public disposeRecording(): Promise<void> {
if (this.state.recording) {
// Stop for good measure, but completely async because we're not concerned with this
// passing or failing.
this.state.recording.stop().catch(e => console.error("Error stopping recording", e));
}
return this.updateState({recording: null});
}
}

View file

@ -30,7 +30,7 @@ export interface IRecordingUpdate {
timeSeconds: number; // float timeSeconds: number; // float
} }
export class VoiceRecorder { export class VoiceRecording {
private recorder: Recorder; private recorder: Recorder;
private recorderContext: AudioContext; private recorderContext: AudioContext;
private recorderSource: MediaStreamAudioSourceNode; private recorderSource: MediaStreamAudioSourceNode;
@ -209,4 +209,4 @@ export class VoiceRecorder {
} }
} }
window.mxVoiceRecorder = VoiceRecorder; window.mxVoiceRecorder = VoiceRecording;