diff --git a/images/emoji/emoji-sheet-64.png b/images/emoji/emoji-sheet-64.png new file mode 100644 index 000000000..c6e0ff705 Binary files /dev/null and b/images/emoji/emoji-sheet-64.png differ diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 41f6ed5ab..bc9350fbc 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -218,7 +218,7 @@ message DataMessage { optional Quote quote = 8; repeated Contact contact = 9; repeated Preview preview = 10; - optional LokiProfile profile = 101; // Loki: The profile of the current user + optional LokiProfile profile = 101; // Loki: The profile of the current user optional GroupInvitation groupInvitation = 102; // Loki: Invitation to a public chat } diff --git a/stylesheets/_session.scss b/stylesheets/_session.scss index 756a3c592..9632726be 100644 --- a/stylesheets/_session.scss +++ b/stylesheets/_session.scss @@ -43,6 +43,8 @@ } } +@import 'node_modules/emoji-mart/css/emoji-mart.css'; + // Session Colors $session-font-family: 'Wasa'; @@ -52,12 +54,13 @@ $session-color-green-alt-2: #00fd73; $session-color-green-alt-3: #00f782; $session-shade-1: #0c0c0c; -$session-shade-1-alt: #0F1011; +$session-shade-1-alt: #0f1011; $session-shade-2: #161616; $session-shade-3: #191818; $session-shade-4: #1b1b1b; $session-shade-5: #222325; $session-shade-6: #232323; +$session-shade-6-alt: #2c2c2c; $session-shade-7: #2e2e2e; $session-shade-8: #2f2f2f; $session-shade-9: #313131; @@ -79,7 +82,7 @@ $session-color-white: #fff; $session-color-dark-grey: #353535; $session-color-black: #000; $session-color-danger: #ff453a; -$session-color-danger-alt: #FF4538; +$session-color-danger-alt: #ff4538; $session-color-primary: $session-shade-13; $session-color-secondary: $session-shade-6; $session-background-overlay: #212121; @@ -590,7 +593,6 @@ label { } } - .hidden { display: none; visibility: hidden; diff --git a/stylesheets/_session_conversation.scss b/stylesheets/_session_conversation.scss index ba5d2497c..9d5458874 100644 --- a/stylesheets/_session_conversation.scss +++ b/stylesheets/_session_conversation.scss @@ -1,8 +1,5 @@ $composition-container-height: 60px; - - - @keyframes fadein { from { opacity: 0; @@ -41,23 +38,22 @@ $composition-container-height: 60px; } } - .conversation-item { display: flex; flex-grow: 1; flex-direction: column; height: 100%; outline: none; - + .selection-mode { .messages-container > *:not(.message-selected) { animation: toShadow $session-transition-duration; opacity: 0.25; } - .conversation-header{ - .conversation-header{ - &--items-wrapper{ + .conversation-header { + .conversation-header { + &--items-wrapper { .session-icon { opacity: 0; } @@ -71,9 +67,8 @@ $composition-container-height: 60px; } } - .conversation-header { - &--items-wrapper{ + &--items-wrapper { display: flex; flex-grow: 1; align-items: center; @@ -89,11 +84,11 @@ $composition-container-height: 60px; align-items: center; justify-content: space-between; height: $main-view-header-height; - + .close-button { float: left; } - + .session-button.default.danger { display: flex; width: 80px; @@ -102,15 +97,12 @@ $composition-container-height: 60px; .message-selection-overlay div[role='button'] { display: inline-block; } - + .message-selection-overlay .button-group { float: right; } } - - - .session-conversation-wrapper { position: absolute; width: 100%; @@ -124,8 +116,8 @@ $composition-container-height: 60px; flex-direction: column; position: relative; - &--blocking-overlay{ - background-color: rgba(0,0,0,0.80); + &--blocking-overlay { + background-color: rgba(0, 0, 0, 0.8); position: absolute; top: 0px; bottom: 0px; @@ -212,13 +204,37 @@ $composition-container-height: 60px; position: absolute; bottom: 68px; right: 0px; - min-height: 400px; - min-width: 400px; - background-color: $session-shade-4; - border: 1px solid $session-shade-6; - border-top-right-radius: 3px; - border-top-left-radius: 3px; padding: $session-margin-lg; + + z-index: 5; + opacity: 0; + visibility: hidden; + transition: $session-transition-duration; + + button:focus { + outline: none; + } + + &.show { + opacity: 1; + visibility: visible; + } + + & > section { + background-color: $session-shade-4; + border: 1px solid $session-shade-6-alt; + border-radius: $session-margin-lg; + + .emoji-mart-category-label { + top: -2px; + + span { + font-family: 'SF Pro Text'; + padding-top: $session-margin-sm; + background-color: $session-shade-4; + } + } + } } .session-progress { @@ -236,7 +252,6 @@ $composition-container-height: 60px; } } - .session-recording { height: $composition-container-height; display: flex; @@ -282,7 +297,6 @@ $composition-container-height: 60px; width: 100%; padding: 0px $session-margin-lg; } - } &--status { @@ -293,18 +307,18 @@ $composition-container-height: 60px; right: 0; bottom: $composition-container-height + $session-margin-md; - .session-button{ + .session-button { display: flex; justify-content: center; align-items: center; width: 173px; font-weight: 300; - font-family: "SF Pro Text"; - + font-family: 'SF Pro Text'; + &.primary { cursor: default; user-select: none; - &:hover{ + &:hover { filter: brightness(100%); border: 2px solid #161819; } @@ -317,7 +331,7 @@ $composition-container-height: 60px; &--timer { display: inline-flex; align-items: center; - font-family: "SF Pro Text"; + font-family: 'SF Pro Text'; font-weight: bold; font-size: 14px; @@ -325,7 +339,7 @@ $composition-container-height: 60px; margin-right: $session-margin-sm; } - &-light{ + &-light { height: $session-margin-sm; width: $session-margin-sm; border-radius: 50%; diff --git a/ts/components/session/conversation/SessionCompositionBox.tsx b/ts/components/session/conversation/SessionCompositionBox.tsx index 68e312901..58c1d0e59 100644 --- a/ts/components/session/conversation/SessionCompositionBox.tsx +++ b/ts/components/session/conversation/SessionCompositionBox.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { debounce } from 'lodash'; import { Attachment } from '../../../types/Attachment'; import * as MIME from '../../../types/MIME'; @@ -33,6 +34,7 @@ interface State { export class SessionCompositionBox extends React.Component { private textarea: React.RefObject; private fileInput: React.RefObject; + private emojiPanel: any; constructor(props: any) { super(props); @@ -49,7 +51,12 @@ export class SessionCompositionBox extends React.Component { this.textarea = React.createRef(); this.fileInput = React.createRef(); - this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this); + // 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); @@ -64,6 +71,7 @@ export class SessionCompositionBox extends React.Component { this.onChooseAttachment = this.onChooseAttachment.bind(this); this.onKeyDown = this.onKeyDown.bind(this); + this.onChange = this.onChange.bind(this); } @@ -76,7 +84,7 @@ export class SessionCompositionBox extends React.Component { this.setState({mediaSetting}); } - render() { + public render() { const { isRecordingView } = this.state; return ( @@ -90,11 +98,37 @@ export class SessionCompositionBox extends React.Component { ); } - public toggleEmojiPanel() { + 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: !this.state.showEmojiPanel, + showEmojiPanel: false, }); } + + public toggleEmojiPanel() { + if (this.state.showEmojiPanel) { + this.hideEmojiPanel(); + } else { + this.showEmojiPanel(); + } + } private renderRecordingView() { return ( @@ -108,7 +142,7 @@ export class SessionCompositionBox extends React.Component { private renderCompositionView() { const { placeholder } = this.props; - const { showEmojiPanel } = this.state; + const { showEmojiPanel, message } = this.state; return ( <> @@ -122,7 +156,7 @@ export class SessionCompositionBox extends React.Component { className="hidden" multiple={true} ref={this.fileInput} - type='file' + type="file" onChange={this.onChoseAttachment} /> @@ -140,6 +174,8 @@ export class SessionCompositionBox extends React.Component { placeholder={placeholder} maxLength={window.CONSTANTS.MAX_MESSAGE_BODY_LENGTH} onKeyDown={this.onKeyDown} + value={message} + onChange={this.onChange} /> @@ -158,11 +194,19 @@ export class SessionCompositionBox extends React.Component { /> - {showEmojiPanel && } +
(this.emojiPanel = ref)} + onKeyDown={this.onKeyDown} + role="button" + > + +
); } + + private onChooseAttachment() { this.fileInput.current?.click(); } @@ -182,7 +226,7 @@ export class SessionCompositionBox extends React.Component { fileName: file.name, flags: undefined, // FIXME VINCE: Set appropriate type - contentType: undefined, + contentType: MIME.AUDIO_WEBM, size: file.size, data: fileBuffer, } @@ -199,45 +243,47 @@ export class SessionCompositionBox extends React.Component { // If shift, newline. Else send message. event.preventDefault(); this.onSendMessage(); + } else if (event.key === 'Escape' && this.state.showEmojiPanel) { + this.hideEmojiPanel(); } } - private onDrop(){ - // On drop attachments! - // this.textarea.current?.ondrop; - // Look into react-dropzone - } private onSendMessage(){ // FIXME VINCE: Get emoiji, attachments, etc const messagePlaintext = this.textarea.current?.value; - const {attachments, voiceRecording} = this.state; + const {attachments} = this.state; const messageInput = this.textarea.current; if (!messageInput) return; console.log(`[vince][msg] Message:`, messagePlaintext); console.log(`[vince][msg] fileAttachments:`, attachments); - console.log(`[vince][msg] Voice message:`, voiceRecording); - + // Verify message length // Handle emojis + + // Send message const messageSuccess = this.props.sendMessage( messagePlaintext, attachments, - MIME.IMAGE_JPEG, + undefined, undefined, null, {}, ); if (messageSuccess) { + // Empty attachments // Empty composition box - messageInput.value = ''; + this.setState({ + message: '', + attachments: [], + }); } } @@ -249,6 +295,7 @@ export class SessionCompositionBox extends React.Component { const audioAttachment: Attachment = { data: fileBuffer, flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE, + contentType: MIME.AUDIO_MP3, }; const messageSuccess = this.props.sendMessage( @@ -295,5 +342,40 @@ export class SessionCompositionBox extends React.Component { 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); + }); + } } diff --git a/ts/components/session/conversation/SessionConversation.tsx b/ts/components/session/conversation/SessionConversation.tsx index fca9f2a43..abdea83af 100644 --- a/ts/components/session/conversation/SessionConversation.tsx +++ b/ts/components/session/conversation/SessionConversation.tsx @@ -100,7 +100,7 @@ export class SessionConversation extends React.Component { } } - render() { + public render() { console.log(`[vince][info] Props`, this.props); const { messages, conversationKey, doneInitialScroll, isRecordingView } = this.state; diff --git a/ts/components/session/conversation/SessionEmojiPanel.tsx b/ts/components/session/conversation/SessionEmojiPanel.tsx index d7cef172a..730785717 100644 --- a/ts/components/session/conversation/SessionEmojiPanel.tsx +++ b/ts/components/session/conversation/SessionEmojiPanel.tsx @@ -1,6 +1,12 @@ import React from 'react'; +import classNames from 'classnames'; -interface Props {} +import { Picker } from 'emoji-mart'; + +interface Props { + onEmojiClicked: (emoji: any) => void; + show: boolean; +} interface State { // FIXME Use Emoji-Mart categories @@ -8,7 +14,7 @@ interface State { } export class SessionEmojiPanel extends React.Component { - constructor(props: any) { + constructor(props: Props) { super(props); this.state = { @@ -17,6 +23,23 @@ export class SessionEmojiPanel extends React.Component { } render() { - return
THIS IS EMOJI STUFF
; + const { onEmojiClicked, show } = this.props; + + return ( +
+ + `./images/emoji/emoji-sheet-${sheetSize}.png` + } + darkMode={true} + color={'#00F782'} + showPreview={true} + title={''} + onSelect={onEmojiClicked} + autoFocus={true} + // set="apple" + /> +
+ ); } } diff --git a/ts/components/session/conversation/SessionRecording.tsx b/ts/components/session/conversation/SessionRecording.tsx index 201e18623..0bfecd2d0 100644 --- a/ts/components/session/conversation/SessionRecording.tsx +++ b/ts/components/session/conversation/SessionRecording.tsx @@ -447,7 +447,7 @@ export class SessionRecording extends React.Component { } // Start recording the stream - const media = new window.MediaRecorder(stream); + const media = new window.MediaRecorder(stream, {mimeType: 'audio/webm'}); media.ondataavailable = (mediaBlob: any) => { this.setState({mediaBlob}, () => { // Generate PCM waveform for playback diff --git a/ts/types/MIME.ts b/ts/types/MIME.ts index a083e19cd..7d9a920c7 100644 --- a/ts/types/MIME.ts +++ b/ts/types/MIME.ts @@ -3,6 +3,7 @@ export type MIMEType = string & { _mimeTypeBrand: any }; export const APPLICATION_OCTET_STREAM = 'application/octet-stream' as MIMEType; export const APPLICATION_JSON = 'application/json' as MIMEType; export const AUDIO_AAC = 'audio/aac' as MIMEType; +export const AUDIO_WEBM = 'audio/webm' as MIMEType; export const AUDIO_MP3 = 'audio/mp3' as MIMEType; export const IMAGE_GIF = 'image/gif' as MIMEType; export const IMAGE_JPEG = 'image/jpeg' as MIMEType;