Add download button and pending spinner for audio messages
This commit is contained in:
parent
f98c3cba8c
commit
05f59f3db1
9 changed files with 246 additions and 102 deletions
|
@ -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"
|
||||
|
|
1
images/icons/v2/arrow-down-20.svg
Normal file
1
images/icons/v2/arrow-down-20.svg
Normal 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 |
1
images/icons/v2/audio-spinner-arc-22.svg
Normal file
1
images/icons/v2/audio-spinner-arc-22.svg
Normal 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 |
|
@ -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 {
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue