diff --git a/background.html b/background.html index ada98bf3d..787c00fbc 100644 --- a/background.html +++ b/background.html @@ -107,27 +107,18 @@
-
-
-
+
+
+
diff --git a/images/collapse-down.svg b/images/collapse-down.svg new file mode 100644 index 000000000..985c00a55 --- /dev/null +++ b/images/collapse-down.svg @@ -0,0 +1 @@ +collapse-down-20 \ No newline at end of file diff --git a/images/expand-up.svg b/images/expand-up.svg new file mode 100644 index 000000000..858b81781 --- /dev/null +++ b/images/expand-up.svg @@ -0,0 +1 @@ +expand-up-20 \ No newline at end of file diff --git a/images/send.svg b/images/send.svg new file mode 100644 index 000000000..e9399beea --- /dev/null +++ b/images/send.svg @@ -0,0 +1 @@ +send-solid-24 \ No newline at end of file diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 2ef999255..43477c74c 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -181,8 +181,11 @@ this.loadingScreen.$el.prependTo(this.$('.discussion-container')); this.window = options.window; + const attachmentListEl = $( + '
' + ); this.fileInput = new Whisper.FileInputView({ - el: this.$('.attachment-list'), + el: attachmentListEl, }); this.listenTo( this.fileInput, @@ -221,7 +224,7 @@ this.$('.send-message').blur(this.unfocusBottomBar.bind(this)); this.setupHeader(); - this.setupCompositionArea(); + this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] }); }, events: { @@ -316,20 +319,33 @@ this.$('.conversation-header').append(this.titleView.el); }, - setupCompositionArea() { + setupCompositionArea({ attachmentListEl }) { const compositionApi = { current: null }; this.compositionApi = compositionApi; + const micCellEl = $(` +
+ +
+ `)[0]; + const attCellEl = $(` +
+ +
+ `)[0]; + const props = { compositionApi, onClickAddPack: () => this.showStickerManager(), onPickSticker: (packId, stickerId) => this.sendStickerMessage({ packId, stickerId }), onSubmit: message => this.sendMessage(message), - onDirtyChange: dirty => this.toggleMicrophone(dirty), onEditorStateChange: (msg, caretLocation) => this.onEditorStateChange(msg, caretLocation), onEditorSizeChange: rect => this.onEditorSizeChange(rect), + micCellEl, + attCellEl, + attachmentListEl, }; this.compositionAreaView = new Whisper.ReactWrapperView({ @@ -585,13 +601,10 @@ } }, - toggleMicrophone(dirty = false) { - if (dirty || this.fileInput.hasFiles()) { - this.$('.capture-audio').hide(); - } else { - this.$('.capture-audio').show(); - } + toggleMicrophone() { + this.compositionApi.current.setShowMic(!this.fileInput.hasFiles()); }, + captureAudio(e) { e.preventDefault(); @@ -617,6 +630,7 @@ view.on('send', this.handleAudioCapture.bind(this)); view.on('closed', this.endCaptureAudio.bind(this)); view.$el.appendTo(this.$('.capture-audio')); + this.compositionApi.current.setMicActive(true); this.disableMessageField(); this.$('.microphone').hide(); @@ -633,6 +647,7 @@ this.enableMessageField(); this.$('.microphone').show(); this.captureAudioView = null; + this.compositionApi.current.setMicActive(false); }, unfocusBottomBar() { @@ -1808,7 +1823,8 @@ this.quoteView = new Whisper.ReactWrapperView({ className: 'quote-wrapper', Component: window.Signal.Components.Quote, - elCallback: el => this.$('.send').prepend(el), + elCallback: el => + this.$(this.compositionApi.current.attSlotRef.current).prepend(el), props: Object.assign({}, props, { withContentAbove: true, onClose: () => { @@ -2262,7 +2278,8 @@ this.previewView = new Whisper.ReactWrapperView({ className: 'preview-wrapper', Component: window.Signal.Components.StagedLinkPreview, - elCallback: el => this.$('.send').prepend(el), + elCallback: el => + this.$(this.compositionApi.current.attSlotRef.current).prepend(el), props, onInitialRender: () => { this.view.restoreBottomOffset(); diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 847e46680..96a04e279 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -229,16 +229,15 @@ // things in the composition area. A margin on an inner div won't be included in that // height calculation. .bottom-bar .quote-wrapper { - margin-left: 37px; - margin-right: 73px; + margin-left: 18px; + margin-right: 18px; margin-top: 3px; - margin-bottom: -5px; } .bottom-bar .preview-wrapper { margin-top: 3px; - margin-left: 37px; - margin-right: 73px; + margin-left: 12px; + margin-right: 12px; margin-bottom: 2px; } diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index e1b4d0a6b..eb71a4ddd 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -111,16 +111,14 @@ a { opacity: 0.5; border: none; background: transparent; - margin-top: 2px; &:before { - margin-top: 4px; content: ''; display: inline-block; width: $button-height; height: $button-height; @include color-svg('../images/paperclip.svg', $grey); - transform: rotateZ(-45deg); + transform: rotateZ(-45deg) translateY(-2px); } &:focus, diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 99deb9429..b7da64888 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -829,10 +829,12 @@ // Module: Quoted Reply .module-quote-container { - margin-left: -6px; - margin-right: -6px; - margin-top: -4px; - margin-bottom: 5px; + margin: { + left: -6px; + right: -6px; + top: -4px; + bottom: 5px; + } } .module-quote-container--with-content-above { @@ -2630,10 +2632,6 @@ // Module: Attachments -.module-attachments { - border-top: 1px solid $color-black-015; -} - .module-attachments__header { height: 24px; position: relative; @@ -2654,8 +2652,8 @@ .module-attachments__rail { margin-top: 12px; - margin-left: 16px; - padding-right: 16px; + margin-left: 12px; + padding-right: 12px; overflow-x: scroll; max-height: 142px; white-space: nowrap; @@ -4712,6 +4710,13 @@ min-height: 32px; max-height: 80px; overflow: auto; + &--large { + max-height: 227px; + height: 227px; + .DraftEditor-root { + height: 227px - 2 * 7px; // subtract padding + } + } } @include light-theme() { @@ -4808,11 +4813,35 @@ // Module: CompositionArea .module-composition-area { - // Layout - display: flex; - flex-direction: row; + &__row { + display: flex; + flex-direction: row; + &--center { + justify-content: center; + } + &--padded { + padding: 0 12px; + } + &--control-row { + margin-top: 8px; + } + &--column { + flex-direction: column; + } + &--show-on-focus { + opacity: 0; + transition: opacity 250ms ease-out; + } + } + + $this: &; + &:focus-within, + &:hover { + #{$this}__row--show-on-focus { + opacity: 1; + } + } - // Child Elements &__button-cell { display: flex; justify-content: center; @@ -4820,13 +4849,60 @@ width: 44px; height: 100%; flex-shrink: 0; - &--microphone-active { - width: 100px; + &--mic-active { + width: 141px; + margin-right: 12px; + } + &--large-right { + margin-left: auto; + } + } + &__send-button { + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + background: none; + border: none; + &:after { + display: block; + content: ''; + width: 24px; + height: 24px; + flex-shrink: 0; + @include color-svg('../images/send.svg', $color-signal-blue); } } &__input { flex-grow: 1; } + &__toggle-large { + width: 20px; + height: 20px; + border: none; + + @include light-theme() { + @include color-svg('../images/expand-up.svg', $color-gray-45); + } + + @include dark-theme() { + @include color-svg('../images/expand-up.svg', $color-gray-45); + } + + &--large-active { + @include light-theme() { + @include color-svg('../images/collapse-down.svg', $color-gray-45); + } + + @include dark-theme() { + @include color-svg('../images/collapse-down.svg', $color-gray-45); + } + } + } + &__attachment-list { + width: 100%; + } } .composition-area-placeholder { diff --git a/stylesheets/_recorder.scss b/stylesheets/_recorder.scss index b76a1985e..6061453f4 100644 --- a/stylesheets/_recorder.scss +++ b/stylesheets/_recorder.scss @@ -2,14 +2,13 @@ text-align: center; .microphone { - height: 36px; - width: 36px; + height: 32px; + width: 32px; text-align: center; opacity: 0.5; background: transparent; padding: 0; border: none; - margin-top: 2px; &:focus, &:hover { @@ -26,18 +25,15 @@ } } .recorder { - background: $color-white; - button { float: right; - width: 36px; - height: 36px; - border-radius: 36px; + width: 32px; + height: 32px; + border-radius: 32px; margin-left: 5px; opacity: 0.5; text-align: center; padding: 0; - margin-top: 5px; &:focus, &:hover { @@ -74,6 +70,7 @@ float: right; line-height: 36px; padding: 0 10px; + transform: translateY(-2px); @keyframes pulse { 0% { diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index eca928bc7..0db47c64f 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -1433,10 +1433,6 @@ body.dark-theme { // Module: Attachments - .module-attachments { - border-top: 1px solid $color-gray-75; - } - .module-attachments__close-button { @include color-svg('../images/x-16.svg', $color-gray-45); } @@ -1694,8 +1690,6 @@ body.dark-theme { } } .recorder { - background: $color-black; - .finish { background: lighten($color-core-green, 20%); border: 1px solid $color-core-green; diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 6b61e852a..b42cfb952 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -1,5 +1,7 @@ import * as React from 'react'; import { Editor } from 'draft-js'; +import { noop } from 'lodash'; +import classNames from 'classnames'; import { EmojiButton, EmojiPickDataType, @@ -22,12 +24,21 @@ export type OwnProps = { readonly compositionApi?: React.MutableRefObject<{ focusInput: () => void; setDisabled: (disabled: boolean) => void; + setShowMic: (showMic: boolean) => void; + setMicActive: (micActive: boolean) => void; + attSlotRef: React.RefObject; reset: InputApi['reset']; resetEmojiResults: InputApi['resetEmojiResults']; }>; + readonly micCellEl?: HTMLElement; + readonly attCellEl?: HTMLElement; + readonly attachmentListEl?: HTMLElement; }; -export type Props = CompositionInputProps & +export type Props = Pick< + CompositionInputProps, + 'onSubmit' | 'onEditorSizeChange' | 'onEditorStateChange' +> & Pick< EmojiButtonProps, 'onPickEmoji' | 'onSetSkinTone' | 'recentEmojis' | 'skinTone' @@ -48,11 +59,18 @@ export type Props = CompositionInputProps & > & OwnProps; +const emptyElement = (el: HTMLElement) => { + // tslint:disable-next-line no-inner-html + el.innerHTML = ''; +}; + // tslint:disable-next-line max-func-body-length export const CompositionArea = ({ i18n, + attachmentListEl, + micCellEl, + attCellEl, // CompositionInput - onDirtyChange, onSubmit, compositionApi, onEditorSizeChange, @@ -76,16 +94,29 @@ export const CompositionArea = ({ clearShowPickerHint, }: Props) => { const [disabled, setDisabled] = React.useState(false); + const [showMic, setShowMic] = React.useState(true); + const [micActive, setMicActive] = React.useState(false); + const [dirty, setDirty] = React.useState(false); + const [large, setLarge] = React.useState(false); const editorRef = React.useRef(null); const inputApiRef = React.useRef(); const handleForceSend = React.useCallback( () => { + setLarge(false); if (inputApiRef.current) { inputApiRef.current.submit(); } }, - [inputApiRef] + [inputApiRef, setLarge] + ); + + const handleSubmit = React.useCallback( + (...args) => { + setLarge(false); + onSubmit(...args); + }, + [setLarge, onSubmit] ); const focusInput = React.useCallback( @@ -105,10 +136,16 @@ export const CompositionArea = ({ receivedPacks, }) > 0; + // A ref to grab a slot where backbone can insert link previews and attachments + const attSlotRef = React.useRef(null); + if (compositionApi) { compositionApi.current = { focusInput, setDisabled, + setShowMic, + setMicActive, + attSlotRef, reset: () => { if (inputApiRef.current) { inputApiRef.current.reset(); @@ -132,50 +169,178 @@ export const CompositionArea = ({ [inputApiRef, onPickEmoji] ); + const handleToggleLarge = React.useCallback( + () => { + setLarge(l => !l); + }, + [setLarge] + ); + + // The following is a work-around to allow react to lay-out backbone-managed + // dom nodes until those functions are in React + const micCellRef = React.useRef(null); + const attCellRef = React.useRef(null); + React.useLayoutEffect( + () => { + const { current: micCellContainer } = micCellRef; + const { current: attCellContainer } = attCellRef; + if (micCellContainer && micCellEl) { + emptyElement(micCellContainer); + micCellContainer.appendChild(micCellEl); + } + if (attCellContainer && attCellEl) { + emptyElement(attCellContainer); + attCellContainer.appendChild(attCellEl); + } + + return noop; + }, + [micCellRef, attCellRef, micCellEl, attCellEl, large, dirty, showMic] + ); + + React.useLayoutEffect( + () => { + const { current: attSlot } = attSlotRef; + if (attSlot && attachmentListEl) { + attSlot.appendChild(attachmentListEl); + } + + return noop; + }, + [attSlotRef, attachmentListEl] + ); + + const emojiButtonFragment = ( +
+ +
+ ); + + const micButtonFragment = showMic ? ( +
+ ) : null; + + const attButtonFragment = ( +
+ ); + + const sendButtonFragment = ( +
+
+ ); + + const stickerButtonPlacement = large ? 'top-start' : 'top-end'; + const stickerButtonFragment = withStickers ? ( +
+ +
+ ) : null; + return (
-
- +
-
- -
- {withStickers ? ( -
- +
+ {!large ? emojiButtonFragment : null} +
+
+ {!large ? ( + <> + {stickerButtonFragment} + {!dirty ? micButtonFragment : null} + {attButtonFragment} + + ) : null} +
+ {large ? ( +
+ {emojiButtonFragment} + {stickerButtonFragment} + {attButtonFragment} + {!dirty ? micButtonFragment : null} + {dirty || !showMic ? sendButtonFragment : null} +
) : null}
); diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index a52487267..aadb6e6f5 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -34,6 +34,7 @@ const colonsRegex = /(?:^|\s):[a-z0-9-_+]+:?/gi; export type Props = { readonly i18n: LocalizerType; readonly disabled?: boolean; + readonly large?: boolean; readonly editorRef?: React.RefObject; readonly inputApi?: React.MutableRefObject; readonly skinTone?: EmojiPickDataType['skinTone']; @@ -144,6 +145,7 @@ const combineRefs = createSelector( export const CompositionInput = ({ i18n, disabled, + large, editorRef, inputApi, onDirtyChange, @@ -531,6 +533,10 @@ export const CompositionInput = ({ } if (e.key === 'Enter' && !e.shiftKey) { + if (large && !(e.ctrlKey || e.metaKey)) { + return getDefaultKeyBinding(e); + } + e.preventDefault(); return 'submit'; @@ -562,7 +568,7 @@ export const CompositionInput = ({ return getDefaultKeyBinding(e); }, - [emojiResults] + [emojiResults, large] ); // Create popper root @@ -647,7 +653,14 @@ export const CompositionInput = ({ className="module-composition-input__input" ref={combineRefs(popperRef, measureRef, rootElRef)} > -
+
unknown; readonly showPickerHint: boolean; readonly clearShowPickerHint: () => unknown; + readonly position?: 'top-end' | 'top-start'; }; export type Props = OwnProps; @@ -44,6 +45,7 @@ export const StickerButton = React.memo( clearShowIntroduction, showPickerHint, clearShowPickerHint, + position = 'top-end', }: Props) => { const [open, setOpen] = React.useState(false); const [popperRoot, setPopperRoot] = React.useState( @@ -188,7 +190,7 @@ export const StickerButton = React.memo( )} {!open && !showIntroduction && installedPack ? ( - + {({ ref, style, placement, arrowProps }) => (
) : null} {!open && showIntroduction ? ( - + {({ ref, style, placement, arrowProps }) => (
+ {({ ref, style }) => (