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

Commit 5fa2ca8

Browse files
authored
Allow voice messages to be scrubbed in the timeline (#8079)
* Use SeekBar for voice messages + move seeking logic to base class * Appease the linter * Update tests
1 parent 2adc972 commit 5fa2ca8

File tree

6 files changed

+103
-82
lines changed

6 files changed

+103
-82
lines changed

res/css/views/audio_messages/_PlaybackContainer.scss

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2021 The Matrix.org Foundation C.I.C.
2+
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -29,6 +29,7 @@ limitations under the License.
2929

3030
contain: content;
3131

32+
// Waveforms are present in live recording only
3233
.mx_Waveform {
3334
.mx_Waveform_bar {
3435
background-color: $quaternary-content;
@@ -46,11 +47,22 @@ limitations under the License.
4647

4748
.mx_Clock {
4849
width: $font-42px; // we're not using a monospace font, so fake it
50+
min-width: $font-42px; // force sensible layouts in awkward flexboxes (file panel, for example)
4951
padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended.
5052
padding-left: 8px; // isolate from recording circle / play control
5153
}
5254

53-
&.mx_VoiceMessagePrimaryContainer_noWaveform {
54-
max-width: 162px; // with all the padding this results in 185px wide
55+
// For timeline-rendered playback, mirror the values for where the clock is in
56+
// the waveform version.
57+
.mx_SeekBar {
58+
margin-left: 8px;
59+
margin-right: 6px;
60+
61+
& + .mx_Clock {
62+
text-align: right;
63+
64+
// Take the padding off the clock because it's accounted for in the seek bar
65+
padding: 0;
66+
}
5567
}
5668
}

src/components/views/audio_messages/AudioPlayer.tsx

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2021 The Matrix.org Foundation C.I.C.
2+
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import React, { createRef, ReactNode, RefObject } from "react";
17+
import React, { ReactNode } from "react";
1818

1919
import PlayPauseButton from "./PlayPauseButton";
2020
import { replaceableComponent } from "../../../utils/replaceableComponent";
@@ -24,41 +24,9 @@ import { _t } from "../../../languageHandler";
2424
import SeekBar from "./SeekBar";
2525
import PlaybackClock from "./PlaybackClock";
2626
import AudioPlayerBase from "./AudioPlayerBase";
27-
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
28-
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
2927

3028
@replaceableComponent("views.audio_messages.AudioPlayer")
3129
export default class AudioPlayer extends AudioPlayerBase {
32-
private playPauseRef: RefObject<PlayPauseButton> = createRef();
33-
private seekRef: RefObject<SeekBar> = createRef();
34-
35-
private onKeyDown = (ev: React.KeyboardEvent) => {
36-
let handled = true;
37-
const action = getKeyBindingsManager().getAccessibilityAction(ev);
38-
39-
switch (action) {
40-
case KeyBindingAction.Space:
41-
this.playPauseRef.current?.toggleState();
42-
break;
43-
case KeyBindingAction.ArrowLeft:
44-
this.seekRef.current?.left();
45-
break;
46-
case KeyBindingAction.ArrowRight:
47-
this.seekRef.current?.right();
48-
break;
49-
default:
50-
handled = false;
51-
break;
52-
}
53-
54-
// stopPropagation() prevents the FocusComposer catch-all from triggering,
55-
// but we need to do it on key down instead of press (even though the user
56-
// interaction is typically on press).
57-
if (handled) {
58-
ev.stopPropagation();
59-
}
60-
};
61-
6230
protected renderFileSize(): string {
6331
const bytes = this.props.playback.sizeBytes;
6432
if (!bytes) return null;

src/components/views/audio_messages/AudioPlayerBase.tsx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2021 The Matrix.org Foundation C.I.C.
2+
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -14,15 +14,19 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import React, { ReactNode } from "react";
17+
import React, { createRef, ReactNode, RefObject } from "react";
1818
import { logger } from "matrix-js-sdk/src/logger";
1919

2020
import { Playback, PlaybackState } from "../../../audio/Playback";
2121
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
2222
import { replaceableComponent } from "../../../utils/replaceableComponent";
2323
import { _t } from "../../../languageHandler";
24+
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
25+
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
26+
import SeekBar from "./SeekBar";
27+
import PlayPauseButton from "./PlayPauseButton";
2428

25-
interface IProps {
29+
export interface IProps {
2630
// Playback instance to render. Cannot change during component lifecycle: create
2731
// an all-new component instead.
2832
playback: Playback;
@@ -36,8 +40,11 @@ interface IState {
3640
}
3741

3842
@replaceableComponent("views.audio_messages.AudioPlayerBase")
39-
export default abstract class AudioPlayerBase extends React.PureComponent<IProps, IState> {
40-
constructor(props: IProps) {
43+
export default abstract class AudioPlayerBase<T extends IProps = IProps> extends React.PureComponent<T, IState> {
44+
protected seekRef: RefObject<SeekBar> = createRef();
45+
protected playPauseRef: RefObject<PlayPauseButton> = createRef();
46+
47+
constructor(props: T) {
4148
super(props);
4249

4350
// Playback instances can be reused in the composer
@@ -56,6 +63,33 @@ export default abstract class AudioPlayerBase extends React.PureComponent<IProps
5663
});
5764
}
5865

66+
protected onKeyDown = (ev: React.KeyboardEvent) => {
67+
let handled = true;
68+
const action = getKeyBindingsManager().getAccessibilityAction(ev);
69+
70+
switch (action) {
71+
case KeyBindingAction.Space:
72+
this.playPauseRef.current?.toggleState();
73+
break;
74+
case KeyBindingAction.ArrowLeft:
75+
this.seekRef.current?.left();
76+
break;
77+
case KeyBindingAction.ArrowRight:
78+
this.seekRef.current?.right();
79+
break;
80+
default:
81+
handled = false;
82+
break;
83+
}
84+
85+
// stopPropagation() prevents the FocusComposer catch-all from triggering,
86+
// but we need to do it on key down instead of press (even though the user
87+
// interaction is typically on press).
88+
if (handled) {
89+
ev.stopPropagation();
90+
}
91+
};
92+
5993
private onPlaybackUpdate = (ev: PlaybackState) => {
6094
this.setState({ playbackPhase: ev });
6195
};

src/components/views/audio_messages/RecordingPlayback.tsx

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2021 The Matrix.org Foundation C.I.C.
2+
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -19,29 +19,50 @@ import React, { ReactNode } from "react";
1919
import PlayPauseButton from "./PlayPauseButton";
2020
import PlaybackClock from "./PlaybackClock";
2121
import { replaceableComponent } from "../../../utils/replaceableComponent";
22+
import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase";
23+
import SeekBar from "./SeekBar";
2224
import PlaybackWaveform from "./PlaybackWaveform";
23-
import AudioPlayerBase from "./AudioPlayerBase";
24-
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
25+
26+
interface IProps extends IAudioPlayerBaseProps {
27+
/**
28+
* When true, use a waveform instead of a seek bar
29+
*/
30+
withWaveform?: boolean;
31+
}
2532

2633
@replaceableComponent("views.audio_messages.RecordingPlayback")
27-
export default class RecordingPlayback extends AudioPlayerBase {
28-
static contextType = RoomContext;
29-
public context!: React.ContextType<typeof RoomContext>;
30-
31-
private get isWaveformable(): boolean {
32-
return this.context.timelineRenderingType !== TimelineRenderingType.Notification
33-
&& this.context.timelineRenderingType !== TimelineRenderingType.File
34-
&& this.context.timelineRenderingType !== TimelineRenderingType.Pinned;
34+
export default class RecordingPlayback extends AudioPlayerBase<IProps> {
35+
// This component is rendered in two ways: the composer and timeline. They have different
36+
// rendering properties (specifically the difference of a waveform or not).
37+
38+
private renderWaveformLook(): ReactNode {
39+
return <>
40+
<PlaybackClock playback={this.props.playback} />
41+
<PlaybackWaveform playback={this.props.playback} />
42+
</>;
3543
}
3644

37-
protected renderComponent(): ReactNode {
38-
const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : '';
45+
private renderSeekableLook(): ReactNode {
46+
return <>
47+
<SeekBar
48+
playback={this.props.playback}
49+
tabIndex={-1} // prevent tabbing into the bar
50+
playbackPhase={this.state.playbackPhase}
51+
ref={this.seekRef}
52+
/>
53+
<PlaybackClock playback={this.props.playback} />
54+
</>;
55+
}
3956

57+
protected renderComponent(): ReactNode {
4058
return (
41-
<div className={'mx_MediaBody mx_VoiceMessagePrimaryContainer ' + shapeClass}>
42-
<PlayPauseButton playback={this.props.playback} playbackPhase={this.state.playbackPhase} />
43-
<PlaybackClock playback={this.props.playback} />
44-
{ this.isWaveformable && <PlaybackWaveform playback={this.props.playback} /> }
59+
<div className="mx_MediaBody mx_VoiceMessagePrimaryContainer" onKeyDown={this.onKeyDown}>
60+
<PlayPauseButton
61+
playback={this.props.playback}
62+
playbackPhase={this.state.playbackPhase}
63+
ref={this.playPauseRef}
64+
/>
65+
{ this.props.withWaveform ? this.renderWaveformLook() : this.renderSeekableLook() }
4566
</div>
4667
);
4768
}

src/components/views/rooms/VoiceRecordComposerTile.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
233233
if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
234234

235235
if (this.state.recordingPhase !== RecordingState.Started) {
236-
return <RecordingPlayback playback={this.state.recorder.getPlayback()} />;
236+
return <RecordingPlayback playback={this.state.recorder.getPlayback()} withWaveform={true} />;
237237
}
238238

239239
// only other UI is the recording-in-progress UI

test/components/views/audio_messages/RecordingPlayback-test.tsx

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import RoomContext, { TimelineRenderingType } from '../../../../src/contexts/Roo
2727
import { createAudioContext } from '../../../../src/audio/compat';
2828
import { findByTestId, flushPromises } from '../../../test-utils';
2929
import PlaybackWaveform from '../../../../src/components/views/audio_messages/PlaybackWaveform';
30+
import SeekBar from "../../../../src/components/views/audio_messages/SeekBar";
3031

3132
jest.mock('../../../../src/audio/compat', () => ({
3233
createAudioContext: jest.fn(),
@@ -56,7 +57,7 @@ describe('<RecordingPlayback />', () => {
5657
const mockChannelData = new Float32Array();
5758

5859
const defaultRoom = { roomId: '!room:server.org', timelineRenderingType: TimelineRenderingType.File };
59-
const getComponent = (props: { playback: Playback }, room = defaultRoom) =>
60+
const getComponent = (props: React.ComponentProps<RecordingPlayback>, room = defaultRoom) =>
6061
mount(<RecordingPlayback {...props} />, {
6162
wrappingComponent: RoomContext.Provider,
6263
wrappingComponentProps: { value: room },
@@ -128,34 +129,19 @@ describe('<RecordingPlayback />', () => {
128129
expect(playback.toggle).toHaveBeenCalled();
129130
});
130131

131-
it.each([
132-
[TimelineRenderingType.Notification],
133-
[TimelineRenderingType.File],
134-
[TimelineRenderingType.Pinned],
135-
])('does not render waveform when timeline rendering type for room is %s', (timelineRenderingType) => {
132+
it('should render a seek bar by default', () => {
136133
const playback = new Playback(new ArrayBuffer(8));
137-
const room = {
138-
...defaultRoom,
139-
timelineRenderingType,
140-
};
141-
const component = getComponent({ playback }, room);
134+
const component = getComponent({ playback });
142135

143136
expect(component.find(PlaybackWaveform).length).toBeFalsy();
137+
expect(component.find(SeekBar).length).toBeTruthy();
144138
});
145139

146-
it.each([
147-
[TimelineRenderingType.Room],
148-
[TimelineRenderingType.Thread],
149-
[TimelineRenderingType.ThreadsList],
150-
[TimelineRenderingType.Search],
151-
])('renders waveform when timeline rendering type for room is %s', (timelineRenderingType) => {
140+
it('should render a waveform when requested', () => {
152141
const playback = new Playback(new ArrayBuffer(8));
153-
const room = {
154-
...defaultRoom,
155-
timelineRenderingType,
156-
};
157-
const component = getComponent({ playback }, room);
142+
const component = getComponent({ playback, withWaveform: true });
158143

159144
expect(component.find(PlaybackWaveform).length).toBeTruthy();
145+
expect(component.find(SeekBar).length).toBeFalsy();
160146
});
161147
});

0 commit comments

Comments
 (0)