diff --git a/src/components/Attachment/hooks/useAudioController.ts b/src/components/Attachment/hooks/useAudioController.ts index 206c28295..c453e7e44 100644 --- a/src/components/Attachment/hooks/useAudioController.ts +++ b/src/components/Attachment/hooks/useAudioController.ts @@ -64,6 +64,7 @@ export const useAudioController = ({ if (!audioRef.current) return; try { audioRef.current.pause(); + setIsPlaying(false); } catch (e) { registerError(new Error(t('Failed to play the recording'))); } diff --git a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx index 18b337c35..e2d8bcd39 100644 --- a/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx +++ b/src/components/MediaRecorder/AudioRecorder/AudioRecorder.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { AudioRecordingPreview } from './AudioRecordingPreview'; import { AudioRecordingInProgress } from './AudioRecordingInProgress'; import { MediaRecordingState } from '../classes'; @@ -19,6 +19,15 @@ export const AudioRecorder = () => { const isUploadingFile = recording?.$internal?.uploadState === 'uploading'; + const state = useMemo( + () => ({ + paused: recordingState === MediaRecordingState.PAUSED, + recording: recordingState === MediaRecordingState.RECORDING, + stopped: recordingState === MediaRecordingState.STOPPED, + }), + [recordingState], + ); + if (!recorder) return null; return ( @@ -26,24 +35,25 @@ export const AudioRecorder = () => {
- {recording?.asset_url ? ( + {state.stopped && recording?.asset_url ? ( - ) : ( + ) : state.paused || state.recording ? ( - )} + ) : null} - {recordingState === MediaRecordingState.PAUSED && ( + {state.paused && ( )} - {recordingState === MediaRecordingState.RECORDING && ( + {state.recording && ( )} - {recordingState === MediaRecordingState.STOPPED ? ( + {state.stopped ? ( diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js index b96edec21..ac51fb910 100644 --- a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import * as transcoder from '../../transcode'; @@ -9,6 +9,7 @@ import { ChannelStateProvider, ChatProvider, ComponentProvider, + MessageInputContextProvider, useMessageInputContext, } from '../../../../context'; import { @@ -28,13 +29,17 @@ import { MediaRecorderMock, } from '../../../../mock-builders/browser'; import { generateDataavailableEvent } from '../../../../mock-builders/browser/events/dataavailable'; +import { AudioRecorder } from '../AudioRecorder'; +import { MediaRecordingState } from '../../classes'; const PERM_DENIED_NOTIFICATION_TEXT = 'To start recording, allow the microphone access in your browser'; const START_RECORDING_AUDIO_BUTTON_TEST_ID = 'start-recording-audio-button'; -const AUDIO_RECORDER_TEST_ID = 'audio-recorder'; +const CANCEL_RECORDING_AUDIO_BUTTON_TEST_ID = 'cancel-recording-audio-button'; +const PAUSE_RECORDING_AUDIO_BUTTON_TEST_ID = 'pause-recording-audio-button'; const AUDIO_RECORDER_STOP_BTN_TEST_ID = 'audio-recorder-stop-button'; +const AUDIO_RECORDER_TEST_ID = 'audio-recorder'; const AUDIO_RECORDER_COMPLETE_BTN_TEST_ID = 'audio-recorder-complete-button'; const CSS_THEME_VERSION = '2'; @@ -62,8 +67,8 @@ const renderComponent = async ({ client, } = await initClientWithChannels(); let result; - await act(() => { - result = render( + await act(async () => { + result = await render( ({ jest.mock('fix-webm-duration', () => jest.fn((blob) => blob)); +jest.spyOn(console, 'warn').mockImplementation(); + jest .spyOn(transcoder, 'transcode') .mockImplementation((opts) => @@ -189,6 +196,44 @@ describe('MessageInput', () => { expect(screen.queryByTestId(AUDIO_RECORDER_TEST_ID)).toBeInTheDocument(); }); + it.each([MediaRecordingState.PAUSED, MediaRecordingState.RECORDING, MediaRecordingState.STOPPED])( + 'renders message composer when recording cancelled while recording in state %s', + async (state) => { + const { container } = await renderComponent(); + const Input = () => container.querySelector('.str-chat__message-input'); + await waitFor(() => { + expect(Input()).toBeInTheDocument(); + }); + + await act(() => { + fireEvent.click(screen.queryByTestId(START_RECORDING_AUDIO_BUTTON_TEST_ID)); + }); + await waitFor(() => { + expect(Input()).not.toBeInTheDocument(); + }); + + if (state === MediaRecordingState.PAUSED) { + await act(() => { + fireEvent.click(screen.queryByTestId(PAUSE_RECORDING_AUDIO_BUTTON_TEST_ID)); + }); + } else if (state === MediaRecordingState.STOPPED) { + await act(() => { + fireEvent.click(screen.queryByTestId(AUDIO_RECORDER_STOP_BTN_TEST_ID)); + }); + } + await waitFor(() => { + expect(Input()).not.toBeInTheDocument(); + }); + + await act(() => { + fireEvent.click(screen.queryByTestId(CANCEL_RECORDING_AUDIO_BUTTON_TEST_ID)); + }); + await waitFor(() => { + expect(Input()).toBeInTheDocument(); + }); + }, + ); + it('does not show RecordingPermissionDeniedNotification until start recording button clicked if microphone permission is denied', async () => { expect(screen.queryByText(PERM_DENIED_NOTIFICATION_TEXT)).not.toBeInTheDocument(); const status = new EventEmitterMock(); @@ -309,14 +354,75 @@ describe('MessageInput', () => { expect(sendMessage).not.toHaveBeenCalled(); }); }); + +const recorderMock = {}; + +const DEFAULT_RECORDING_CONTROLLER = { + completeRecording: jest.fn(), + recorder: recorderMock, + recording: undefined, + recordingState: undefined, +}; + +const renderAudioRecorder = (controller = {}) => + render( + + + + + , + ); + describe('AudioRecorder', () => { - it.todo('does not render anything if recorder is not available'); - it.todo('renders audio recording in progress UI'); - it.todo('renders audio recording paused UI when paused'); - it.todo('renders audio recording in progress UI when recording resumed'); - it.todo('renders audio recording stopped UI when stopped'); - it.todo('renders message composer when recording cancelled while recording'); - it.todo('renders message composer when recording cancelled while paused'); - it.todo('renders message composer when recording cancelled while stopped'); - it.todo('renders loading indicators while recording being uploaded'); + it('does not render anything if recorder is not available', async () => { + const { container } = await renderAudioRecorder({ recorder: undefined }); + expect(container).toBeEmpty(); + }); + + it('renders audio recording in progress UI', async () => { + const { container } = await renderAudioRecorder({ + recordingState: MediaRecordingState.RECORDING, + }); + expect(container).toMatchSnapshot(); + }); + it('renders audio recording paused UI when paused', async () => { + const { container } = await renderAudioRecorder({ + recordingState: MediaRecordingState.PAUSED, + }); + expect(container).toMatchSnapshot(); + }); + it('renders audio recording stopped UI when stopped without recording preview', async () => { + const { container } = await renderAudioRecorder({ + recordingState: MediaRecordingState.STOPPED, + }); + expect(container).toMatchSnapshot(); + }); + it('renders audio recording stopped UI with recording preview', async () => { + const { container } = await renderAudioRecorder({ + recording: generateVoiceRecordingAttachment(), + recordingState: MediaRecordingState.STOPPED, + }); + expect(container).toMatchSnapshot(); + }); + + it.each([MediaRecordingState.PAUSED, MediaRecordingState.RECORDING])( + 'does not render recording preview if %s', + async (state) => { + const { container } = await renderAudioRecorder({ + recording: generateVoiceRecordingAttachment(), + recordingState: state, + }); + expect(container).toMatchSnapshot(); + }, + ); + + it('renders loading indicators while recording being uploaded', async () => { + await renderAudioRecorder({ + recording: generateVoiceRecordingAttachment({ $internal: { uploadState: 'uploading' } }), + recordingState: MediaRecordingState.STOPPED, + }); + expect(screen.queryByTestId('loading-indicator')).toBeInTheDocument(); + }); }); diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.js b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.js new file mode 100644 index 000000000..66c618ec6 --- /dev/null +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecordingPreview.test.js @@ -0,0 +1,105 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { AudioRecordingPreview } from '../AudioRecordingPreview'; +import { ChannelActionProvider } from '../../../../context'; + +const TOGGLE_PLAY_BTN_TEST_ID = 'audio-recording-preview-toggle-play-btn'; +const PLAY_ICON_TEST_ID = 'str-chat__play-icon'; +const PAUSE_ICON_TEST_ID = 'str-chat__pause-icon'; +const WAVE_PROGRESS_BAR_TEST_ID = 'wave-progress-bar-track'; +const TIMER_CLASS_SELECTOR = '.str-chat__recording-timer'; +const WAVE_PROGRESS_BAR_INDICATOR_SELECTOR = '.str-chat__wave-progress-bar__progress-indicator'; + +const togglePlay = async () => { + await act(async () => { + await fireEvent.click(screen.queryByTestId(TOGGLE_PLAY_BTN_TEST_ID)); + }); +}; + +const defaultProps = { + durationSeconds: 5, + waveformData: [0.1, 0.2, 0.3, 0.4, 0.5], +}; + +jest.spyOn(console, 'warn').mockImplementation(() => {}); +jest + .spyOn(window.HTMLDivElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ width: defaultProps.waveformData.length, x: 0 }); +jest.spyOn(window.HTMLMediaElement.prototype, 'play').mockImplementation(() => {}); +jest.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {}); +jest + .spyOn(window.HTMLMediaElement.prototype, 'duration', 'get') + .mockReturnValue(defaultProps.durationSeconds); + +const addNotificationSpy = jest.fn(); + +class PointerEventMock extends Event { + constructor(type, { overrides, ...opts }) { + super(type, opts); + if (!overrides) return; + Object.entries(overrides).forEach(([k, v]) => { + this[k] = v; + }); + } +} + +window.PointerEvent = PointerEventMock; + +const renderComponent = (props) => + render( + + + , + ); +describe('AudioRecordingPreview', () => { + it('displays the track duration on render', () => { + const { container } = renderComponent(); + expect(container.querySelector(TIMER_CLASS_SELECTOR)).toHaveTextContent('00:05'); + }); + it('toggles the playback', async () => { + renderComponent(); + expect(screen.queryByTestId(PLAY_ICON_TEST_ID)).toBeInTheDocument(); + await act(async () => { + await togglePlay(); + }); + expect(screen.queryByTestId(PAUSE_ICON_TEST_ID)).toBeInTheDocument(); + }); + it('does not render waveform if data is unavailable', () => { + renderComponent({ waveformData: [] }); + expect(screen.queryByTestId(WAVE_PROGRESS_BAR_TEST_ID)).not.toBeInTheDocument(); + }); + it('seeks in the playback by dragging the slider', async () => { + const { container } = renderComponent(); + const slider = container.querySelector(WAVE_PROGRESS_BAR_INDICATOR_SELECTOR); + expect(slider).toHaveStyle({ left: '0%' }); + await act(() => { + fireEvent.pointerDown(screen.getByTestId(WAVE_PROGRESS_BAR_TEST_ID)); + }); + const clientX = 3; + await act(() => { + fireEvent.pointerMove(screen.getByTestId(WAVE_PROGRESS_BAR_TEST_ID), { + overrides: { + clientX, + }, + }); + }); + await act(() => { + fireEvent.pointerUp(screen.getByTestId(WAVE_PROGRESS_BAR_TEST_ID)); + }); + expect(slider).toHaveStyle({ left: '60%' }); + }); + + it('seeks in the playback by clicking on waveform', async () => { + const { container } = renderComponent(); + const clientX = 3; + const slider = container.querySelector(WAVE_PROGRESS_BAR_INDICATOR_SELECTOR); + expect(slider).toHaveStyle({ left: '0%' }); + await act(() => { + fireEvent.click(screen.getByTestId(WAVE_PROGRESS_BAR_TEST_ID), { + clientX, + }); + }); + expect(slider).toHaveStyle({ left: '60%' }); + }); +}); diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/__snapshots__/AudioRecorder.test.js.snap b/src/components/MediaRecorder/AudioRecorder/__tests__/__snapshots__/AudioRecorder.test.js.snap new file mode 100644 index 000000000..40244f0b7 --- /dev/null +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/__snapshots__/AudioRecorder.test.js.snap @@ -0,0 +1,616 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AudioRecorder does not render recording preview if paused 1`] = ` +
+
+
+ +
+ 00:00 +
+
+
+
+ + +
+
+
+`; + +exports[`AudioRecorder does not render recording preview if recording 1`] = ` +
+
+
+ +
+ 00:00 +
+
+
+
+ + +
+
+
+`; + +exports[`AudioRecorder renders audio recording in progress UI 1`] = ` +
+
+
+ +
+ 00:00 +
+
+
+
+ + +
+
+
+`; + +exports[`AudioRecorder renders audio recording paused UI when paused 1`] = ` +
+
+
+ +
+ 00:00 +
+
+
+
+ + +
+
+
+`; + +exports[`AudioRecorder renders audio recording stopped UI when stopped without recording preview 1`] = ` +
+
+
+ + +
+
+
+`; + +exports[`AudioRecorder renders audio recording stopped UI with recording preview 1`] = ` +
+
+
+ + + +
+ 00:44 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+`; diff --git a/src/components/MessageInput/icons.tsx b/src/components/MessageInput/icons.tsx index 575213757..c441bd154 100644 --- a/src/components/MessageInput/icons.tsx +++ b/src/components/MessageInput/icons.tsx @@ -227,13 +227,23 @@ export const BinIcon = () => ( ); export const PauseIcon = () => ( - + ); export const PlayIcon = () => ( - + );