From 99015d7b965dc13019efbbe80d66cef60b5e12a0 Mon Sep 17 00:00:00 2001 From: Alvaro <110414366+alvaro-signal@users.noreply.github.com> Date: Thu, 2 Mar 2023 13:55:40 -0700 Subject: [PATCH] Voice notes drafts --- _locales/en/messages.json | 8 + .../components/CompositionRecording.scss | 42 +++ .../components/CompositionRecordingDraft.scss | 17 + stylesheets/components/MessageAudio.scss | 168 +-------- stylesheets/components/PlaybackButton.scss | 84 +++++ stylesheets/components/RecordingComposer.scss | 34 ++ stylesheets/components/Waveform.scss | 62 ++++ stylesheets/components/WaveformScrubber.scss | 8 + stylesheets/manifest.scss | 6 + ts/components/CompositionArea.stories.tsx | 2 + ts/components/CompositionArea.tsx | 49 ++- .../CompositionRecording.stories.tsx | 57 +++ ts/components/CompositionRecording.tsx | 156 ++++++++ .../CompositionRecordingDraft.stories.tsx | 85 +++++ ts/components/CompositionRecordingDraft.tsx | 162 +++++++++ ts/components/MiniPlayer.stories.tsx | 2 +- ts/components/MiniPlayer.tsx | 21 +- ts/components/PlaybackButton.stories.tsx | 48 +++ ts/components/PlaybackButton.tsx | 86 +++++ ts/components/RecordingComposer.tsx | 42 +++ ts/components/VoiceNotesPlaybackContext.tsx | 7 +- .../conversation/AudioCapture.stories.tsx | 85 ----- ts/components/conversation/AudioCapture.tsx | 228 ++---------- ts/components/conversation/MessageAudio.tsx | 341 +++++------------- .../conversation/TimelineMessage.stories.tsx | 80 ++-- ts/components/conversation/Waveform.tsx | 60 +++ .../conversation/WaveformScrubber.tsx | 123 +++++++ ts/hooks/useComputePeaks.ts | 73 ++++ ts/models/conversations.ts | 9 +- ts/services/audioRecorder.ts | 1 + ts/services/globalMessageAudio.ts | 8 +- ts/state/ducks/audioPlayer.ts | 96 ++++- ts/state/ducks/audioRecorder.ts | 14 +- ts/state/ducks/composer.ts | 42 ++- ts/state/ducks/conversations.ts | 9 +- ts/state/selectors/message.ts | 1 - ts/state/smart/CompositionArea.tsx | 15 + ts/state/smart/CompositionRecording.tsx | 59 +++ ts/state/smart/CompositionRecordingDraft.tsx | 156 ++++++++ ts/state/smart/MessageAudio.tsx | 40 +- ts/state/smart/MiniPlayer.tsx | 19 +- ts/state/smart/VoiceNotesPlaybackProvider.tsx | 216 +++++++---- ts/state/smart/Waveform.tsx | 114 ++++++ ts/test-both/state/ducks/audioPlayer_test.ts | 47 ++- .../state/selectors/audioPlayer_test.ts | 3 +- ts/types/AudioRecorder.ts | 1 - ts/util/lint/exceptions.json | 35 +- ts/util/resolveDraftAttachmentOnDisk.ts | 1 + 48 files changed, 2113 insertions(+), 909 deletions(-) create mode 100644 stylesheets/components/CompositionRecording.scss create mode 100644 stylesheets/components/CompositionRecordingDraft.scss create mode 100644 stylesheets/components/PlaybackButton.scss create mode 100644 stylesheets/components/RecordingComposer.scss create mode 100644 stylesheets/components/Waveform.scss create mode 100644 stylesheets/components/WaveformScrubber.scss create mode 100644 ts/components/CompositionRecording.stories.tsx create mode 100644 ts/components/CompositionRecording.tsx create mode 100644 ts/components/CompositionRecordingDraft.stories.tsx create mode 100644 ts/components/CompositionRecordingDraft.tsx create mode 100644 ts/components/PlaybackButton.stories.tsx create mode 100644 ts/components/PlaybackButton.tsx create mode 100644 ts/components/RecordingComposer.tsx delete mode 100644 ts/components/conversation/AudioCapture.stories.tsx create mode 100644 ts/components/conversation/Waveform.tsx create mode 100644 ts/components/conversation/WaveformScrubber.tsx create mode 100644 ts/hooks/useComputePeaks.ts create mode 100644 ts/state/smart/CompositionRecording.tsx create mode 100644 ts/state/smart/CompositionRecordingDraft.tsx create mode 100644 ts/state/smart/Waveform.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 70dd4cb1de24..4691f9d04d3f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -51,6 +51,14 @@ "message": "Adding $contact$...", "description": "Shown in toast while a user is being added to a group" }, + "icu:RecordingComposer__cancel": { + "messageformat": "Cancel", + "description": "Label of cancel button on voice note recording UI" + }, + "icu:RecordingComposer__send": { + "messageformat": "Send", + "description": "Label of send button on voice note recording UI" + }, "GroupListItem__message-default": { "message": "$count$ members", "description": "Shown below the group name when selecting a group to invite a contact to" diff --git a/stylesheets/components/CompositionRecording.scss b/stylesheets/components/CompositionRecording.scss new file mode 100644 index 000000000000..fc65f7d05094 --- /dev/null +++ b/stylesheets/components/CompositionRecording.scss @@ -0,0 +1,42 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.CompositionRecording { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 18px; + + &__wave { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + border-radius: 16px; + height: 32px; + padding: 6px 12px; + + @include light-theme { + background: $color-gray-05; + } + @include dark-theme { + background: $color-gray-75; + } + } + + &__microphone { + display: inline-block; + height: 20px; + width: 20px; + @include color-svg( + '../images/icons/v2/mic-solid-24.svg', + $color-accent-red + ); + animation: pulse 2s infinite; + } + + &__timer { + min-width: 40px; + text-align: right; + } +} diff --git a/stylesheets/components/CompositionRecordingDraft.scss b/stylesheets/components/CompositionRecordingDraft.scss new file mode 100644 index 000000000000..59b1fb1a8e06 --- /dev/null +++ b/stylesheets/components/CompositionRecordingDraft.scss @@ -0,0 +1,17 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.CompositionRecordingDraft { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 18px; + + &__sizer { + // ignore the content size + // size based on the parent + flex: 1; + flex-basis: 0; + overflow: hidden; + } +} diff --git a/stylesheets/components/MessageAudio.scss b/stylesheets/components/MessageAudio.scss index bd1549b22178..4e7be5f57e32 100644 --- a/stylesheets/components/MessageAudio.scss +++ b/stylesheets/components/MessageAudio.scss @@ -9,6 +9,12 @@ $audio-attachment-button-margin-small: 4px; display: flex; flex-direction: column; margin-top: 2px; + + .PlaybackButton { + @media (min-width: 0px) and (max-width: 799px) { + margin-right: $audio-attachment-button-margin-small; + } + } } .module-message__audio-attachment__button-and-waveform { @@ -81,125 +87,6 @@ $audio-attachment-button-margin-small: 4px; } } -.module-message__audio-attachment__playback-rate-button { - @include button-reset; - @include font-body-2-bold; - - width: 38px; - height: 18px; - text-align: center; - font-weight: 700; - border-radius: 4px; - font-size: 11px; - padding: 1px 2px; - margin: -2px 0; - line-height: 16px; - letter-spacing: 0.05px; - user-select: none; - - .module-message__audio-attachment--incoming & { - @include light-theme { - color: $color-gray-60; - background: $color-black-alpha-08; - } - @include dark-theme { - color: $color-gray-25; - background: $color-white-alpha-08; - } - } - .module-message__audio-attachment--outgoing & { - color: $color-white-alpha-80; - background: $color-white-alpha-20; - } - - &::after { - content: ''; - display: inline-block; - width: 8px; - height: 8px; - margin-left: 2px; - - @mixin x-icon($color) { - @include color-svg('../images/icons/v2/x-8.svg', $color, false); - } - - .module-message__audio-attachment--incoming & { - @include light-theme { - @include x-icon($color-gray-60); - } - @include dark-theme { - @include x-icon($color-gray-25); - } - } - .module-message__audio-attachment--outgoing & { - @include x-icon($color-white-alpha-80); - } - } -} - -.module-message__audio-attachment__play-button, -.module-message__audio-attachment__spinner { - @include button-reset; - - flex-shrink: 0; - width: $audio-attachment-button-size; - height: $audio-attachment-button-size; - margin-right: $audio-attachment-button-margin-big; - - outline: none; - border-radius: 18px; - - @media (min-width: 0px) and (max-width: 799px) { - margin-right: $audio-attachment-button-margin-small; - } - - &::before { - display: block; - height: 100%; - content: ''; - } - - @mixin audio-icon($name, $icon, $color) { - &--#{$name}::before { - @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 { - cursor: auto; - } - - &--pending::before { - animation: rotate 1000ms linear infinite; - } - - .module-message__audio-attachment--incoming & { - @include light-theme { - background: $color-white; - - @include all-audio-icons($color-gray-60); - } - - @include dark-theme { - background: $color-gray-60; - - @include all-audio-icons($color-gray-15); - } - } - - .module-message__audio-attachment--outgoing & { - background: $color-white-alpha-20; - @include all-audio-icons($color-white); - } -} - .module-message__audio-attachment__waveform { flex-shrink: 0; @@ -210,9 +97,8 @@ $audio-attachment-button-margin-small: 4px; outline: 0; } -.module-message__audio-attachment__play-button, -.module-message__audio-attachment__playback-rate-button, -.module-message__audio-attachment__spinner, +.PlaybackButton, +.PlaybackRateButton, .module-message__audio-attachment__waveform { &:focus { @include keyboard-mode { @@ -229,44 +115,6 @@ $audio-attachment-button-margin-small: 4px; } } -.module-message__audio-attachment__waveform__bar { - display: inline-block; - - width: 2px; - border-radius: 2px; - transition: height 250ms, background 250ms; - - &:not(:first-of-type) { - margin-left: 2px; - } - - .module-message__audio-attachment--incoming & { - @include light-theme { - background: $color-black-alpha-40; - - &--active { - background: $color-black-alpha-80; - } - } - - @include dark-theme { - background: $color-white-alpha-40; - - &--active { - background: $color-white-alpha-70; - } - } - } - - .module-message__audio-attachment--outgoing & { - background: $color-white-alpha-40; - - &--active { - background: $color-white-alpha-80; - } - } -} - .module-message__audio-attachment__metadata { display: flex; flex-direction: row; diff --git a/stylesheets/components/PlaybackButton.scss b/stylesheets/components/PlaybackButton.scss new file mode 100644 index 000000000000..722f63ff72fb --- /dev/null +++ b/stylesheets/components/PlaybackButton.scss @@ -0,0 +1,84 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.PlaybackButton { + @include button-reset; + + flex-shrink: 0; + margin-right: $audio-attachment-button-margin-big; + + outline: none; + border-radius: 18px; + + &::before { + display: block; + height: 100%; + content: ''; + } + + @mixin audio-icon($name, $icon, $color) { + &.PlaybackButton--#{$name}::before { + @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); + } + + &--variant-message { + width: $audio-attachment-button-size; + height: $audio-attachment-button-size; + } + + &--variant-mini { + &::before { + -webkit-mask-size: 100% !important; + } + width: 14px; + height: 14px; + } + &--variant-draft { + &::before { + -webkit-mask-size: 100% !important; + } + width: 18px; + height: 18px; + } + + &--pending { + cursor: auto; + } + + &--pending::before { + animation: rotate 1000ms linear infinite; + } + + @include light-theme { + &--context-incoming { + &.PlaybackButton--variant-message { + background: $color-white; + } + } + @include all-audio-icons($color-gray-60); + } + + @include dark-theme { + &--context-incoming { + &.PlaybackButton--variant-message { + background: $color-gray-60; + } + } + @include all-audio-icons($color-gray-15); + } + + &--context-outgoing { + &.PlaybackButton--variant-message { + background: $color-white-alpha-20; + } + @include all-audio-icons($color-white); + } +} diff --git a/stylesheets/components/RecordingComposer.scss b/stylesheets/components/RecordingComposer.scss new file mode 100644 index 000000000000..2d83419f0cb5 --- /dev/null +++ b/stylesheets/components/RecordingComposer.scss @@ -0,0 +1,34 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.RecordingComposer { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 18px; + + &__content { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + border-radius: 16px; + height: 32px; + padding: 6px 12px; + overflow: hidden; + + @include light-theme { + background: $color-gray-05; + } + @include dark-theme { + background: $color-gray-75; + } + } + + &__button { + font-size: 13px; + min-width: 76px; + line-height: 18px; + padding: 5px 16px; + } +} diff --git a/stylesheets/components/Waveform.scss b/stylesheets/components/Waveform.scss new file mode 100644 index 000000000000..5583a64731a5 --- /dev/null +++ b/stylesheets/components/Waveform.scss @@ -0,0 +1,62 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.Waveform { + flex-shrink: 0; + + display: flex; + align-items: center; + cursor: pointer; + + outline: 0; + + &__bar { + display: inline-block; + + width: 2px; + border-radius: 2px; + transition: height 250ms, background 250ms; + + &:not(:first-of-type) { + margin-left: 2px; + } + + @include light-theme { + background: $color-black-alpha-40; + + &--active { + background: $color-black-alpha-80; + } + } + + @include dark-theme { + background: $color-white-alpha-40; + + &--active { + background: $color-white-alpha-80; + } + } + + .module-message__audio-attachment--incoming & { + @include light-theme { + &--active { + background: $color-black-alpha-80; + } + } + + @include dark-theme { + &--active { + background: $color-white-alpha-70; + } + } + } + + .module-message__audio-attachment--outgoing & { + background: $color-white-alpha-40; + + &--active { + background: $color-white-alpha-80; + } + } + } +} diff --git a/stylesheets/components/WaveformScrubber.scss b/stylesheets/components/WaveformScrubber.scss new file mode 100644 index 000000000000..d3fc2b462816 --- /dev/null +++ b/stylesheets/components/WaveformScrubber.scss @@ -0,0 +1,8 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.WaveformScrubber { + display: flex; + flex: 1; + flex-basis: 0; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 97dc696775d6..cbff6336b0c9 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -51,6 +51,8 @@ @import './components/Checkbox.scss'; @import './components/CircleCheckbox.scss'; @import './components/CompositionArea.scss'; +@import './components/CompositionRecording.scss'; +@import './components/CompositionRecordingDraft.scss'; @import './components/CompositionInput.scss'; @import './components/CompositionTextArea.scss'; @import './components/ContactModal.scss'; @@ -100,11 +102,13 @@ @import './components/MyStories.scss'; @import './components/OutgoingGiftBadgeModal.scss'; @import './components/PermissionsPopup.scss'; +@import './components/PlaybackButton.scss'; @import './components/PlaybackRateButton.scss'; @import './components/Preferences.scss'; @import './components/ProfileEditor.scss'; @import './components/Quote.scss'; @import './components/ReactionPickerPicker.scss'; +@import './components/RecordingComposer.scss'; @import './components/SafetyNumberChangeDialog.scss'; @import './components/SafetyNumberViewer.scss'; @import './components/ScrollDownButton.scss'; @@ -136,5 +140,7 @@ @import './components/TimelineWarnings.scss'; @import './components/TitleBarContainer.scss'; @import './components/Toast.scss'; +@import './components/Waveform.scss'; +@import './components/WaveformScrubber.scss'; @import './components/UsernameOnboardingModalBody.scss'; @import './components/WhatsNew.scss'; diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index bff2499c6a5a..067815d75cce 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -126,6 +126,8 @@ const useProps = (overrideProps: Partial = {}): Props => ({ // SMS-only isSMSOnly: overrideProps.isSMSOnly || false, isFetchingUUID: overrideProps.isFetchingUUID || false, + renderSmartCompositionRecording: _ =>
RECORDING
, + renderSmartCompositionRecordingDraft: _ =>
RECORDING DRAFT
, }); export function Default(): JSX.Element { diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 052761682583..2a3a9c0ad06a 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -37,7 +37,7 @@ import type { AttachmentDraftType, InMemoryAttachmentDraftType, } from '../types/Attachment'; -import { isImageAttachment } from '../types/Attachment'; +import { isImageAttachment, isVoiceMessage } from '../types/Attachment'; import { AudioCapture } from './conversation/AudioCapture'; import { CompositionUpload } from './CompositionUpload'; import type { @@ -62,7 +62,9 @@ import { isImageTypeSupported } from '../util/GoogleChrome'; import * as KeyboardLayout from '../services/keyboardLayout'; import { usePrevious } from '../hooks/usePrevious'; import { PanelType } from '../types/Panels'; +import type { SmartCompositionRecordingDraftProps } from '../state/smart/CompositionRecordingDraft'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; +import type { SmartCompositionRecordingProps } from '../state/smart/CompositionRecording'; export type OwnProps = Readonly<{ acceptedMessageRequest?: boolean; @@ -77,7 +79,7 @@ export type OwnProps = Readonly<{ cancelRecording: () => unknown; completeRecording: ( conversationId: string, - onSendAudioRecording?: (rec: InMemoryAttachmentDraftType) => unknown + onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown ) => unknown; conversationId: string; uuid?: string; @@ -138,6 +140,12 @@ export type OwnProps = Readonly<{ showConversation: ShowConversationType; startRecording: (id: string) => unknown; theme: ThemeType; + renderSmartCompositionRecording: ( + props: SmartCompositionRecordingProps + ) => JSX.Element; + renderSmartCompositionRecordingDraft: ( + props: SmartCompositionRecordingDraftProps + ) => JSX.Element | null; }>; export type Props = Pick< @@ -196,10 +204,6 @@ export function CompositionArea({ draftAttachments, onClearAttachments, // AudioCapture - cancelRecording, - completeRecording, - errorDialogAudioRecorderType, - errorRecording, recordingState, startRecording, // StagedLinkPreview @@ -266,7 +270,9 @@ export function CompositionArea({ // SMS-only contacts isSMSOnly, isFetchingUUID, -}: Props): JSX.Element { + renderSmartCompositionRecording, + renderSmartCompositionRecordingDraft, +}: Props): JSX.Element | null { const [dirty, setDirty] = useState(false); const [large, setLarge] = useState(false); const [attachmentToEdit, setAttachmentToEdit] = useState< @@ -418,20 +424,9 @@ export function CompositionArea({ const micButtonFragment = shouldShowMicrophone ? (
{ - emojiButtonRef.current?.close(); - sendMultiMediaMessage(conversationId, { voiceNoteAttachment }); - }} startRecording={startRecording} />
@@ -517,6 +512,10 @@ export function CompositionArea({ }; }, [setLarge]); + const handleRecordingBeforeSend = useCallback(() => { + emojiButtonRef.current?.close(); + }, [emojiButtonRef]); + const clearQuote = useCallback(() => { if (quotedMessageId) { setQuoteByMessageId(conversationId, undefined); @@ -633,6 +632,20 @@ export function CompositionArea({ ); } + if (isRecording) { + return renderSmartCompositionRecording({ + onBeforeSend: handleRecordingBeforeSend, + }); + } + + if (draftAttachments.length === 1 && isVoiceMessage(draftAttachments[0])) { + const voiceNoteAttachment = draftAttachments[0]; + + if (!voiceNoteAttachment.pending && voiceNoteAttachment.url) { + return renderSmartCompositionRecordingDraft({ voiceNoteAttachment }); + } + } + return (
{attachmentToEdit && diff --git a/ts/components/CompositionRecording.stories.tsx b/ts/components/CompositionRecording.stories.tsx new file mode 100644 index 000000000000..d537c39447fd --- /dev/null +++ b/ts/components/CompositionRecording.stories.tsx @@ -0,0 +1,57 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { CompositionRecording } from './CompositionRecording'; + +import { setupI18n } from '../util/setupI18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'components/CompositionRecording', + component: CompositionRecording, +}; + +export function Default(): JSX.Element { + const [active, setActive] = useState(false); + + const cancel = action('cancel'); + const send = action('send'); + + const handleActivate = () => { + setActive(true); + }; + + const handleCancel = () => { + cancel(); + setActive(false); + }; + const handleSend = () => { + send(); + setActive(false); + }; + + return ( + <> + {!active && ( + + )} + {active && ( + action('error')()} + addAttachment={action('addAttachment')} + completeRecording={action('completeRecording')} + /> + )} + + ); +} diff --git a/ts/components/CompositionRecording.tsx b/ts/components/CompositionRecording.tsx new file mode 100644 index 000000000000..55498cf05bea --- /dev/null +++ b/ts/components/CompositionRecording.tsx @@ -0,0 +1,156 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { noop } from 'lodash'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useEscapeHandling } from '../hooks/useEscapeHandling'; +import { usePrevious } from '../hooks/usePrevious'; +import type { InMemoryAttachmentDraftType } from '../types/Attachment'; +import { ErrorDialogAudioRecorderType } from '../types/AudioRecorder'; +import type { LocalizerType } from '../types/Util'; +import { DurationInSeconds, SECOND } from '../util/durations'; +import { durationToPlaybackText } from '../util/durationToPlaybackText'; +import { ConfirmationDialog } from './ConfirmationDialog'; +import { RecordingComposer } from './RecordingComposer'; +import { ToastVoiceNoteLimit } from './ToastVoiceNoteLimit'; + +export type Props = { + i18n: LocalizerType; + conversationId: string; + onCancel: () => void; + onSend: () => void; + errorRecording: (e: ErrorDialogAudioRecorderType) => unknown; + errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; + addAttachment: ( + conversationId: string, + attachment: InMemoryAttachmentDraftType + ) => unknown; + completeRecording: ( + conversationId: string, + onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown + ) => unknown; +}; + +export function CompositionRecording({ + i18n, + conversationId, + onCancel, + onSend, + errorRecording, + errorDialogAudioRecorderType, + addAttachment, + completeRecording, +}: Props): JSX.Element { + useEscapeHandling(onCancel); + + const [showVoiceNoteLimitToast, setShowVoiceNoteLimitToast] = useState(true); + + // when interrupted (blur, switching convos) + // stop recording and save draft + const handleRecordingInterruption = useCallback(() => { + completeRecording(conversationId, attachment => { + addAttachment(conversationId, attachment); + }); + }, [conversationId, completeRecording, addAttachment]); + + // switched to another app + useEffect(() => { + window.addEventListener('blur', handleRecordingInterruption); + return () => { + window.removeEventListener('blur', handleRecordingInterruption); + }; + }, [handleRecordingInterruption]); + + // switched conversations + const previousConversationId = usePrevious(conversationId, conversationId); + useEffect(() => { + if (previousConversationId !== conversationId) { + handleRecordingInterruption(); + } + }); + + const handleCloseToast = useCallback(() => { + setShowVoiceNoteLimitToast(false); + }, []); + + useEffect(() => { + return () => { + handleCloseToast(); + }; + }, [handleCloseToast]); + + const startTime = useRef(Date.now()); + const [duration, setDuration] = useState(0); + const drift = useRef(0); + + // update recording duration + useEffect(() => { + const timeoutId = setTimeout(() => { + const now = Date.now(); + const newDurationMs = now - startTime.current; + drift.current = newDurationMs % SECOND; + setDuration(newDurationMs / SECOND); + + if ( + DurationInSeconds.fromMillis(newDurationMs) >= DurationInSeconds.HOUR + ) { + errorRecording(ErrorDialogAudioRecorderType.Timeout); + } + }, SECOND - drift.current); + + return () => { + clearTimeout(timeoutId); + }; + }, [duration, errorRecording]); + + let confirmationDialog: JSX.Element | undefined; + if (errorDialogAudioRecorderType === ErrorDialogAudioRecorderType.Timeout) { + confirmationDialog = ( + + {i18n('voiceRecordingInterruptedMax')} + + ); + } else if ( + errorDialogAudioRecorderType === ErrorDialogAudioRecorderType.ErrorRecording + ) { + confirmationDialog = ( + + {i18n('voiceNoteError')} + + ); + } + + return ( + +
+
+ {durationToPlaybackText(duration)} +
+ + {confirmationDialog} + {showVoiceNoteLimitToast && ( + + )} + + ); +} diff --git a/ts/components/CompositionRecordingDraft.stories.tsx b/ts/components/CompositionRecordingDraft.stories.tsx new file mode 100644 index 000000000000..10d966b5a67e --- /dev/null +++ b/ts/components/CompositionRecordingDraft.stories.tsx @@ -0,0 +1,85 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { CompositionRecordingDraft } from './CompositionRecordingDraft'; + +import { setupI18n } from '../util/setupI18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'components/CompositionRecordingDraft', + component: CompositionRecordingDraft, +}; + +export function Default(): JSX.Element { + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = React.useState(0); + const [duration, setDuration] = React.useState(undefined); + + const audio = React.useMemo(() => { + const a = new Audio(); + + a.addEventListener('loadedmetadata', () => { + setDuration(duration); + }); + + a.src = '/fixtures/incompetech-com-Agnus-Dei-X.mp3'; + + a.addEventListener('timeupdate', () => { + setCurrentTime(a.currentTime); + }); + + a.addEventListener('ended', () => { + setIsPlaying(false); + setCurrentTime(0); + }); + + a.addEventListener('loadeddata', () => { + a.currentTime = currentTime; + }); + + return a; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handlePlay = (positionAsRatio?: number) => { + if (positionAsRatio !== undefined) { + audio.currentTime = positionAsRatio * audio.duration; + } + void audio.play(); + setCurrentTime(audio.currentTime); + setIsPlaying(true); + }; + + const handlePause = () => { + audio.pause(); + setIsPlaying(false); + }; + + const handleScrub = (newPosition: number) => { + if (duration !== undefined) { + audio.currentTime = newPosition * duration; + } + }; + + return ( + + ); +} diff --git a/ts/components/CompositionRecordingDraft.tsx b/ts/components/CompositionRecordingDraft.tsx new file mode 100644 index 000000000000..b5f15ddf1a91 --- /dev/null +++ b/ts/components/CompositionRecordingDraft.tsx @@ -0,0 +1,162 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState, useCallback, useRef } from 'react'; +import type { ContentRect } from 'react-measure'; +import Measure from 'react-measure'; +import { useComputePeaks } from '../hooks/useComputePeaks'; +import type { LocalizerType } from '../types/Util'; +import { WaveformScrubber } from './conversation/WaveformScrubber'; +import { PlaybackButton } from './PlaybackButton'; +import { RecordingComposer } from './RecordingComposer'; +import * as log from '../logging/log'; + +type Props = { + i18n: LocalizerType; + audioUrl: string | undefined; + active: + | { + playing: boolean; + duration: number | undefined; + currentTime: number; + } + | undefined; + onCancel: () => void; + onSend: () => void; + onPlay: (positionAsRatio?: number) => void; + onPause: () => void; + onScrub: (positionAsRatio: number) => void; +}; + +export function CompositionRecordingDraft({ + i18n, + audioUrl, + active, + onCancel, + onSend, + onPlay, + onPause, + onScrub, +}: Props): JSX.Element { + const [state, setState] = useState<{ + calculatingWidth: boolean; + width: undefined | number; + }>({ calculatingWidth: false, width: undefined }); + + const timeout = useRef(undefined); + + const handleResize = useCallback( + ({ bounds }: ContentRect) => { + if (!bounds || bounds.width === state.width) { + return; + } + + if (!state.calculatingWidth) { + setState({ ...state, calculatingWidth: true }); + } + + if (timeout.current) { + clearTimeout(timeout.current); + } + + const newWidth = bounds.width; + + // if mounting, set width immediately + // otherwise debounce + if (state.width === undefined) { + setState({ calculatingWidth: false, width: newWidth }); + } else { + timeout.current = setTimeout(() => { + setState({ calculatingWidth: false, width: newWidth }); + }, 500); + } + }, + [state] + ); + + const handlePlaybackClick = useCallback(() => { + if (active?.playing) { + onPause(); + } else { + onPlay(); + } + }, [active, onPause, onPlay]); + + const scrubber = ( + + ); + + return ( + + + + {({ measureRef }) => ( +
+ {scrubber} +
+ )} +
+
+ ); +} + +type SizedWaveformScrubberProps = { + i18n: LocalizerType; + audioUrl: string | undefined; + // undefined if we don't have a size yet + width: number | undefined; + // defined if we are playing + activeDuration: number | undefined; + currentTime: number; + onScrub: (progressAsRatio: number) => void; + onClick: (progressAsRatio: number) => void; +}; +function SizedWaveformScrubber({ + i18n, + audioUrl, + activeDuration, + currentTime, + onClick, + onScrub, + width, +}: SizedWaveformScrubberProps) { + const handleCorrupted = () => { + log.warn('SizedWaveformScrubber: audio corrupted'); + }; + const { peaks, duration } = useComputePeaks({ + audioUrl, + activeDuration, + onCorrupted: handleCorrupted, + barCount: Math.floor((width ?? 800) / 4), + }); + + return ( + + ); +} diff --git a/ts/components/MiniPlayer.stories.tsx b/ts/components/MiniPlayer.stories.tsx index f85f7f2741b4..7c5e58cc89c0 100644 --- a/ts/components/MiniPlayer.stories.tsx +++ b/ts/components/MiniPlayer.stories.tsx @@ -17,7 +17,7 @@ export default { component: MiniPlayer, }; -export function Basic(): JSX.Element { +export function Default(): JSX.Element { const [active, setActive] = useState(false); const [playerState, setPlayerState] = useState(PlayerState.loading); diff --git a/ts/components/MiniPlayer.tsx b/ts/components/MiniPlayer.tsx index 185ffb4b2524..7530d7d69d65 100644 --- a/ts/components/MiniPlayer.tsx +++ b/ts/components/MiniPlayer.tsx @@ -1,11 +1,11 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import classNames from 'classnames'; import React, { useCallback } from 'react'; import type { LocalizerType } from '../types/Util'; import { durationToPlaybackText } from '../util/durationToPlaybackText'; import { Emojify } from './conversation/Emojify'; +import { PlaybackButton } from './PlaybackButton'; import { PlaybackRateButton } from './PlaybackRateButton'; export enum PlayerState { @@ -60,15 +60,19 @@ export function MiniPlayer({ }, [state, onPause, onPlay]); let label: string | undefined; + let mod: 'play' | 'pause' | 'pending'; switch (state) { case PlayerState.playing: label = i18n('MessageAudio--pause'); + mod = 'pause'; break; case PlayerState.paused: label = i18n('MessageAudio--play'); + mod = 'play'; break; case PlayerState.loading: label = i18n('MessageAudio--pending'); + mod = 'pending'; break; default: throw new TypeError(`Missing case ${state}`); @@ -76,17 +80,12 @@ export function MiniPlayer({ return (
- + +
+ ); +} diff --git a/ts/components/VoiceNotesPlaybackContext.tsx b/ts/components/VoiceNotesPlaybackContext.tsx index 790d72ddd57b..07e86a986751 100644 --- a/ts/components/VoiceNotesPlaybackContext.tsx +++ b/ts/components/VoiceNotesPlaybackContext.tsx @@ -81,7 +81,8 @@ async function doComputePeaks( url: string, barCount: number ): Promise { - const existing = waveformCache.get(url); + const cacheKey = `${url}:${barCount}`; + const existing = waveformCache.get(cacheKey); if (existing) { log.info('GlobalAudioContext: waveform cache hit', url); return Promise.resolve(existing); @@ -101,7 +102,7 @@ async function doComputePeaks( `GlobalAudioContext: audio ${url} duration ${duration}s is too long` ); const emptyResult = { peaks, duration }; - waveformCache.set(url, emptyResult); + waveformCache.set(cacheKey, emptyResult); return emptyResult; } @@ -143,7 +144,7 @@ async function doComputePeaks( } const result = { peaks, duration }; - waveformCache.set(url, result); + waveformCache.set(cacheKey, result); return result; } diff --git a/ts/components/conversation/AudioCapture.stories.tsx b/ts/components/conversation/AudioCapture.stories.tsx deleted file mode 100644 index 6eaaa1b8aa08..000000000000 --- a/ts/components/conversation/AudioCapture.stories.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; - -import { action } from '@storybook/addon-actions'; -import { select } from '@storybook/addon-knobs'; - -import { - ErrorDialogAudioRecorderType, - RecordingState, -} from '../../types/AudioRecorder'; -import type { PropsType } from './AudioCapture'; -import { AudioCapture } from './AudioCapture'; -import { setupI18n } from '../../util/setupI18n'; -import enMessages from '../../../_locales/en/messages.json'; - -const i18n = setupI18n('en', enMessages); - -export default { - title: 'Components/Conversation/AudioCapture', -}; - -const createProps = (overrideProps: Partial = {}): PropsType => ({ - cancelRecording: action('cancelRecording'), - completeRecording: action('completeRecording'), - conversationId: '123', - draftAttachments: [], - errorDialogAudioRecorderType: overrideProps.errorDialogAudioRecorderType, - errorRecording: action('errorRecording'), - i18n, - recordingState: select( - 'recordingState', - RecordingState, - overrideProps.recordingState || RecordingState.Idle - ), - onSendAudioRecording: action('onSendAudioRecording'), - startRecording: action('startRecording'), -}); - -export function Default(): JSX.Element { - return ; -} - -export const _Initializing = (): JSX.Element => { - return ( - - ); -}; - -export const _Recording = (): JSX.Element => { - return ( - - ); -}; - -export function VoiceLimit(): JSX.Element { - return ( - - ); -} - -export function SwitchedApps(): JSX.Element { - return ( - - ); -} diff --git a/ts/components/conversation/AudioCapture.tsx b/ts/components/conversation/AudioCapture.tsx index 5ba24412d375..35596b1e561b 100644 --- a/ts/components/conversation/AudioCapture.tsx +++ b/ts/components/conversation/AudioCapture.tsx @@ -1,100 +1,30 @@ // Copyright 2016 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useEffect, useState } from 'react'; -import * as moment from 'moment'; -import { noop } from 'lodash'; +import React, { useCallback, useState } from 'react'; -import type { - AttachmentDraftType, - InMemoryAttachmentDraftType, -} from '../../types/Attachment'; -import { ConfirmationDialog } from '../ConfirmationDialog'; +import type { AttachmentDraftType } from '../../types/Attachment'; import type { LocalizerType } from '../../types/Util'; -import { ToastVoiceNoteLimit } from '../ToastVoiceNoteLimit'; import { ToastVoiceNoteMustBeOnlyAttachment } from '../ToastVoiceNoteMustBeOnlyAttachment'; -import { useEscapeHandling } from '../../hooks/useEscapeHandling'; import { useStartRecordingShortcut, useKeyboardShortcuts, } from '../../hooks/useKeyboardShortcuts'; -import { - ErrorDialogAudioRecorderType, - RecordingState, -} from '../../types/AudioRecorder'; - -type OnSendAudioRecordingType = (rec: InMemoryAttachmentDraftType) => unknown; export type PropsType = { - cancelRecording: () => unknown; conversationId: string; - completeRecording: ( - conversationId: string, - onSendAudioRecording?: OnSendAudioRecordingType - ) => unknown; draftAttachments: ReadonlyArray; - errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; - errorRecording: (e: ErrorDialogAudioRecorderType) => unknown; i18n: LocalizerType; - recordingState: RecordingState; - onSendAudioRecording: OnSendAudioRecordingType; startRecording: (id: string) => unknown; }; -enum ToastType { - VoiceNoteLimit, - VoiceNoteMustBeOnlyAttachment, -} - -const START_DURATION_TEXT = '0:00'; - export function AudioCapture({ - cancelRecording, - completeRecording, conversationId, draftAttachments, - errorDialogAudioRecorderType, - errorRecording, i18n, - recordingState, - onSendAudioRecording, startRecording, }: PropsType): JSX.Element { - const [durationText, setDurationText] = useState(START_DURATION_TEXT); - const [toastType, setToastType] = useState(); - - // Cancel recording if we switch away from this conversation, unmounting - useEffect(() => { - return () => { - cancelRecording(); - }; - }, [cancelRecording]); - - // Stop recording and show confirmation if user switches away from this app - useEffect(() => { - if (recordingState !== RecordingState.Recording) { - return; - } - - const handler = () => { - errorRecording(ErrorDialogAudioRecorderType.Blur); - }; - window.addEventListener('blur', handler); - - return () => { - window.removeEventListener('blur', handler); - }; - }, [recordingState, completeRecording, errorRecording]); - - const escapeRecording = useCallback(() => { - if (recordingState !== RecordingState.Recording) { - return; - } - - cancelRecording(); - }, [cancelRecording, recordingState]); - - useEscapeHandling(escapeRecording); + const [showOnlyAttachmentToast, setShowOnlyAttachmentToast] = useState(false); const recordConversation = useCallback( () => startRecording(conversationId), @@ -103,156 +33,40 @@ export function AudioCapture({ const startRecordingShortcut = useStartRecordingShortcut(recordConversation); useKeyboardShortcuts(startRecordingShortcut); - const closeToast = useCallback(() => { - setToastType(undefined); + const handleCloseToast = useCallback(() => { + setShowOnlyAttachmentToast(false); }, []); - // Update timestamp regularly, then timeout if recording goes over five minutes - useEffect(() => { - if (recordingState !== RecordingState.Recording) { - return; + const handleClick = useCallback(() => { + if (draftAttachments.length) { + setShowOnlyAttachmentToast(true); + } else { + startRecording(conversationId); } - - setDurationText(START_DURATION_TEXT); - setToastType(ToastType.VoiceNoteLimit); - - 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(1, 'hours')) { - errorRecording(ErrorDialogAudioRecorderType.Timeout); - } - }, 1000); - - return () => { - clearInterval(interval); - closeToast(); - }; }, [ - closeToast, - completeRecording, - errorRecording, - recordingState, - setDurationText, + conversationId, + draftAttachments, + setShowOnlyAttachmentToast, + startRecording, ]); - const clickCancel = useCallback(() => { - cancelRecording(); - }, [cancelRecording]); - - const clickSend = useCallback(() => { - completeRecording(conversationId, onSendAudioRecording); - }, [conversationId, completeRecording, onSendAudioRecording]); - - let toastElement: JSX.Element | undefined; - if (toastType === ToastType.VoiceNoteLimit) { - toastElement = ; - } else if (toastType === ToastType.VoiceNoteMustBeOnlyAttachment) { - toastElement = ( - - ); - } - - let confirmationDialog: JSX.Element | undefined; - if ( - errorDialogAudioRecorderType === ErrorDialogAudioRecorderType.Blur || - errorDialogAudioRecorderType === ErrorDialogAudioRecorderType.Timeout - ) { - const confirmationDialogText = - errorDialogAudioRecorderType === ErrorDialogAudioRecorderType.Blur - ? i18n('voiceRecordingInterruptedBlur') - : i18n('voiceRecordingInterruptedMax'); - - confirmationDialog = ( - - {confirmationDialogText} - - ); - } else if ( - errorDialogAudioRecorderType === ErrorDialogAudioRecorderType.ErrorRecording - ) { - confirmationDialog = ( - - {i18n('voiceNoteError')} - - ); - } - - if (recordingState === RecordingState.Recording && !confirmationDialog) { - return ( - <> -
- - {durationText} - -
- {toastElement} - - ); - } - return ( <>
- {toastElement} + {showOnlyAttachmentToast && ( + + )} ); } diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index ba1662eaf7ed..88c2486d3e41 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useRef, useEffect, useState } from 'react'; +import React, { useCallback } from 'react'; import type { RefObject } from 'react'; import classNames from 'classnames'; import { noop } from 'lodash'; @@ -18,6 +18,9 @@ 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'; export type OwnProps = Readonly<{ @@ -58,15 +61,6 @@ export type DispatchProps = Readonly<{ export type Props = OwnProps & DispatchProps; -type ButtonProps = { - mod?: string; - label: string; - visible?: boolean; - onClick: () => void; - onMouseDown?: () => void; - onMouseUp?: () => void; -}; - enum State { NotDownloaded = 'NotDownloaded', Pending = 'Pending', @@ -82,12 +76,6 @@ const BAR_NOT_DOWNLOADED_HEIGHT = 2; const BAR_MIN_HEIGHT = 4; const BAR_MAX_HEIGHT = 20; -const REWIND_BAR_COUNT = 2; - -// Increments for keyboard audio seek (in seconds) -const SMALL_INCREMENT = 1; -const BIG_INCREMENT = 5; - const SPRING_CONFIG = { mass: 0.5, tension: 350, @@ -97,62 +85,6 @@ const SPRING_CONFIG = { const DOT_DIV_WIDTH = 14; -/** Handles animations, key events, and stopping event propagation */ -const PlaybackButton = React.forwardRef( - function ButtonInner(props, ref) { - const { mod, label, onClick, visible = true } = props; - const [animProps] = useSpring( - { - config: SPRING_CONFIG, - to: { scale: visible ? 1 : 0 }, - }, - [visible] - ); - - // Clicking button toggle playback - const onButtonClick = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - - onClick(); - }, - [onClick] - ); - - // Keyboard playback toggle - const onButtonKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== 'Space') { - return; - } - event.stopPropagation(); - event.preventDefault(); - - onClick(); - }, - [onClick] - ); - - return ( - -