diff --git a/webapp/channels/package.json b/webapp/channels/package.json
index 791d990694..133a86ba62 100644
--- a/webapp/channels/package.json
+++ b/webapp/channels/package.json
@@ -102,6 +102,7 @@
"tinycolor2": "1.4.2",
"turndown": "7.1.1",
"typescript": "4.7.4",
+ "wasm-media-encoders": "0.6.4",
"zen-observable": "0.9.0"
},
"devDependencies": {
diff --git a/webapp/channels/src/actions/views/create_comment.tsx b/webapp/channels/src/actions/views/create_comment.tsx
index 2477f35732..361b3c8ec1 100644
--- a/webapp/channels/src/actions/views/create_comment.tsx
+++ b/webapp/channels/src/actions/views/create_comment.tsx
@@ -92,6 +92,7 @@ export function submitPost(channelId: string, rootId: string, draft: PostDraft)
user_id: userId,
create_at: time,
metadata: {},
+ type: draft?.postType ?? '',
props: {...draft.props},
} as unknown as Post;
diff --git a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.jsx b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.jsx
index 62091fcbbb..85b734f819 100644
--- a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.jsx
+++ b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.test.jsx
@@ -12,6 +12,8 @@ import Constants, {ModalIdentifiers} from 'utils/constants';
import AdvanceTextEditor from '../advanced_text_editor/advanced_text_editor';
+jest.mock('components/advanced_text_editor/voice_message_attachment', () => () =>
);
+
describe('components/AdvancedCreateComment', () => {
jest.useFakeTimers();
beforeEach(() => {
diff --git a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx
index de1533dd0b..71036a26d6 100644
--- a/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx
+++ b/webapp/channels/src/components/advanced_create_comment/advanced_create_comment.tsx
@@ -11,6 +11,7 @@ import type {ServerError} from '@mattermost/types/errors';
import type {FileInfo} from '@mattermost/types/files';
import type {Group} from '@mattermost/types/groups';
import {GroupSource} from '@mattermost/types/groups';
+import type {Post} from '@mattermost/types/posts';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {ActionResult} from 'mattermost-redux/types/actions';
@@ -40,6 +41,8 @@ import {
splitMessageBasedOnCaretPosition,
groupsMentionedInText,
mentionsMinusSpecialMentionsInText,
+ getVoiceMessageStateFromDraft,
+ VoiceMessageStates,
} from 'utils/post_utils';
import * as UserAgent from 'utils/user_agent';
import * as Utils from 'utils/utils';
@@ -206,6 +209,7 @@ type State = {
serverError: (ServerError & {submittedMessage?: string}) | null;
showFormat: boolean;
isFormattingBarHidden: boolean;
+ voiceMessageClientId: string;
};
function isDraftEmpty(draft: PostDraft): boolean {
@@ -267,6 +271,7 @@ class AdvancedCreateComment extends React.PureComponent {
serverError: null,
showFormat: false,
isFormattingBarHidden: props.isFormattingBarHidden,
+ voiceMessageClientId: '',
caretPosition: props.draft.caretPosition,
};
@@ -303,6 +308,14 @@ class AdvancedCreateComment extends React.PureComponent {
document.removeEventListener('keydown', this.focusTextboxIfNecessary);
window.removeEventListener('beforeunload', this.saveDraftWithShow);
this.saveDraftOnUnmount();
+
+ // Remove voice message recorder on thread/comment switch or another thread opened
+ if (this.props.rootId) {
+ const previousChannelVoiceMessageState = getVoiceMessageStateFromDraft(this.props.draft);
+ if (previousChannelVoiceMessageState === VoiceMessageStates.RECORDING) {
+ this.setDraftAsPostType(this.props.rootId, this.props.draft);
+ }
+ }
}
componentDidUpdate(prevProps: Props, prevState: State) {
@@ -1024,6 +1037,32 @@ class AdvancedCreateComment extends React.PureComponent {
});
};
+ setDraftAsPostType = (rootId: Post['id'], draft: PostDraft, postType?: PostDraft['postType']) => {
+ const updatedDraft: PostDraft = {...draft};
+
+ if (postType) {
+ updatedDraft.postType = Constants.PostTypes.VOICE;
+ } else {
+ Reflect.deleteProperty(updatedDraft, 'postType');
+ }
+
+ this.props.updateCommentDraftWithRootId(rootId, updatedDraft);
+ this.draftsForPost[rootId] = updatedDraft;
+ };
+
+ handleVoiceMessageUploadStart = (clientId: string, rootId: Post['id']) => {
+ const uploadsInProgress = [...this.props.draft.uploadsInProgress, clientId];
+ const draft = {
+ ...this.props.draft,
+ uploadsInProgress,
+ postType: Constants.PostTypes.VOICE,
+ };
+
+ this.props.updateCommentDraftWithRootId(rootId, draft);
+ this.setState({voiceMessageClientId: clientId});
+ this.draftsForPost[rootId] = draft;
+ };
+
handleFileUploadChange = () => {
this.isDraftEdited = true;
this.focusTextbox();
@@ -1044,6 +1083,8 @@ class AdvancedCreateComment extends React.PureComponent {
// this is a bit redundant with the code that sets focus when the file input is clicked,
// but this also resets the focus after a drag and drop
this.focusTextbox();
+
+ this.setState({voiceMessageClientId: ''});
};
handleUploadProgress = (filePreviewInfo: FilePreviewInfo) => {
@@ -1102,7 +1143,7 @@ class AdvancedCreateComment extends React.PureComponent {
serverError = new Error(serverError);
}
- this.setState({serverError}, () => {
+ this.setState({serverError, voiceMessageClientId: ''}, () => {
if (serverError && this.props.scrollToBottom) {
this.props.scrollToBottom();
}
@@ -1114,6 +1155,11 @@ class AdvancedCreateComment extends React.PureComponent {
const fileInfos = [...draft.fileInfos];
const uploadsInProgress = [...draft.uploadsInProgress];
+ if (draft.postType === Constants.PostTypes.VOICE) {
+ Reflect.deleteProperty(draft, 'postType');
+ this.setState({voiceMessageClientId: ''});
+ }
+
// Clear previous errors
this.handleUploadError(null);
@@ -1268,6 +1314,9 @@ class AdvancedCreateComment extends React.PureComponent {
getFileUploadTarget={this.getFileUploadTarget}
fileUploadRef={this.fileUploadRef}
isThreadView={this.props.isThreadView}
+ voiceMessageClientId={this.state.voiceMessageClientId}
+ handleVoiceMessageUploadStart={this.handleVoiceMessageUploadStart}
+ setDraftAsPostType={this.setDraftAsPostType}
isSchedulable={true}
handleSchedulePost={this.handleSchedulePost}
caretPosition={this.state.caretPosition}
diff --git a/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.jsx b/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.jsx
index 720cc8ccbd..b345e7263f 100644
--- a/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.jsx
+++ b/webapp/channels/src/components/advanced_create_post/advanced_create_post.test.jsx
@@ -31,6 +31,8 @@ jest.mock('actions/post_actions', () => ({
}),
}));
+jest.mock('components/advanced_text_editor/voice_message_attachment', () => () => );
+
const currentTeamIdProp = 'r7rws4y7ppgszym3pdd5kaibfa';
const currentUserIdProp = 'zaktnt8bpbgu8mb6ez9k64r7sa';
const showTutorialTipProp = false;
diff --git a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx
index 1944c71a0d..bc107f24e0 100644
--- a/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx
+++ b/webapp/channels/src/components/advanced_create_post/advanced_create_post.tsx
@@ -58,6 +58,8 @@ import {
splitMessageBasedOnCaretPosition,
groupsMentionedInText,
mentionsMinusSpecialMentionsInText,
+ getVoiceMessageStateFromDraft,
+ VoiceMessageStates,
} from 'utils/post_utils';
import * as UserAgent from 'utils/user_agent';
import * as Utils from 'utils/utils';
@@ -263,6 +265,7 @@ type State = {
showFormat: boolean;
isFormattingBarHidden: boolean;
showPostPriorityPicker: boolean;
+ voiceMessageClientId: string;
};
class AdvancedCreatePost extends React.PureComponent {
@@ -316,6 +319,7 @@ class AdvancedCreatePost extends React.PureComponent {
showFormat: false,
isFormattingBarHidden: props.isFormattingBarHidden,
showPostPriorityPicker: false,
+ voiceMessageClientId: '',
};
this.topDiv = React.createRef();
@@ -359,6 +363,14 @@ class AdvancedCreatePost extends React.PureComponent {
if (prevProps.shouldShowPreview && !this.props.shouldShowPreview) {
this.focusTextbox();
}
+
+ // Remove voice message recorder on channel/team switch
+ if (this.props.currentChannel.id !== prevProps.currentChannel.id || this.props.currentTeamId !== prevProps.currentTeamId) {
+ const previousChannelVoiceMessageState = getVoiceMessageStateFromDraft(prevProps.draft);
+ if (previousChannelVoiceMessageState === VoiceMessageStates.RECORDING) {
+ this.setDraftAsPostType(prevProps.currentChannel.id, prevProps.draft);
+ }
+ }
}
componentWillUnmount() {
@@ -381,6 +393,14 @@ class AdvancedCreatePost extends React.PureComponent {
actions.getChannelMemberCountsByGroup(currentChannel.id, isTimezoneEnabled);
}
}
+
+ // If the draft is of type voice message, but has no recording then remove the voice message type draft on component mount.
+ if (this.props.currentChannel.id) {
+ const previousChannelVoiceMessageState = getVoiceMessageStateFromDraft(this.props.draft);
+ if (previousChannelVoiceMessageState === VoiceMessageStates.RECORDING) {
+ this.setDraftAsPostType(this.props.currentChannel.id, this.props.draft);
+ }
+ }
};
unloadHandler = () => {
@@ -768,6 +788,7 @@ class AdvancedCreatePost extends React.PureComponent {
post.metadata = {
...originalPost.metadata,
} as PostMetadata;
+ post.type = draft?.postType ?? '';
post.props = {
...originalPost.props,
@@ -878,6 +899,30 @@ class AdvancedCreatePost extends React.PureComponent {
GlobalActions.emitLocalUserTypingEvent(channelId, '');
};
+ setDraftAsPostType = (channelId: Channel['id'], draft: PostDraft, postType?: PostDraft['postType']) => {
+ if (postType) {
+ const updatedDraft: PostDraft = {...draft, postType: Constants.PostTypes.VOICE};
+ this.props.actions.setDraft(StoragePrefixes.DRAFT + channelId, updatedDraft, channelId);
+ this.draftsForChannel[channelId] = updatedDraft;
+ } else {
+ this.props.actions.setDraft(StoragePrefixes.DRAFT + channelId, null, channelId);
+ this.draftsForChannel[channelId] = null;
+ }
+ };
+
+ handleVoiceMessageUploadStart = (clientId: string, channelId: Channel['id']) => {
+ const uploadsInProgress = [...this.props.draft.uploadsInProgress, clientId];
+ const draft = {
+ ...this.props.draft,
+ uploadsInProgress,
+ postType: Constants.PostTypes.VOICE,
+ };
+
+ this.props.actions.setDraft(StoragePrefixes.DRAFT + channelId, draft, channelId);
+ this.setState({voiceMessageClientId: clientId});
+ this.draftsForChannel[channelId] = draft;
+ };
+
handleChange = (e: React.ChangeEvent) => {
const message = e.target.value;
@@ -1037,6 +1082,7 @@ class AdvancedCreatePost extends React.PureComponent {
}
this.handleDraftChange(draft, channelId, true);
+ this.setState({voiceMessageClientId: ''});
};
handleUploadError = (err: string | ServerError, clientId?: string, channelId?: string) => {
@@ -1046,7 +1092,7 @@ class AdvancedCreatePost extends React.PureComponent {
}
if (!channelId || !clientId) {
- this.setState({serverError});
+ this.setState({serverError, voiceMessageClientId: ''});
return;
}
@@ -1072,6 +1118,11 @@ class AdvancedCreatePost extends React.PureComponent {
let modifiedDraft = {} as PostDraft;
const draft = {...this.props.draft};
+ if (draft.postType === Constants.PostTypes.VOICE) {
+ Reflect.deleteProperty(draft, 'postType');
+ this.setState({voiceMessageClientId: ''});
+ }
+
// Clear previous errors
this.setState({serverError: null});
@@ -1713,6 +1764,9 @@ class AdvancedCreatePost extends React.PureComponent {
fileUploadRef={this.fileUploadRef}
prefillMessage={this.prefillMessage}
textboxRef={this.textboxRef}
+ voiceMessageClientId={this.state.voiceMessageClientId}
+ handleVoiceMessageUploadStart={this.handleVoiceMessageUploadStart}
+ setDraftAsPostType={this.setDraftAsPostType}
labels={priorityLabels}
isSchedulable={true}
handleSchedulePost={this.handleSchedulePost}
diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss
index 38d92223ec..6318c637b5 100644
--- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss
+++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.scss
@@ -144,13 +144,28 @@
cursor: pointer;
fill: currentColor;
- &:hover {
+ &:hover:not(:disabled) {
background: rgba(var(--center-channel-color-rgb), 0.08);
color: rgba(var(--center-channel-color-rgb), 0.72);
fill: currentColor;
text-decoration: none;
}
+ &:disabled {
+ color: rgba(var(--center-channel-color-rgb), 0.32);
+ cursor: not-allowed;
+ pointer-events: none;
+
+ &:hover,
+ &:active,
+ &.active,
+ &.active:hover {
+ background: inherit;
+ color: inherit;
+ fill: inherit;
+ }
+ }
+
&.hidden {
visibility: hidden;
}
diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx
index f9ecdabdf8..d68daf2bf6 100644
--- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx
@@ -10,6 +10,7 @@ import type {Channel} from '@mattermost/types/channels';
import type {Emoji} from '@mattermost/types/emojis';
import type {ServerError} from '@mattermost/types/errors';
import type {FileInfo} from '@mattermost/types/files';
+import {Post} from '@mattermost/types/posts';
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
import FilePreview from 'components/file_preview';
@@ -27,7 +28,8 @@ import {SendMessageTour} from 'components/tours/onboarding_tour';
import Constants, {Locations} from 'utils/constants';
import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
-import * as Utils from 'utils/utils';
+import {getVoiceMessageStateFromDraft, VoiceMessageStates} from 'utils/post_utils';
+import {localizeMessage, scrollbarWidth as getScrollbarWidth} from 'utils/utils';
import type {PostDraft} from 'types/store/draft';
@@ -38,6 +40,8 @@ import SendButton from './send_button';
import ShowFormat from './show_formatting';
import TexteditorActions from './texteditor_actions';
import ToggleFormattingBar from './toggle_formatting_bar/toggle_formatting_bar';
+import VoiceMessageAttachment from './voice_message_attachment';
+import VoiceMessageButton from './voice_message_button';
import AutoHeightSwitcher from '../common/auto_height_switcher';
import RhsSuggestionList from '../suggestion/rhs_suggestion_list';
@@ -90,7 +94,7 @@ type Props = {
hideEmojiPicker: () => void;
toggleAdvanceTextEditor: () => void;
handleUploadProgress: (filePreviewInfo: FilePreviewInfo) => void;
- handleUploadError: (err: string | ServerError, clientId?: string, channelId?: string) => void;
+ handleUploadError: (err: string | ServerError, clientId?: string, channelId?: string, rootId?: string) => void;
handleFileUploadComplete: (fileInfos: FileInfo[], clientIds: string[], channelId: string, rootId?: string) => void;
handleUploadStart: (clientIds: string[], channelId: string) => void;
handleFileUploadChange: () => void;
@@ -100,9 +104,12 @@ type Props = {
channelId: string;
postId: string;
textboxRef: React.RefObject;
+ voiceMessageClientId: string;
+ handleVoiceMessageUploadStart: (clientId: string, channelOrRootId: Channel['id'] | Post['id']) => void;
isThreadView?: boolean;
additionalControls?: React.ReactNodeArray;
labels?: React.ReactNode;
+ setDraftAsPostType: (channelOrRootId: Channel['id'] | Post['id'], draft: PostDraft, postType?: PostDraft['postType']) => void;
isSchedulable?: boolean;
handleSchedulePost: (scheduleUTCTimestamp: number) => void;
}
@@ -158,6 +165,9 @@ const AdvanceTextEditor = ({
fileUploadRef,
prefillMessage,
textboxRef,
+ voiceMessageClientId,
+ setDraftAsPostType,
+ handleVoiceMessageUploadStart,
isThreadView,
additionalControls,
labels,
@@ -166,7 +176,7 @@ const AdvanceTextEditor = ({
}: Props) => {
const readOnlyChannel = !canPost;
const {formatMessage} = useIntl();
- const ariaLabelMessageInput = Utils.localizeMessage(
+ const ariaLabelMessageInput = localizeMessage(
'accessibility.sections.centerFooter',
'message input complimentary region',
);
@@ -177,6 +187,8 @@ const AdvanceTextEditor = ({
const [renderScrollbar, setRenderScrollbar] = useState(false);
const [showFormattingSpacer, setShowFormattingSpacer] = useState(shouldShowPreview);
+ const voiceMessageState = getVoiceMessageStateFromDraft(draft);
+
const input = textboxRef.current?.getInputBox();
const handleHeightChange = useCallback((height: number, maxHeight: number) => {
@@ -199,7 +211,29 @@ const AdvanceTextEditor = ({
}
let attachmentPreview = null;
- if (!readOnlyChannel && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0)) {
+ if (!readOnlyChannel && draft.postType === Constants.PostTypes.VOICE) {
+ attachmentPreview = (
+
+
+
+ );
+ } else if (!readOnlyChannel && (draft.fileInfos.length > 0 || draft.uploadsInProgress.length > 0)) {
attachmentPreview = (
+ ) : null;
+
+ const isSendButtonDisabled = readOnlyChannel ||
+ voiceMessageState === VoiceMessageStates.RECORDING || voiceMessageState === VoiceMessageStates.UPLOADING ||
+ !hasDraftMessagesOrFileAttachments;
+
const sendButton = readOnlyChannel ? null : (
);
- const showFormatJSX = disableSendButton ? null : (
+ const showFormatJSX = isSendButtonDisabled || draft.postType === Constants.PostTypes.VOICE ? null : (
@@ -439,7 +493,7 @@ const AdvanceTextEditor = ({
role='application'
id='advancedTextEditorCell'
data-a11y-sort-order='2'
- aria-label={Utils.localizeMessage(
+ aria-label={localizeMessage(
'channelView.login.successfull',
'Login Successfull',
) + ' ' + ariaLabelMessageInput}
@@ -467,6 +521,7 @@ const AdvanceTextEditor = ({
id={textboxId}
ref={textboxRef!}
disabled={readOnlyChannel}
+ hidden={draft.postType === Constants.PostTypes.VOICE}
characterLimit={maxPostSize}
preview={shouldShowPreview}
badConnection={badConnection}
@@ -501,6 +556,7 @@ const AdvanceTextEditor = ({
{fileUploadJSX}
{emojiPicker}
+ {voiceMessageButton}
{sendButton}
)}
diff --git a/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/file_attachment_containers/index.tsx b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/file_attachment_containers/index.tsx
new file mode 100644
index 0000000000..c625879e31
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/file_attachment_containers/index.tsx
@@ -0,0 +1,124 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {HTMLProps, ReactNode} from 'react';
+import React from 'react';
+import styled from 'styled-components';
+
+const StyledButton = styled.button`
+ border-radius: 50%;
+ width: 28px;
+ height: 28px;
+ border-width: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+`;
+
+export const OkButton = styled(StyledButton)`
+ background-color: var(--button-bg);
+ color: var(--button-color);
+`;
+
+export const CancelButton = styled(StyledButton)`
+ background-color: var(--center-channel-bg);
+ color: rgba(var(--center-channel-color-rgb), 0.56);
+ margin-right: 4px;
+ &:focus {
+ color: rgba(var(--center-channel-color-rgb), 0.56);
+ }
+ &:hover {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ color: rgba(var(--center-channel-color-rgb), 0.72);
+ }
+ &:active {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ color: rgba(var(--center-channel-color-rgb), 0.72);
+ }
+`;
+
+export const Duration = styled.div`
+ padding-inline-end: 1.5rem;
+ font-weight: 400;
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--center-channel-text);
+`;
+
+export const TextColumn = styled.div`
+flex: 1;
+display: flex;
+flex-direction: column;
+text-decoration: none;
+`;
+
+const TextOverFlowStopDiv = styled.div`
+ max-width: 20rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ word-break: break-word;
+`;
+
+export const Title = styled(TextOverFlowStopDiv)`
+ color: var(--center-channel-text);
+ font-size: 14px;
+ line-height: 20px;
+ font-weight: 600;
+`;
+
+export const Subtitle = styled(TextOverFlowStopDiv)`
+ color: rgba(var(--center-channel-color-rgb), 0.56);
+ font-size: 12px;
+ line-height: 16px;
+`;
+
+interface AttachmentRootContainerProps extends HTMLProps {
+ icon: ReactNode;
+ onIconClick?: () => void;
+ iconDanger?: boolean;
+}
+
+const IconWrapper = styled.div>`
+ width: 40px;
+ background-color: ${(props) => (props.iconDanger ? 'var(--error-text)' : 'rgba(var(--button-bg-rgb), 0.12)')};
+ height: 40px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 50%;
+ cursor: ${(props) => (props.onClick ? 'pointer' : 'default')};
+`;
+
+const ControlsColumn = styled.div`
+ position: relative;
+ display: flex;
+ overflow: hidden;
+ height: 100%;
+ flex: 1;
+ align-items: center;
+ font-size: 12px;
+ text-align: left;
+ padding-right: 1rem;
+ justify-content: space-between;
+`;
+
+export const AttachmentRootContainer = ({icon, onIconClick, iconDanger, children}: AttachmentRootContainerProps) => {
+ return (
+
+
+
+
+ {icon}
+
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/recording_failed/index.tsx b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/recording_failed/index.tsx
new file mode 100644
index 0000000000..fe5eada1fb
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/recording_failed/index.tsx
@@ -0,0 +1,59 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+
+import {MicrophoneOutlineIcon, CloseIcon} from '@mattermost/compass-icons/components';
+
+import {
+ AttachmentRootContainer,
+ CancelButton,
+ TextColumn,
+ Title,
+ Subtitle,
+} from 'components/advanced_text_editor/voice_message_attachment/components/file_attachment_containers';
+
+interface Props {
+ onCancel: () => void;
+}
+
+const VoiceMessageRecordingFailed = (props: Props) => {
+ const intl = useIntl();
+
+ const errorMessage = intl.formatMessage({
+ id: 'voiceMessage.recordingFailed',
+ defaultMessage: 'Failed to record',
+ });
+
+ return (
+
+ )}
+ iconDanger={true}
+ >
+
+
+
+
+
+ {errorMessage}
+
+
+
+
+
+
+ );
+};
+
+export default VoiceMessageRecordingFailed;
diff --git a/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/recording_started/index.tsx b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/recording_started/index.tsx
new file mode 100644
index 0000000000..7f96d62f2c
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/recording_started/index.tsx
@@ -0,0 +1,139 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useEffect, useRef} from 'react';
+import {FormattedMessage} from 'react-intl';
+import {useSelector} from 'react-redux';
+import styled from 'styled-components';
+
+import {MicrophoneOutlineIcon, CloseIcon, CheckIcon} from '@mattermost/compass-icons/components';
+
+import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
+
+import {getVoiceMessageMaxDuration} from 'selectors/views/textbox';
+
+import {
+ AttachmentRootContainer,
+ CancelButton,
+ OkButton,
+ Duration,
+} from 'components/advanced_text_editor/voice_message_attachment/components/file_attachment_containers';
+import VoiceMessageRecordingFailed from 'components/advanced_text_editor/voice_message_attachment/components/recording_failed';
+import {useAudioRecorder} from 'components/common/hooks/useAudioRecorder';
+
+import {convertSecondsToMSS} from 'utils/datetime';
+
+interface Props {
+ theme: Theme;
+ onCancel: () => void;
+ onComplete: (audioFile: File) => Promise;
+}
+
+function VoiceMessageRecordingStarted(props: Props) {
+ const voiceMessageMaxDuration = useSelector(getVoiceMessageMaxDuration);
+
+ const canvasElemRef = useRef(null);
+
+ const {startRecording, elapsedTime, stopRecording, cleanPostRecording, hasError} = useAudioRecorder({
+ canvasElemRef,
+ canvasBg: props.theme.centerChannelBg,
+ canvasBarColor: props.theme.buttonBg,
+ canvasBarWidth: 4,
+ audioAnalyzerFFTSize: 32,
+ minimumAmplitudePercentage: 20,
+ reducedSampleSize: 13,
+ audioFilePrefix: 'voice_message_',
+ });
+
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Enter') {
+ handleRecordingComplete();
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, []);
+
+ useEffect(() => {
+ startRecording();
+
+ return () => {
+ cleanPostRecording(true);
+ };
+ }, []);
+
+ async function handleRecordingCancelled() {
+ await cleanPostRecording(true);
+ props.onCancel();
+ }
+
+ async function handleRecordingComplete() {
+ const audioFile = await stopRecording();
+
+ if (audioFile) {
+ props.onComplete(audioFile);
+ }
+ }
+
+ useEffect(() => {
+ if (elapsedTime >= voiceMessageMaxDuration) {
+ handleRecordingComplete();
+ }
+ }, [elapsedTime, voiceMessageMaxDuration]);
+
+ if (hasError) {
+ return ;
+ }
+
+ return (
+
+ )}
+ >
+
+
+
+
+ {convertSecondsToMSS(elapsedTime)}
+
+
+
+
+
+
+
+
+ );
+}
+
+export const VisualizerContainer = styled.div`
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding-right: 1rem;
+`;
+
+const Canvas = styled.canvas`
+ width: 100%;
+ height: 24px;
+`;
+
+export default VoiceMessageRecordingStarted;
diff --git a/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/upload_failed/index.tsx b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/upload_failed/index.tsx
new file mode 100644
index 0000000000..4fbf997510
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/upload_failed/index.tsx
@@ -0,0 +1,75 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FormattedMessage, useIntl} from 'react-intl';
+
+import {CloseIcon, MicrophoneOutlineIcon, RefreshIcon} from '@mattermost/compass-icons/components';
+
+import {
+ AttachmentRootContainer,
+ CancelButton,
+ TextColumn,
+ Title,
+ Subtitle,
+} from 'components/advanced_text_editor/voice_message_attachment/components/file_attachment_containers';
+
+interface Props {
+ recordedAudio?: File;
+ onRetry: () => void;
+ onCancel: () => void;
+}
+
+const VoiceMessageRecordingFailed = (props: Props) => {
+ const intl = useIntl();
+
+ let errorMessage;
+ if (props.recordedAudio) {
+ errorMessage = intl.formatMessage({
+ id: 'voiceMessage.uploadFailed.retry',
+ defaultMessage: 'Upload failed. Click to retry.',
+ });
+ } else {
+ errorMessage = intl.formatMessage({
+ id: 'voiceMessage.uploadFailed.tryAgain',
+ defaultMessage: 'Upload failed. Please try again.',
+ });
+ }
+
+ return (
+
+ ) : (
+
+ )}
+ iconDanger={true}
+ onIconClick={props.recordedAudio ? props.onRetry : undefined}
+ >
+
+
+
+
+
+ {errorMessage}
+
+
+
+
+
+
+ );
+};
+
+export default VoiceMessageRecordingFailed;
diff --git a/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/upload_started/index.tsx b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/upload_started/index.tsx
new file mode 100644
index 0000000000..cb327cebdc
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/components/upload_started/index.tsx
@@ -0,0 +1,67 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {ProgressBar} from 'react-bootstrap';
+import {FormattedMessage} from 'react-intl';
+
+import {MicrophoneOutlineIcon, CloseIcon} from '@mattermost/compass-icons/components';
+
+import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
+
+import {
+ AttachmentRootContainer,
+ CancelButton,
+ TextColumn,
+ Title,
+ Subtitle,
+} from 'components/advanced_text_editor/voice_message_attachment/components/file_attachment_containers';
+
+interface Props {
+ theme: Theme;
+ progress: number;
+ onCancel: () => void;
+}
+
+const VoiceMessageUploadingStarted = (props: Props) => {
+ const percentageUploaded = `(${props.progress}%)`;
+
+ return (
+
+ )}
+ >
+
+
+
+
+
+
+ {percentageUploaded}
+
+
+
+
+
+
+
+ );
+};
+
+export default VoiceMessageUploadingStarted;
diff --git a/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/index.tsx b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/index.tsx
new file mode 100644
index 0000000000..0c3c3f91b8
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/voice_message_attachment/index.tsx
@@ -0,0 +1,191 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {FormEvent} from 'react';
+import React, {memo, useEffect, useRef} from 'react';
+import {useDispatch, useSelector} from 'react-redux';
+
+import type {Channel} from '@mattermost/types/channels';
+import type {ServerError} from '@mattermost/types/errors';
+import type {FileInfo, FileUploadResponse} from '@mattermost/types/files';
+import type {Post} from '@mattermost/types/posts';
+
+import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
+
+import {uploadFile} from 'actions/file_actions';
+
+import VoiceMessageRecordingStarted from 'components/advanced_text_editor/voice_message_attachment/components/recording_started';
+import VoiceMessageUploadingFailed from 'components/advanced_text_editor/voice_message_attachment/components/upload_failed';
+import VoiceMessageUploadingStarted from 'components/advanced_text_editor/voice_message_attachment/components/upload_started';
+import type {FilePreviewInfo} from 'components/file_preview/file_preview';
+import VoiceMessageAttachmentPlayer from 'components/voice_message_attachment_player';
+
+import {AudioFileExtensions, Locations} from 'utils/constants';
+import {VoiceMessageStates} from 'utils/post_utils';
+import {generateId} from 'utils/utils';
+
+import type {PostDraft} from 'types/store/draft';
+
+declare global {
+ interface Window {
+ webkitAudioContext: AudioContext;
+ }
+}
+
+interface Props {
+ channelId: Channel['id'];
+ rootId: Post['id'];
+ draft: PostDraft;
+ location: string;
+ vmState: VoiceMessageStates;
+ didUploadFail: boolean;
+ uploadingClientId: string;
+ setDraftAsPostType: (channelOrRootId: Channel['id'] | Post['id'], draft: PostDraft) => void;
+ onUploadStart: (clientIds: string, channelOrRootId: Channel['id'] | Post['id']) => void;
+ uploadProgress: number;
+ onUploadProgress: (filePreviewInfo: FilePreviewInfo) => void;
+ onUploadComplete: (fileInfos: FileInfo[], clientIds: string[], channelId: Channel['id'], rootId?: Post['id']) => void;
+ onUploadError: (err: string | ServerError, clientId?: string, channelId?: Channel['id'], rootId?: Post['id']) => void;
+ onRemoveDraft: (fileInfoIdOrClientId: FileInfo['id'] | string) => void;
+ onSubmit: (e: FormEvent) => void;
+}
+
+const VoiceMessageAttachment = (props: Props) => {
+ const theme = useSelector(getTheme);
+
+ const dispatch = useDispatch();
+
+ const xmlRequestRef = useRef();
+
+ const audioFileRef = useRef();
+
+ // eslint-disable-next-line consistent-return
+ useEffect(() => {
+ if (props.vmState === VoiceMessageStates.ATTACHED) {
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ e.preventDefault();
+ props.onSubmit(e as unknown as FormEvent);
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }
+ }, [props?.draft?.fileInfos?.[0]?.id, props.vmState]);
+
+ function handleOnUploadComplete(data: FileUploadResponse | undefined, channelId: string, rootId: string) {
+ if (data) {
+ props.onUploadComplete(data.file_infos, data.client_ids, channelId, rootId);
+ }
+ }
+
+ function uploadRecording(recordedAudioFile: File) {
+ const clientId = generateId();
+
+ xmlRequestRef.current = dispatch(uploadFile({
+ file: recordedAudioFile,
+ name: recordedAudioFile.name,
+ type: AudioFileExtensions.MP3,
+ rootId: props.rootId || '',
+ channelId: props.channelId,
+ clientId,
+ onProgress: props.onUploadProgress,
+ onSuccess: handleOnUploadComplete,
+ onError: props.onUploadError,
+ })) as unknown as XMLHttpRequest;
+
+ if (props.location === Locations.CENTER) {
+ props.onUploadStart(clientId, props.channelId);
+ }
+ if (props.location === Locations.RHS_COMMENT) {
+ props.onUploadStart(clientId, props.rootId);
+ }
+ }
+
+ function handleUploadRetryClicked() {
+ if (audioFileRef.current) {
+ uploadRecording(audioFileRef.current);
+ }
+ }
+
+ function handleRemoveBeforeUpload() {
+ if (xmlRequestRef.current) {
+ xmlRequestRef.current.abort();
+ }
+
+ props.onRemoveDraft(props.uploadingClientId);
+ }
+
+ function handleRemoveAfterUpload() {
+ const audioFileId = props.draft?.fileInfos?.[0]?.id ?? '';
+ if (audioFileId) {
+ props.onRemoveDraft(audioFileId);
+ }
+ }
+
+ async function handleCompleteRecordingClicked(audioFile: File) {
+ audioFileRef.current = audioFile;
+ uploadRecording(audioFile);
+ }
+
+ function handleCancelRecordingClicked() {
+ if (props.location === Locations.CENTER) {
+ props.setDraftAsPostType(props.channelId, props.draft);
+ }
+ if (props.location === Locations.RHS_COMMENT) {
+ props.setDraftAsPostType(props.rootId, props.draft);
+ }
+ }
+
+ if (props.vmState === VoiceMessageStates.RECORDING) {
+ return (
+
+ );
+ }
+
+ if (props.vmState === VoiceMessageStates.UPLOADING) {
+ return (
+
+ );
+ }
+
+ if (props.didUploadFail) {
+ return (
+
+ );
+ }
+
+ if (props.vmState === VoiceMessageStates.ATTACHED) {
+ const src = props?.draft?.fileInfos?.[0]?.id ?? '';
+
+ return (
+
+
+
+ );
+ }
+
+ return null;
+};
+
+export default memo(VoiceMessageAttachment);
diff --git a/webapp/channels/src/components/advanced_text_editor/voice_message_button/index.tsx b/webapp/channels/src/components/advanced_text_editor/voice_message_button/index.tsx
new file mode 100644
index 0000000000..9d60098d63
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/voice_message_button/index.tsx
@@ -0,0 +1,84 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {MicrophoneOutlineIcon} from '@infomaniak/compass-icons/components';
+import React, {memo} from 'react';
+import {useIntl, FormattedMessage} from 'react-intl';
+import {useSelector} from 'react-redux';
+
+import type {Channel} from '@mattermost/types/channels';
+import type {Post, PostType} from '@mattermost/types/posts';
+
+import {isVoiceMessagesEnabled} from 'selectors/views/textbox';
+
+import {IconContainer} from 'components/advanced_text_editor/formatting_bar/formatting_icon';
+import OverlayTrigger from 'components/overlay_trigger';
+import Tooltip from 'components/tooltip';
+
+import Constants, {Locations} from 'utils/constants';
+
+import type {PostDraft} from 'types/store/draft';
+
+interface Props {
+ channelId: Channel['id'];
+ rootId: Post['id'];
+ location: string;
+ draft: PostDraft;
+ disabled: boolean;
+ onClick: (channelOrRootId: Channel['id'] | Post['id'], draft: PostDraft, postType?: PostDraft['postType']) => void;
+}
+
+const VoiceButton = (props: Props) => {
+ const {formatMessage} = useIntl();
+
+ const isVoiceMessagesFeatureEnabled = useSelector(isVoiceMessagesEnabled);
+
+ const isVoiceRecordingBrowserSupported = navigator && navigator.mediaDevices !== undefined && navigator.mediaDevices.getUserMedia !== undefined;
+
+ if (!isVoiceMessagesFeatureEnabled || !isVoiceRecordingBrowserSupported) {
+ return null;
+ }
+
+ function handleOnClick() {
+ if (!props.disabled) {
+ if (props.location === Locations.CENTER) {
+ props.onClick(props.channelId, props.draft, Constants.PostTypes.VOICE as PostType);
+ }
+ if (props.location === Locations.RHS_COMMENT) {
+ props.onClick(props.rootId, props.draft, Constants.PostTypes.VOICE as PostType);
+ }
+ }
+ }
+
+ return (
+
+
+ }
+ >
+
+
+
+
+ );
+};
+
+export default memo(VoiceButton);
diff --git a/webapp/channels/src/components/channel_layout/center_channel/center_channel.test.tsx b/webapp/channels/src/components/channel_layout/center_channel/center_channel.test.tsx
index 25a956e07e..466a0c9caa 100644
--- a/webapp/channels/src/components/channel_layout/center_channel/center_channel.test.tsx
+++ b/webapp/channels/src/components/channel_layout/center_channel/center_channel.test.tsx
@@ -8,6 +8,8 @@ import CenterChannel from './center_channel';
import type {OwnProps} from './index';
+jest.mock('components/advanced_text_editor/voice_message_attachment', () => () => );
+
describe('components/channel_layout/CenterChannel', () => {
const props = {
location: {
diff --git a/webapp/channels/src/components/channel_layout/channel_identifier_router/channel_identifier_router.test.tsx b/webapp/channels/src/components/channel_layout/channel_identifier_router/channel_identifier_router.test.tsx
index ad3704db05..65391bdacf 100644
--- a/webapp/channels/src/components/channel_layout/channel_identifier_router/channel_identifier_router.test.tsx
+++ b/webapp/channels/src/components/channel_layout/channel_identifier_router/channel_identifier_router.test.tsx
@@ -10,6 +10,8 @@ import ChannelIdentifierRouter from './channel_identifier_router';
jest.useFakeTimers('legacy');
+jest.mock('components/advanced_text_editor/voice_message_attachment', () => () => );
+
describe('components/channel_layout/CenterChannel', () => {
const baseProps = {
diff --git a/webapp/channels/src/components/channel_view/channel_view.test.tsx b/webapp/channels/src/components/channel_view/channel_view.test.tsx
index 9b7b03e0d8..6da67b9806 100644
--- a/webapp/channels/src/components/channel_view/channel_view.test.tsx
+++ b/webapp/channels/src/components/channel_view/channel_view.test.tsx
@@ -7,6 +7,8 @@ import React from 'react';
import type {Props} from './channel_view';
import ChannelView from './channel_view';
+jest.mock('components/advanced_text_editor/voice_message_attachment', () => () => );
+
describe('components/channel_view', () => {
const baseProps: Props = {
channelId: 'channelId',
diff --git a/webapp/channels/src/components/common/hooks/useAudioPlayer.ts b/webapp/channels/src/components/common/hooks/useAudioPlayer.ts
new file mode 100644
index 0000000000..34a0819cad
--- /dev/null
+++ b/webapp/channels/src/components/common/hooks/useAudioPlayer.ts
@@ -0,0 +1,81 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useEffect, useState, useMemo} from 'react';
+
+export enum AudioPlayerState {
+ Playing = 'PLAYING',
+ Paused = 'PAUSED',
+ Stopped = 'STOPPED',
+}
+
+export function useAudioPlayer(src?: string) {
+ const [playerState, setPlayerState] = useState(AudioPlayerState.Stopped);
+ const [duration, setDuration] = useState(0);
+ const [elapsed, setElapsedTime] = useState(0);
+
+ // Create audio element with given audio source
+ const audio = useMemo(() => new Audio(src), [src]);
+
+ // Add event listeners to audio element
+ useEffect(() => {
+ let elapsedTimeRafId: number;
+
+ function onEnded() {
+ setPlayerState(AudioPlayerState.Stopped);
+ audio.currentTime = 0;
+ setElapsedTime(0);
+ }
+
+ function onLoadedData() {
+ if (audio.duration === Infinity || isNaN(Number(audio.duration))) {
+ audio.currentTime = 1e101;
+ return;
+ }
+ setDuration(audio.duration);
+ audio.currentTime = 0;
+ setElapsedTime(0);
+ }
+
+ function onTimeUpdate(event: any) {
+ if (duration !== event.target.duration) {
+ setDuration(event.target.duration);
+ setElapsedTime(0);
+ }
+ elapsedTimeRafId = requestAnimationFrame(() => {
+ setElapsedTime(audio.currentTime);
+ });
+ }
+
+ audio.addEventListener('ended', onEnded);
+ audio.addEventListener('loadeddata', onLoadedData);
+ audio.addEventListener('timeupdate', onTimeUpdate);
+
+ return () => {
+ audio.removeEventListener('ended', onEnded);
+ audio.removeEventListener('loadeddata', onLoadedData);
+ audio.removeEventListener('timeupdate', onTimeUpdate);
+ cancelAnimationFrame(elapsedTimeRafId);
+ };
+ }, [audio]);
+
+ function togglePlayPause() {
+ if (audio && audio.readyState === 4) {
+ const isPlaying = audio.currentTime > 0 && !audio.paused && !audio.ended;
+ if (isPlaying) {
+ audio.pause();
+ setPlayerState(AudioPlayerState.Paused);
+ } else {
+ audio.play();
+ setPlayerState(AudioPlayerState.Playing);
+ }
+ }
+ }
+
+ return {
+ playerState,
+ duration,
+ elapsed,
+ togglePlayPause,
+ };
+}
diff --git a/webapp/channels/src/components/common/hooks/useAudioRecorder.ts b/webapp/channels/src/components/common/hooks/useAudioRecorder.ts
new file mode 100644
index 0000000000..6a09f255ae
--- /dev/null
+++ b/webapp/channels/src/components/common/hooks/useAudioRecorder.ts
@@ -0,0 +1,259 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {RefObject} from 'react';
+import {useRef, useState} from 'react';
+import type {WasmMediaEncoder} from 'wasm-media-encoders';
+import {createEncoder} from 'wasm-media-encoders';
+
+import {AudioFileExtensions} from 'utils/constants';
+import {generateDateSpecificFileName} from 'utils/file_utils';
+
+declare global {
+ interface Window {
+ webkitAudioContext: AudioContext;
+ }
+}
+
+const MP3MimeType = 'audio/mpeg';
+const MAX_SCALE_FREQUENCY_DATA = 255;
+
+interface Props {
+ canvasElemRef: RefObject;
+ canvasBg: string;
+ canvasBarColor: string;
+ canvasBarWidth: number;
+ audioAnalyzerFFTSize: number;
+ reducedSampleSize: number;
+ minimumAmplitudePercentage: number;
+ audioFilePrefix: string;
+}
+
+export function useAudioRecorder(props: Props) {
+ const audioStream = useRef();
+ const audioContextRef = useRef();
+ const audioAnalyzerRef = useRef();
+ const audioScriptProcessorRef = useRef();
+ const audioEncoderRef = useRef>();
+
+ const [hasError, setError] = useState(false);
+
+ const amplitudeArrayRef = useRef();
+ const audioChunksRef = useRef([]);
+ const [recordedAudio, setRecordedAudio] = useState();
+
+ const visualizerRefreshRafId = useRef>();
+
+ const [elapsedTime, setElapsedTime] = useState(0);
+ const lastCountdownTimeRef = useRef((new Date()).getTime());
+ const elapsedTimerRefreshRafId = useRef>();
+
+ function visualizeAudio() {
+ function animateVisualizerRecursively(canvasContext: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number, spacing: number) {
+ if (amplitudeArrayRef.current && audioAnalyzerRef.current) {
+ // Copies new frequency data into the amplitudeArray
+ audioAnalyzerRef.current.getByteFrequencyData(amplitudeArrayRef.current);
+
+ const amplitudeNumArray = Array.from(amplitudeArrayRef.current);
+ const amplitudePercentageFrwArray = amplitudeNumArray.map((amp) => {
+ const ampPercentage = Math.floor((amp / MAX_SCALE_FREQUENCY_DATA) * 100);
+ if (ampPercentage < props.minimumAmplitudePercentage) {
+ return props.minimumAmplitudePercentage;
+ }
+ return ampPercentage;
+ }).slice(0, props.reducedSampleSize);
+ const amplitudePercentageRvArray = [...amplitudePercentageFrwArray].reverse();
+ const amplitudePercentageArray = [...amplitudePercentageRvArray, ...amplitudePercentageFrwArray];
+
+ // We need to clear the canvas before drawing the new visualizer changes
+ canvasContext.clearRect(0, 0, canvasWidth, -canvasHeight);
+ canvasContext.clearRect(0, 0, canvasWidth, canvasHeight);
+ canvasContext.restore();
+
+ amplitudePercentageArray.forEach((amplitude, index) => {
+ const xPoint = ((props.canvasBarWidth * (1 + (2 * index))) + (2 * index * spacing)) / 2;
+ const amplitudeHeight = (amplitude * canvasHeight) / (2 * 100);
+
+ // set properties of the visualizer bars
+ canvasContext.lineWidth = props.canvasBarWidth;
+ canvasContext.strokeStyle = props.canvasBarColor;
+ canvasContext.lineCap = 'round';
+ canvasContext.beginPath();
+ canvasContext.moveTo(xPoint, amplitudeHeight);
+ canvasContext.lineTo(xPoint, -amplitudeHeight);
+ canvasContext.stroke();
+ });
+
+ // Run the visualizer again on each animation frame
+ visualizerRefreshRafId.current = window.requestAnimationFrame(() => {
+ animateVisualizerRecursively(canvasContext, canvasWidth, canvasHeight, spacing);
+ });
+ }
+ }
+
+ // prepare the canvas
+ const visualizerCanvasContext = props.canvasElemRef.current?.getContext('2d');
+ const canvasWidth = Number(props.canvasElemRef?.current?.width ?? 0);
+ const canvasHeight = Number(props.canvasElemRef?.current?.height ?? 0);
+
+ if (visualizerCanvasContext && canvasWidth !== 0 && canvasHeight !== 0 && props.canvasElemRef && props.canvasElemRef.current) {
+ const spacing = (canvasWidth - ((props.reducedSampleSize * 2) * props.canvasBarWidth)) / ((props.reducedSampleSize * 2) - 1);
+
+ // Add background color to canvas
+ props.canvasElemRef.current.style.background = props.canvasBg;
+ visualizerCanvasContext.fillStyle = props.canvasBg;
+ visualizerCanvasContext.fillRect(0, 0, canvasWidth, canvasHeight);
+
+ // translate the canvas origin to middle of the height
+ visualizerCanvasContext.translate(0, canvasHeight / 2);
+ visualizerCanvasContext.save();
+
+ animateVisualizerRecursively(visualizerCanvasContext, canvasWidth, canvasHeight, spacing);
+ }
+ }
+
+ function startElapsedTimer() {
+ const currentTime = (new Date()).getTime();
+ const timeElapsed = currentTime - lastCountdownTimeRef.current;
+
+ if (timeElapsed >= 1000) {
+ setElapsedTime((elapsedTime) => elapsedTime + 1);
+ lastCountdownTimeRef.current = currentTime;
+ }
+
+ elapsedTimerRefreshRafId.current = requestAnimationFrame(() => {
+ startElapsedTimer();
+ });
+ }
+
+ function handleOnAudioDataAvailable(event: AudioProcessingEvent) {
+ const leftChannelInputData = event.inputBuffer.getChannelData(0);
+ const rightChannelInputData = event.inputBuffer.getChannelData(1);
+
+ const mp3Data = audioEncoderRef.current?.encode([leftChannelInputData, rightChannelInputData]);
+
+ if (mp3Data) {
+ audioChunksRef.current.push(new Uint8Array(mp3Data));
+ }
+ }
+
+ async function startRecording() {
+ try {
+ audioStream.current = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
+
+ const audioAnalyzer = audioContext.createAnalyser();
+ audioAnalyzer.fftSize = props.audioAnalyzerFFTSize;
+
+ const audioSourceNode = audioContext.createMediaStreamSource(audioStream.current);
+
+ // CHANGE LATER
+ // migrate to use Audio Worklet instead.
+ const wasmFileURL = new URL('wasm-media-encoders/wasm/mp3', import.meta.url);
+ audioEncoderRef.current = await createEncoder(MP3MimeType, wasmFileURL.href);
+
+ audioEncoderRef.current.configure({
+ sampleRate: 48000,
+ channels: 2,
+ vbrQuality: 2,
+ });
+
+ // CHANGE LATER
+ // migrate createScriptProcessor as it is deprecated.
+ const scriptProcessorNode = audioContext.createScriptProcessor(4096, 2, 2);
+
+ scriptProcessorNode.onaudioprocess = handleOnAudioDataAvailable;
+
+ audioSourceNode.connect(audioAnalyzer).connect(scriptProcessorNode);
+
+ const gainNode = audioContext.createGain();
+ gainNode.gain.value = 0;
+ scriptProcessorNode.connect(gainNode);
+ gainNode.connect(audioContext.destination);
+
+ amplitudeArrayRef.current = new Uint8Array(audioAnalyzer.frequencyBinCount);
+ audioContextRef.current = audioContext;
+ audioAnalyzerRef.current = audioAnalyzer;
+ audioScriptProcessorRef.current = scriptProcessorNode;
+
+ visualizeAudio();
+
+ lastCountdownTimeRef.current = (new Date()).getTime();
+ startElapsedTimer();
+ } catch (error) {
+ console.log('Error in recording', error); // eslint-disable-line no-console
+ setError(true);
+ }
+ }
+
+ async function cleanPostRecording(totalCleanup = false) {
+ if (audioStream.current) {
+ audioStream.current.getTracks().forEach((audioStreamTrack) => {
+ audioStreamTrack.stop();
+ });
+ }
+
+ if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
+ await audioContextRef.current.close();
+ }
+
+ if (audioScriptProcessorRef && audioScriptProcessorRef.current) {
+ audioScriptProcessorRef.current.disconnect();
+ }
+
+ if (visualizerRefreshRafId && visualizerRefreshRafId.current) {
+ cancelAnimationFrame(visualizerRefreshRafId.current);
+ }
+
+ if (elapsedTimerRefreshRafId && elapsedTimerRefreshRafId.current) {
+ cancelAnimationFrame(elapsedTimerRefreshRafId.current);
+ }
+
+ if (totalCleanup) {
+ audioChunksRef.current = [];
+ audioEncoderRef.current = undefined;
+ amplitudeArrayRef.current = undefined;
+ lastCountdownTimeRef.current = 0;
+ }
+ }
+
+ async function stopRecording() {
+ await cleanPostRecording();
+
+ if (audioChunksRef.current && audioEncoderRef.current) {
+ const mp3DataFinal = audioEncoderRef.current.finalize();
+ audioChunksRef.current.push(mp3DataFinal);
+
+ // create blob from audio chunks
+ const audioBlob = new Blob(audioChunksRef.current, {type: MP3MimeType});
+
+ const audioFileName = generateDateSpecificFileName(props.audioFilePrefix, `.${AudioFileExtensions.MP3}`);
+ const audioFile = new File([audioBlob], audioFileName, {
+ type: MP3MimeType,
+ lastModified: Date.now(),
+ });
+
+ setRecordedAudio(audioFile);
+
+ audioChunksRef.current = [];
+ audioEncoderRef.current = undefined;
+
+ return audioFile;
+ }
+
+ setError(true);
+ return undefined;
+ }
+
+ return {
+ startRecording,
+ elapsedTime,
+ stopRecording,
+ cleanPostRecording,
+ recordedAudio,
+ hasError,
+ };
+}
diff --git a/webapp/channels/src/components/file_attachment_list/file_attachment_list.tsx b/webapp/channels/src/components/file_attachment_list/file_attachment_list.tsx
index bc484723cb..038f9f6cfa 100644
--- a/webapp/channels/src/components/file_attachment_list/file_attachment_list.tsx
+++ b/webapp/channels/src/components/file_attachment_list/file_attachment_list.tsx
@@ -8,8 +8,9 @@ import {sortFileInfos} from 'mattermost-redux/utils/file_utils';
import FileAttachment from 'components/file_attachment';
import FilePreviewModal from 'components/file_preview_modal';
import SingleImageView from 'components/single_image_view';
+import VoiceMessageAttachmentPlayer from 'components/voice_message_attachment_player';
-import {FileTypes, ModalIdentifiers} from 'utils/constants';
+import Constants, {FileTypes, ModalIdentifiers} from 'utils/constants';
import {getFileType} from 'utils/utils';
import type {OwnProps, PropsFromRedux} from './index';
@@ -36,10 +37,19 @@ export default function FileAttachmentList(props: Props) {
fileCount,
locale,
isInPermalink,
+ post,
} = props;
const sortedFileInfos = useMemo(() => sortFileInfos(fileInfos ? [...fileInfos] : [], locale), [fileInfos, locale]);
- if (fileInfos && fileInfos.length === 1 && !fileInfos[0].archived) {
+ if (post.type === Constants.PostTypes.VOICE && fileInfos.length === 1) {
+ return (
+
+ );
+ } else if (fileInfos && fileInfos.length === 1 && !fileInfos[0].archived) {
const fileType = getFileType(fileInfos[0].extension);
if (fileType === FileTypes.IMAGE || (fileType === FileTypes.SVG && enableSVGs)) {
diff --git a/webapp/channels/src/components/file_preview/file_progress_preview.tsx b/webapp/channels/src/components/file_preview/file_progress_preview.tsx
index aa0c7902eb..83a9b48d28 100644
--- a/webapp/channels/src/components/file_preview/file_progress_preview.tsx
+++ b/webapp/channels/src/components/file_preview/file_progress_preview.tsx
@@ -65,7 +65,7 @@ export default class FileProgressPreview extends React.PureComponent {
if (percent) {
progressBar = (
diff --git a/webapp/channels/src/components/file_upload/file_upload.test.tsx b/webapp/channels/src/components/file_upload/file_upload.test.tsx
index 74af6d99e8..30d8c62737 100644
--- a/webapp/channels/src/components/file_upload/file_upload.test.tsx
+++ b/webapp/channels/src/components/file_upload/file_upload.test.tsx
@@ -73,6 +73,7 @@ describe('components/FileUpload', () => {
rootId: 'root_id',
pluginFileUploadMethods: [],
pluginFilesWillUploadHooks: [],
+ disabled: false,
actions: {
uploadFile,
},
@@ -95,7 +96,7 @@ describe('components/FileUpload', () => {
expect(baseProps.onClick).toHaveBeenCalledTimes(1);
});
- test('should prevent event default and progogation on call of onTouchEnd on fileInput', () => {
+ test('should prevent event default and propagation on call of onTouchEnd on fileInput', () => {
const wrapper = shallowWithIntl(
,
);
@@ -115,7 +116,7 @@ describe('components/FileUpload', () => {
expect(instance.handleLocalFileUploaded).toHaveBeenCalled();
});
- test('should prevent event default and progogation on call of onClick on fileInput', () => {
+ test('should prevent event default and propagation on call of onClick on fileInput', () => {
const wrapper = shallowWithIntl(
,
);
@@ -412,4 +413,14 @@ describe('components/FileUpload', () => {
expect(baseProps.onUploadError).toHaveBeenCalledTimes(1);
expect(baseProps.onUploadError).toHaveBeenCalledWith('');
});
+
+ test('Should disable when disabled prop is passed', () => {
+ const props = {...baseProps, disabled: true};
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ expect(wrapper.find('button').prop('disabled')).toEqual(true);
+ expect(wrapper.find('input').prop('disabled')).toEqual(true);
+ });
});
diff --git a/webapp/channels/src/components/file_upload/file_upload.tsx b/webapp/channels/src/components/file_upload/file_upload.tsx
index 7cfa6f6a44..e7917440d5 100644
--- a/webapp/channels/src/components/file_upload/file_upload.tsx
+++ b/webapp/channels/src/components/file_upload/file_upload.tsx
@@ -23,6 +23,7 @@ import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import Constants from 'utils/constants';
import DelayedAction from 'utils/delayed_action';
import dragster from 'utils/dragster';
+import {generateDateSpecificFileName} from 'utils/file_utils';
import {t} from 'utils/i18n';
import {getTable} from 'utils/paste';
import {
@@ -105,6 +106,7 @@ export type Props = {
intl: IntlShape;
locale: string;
+ disabled: boolean;
/**
* Function to be called when file upload input is clicked
@@ -502,10 +504,6 @@ export class FileUpload extends PureComponent {
continue;
}
- const now = new Date();
- const hour = now.getHours().toString().padStart(2, '0');
- const minute = now.getMinutes().toString().padStart(2, '0');
-
let ext = '';
if (file.name && file.name.includes('.')) {
ext = file.name.substr(file.name.lastIndexOf('.'));
@@ -513,7 +511,7 @@ export class FileUpload extends PureComponent {
ext = '.' + items[i].type.split('/')[1].toLowerCase();
}
- const name = file.name || formatMessage(holders.pasted) + now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate() + ' ' + hour + '-' + minute + ext;
+ const name = file.name || generateDateSpecificFileName(formatMessage(holders.pasted), ext);
const newFile: File = new File([file], name, {type: file.type});
files.push(newFile);
@@ -656,6 +654,7 @@ export class FileUpload extends PureComponent {