diff --git a/src/app/organisms/room/AttachmentUis/AttachmentFrame.jsx b/src/app/organisms/room/AttachmentUis/AttachmentFrame.jsx new file mode 100644 index 000000000..b6032792b --- /dev/null +++ b/src/app/organisms/room/AttachmentUis/AttachmentFrame.jsx @@ -0,0 +1,40 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import PropTypes from 'prop-types'; +import FileAttachedIndicator from './FileAttachedIndicator'; +import attachmentUis from './attachmentUis'; + +function AttachmentFrame({ + attachmentOrUi, + fileSetter, + uploadProgressRef, +}) { + // To enable child components to learn how to attach their result + let submission; + const fnHowToSubmit = (func) => { + submission = func; + fileSetter(submission); + }; + + // If there already is an attachment, show it + if (typeof attachmentOrUi === 'object') { + return ( + + ); + } + + // Show the desired UI + const UiComponent = attachmentUis.get(attachmentOrUi).component; + return (); +} + +AttachmentFrame.propTypes = { + attachmentOrUi: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + fileSetter: PropTypes.func.isRequired, + uploadProgressRef: PropTypes.shape().isRequired, +}; + +export default AttachmentFrame; diff --git a/src/app/organisms/room/AttachmentUis/AttachmentTypeSelector.jsx b/src/app/organisms/room/AttachmentUis/AttachmentTypeSelector.jsx new file mode 100644 index 000000000..c4b57ab3b --- /dev/null +++ b/src/app/organisms/room/AttachmentUis/AttachmentTypeSelector.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import IconButton from '../../../atoms/button/IconButton'; +import CirclePlusIC from '../../../../../public/res/ic/outlined/circle-plus.svg'; +import PlusIC from '../../../../../public/res/ic/outlined/plus.svg'; +import ContextMenu, { MenuHeader, MenuItem } from '../../../atoms/context-menu/ContextMenu'; +import attachmentUiFrameTypes from './attachmentUis'; + +function AttachmentTypeSelector({ alreadyHasAttachment, actOnAttaching }) { + function getList(toggleMenu) { + const list = []; + + attachmentUiFrameTypes.forEach((obj, key) => { + // Entries have to have an icon + const icon = obj.icon ?? PlusIC; + + list.push( + { + toggleMenu(); + actOnAttaching(key); + }} + iconSrc={icon} + > + {obj.fullName} + , + ); + }); + + return list; + } + + return ( + ( +
+ Attachment + {getList(toggleMenu)} +
+ )} + render={(toggleMenu) => ( + { + if (!alreadyHasAttachment) { + toggleMenu(); + } else { + actOnAttaching(attachmentUiFrameTypes.none); + } + }} + tooltip={alreadyHasAttachment ? 'Cancel' : 'Select attachment'} + src={CirclePlusIC} + /> + )} + /> + ); +} + +AttachmentTypeSelector.propTypes = { + alreadyHasAttachment: PropTypes.bool, + actOnAttaching: PropTypes.func.isRequired, +}; + +AttachmentTypeSelector.defaultProps = { + alreadyHasAttachment: false, +}; + +export default AttachmentTypeSelector; diff --git a/src/app/organisms/room/AttachmentUis/FileAttachedIndicator.jsx b/src/app/organisms/room/AttachmentUis/FileAttachedIndicator.jsx new file mode 100644 index 000000000..119c99a3f --- /dev/null +++ b/src/app/organisms/room/AttachmentUis/FileAttachedIndicator.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import RawIcon from '../../../atoms/system-icons/RawIcon'; +import VLCIC from '../../../../../public/res/ic/outlined/vlc.svg'; +import VolumeFullIC from '../../../../../public/res/ic/outlined/volume-full.svg'; +import FileIC from '../../../../../public/res/ic/outlined/file.svg'; +import Text from '../../../atoms/text/Text'; +import { bytesToSize } from '../../../../util/common'; + +function FileAttachedIndicator({ + attachmentOrUi, + uploadProgressRef, +}) { + if (typeof attachmentOrUi !== 'object') return null; + + const fileType = attachmentOrUi.type.slice(0, attachmentOrUi.type.indexOf('/')); + return ( +
+
+ {fileType === 'image' && {attachmentOrUi.name}} + {fileType === 'video' && } + {fileType === 'audio' && } + {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && } +
+
+ {attachmentOrUi.name} + {`size: ${bytesToSize(attachmentOrUi.size)}`} +
+
+ ); +} + +FileAttachedIndicator.propTypes = { + attachmentOrUi: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, + uploadProgressRef: PropTypes.shape().isRequired, +}; + +export default FileAttachedIndicator; diff --git a/src/app/organisms/room/AttachmentUis/VoiceMailRecorder.jsx b/src/app/organisms/room/AttachmentUis/VoiceMailRecorder.jsx new file mode 100644 index 000000000..e9efc2486 --- /dev/null +++ b/src/app/organisms/room/AttachmentUis/VoiceMailRecorder.jsx @@ -0,0 +1,169 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import Text from '../../../atoms/text/Text'; +import RawIcon from '../../../atoms/system-icons/RawIcon'; +import VolumeFullIC from '../../../../../public/res/ic/outlined/volume-full.svg'; +import ChevronBottomIC from '../../../../../public/res/ic/outlined/chevron-bottom.svg'; +import ArrowIC from '../../../../../public/res/ic/outlined/leave-arrow.svg'; +import PauseIC from '../../../../../public/res/ic/outlined/pause.svg'; +import PlayIC from '../../../../../public/res/ic/outlined/play.svg'; +import IconButton from '../../../atoms/button/IconButton'; +import './VoiceMailRecorder.scss'; +import Timer from '../../../../util/Timer'; + +/** + * @type {Timer} + */ +let timer; + +/** + * @type {MediaStream} + */ +let _stream; +/** + * @type {MediaRecorder} + */ +let _mediaRecorder; + +async function init() { + if (_mediaRecorder) return; + + timer = new Timer(); + _stream = null; + _mediaRecorder = null; + + _stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + _mediaRecorder = new MediaRecorder(_stream); + + _mediaRecorder.onerror = (error) => { + console.log(error); + _mediaRecorder.stop(); + }; +} + +function pauseRec() { + if (_mediaRecorder.state === 'recording') { + _mediaRecorder.pause(); + timer.pause(); + } +} + +function startOrResumeRec() { + if (!_mediaRecorder) return; + + if (_mediaRecorder.state === 'paused') { + _mediaRecorder.resume(); + } else if (_mediaRecorder.state === 'inactive') { + _mediaRecorder.start(); + } + timer.resume(); +} + +async function restartRec() { + // Needed, otherwise the browser indicator would remain after closing UI + if (_mediaRecorder.state !== 'inactive') _mediaRecorder.stop(); + _stream.getTracks().forEach((track) => track.stop()); + _mediaRecorder = null; + timer = new Timer(); + await init(); + startOrResumeRec(); +} + +function VoiceMailRecorder({ fnHowToSubmit }) { + const [state, setState] = React.useState('Recording'); + const [timeRecording, setTimeRecording] = React.useState('00:00'); + const [browserHasNoSupport, setBrowserHasNoSupport] = React.useState(!navigator.mediaDevices + ? 'It seems like your browser is unsupported' : null); + + async function initiateInitiation() { + if (!_mediaRecorder) { + await init().catch((err) => { + console.warn('Recording is disallowed', err); + setBrowserHasNoSupport('It seems like you have disallowed Cinny to record your voice ༼ つ ◕_◕ ༽つ'); + }); + + if (browserHasNoSupport) return; + startOrResumeRec(); + } + + _mediaRecorder.onstart = () => setState('Recording...'); + _mediaRecorder.onpause = () => setState('Recording paused'); + _mediaRecorder.onresume = () => setState('Recording...'); + } + + function stopAndSubmit(skipSubmission = false) { + if (!skipSubmission) { + _mediaRecorder.ondataavailable = (event) => { + const audioChunks = []; + audioChunks.push(event.data); + _mediaRecorder = null; + _stream = null; + + const opts = { type: 'audio/webm' }; + const audioBlob = new Blob(audioChunks, opts); + + const audioFile = new File([audioBlob], 'voicemail.webm', opts); + fnHowToSubmit(audioFile); + }; + } + + // Stop recording, remove browser indicator + if (_mediaRecorder && _mediaRecorder.state !== 'inactive') _mediaRecorder.stop(); + _stream.getTracks().forEach((track) => track.stop()); + } + + useEffect(() => { + const timerUpdater = setInterval(() => { + setTimeRecording(timer.getTimeStr); + }, 500); // .5 seconds + + // Cleanup after components unmount + return () => { + clearInterval(timerUpdater); + if (_mediaRecorder) { + _mediaRecorder = null; + } + if (_stream) { + // To remove the browser's recording indicator + _stream.getTracks().forEach((track) => track.stop()); + _stream = null; + } + }; + }, []); + + initiateInitiation(); + + const ui = ( +
+
+ +
+
+
+ + {state} + + {`for ${timeRecording}`} +
+ {(_mediaRecorder && _mediaRecorder.state === 'recording') + ? (Pause) + : (Start)} + restartRec().then(() => setState('Recording...'))} src={ArrowIC} tooltip="Start over"> + Reset + + stopAndSubmit()} src={ChevronBottomIC} tooltip="Add as attachment" type="submit">Submit +
+
+ ); + + return browserHasNoSupport + ? {browserHasNoSupport} + : ui; +} + +VoiceMailRecorder.propTypes = { + fnHowToSubmit: PropTypes.func.isRequired, +}; + +export default VoiceMailRecorder; diff --git a/src/app/organisms/room/AttachmentUis/VoiceMailRecorder.scss b/src/app/organisms/room/AttachmentUis/VoiceMailRecorder.scss new file mode 100644 index 000000000..f30369bc7 --- /dev/null +++ b/src/app/organisms/room/AttachmentUis/VoiceMailRecorder.scss @@ -0,0 +1,13 @@ + +.room-attachment-ui-recorder { + display: flex; + + div { + width: 150px; + max-width: 100%; + } +} + +.unsupported-info { + height: 2em; +} \ No newline at end of file diff --git a/src/app/organisms/room/AttachmentUis/attachmentUis.js b/src/app/organisms/room/AttachmentUis/attachmentUis.js new file mode 100644 index 000000000..2f96fee7a --- /dev/null +++ b/src/app/organisms/room/AttachmentUis/attachmentUis.js @@ -0,0 +1,27 @@ +import FileIC from '../../../../../public/res/ic/outlined/file.svg'; +import VoiceMailRecorder from './VoiceMailRecorder'; + +/** + * @typedef {Object} AttachmentUi + * @property {string} fullName How should it be listed as to the user? + * @property {any} icon The icon to use for the attachment type. + * @property {React.ComponentType<{fnHowToSubmit: Function}>} component + * The component for the attachment type + */ + +/** + * @type {Map} attachmentUis + */ +const attachmentUis = new Map(); + +// Populate attachmentUis +attachmentUis.set('file', { + fullName: 'File', + icon: FileIC, +}); +attachmentUis.set('voiceMailRecorder', { + fullName: 'Voice mail', + component: VoiceMailRecorder, +}); + +export default attachmentUis; diff --git a/src/app/organisms/room/RoomViewInput.jsx b/src/app/organisms/room/RoomViewInput.jsx index e8d5d3978..5128effab 100644 --- a/src/app/organisms/room/RoomViewInput.jsx +++ b/src/app/organisms/room/RoomViewInput.jsx @@ -20,15 +20,13 @@ import IconButton from '../../atoms/button/IconButton'; import ScrollView from '../../atoms/scroll/ScrollView'; import { MessageReply } from '../../molecules/message/Message'; -import CirclePlusIC from '../../../../public/res/ic/outlined/circle-plus.svg'; import EmojiIC from '../../../../public/res/ic/outlined/emoji.svg'; import SendIC from '../../../../public/res/ic/outlined/send.svg'; import ShieldIC from '../../../../public/res/ic/outlined/shield.svg'; -import VLCIC from '../../../../public/res/ic/outlined/vlc.svg'; -import VolumeFullIC from '../../../../public/res/ic/outlined/volume-full.svg'; import MarkdownIC from '../../../../public/res/ic/outlined/markdown.svg'; -import FileIC from '../../../../public/res/ic/outlined/file.svg'; import CrossIC from '../../../../public/res/ic/outlined/cross.svg'; +import AttachmentTypeSelector from './AttachmentUis/AttachmentTypeSelector'; +import AttachmentFrame from './AttachmentUis/AttachmentFrame'; const CMD_REGEX = /(^\/|:|@)(\S*)$/; let isTyping = false; @@ -37,7 +35,16 @@ let cmdCursorPos = null; function RoomViewInput({ roomId, roomTimeline, viewEvent, }) { - const [attachment, setAttachment] = useState(null); + /** + * @typedef attachmentOrUiType + * @type {string | File | null} + * Either contains the file object which is attached + * Or the name of a UI which is to be shown + */ + /** + * @type {[attachmentOrUiType, Function]} + */ + const [attachmentOrUi, setAttachmentOrUi] = useState(null); const [isMarkdown, setIsMarkdown] = useState(settings.isMarkdown); const [replyTo, setReplyTo] = useState(null); @@ -84,7 +91,7 @@ function RoomViewInput({ } function clearAttachment(myRoomId) { if (roomId !== myRoomId) return; - setAttachment(null); + setAttachmentOrUi(null); inputBaseRef.current.style.backgroundImage = 'unset'; uploadInputRef.current.value = null; } @@ -152,7 +159,7 @@ function RoomViewInput({ if (textAreaRef?.current !== null) { isTyping = false; textAreaRef.current.value = roomsInput.getMessage(roomId); - setAttachment(roomsInput.getAttachment(roomId)); + setAttachmentOrUi(roomsInput.getAttachment(roomId)); setReplyTo(roomsInput.getReplyTo(roomId)); } return () => { @@ -179,12 +186,13 @@ function RoomViewInput({ requestAnimationFrame(() => deactivateCmdAndEmit()); const msgBody = textAreaRef.current.value; if (roomsInput.isSending(roomId)) return; - if (msgBody.trim() === '' && attachment === null) return; + if (msgBody.trim() === '' && attachmentOrUi === null) return; sendIsTyping(false); + if (typeof attachmentOrUi === 'string') return; // Attachment UI is not finished roomsInput.setMessage(roomId, msgBody); - if (attachment !== null) { - roomsInput.setAttachment(roomId, attachment); + if (attachmentOrUi !== null && typeof attachmentOrUi === 'object') { + roomsInput.setAttachment(roomId, attachmentOrUi); } textAreaRef.current.disabled = true; textAreaRef.current.style.cursor = 'not-allowed'; @@ -271,8 +279,8 @@ function RoomViewInput({ const item = e.clipboardData.items[i]; if (item.type.indexOf('image') !== -1) { const image = item.getAsFile(); - if (attachment === null) { - setAttachment(image); + if (attachmentOrUi === null) { + setAttachmentOrUi(image); if (image !== null) { roomsInput.setAttachment(roomId, image); return; @@ -289,18 +297,23 @@ function RoomViewInput({ textAreaRef.current.focus(); } - const handleUploadClick = () => { - if (attachment === null) uploadInputRef.current.click(); - else { - roomsInput.cancelAttachment(roomId); - } - }; function uploadFileChange(e) { const file = e.target.files.item(0); - setAttachment(file); + setAttachmentOrUi(file); if (file !== null) roomsInput.setAttachment(roomId, file); } + const handleAttachmentTypeSelectorReturn = (ret) => { + if (!ret) { + setAttachmentOrUi(null); + roomsInput.cancelAttachment(roomId); + } else if (ret === 'file') { + uploadInputRef.current.click(); + } else { + setAttachmentOrUi(ret); + } + }; + const canISend = roomTimeline.room.currentState.maySendMessage(mx.getUserId()); function renderInputs() { @@ -311,9 +324,12 @@ function RoomViewInput({ } return ( <> -
+
+ -
{roomTimeline.isEncrypted() && } @@ -348,24 +364,6 @@ function RoomViewInput({ ); } - function attachFile() { - const fileType = attachment.type.slice(0, attachment.type.indexOf('/')); - return ( -
-
- {fileType === 'image' && {attachment.name}} - {fileType === 'video' && } - {fileType === 'audio' && } - {fileType !== 'image' && fileType !== 'video' && fileType !== 'audio' && } -
-
- {attachment.name} - {`size: ${bytesToSize(attachment.size)}`} -
-
- ); - } - function attachReply() { return (
@@ -391,7 +389,16 @@ function RoomViewInput({ return ( <> { replyTo !== null && attachReply()} - { attachment !== null && attachFile() } + { attachmentOrUi !== null && ( + { + setAttachmentOrUi(blob); + roomsInput.setAttachment(roomId, blob); + }} + /> + ) }
{ e.preventDefault(); }}> { renderInputs() diff --git a/src/client/action/navigation.js b/src/client/action/navigation.js index 28aa0477d..ab2589c19 100644 --- a/src/client/action/navigation.js +++ b/src/client/action/navigation.js @@ -96,6 +96,13 @@ export function replyTo(userId, eventId, body) { }); } +export function openAttachmentTypeSelector(params) { + appDispatcher.dispatch({ + type: cons.actions.navigation.OPEN_ATTACHMENT_TYPE_SELECTOR, + params, + }); +} + export function openSearch(term) { appDispatcher.dispatch({ type: cons.actions.navigation.OPEN_SEARCH, diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index 1f0f8a403..8a059968b 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -406,7 +406,7 @@ class RoomsInput extends EventEmitter { // Apply formatting if relevant const formattedBody = formatAndEmojifyText( this.matrixClient.getRoom(roomId), - editedBody + editedBody, ); if (formattedBody !== editedBody) { content.formatted_body = ` * ${formattedBody}`; diff --git a/src/client/state/cons.js b/src/client/state/cons.js index c0314269e..b0dee9c41 100644 --- a/src/client/state/cons.js +++ b/src/client/state/cons.js @@ -41,6 +41,7 @@ const cons = { OPEN_READRECEIPTS: 'OPEN_READRECEIPTS', CLICK_REPLY_TO: 'CLICK_REPLY_TO', OPEN_SEARCH: 'OPEN_SEARCH', + OPEN_ATTACHMENT_TYPE_SELECTOR: 'OPEN_ATTACHMENT_TYPE_SELECTOR', OPEN_REUSABLE_CONTEXT_MENU: 'OPEN_REUSABLE_CONTEXT_MENU', }, room: { @@ -76,6 +77,7 @@ const cons = { EMOJIBOARD_OPENED: 'EMOJIBOARD_OPENED', READRECEIPTS_OPENED: 'READRECEIPTS_OPENED', REPLY_TO_CLICKED: 'REPLY_TO_CLICKED', + OPEN_ATTACHMENT_TYPE_SELECTOR: 'OPEN_ATTACHMENT_TYPE_SELECTOR', SEARCH_OPENED: 'SEARCH_OPENED', REUSABLE_CONTEXT_MENU_OPENED: 'REUSABLE_CONTEXT_MENU_OPENED', }, diff --git a/src/client/state/navigation.js b/src/client/state/navigation.js index 977cf7e97..5a34f07e9 100644 --- a/src/client/state/navigation.js +++ b/src/client/state/navigation.js @@ -126,6 +126,13 @@ class Navigation extends EventEmitter { action.userIds, ); }, + [cons.actions.navigation.OPEN_ATTACHMENT_TYPE_SELECTOR]: () => { + this.emit( + cons.events.navigation.OPEN_ATTACHMENT_TYPE_SELECTOR, + action.cords, + action.roomId, + ); + }, [cons.actions.navigation.CLICK_REPLY_TO]: () => { this.emit( cons.events.navigation.REPLY_TO_CLICKED, diff --git a/src/util/Timer.js b/src/util/Timer.js new file mode 100644 index 000000000..7e30e00f9 --- /dev/null +++ b/src/util/Timer.js @@ -0,0 +1,66 @@ +function nToStr(num) { + return num.toString().padStart(2, '0'); +} + +/** + * Start a timer. + * Starts automatically once constructed. + */ +class Timer { + constructor() { + this.savedTime = 0; + this.timeStarted = new Date().getTime(); + } + + resume() { + if (this.timeStarted) return; + this.timeStarted = new Date().getTime(); + } + + pause() { + if (!this.timeStarted) return; + this.savedTime += new Date().getTime() - this.timeStarted; + this.timeStarted = null; + } + + /** + * Return time in milliseconds + */ + get getTimeMs() { + let time = this.savedTime; + if (this.timeStarted) time += new Date().getTime() - this.timeStarted; + return time; + } + + /** + * @return {string} formatted time (hh:mm:ss) + */ + get getTimeStr() { + let time = this.getTimeMs; + let hours = 0; + let minutes = 0; + let seconds = 0; + + // Hours + while (time >= 3600000) { + hours += 1; + time -= 3600000; + } + // Minutes + while (time >= 60000) { + minutes += 1; + time -= 60000; + } + // Seconds + while (time >= 1000) { + seconds += 1; + time -= 1000; + } + + return hours === 0 + ? `${nToStr(minutes)}:${nToStr(seconds)}` + : `${nToStr(hours)}:${nToStr(minutes)}:${nToStr(seconds)}`; + } +} + +export default Timer;