Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Limit voice recording length #5871

Merged
merged 8 commits into from
Apr 19, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/views/elements/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)}
visible={(this.state.focused && this.props.forceTooltipVisible) || this.state.feedbackVisible}
label={tooltipContent || this.state.feedback}
forceOnRight
alignment={Tooltip.Alignment.Right}
/>;
}

Expand Down
6 changes: 3 additions & 3 deletions src/components/views/elements/InfoTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ limitations under the License.
import React from 'react';
import classNames from 'classnames';

import Tooltip from './Tooltip';
import { _t } from "../../../languageHandler";
import Tooltip, {Alignment} from './Tooltip';
import {_t} from "../../../languageHandler";
import {replaceableComponent} from "../../../utils/replaceableComponent";

interface ITooltipProps {
Expand Down Expand Up @@ -61,7 +61,7 @@ export default class InfoTooltip extends React.PureComponent<ITooltipProps, ISta
className="mx_InfoTooltip_container"
tooltipClassName={classNames("mx_InfoTooltip_tooltip", tooltipClassName)}
label={tooltip || title}
forceOnRight={true}
alignment={Alignment.Right}
/> : <div />;
return (
<div onMouseOver={this.onMouseOver} onMouseLeave={this.onMouseLeave} className="mx_InfoTooltip">
Expand Down
48 changes: 42 additions & 6 deletions src/components/views/elements/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";

const MIN_TOOLTIP_HEIGHT = 25;

export enum Alignment {
Natural, // Pick left or right
Left,
Right,
Top, // Centered
Bottom, // Centered
}

interface IProps {
// Class applied to the element used to position the tooltip
className?: string;
Expand All @@ -36,7 +44,7 @@ interface IProps {
visible?: boolean;
// the react element to put into the tooltip
label: React.ReactNode;
forceOnRight?: boolean;
alignment?: Alignment; // defaults to Natural
yOffset?: number;
}

Expand All @@ -46,10 +54,14 @@ export default class Tooltip extends React.Component<IProps> {
private tooltip: void | Element | Component<Element, any, any>;
private parent: Element;

// XXX: This is because some components (Field) are unable to `import` the Tooltip class,
// so we expose the Alignment options off of us statically.
public static readonly Alignment = Alignment;

public static readonly defaultProps = {
visible: true,
yOffset: 0,
alignment: Alignment.Natural,
};

// Create a wrapper for the tooltip outside the parent and attach it to the body element
Expand Down Expand Up @@ -86,11 +98,35 @@ export default class Tooltip extends React.Component<IProps> {
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
}

style.top = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset + offset;
if (!this.props.forceOnRight && parentBox.right > window.innerWidth / 2) {
style.right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
} else {
style.left = parentBox.right + window.pageXOffset + 6;
const baseTop = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset;
const top = baseTop + offset;
const right = window.innerWidth - parentBox.right - window.pageXOffset - 16;
const left = parentBox.right + window.pageXOffset + 6;
const horizontalCenter = parentBox.right - window.pageXOffset - (parentBox.width / 2);
switch (this.props.alignment) {
case Alignment.Natural:
if (parentBox.right > window.innerWidth / 2) {
style.right = right;
style.top = top;
break;
}
// fall through to Right
case Alignment.Right:
style.left = left;
style.top = top;
break;
case Alignment.Left:
style.right = right;
style.top = top;
break;
case Alignment.Top:
style.top = baseTop - 16;
style.left = horizontalCenter;
break;
case Alignment.Bottom:
style.top = baseTop + parentBox.height;
style.left = horizontalCenter;
break;
}

return style;
Expand Down
25 changes: 24 additions & 1 deletion src/components/views/rooms/MessageComposer.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
import {RecordingState} from "../../../voice/VoiceRecording";
import Tooltip, {Alignment} from "../elements/Tooltip";

function ComposerAvatar(props) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
Expand Down Expand Up @@ -191,6 +193,7 @@ export default class MessageComposer extends React.Component {
joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room),
isComposerEmpty: true,
haveRecording: false,
recordingTimeLeftSeconds: null, // when set to a number, shows a toast
};
}

Expand Down Expand Up @@ -331,7 +334,17 @@ export default class MessageComposer extends React.Component {
}

_onVoiceStoreUpdate = () => {
this.setState({haveRecording: !!VoiceRecordingStore.instance.activeRecording});
const recording = VoiceRecordingStore.instance.activeRecording;
this.setState({haveRecording: !!recording});
if (recording) {
// We show a little head's up that the recording is about to automatically end soon. The 3s
turt2live marked this conversation as resolved.
Show resolved Hide resolved
// display time is completely arbitrary. Note that we don't need to deregister the listener
// because the recording instance will clean that up for us.
recording.on(RecordingState.EndingSoon, ({secondsLeft}) => {
this.setState({recordingTimeLeftSeconds: secondsLeft});
setTimeout(() => this.setState({recordingTimeLeftSeconds: null}), 3000);
});
}
};

render() {
Expand Down Expand Up @@ -412,8 +425,18 @@ export default class MessageComposer extends React.Component {
);
}

let recordingTooltip;
const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds);
if (secondsLeft) {
recordingTooltip = <Tooltip
label={_t("%(seconds)ss left", {seconds: secondsLeft})}
alignment={Alignment.Top} yOffset={-50}
/>;
}

return (
<div className="mx_MessageComposer mx_GroupLayout">
{recordingTooltip}
<div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
<div className="mx_MessageComposer_row">
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,7 @@
"The conversation continues here.": "The conversation continues here.",
"This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.",
"You do not have permission to post to this room": "You do not have permission to post to this room",
"%(seconds)ss left": "%(seconds)ss left",
"Bold": "Bold",
"Italics": "Italics",
"Strikethrough": "Strikethrough",
Expand Down
4 changes: 1 addition & 3 deletions src/stores/VoiceRecordingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,7 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
*/
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));
this.state.recording.destroy(); // stops internally
}
return this.updateState({recording: null});
}
Expand Down
101 changes: 101 additions & 0 deletions src/utils/Singleflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
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 {EnhancedMap} from "./maps";

