diff --git a/docusaurus/docs/React/assets/audio-recorder-paused.png b/docusaurus/docs/React/assets/audio-recorder-paused.png new file mode 100644 index 000000000..66f3a8705 Binary files /dev/null and b/docusaurus/docs/React/assets/audio-recorder-paused.png differ diff --git a/docusaurus/docs/React/assets/audio-recorder-permission-denied-notification.png b/docusaurus/docs/React/assets/audio-recorder-permission-denied-notification.png new file mode 100644 index 000000000..818f3f86c Binary files /dev/null and b/docusaurus/docs/React/assets/audio-recorder-permission-denied-notification.png differ diff --git a/docusaurus/docs/React/assets/audio-recorder-recording.png b/docusaurus/docs/React/assets/audio-recorder-recording.png new file mode 100644 index 000000000..c856c39fa Binary files /dev/null and b/docusaurus/docs/React/assets/audio-recorder-recording.png differ diff --git a/docusaurus/docs/React/assets/audio-recorder-start-recording-button.png b/docusaurus/docs/React/assets/audio-recorder-start-recording-button.png new file mode 100644 index 000000000..2774a68c5 Binary files /dev/null and b/docusaurus/docs/React/assets/audio-recorder-start-recording-button.png differ diff --git a/docusaurus/docs/React/assets/audio-recorder-stopped.png b/docusaurus/docs/React/assets/audio-recorder-stopped.png new file mode 100644 index 000000000..f2acd43ac Binary files /dev/null and b/docusaurus/docs/React/assets/audio-recorder-stopped.png differ diff --git a/docusaurus/docs/React/components/message-input-components/audio-recorder.mdx b/docusaurus/docs/React/components/message-input-components/audio-recorder.mdx new file mode 100644 index 000000000..11803a087 --- /dev/null +++ b/docusaurus/docs/React/components/message-input-components/audio-recorder.mdx @@ -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 + +``` + +Once enabled, the `MessageInput` UI will render a `StartRecordingAudioButton`. + +Message composer with the recording button + +The default implementation of `StartRecordingAudioButton` button can be replaced with custom implementation through the `Channel` component context: + +```jsx + +``` + +Click on the recording button will replace the message composer UI with `AudioRecorder` component UI. + +AudioRecorder UI with recording in progress + +The default `AudioRecorder` component can be replaced by a custom implementation through the `Channel` component context: + +```jsx + +``` + +## 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. + +RecordingPermissionDeniedNotification rendered when microphone permission is denied + +The dialog can be customized by passing own component to `Channel` component context: + +```jsx + +``` + +## Audio recorder states + +The `AudioRecorder` UI switches between the following states + +**1. Recording state** + +The recording can be paused or stopped. + +AudioRecorder UI in recording state + +**2. Paused state** + +The recording can be stopped or resumed. + +AudioRecorder UI paused state + +**3. Stopped state** + +The recording can be played back before it is sent. + +AudioRecorder UI stopped state + +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 + +``` + +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. | diff --git a/docusaurus/sidebars-react.json b/docusaurus/sidebars-react.json index e8292df10..5a6642536 100644 --- a/docusaurus/sidebars-react.json +++ b/docusaurus/sidebars-react.json @@ -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" ] }, diff --git a/package.json b/package.json index e0a62250c..49b59c267 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index 303d9c78e..b8da296f2 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -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 (
@@ -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; diff --git a/src/components/Attachment/Card.tsx b/src/components/Attachment/Card.tsx index 0084f5ae1..66bfe5820 100644 --- a/src/components/Attachment/Card.tsx +++ b/src/components/Attachment/Card.tsx @@ -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'; diff --git a/src/components/Attachment/VoiceRecording.tsx b/src/components/Attachment/VoiceRecording.tsx index 6b249a167..8baf03208 100644 --- a/src/components/Attachment/VoiceRecording.tsx +++ b/src/components/Attachment/VoiceRecording.tsx @@ -22,7 +22,7 @@ export const VoiceRecordingPlayer = ({ attachment, playbackRates }: VoiceRecordi const { t } = useTranslationContext('VoiceRecordingPlayer'); const { asset_url, - duration, + duration = 0, mime_type, title = t('Voice message'), waveform_data, @@ -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 (