signal-desktop/ts/components/conversation/MessageAudio.tsx
Josh Perez 1f2cde6d04
Send edited messages support
Co-authored-by: Fedor Indutnyy <indutny@signal.org>
2023-04-20 09:31:59 -07:00

389 lines
9.9 KiB
TypeScript

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import type { RefObject } from 'react';
import classNames from 'classnames';
import { noop } from 'lodash';
import { animated, useSpring } from '@react-spring/web';
import type { LocalizerType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
import { isDownloaded } from '../../types/Attachment';
import type { DirectionType, MessageStatusType } from './Message';
import type { ComputePeaksResult } from '../VoiceNotesPlaybackContext';
import { MessageMetadata } from './MessageMetadata';
import * as log from '../../logging/log';
import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer';
import { PlaybackRateButton } from '../PlaybackRateButton';
import { PlaybackButton } from '../PlaybackButton';
import { WaveformScrubber } from './WaveformScrubber';
import { useComputePeaks } from '../../hooks/useComputePeaks';
import { durationToPlaybackText } from '../../util/durationToPlaybackText';
import { shouldNeverBeCalled } from '../../util/shouldNeverBeCalled';
export type OwnProps = Readonly<{
active:
| Pick<
ActiveAudioPlayerStateType,
'currentTime' | 'duration' | 'playing' | 'playbackRate'
>
| undefined;
buttonRef: RefObject<HTMLButtonElement>;
i18n: LocalizerType;
attachment: AttachmentType;
collapseMetadata: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
// Message properties. Many are needed for rendering metadata
direction: DirectionType;
expirationLength?: number;
expirationTimestamp?: number;
id: string;
played: boolean;
status?: MessageStatusType;
textPending?: boolean;
timestamp: number;
kickOffAttachmentDownload(): void;
onCorrupted(): void;
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
onPlayMessage: (id: string, position: number) => void;
}>;
export type DispatchProps = Readonly<{
pushPanelForConversation: PushPanelForConversationActionType;
setPosition: (positionAsRatio: number) => void;
setPlaybackRate: (rate: number) => void;
setIsPlaying: (value: boolean) => void;
}>;
export type Props = OwnProps & DispatchProps;
enum State {
NotDownloaded = 'NotDownloaded',
Pending = 'Pending',
Computing = 'Computing',
Normal = 'Normal',
}
// Constants
const CSS_BASE = 'module-message__audio-attachment';
const BAR_COUNT = 47;
const BAR_NOT_DOWNLOADED_HEIGHT = 2;
const BAR_MIN_HEIGHT = 4;
const BAR_MAX_HEIGHT = 20;
const SPRING_CONFIG = {
mass: 0.5,
tension: 350,
friction: 20,
velocity: 0.01,
};
const DOT_DIV_WIDTH = 14;
function PlayedDot({
played,
onHide,
}: {
played: boolean;
onHide: () => void;
}) {
const start = played ? 1 : 0;
const end = played ? 0 : 1;
const [animProps] = useSpring(
{
config: SPRING_CONFIG,
from: { scale: start, opacity: start, width: start },
to: { scale: end, opacity: end, width: end * DOT_DIV_WIDTH },
onRest: () => {
if (played) {
onHide();
}
},
},
[played]
);
return (
<animated.div
style={animProps}
aria-hidden="true"
className={classNames(
`${CSS_BASE}__dot`,
`${CSS_BASE}__dot--${played ? 'played' : 'unplayed'}`
)}
/>
);
}
/**
* Display message audio attachment along with its waveform, duration, and
* toggle Play/Pause button.
*
* A global audio player is used for playback and access is managed by the
* `active.content.current.id` and the `active.content.context` properties. Whenever both
* are equal to `id` and `context` respectively the instance of the `MessageAudio`
* assumes the ownership of the `Audio` instance and fully manages it.
*
* `context` is required for displaying separate MessageAudio instances in
* MessageDetails and Message React components.
*/
export function MessageAudio(props: Props): JSX.Element {
const {
active,
buttonRef,
i18n,
attachment,
collapseMetadata,
withContentAbove,
withContentBelow,
direction,
expirationLength,
expirationTimestamp,
id,
played,
status,
textPending,
timestamp,
kickOffAttachmentDownload,
onCorrupted,
setPlaybackRate,
onPlayMessage,
pushPanelForConversation,
setPosition,
setIsPlaying,
} = props;
const isPlaying = active?.playing ?? false;
const [isPlayedDotVisible, setIsPlayedDotVisible] = React.useState(!played);
const audioUrl = isDownloaded(attachment) ? attachment.url : undefined;
const { duration, hasPeaks, peaks } = useComputePeaks({
audioUrl,
activeDuration: active?.duration,
barCount: BAR_COUNT,
onCorrupted,
});
let state: State;
if (attachment.pending) {
state = State.Pending;
} else if (!isDownloaded(attachment)) {
state = State.NotDownloaded;
} else if (!hasPeaks) {
state = State.Computing;
} else {
state = State.Normal;
}
const toggleIsPlaying = useCallback(() => {
if (!isPlaying) {
if (!attachment.url) {
throw new Error(
'Expected attachment url in the MessageAudio with ' +
`state: ${state}`
);
}
if (active) {
setIsPlaying(true);
} else {
onPlayMessage(id, 0);
}
} else {
setIsPlaying(false);
}
}, [
isPlaying,
attachment.url,
active,
state,
setIsPlaying,
id,
onPlayMessage,
]);
const currentTimeOrZero = active?.currentTime ?? 0;
const updatePosition = useCallback(
(newPosition: number) => {
if (active) {
setPosition(newPosition);
if (!active.playing) {
setIsPlaying(true);
}
return;
}
if (attachment.url) {
onPlayMessage(id, newPosition);
} else {
log.warn('Waveform clicked on attachment with no url');
}
},
[active, attachment.url, id, onPlayMessage, setIsPlaying, setPosition]
);
const handleWaveformClick = useCallback(
(positionAsRatio: number) => {
if (state !== State.Normal) {
return;
}
updatePosition(positionAsRatio);
},
[state, updatePosition]
);
const handleWaveformScrub = useCallback(
(amountInSeconds: number) => {
const currentPosition = currentTimeOrZero / duration;
const positionIncrement = amountInSeconds / duration;
updatePosition(
Math.min(Math.max(0, currentPosition + positionIncrement), duration)
);
},
[currentTimeOrZero, duration, updatePosition]
);
const waveform = (
<WaveformScrubber
i18n={i18n}
peaks={peaks}
duration={duration}
currentTime={currentTimeOrZero}
barMinHeight={
state !== State.Normal ? BAR_NOT_DOWNLOADED_HEIGHT : BAR_MIN_HEIGHT
}
barMaxHeight={BAR_MAX_HEIGHT}
onClick={handleWaveformClick}
onScrub={handleWaveformScrub}
/>
);
let button: React.ReactElement;
if (state === State.Pending || state === State.Computing) {
// Not really a button, but who cares?
button = (
<PlaybackButton
variant="message"
mod="pending"
onClick={noop}
label={i18n('icu:MessageAudio--pending')}
context={direction}
/>
);
} else if (state === State.NotDownloaded) {
button = (
<PlaybackButton
ref={buttonRef}
variant="message"
mod="download"
label={i18n('icu:MessageAudio--download')}
onClick={kickOffAttachmentDownload}
context={direction}
/>
);
} else {
// State.Normal
button = (
<PlaybackButton
ref={buttonRef}
variant="message"
mod={isPlaying ? 'pause' : 'play'}
label={
isPlaying
? i18n('icu:MessageAudio--pause')
: i18n('icu:MessageAudio--play')
}
onClick={toggleIsPlaying}
context={direction}
/>
);
}
const countDown = Math.max(0, duration - (active?.currentTime ?? 0));
const metadata = (
<div className={`${CSS_BASE}__metadata`}>
<div
aria-hidden="true"
className={classNames(
`${CSS_BASE}__countdown`,
`${CSS_BASE}__countdown--${played ? 'played' : 'unplayed'}`
)}
>
{durationToPlaybackText(countDown)}
</div>
<div className={`${CSS_BASE}__controls`}>
<PlayedDot
played={played}
onHide={() => setIsPlayedDotVisible(false)}
/>
<PlaybackRateButton
i18n={i18n}
variant={`message-${direction}`}
playbackRate={active?.playbackRate}
visible={isPlaying && (!played || !isPlayedDotVisible)}
onClick={() => {
if (active) {
setPlaybackRate(
PlaybackRateButton.nextPlaybackRate(active.playbackRate)
);
}
}}
/>
</div>
{!withContentBelow && !collapseMetadata && (
<MessageMetadata
direction={direction}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
hasText={withContentBelow}
i18n={i18n}
id={id}
isShowingImage={false}
isSticker={false}
isTapToViewExpired={false}
pushPanelForConversation={pushPanelForConversation}
retryMessageSend={shouldNeverBeCalled}
status={status}
textPending={textPending}
timestamp={timestamp}
/>
)}
</div>
);
return (
<div
className={classNames(
CSS_BASE,
`${CSS_BASE}--${direction}`,
withContentBelow ? `${CSS_BASE}--with-content-below` : null,
withContentAbove ? `${CSS_BASE}--with-content-above` : null
)}
>
<div className={`${CSS_BASE}__button-and-waveform`}>
{button}
{waveform}
</div>
{metadata}
</div>
);
}