Skip to content

Commit

Permalink
feat: async voice messages recording (#2339)
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinCupela committed Apr 17, 2024
1 parent 0f70414 commit b81ab96
Show file tree
Hide file tree
Showing 99 changed files with 4,643 additions and 1,038 deletions.
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

0 comments on commit b81ab96

Please sign in to comment.