Virtualize Messages List - only render what's visible

This commit is contained in:
Scott Nonnenberg 2019-05-31 15:42:01 -07:00
parent a976cfe6b6
commit 5ebd8bc690
73 changed files with 4717 additions and 2745 deletions

View file

@ -27,13 +27,7 @@
};
const { Util } = window.Signal;
const {
Conversation,
Contact,
Errors,
Message,
PhoneNumber,
} = window.Signal.Types;
const { Conversation, Contact, Message, PhoneNumber } = window.Signal.Types;
const {
deleteAttachmentData,
getAbsoluteAttachmentPath,
@ -277,6 +271,7 @@
this.messageCollection.remove(id);
existing.trigger('expired');
existing.cleanup();
};
// If a fetch is in progress, then we need to wait until that's complete to
@ -288,18 +283,33 @@
},
async onNewMessage(message) {
await this.updateLastMessage();
// 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);
await this.updateLastMessage();
},
addSingleMessage(message) {
const { id } = message;
const existing = this.messageCollection.get(id);
const model = this.messageCollection.add(message, { merge: true });
model.setToExpire();
if (!existing) {
const { messagesAdded } = window.reduxActions.conversations;
const isNewMessage = true;
messagesAdded(
this.id,
[model.getReduxData()],
isNewMessage,
document.hasFocus()
);
}
return model;
},
@ -310,7 +320,12 @@
const { format } = PhoneNumber;
const regionCode = storage.get('regionCode');
const color = this.getColor();
const typingKeys = Object.keys(this.contactTypingTimers || {});
const typingValues = _.values(this.contactTypingTimers || {});
const typingMostRecent = _.first(_.sortBy(typingValues, 'timestamp'));
const typingContact = typingMostRecent
? ConversationController.getOrCreate(typingMostRecent.sender, 'private')
: null;
const result = {
id: this.id,
@ -321,7 +336,7 @@
color,
type: this.isPrivate() ? 'direct' : 'group',
isMe: this.isMe(),
isTyping: typingKeys.length > 0,
typingContact: typingContact ? typingContact.format() : null,
lastUpdated: this.get('timestamp'),
name: this.getName(),
profileName: this.getProfileName(),
@ -894,6 +909,9 @@
sendMessage(body, attachments, quote, preview, sticker) {
this.clearTypingTimers();
const { clearUnreadMetrics } = window.reduxActions.conversations;
clearUnreadMetrics(this.id);
const destination = this.id;
const expireTimer = this.get('expireTimer');
const recipients = this.getRecipients();
@ -1202,7 +1220,7 @@
return;
}
const messages = await window.Signal.Data.getMessagesByConversation(
const messages = await window.Signal.Data.getOlderMessagesByConversation(
this.id,
{ limit: 1, MessageCollection: Whisper.MessageCollection }
);
@ -1310,7 +1328,7 @@
model.set({ id });
const message = MessageController.register(id, model);
this.messageCollection.add(message);
this.addSingleMessage(message);
// if change was made remotely, don't send it to the number/group
if (receivedAt) {
@ -1373,7 +1391,7 @@
async endSession() {
if (this.isPrivate()) {
const now = Date.now();
const message = this.messageCollection.add({
const model = new Whisper.Message({
conversationId: this.id,
type: 'outgoing',
sent_at: now,
@ -1383,10 +1401,13 @@
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
});
const id = await window.Signal.Data.saveMessage(message.attributes, {
const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: Whisper.Message,
});
message.set({ id });
model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
message.send(
@ -1407,7 +1428,7 @@
groupUpdate = this.pick(['name', 'avatar', 'members']);
}
const now = Date.now();
const message = this.messageCollection.add({
const model = new Whisper.Message({
conversationId: this.id,
type: 'outgoing',
sent_at: now,
@ -1415,10 +1436,14 @@
group_update: groupUpdate,
});
const id = await window.Signal.Data.saveMessage(message.attributes, {
const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: Whisper.Message,
});
message.set({ id });
model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
message.send(
@ -1443,7 +1468,7 @@
Conversation: Whisper.Conversation,
});
const message = this.messageCollection.add({
const model = new Whisper.Message({
group_update: { left: 'You' },
conversationId: this.id,
type: 'outgoing',
@ -1451,10 +1476,13 @@
received_at: now,
});
const id = await window.Signal.Data.saveMessage(message.attributes, {
const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: Whisper.Message,
});
message.set({ id });
model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
message.send(
@ -1830,57 +1858,6 @@
this.set({ accessKey });
},
async upgradeMessages(messages) {
for (let max = messages.length, i = 0; i < max; i += 1) {
const message = messages.at(i);
const { attributes } = message;
const { schemaVersion } = attributes;
if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) {
// Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop
const upgradedMessage = await upgradeMessageSchema(attributes);
message.set(upgradedMessage);
// eslint-disable-next-line no-await-in-loop
await window.Signal.Data.saveMessage(upgradedMessage, {
Message: Whisper.Message,
});
}
}
},
async fetchMessages() {
if (!this.id) {
throw new Error('This conversation has no id!');
}
if (this.inProgressFetch) {
window.log.warn('Attempting to start a parallel fetchMessages() call');
return;
}
this.inProgressFetch = this.messageCollection.fetchConversation(
this.id,
undefined,
this.get('unreadCount')
);
await this.inProgressFetch;
try {
// We are now doing the work to upgrade messages before considering the load from
// the database complete. Note that we do save messages back, so it is a
// one-time hit. We do this so we have guarantees about message structure.
await this.upgradeMessages(this.messageCollection);
} catch (error) {
window.log.error(
'fetchMessages: failed to upgrade messages',
Errors.toLogFormat(error)
);
}
this.inProgressFetch = null;
},
hasMember(number) {
return _.contains(this.get('members'), number);
},
@ -1908,10 +1885,6 @@
},
async destroyMessages() {
await window.Signal.Data.removeAllMessagesInConversation(this.id, {
MessageCollection: Whisper.MessageCollection,
});
this.messageCollection.reset([]);
this.set({
@ -1922,6 +1895,10 @@
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
});
await window.Signal.Data.removeAllMessagesInConversation(this.id, {
MessageCollection: Whisper.MessageCollection,
});
},
getName() {
@ -2102,10 +2079,6 @@
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
@ -2121,14 +2094,12 @@
);
if (!record) {
// User was not previously typing before. State change!
this.trigger('typing-update');
this.trigger('change', this);
}
} else {
delete this.contactTypingTimers[identifier];
if (record) {
// User was previously typing, and is no longer. State change!
this.trigger('typing-update');
this.trigger('change', this);
}
}
@ -2143,7 +2114,6 @@
delete this.contactTypingTimers[identifier];
// User was previously typing, but timed out or we received message. State change!
this.trigger('typing-update');
this.trigger('change', this);
}
},
@ -2155,17 +2125,6 @@
comparator(m) {
return -m.get('timestamp');
},
async destroyAll() {
await Promise.all(
this.models.map(conversation =>
window.Signal.Data.removeConversation(conversation.id, {
Conversation: Whisper.Conversation,
})
)
);
this.reset([]);
},
});
Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' ');

View file

@ -100,69 +100,67 @@
this.on('expired', this.onExpired);
this.setToExpire();
this.on('change', this.generateProps);
this.on('change', this.notifyRedux);
},
const applicableConversationChanges =
'change:color change:name change:number change:profileName change:profileAvatar';
notifyRedux() {
const { messageChanged } = window.reduxActions.conversations;
const conversation = this.getConversation();
const fromContact = this.getIncomingContact();
this.listenTo(
conversation,
applicableConversationChanges,
this.generateProps
);
if (fromContact) {
this.listenTo(
fromContact,
applicableConversationChanges,
this.generateProps
);
if (messageChanged) {
const conversationId = this.get('conversationId');
// Note: The clone is important for triggering a re-run of selectors
messageChanged(this.id, conversationId, _.clone(this.attributes));
}
},
this.generateProps();
getReduxData() {
const contact = this.getPropsForEmbeddedContact();
return {
...this.attributes,
// We need this in the reducer to detect if the message's height has changed
hasSignalAccount: contact ? Boolean(contact.signalAccount) : null,
};
},
// Top-level prop generation for the message bubble
generateProps() {
getPropsForBubble() {
if (this.isUnsupportedMessage()) {
this.props = {
return {
type: 'unsupportedMessage',
data: this.getPropsForUnsupportedMessage(),
};
} else if (this.isExpirationTimerUpdate()) {
this.props = {
return {
type: 'timerNotification',
data: this.getPropsForTimerNotification(),
};
} else if (this.isKeyChange()) {
this.props = {
return {
type: 'safetyNumberNotification',
data: this.getPropsForSafetyNumberNotification(),
};
} else if (this.isVerifiedChange()) {
this.props = {
return {
type: 'verificationNotification',
data: this.getPropsForVerificationNotification(),
};
} else if (this.isGroupUpdate()) {
this.props = {
return {
type: 'groupNotification',
data: this.getPropsForGroupNotification(),
};
} else if (this.isEndSession()) {
this.props = {
return {
type: 'resetSessionNotification',
data: this.getPropsForResetSessionNotification(),
};
} else {
this.propsForSearchResult = this.getPropsForSearchResult();
this.props = {
type: 'message',
data: this.getPropsForMessage(),
};
}
return {
type: 'message',
data: this.getPropsForMessage(),
};
},
// Other top-level prop-generation
@ -269,6 +267,21 @@
disableScroll: true,
// To ensure that group avatar doesn't show up
conversationType: 'direct',
downloadNewVersion: () => {
this.trigger('download-new-version');
},
deleteMessage: messageId => {
this.trigger('delete', messageId);
},
showVisualAttachment: options => {
this.trigger('show-visual-attachment', options);
},
displayTapToViewMessage: messageId => {
this.trigger('display-tap-to-view-message', messageId);
},
openLink: url => {
this.trigger('navigate-to', url);
},
},
errors,
contacts: sortedContacts,
@ -290,7 +303,7 @@
const flag =
textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
// eslint-disable-next-line no-bitwise
return !!(this.get('flags') & flag);
return Boolean(this.get('flags') & flag);
},
isKeyChange() {
return this.get('type') === 'keychange';
@ -353,12 +366,10 @@
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const phoneNumber = this.get('key_changed');
const showIdentity = id => this.trigger('show-identity', id);
return {
isGroup,
contact: this.findAndFormatContact(phoneNumber),
showIdentity,
};
},
getPropsForVerificationNotification() {
@ -498,28 +509,6 @@
isTapToViewExpired: isTapToView && this.get('isErased'),
isTapToViewError:
isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'),
replyToMessage: id => this.trigger('reply', id),
retrySend: id => this.trigger('retry', id),
deleteMessage: id => this.trigger('delete', id),
showMessageDetail: id => this.trigger('show-message-detail', id),
openConversation: conversationId =>
this.trigger('open-conversation', conversationId),
showContactDetail: contactOptions =>
this.trigger('show-contact-detail', contactOptions),
showVisualAttachment: lightboxOptions =>
this.trigger('show-lightbox', lightboxOptions),
downloadAttachment: downloadOptions =>
this.trigger('download', downloadOptions),
displayTapToViewMessage: messageId =>
this.trigger('display-tap-to-view-message', messageId),
openLink: url => this.trigger('navigate-to', url),
downloadNewVersion: () => this.trigger('download-new-version'),
scrollToMessage: scrollOptions =>
this.trigger('scroll-to-message', scrollOptions),
};
},
@ -692,6 +681,7 @@
authorName,
authorColor,
referencedMessageNotFound,
onClick: () => this.trigger('scroll-to-message'),
};
},
getStatus(number) {
@ -851,6 +841,8 @@
this.cleanup();
},
async cleanup() {
const { messageDeleted } = window.reduxActions.conversations;
messageDeleted(this.id, this.get('conversationId'));
MessageController.unregister(this.id);
this.unload();
await this.deleteData();
@ -2193,74 +2185,5 @@
return (left.get('received_at') || 0) - (right.get('received_at') || 0);
},
initialize(models, options) {
if (options) {
this.conversation = options.conversation;
}
},
async destroyAll() {
await Promise.all(
this.models.map(message =>
window.Signal.Data.removeMessage(message.id, {
Message: Whisper.Message,
})
)
);
this.reset([]);
},
getLoadedUnreadCount() {
return this.reduce((total, model) => {
const unread = model.get('unread') && model.isIncoming();
return total + (unread ? 1 : 0);
}, 0);
},
async fetchConversation(conversationId, limit = 100, unreadCount = 0) {
const startingLoadedUnread =
unreadCount > 0 ? this.getLoadedUnreadCount() : 0;
// We look for older messages if we've fetched once already
const receivedAt =
this.length === 0 ? Number.MAX_VALUE : this.at(0).get('received_at');
const messages = await window.Signal.Data.getMessagesByConversation(
conversationId,
{
limit,
receivedAt,
MessageCollection: Whisper.MessageCollection,
}
);
const models = messages
.filter(message => Boolean(message.id))
.map(message => MessageController.register(message.id, message));
const eliminated = messages.length - models.length;
if (eliminated > 0) {
window.log.warn(
`fetchConversation: Eliminated ${eliminated} messages without an id`
);
}
this.add(models);
if (unreadCount <= 0) {
return;
}
const loadedUnread = this.getLoadedUnreadCount();
if (loadedUnread >= unreadCount) {
return;
}
if (startingLoadedUnread === loadedUnread) {
// that fetch didn't get us any more unread. stop fetching more.
return;
}
window.log.info(
'fetchConversation: doing another fetch to get all unread'
);
await this.fetchConversation(conversationId, limit, unreadCount);
},
});
})();