// Inspired by https://pkg.go.dev/golang.org/x/sync/singleflight

const keyMap = new EnhancedMap<Object, EnhancedMap<string, unknown>>();

/**
* Access class to get a singleflight context. Singleflights execute a
turt2live marked this conversation as resolved.
Show resolved Hide resolved
* function exactly once, unless instructed to forget about a result.
*/
export class Singleflight {
private constructor() {
}

/**
* A void marker to help with returning a value in a singleflight context.
* If your code doesn't return anything, return this instead.
*/
public static Void = Symbol("void");

/**
* Acquire a singleflight context.
* @param {Object} instance An instance to associate the context with. Can be any object.
* @param {string} key A string key relevant to that instance to namespace under.
* @returns {SingleflightContext} Returns the context to execute the function.
*/
public static for(instance: Object, key: string): SingleflightContext {
if (!instance || !key) throw new Error("An instance and key must be supplied");
return new SingleflightContext(instance, key);
}

/**
* Forgets all results for a given instance.
* @param {Object} instance The instance to forget about.
*/
public static forgetAllFor(instance: Object) {
keyMap.delete(instance);
}

/**
* Forgets all cached results for all instances. Intended for use by tests.
*/
public static forgetAll() {
for (const k of keyMap.keys()) {
keyMap.remove(k);
}
}
}

class SingleflightContext {
public constructor(private instance: Object, private key: string) {
}

/**
* Forget this particular instance and key combination, discarding the result.
*/
public forget() {
const map = keyMap.get(this.instance);
if (!map) return;
map.remove(this.key);
if (!map.size) keyMap.remove(this.instance);
}

/**
* Execute a function. If a result is already known, that will be returned instead
* of executing the provided function. However, if no result is known then the function
* will be called, with its return value cached. The function must return a value
* other than `undefined` - take a look at Singleflight.Void if you don't have a return
* to make.
* @param {Function} fn The function to execute.
* @returns The recorded value.
*/
public do<T>(fn: () => T): T {
turt2live marked this conversation as resolved.
Show resolved Hide resolved
const map = keyMap.getOrCreate(this.instance, new EnhancedMap<string, unknown>());

// We have to manually getOrCreate() because we need to execute the fn
let val = <T>map.get(this.key);
if (val === undefined) {
val = fn();
map.set(this.key, val);
}

return val;
}
}
Loading