import React from 'react'; import { debounce } from 'lodash'; import { Attachment } from '../../../types/Attachment'; import * as MIME from '../../../types/MIME'; import TextareaAutosize from 'react-autosize-textarea'; import { SessionIconButton, SessionIconSize, SessionIconType } from '../icon'; import { SessionEmojiPanel } from './SessionEmojiPanel'; import { SessionRecording } from './SessionRecording'; import { SignalService } from '../../../../ts/protobuf'; interface Props { placeholder?: string; sendMessage: any; onMessageSending: any; onMessageSuccess: any; onMessageFailure: any; onLoadVoiceNoteView: any; onExitVoiceNoteView: any; } interface State { message: string; showRecordingView: boolean; mediaSetting: boolean | null; showEmojiPanel: boolean; attachments: Array<Attachment>; voiceRecording?: Blob; } export class SessionCompositionBox extends React.Component<Props, State> { private readonly textarea: React.RefObject<HTMLTextAreaElement>; private readonly fileInput: React.RefObject<HTMLInputElement>; private emojiPanel: any; constructor(props: any) { super(props); this.state = { message: '', attachments: [], voiceRecording: undefined, showRecordingView: false, mediaSetting: null, showEmojiPanel: false, }; this.textarea = React.createRef(); this.fileInput = React.createRef(); // Emojis this.emojiPanel = null; this.toggleEmojiPanel = debounce(this.toggleEmojiPanel.bind(this), 100); this.hideEmojiPanel = this.hideEmojiPanel.bind(this); this.onEmojiClick = this.onEmojiClick.bind(this); this.handleClick = this.handleClick.bind(this); this.renderRecordingView = this.renderRecordingView.bind(this); this.renderCompositionView = this.renderCompositionView.bind(this); // Recording view functions this.sendVoiceMessage = this.sendVoiceMessage.bind(this); this.onLoadVoiceNoteView = this.onLoadVoiceNoteView.bind(this); this.onExitVoiceNoteView = this.onExitVoiceNoteView.bind(this); // Attachments this.onChoseAttachment = this.onChoseAttachment.bind(this); this.onChooseAttachment = this.onChooseAttachment.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.onChange = this.onChange.bind(this); } public componentWillReceiveProps() { console.log(`[vince][info] Here are my composition props: `, this.props); } public async componentWillMount() { const mediaSetting = await window.getSettingValue('media-permissions'); this.setState({ mediaSetting }); } public render() { const { showRecordingView } = this.state; return ( <div className="composition-container"> {showRecordingView ? ( <>{this.renderRecordingView()}</> ) : ( <>{this.renderCompositionView()}</> )} </div> ); } private handleClick(e: any) { if (this.emojiPanel && this.emojiPanel.contains(e.target)) { return; } this.toggleEmojiPanel(); } private showEmojiPanel() { document.addEventListener('mousedown', this.handleClick, false); this.setState({ showEmojiPanel: true, }); } private hideEmojiPanel() { document.removeEventListener('mousedown', this.handleClick, false); this.setState({ showEmojiPanel: false, }); } private toggleEmojiPanel() { if (this.state.showEmojiPanel) { this.hideEmojiPanel(); } else { this.showEmojiPanel(); } } private renderRecordingView() { return ( <SessionRecording sendVoiceMessage={this.sendVoiceMessage} onLoadVoiceNoteView={this.onLoadVoiceNoteView} onExitVoiceNoteView={this.onExitVoiceNoteView} /> ); } private renderCompositionView() { const { placeholder } = this.props; const { showEmojiPanel, message } = this.state; return ( <> <SessionIconButton iconType={SessionIconType.CirclePlus} iconSize={SessionIconSize.Large} onClick={this.onChooseAttachment} /> <input className="hidden" multiple={true} ref={this.fileInput} type="file" onChange={this.onChoseAttachment} /> <SessionIconButton iconType={SessionIconType.Microphone} iconSize={SessionIconSize.Huge} onClick={this.onLoadVoiceNoteView} /> <div className="send-message-input"> <TextareaAutosize rows={1} maxRows={3} ref={this.textarea} placeholder={placeholder} maxLength={window.CONSTANTS.MAX_MESSAGE_BODY_LENGTH} onKeyDown={this.onKeyDown} value={message} onChange={this.onChange} /> </div> <SessionIconButton iconType={SessionIconType.Emoji} iconSize={SessionIconSize.Large} onClick={this.toggleEmojiPanel} /> <div className="send-message-button"> <SessionIconButton iconType={SessionIconType.Send} iconSize={SessionIconSize.Large} iconColor={'#FFFFFF'} iconRotation={90} onClick={this.onSendMessage} /> </div> <div ref={ref => (this.emojiPanel = ref)} onKeyDown={this.onKeyDown} role="button" > <SessionEmojiPanel onEmojiClicked={this.onEmojiClick} show={showEmojiPanel} /> </div> </> ); } private onChooseAttachment() { const fileInput = this.fileInput.current; if (fileInput) fileInput.click(); } private onChoseAttachment() { // Build attachments list const attachmentsFileList = this.fileInput.current?.files; if (!attachmentsFileList) return; const attachments: Array<Attachment> = []; Array.from(attachmentsFileList).forEach(async (file: File) => { const fileBlob = new Blob([file]); const fileBuffer = await new Response(fileBlob).arrayBuffer(); const attachment = { fileName: file.name, flags: undefined, // FIXME VINCE: Set appropriate type contentType: MIME.AUDIO_WEBM, size: file.size, data: fileBuffer, }; // Push if size is nonzero if (attachment.data.byteLength) { attachments.push(attachment); } }); this.setState({ attachments }); } private onKeyDown(event: any) { if (event.key === 'Enter' && !event.shiftKey) { // If shift, newline. Else send message. event.preventDefault(); this.onSendMessage(); } else if (event.key === 'Escape' && this.state.showEmojiPanel) { this.hideEmojiPanel(); } } private onSendMessage() { const messageInput = this.textarea.current; if (!messageInput) { return; } // Verify message length const messagePlaintext = messageInput.value; const msgLen = messagePlaintext.length; if (msgLen === 0 || msgLen > window.CONSTANTS.MAX_MESSAGE_BODY_LENGTH) { return; } // handle Attachments const { attachments } = this.state; console.log(`[vince][msg] Message:`, messagePlaintext); console.log(`[vince][msg] fileAttachments:`, attachments); // Handle emojis // Send message this.props.onMessageSending(); this.props .sendMessage( messagePlaintext, attachments, undefined, undefined, null, {} ) .then(() => { // Message sending sucess this.props.onMessageSuccess(); // Empty attachments // Empty composition box this.setState({ message: '', attachments: [], }); }) .catch(() => { // Message sending failed this.props.onMessageFailure(); }); } private async sendVoiceMessage(audioBlob: Blob) { if (!this.state.showRecordingView) { return; } const fileBuffer = await new Response(audioBlob).arrayBuffer(); const audioAttachment: Attachment = { data: fileBuffer, flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE, contentType: MIME.AUDIO_MP3, }; const messageSuccess = this.props.sendMessage( '', [audioAttachment], undefined, undefined, null, {} ); if (messageSuccess) { // success! } console.log(`[compositionbox] Sending voice message:`, audioBlob); this.onExitVoiceNoteView(); } private onLoadVoiceNoteView() { // Do stuff for component, then run callback to SessionConversation const { mediaSetting } = this.state; if (mediaSetting) { this.setState({ showRecordingView: true, showEmojiPanel: false, }); this.props.onLoadVoiceNoteView(); return; } window.pushToast({ id: 'audioPermissionNeeded', title: window.i18n('audioPermissionNeededTitle'), description: window.i18n('audioPermissionNeededDescription'), type: 'info', }); } private onExitVoiceNoteView() { // Do stuff for component, then run callback to SessionConversation this.setState({ showRecordingView: false }); this.props.onExitVoiceNoteView(); } private onDrop() { // On drop attachments! // this.textarea.current?.ondrop; // Look into react-dropzone } private onChange(event: any) { this.setState({ message: event.target.value }); } private onEmojiClick({ native }: any) { const messageBox = this.textarea.current; if (!messageBox) { return; } const { message } = this.state; const currentSelectionStart = Number(messageBox.selectionStart); const currentSelectionEnd = Number(messageBox.selectionEnd); const before = message.slice(0, currentSelectionStart); const end = message.slice(currentSelectionEnd); const newMessage = `${before}${native}${end}`; this.setState({ message: newMessage }, () => { // update our selection because updating text programmatically // will put the selection at the end of the textarea const selectionStart = currentSelectionStart + Number(native.length); messageBox.selectionStart = selectionStart; messageBox.selectionEnd = selectionStart; // Sometimes, we have to repeat the set of the selection position with a timeout to be effective setTimeout(() => { messageBox.selectionStart = selectionStart; messageBox.selectionEnd = selectionStart; }, 20); }); } }