diff --git a/app/graphql/mutations/add_conversation_message.rb b/app/graphql/mutations/add_conversation_message.rb index b4d06db81a837..fa06964ae1876 100644 --- a/app/graphql/mutations/add_conversation_message.rb +++ b/app/graphql/mutations/add_conversation_message.rb @@ -28,7 +28,7 @@ class Mutations::AddConversationMessage < Mutations::BaseMutation argument :recipients, [String], required: true argument :included_messages, [ID], required: false, prepare: GraphQLHelpers.relay_or_legacy_ids_prepare_func('ConversationMessage') argument :attachment_ids, [ID], required: false, prepare: GraphQLHelpers.relay_or_legacy_ids_prepare_func('Attachment') - argument :media_comment_id, ID, required: false, prepare: GraphQLHelpers.relay_or_legacy_id_prepare_func('MediaObject') + argument :media_comment_id, ID, required: false argument :media_comment_type, String, required: false argument :user_note, Boolean, required: false diff --git a/app/graphql/mutations/create_conversation.rb b/app/graphql/mutations/create_conversation.rb index 0accbce276e78..397826731c474 100644 --- a/app/graphql/mutations/create_conversation.rb +++ b/app/graphql/mutations/create_conversation.rb @@ -30,7 +30,7 @@ class Mutations::CreateConversation < Mutations::BaseMutation argument :force_new, Boolean, required: false argument :group_conversation, Boolean, required: false argument :attachment_ids, [ID], required: false, prepare: GraphQLHelpers.relay_or_legacy_ids_prepare_func('Attachment') - argument :media_comment_id, ID, required: false, prepare: GraphQLHelpers.relay_or_legacy_id_prepare_func('MediaObject') + argument :media_comment_id, ID, required: false argument :media_comment_type, String, required: false argument :context_code, String, required: false argument :conversation_id, ID, required: false, prepare: GraphQLHelpers.relay_or_legacy_id_prepare_func('Conversation') diff --git a/app/helpers/conversations_helper.rb b/app/helpers/conversations_helper.rb index 5ca7db23042ac..b802e0c7f8471 100644 --- a/app/helpers/conversations_helper.rb +++ b/app/helpers/conversations_helper.rb @@ -220,23 +220,23 @@ def build_message_args( attachment_ids: attachment_ids, forwarded_message_ids: forwarded_message_ids, root_account_id: domain_root_account_id, - media_comment: infer_media_comment(media_comment_id, media_comment_type), + media_comment: infer_media_comment(media_comment_id, media_comment_type, domain_root_account_id, current_user), generate_user_note: user_note } ] end - def infer_media_comment(media_id, media_type) + def infer_media_comment(media_id, media_type, root_account_id, user) if media_id.present? && media_type.present? media_comment = MediaObject.by_media_id(media_id).first unless media_comment media_comment ||= MediaObject.new media_comment.media_type = media_type media_comment.media_id = media_id - media_comment.root_account_id = @domain_root_account.id - media_comment.user = @current_user + media_comment.root_account_id = root_account_id + media_comment.user = user end - media_comment.context = @current_user + media_comment.context = user media_comment.save media_comment end diff --git a/ui/features/inbox/react/components/ComposeActionButtons/ComposeActionButtons.js b/ui/features/inbox/react/components/ComposeActionButtons/ComposeActionButtons.js index e1f449e6713da..fbfa4a58d9bda 100644 --- a/ui/features/inbox/react/components/ComposeActionButtons/ComposeActionButtons.js +++ b/ui/features/inbox/react/components/ComposeActionButtons/ComposeActionButtons.js @@ -65,6 +65,7 @@ const renderUploadButtons = props => { onClick={props.onMediaUpload} margin="xx-small" data-testid="media-upload" + interaction={props.hasMediaComment ? 'disabled' : 'enabled'} > @@ -118,7 +119,11 @@ ComposeActionButtons.propTypes = { /** * Indicates that a message is currently being sent */ - isSending: PropTypes.bool.isRequired + isSending: PropTypes.bool.isRequired, + /** + * Indicates whether or not there is a media comment already attached + */ + hasMediaComment: PropTypes.bool } export default ComposeActionButtons diff --git a/ui/features/inbox/react/components/ComposeActionButtons/__tests__/ComposeActionButtons.test.js b/ui/features/inbox/react/components/ComposeActionButtons/__tests__/ComposeActionButtons.test.js index 387ed922c1a3e..a31eeda86b9ec 100644 --- a/ui/features/inbox/react/components/ComposeActionButtons/__tests__/ComposeActionButtons.test.js +++ b/ui/features/inbox/react/components/ComposeActionButtons/__tests__/ComposeActionButtons.test.js @@ -57,6 +57,12 @@ describe('ComposeActionButtons', () => { expect(queryByTestId('media-upload')).toBe(null) }) }) + + it('disables the media upload button if hasMediaComment is true', () => { + const props = createProps({hasMediaComment: true}) + const container = render() + expect(container.getByTestId('media-upload')).toBeDisabled() + }) }) describe('message cancel button', () => { diff --git a/ui/features/inbox/react/containers/ComposeModalContainer/ComposeModalContainer.js b/ui/features/inbox/react/containers/ComposeModalContainer/ComposeModalContainer.js index 1be30f4df8c42..a639dec1b122d 100644 --- a/ui/features/inbox/react/containers/ComposeModalContainer/ComposeModalContainer.js +++ b/ui/features/inbox/react/containers/ComposeModalContainer/ComposeModalContainer.js @@ -17,18 +17,20 @@ */ import {AlertManagerContext} from '@canvas/alerts/react/AlertManager' +import closedCaptionLanguages from '@canvas/util/closedCaptionLanguages' import {ComposeActionButtons} from '../../components/ComposeActionButtons/ComposeActionButtons' import {Conversation} from '../../../graphql/Conversation' import HeaderInputs from './HeaderInputs' import I18n from 'i18n!conversations_2' +import {Modal} from '@instructure/ui-modal' import ModalBody from './ModalBody' import ModalHeader from './ModalHeader' import ModalSpinner from './ModalSpinner' import PropTypes from 'prop-types' import React, {useContext, useState} from 'react' import {uploadFiles} from '@canvas/upload-file' - -import {Modal} from '@instructure/ui-modal' +import UploadMedia from '@instructure/canvas-media' +import {MediaCaptureStrings, SelectStrings, UploadMediaStrings} from '../../../util/constants' const ComposeModalContainer = props => { const {setOnFailure, setOnSuccess} = useContext(AlertManagerContext) @@ -40,6 +42,22 @@ const ComposeModalContainer = props => { const [bodyMessages, setBodyMessages] = useState([]) const [sendIndividualMessages, setSendIndividualMessages] = useState(false) const [selectedContext, setSelectedContext] = useState() + const [mediaUploadOpen, setMediaUploadOpen] = useState(false) + const [uploadingMediaFile, setUploadingMediaFile] = useState(false) + const [mediaUploadFile, setMediaUploadFile] = useState(null) + + const onMediaUploadComplete = (err, data) => { + if (err) { + setOnFailure(I18n.t('There was an error uploading the media.')) + } else { + setUploadingMediaFile(false) + setMediaUploadFile(data) + } + } + + const onRemoveMedia = () => { + setMediaUploadFile(null) + } const fileUploadUrl = attachmentFolderId => { return `/api/v1/folders/${attachmentFolderId}/files` @@ -126,7 +144,9 @@ const ComposeModalContainer = props => { body, includedMessages: props.pastConversation?.conversationMessagesConnection.nodes.map( c => c._id - ) + ), + mediaCommentId: mediaUploadFile?.mediaObject?.media_object?.media_id, + mediaCommentType: mediaUploadFile?.mediaObject?.media_object?.media_type } }) } else { @@ -137,7 +157,9 @@ const ComposeModalContainer = props => { contextCode: selectedContext, recipients: ['5'], // TODO: replace this with selected users subject, - groupConversation: !sendIndividualMessages + groupConversation: !sendIndividualMessages, + mediaCommentId: mediaUploadFile?.mediaObject?.media_object?.media_id, + mediaCommentType: mediaUploadFile?.mediaObject?.media_object?.media_type } }) } @@ -154,6 +176,7 @@ const ComposeModalContainer = props => { props.setSendingMessage(false) setSubject(null) setSendIndividualMessages(false) + setMediaUploadFile(null) } return ( @@ -184,12 +207,14 @@ const ComposeModalContainer = props => { onSubjectChange={onSubjectChange} sendIndividualMessages={sendIndividualMessages} subject={props.isReply ? props.pastConversation?.subject : subject} + mediaAttachmentTitle={mediaUploadFile?.uploadedFile.name} + onRemoveMediaComment={onRemoveMedia} /> {}} + onMediaUpload={() => setMediaUploadOpen(true)} onCancel={props.onDismiss} onSend={() => { if (!validMessageFields()) { @@ -202,19 +227,40 @@ const ComposeModalContainer = props => { props.setSendingMessage(true) }} isSending={false} + hasMediaComment={!!mediaUploadFile} /> + setUploadingMediaFile(true)} + onUploadComplete={onMediaUploadComplete} + onDismiss={() => setMediaUploadOpen(false)} + open={mediaUploadOpen} + tabs={{embed: false, record: true, upload: true}} + uploadMediaTranslations={{ + UploadMediaStrings: UploadMediaStrings(), + MediaCaptureStrings: MediaCaptureStrings(), + SelectStrings: SelectStrings() + }} + liveRegion={() => document.getElementById('flash_screenreader_holder')} + languages={Object.keys(closedCaptionLanguages).map(key => { + return {id: key, label: closedCaptionLanguages[key]} + })} + rcsConfig={{ + contextId: ENV.current_user_id, + contextType: 'user' + }} + /> sendMessage()} - open={props.sendingMessage && !!attachmentsToUpload.length} + open={props.sendingMessage && !!attachmentsToUpload.length && uploadingMediaFile} /> ) diff --git a/ui/features/inbox/react/containers/ComposeModalContainer/HeaderInputs.js b/ui/features/inbox/react/containers/ComposeModalContainer/HeaderInputs.js index f694358e207da..52ba7f0418390 100644 --- a/ui/features/inbox/react/containers/ComposeModalContainer/HeaderInputs.js +++ b/ui/features/inbox/react/containers/ComposeModalContainer/HeaderInputs.js @@ -26,10 +26,11 @@ import {reduceDuplicateCourses} from '../../../util/courses_helper' import {SubjectInput} from '../../components/SubjectInput/SubjectInput' import {Flex} from '@instructure/ui-flex' +import {MediaAttachment} from '../../components/MediaAttachment/MediaAttachment' import {PresentationContent} from '@instructure/ui-a11y-content' import {Text} from '@instructure/ui-text' -const HeaderInputs = (props) => { +const HeaderInputs = props => { let moreCourses if (!props.isReply) { moreCourses = reduceDuplicateCourses( @@ -57,7 +58,7 @@ const HeaderInputs = (props) => { favoriteCourses: props.courses?.favoriteCoursesConnection.nodes, moreCourses, concludedCourses: [], - groups: props.courses?.favoriteGroupsConnection.nodes, + groups: props.courses?.favoriteGroupsConnection.nodes }} onCourseFilterSelect={props.onContextSelect} /> @@ -91,6 +92,19 @@ const HeaderInputs = (props) => { /> )} + {props.mediaAttachmentTitle && ( + + + } + /> + + )} ) } @@ -104,6 +118,8 @@ HeaderInputs.propTypes = { onSubjectChange: PropTypes.func, sendIndividualMessages: PropTypes.bool, subject: PropTypes.string, + mediaAttachmentTitle: PropTypes.string, + onRemoveMediaComment: PropTypes.func } export default HeaderInputs diff --git a/ui/features/inbox/react/containers/ComposeModalContainer/__tests__/HeaderInputs.test.js b/ui/features/inbox/react/containers/ComposeModalContainer/__tests__/HeaderInputs.test.js new file mode 100644 index 0000000000000..4e65d95c8de45 --- /dev/null +++ b/ui/features/inbox/react/containers/ComposeModalContainer/__tests__/HeaderInputs.test.js @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2020 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import {Course} from '../../../../graphql/Course' +import {Enrollment} from '../../../../graphql/Enrollment' +import {fireEvent, render} from '@testing-library/react' +import {Group} from '../../../../graphql/Group' +import HeaderInputs from '../HeaderInputs' +import React from 'react' + +describe('HeaderInputs', () => { + const defaultProps = () => ({ + courses: { + favoriteGroupsConnection: { + nodes: [Group.mock()] + }, + favoriteCoursesConnection: { + nodes: [Course.mock()] + }, + enrollments: [Enrollment.mock()] + }, + onContextSelect: jest.fn(), + onSendIndividualMessagesChange: jest.fn(), + onSubjectChange: jest.fn(), + onRemoveMediaComment: jest.fn() + }) + + describe('Media Comments', () => { + it('does not render a media comment if one is not provided', () => { + const container = render() + expect(container.queryByTestId('media-attachment')).toBeNull() + }) + + it('does render a media comment if one is provided', () => { + const container = render( + + ) + expect(container.getByTestId('media-attachment')).toBeInTheDocument() + expect(container.getByText('I am Lord Lemon')).toBeInTheDocument() + }) + + it('calls the onRemoveMediaComment callback when the remove media button is clicked', () => { + const props = defaultProps() + const container = render( + + ) + const removeMediaButton = container.getByTestId('remove-media-attachment') + fireEvent.click(removeMediaButton) + expect(props.onRemoveMediaComment).toHaveBeenCalled() + }) + }) +}) diff --git a/ui/features/inbox/react/containers/__tests__/ComposeModalContainer.test.js b/ui/features/inbox/react/containers/__tests__/ComposeModalContainer.test.js index fe8fdddde32ef..e0f7e137982dc 100644 --- a/ui/features/inbox/react/containers/__tests__/ComposeModalContainer.test.js +++ b/ui/features/inbox/react/containers/__tests__/ComposeModalContainer.test.js @@ -126,6 +126,15 @@ describe('ComposeModalContainer', () => { }) }) + describe('Media', () => { + it('opens the media upload modal', async () => { + const container = setup() + const mediaButton = await container.findByTestId('media-upload') + fireEvent.click(mediaButton) + expect(await container.findByText('Upload Media')).toBeInTheDocument() + }) + }) + describe('Subject', () => { it('allows setting the subject', async () => { const {findByTestId} = setup() diff --git a/ui/features/inbox/util/constants.js b/ui/features/inbox/util/constants.js index c4511932567ee..d5f679a2f8cd4 100644 --- a/ui/features/inbox/util/constants.js +++ b/ui/features/inbox/util/constants.js @@ -15,5 +15,67 @@ * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see . */ +import I18n from 'i18n!conversations_2' export const PARTICIPANT_EXPANSION_THRESHOLD = 2 + +export const MediaCaptureStrings = () => ({ + ARIA_VIDEO_LABEL: I18n.t('Video Player'), + ARIA_VOLUME: I18n.t('Current Volume Level'), + ARIA_RECORDING: I18n.t('Recording'), + DEFAULT_ERROR: I18n.t('Something went wrong accessing your mic or webcam.'), + DEVICE_AUDIO: I18n.t('Mic'), + DEVICE_VIDEO: I18n.t('Webcam'), + FILE_PLACEHOLDER: I18n.t('Untitled'), + FINISH: I18n.t('Finish'), + NO_WEBCAM: I18n.t('No Video'), + NOT_ALLOWED_ERROR: I18n.t('Please allow Canvas to access your microphone and webcam.'), + NOT_READABLE_ERROR: I18n.t('Your webcam may already be in use.'), + PLAYBACK_PAUSE: I18n.t('Pause'), + PLAYBACK_PLAY: I18n.t('Play'), + PREVIEW: I18n.t('PREVIEW'), + SAVE: I18n.t('Save'), + SR_FILE_INPUT: I18n.t('File name'), + START: I18n.t('Start Recording'), + START_OVER: I18n.t('Start Over') +}) + +export const UploadMediaStrings = () => ({ + LOADING_MEDIA: I18n.t('Loading Media'), + PROGRESS_LABEL: I18n.t('Uploading media Progress'), + ADD_CLOSED_CAPTIONS_OR_SUBTITLES: I18n.t('Add CC/Subtitle'), + COMPUTER_PANEL_TITLE: I18n.t('Computer'), + DRAG_FILE_TEXT: I18n.t('Drag a File Here'), + RECORD_PANEL_TITLE: I18n.t('Record'), + EMBED_PANEL_TITLE: I18n.t('Embed'), + SUBMIT_TEXT: I18n.t('Submit'), + CLOSE_TEXT: I18n.t('Close'), + UPLOAD_MEDIA_LABEL: I18n.t('Upload Media'), + CLEAR_FILE_TEXT: I18n.t('Clear selected file'), + INVALID_FILE_TEXT: I18n.t('Invalid file type'), + DRAG_DROP_CLICK_TO_BROWSE: I18n.t('Drag and drop, or click to browse your computer'), + EMBED_VIDEO_CODE_TEXT: I18n.t('Embed Video Code'), + UPLOADING_ERROR: I18n.t('Error uploading video/audio recording'), + CLOSED_CAPTIONS_PANEL_TITLE: I18n.t('CC/Subtitles'), + CLOSED_CAPTIONS_LANGUAGE_HEADER: I18n.t('Language'), + CLOSED_CAPTIONS_FILE_NAME_HEADER: I18n.t('File Name'), + CLOSED_CAPTIONS_ACTIONS_HEADER: I18n.t('Actions'), + CLOSED_CAPTIONS_ADD_SUBTITLE: I18n.t('Subtitle'), + CLOSED_CAPTIONS_ADD_SUBTITLE_SCREENREADER: I18n.t('Add Subtitle'), + CLOSED_CAPTIONS_CHOOSE_FILE: I18n.t('Choose File'), + CLOSED_CAPTIONS_SELECT_LANGUAGE: I18n.t('Select Language'), + MEDIA_RECORD_NOT_AVAILABLE: I18n.t('Media record not available'), + ADDED_CAPTION: I18n.t('Added caption'), + DELETED_CAPTION: I18n.t('Deleted caption'), + REMOVE_FILE: I18n.t('Remove file'), + NO_FILE_CHOSEN: I18n.t('No file selected'), + SUPPORTED_FILE_TYPES: I18n.t('Supported file types: .vtt, .srt'), + ADD_NEW_CAPTION_OR_SUBTITLE: I18n.t('Add new caption or subtitle') +}) + +export const SelectStrings = () => ({ + USE_ARROWS: I18n.t('Use Arrows'), + LIST_COLLAPSED: I18n.t('List Collapsed'), + LIST_EXPANDED: I18n.t('List Expanded'), + OPTION_SELECTED: I18n.t('{option} Selected') +})