New composition area with emoji typeahead

This commit is contained in:
Ken Powers 2019-06-27 16:35:21 -04:00 committed by Scott Nonnenberg
parent e62a1a7812
commit 7b645011c2
95 changed files with 1740 additions and 1293 deletions

View file

@ -74,11 +74,10 @@ const {
} = require('../../ts/components/conversation/VerificationNotification');
// State
const { createEmojiButton } = require('../../ts/state/roots/createEmojiButton');
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
const {
createStickerButton,
} = require('../../ts/state/roots/createStickerButton');
createCompositionArea,
} = require('../../ts/state/roots/createCompositionArea');
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
const {
createStickerManager,
} = require('../../ts/state/roots/createStickerManager');
@ -286,9 +285,8 @@ exports.setup = (options = {}) => {
};
const Roots = {
createEmojiButton,
createCompositionArea,
createLeftPane,
createStickerButton,
createStickerManager,
createStickerPreviewModal,
};

View file

@ -92,6 +92,7 @@
this.listenTo(this.model, 'change:verified', this.onVerifiedChange);
this.listenTo(this.model, 'newmessage', this.addMessage);
this.listenTo(this.model, 'opened', this.onOpened);
this.listenTo(this.model, 'backgrounded', this.resetEmojiResults);
this.listenTo(this.model, 'prune', this.onPrune);
this.listenTo(this.model, 'unload', () => this.unload('model trigger'));
this.listenTo(this.model, 'typing-update', this.renderTypingBubble);
@ -200,11 +201,6 @@
this.$('.discussion-container').append(this.view.el);
this.view.render();
this.$messageField = this.$('.send-message');
this.onResize = this.forceUpdateMessageFieldSize.bind(this);
this.window.addEventListener('resize', this.onResize);
this.onFocus = () => {
if (this.$el.css('display') !== 'none') {
this.markRead();
@ -222,36 +218,22 @@
this.$('.send-message').blur(this.unfocusBottomBar.bind(this));
this.setupHeader();
this.setupEmojiPickerButton();
this.setupStickerPickerButton();
this.lastSelectionStart = 0;
document.addEventListener(
'selectionchange',
this.updateLastSelectionStart.bind(this, undefined)
);
this.setupCompositionArea();
},
events: {
'submit .send': 'clickSend',
'input .send-message': 'updateMessageFieldSize',
'keydown .send-message': 'updateMessageFieldSize',
'keyup .send-message': 'onKeyUp',
click: 'onClick',
'click .emoji-button-placeholder': 'onClickPlaceholder',
'click .sticker-button-placeholder': 'onClickPlaceholder',
'click .composition-area-placeholder': 'onClickPlaceholder',
'click .bottom-bar': 'focusMessageField',
'click .capture-audio .microphone': 'captureAudio',
'click .module-scroll-down': 'scrollToBottom',
'focus .send-message': 'focusBottomBar',
'change .file-input': 'toggleMicrophone',
'blur .send-message': 'unfocusBottomBar',
'loadMore .message-list': 'loadMoreMessages',
'newOffscreenMessage .message-list': 'addScrollDownButtonWithCount',
'atBottom .message-list': 'removeScrollDownButton',
'farFromBottom .message-list': 'addScrollDownButton',
'lazyScroll .message-list': 'onLazyScroll',
'force-resize': 'forceUpdateMessageFieldSize',
'click button.paperclip': 'onChooseAttachment',
'change input.file-input': 'onChoseAttachment',
@ -331,52 +313,31 @@
this.$('.conversation-header').append(this.titleView.el);
},
setupEmojiPickerButton() {
const props = {
onForceSend: () => {
this.sendMessage({});
},
onPickEmoji: e => this.insertEmoji(e),
onClose: () => {
const textarea = this.$messageField[0];
textarea.focus();
const newPos = textarea.value.length;
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
this.forceUpdateLastSelectionStart(newPos);
},
};
this.emojiButtonView = new Whisper.ReactWrapperView({
className: 'emoji-button-wrapper',
JSX: Signal.State.Roots.createEmojiButton(window.reduxStore, props),
});
// Finally, add it to the DOM
this.$('.emoji-button-placeholder').append(this.emojiButtonView.el);
},
setupStickerPickerButton() {
if (!window.ENABLE_STICKER_SEND) {
return;
}
setupCompositionArea() {
const compositionApi = { current: null };
this.compositionApi = compositionApi;
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),
};
this.stickerButtonView = new Whisper.ReactWrapperView({
className: 'sticker-button-wrapper',
JSX: Signal.State.Roots.createStickerButton(window.reduxStore, props),
this.compositionAreaView = new Whisper.ReactWrapperView({
className: 'composition-area-wrapper',
JSX: Signal.State.Roots.createCompositionArea(window.reduxStore, props),
});
// Finally, add it to the DOM
this.$('.sticker-button-placeholder').append(this.stickerButtonView.el);
this.$('.composition-area-placeholder').append(
this.compositionAreaView.el
);
},
// We need this, or clicking the reactified buttons will submit the form and send any
@ -479,14 +440,7 @@
}
}
this.window.removeEventListener('resize', this.onResize);
this.window.removeEventListener('focus', this.onFocus);
document.removeEventListener(
'selectionchange',
this.updateLastSelectionStart
);
window.autosize.destroy(this.$messageField);
this.view.remove();
@ -628,11 +582,8 @@
}
},
toggleMicrophone() {
if (
this.$('.send-message').val().length > 0 ||
this.fileInput.hasFiles()
) {
toggleMicrophone(dirty = false) {
if (dirty || this.fileInput.hasFiles()) {
this.$('.capture-audio').hide();
} else {
this.$('.capture-audio').show();
@ -664,7 +615,7 @@
view.on('closed', this.endCaptureAudio.bind(this));
view.$el.appendTo(this.$('.capture-audio'));
this.$('.send-message').attr('disabled', true);
this.disableMessageField();
this.$('.microphone').hide();
},
handleAudioCapture(blob) {
@ -673,10 +624,10 @@
file: blob,
isVoiceNote: true,
});
this.$('.bottom-bar form').submit();
this.sendMessage();
},
endCaptureAudio() {
this.$('.send-message').removeAttr('disabled');
this.enableMessageField();
this.$('.microphone').show();
this.captureAudioView = null;
},
@ -745,7 +696,6 @@
messagesLoaded.then(this.onLoaded.bind(this), this.onLoaded.bind(this));
this.view.resetScrollPosition();
this.$el.trigger('force-resize');
this.focusMessageField();
this.renderTypingBubble();
@ -1088,13 +1038,28 @@
return;
}
this.$messageField.focus();
const { compositionApi } = this;
if (compositionApi && compositionApi.current) {
compositionApi.current.focusInput();
}
},
focusMessageFieldAndClearDisabled() {
this.$messageField.removeAttr('disabled');
this.$messageField.focus();
this.updateLastSelectionStart();
this.compositionApi.current.setDisabled(false);
this.focusMessageField();
},
disableMessageField() {
this.compositionApi.current.setDisabled(true);
},
enableMessageField() {
this.compositionApi.current.setDisabled(false);
},
resetEmojiResults() {
this.compositionApi.current.resetEmojiResults(false);
},
async loadMoreMessages() {
@ -1648,7 +1613,6 @@
view.remove();
if (this.panels.length === 0) {
this.$el.trigger('force-resize');
// Make sure poppers are positioned properly
window.dispatchEvent(new Event('resize'));
}
@ -1716,36 +1680,6 @@
});
},
async clickSend(e, options) {
e.preventDefault();
this.sendStart = Date.now();
this.$messageField.attr('disabled', true);
try {
const contacts = await this.getUntrustedContacts(options);
if (contacts && contacts.length) {
const sendAnyway = await this.showSendAnywayDialog(contacts);
if (sendAnyway) {
this.clickSend(e, { force: true });
return;
}
this.focusMessageFieldAndClearDisabled();
return;
}
this.sendMessage(e);
} catch (error) {
this.focusMessageFieldAndClearDisabled();
window.log.error(
'clickSend error:',
error && error.stack ? error.stack : error
);
}
},
async sendStickerMessage(options = {}) {
try {
const contacts = await this.getUntrustedContacts(options);
@ -1799,34 +1733,6 @@
return null;
},
insertEmoji({ shortName, skinTone }) {
const skinReplacement = window.Signal.Emojis.hasVariation(
shortName,
skinTone
)
? `:skin-tone-${skinTone}:`
: '';
const colons = `:${shortName}:${skinReplacement}`;
const textarea = this.$messageField[0];
const hasFocus = document.activeElement === textarea;
const startPos = hasFocus
? textarea.selectionStart
: this.lastSelectionStart;
const endPos = hasFocus ? textarea.selectionEnd : this.lastSelectionStart;
textarea.value =
textarea.value.substring(0, startPos) +
colons +
textarea.value.substring(endPos, textarea.value.length);
const newPos = startPos + colons.length;
textarea.selectionStart = newPos;
textarea.selectionEnd = newPos;
this.forceUpdateLastSelectionStart(newPos);
this.forceUpdateMessageFieldSize({});
},
async setQuoteMessage(messageId) {
this.quote = null;
this.quotedMessage = null;
@ -1858,7 +1764,6 @@
}
if (!this.quotedMessage) {
this.view.restoreBottomOffset();
this.updateMessageFieldSize({});
return;
}
@ -1890,18 +1795,39 @@
}),
onInitialRender: () => {
this.view.restoreBottomOffset();
this.updateMessageFieldSize({});
},
});
},
async sendMessage(e) {
async sendMessage(message = '', options = {}) {
this.sendStart = Date.now();
try {
const contacts = await this.getUntrustedContacts(options);
this.disableMessageField();
if (contacts && contacts.length) {
const sendAnyway = await this.showSendAnywayDialog(contacts);
if (sendAnyway) {
this.sendMessage(message, { force: true });
return;
}
this.focusMessageFieldAndClearDisabled();
return;
}
} catch (error) {
this.focusMessageFieldAndClearDisabled();
window.log.error(
'sendMessage error:',
error && error.stack ? error.stack : error
);
return;
}
this.removeLastSeenIndicator();
this.model.clearTypingTimers();
const input = this.$messageField;
const message = window.Signal.Emojis.replaceColons(input.val()).trim();
let toast;
if (extension.expired()) {
toast = new Whisper.ExpiredToast();
@ -1942,11 +1868,9 @@
this.getLinkPreview()
);
input.val('');
this.compositionApi.current.reset();
this.setQuoteMessage(null);
this.resetLinkPreview();
this.focusMessageFieldAndClearDisabled();
this.forceUpdateMessageFieldSize(e);
this.fileInput.clearAttachments();
} catch (error) {
window.log.error(
@ -1958,24 +1882,16 @@
}
},
onKeyUp() {
this.maybeBumpTyping();
this.debouncedMaybeGrabLinkPreview();
onEditorStateChange(messageText, caretLocation) {
this.maybeBumpTyping(messageText);
this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
},
updateLastSelectionStart(newPos) {
if (document.activeElement === this.$messageField[0]) {
this.forceUpdateLastSelectionStart(newPos);
}
onEditorSizeChange() {
this.view.scrollToBottomIfNeeded();
},
forceUpdateLastSelectionStart(
newPos = this.$messageField[0].selectionStart
) {
this.lastSelectionStart = newPos;
},
maybeGrabLinkPreview() {
maybeGrabLinkPreview(message, caretLocation) {
// Don't generate link previews if user has turned them off
if (!storage.get('linkPreviews', false)) {
return;
@ -1993,10 +1909,7 @@
return;
}
const messageText = this.$messageField.val().trim();
const caretLocation = this.$messageField.get(0).selectionStart;
if (!messageText) {
if (!message) {
this.resetLinkPreview();
return;
}
@ -2005,7 +1918,7 @@
}
const links = window.Signal.LinkPreviews.findLinks(
messageText,
message,
caretLocation
);
const { currentlyMatchedLink } = this;
@ -2310,7 +2223,6 @@
}
if (!this.currentlyMatchedLink) {
this.view.restoreBottomOffset();
this.updateMessageFieldSize({});
return;
}
@ -2332,7 +2244,6 @@
props,
onInitialRender: () => {
this.view.restoreBottomOffset();
this.updateMessageFieldSize({});
},
});
},
@ -2362,59 +2273,12 @@
// Called whenever the user changes the message composition field. But only
// fires if there's content in the message field after the change.
maybeBumpTyping() {
const messageText = this.$messageField.val();
maybeBumpTyping(messageText) {
if (messageText.length) {
this.model.throttledBumpTyping();
}
},
updateMessageFieldSize(event) {
const keyCode = event.which || event.keyCode;
if (
keyCode === 13 &&
!event.altKey &&
!event.shiftKey &&
!event.ctrlKey
) {
// enter pressed - submit the form now
event.preventDefault();
this.$('.bottom-bar form').submit();
return;
}
this.toggleMicrophone();
this.view.measureScrollPosition();
window.autosize(this.$messageField);
const $attachmentPreviews = this.$('.attachment-previews');
const $bottomBar = this.$('.bottom-bar');
const includeMargin = true;
const quoteHeight = this.quoteView
? this.quoteView.$el.outerHeight(includeMargin)
: 0;
const height =
this.$messageField.outerHeight() +
$attachmentPreviews.outerHeight() +
quoteHeight +
parseInt($bottomBar.css('min-height'), 10);
$bottomBar.outerHeight(height);
this.view.scrollToBottomIfNeeded();
},
forceUpdateMessageFieldSize(event) {
if (this.isHidden()) {
return;
}
this.view.scrollToBottomIfNeeded();
window.autosize.update(this.$messageField);
this.updateMessageFieldSize(event);
},
isHidden() {
return (
this.$el.css('display') === 'none' ||

View file

@ -21,6 +21,7 @@
Whisper.ConversationStack = Whisper.View.extend({
className: 'conversation-stack',
lastConversation: null,
open(conversation) {
const id = `conversation-${conversation.cid}`;
if (id !== this.el.firstChild.id) {
@ -42,6 +43,10 @@
$el.prependTo(this.el);
}
conversation.trigger('opened');
if (this.lastConversation) {
this.lastConversation.trigger('backgrounded');
}
this.lastConversation = conversation;
// Make sure poppers are positioned properly
window.dispatchEvent(new Event('resize'));
},