Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: async voice messages recording #2339

Merged
merged 38 commits into from
Apr 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
90aa6e7
feat: add audio recording
MartinCupela Mar 22, 2024
274d8d2
Merge branch 'master' into feat/async-voice-messages-recording
MartinCupela Mar 22, 2024
7ed9fff
fix: fix import
MartinCupela Mar 22, 2024
b54eb17
feat: add transcoder
MartinCupela Mar 26, 2024
1cdb3ab
fix: use pointer events to handle audio drag seek
MartinCupela Mar 27, 2024
8c2ebaf
refactor: remove transcoding
MartinCupela Mar 27, 2024
682c3ce
refactor: rename var
MartinCupela Mar 27, 2024
05d1b75
feat: add audio transcoding to mp3 and wav
MartinCupela Apr 3, 2024
6757853
Merge branch 'master' into feat/async-voice-messages-recording
MartinCupela Apr 3, 2024
0899a57
refactor: move media recording functionality to MediaRecorder folder
MartinCupela Apr 3, 2024
386e00e
refactor: move media recording functionality to MediaRecorderControll…
MartinCupela Apr 9, 2024
03d6c4c
refactor: extract amplitude recording to AmplitudeRecorder
MartinCupela Apr 9, 2024
7224908
refactor: extract disposeOfMediaStream to global function
MartinCupela Apr 9, 2024
38636f2
refactor: remove AttachmentUploadState enum & allow to disable media …
MartinCupela Apr 10, 2024
7ec0b73
refactor: move RecordingAttachmentType to MediaRecorderController.ts
MartinCupela Apr 10, 2024
4b7e429
refactor: move uploadRecording from useMediaRecorder to useAttachments
MartinCupela Apr 11, 2024
b8113fb
test: update AttachmentPreviewList tests
MartinCupela Apr 11, 2024
86cf4c2
test: fix Attachment tests
MartinCupela Apr 12, 2024
1d9ea1d
refactor: change utils file extension
MartinCupela Apr 12, 2024
fc33659
test: add missing snapshots
MartinCupela Apr 12, 2024
bcb3cba
test: move audio sampling tests to dedicated file
MartinCupela Apr 12, 2024
83d8680
refactor: convert MediaRecorderController methods to arrow functions
MartinCupela Apr 12, 2024
f78ef71
fix: transcode only audios other than mp4
MartinCupela Apr 12, 2024
dab90e2
fix: close audio context
MartinCupela Apr 12, 2024
3577a09
fix: throw error when recorded media type is video
MartinCupela Apr 12, 2024
3c75ff9
fix: provide a more general types for attachment type inferring funct…
MartinCupela Apr 12, 2024
2d17bb7
fix: close AudioContext when not closed
MartinCupela Apr 12, 2024
341c260
test: add MediaRecorderController tests
MartinCupela Apr 16, 2024
484e497
test: add MediaRecorderController tests
MartinCupela Apr 16, 2024
4189aa7
test: add more MediaRecorder tests
MartinCupela Apr 17, 2024
d6955c4
test: add more MediaRecorder tests
MartinCupela Apr 17, 2024
61033bf
feat: add translations
MartinCupela Apr 17, 2024
f3a183b
Merge branch 'master' into feat/async-voice-messages-recording
MartinCupela Apr 17, 2024
69c016b
fix: reorder DE translations
MartinCupela Apr 17, 2024
874b0e1
test: fix failing AudioRecorder tests
MartinCupela Apr 17, 2024
f91c294
docs: add documentation for AudioRecorder feature
MartinCupela Apr 17, 2024
5a50fff
feat: allow to customize StartRecordingAudioButton
MartinCupela Apr 17, 2024
7d1c487
docs: omit documenting audioRecordingConfig
MartinCupela Apr 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
id: audio_recorder
title: Audio Recorder
---

import RecordingPermissionDeniedNotification from '../../assets/audio-recorder-permission-denied-notification.png';
import StartRecordingButton from '../../assets/audio-recorder-start-recording-button.png';
import AudioRecorderRecording from '../../assets/audio-recorder-recording.png';
import AudioRecorderPaused from '../../assets/audio-recorder-paused.png';
import AudioRecorderStopped from '../../assets/audio-recorder-stopped.png';

Recording voice messages is possible by enabling audio recording on `MessageInput` component.

```jsx
<MessageInput audioRecordingEnabled />
```

Once enabled, the `MessageInput` UI will render a `StartRecordingAudioButton`.

<img src={StartRecordingButton} alt='Message composer with the recording button' height='300' />

The default implementation of `StartRecordingAudioButton` button can be replaced with custom implementation through the `Channel` component context:

```jsx
<Channel StartRecordingAudioButton={CustomComponent}>
```

Click on the recording button will replace the message composer UI with `AudioRecorder` component UI.

<img src={AudioRecorderRecording} alt='AudioRecorder UI with recording in progress' height='300' />

The default `AudioRecorder` component can be replaced by a custom implementation through the `Channel` component context:

```jsx
<Channel AudioRecorder={CustomComponent}>
```

## Browser permissions

Updates in `'microphone'` browser permission are observed and handled. If a user clicks the start recording button and the `'microphone'` permission state is `'denied'`, then a notification dialog `RecordingPermissionDeniedNotification` is rendered.

<img
src={RecordingPermissionDeniedNotification}
alt='RecordingPermissionDeniedNotification rendered when microphone permission is denied'
height='300'
/>

The dialog can be customized by passing own component to `Channel` component context:

```jsx
<Channel RecordingPermissionDeniedNotification={CustomComponent}>
```

## Audio recorder states

The `AudioRecorder` UI switches between the following states

**1. Recording state**

The recording can be paused or stopped.

<img src={AudioRecorderRecording} alt='AudioRecorder UI in recording state' height='300' />

**2. Paused state**

The recording can be stopped or resumed.

<img src={AudioRecorderPaused} alt='AudioRecorder UI paused state' height='300' />

**3. Stopped state**

The recording can be played back before it is sent.

<img src={AudioRecorderStopped} alt='AudioRecorder UI stopped state' height='300' />

At any time, the recorder allows to cancel the recording and return to message composer UI by clicking the button with the bin icon.

## The message sending behavior

The resulting recording is always uploaded on the recording completion. The recording is completed when user stops the recording and confirms the completion with a send button.

The behavior, when a message with the given recording attachment is sent, however, can be controlled through the `asyncMessagesMultiSendEnabled` configuration prop on `MessageInput`.

```jsx
<MessageInput asyncMessagesMultiSendEnabled audioRecordingEnabled />
```

And so the message is sent depending on `asyncMessagesMultiSendEnabled` value as follows:

| `asyncMessagesMultiSendEnabled` value | Impact |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `false` (default behavior) | immediately after a successful upload at one step on completion. In that case as a single attachment (voice recording only), no-text message is submitted |
| `true` | upon clicking the `SendMessage` button if `asyncMessagesMultiSendEnabled` is enabled |

:::note
Enabling `asyncMessagesMultiSendEnabled` would allow users to record multiple voice messages or accompany the voice recording with text or other types of attachments.
:::

## Audio recorder controller

The components consuming the `MessageInputContext` can access the recording state through the `recordingController`:

```jsx
import { useMessageInputContext } from 'stream-chat-react';

const Component = () => {
const {
recordingController: {
completeRecording,
permissionState,
recorder,
recording,
recordingState,
},
} = useMessageInputContext();
};
```

The controller exposes the following API:

