Typing Indicators

This commit is contained in:
Scott Nonnenberg 2018-11-14 11:10:32 -08:00
parent 99252702e1
commit 79a861a870
23 changed files with 906 additions and 121 deletions

View file

@ -664,6 +664,7 @@
messageReceiver.addEventListener('reconnect', onReconnect);
messageReceiver.addEventListener('progress', onProgress);
messageReceiver.addEventListener('configuration', onConfiguration);
messageReceiver.addEventListener('typing', onTyping);
window.textsecure.messaging = new textsecure.MessageSender(
USERNAME,
@ -790,10 +791,14 @@
}
function onConfiguration(ev) {
const { configuration } = ev;
const {
readReceipts,
typingIndicators,
unidentifiedDeliveryIndicators,
} = configuration;
storage.put('read-receipt-setting', configuration.readReceipts);
storage.put('read-receipt-setting', readReceipts);
const { unidentifiedDeliveryIndicators } = configuration;
if (
unidentifiedDeliveryIndicators === true ||
unidentifiedDeliveryIndicators === false
@ -803,9 +808,34 @@
unidentifiedDeliveryIndicators
);
}
if (typingIndicators === true || typingIndicators === false) {
storage.put('typingIndicators', typingIndicators);
}
ev.confirm();
}
function onTyping(ev) {
const { typing, sender, senderDevice } = ev;
const { groupId, started } = typing || {};
// We don't do anything with incoming typing messages if the setting is disabled
if (!storage.get('typingIndicators')) {
return;
}
const conversation = ConversationController.get(groupId || sender);
if (conversation) {
conversation.notifyTyping({
isTyping: started,
sender,
senderDevice,
});
}
}
async function onContactReceived(ev) {
const details = ev.contactDetails;

View file

@ -131,12 +131,93 @@
this.unset('tokens');
this.unset('lastMessage');
this.unset('lastMessageStatus');
this.typingRefreshTimer = null;
this.typingPauseTimer = null;
},
isMe() {
return this.id === this.ourNumber;
},
bumpTyping() {
// We don't send typing messages if the setting is disabled
if (!storage.get('typingIndicators')) {
return;
}
if (!this.typingRefreshTimer) {
const isTyping = true;
this.setTypingRefreshTimer();
this.sendTypingMessage(isTyping);
}
this.setTypingPauseTimer();
},
setTypingRefreshTimer() {
if (this.typingRefreshTimer) {
clearTimeout(this.typingRefreshTimer);
}
this.typingRefreshTimer = setTimeout(
this.onTypingRefreshTimeout.bind(this),
10 * 1000
);
},
onTypingRefreshTimeout() {
const isTyping = true;
this.sendTypingMessage(isTyping);
// This timer will continue to reset itself until the pause timer stops it
this.setTypingRefreshTimer();
},
setTypingPauseTimer() {
if (this.typingPauseTimer) {
clearTimeout(this.typingPauseTimer);
}
this.typingPauseTimer = setTimeout(
this.onTypingPauseTimeout.bind(this),
3 * 1000
);
},
onTypingPauseTimeout() {
const isTyping = false;
this.sendTypingMessage(isTyping);
this.clearTypingTimers();
},
clearTypingTimers() {
if (this.typingPauseTimer) {
clearTimeout(this.typingPauseTimer);
this.typingPauseTimer = null;
}
if (this.typingRefreshTimer) {
clearTimeout(this.typingRefreshTimer);
this.typingRefreshTimer = null;
}
},
sendTypingMessage(isTyping) {
const groupId = !this.isPrivate() ? this.id : null;
const recipientId = this.isPrivate() ? this.id : null;
const sendOptions = this.getSendOptions();
this.wrapSend(
textsecure.messaging.sendTypingMessage(
{
groupId,
isTyping,
recipientId,
},
sendOptions
)
);
},
async cleanup() {
await window.Signal.Types.Conversation.deleteExternalFiles(
this.attributes,
@ -189,6 +270,12 @@
},
addSingleMessage(message) {
// Clear typing indicator for a given contact if we receive a message from them
const identifier = message.get
? `${message.get('source')}.${message.get('sourceDevice')}`
: `${message.source}.${message.sourceDevice}`;
this.clearContactTypingTimer(identifier);
const model = this.messageCollection.add(message, { merge: true });
model.setToExpire();
return model;
@ -211,6 +298,8 @@
};
},
getPropsForListItem() {
const typingKeys = Object.keys(this.contactTypingTimers || {});
const result = {
...this.format(),
conversationType: this.isPrivate() ? 'direct' : 'group',
@ -219,6 +308,7 @@
unreadCount: this.get('unreadCount') || 0,
isSelected: this.isSelected,
isTyping: typingKeys.length > 0,
lastMessage: {
status: this.lastMessageStatus,
text: this.lastMessage,
@ -698,6 +788,8 @@
},
sendMessage(body, attachments, quote) {
this.clearTypingTimers();
const destination = this.id;
const expireTimer = this.get('expireTimer');
const recipients = this.getRecipients();
@ -1753,6 +1845,69 @@
})
);
},
notifyTyping(options = {}) {
const { isTyping, sender, senderDevice } = options;
// We don't do anything with typing messages from our other devices
if (sender === this.ourNumber) {
return;
}
const identifier = `${sender}.${senderDevice}`;
this.contactTypingTimers = this.contactTypingTimers || {};
const record = this.contactTypingTimers[identifier];
if (record) {
clearTimeout(record.timer);
}
// Note: We trigger two events because:
// 'typing-update' is a surgical update ConversationView does for in-convo bubble
// 'change' causes a re-render of this conversation's list item in the left pane
if (isTyping) {
this.contactTypingTimers[identifier] = this.contactTypingTimers[
identifier
] || {
timestamp: Date.now(),
sender,
senderDevice,
};
this.contactTypingTimers[identifier].timer = setTimeout(
this.clearContactTypingTimer.bind(this, identifier),
15 * 1000
);
if (!record) {
// User was not previously typing before. State change!
this.trigger('typing-update');
this.trigger('change');
}
} else {
delete this.contactTypingTimers[identifier];
if (record) {
// User was previously typing, and is no longer. State change!
this.trigger('typing-update');
this.trigger('change');
}
}
},
clearContactTypingTimer(identifier) {
this.contactTypingTimers = this.contactTypingTimers || {};
const record = this.contactTypingTimers[identifier];
if (record) {
clearTimeout(record.timer);
delete this.contactTypingTimers[identifier];
// User was previously typing, but timed out or we received message. State change!
this.trigger('typing-update');
this.trigger('change');
}
},
});
Whisper.ConversationCollection = Backbone.Collection.extend({

View file

@ -55,6 +55,9 @@ const {
const {
TimerNotification,
} = require('../../ts/components/conversation/TimerNotification');
const {
TypingBubble,
} = require('../../ts/components/conversation/TypingBubble');
const {
VerificationNotification,
} = require('../../ts/components/conversation/VerificationNotification');
@ -191,6 +194,7 @@ exports.setup = (options = {}) => {
Types: {
Message: MediaGalleryMessage,
},
TypingBubble,
VerificationNotification,
};

View file

@ -695,6 +695,7 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
messageArray,
timestamp,
silent,
online,
{ accessKey } = {}
) {
const jsonData = { messages: messageArray, timestamp };
@ -702,6 +703,9 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
if (silent) {
jsonData.silent = true;
}
if (online) {
jsonData.online = true;
}
return _ajax({
call: 'messages',
@ -714,12 +718,21 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
});
}
function sendMessages(destination, messageArray, timestamp, silent) {
function sendMessages(
destination,
messageArray,
timestamp,
silent,
online
) {
const jsonData = { messages: messageArray, timestamp };
if (silent) {
jsonData.silent = true;
}
if (online) {
jsonData.online = true;
}
return _ajax({
call: 'messages',

View file

@ -1,12 +1,15 @@
/* global $: false */
/* global _: false */
/* global emojiData: false */
/* global EmojiPanel: false */
/* global extension: false */
/* global i18n: false */
/* global Signal: false */
/* global storage: false */
/* global Whisper: false */
/* global
$,
_,
emojiData,
EmojiPanel,
extension,
i18n,
Signal,
storage,
Whisper,
ConversationController
*/
// eslint-disable-next-line func-names
(function() {
@ -80,6 +83,7 @@
this.listenTo(this.model, 'newmessage', this.addMessage);
this.listenTo(this.model, 'opened', this.onOpened);
this.listenTo(this.model, 'prune', this.onPrune);
this.listenTo(this.model, 'typing-update', this.renderTypingBubble);
this.listenTo(
this.model.messageCollection,
'show-identity',
@ -236,6 +240,7 @@
'submit .send': 'checkUnverifiedSendMessage',
'input .send-message': 'updateMessageFieldSize',
'keydown .send-message': 'updateMessageFieldSize',
'keyup .send-message': 'maybeBumpTyping',
click: 'onClick',
'click .bottom-bar': 'focusMessageField',
'click .capture-audio .microphone': 'captureAudio',
@ -421,6 +426,43 @@
}
},
renderTypingBubble() {
const timers = this.model.contactTypingTimers || {};
const records = _.values(timers);
const mostRecent = _.first(_.sortBy(records, 'timestamp'));
if (!mostRecent && this.typingBubbleView) {
this.typingBubbleView.remove();
this.typingBubbleView = null;
}
if (!mostRecent) {
return;
}
const { sender } = mostRecent;
const contact = ConversationController.getOrCreate(sender, 'private');
const props = {
...contact.format(),
conversationType: this.model.isPrivate() ? 'direct' : 'group',
};
if (this.typingBubbleView) {
this.typingBubbleView.update(props);
return;
}
this.typingBubbleView = new Whisper.ReactWrapperView({
className: 'message-wrapper typing-bubble-wrapper',
Component: Signal.Components.TypingBubble,
props,
});
this.typingBubbleView.$el.appendTo(this.$('.typing-container'));
if (this.view.atBottom()) {
this.typingBubbleView.el.scrollIntoView();
}
},
toggleMicrophone() {
if (
this.$('.send-message').val().length > 0 ||
@ -538,6 +580,7 @@
this.view.resetScrollPosition();
this.$el.trigger('force-resize');
this.focusMessageField();
this.renderTypingBubble();
if (this.inProgressFetch) {
// eslint-disable-next-line more/no-then
@ -1492,6 +1535,7 @@
async sendMessage(e) {
this.removeLastSeenIndicator();
this.closeEmojiPanel();
this.model.clearTypingTimers();
let toast;
if (extension.expired()) {
@ -1543,6 +1587,15 @@
}
},
// 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();
if (messageText.length) {
this.model.bumpTyping();
}
},
updateMessageFieldSize(event) {
const keyCode = event.which || event.keyCode;

View file

@ -1,4 +1,4 @@
/* global Whisper, _ */
/* global Whisper, Backbone, _, $ */
// eslint-disable-next-line func-names
(function() {
@ -6,15 +6,36 @@
window.Whisper = window.Whisper || {};
Whisper.MessageListView = Whisper.ListView.extend({
Whisper.MessageListView = Backbone.View.extend({
tagName: 'ul',
className: 'message-list',
template: $('#message-list').html(),
itemView: Whisper.MessageView,
events: {
scroll: 'onScroll',
},
// Here we reimplement Whisper.ListView so we can override addAll
render() {
this.addAll();
return this;
},
// The key is that we don't erase all inner HTML, we re-render our template.
// And then we keep a reference to .messages
addAll() {
Whisper.View.prototype.render.call(this);
this.$messages = this.$('.messages');
this.collection.each(this.addOne, this);
},
initialize() {
Whisper.ListView.prototype.initialize.call(this);
this.listenTo(this.collection, 'add', this.addOne);
this.listenTo(this.collection, 'reset', this.addAll);
this.render();
this.triggerLazyScroll = _.debounce(() => {
this.$el.trigger('lazyScroll');
@ -78,10 +99,10 @@
if (index === this.collection.length - 1) {
// add to the bottom.
this.$el.append(view.el);
this.$messages.append(view.el);
} else if (index === 0) {
// add to top
this.$el.prepend(view.el);
this.$messages.prepend(view.el);
} else {
// insert
const next = this.$(`#${this.collection.at(index + 1).id}`);
@ -92,7 +113,7 @@
view.$el.insertAfter(prev);
} else {
// scan for the right spot
const elements = this.$el.children();
const elements = this.$messages.children();
if (elements.length > 0) {
for (let i = 0; i < elements.length; i += 1) {
const m = this.collection.get(elements[i].id);
@ -103,7 +124,7 @@
}
}
} else {
this.$el.append(view.el);
this.$messages.append(view.el);
}
}
}