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} + > + + + <FormattedMessage + id='voiceMessage.preview.title' + defaultMessage='Voice message' + /> + + + {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} + > + + + <FormattedMessage + id='voiceMessage.preview.title' + defaultMessage='Voice message' + /> + + + {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 ( + + )} + > + + + <FormattedMessage + id='voiceMessage.preview.title' + defaultMessage='Voice message' + /> + + + + {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 {
); @@ -735,6 +735,7 @@ export class FileUpload extends PureComponent { onClick={this.handleLocalFileUploaded} multiple={multiple} accept={accept} + disabled={this.props.disabled} /> { type='button' id='fileUploadButton' aria-label={buttonAriaLabel} + disabled={this.props.disabled} className='style--none AdvancedTextEditor__action-button' > { href='#' onClick={this.simulateInputClick} onTouchEnd={this.simulateInputClick} + disabled={this.props.disabled} > diff --git a/webapp/channels/src/components/rhs_thread/rhs_thread.test.tsx b/webapp/channels/src/components/rhs_thread/rhs_thread.test.tsx index 287de1190b..4d9755f0f9 100644 --- a/webapp/channels/src/components/rhs_thread/rhs_thread.test.tsx +++ b/webapp/channels/src/components/rhs_thread/rhs_thread.test.tsx @@ -21,6 +21,8 @@ jest.mock('react-redux', () => ({ useDispatch: () => mockDispatch, })); +jest.mock('components/advanced_text_editor/voice_message_attachment', () => () =>
); + describe('components/RhsThread', () => { const post: Post = TestHelper.getPostMock({ channel_id: 'channel_id', diff --git a/webapp/channels/src/components/root/root.test.tsx b/webapp/channels/src/components/root/root.test.tsx index f218169eef..96712352bf 100644 --- a/webapp/channels/src/components/root/root.test.tsx +++ b/webapp/channels/src/components/root/root.test.tsx @@ -42,6 +42,8 @@ jest.mock('mattermost-redux/actions/general', () => ({ setUrl: () => {}, })); +jest.mock('components/advanced_text_editor/voice_message_attachment', () => () =>
); + describe('components/Root', () => { const baseProps = { telemetryEnabled: true, diff --git a/webapp/channels/src/components/textbox/textbox.tsx b/webapp/channels/src/components/textbox/textbox.tsx index 3ccd93b299..f546074df4 100644 --- a/webapp/channels/src/components/textbox/textbox.tsx +++ b/webapp/channels/src/components/textbox/textbox.tsx @@ -55,6 +55,7 @@ export type Props = { emojiEnabled?: boolean; characterLimit: number; disabled?: boolean; + hidden?: boolean; badConnection?: boolean; currentUserId: string; currentTeamId: string; @@ -85,6 +86,7 @@ export default class Textbox extends React.PureComponent { supportsCommands: true, inputComponent: AutosizeTextarea, suggestionList: SuggestionList, + hidden: false, }; constructor(props: Props) { @@ -293,7 +295,7 @@ export default class Textbox extends React.PureComponent { return (
() =>
); + describe('components/threading/ThreadViewer', () => { const post: Post = TestHelper.getPostMock({ channel_id: 'channel_id', diff --git a/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.test.tsx b/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.test.tsx index 942dbbdca4..c6cecbf42f 100644 --- a/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.test.tsx +++ b/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.test.tsx @@ -12,6 +12,8 @@ import {TestHelper} from 'utils/test_helper'; import VirtualizedThreadViewer from './virtualized_thread_viewer'; +jest.mock('components/advanced_text_editor/voice_message_attachment', () => () =>
); + describe('components/threading/VirtualizedThreadViewer', () => { const post: Post = TestHelper.getPostMock({ channel_id: 'channel_id', diff --git a/webapp/channels/src/components/voice_message_attachment_player/index.tsx b/webapp/channels/src/components/voice_message_attachment_player/index.tsx new file mode 100644 index 0000000000..e8b2aa5bbb --- /dev/null +++ b/webapp/channels/src/components/voice_message_attachment_player/index.tsx @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import { + PlayIcon, + PauseIcon, + DotsVerticalIcon, + CloseIcon, + LinkVariantIcon, + DownloadOutlineIcon, +} from '@infomaniak/compass-icons/components'; +import React from 'react'; +import {useIntl} from 'react-intl'; + +import type {FileInfo} from '@mattermost/types/files'; +import type {Post} from '@mattermost/types/posts'; + +import {getFileDownloadUrl} from 'mattermost-redux/utils/file_utils'; + +import { + AudioPlayerState, + useAudioPlayer, +} from 'components/common/hooks/useAudioPlayer'; +import Menu from 'components/widgets/menu/menu'; +import MenuWrapper from 'components/widgets/menu/menu_wrapper'; + +import {convertSecondsToMSS} from 'utils/datetime'; +import {getSiteURL} from 'utils/url'; +import {copyToClipboard} from 'utils/utils'; + +interface Props { + postId?: Post['id']; + fileId: FileInfo['id']; + inPost?: boolean; + onCancel?: () => void; +} + +function VoiceMessageAttachmentPlayer(props: Props) { + const {formatMessage} = useIntl(); + + const {playerState, duration, elapsed, togglePlayPause} = useAudioPlayer(props.fileId ? `/api/v4/files/${props.fileId}` : ''); + + const progressValue = elapsed === 0 || duration === 0 ? '0' : (elapsed / duration).toFixed(2); + + function copyLink() { + copyToClipboard(`${getSiteURL()}/api/v4/files/${props.fileId}`); + } + + function downloadFile() { + window.location.assign(getFileDownloadUrl(props.fileId)); + } + + return ( +
+
+
+ {playerState === AudioPlayerState.Playing ? ( + + ) : ( + + )} +
+
+
+
+
+
+ +
+
+
+
+ {playerState === AudioPlayerState.Playing || playerState === AudioPlayerState.Paused ? convertSecondsToMSS(elapsed) : convertSecondsToMSS(duration)} +
+ {props.inPost && ( + + + + + } + text={formatMessage({id: 'voiceMessageAttachment.copyLink', defaultMessage: 'Copy link'})} + onClick={copyLink} + /> + + } + text={formatMessage({id: 'voiceMessageAttachment.download', defaultMessage: 'Download'})} + onClick={downloadFile} + /> + + + )} + {!props.inPost && ( + + )} +
+
+ ); +} + +export default VoiceMessageAttachmentPlayer; diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts index d7ae9f4227..c395878e8b 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/posts.ts @@ -35,6 +35,7 @@ export const PostTypes = { CALL: 'custom_call' as PostType, SYSTEM_POST_REMINDER: 'system_post_reminder' as PostType, CHANGE_CHANNEL_PRIVACY: 'system_change_chan_privacy' as PostType, + VOICE: 'voice' as PostType, }; export default { diff --git a/webapp/channels/src/sass/components/_files.scss b/webapp/channels/src/sass/components/_files.scss index f8c3ba88a8..4fe53accd2 100644 --- a/webapp/channels/src/sass/components/_files.scss +++ b/webapp/channels/src/sass/components/_files.scss @@ -464,6 +464,52 @@ justify-content: center; } +.post-image__icon-background { + display: flex; + width: 40px; + height: 40px; + align-items: center; + justify-content: center; + background-color: rgba(var(--button-bg-rgb), 0.12); + border-radius: 50%; +} + +.post-image__end-button { + display: flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border: 0; + margin: 0 8px; + background-color: var(--center-channel-bg); + border-radius: 4px; + color: rgba(var(--center-channel-color-rgb), 0.56); + cursor: pointer; + + &: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); + } +} + +.post-image__elapsed-time { + color: var(--center-channel-color); + font-size: 14px; + font-weight: 400; + line-height: 20px; + padding-inline: 1.5rem; +} + .file-dropdown-icon { display: none; width: 32px; @@ -656,21 +702,48 @@ padding-left: 8px; } - .post-image__progressBar { - position: absolute; - bottom: 0; + .post-image__uploadingTxt { + color: rgba(v(center-channel-color-rgb), 0.56); + } +} + +.attachments-progress-bar { + position: absolute; + bottom: 0; + width: 100%; + height: 7px; + margin-bottom: 0; + border-radius: 0; + + & > .progress-bar { + background-color: var(--button-bg); + } +} + +.temp__audio-seeker { + width: 100%; + + progress { + display: block; width: 100%; - height: 7px; - margin-bottom: 0; - border-radius: 0; + height: 4px; + border: 0 none; + background: rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; } - .progress-bar { - background-color: rgb(40, 90, 185); + progress::-moz-progress-bar { + background: var(--button-bg); + border-radius: 4px; } - .post-image__uploadingTxt { - color: rgba(v(center-channel-color-rgb), 0.56); + progress::-webkit-progress-bar { + background: transparent; + } + + progress::-webkit-progress-value { + background: var(--button-bg); + border-radius: 4px; } } diff --git a/webapp/channels/src/sass/components/_post.scss b/webapp/channels/src/sass/components/_post.scss index 5d77ce09d2..19b0ed1bfe 100644 --- a/webapp/channels/src/sass/components/_post.scss +++ b/webapp/channels/src/sass/components/_post.scss @@ -68,6 +68,10 @@ .textbox-preview-link { margin-right: 8px; } + + &.hidden { + display: none; + } } .help__format-text { diff --git a/webapp/channels/src/selectors/views/textbox.test.ts b/webapp/channels/src/selectors/views/textbox.test.ts new file mode 100644 index 0000000000..78b361ac2c --- /dev/null +++ b/webapp/channels/src/selectors/views/textbox.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {isVoiceMessagesEnabled} from 'selectors/views/textbox'; + +import type {GlobalState} from 'types/store'; + +describe('selectors/views/textbox', () => { + it('isVoiceMessagesEnabled', () => { + expect(isVoiceMessagesEnabled({ + entities: { + general: { + config: { + EnableFileAttachments: 'true', + FeatureFlagVoiceMessages: 'true', + EnableVoiceMessages: 'true', + }, + }, + }, + } as GlobalState)).toEqual(true); + + [ + ['false', 'false', 'false'], + ['false', 'false', 'true'], + ['false', 'true', 'false'], + ['false', 'true', 'true'], + ['true', 'false', 'false'], + ['true', 'false', 'true'], + ['true', 'true', 'false'], + ].forEach((config) => { + expect(isVoiceMessagesEnabled({ + entities: { + general: { + config: { + EnableFileAttachments: config[0], + FeatureFlagVoiceMessages: config[1], + EnableVoiceMessages: config[2], + }, + }, + }, + } as GlobalState)).toEqual(false); + }); + }); +}); diff --git a/webapp/channels/src/selectors/views/textbox.ts b/webapp/channels/src/selectors/views/textbox.ts index 0dd78cf917..5635ab878f 100644 --- a/webapp/channels/src/selectors/views/textbox.ts +++ b/webapp/channels/src/selectors/views/textbox.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {getConfig, getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; + import type {GlobalState} from 'types/store'; export function showPreviewOnCreateComment(state: GlobalState) { @@ -14,3 +16,18 @@ export function showPreviewOnCreatePost(state: GlobalState) { export function showPreviewOnEditChannelHeaderModal(state: GlobalState) { return state.views.textbox.shouldShowPreviewOnEditChannelHeaderModal; } + +export function isVoiceMessagesEnabled(state: GlobalState): boolean { + const config = getConfig(state); + + const fileAttachmentsEnabled = config.EnableFileAttachments === 'true'; + const voiceMessageFeatureFlagEnabled = getFeatureFlagValue(state, 'EnableVoiceMessages') === 'true'; + const voiceMessagesEnabled = config.ExperimentalEnableVoiceMessage === 'true'; + + return (fileAttachmentsEnabled && voiceMessagesEnabled && voiceMessageFeatureFlagEnabled); +} + +export function getVoiceMessageMaxDuration(state: GlobalState): number { + const config = getConfig(state); + return parseInt(config.MaxVoiceMessagesDuration as string, 10); +} diff --git a/webapp/channels/src/types/store/draft.ts b/webapp/channels/src/types/store/draft.ts index 759a974a78..18d8f39f72 100644 --- a/webapp/channels/src/types/store/draft.ts +++ b/webapp/channels/src/types/store/draft.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import type {FileInfo} from '@mattermost/types/files'; -import type {PostPriority} from '@mattermost/types/posts'; +import type {PostType, PostPriority} from '@mattermost/types/posts'; export type DraftInfo = { id: string; @@ -20,6 +20,7 @@ export type PostDraft = { uploadsInProgress: string[]; props?: any; caretPosition?: number; + postType?: PostType; channelId: string; rootId: string; createAt: number; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 1afdfd6124..c37338a95d 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -889,6 +889,7 @@ export const PostTypes = { EPHEMERAL_ADD_TO_CHANNEL: 'system_ephemeral_add_to_channel', REMOVE_LINK_PREVIEW: 'remove_link_preview', ME: 'me', + VOICE: 'voice', REMINDER: 'reminder', CUSTOM_CALLS: 'custom_calls', CUSTOM_CALLS_RECORDING: 'custom_calls_recording', @@ -918,6 +919,17 @@ export const StatTypes = keyMirror({ MONTHLY_ACTIVE_USERS: null, }); +export const AudioFileExtensions = { + MP3: 'mp3', + WAV: 'wav', + OGG: 'ogg', + WMA: 'wma', + M4A: 'm4a', + AAC: 'aac', + FLAC: 'flac', + M4R: 'm4r', +}; + export const SearchUserTeamFilter = { ALL_USERS: '', NO_TEAM: 'no_team', @@ -1535,7 +1547,7 @@ export const Constants = { IMAGE_TYPE_GIF: 'gif', TEXT_TYPES: ['txt', 'rtf', 'vtt'], IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif', 'psd', 'heic', 'HEIC', 'avif'], - AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg', 'm4r'], + AUDIO_TYPES: [AudioFileExtensions.MP3, AudioFileExtensions.WAV, AudioFileExtensions.WMA, AudioFileExtensions.M4A, AudioFileExtensions.FLAC, AudioFileExtensions.AAC, AudioFileExtensions.OGG, AudioFileExtensions.M4R], VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv'], PRESENTATION_TYPES: ['ppt', 'pptx'], SPREADSHEET_TYPES: ['xlsx', 'csv'], diff --git a/webapp/channels/src/utils/datetime.ts b/webapp/channels/src/utils/datetime.ts index 732a714bd2..a0005b8019 100644 --- a/webapp/channels/src/utils/datetime.ts +++ b/webapp/channels/src/utils/datetime.ts @@ -88,3 +88,11 @@ export function toUTCUnix(date: Date): number { return Math.round(new Date(date.toISOString()).getTime() / 1000); } +export function convertSecondsToMSS(seconds: number) { + const minutes = Math.floor(seconds / 60); + + const secondsLeft = Math.floor(seconds - (minutes * 60)); + const secondsPadded = secondsLeft.toString().padStart(2, '0'); + + return `${minutes}:${secondsPadded}`; +} diff --git a/webapp/channels/src/utils/file_utils.tsx b/webapp/channels/src/utils/file_utils.tsx index bbfdfa5ae1..c16b4ea8fc 100644 --- a/webapp/channels/src/utils/file_utils.tsx +++ b/webapp/channels/src/utils/file_utils.tsx @@ -126,3 +126,16 @@ export function getOrientationStyles(orientation: number) { } = exif2css(orientation); return {transform, transformOrigin}; } + +/** + * @param prefix eg. "user_attachment" + * @param ext eg. ".png", ".jpg" + */ +export function generateDateSpecificFileName(prefix: string, ext: string) { + const now = new Date(); + const hour = now.getHours().toString().padStart(2, '0'); + const minute = now.getMinutes().toString().padStart(2, '0'); + const name = prefix + now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate() + ' ' + hour + '-' + minute + ext; + + return name; +} diff --git a/webapp/channels/src/utils/post_utils.ts b/webapp/channels/src/utils/post_utils.ts index 219d6cf190..659437b2f8 100644 --- a/webapp/channels/src/utils/post_utils.ts +++ b/webapp/channels/src/utils/post_utils.ts @@ -31,7 +31,7 @@ import {displayUsername} from 'mattermost-redux/utils/user_utils'; import {getEmojiMap} from 'selectors/emojis'; import {getIsMobileView} from 'selectors/views/browser'; -import Constants, {PostListRowListIds, Preferences} from 'utils/constants'; +import Constants, {PostListRowListIds, PostTypes, Preferences} from 'utils/constants'; import {formatWithRenderer} from 'utils/markdown'; import MentionableRenderer from 'utils/markdown/mentionable_renderer'; import {allAtMentions} from 'utils/text_formatting'; @@ -39,6 +39,7 @@ import {isMobile} from 'utils/user_agent'; import * as Utils from 'utils/utils'; import type {GlobalState} from 'types/store'; +import type {PostDraft} from 'types/store/draft'; import type EmojiMap from './emoji_map'; import * as Emoticons from './emoticons'; @@ -786,3 +787,22 @@ export function getMentionDetails(usersByUsername: Record