You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			410 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
			
		
		
	
	
			410 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
| 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);
 | |
|     });
 | |
|   }
 | |
| 
 | |
| }
 |