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", "message": "Pause audio attachment",
"description": "Aria label for audio attachment's Pause button" "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": { "MessageAudio--slider": {
"message": "Playback time of audio attachment", "message": "Playback time of audio attachment",
"description": "Aria label for audio attachment's playback time slider" "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; margin-top: 6px;
} }
.module-message__audio-attachment__button { .module-message__audio-attachment__button,
.module-message__audio-attachment__spinner {
flex-shrink: 0; flex-shrink: 0;
width: 36px; width: 36px;
height: 36px; height: 36px;
@ -75,22 +76,28 @@
content: ''; content: '';
} }
@mixin audio-icon($name, $color) { @mixin audio-icon($name, $icon, $color) {
&--#{$name}::before { &--#{$name}::before {
@include color-svg( @include color-svg('../images/icons/v2/#{$icon}.svg', $color, false);
'../images/icons/v2/#{$name}-solid-20.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 & { .module-message__audio-attachment--incoming & {
@mixin android { @mixin android {
background: $color-white-alpha-20; background: $color-white-alpha-20;
@include audio-icon(play, $color-white); @include all-audio-icons($color-white);
@include audio-icon(pause, $color-white);
} }
@include light-theme { @include light-theme {
@ -102,14 +109,12 @@
@include ios-theme { @include ios-theme {
background: $color-white; background: $color-white;
@include audio-icon(play, $color-gray-60); @include all-audio-icons($color-gray-60);
@include audio-icon(pause, $color-gray-60);
} }
@include ios-dark-theme { @include ios-dark-theme {
background: $color-gray-60; background: $color-gray-60;
@include audio-icon(play, $color-gray-15); @include all-audio-icons($color-gray-15);
@include audio-icon(pause, $color-gray-15);
} }
} }
@ -117,15 +122,13 @@
@mixin android { @mixin android {
background: $color-white; background: $color-white;
@include audio-icon(play, $color-gray-60); @include all-audio-icons($color-gray-60);
@include audio-icon(pause, $color-gray-60);
} }
@mixin ios { @mixin ios {
background: $color-white-alpha-20; background: $color-white-alpha-20;
@include audio-icon(play, $color-white); @include all-audio-icons($color-white);
@include audio-icon(pause, $color-white);
} }
@include light-theme { @include light-theme {

View File

@ -753,6 +753,39 @@ story.add('Audio with Caption', () => {
return renderBothDirections(props); 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', () => { story.add('Other File Type', () => {
const props = createProps({ const props = createProps({
attachments: [ attachments: [

View File

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

View File

@ -8,12 +8,13 @@ import { noop } from 'lodash';
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
import { WaveformCache } from '../../types/Audio'; import { WaveformCache } from '../../types/Audio';
import { hasNotDownloaded, AttachmentType } from '../../types/Attachment';
export type Props = { export type Props = {
direction?: 'incoming' | 'outgoing'; direction?: 'incoming' | 'outgoing';
id: string; id: string;
i18n: LocalizerType; i18n: LocalizerType;
url: string; attachment: AttachmentType;
withContentAbove: boolean; withContentAbove: boolean;
withContentBelow: boolean; withContentBelow: boolean;
@ -23,11 +24,21 @@ export type Props = {
waveformCache: WaveformCache; waveformCache: WaveformCache;
buttonRef: React.RefObject<HTMLButtonElement>; buttonRef: React.RefObject<HTMLButtonElement>;
kickOffAttachmentDownload(): void;
activeAudioID: string | undefined; activeAudioID: string | undefined;
setActiveAudioID: (id: string | undefined) => void; setActiveAudioID: (id: string | undefined) => void;
}; };
type ButtonProps = {
i18n: LocalizerType;
buttonRef: React.RefObject<HTMLButtonElement>;
mod: string;
label: string;
onClick: () => void;
};
type LoadAudioOptions = { type LoadAudioOptions = {
audioContext: AudioContext; audioContext: AudioContext;
waveformCache: WaveformCache; waveformCache: WaveformCache;
@ -39,10 +50,17 @@ type LoadAudioResult = {
peaks: ReadonlyArray<number>; peaks: ReadonlyArray<number>;
}; };
enum State {
NotDownloaded = 'NotDownloaded',
Pending = 'Pending',
Normal = 'Normal',
}
// Constants // Constants
const CSS_BASE = 'module-message__audio-attachment'; const CSS_BASE = 'module-message__audio-attachment';
const PEAK_COUNT = 47; const PEAK_COUNT = 47;
const BAR_NOT_DOWNLOADED_HEIGHT = 2;
const BAR_MIN_HEIGHT = 4; const BAR_MIN_HEIGHT = 4;
const BAR_MAX_HEIGHT = 20; const BAR_MAX_HEIGHT = 20;
@ -130,6 +148,43 @@ async function loadAudio(options: LoadAudioOptions): Promise<LoadAudioResult> {
return result; 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 * Display message audio attachment along with its waveform, duration, and
* toggle Play/Pause button. * toggle Play/Pause button.
@ -147,11 +202,12 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
i18n, i18n,
id, id,
direction, direction,
url, attachment,
withContentAbove, withContentAbove,
withContentBelow, withContentBelow,
buttonRef, buttonRef,
kickOffAttachmentDownload,
audio, audio,
audioContext, audioContext,
@ -179,10 +235,20 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
new Array(PEAK_COUNT).fill(0) 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 // This effect loads audio file and computes its RMS peak for dispalying the
// waveform. // waveform.
useEffect(() => { useEffect(() => {
if (!isLoading) { if (!isLoading || state !== State.Normal) {
return noop; return noop;
} }
@ -193,7 +259,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
const { peaks: newPeaks, duration: newDuration } = await loadAudio({ const { peaks: newPeaks, duration: newDuration } = await loadAudio({
audioContext, audioContext,
waveformCache, waveformCache,
url, url: attachment.url,
}); });
if (canceled) { if (canceled) {
return; return;
@ -212,7 +278,15 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
return () => { return () => {
canceled = true; 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/> // This effect attaches/detaches event listeners to the global <audio/>
// instance that we reuse from the GlobalAudioContext. // instance that we reuse from the GlobalAudioContext.
@ -300,34 +374,19 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
audio.pause(); 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. // Clicking waveform moves playback head position and starts playback.
const onWaveformClick = (event: React.MouseEvent) => { const onWaveformClick = (event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (state !== State.Normal) {
return;
}
if (!isPlaying) { if (!isPlaying) {
toggleIsPlaying(); toggleIsPlaying();
} }
@ -381,11 +440,86 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
} }
}; };
const buttonLabel = i18n( const peakPosition = peaks.length * (currentTime / duration);
isPlaying ? 'MessageAudio--play' : 'MessageAudio--pause'
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 ( return (
<div <div
@ -396,55 +530,8 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
withContentAbove ? `${CSS_BASE}--with-content-above` : null withContentAbove ? `${CSS_BASE}--with-content-above` : null
)} )}
> >
<button {button}
type="button" {waveform}
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>
<div className={`${CSS_BASE}__duration`}>{timeToText(duration)}</div> <div className={`${CSS_BASE}__duration`}>{timeToText(duration)}</div>
</div> </div>
); );

View File

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

View File

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