| Property | Description |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `completeRecording` | A function that allows to stop the recording and upload it the back-end and submit the message if `asyncMessagesMultiSendEnabled` is disabled |
| `permissionState` | One of the values for microphone permission: `'granted'`, `'prompt'`, `'denied'` |
| `recorder` | Instance of `MediaRecorderController` that exposes the API to control the recording states (`start`, `pause`, `resume`, `stop`, `cancel`) |
| `recording` | Generated attachment of type `voiceRecording`. This is available once the recording is stopped. |
| `recordingState` | One of the values `'recording'`, `'paused'`, `'stopped'`. Useful to reflect the changes in `recorder` state in the UI. |
1 change: 1 addition & 0 deletions docusaurus/sidebars-react.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"components/message-input-components/input_ui",
"components/message-input-components/ui_components",
"components/message-input-components/emoji-picker",
"components/message-input-components/audio_recorder",
"components/contexts/typing_context"
]
},
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,20 @@
"dependencies": {
"@babel/runtime": "^7.23.6",
"@braintree/sanitize-url": "^6.0.4",
"@breezystack/lamejs": "^1.2.7",
"@popperjs/core": "^2.11.5",
"@react-aria/focus": "^3",
"clsx": "^2.0.0",
"dayjs": "^1.10.4",
"emoji-regex": "^9.2.0",
"fix-webm-duration": "^1.0.5",
"hast-util-find-and-replace": "^5.0.1",
"i18next": "^21.6.14",
"isomorphic-ws": "^4.0.1",
"linkifyjs": "^4.1.0",
"lodash.debounce": "^4.0.8",
"lodash.defaultsdeep": "^4.6.1",
"lodash.mergewith": "^4.6.2",
"lodash.throttle": "^4.1.1",
"lodash.uniqby": "^4.7.0",
"nanoid": "^3.3.4",
Expand Down Expand Up @@ -156,6 +159,7 @@
"@types/linkifyjs": "^2.1.3",
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.defaultsdeep": "^4.6.9",
"@types/lodash.mergewith": "^4.6.9",
"@types/lodash.throttle": "^4.1.7",
"@types/lodash.uniqby": "^4.7.7",
"@types/moment": "^2.13.0",
Expand Down
11 changes: 7 additions & 4 deletions src/components/Attachment/Audio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ export type AudioProps<
};

const AudioV1 = ({ og }: AudioProps) => {
const { asset_url, description, image_url, text, title } = og;
const { audioRef, isPlaying, progress, togglePlay } = useAudioController();
// fixme: pass mimeType if available
const { asset_url, description, image_url, mime_type, text, title } = og;
const { audioRef, isPlaying, progress, togglePlay } = useAudioController({ mimeType: mime_type });

return (
<div className='str-chat__audio'>
Expand Down Expand Up @@ -77,8 +78,10 @@ const AudioV1 = ({ og }: AudioProps) => {
};

const AudioV2 = ({ og }: AudioProps) => {
const { asset_url, file_size, title } = og;
const { audioRef, isPlaying, progress, seek, togglePlay } = useAudioController();
const { asset_url, file_size, mime_type, title } = og;
const { audioRef, isPlaying, progress, seek, togglePlay } = useAudioController({
mimeType: mime_type,
});

if (!asset_url) return null;

Expand Down
6 changes: 4 additions & 2 deletions src/components/Attachment/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,11 @@ const CardV2 = (props: CardProps) => {
};

export const CardAudio = ({
og: { asset_url, author_name, og_scrape_url, text, title, title_link },
og: { asset_url, author_name, mime_type, og_scrape_url, text, title, title_link },
}: AudioProps) => {
const { audioRef, isPlaying, progress, seek, togglePlay } = useAudioController();
const { audioRef, isPlaying, progress, seek, togglePlay } = useAudioController({
mimeType: mime_type,
});

const url = title_link || og_scrape_url;
const dataTestId = 'card-audio-widget';
Expand Down
7 changes: 5 additions & 2 deletions src/components/Attachment/VoiceRecording.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const VoiceRecordingPlayer = ({ attachment, playbackRates }: VoiceRecordi
const { t } = useTranslationContext('VoiceRecordingPlayer');
const {
asset_url,
duration,
duration = 0,
mime_type,
title = t<string>('Voice message'),
waveform_data,
Expand All @@ -39,11 +39,14 @@ export const VoiceRecordingPlayer = ({ attachment, playbackRates }: VoiceRecordi
togglePlay,
} = useAudioController({
durationSeconds: duration ?? 0,
mimeType: mime_type,
playbackRates,
});

if (!asset_url) return null;

const displayedDuration = secondsElapsed || duration;

return (
<div className={rootClassName} data-testid='voice-recording-widget'>
<audio ref={audioRef}>
Expand All @@ -61,7 +64,7 @@ export const VoiceRecordingPlayer = ({ attachment, playbackRates }: VoiceRecordi
<div className='str-chat__message-attachment__voice-recording-widget__audio-state'>
<div className='str-chat__message-attachment__voice-recording-widget__timer'>
{attachment.duration ? (
displayDuration(secondsElapsed)
displayDuration(displayedDuration)
) : (
<FileSizeIndicator fileSize={attachment.file_size} maximumFractionDigits={0} />
)}
Expand Down
Loading
Loading