Typing Indicators
This commit is contained in:
parent
99252702e1
commit
79a861a870
23 changed files with 906 additions and 121 deletions
|
@ -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;
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue