Large Message Composition

This commit is contained in:
Ken Powers 2019-08-06 15:18:37 -04:00 committed by Scott Nonnenberg
parent 4d659f69cb
commit 79bba52cfb
14 changed files with 388 additions and 115 deletions

View File

@ -107,27 +107,18 @@
<div class='conversation-header'></div>
<div class='main panel'>
<div class='discussion-container'>
<div class='bar-container hide'>
<div class='bar active progress-bar-striped progress-bar'></div>
</div>
<div class='bar-container hide'>
<div class='bar active progress-bar-striped progress-bar'></div>
</div>
</div>
<div class='bottom-bar' id='footer'>
<div class='attachment-list'></div>
<div class='compose'>
<form class='send clearfix file-input'>
<div class='flex'>
<div class='composition-area-placeholder'></div>
<div class='capture-audio'>
<button class='microphone'></button>
</div>
<div class='choose-file'>
<button class='paperclip thumbnail'></button>
<input type='file' class='file-input' multiple='multiple'>
</div>
</div>
</form>
</div>
<div class='compose'>
<form class='send clearfix file-input'>
<input type="file" class="file-input" multiple="multiple">
<div class='composition-area-placeholder'></div>
</form>
</div>
</div>
</div>
</script>

1
images/collapse-down.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><title>collapse-down-20</title><path d="M10,13.75a.746.746,0,0,1-.4-.114l-8-5A.75.75,0,1,1,2.4,7.364L10,12.116l7.6-4.752A.75.75,0,1,1,18.4,8.636l-8,5A.746.746,0,0,1,10,13.75Z"/></svg>

After

Width:  |  Height:  |  Size: 266 B

1
images/expand-up.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"><title>expand-up-20</title><path d="M18,12.75a.746.746,0,0,1-.4-.114L10,7.884,2.4,12.636A.75.75,0,1,1,1.6,11.364l8-5a.748.748,0,0,1,.794,0l8,5A.75.75,0,0,1,18,12.75Z"/></svg>

After

Width:  |  Height:  |  Size: 257 B

1
images/send.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>send-solid-24</title><path d="M22.1,10.915,5.286,1.306A1.25,1.25,0,0,0,3.433,2.6L4.69,10.138,14,12,4.69,13.862,3.433,21.4a1.25,1.25,0,0,0,1.853,1.291L22.1,13.085A1.25,1.25,0,0,0,22.1,10.915Z"/></svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@ -181,8 +181,11 @@
this.loadingScreen.$el.prependTo(this.$('.discussion-container'));
this.window = options.window;
const attachmentListEl = $(
'<div class="module-composition-area__attachment-list"></div>'
);
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 = $(`
<div class="capture-audio">
<button class="microphone"></button>
</div>
`)[0];
const attCellEl = $(`
<div class="choose-file">
<button class="paperclip thumbnail"></button>
</div>
`)[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();

View File

@ -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;
}

View File

@ -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,

View File

@ -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 {

View File

@ -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% {

View File

@ -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;

View File

@ -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<HTMLDivElement>;
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<Editor>(null);
const inputApiRef = React.useRef<InputApi | undefined>();
const handleForceSend = React.useCallback(
() => {
setLarge(false);
if (inputApiRef.current) {
inputApiRef.current.submit();
}
},
[inputApiRef]
[inputApiRef, setLarge]
);
const handleSubmit = React.useCallback<typeof onSubmit>(
(...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<HTMLDivElement>(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<HTMLDivElement>(null);
const attCellRef = React.useRef<HTMLDivElement>(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 = (
<div className="module-composition-area__button-cell">
<EmojiButton
i18n={i18n}
doSend={handleForceSend}
onPickEmoji={insertEmoji}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
onClose={focusInput}
/>
</div>
);
const micButtonFragment = showMic ? (
<div
className={classNames(
'module-composition-area__button-cell',
micActive ? 'module-composition-area__button-cell--mic-active' : null,
large ? 'module-composition-area__button-cell--large-right' : null
)}
ref={micCellRef}
/>
) : null;
const attButtonFragment = (
<div className="module-composition-area__button-cell" ref={attCellRef} />
);
const sendButtonFragment = (
<div
className={classNames(
'module-composition-area__button-cell',
large ? 'module-composition-area__button-cell--large-right' : null
)}
>
<button
className="module-composition-area__send-button"
onClick={handleForceSend}
/>
</div>
);
const stickerButtonPlacement = large ? 'top-start' : 'top-end';
const stickerButtonFragment = withStickers ? (
<div className="module-composition-area__button-cell">
<StickerButton
i18n={i18n}
knownPacks={knownPacks}
receivedPacks={receivedPacks}
installedPacks={installedPacks}
blessedPacks={blessedPacks}
recentStickers={recentStickers}
clearInstalledStickerPack={clearInstalledStickerPack}
onClickAddPack={onClickAddPack}
onPickSticker={onPickSticker}
clearShowIntroduction={clearShowIntroduction}
showPickerHint={showPickerHint}
clearShowPickerHint={clearShowPickerHint}
position={stickerButtonPlacement}
/>
</div>
) : null;
return (
<div className="module-composition-area">
<div className="module-composition-area__button-cell">
<EmojiButton
i18n={i18n}
doSend={handleForceSend}
onPickEmoji={insertEmoji}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
onClose={focusInput}
<div
className={classNames(
'module-composition-area__row',
'module-composition-area__row--center',
'module-composition-area__row--show-on-focus'
)}
>
<button
className={classNames(
'module-composition-area__toggle-large',
large ? 'module-composition-area__toggle-large--large-active' : null
)}
onClick={handleToggleLarge}
/>
</div>
<div className="module-composition-area__input">
<CompositionInput
i18n={i18n}
disabled={disabled}
editorRef={editorRef}
inputApi={inputApiRef}
onPickEmoji={onPickEmoji}
onSubmit={onSubmit}
onEditorSizeChange={onEditorSizeChange}
onEditorStateChange={onEditorStateChange}
onDirtyChange={onDirtyChange}
skinTone={skinTone}
/>
</div>
{withStickers ? (
<div className="module-composition-area__button-cell">
<StickerButton
<div
className={classNames(
'module-composition-area__row',
'module-composition-area__row--column'
)}
ref={attSlotRef}
/>
<div
className={classNames(
'module-composition-area__row',
large ? 'module-composition-area__row--padded' : null
)}
>
{!large ? emojiButtonFragment : null}
<div className="module-composition-area__input">
<CompositionInput
i18n={i18n}
knownPacks={knownPacks}
receivedPacks={receivedPacks}
installedPacks={installedPacks}
blessedPacks={blessedPacks}
recentStickers={recentStickers}
clearInstalledStickerPack={clearInstalledStickerPack}
onClickAddPack={onClickAddPack}
onPickSticker={onPickSticker}
clearShowIntroduction={clearShowIntroduction}
showPickerHint={showPickerHint}
clearShowPickerHint={clearShowPickerHint}
disabled={disabled}
large={large}
editorRef={editorRef}
inputApi={inputApiRef}
onPickEmoji={onPickEmoji}
onSubmit={handleSubmit}
onEditorSizeChange={onEditorSizeChange}
onEditorStateChange={onEditorStateChange}
onDirtyChange={setDirty}
skinTone={skinTone}
/>
</div>
{!large ? (
<>
{stickerButtonFragment}
{!dirty ? micButtonFragment : null}
{attButtonFragment}
</>
) : null}
</div>
{large ? (
<div
className={classNames(
'module-composition-area__row',
'module-composition-area__row--control-row'
)}
>
{emojiButtonFragment}
{stickerButtonFragment}
{attButtonFragment}
{!dirty ? micButtonFragment : null}
{dirty || !showMic ? sendButtonFragment : null}
</div>
) : null}
</div>
);

View File

@ -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<Editor>;
readonly inputApi?: React.MutableRefObject<InputApi | undefined>;
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)}
>
<div className="module-composition-input__input__scroller">
<div
className={classNames(
'module-composition-input__input__scroller',
large
? 'module-composition-input__input__scroller--large'
: null
)}
>
<Editor
ref={editorRef}
editorState={editorRenderState}

View File

@ -23,6 +23,7 @@ export type OwnProps = {
readonly clearShowIntroduction: () => 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<HTMLElement | null>(
@ -188,7 +190,7 @@ export const StickerButton = React.memo(
)}
</Reference>
{!open && !showIntroduction && installedPack ? (
<Popper placement="top-end" key={installedPack.id}>
<Popper placement={position} key={installedPack.id}>
{({ ref, style, placement, arrowProps }) => (
<div
ref={ref}
@ -225,7 +227,7 @@ export const StickerButton = React.memo(
</Popper>
) : null}
{!open && showIntroduction ? (
<Popper placement="top-end">
<Popper placement={position}>
{({ ref, style, placement, arrowProps }) => (
<div
ref={ref}
@ -267,7 +269,7 @@ export const StickerButton = React.memo(
) : null}
{open && popperRoot
? createPortal(
<Popper placement="top-end">
<Popper placement={position}>
{({ ref, style }) => (
<StickerPicker
ref={ref}

View File

@ -7885,5 +7885,23 @@
"lineNumber": 60,
"reasonCategory": "falseMatch",
"updated": "2019-05-02T20:44:56.470Z"
},
{
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.js",
"line": " el.innerHTML = '';",
"lineNumber": 22,
"reasonCategory": "usageTrusted",
"updated": "2019-08-01T14:10:37.481Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
},
{
"rule": "DOM-innerHTML",
"path": "ts/components/CompositionArea.tsx",
"line": " el.innerHTML = '';",
"lineNumber": 64,
"reasonCategory": "usageTrusted",
"updated": "2019-08-01T14:10:37.481Z",
"reasonDetail": "Our code, no user input, only clearing out the dom"
}
]
]