Add download button and pending spinner for audio messages

This commit is contained in:
Fedor Indutny 2021-03-15 17:59:48 -07:00 committed by Josh Perez
parent f98c3cba8c
commit 05f59f3db1
9 changed files with 246 additions and 102 deletions

View File

@ -5103,6 +5103,14 @@
"message": "Pause audio attachment",
"description": "Aria label for audio attachment's Pause button"
},
"MessageAudio--download": {
"message": "Download audio attachment",
"description": "Aria label for audio attachment's Download button"
},
"MessageAudio--pending": {
"message": "Downloading audio attachment...",
"description": "Aria label for pending audio attachment spinner"
},
"MessageAudio--slider": {
"message": "Playback time of audio attachment",
"description": "Aria label for audio attachment's playback time slider"

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><path d="m2.486 10.5 1.061-1.061 4.885 4.886.804 1.125V3h1.5v12.45l.759-1.062 4.963-4.92 1.056 1.064-7.53 7.466L2.486 10.5z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

View File

@ -0,0 +1 @@
<svg width="22" height="22" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="22" height="22"><path fill-rule="evenodd" clip-rule="evenodd" d="M22 0H0v22h11V11h11V0z" fill="#C4C4C4"/></mask><g mask="url(#a)"><circle cx="11" cy="11" r="9.75" stroke="#5E5E5E" stroke-width="2.5"/></g></svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@ -59,7 +59,8 @@
margin-top: 6px;
}
.module-message__audio-attachment__button {
.module-message__audio-attachment__button,
.module-message__audio-attachment__spinner {
flex-shrink: 0;
width: 36px;
height: 36px;
@ -75,22 +76,28 @@
content: '';
}
@mixin audio-icon($name, $color) {
@mixin audio-icon($name, $icon, $color) {
&--#{$name}::before {
@include color-svg(
'../images/icons/v2/#{$name}-solid-20.svg',
$color,
false
);
@include color-svg('../images/icons/v2/#{$icon}.svg', $color, false);
}
}
@mixin all-audio-icons($color) {
@include audio-icon(play, play-solid-20, $color);
@include audio-icon(pause, pause-solid-20, $color);
@include audio-icon(download, arrow-down-20, $color);
@include audio-icon(pending, audio-spinner-arc-22, $color);
}
&--pending::before {
animation: spinner-arc-animation 1000ms linear infinite;
}
.module-message__audio-attachment--incoming & {
@mixin android {
background: $color-white-alpha-20;
@include audio-icon(play, $color-white);
@include audio-icon(pause, $color-white);
@include all-audio-icons($color-white);
}
@include light-theme {
@ -102,14 +109,12 @@
@include ios-theme {
background: $color-white;
@include audio-icon(play, $color-gray-60);
@include audio-icon(pause, $color-gray-60);
@include all-audio-icons($color-gray-60);
}
@include ios-dark-theme {
background: $color-gray-60;
@include audio-icon(play, $color-gray-15);
@include audio-icon(pause, $color-gray-15);
@include all-audio-icons($color-gray-15);
}
}
@ -117,15 +122,13 @@
@mixin android {
background: $color-white;
@include audio-icon(play, $color-gray-60);
@include audio-icon(pause, $color-gray-60);
@include all-audio-icons($color-gray-60);
}
@mixin ios {
background: $color-white-alpha-20;
@include audio-icon(play, $color-white);
@include audio-icon(pause, $color-white);
@include all-audio-icons($color-white);
}
@include light-theme {

View File

@ -753,6 +753,39 @@ story.add('Audio with Caption', () => {
return renderBothDirections(props);
});
story.add('Audio with Not Downloaded Attachment', () => {
const props = createProps({
attachments: [
{
contentType: AUDIO_MP3,
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
url: undefined as any,
},
],
status: 'sent',
});
return renderBothDirections(props);
});
story.add('Audio with Pending Attachment', () => {
const props = createProps({
attachments: [
{
contentType: AUDIO_MP3,
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
url: undefined as any,
pending: true,
},
],
status: 'sent',
});
return renderBothDirections(props);
});
story.add('Other File Type', () => {
const props = createProps({
attachments: [

View File

@ -85,9 +85,11 @@ export type AudioAttachmentProps = {
buttonRef: React.RefObject<HTMLButtonElement>;
direction: DirectionType;
theme: ThemeType | undefined;
url: string;
attachment: AttachmentType;
withContentAbove: boolean;
withContentBelow: boolean;
kickOffAttachmentDownload(): void;
};
export type PropsData = {
@ -754,16 +756,23 @@ export class Message extends React.PureComponent<Props, State> {
</div>
);
}
if (!firstAttachment.pending && isAudio(attachments)) {
if (isAudio(attachments)) {
return renderAudioAttachment({
i18n,
buttonRef: this.audioButtonRef,
id,
direction,
theme,
url: firstAttachment.url,
attachment: firstAttachment,
withContentAbove,
withContentBelow,
kickOffAttachmentDownload() {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
},
});
}
const { pending, fileName, fileSize, contentType } = firstAttachment;

View File

@ -8,12 +8,13 @@ import { noop } from 'lodash';
import { assert } from '../../util/assert';
import { LocalizerType } from '../../types/Util';
import { WaveformCache } from '../../types/Audio';
import { hasNotDownloaded, AttachmentType } from '../../types/Attachment';
export type Props = {
direction?: 'incoming' | 'outgoing';
id: string;
i18n: LocalizerType;
url: string;
attachment: AttachmentType;
withContentAbove: boolean;
withContentBelow: boolean;
@ -23,11 +24,21 @@ export type Props = {
waveformCache: WaveformCache;
buttonRef: React.RefObject<HTMLButtonElement>;
kickOffAttachmentDownload(): void;
activeAudioID: string | undefined;
setActiveAudioID: (id: string | undefined) => void;
};
type ButtonProps = {
i18n: LocalizerType;
buttonRef: React.RefObject<HTMLButtonElement>;
mod: string;
label: string;
onClick: () => void;
};
type LoadAudioOptions = {
audioContext: AudioContext;
waveformCache: WaveformCache;
@ -39,10 +50,17 @@ type LoadAudioResult = {
peaks: ReadonlyArray<number>;
};
enum State {
NotDownloaded = 'NotDownloaded',
Pending = 'Pending',
Normal = 'Normal',
}
// Constants
const CSS_BASE = 'module-message__audio-attachment';
const PEAK_COUNT = 47;
const BAR_NOT_DOWNLOADED_HEIGHT = 2;
const BAR_MIN_HEIGHT = 4;
const BAR_MAX_HEIGHT = 20;
@ -130,6 +148,43 @@ async function loadAudio(options: LoadAudioOptions): Promise<LoadAudioResult> {
return result;
}
const Button: React.FC<ButtonProps> = props => {
const { i18n, buttonRef, mod, label, onClick } = props;
// Clicking button toggle playback
const onButtonClick = (event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onClick();
};
// Keyboard playback toggle
const onButtonKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
event.stopPropagation();
event.preventDefault();
onClick();
};
return (
<button
type="button"
ref={buttonRef}
className={classNames(
`${CSS_BASE}__button`,
`${CSS_BASE}__button--${mod}`
)}
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
tabIndex={0}
aria-label={i18n(label)}
/>
);
};
/**
* Display message audio attachment along with its waveform, duration, and
* toggle Play/Pause button.
@ -147,11 +202,12 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
i18n,
id,
direction,
url,
attachment,
withContentAbove,
withContentBelow,
buttonRef,
kickOffAttachmentDownload,
audio,
audioContext,
@ -179,10 +235,20 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
new Array(PEAK_COUNT).fill(0)
);
let state: State;
if (attachment.pending) {
state = State.Pending;
} else if (hasNotDownloaded(attachment)) {
state = State.NotDownloaded;
} else {
state = State.Normal;
}
// This effect loads audio file and computes its RMS peak for dispalying the
// waveform.
useEffect(() => {
if (!isLoading) {
if (!isLoading || state !== State.Normal) {
return noop;
}
@ -193,7 +259,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
const { peaks: newPeaks, duration: newDuration } = await loadAudio({
audioContext,
waveformCache,
url,
url: attachment.url,
});
if (canceled) {
return;
@ -212,7 +278,15 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
return () => {
canceled = true;
};
}, [url, isLoading, setPeaks, setDuration, audioContext, waveformCache]);
}, [
attachment,
audioContext,
isLoading,
setDuration,
setPeaks,
state,
waveformCache,
]);
// This effect attaches/detaches event listeners to the global <audio/>
// instance that we reuse from the GlobalAudioContext.
@ -300,34 +374,19 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
audio.pause();
}
audio.src = url;
audio.src = attachment.url;
}
};
// Clicking button toggle playback
const onClick = (event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
toggleIsPlaying();
};
// Keyboard playback toggle
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
event.stopPropagation();
event.preventDefault();
toggleIsPlaying();
};
// Clicking waveform moves playback head position and starts playback.
const onWaveformClick = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (state !== State.Normal) {
return;
}
if (!isPlaying) {
toggleIsPlaying();
}
@ -381,11 +440,86 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
}
};
const buttonLabel = i18n(
isPlaying ? 'MessageAudio--play' : 'MessageAudio--pause'
const peakPosition = peaks.length * (currentTime / duration);
const waveform = (
<div
ref={waveformRef}
className={`${CSS_BASE}__waveform`}
onClick={onWaveformClick}
onKeyDown={onWaveformKeyDown}
tabIndex={0}
role="slider"
aria-label={i18n('MessageAudio--slider')}
aria-orientation="horizontal"
aria-valuenow={currentTime}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuetext={timeToText(currentTime)}
>
{peaks.map((peak, i) => {
let height = Math.max(BAR_MIN_HEIGHT, BAR_MAX_HEIGHT * peak);
if (state !== State.Normal) {
height = BAR_NOT_DOWNLOADED_HEIGHT;
}
const highlight = i < peakPosition;
// Use maximum height for current audio position
if (highlight && i + 1 >= peakPosition) {
height = BAR_MAX_HEIGHT;
}
const key = i;
return (
<div
className={classNames([
`${CSS_BASE}__waveform__bar`,
highlight ? `${CSS_BASE}__waveform__bar--active` : null,
])}
key={key}
style={{ height }}
/>
);
})}
</div>
);
const peakPosition = peaks.length * (currentTime / duration);
let button: React.ReactElement;
if (state === State.Pending) {
// Not really a button, but who cares?
button = (
<div
className={classNames(
`${CSS_BASE}__spinner`,
`${CSS_BASE}__spinner--pending`
)}
title={i18n('MessageAudio--pending')}
/>
);
} else if (state === State.NotDownloaded) {
button = (
<Button
i18n={i18n}
buttonRef={buttonRef}
mod="download"
label="MessageAudio--download"
onClick={kickOffAttachmentDownload}
/>
);
} else {
// State.Normal
button = (
<Button
i18n={i18n}
buttonRef={buttonRef}
mod={isPlaying ? 'pause' : 'play'}
label={isPlaying ? 'MessageAudio--pause' : 'MessageAudio--play'}
onClick={toggleIsPlaying}
/>
);
}
return (
<div
@ -396,55 +530,8 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
withContentAbove ? `${CSS_BASE}--with-content-above` : null
)}
>
<button
type="button"
className={classNames(
`${CSS_BASE}__button`,
`${CSS_BASE}__button--${isPlaying ? 'pause' : 'play'}`
)}
ref={buttonRef}
onClick={onClick}
onKeyDown={onKeyDown}
tabIndex={0}
aria-label={buttonLabel}
/>
<div
ref={waveformRef}
className={`${CSS_BASE}__waveform`}
onClick={onWaveformClick}
onKeyDown={onWaveformKeyDown}
tabIndex={0}
role="slider"
aria-label={i18n('MessageAudio--slider')}
aria-orientation="horizontal"
aria-valuenow={currentTime}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuetext={timeToText(currentTime)}
>
{peaks.map((peak, i) => {
let height = Math.max(BAR_MIN_HEIGHT, BAR_MAX_HEIGHT * peak);
const highlight = i < peakPosition;
// Use maximum height for current audio position
if (highlight && i + 1 >= peakPosition) {
height = BAR_MAX_HEIGHT;
}
const key = i;
return (
<div
className={classNames([
`${CSS_BASE}__waveform__bar`,
highlight ? `${CSS_BASE}__waveform__bar--active` : null,
])}
key={key}
style={{ height }}
/>
);
})}
</div>
{button}
{waveform}
<div className={`${CSS_BASE}__duration`}>{timeToText(duration)}</div>
</div>
);

View File

@ -9,6 +9,7 @@ import { mapDispatchToProps } from '../actions';
import { StateType } from '../reducer';
import { WaveformCache } from '../../types/Audio';
import { LocalizerType } from '../../types/Util';
import { AttachmentType } from '../../types/Attachment';
export type Props = {
audio: HTMLAudioElement;
@ -18,11 +19,12 @@ export type Props = {
direction?: 'incoming' | 'outgoing';
id: string;
i18n: LocalizerType;
url: string;
attachment: AttachmentType;
withContentAbove: boolean;
withContentBelow: boolean;
buttonRef: React.RefObject<HTMLButtonElement>;
kickOffAttachmentDownload(): void;
};
const mapStateToProps = (state: StateType, props: Props) => {

View File

@ -14694,7 +14694,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
"lineNumber": 235,
"lineNumber": 237,
"reasonCategory": "usageTrusted",
"updated": "2021-03-05T19:57:01.431Z",
"reasonDetail": "Used for managing focus only"
@ -14703,7 +14703,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();",
"lineNumber": 237,
"lineNumber": 239,
"reasonCategory": "usageTrusted",
"updated": "2021-03-05T19:57:01.431Z",
"reasonDetail": "Used for propagating click from the Message to MessageAudio's button"
@ -14712,7 +14712,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " > = React.createRef();",
"lineNumber": 241,
"lineNumber": 243,
"reasonCategory": "usageTrusted",
"updated": "2021-03-05T19:57:01.431Z",
"reasonDetail": "Used for detecting clicks outside reaction viewer"
@ -14721,7 +14721,7 @@
"rule": "React-useRef",
"path": "ts/components/conversation/MessageAudio.js",
"line": " const waveformRef = react_1.useRef(null);",
"lineNumber": 116,
"lineNumber": 143,
"reasonCategory": "usageTrusted",
"updated": "2021-03-09T01:19:04.057Z",
"reasonDetail": "Used for obtanining the bounding box for the container"