Moves AudioCapture into react

This commit is contained in:
Josh Perez 2021-09-29 16:23:06 -04:00 committed by GitHub
parent c170d04ffa
commit 603c315c82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1012 additions and 492 deletions

View file

@ -19,23 +19,12 @@ const story = storiesOf('Components/CompositionArea', module);
// necessary for the add attachment button to render properly
story.addDecorator(storyFn => <div className="file-input">{storyFn()}</div>);
// necessary for the mic button to render properly
const micCellEl = new DOMParser().parseFromString(
`
<div class="capture-audio">
<button class="microphone"></button>
</div>
`,
'text/html'
).body.firstElementChild as HTMLElement;
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversationId: '123',
i18n,
micCellEl,
addAttachment: action('addAttachment'),
addPendingAttachment: action('addPendingAttachment'),
conversationId: '123',
i18n,
onSendMessage: action('onSendMessage'),
processAttachments: action('processAttachments'),
removeAttachment: action('removeAttachment'),
@ -43,6 +32,12 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
draftAttachments: overrideProps.draftAttachments || [],
onClearAttachments: action('onClearAttachments'),
onClickAttachment: action('onClickAttachment'),
// AudioCapture
cancelRecording: action('cancelRecording'),
completeRecording: action('completeRecording'),
errorRecording: action('errorRecording'),
isRecording: Boolean(overrideProps.isRecording),
startRecording: action('startRecording'),
// StagedLinkPreview
linkPreviewLoading: Boolean(overrideProps.linkPreviewLoading),
linkPreviewResult: overrideProps.linkPreviewResult,
@ -57,7 +52,6 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
overrideProps.shouldSendHighQualityAttachments
),
// CompositionInput
onSubmit: action('onSubmit'),
onEditorStateChange: action('onEditorStateChange'),
onTextTooLong: action('onTextTooLong'),
draftText: overrideProps.draftText || undefined,

View file

@ -5,12 +5,14 @@ import React, {
MutableRefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { get, noop } from 'lodash';
import { get } from 'lodash';
import classNames from 'classnames';
import type { BodyRangeType, BodyRangesType } from '../types/Util';
import type { ErrorDialogAudioRecorderType } from '../state/ducks/audioRecorder';
import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing';
import { Spinner } from './Spinner';
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
import {
@ -34,26 +36,25 @@ import {
GroupV2PendingApprovalActions,
PropsType as GroupV2PendingApprovalActionsPropsType,
} from './conversation/GroupV2PendingApprovalActions';
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
import { countStickers } from './stickers/lib';
import { LocalizerType } from '../types/Util';
import { EmojiPickDataType } from './emoji/EmojiPicker';
import { AttachmentType, isImageAttachment } from '../types/Attachment';
import { AnnouncementsOnlyGroupBanner } from './AnnouncementsOnlyGroupBanner';
import { AttachmentList } from './conversation/AttachmentList';
import { AttachmentType, isImageAttachment } from '../types/Attachment';
import { AudioCapture } from './conversation/AudioCapture';
import { CompositionUpload } from './CompositionUpload';
import { ConversationType } from '../state/ducks/conversations';
import { EmojiPickDataType } from './emoji/EmojiPicker';
import { LinkPreviewWithDomain } from '../types/LinkPreview';
import { LocalizerType } from '../types/Util';
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
import { MediaQualitySelector } from './MediaQualitySelector';
import { Quote, Props as QuoteProps } from './conversation/Quote';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { LinkPreviewWithDomain } from '../types/LinkPreview';
import { ConversationType } from '../state/ducks/conversations';
import { AnnouncementsOnlyGroupBanner } from './AnnouncementsOnlyGroupBanner';
import { CompositionUpload } from './CompositionUpload';
import type { HandleAttachmentsProcessingArgsType } from '../util/handleAttachmentsProcessing';
import { countStickers } from './stickers/lib';
export type CompositionAPIType = {
focusInput: () => void;
isDirty: () => boolean;
setDisabled: (disabled: boolean) => void;
setMicActive: (micActive: boolean) => void;
reset: InputApi['reset'];
resetEmojiResults: InputApi['resetEmojiResults'];
};
@ -72,27 +73,41 @@ export type OwnProps = Readonly<{
areWeAdmin?: boolean;
areWePending?: boolean;
areWePendingApproval?: boolean;
cancelRecording: () => unknown;
completeRecording: (
conversationId: string,
onSendAudioRecording?: (rec: AttachmentType) => unknown
) => unknown;
compositionApi?: MutableRefObject<CompositionAPIType>;
conversationId: string;
draftAttachments: ReadonlyArray<AttachmentType>;
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType;
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
groupAdmins: Array<ConversationType>;
groupVersion?: 1 | 2;
i18n: LocalizerType;
isFetchingUUID?: boolean;
isGroupV1AndDisabled?: boolean;
isMissingMandatoryProfileSharing?: boolean;
isRecording: boolean;
isSMSOnly?: boolean;
left?: boolean;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewWithDomain;
messageRequestsEnabled?: boolean;
micCellEl?: HTMLElement;
onClearAttachments(): unknown;
onClickAttachment(): unknown;
onClickQuotedMessage(): unknown;
onCloseLinkPreview(): unknown;
processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown;
onSelectMediaQuality(isHQ: boolean): unknown;
onSendMessage(options: {
draftAttachments?: ReadonlyArray<AttachmentType>;
mentions?: BodyRangesType;
message?: string;
timestamp?: number;
voiceNoteAttachment?: AttachmentType;
}): unknown;
openConversation(conversationId: string): unknown;
quotedMessageProps?: Omit<
QuoteProps,
@ -101,12 +116,12 @@ export type OwnProps = Readonly<{
removeAttachment: (conversationId: string, filePath: string) => unknown;
setQuotedMessage(message: undefined): unknown;
shouldSendHighQualityAttachments: boolean;
startRecording: () => unknown;
}>;
export type Props = Pick<
CompositionInputProps,
| 'sortedGroupMembers'
| 'onSubmit'
| 'onEditorStateChange'
| 'onTextTooLong'
| 'draftText'
@ -138,19 +153,13 @@ export type Props = Pick<
Pick<GroupV2PendingApprovalActionsPropsType, 'onCancelJoinRequest'> &
OwnProps;
const emptyElement = (el: HTMLElement) => {
// Necessary to deal with Backbone views
// eslint-disable-next-line no-param-reassign
el.innerHTML = '';
};
export const CompositionArea = ({
// Base props
addAttachment,
addPendingAttachment,
conversationId,
i18n,
micCellEl,
onSendMessage,
processAttachments,
removeAttachment,
@ -158,6 +167,13 @@ export const CompositionArea = ({
draftAttachments,
onClearAttachments,
onClickAttachment,
// AudioCapture
cancelRecording,
completeRecording,
errorDialogAudioRecorderType,
errorRecording,
isRecording,
startRecording,
// StagedLinkPreview
linkPreviewLoading,
linkPreviewResult,
@ -170,7 +186,6 @@ export const CompositionArea = ({
onSelectMediaQuality,
shouldSendHighQualityAttachments,
// CompositionInput
onSubmit,
compositionApi,
onEditorStateChange,
onTextTooLong,
@ -227,7 +242,6 @@ export const CompositionArea = ({
isFetchingUUID,
}: Props): JSX.Element => {
const [disabled, setDisabled] = useState(false);
const [micActive, setMicActive] = useState(false);
const [dirty, setDirty] = useState(false);
const [large, setLarge] = useState(false);
const inputApiRef = useRef<InputApi | undefined>();
@ -240,12 +254,17 @@ export const CompositionArea = ({
}
}, [inputApiRef, setLarge]);
const handleSubmit = useCallback<typeof onSubmit>(
(...args) => {
const handleSubmit = useCallback(
(message: string, mentions: Array<BodyRangeType>, timestamp: number) => {
setLarge(false);
onSubmit(...args);
onSendMessage({
draftAttachments,
mentions,
message,
timestamp,
});
},
[setLarge, onSubmit]
[draftAttachments, onSendMessage, setLarge]
);
const launchAttachmentPicker = () => {
@ -279,7 +298,6 @@ export const CompositionArea = ({
isDirty: () => dirty,
focusInput,
setDisabled,
setMicActive,
reset: () => {
if (inputApiRef.current) {
inputApiRef.current.reset();
@ -309,19 +327,6 @@ export const CompositionArea = ({
const shouldShowMicrophone = !draftAttachments.length && !draftText;
// The following is a work-around to allow react to lay-out backbone-managed
// dom nodes until those functions are in React
const micCellRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const { current: micCellContainer } = micCellRef;
if (micCellContainer && micCellEl) {
emptyElement(micCellContainer);
micCellContainer.appendChild(micCellEl);
}
return noop;
}, [micCellRef, micCellEl, large, dirty, shouldShowMicrophone]);
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
const leftHandSideButtonsFragment = (
@ -350,16 +355,19 @@ export const CompositionArea = ({
);
const micButtonFragment = shouldShowMicrophone ? (
<div
className={classNames(
'CompositionArea__button-cell',
micActive ? 'CompositionArea__button-cell--mic-active' : null,
large ? 'CompositionArea__button-cell--large-right' : null,
micActive && large
? 'CompositionArea__button-cell--large-right-mic-active'
: null
)}
ref={micCellRef}
<AudioCapture
cancelRecording={cancelRecording}
completeRecording={completeRecording}
conversationId={conversationId}
draftAttachments={draftAttachments}
errorDialogAudioRecorderType={errorDialogAudioRecorderType}
errorRecording={errorRecording}
i18n={i18n}
isRecording={isRecording}
onSendAudioRecording={(voiceNoteAttachment: AttachmentType) => {
onSendMessage({ voiceNoteAttachment });
}}
startRecording={startRecording}
/>
) : null;

View file

@ -0,0 +1,66 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import { boolean } from '@storybook/addon-knobs';
import { ErrorDialogAudioRecorderType } from '../../state/ducks/audioRecorder';
import { AudioCapture, PropsType } from './AudioCapture';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const story = storiesOf('Components/Conversation/AudioCapture', module);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
cancelRecording: action('cancelRecording'),
completeRecording: action('completeRecording'),
conversationId: '123',
draftAttachments: [],
errorDialogAudioRecorderType: overrideProps.errorDialogAudioRecorderType,
errorRecording: action('errorRecording'),
i18n,
isRecording: boolean('isRecording', overrideProps.isRecording || false),
onSendAudioRecording: action('onSendAudioRecording'),
startRecording: action('startRecording'),
});
story.add('Default', () => {
return <AudioCapture {...createProps()} />;
});
story.add('Recording', () => {
return (
<AudioCapture
{...createProps({
isRecording: true,
})}
/>
);
});
story.add('Voice Limit', () => {
return (
<AudioCapture
{...createProps({
errorDialogAudioRecorderType: ErrorDialogAudioRecorderType.Timeout,
isRecording: true,
})}
/>
);
});
story.add('Switched Apps', () => {
return (
<AudioCapture
{...createProps({
errorDialogAudioRecorderType: ErrorDialogAudioRecorderType.Blur,
isRecording: true,
})}
/>
);
});

View file

@ -0,0 +1,227 @@
// Copyright 2016-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import * as moment from 'moment';
import { noop } from 'lodash';
import { AttachmentType } from '../../types/Attachment';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { LocalizerType } from '../../types/Util';
import { ErrorDialogAudioRecorderType } from '../../state/ducks/audioRecorder';
import { ToastVoiceNoteLimit } from '../ToastVoiceNoteLimit';
import { ToastVoiceNoteMustBeOnlyAttachment } from '../ToastVoiceNoteMustBeOnlyAttachment';
import { useEscapeHandling } from '../../hooks/useEscapeHandling';
import {
getStartRecordingShortcut,
useKeyboardShortcuts,
} from '../../hooks/useKeyboardShortcuts';
type OnSendAudioRecordingType = (rec: AttachmentType) => unknown;
export type PropsType = {
cancelRecording: () => unknown;
conversationId: string;
completeRecording: (
conversationId: string,
onSendAudioRecording?: OnSendAudioRecordingType
) => unknown;
draftAttachments: ReadonlyArray<AttachmentType>;
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType;
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
i18n: LocalizerType;
isRecording: boolean;
onSendAudioRecording: OnSendAudioRecordingType;
startRecording: () => unknown;
};
enum ToastType {
VoiceNoteLimit,
VoiceNoteMustBeOnlyAttachment,
}
const START_DURATION_TEXT = '0:00';
export const AudioCapture = ({
cancelRecording,
completeRecording,
conversationId,
draftAttachments,
errorDialogAudioRecorderType,
errorRecording,
i18n,
isRecording,
onSendAudioRecording,
startRecording,
}: PropsType): JSX.Element => {
const [durationText, setDurationText] = useState<string>(START_DURATION_TEXT);
const [toastType, setToastType] = useState<ToastType | undefined>();
// Cancel recording if we switch away from this conversation, unmounting
useEffect(() => {
if (!isRecording) {
return;
}
return () => {
cancelRecording();
};
}, [cancelRecording, isRecording]);
// Stop recording and show confirmation if user switches away from this app
useEffect(() => {
if (!isRecording) {
return;
}
const handler = () => {
errorRecording(ErrorDialogAudioRecorderType.Blur);
};
window.addEventListener('blur', handler);
return () => {
window.removeEventListener('blur', handler);
};
}, [isRecording, completeRecording, errorRecording]);
const escapeRecording = useCallback(() => {
if (!isRecording) {
return;
}
cancelRecording();
}, [cancelRecording, isRecording]);
useEscapeHandling(escapeRecording);
const startRecordingShortcut = useMemo(() => {
return getStartRecordingShortcut(startRecording);
}, [startRecording]);
useKeyboardShortcuts(startRecordingShortcut);
// Update timestamp regularly, then timeout if recording goes over five minutes
useEffect(() => {
if (!isRecording) {
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
const duration = moment.duration(Date.now() - startTime, 'ms');
const minutes = `${Math.trunc(duration.asMinutes())}`;
let seconds = `${duration.seconds()}`;
if (seconds.length < 2) {
seconds = `0${seconds}`;
}
setDurationText(`${minutes}:${seconds}`);
if (duration >= moment.duration(5, 'minutes')) {
errorRecording(ErrorDialogAudioRecorderType.Timeout);
}
}, 1000);
return () => {
clearInterval(interval);
};
}, [completeRecording, errorRecording, isRecording, setDurationText]);
const clickCancel = useCallback(() => {
cancelRecording();
}, [cancelRecording]);
const clickSend = useCallback(() => {
completeRecording(conversationId, onSendAudioRecording);
}, [conversationId, completeRecording, onSendAudioRecording]);
function closeToast() {
setToastType(undefined);
}
let toastElement: JSX.Element | undefined;
if (toastType === ToastType.VoiceNoteLimit) {
toastElement = <ToastVoiceNoteLimit i18n={i18n} onClose={closeToast} />;
} else if (toastType === ToastType.VoiceNoteMustBeOnlyAttachment) {
toastElement = (
<ToastVoiceNoteMustBeOnlyAttachment i18n={i18n} onClose={closeToast} />
);
}
let confirmationDialogText: string | undefined;
if (errorDialogAudioRecorderType === ErrorDialogAudioRecorderType.Blur) {
confirmationDialogText = i18n('voiceRecordingInterruptedBlur');
} else if (
errorDialogAudioRecorderType === ErrorDialogAudioRecorderType.Timeout
) {
confirmationDialogText = i18n('voiceRecordingInterruptedMax');
}
if (isRecording && !confirmationDialogText) {
return (
<>
<div className="AudioCapture">
<button
className="AudioCapture__recorder-button AudioCapture__recorder-button--complete"
onClick={clickSend}
tabIndex={0}
title={i18n('voiceRecording--complete')}
type="button"
>
<span className="icon" />
</button>
<span className="AudioCapture__time">{durationText}</span>
<button
className="AudioCapture__recorder-button AudioCapture__recorder-button--cancel"
onClick={clickCancel}
tabIndex={0}
title={i18n('voiceRecording--cancel')}
type="button"
>
<span className="icon" />
</button>
</div>
{toastElement}
</>
);
}
return (
<>
<div className="AudioCapture">
<button
aria-label={i18n('voiceRecording--start')}
className="AudioCapture__microphone"
onClick={() => {
if (draftAttachments.length) {
setToastType(ToastType.VoiceNoteMustBeOnlyAttachment);
} else {
setDurationText(START_DURATION_TEXT);
setToastType(ToastType.VoiceNoteLimit);
startRecording();
}
}}
title={i18n('voiceRecording--start')}
type="button"
/>
{confirmationDialogText ? (
<ConfirmationDialog
i18n={i18n}
onCancel={clickCancel}
onClose={noop}
cancelText={i18n('discard')}
actions={[
{
text: i18n('sendAnyway'),
style: 'affirmative',
action: clickSend,
},
]}
>
{confirmationDialogText}
</ConfirmationDialog>
) : null}
</div>
{toastElement}
</>
);
};