Virtualize Messages List - only render what's visible
This commit is contained in:
parent
a976cfe6b6
commit
5ebd8bc690
73 changed files with 4717 additions and 2745 deletions
|
@ -476,6 +476,12 @@
|
|||
const initialState = {
|
||||
conversations: {
|
||||
conversationLookup: Signal.Util.makeLookup(conversations, 'id'),
|
||||
messagesByConversation: {},
|
||||
messagesLookup: {},
|
||||
selectedConversation: null,
|
||||
selectedMessage: null,
|
||||
selectedMessageCounter: 0,
|
||||
showArchived: false,
|
||||
},
|
||||
emojis: Signal.Emojis.getInitialState(),
|
||||
items: storage.getItemsState(),
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
'add remove change:unreadCount',
|
||||
_.debounce(this.updateUnreadCount.bind(this), 1000)
|
||||
);
|
||||
this.startPruning();
|
||||
},
|
||||
addActive(model) {
|
||||
if (model.get('active_at')) {
|
||||
|
@ -44,14 +43,6 @@
|
|||
}
|
||||
window.updateTrayIcon(newUnreadCount);
|
||||
},
|
||||
startPruning() {
|
||||
const halfHour = 30 * 60 * 1000;
|
||||
this.interval = setInterval(() => {
|
||||
this.forEach(conversation => {
|
||||
conversation.trigger('prune');
|
||||
});
|
||||
}, halfHour);
|
||||
},
|
||||
}))();
|
||||
|
||||
window.getInboxCollection = () => inboxCollection;
|
||||
|
|
|
@ -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(' ');
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -696,7 +696,7 @@ async function exportConversation(conversation, options = {}) {
|
|||
|
||||
while (!complete) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const collection = await window.Signal.Data.getMessagesByConversation(
|
||||
const collection = await window.Signal.Data.getOlderMessagesByConversation(
|
||||
conversation.id,
|
||||
{
|
||||
limit: CHUNK_SIZE,
|
||||
|
|
|
@ -121,9 +121,11 @@ module.exports = {
|
|||
getExpiredMessages,
|
||||
getOutgoingWithoutExpiresAt,
|
||||
getNextExpiringMessage,
|
||||
getMessagesByConversation,
|
||||
getNextTapToViewMessageToAgeOut,
|
||||
getTapToViewMessagesNeedingErase,
|
||||
getOlderMessagesByConversation,
|
||||
getNewerMessagesByConversation,
|
||||
getMessageMetricsForConversation,
|
||||
|
||||
getUnprocessedCount,
|
||||
getAllUnprocessed,
|
||||
|
@ -779,17 +781,40 @@ async function getUnreadByConversation(conversationId, { MessageCollection }) {
|
|||
return new MessageCollection(messages);
|
||||
}
|
||||
|
||||
async function getMessagesByConversation(
|
||||
async function getOlderMessagesByConversation(
|
||||
conversationId,
|
||||
{ limit = 100, receivedAt = Number.MAX_VALUE, MessageCollection }
|
||||
) {
|
||||
const messages = await channels.getMessagesByConversation(conversationId, {
|
||||
limit,
|
||||
receivedAt,
|
||||
});
|
||||
const messages = await channels.getOlderMessagesByConversation(
|
||||
conversationId,
|
||||
{
|
||||
limit,
|
||||
receivedAt,
|
||||
}
|
||||
);
|
||||
|
||||
return new MessageCollection(messages);
|
||||
}
|
||||
async function getNewerMessagesByConversation(
|
||||
conversationId,
|
||||
{ limit = 100, receivedAt = 0, MessageCollection }
|
||||
) {
|
||||
const messages = await channels.getNewerMessagesByConversation(
|
||||
conversationId,
|
||||
{
|
||||
limit,
|
||||
receivedAt,
|
||||
}
|
||||
);
|
||||
|
||||
return new MessageCollection(messages);
|
||||
}
|
||||
async function getMessageMetricsForConversation(conversationId) {
|
||||
const result = await channels.getMessageMetricsForConversation(
|
||||
conversationId
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function removeAllMessagesInConversation(
|
||||
conversationId,
|
||||
|
@ -800,7 +825,7 @@ async function removeAllMessagesInConversation(
|
|||
// Yes, we really want the await in the loop. We're deleting 100 at a
|
||||
// time so we don't use too much memory.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
messages = await getMessagesByConversation(conversationId, {
|
||||
messages = await getOlderMessagesByConversation(conversationId, {
|
||||
limit: 100,
|
||||
MessageCollection,
|
||||
});
|
||||
|
|
|
@ -28,51 +28,25 @@ const {
|
|||
ContactDetail,
|
||||
} = require('../../ts/components/conversation/ContactDetail');
|
||||
const { ContactListItem } = require('../../ts/components/ContactListItem');
|
||||
const { ContactName } = require('../../ts/components/conversation/ContactName');
|
||||
const {
|
||||
ConversationHeader,
|
||||
} = require('../../ts/components/conversation/ConversationHeader');
|
||||
const {
|
||||
EmbeddedContact,
|
||||
} = require('../../ts/components/conversation/EmbeddedContact');
|
||||
const { Emojify } = require('../../ts/components/conversation/Emojify');
|
||||
const {
|
||||
GroupNotification,
|
||||
} = require('../../ts/components/conversation/GroupNotification');
|
||||
const { Lightbox } = require('../../ts/components/Lightbox');
|
||||
const { LightboxGallery } = require('../../ts/components/LightboxGallery');
|
||||
const {
|
||||
MediaGallery,
|
||||
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
|
||||
const { Message } = require('../../ts/components/conversation/Message');
|
||||
const { MessageBody } = require('../../ts/components/conversation/MessageBody');
|
||||
const {
|
||||
MessageDetail,
|
||||
} = require('../../ts/components/conversation/MessageDetail');
|
||||
const { Quote } = require('../../ts/components/conversation/Quote');
|
||||
const {
|
||||
ResetSessionNotification,
|
||||
} = require('../../ts/components/conversation/ResetSessionNotification');
|
||||
const {
|
||||
SafetyNumberNotification,
|
||||
} = require('../../ts/components/conversation/SafetyNumberNotification');
|
||||
const {
|
||||
StagedLinkPreview,
|
||||
} = require('../../ts/components/conversation/StagedLinkPreview');
|
||||
const {
|
||||
TimerNotification,
|
||||
} = require('../../ts/components/conversation/TimerNotification');
|
||||
const {
|
||||
TypingBubble,
|
||||
} = require('../../ts/components/conversation/TypingBubble');
|
||||
const {
|
||||
UnsupportedMessage,
|
||||
} = require('../../ts/components/conversation/UnsupportedMessage');
|
||||
const {
|
||||
VerificationNotification,
|
||||
} = require('../../ts/components/conversation/VerificationNotification');
|
||||
|
||||
// State
|
||||
const { createTimeline } = require('../../ts/state/roots/createTimeline');
|
||||
const {
|
||||
createCompositionArea,
|
||||
} = require('../../ts/state/roots/createCompositionArea');
|
||||
|
@ -264,33 +238,23 @@ exports.setup = (options = {}) => {
|
|||
CaptionEditor,
|
||||
ContactDetail,
|
||||
ContactListItem,
|
||||
ContactName,
|
||||
ConversationHeader,
|
||||
EmbeddedContact,
|
||||
Emojify,
|
||||
GroupNotification,
|
||||
Lightbox,
|
||||
LightboxGallery,
|
||||
MediaGallery,
|
||||
Message,
|
||||
MessageBody,
|
||||
MessageDetail,
|
||||
Quote,
|
||||
ResetSessionNotification,
|
||||
SafetyNumberNotification,
|
||||
StagedLinkPreview,
|
||||
TimerNotification,
|
||||
Types: {
|
||||
Message: MediaGalleryMessage,
|
||||
},
|
||||
TypingBubble,
|
||||
UnsupportedMessage,
|
||||
VerificationNotification,
|
||||
};
|
||||
|
||||
const Roots = {
|
||||
createCompositionArea,
|
||||
createLeftPane,
|
||||
createTimeline,
|
||||
createStickerManager,
|
||||
createStickerPreviewModal,
|
||||
};
|
||||
|
|
|
@ -152,7 +152,7 @@
|
|||
silent: !status.shouldPlayNotificationSound,
|
||||
});
|
||||
this.lastNotification.onclick = () =>
|
||||
this.trigger('click', last.conversationId, last.id);
|
||||
this.trigger('click', last.conversationId, last.messageId);
|
||||
|
||||
// We continue to build up more and more messages for our notifications
|
||||
// until the user comes back to our app or closes the app. Then we’ll
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,32 +0,0 @@
|
|||
/* global Backbone, Whisper */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
Whisper.GroupUpdateView = Backbone.View.extend({
|
||||
tagName: 'div',
|
||||
className: 'group-update',
|
||||
render() {
|
||||
// TODO l10n
|
||||
if (this.model.left) {
|
||||
this.$el.text(`${this.model.left} left the group`);
|
||||
return this;
|
||||
}
|
||||
|
||||
const messages = ['Updated the group.'];
|
||||
if (this.model.name) {
|
||||
messages.push(`Title is now '${this.model.name}'.`);
|
||||
}
|
||||
if (this.model.joined) {
|
||||
messages.push(`${this.model.joined.join(', ')} joined the group`);
|
||||
}
|
||||
|
||||
this.$el.text(messages.join(' '));
|
||||
|
||||
return this;
|
||||
},
|
||||
});
|
||||
})();
|
|
@ -22,31 +22,28 @@
|
|||
Whisper.ConversationStack = Whisper.View.extend({
|
||||
className: 'conversation-stack',
|
||||
lastConversation: null,
|
||||
open(conversation) {
|
||||
open(conversation, messageId) {
|
||||
const id = `conversation-${conversation.cid}`;
|
||||
if (id !== this.el.firstChild.id) {
|
||||
this.$el
|
||||
.first()
|
||||
.find('video, audio')
|
||||
.each(function pauseMedia() {
|
||||
this.pause();
|
||||
});
|
||||
let $el = this.$(`#${id}`);
|
||||
if ($el === null || $el.length === 0) {
|
||||
const view = new Whisper.ConversationView({
|
||||
model: conversation,
|
||||
window: this.model.window,
|
||||
});
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
$el = view.$el;
|
||||
if (id !== this.el.lastChild.id) {
|
||||
const view = new Whisper.ConversationView({
|
||||
model: conversation,
|
||||
window: this.model.window,
|
||||
});
|
||||
view.$el.appendTo(this.el);
|
||||
|
||||
if (this.lastConversation) {
|
||||
this.lastConversation.trigger(
|
||||
'unload',
|
||||
'opened another conversation'
|
||||
);
|
||||
}
|
||||
$el.prependTo(this.el);
|
||||
|
||||
this.lastConversation = conversation;
|
||||
conversation.trigger('opened', messageId);
|
||||
} else if (messageId) {
|
||||
conversation.trigger('scroll-to-message', messageId);
|
||||
}
|
||||
conversation.trigger('opened');
|
||||
if (this.lastConversation) {
|
||||
this.lastConversation.trigger('backgrounded');
|
||||
}
|
||||
this.lastConversation = conversation;
|
||||
|
||||
// Make sure poppers are positioned properly
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
},
|
||||
|
@ -122,11 +119,10 @@
|
|||
},
|
||||
setupLeftPane() {
|
||||
this.leftPaneView = new Whisper.ReactWrapperView({
|
||||
JSX: Signal.State.Roots.createLeftPane(window.reduxStore),
|
||||
className: 'left-pane-wrapper',
|
||||
JSX: Signal.State.Roots.createLeftPane(window.reduxStore),
|
||||
});
|
||||
|
||||
// Finally, add it to the DOM
|
||||
this.$('.left-pane-placeholder').append(this.leftPaneView.el);
|
||||
},
|
||||
startConnectionListener() {
|
||||
|
@ -194,7 +190,7 @@
|
|||
openConversationExternal(id, messageId);
|
||||
}
|
||||
|
||||
this.conversation_stack.open(conversation);
|
||||
this.conversation_stack.open(conversation, messageId);
|
||||
this.focusConversation();
|
||||
},
|
||||
closeRecording(e) {
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
/* global Whisper, i18n */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
Whisper.LastSeenIndicatorView = Whisper.View.extend({
|
||||
className: 'module-last-seen-indicator',
|
||||
templateName: 'last-seen-indicator-view',
|
||||
initialize(options = {}) {
|
||||
this.count = options.count || 0;
|
||||
},
|
||||
|
||||
increment(count) {
|
||||
this.count += count;
|
||||
this.render();
|
||||
},
|
||||
|
||||
getCount() {
|
||||
return this.count;
|
||||
},
|
||||
|
||||
render_attributes() {
|
||||
const unreadMessages =
|
||||
this.count === 1
|
||||
? i18n('unreadMessage')
|
||||
: i18n('unreadMessages', [this.count]);
|
||||
|
||||
return {
|
||||
unreadMessages,
|
||||
};
|
||||
},
|
||||
});
|
||||
})();
|
|
@ -1,143 +0,0 @@
|
|||
/* global Whisper, Backbone, _, $ */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
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() {
|
||||
this.listenTo(this.collection, 'add', this.addOne);
|
||||
this.listenTo(this.collection, 'reset', this.addAll);
|
||||
|
||||
this.render();
|
||||
|
||||
this.triggerLazyScroll = _.debounce(() => {
|
||||
this.$el.trigger('lazyScroll');
|
||||
}, 500);
|
||||
},
|
||||
onScroll() {
|
||||
this.measureScrollPosition();
|
||||
if (this.$el.scrollTop() === 0) {
|
||||
this.$el.trigger('loadMore');
|
||||
}
|
||||
if (this.atBottom()) {
|
||||
this.$el.trigger('atBottom');
|
||||
} else if (this.bottomOffset > this.outerHeight) {
|
||||
this.$el.trigger('farFromBottom');
|
||||
}
|
||||
|
||||
this.triggerLazyScroll();
|
||||
},
|
||||
atBottom() {
|
||||
return this.bottomOffset < 30;
|
||||
},
|
||||
measureScrollPosition() {
|
||||
if (this.el.scrollHeight === 0) {
|
||||
// hidden
|
||||
return;
|
||||
}
|
||||
this.outerHeight = this.$el.outerHeight();
|
||||
this.scrollPosition = this.$el.scrollTop() + this.outerHeight;
|
||||
this.scrollHeight = this.el.scrollHeight;
|
||||
this.bottomOffset = this.scrollHeight - this.scrollPosition;
|
||||
},
|
||||
resetScrollPosition() {
|
||||
this.$el.scrollTop(this.scrollPosition - this.$el.outerHeight());
|
||||
},
|
||||
restoreBottomOffset() {
|
||||
if (_.isNumber(this.bottomOffset)) {
|
||||
// + 10 is necessary to account for padding
|
||||
const height = this.$el.height() + 10;
|
||||
|
||||
const topOfBottomScreen = this.el.scrollHeight - height;
|
||||
this.$el.scrollTop(topOfBottomScreen - this.bottomOffset);
|
||||
}
|
||||
},
|
||||
scrollToBottomIfNeeded() {
|
||||
// This is counter-intuitive. Our current bottomOffset is reflective of what
|
||||
// we last measured, not necessarily the current state. And this is called
|
||||
// after we just made a change to the DOM: inserting a message, or an image
|
||||
// finished loading. So if we were near the bottom before, we _need_ to be
|
||||
// at the bottom again. So we scroll to the bottom.
|
||||
if (this.atBottom()) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.$el.scrollTop(this.el.scrollHeight);
|
||||
this.measureScrollPosition();
|
||||
},
|
||||
addOne(model) {
|
||||
// eslint-disable-next-line new-cap
|
||||
const view = new this.itemView({ model }).render();
|
||||
this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition);
|
||||
this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded);
|
||||
|
||||
const index = this.collection.indexOf(model);
|
||||
this.measureScrollPosition();
|
||||
|
||||
if (model.get('unread') && !this.atBottom()) {
|
||||
this.$el.trigger('newOffscreenMessage');
|
||||
}
|
||||
|
||||
if (index === this.collection.length - 1) {
|
||||
// add to the bottom.
|
||||
this.$messages.append(view.el);
|
||||
} else if (index === 0) {
|
||||
// add to top
|
||||
this.$messages.prepend(view.el);
|
||||
} else {
|
||||
// insert
|
||||
const next = this.$(`#${this.collection.at(index + 1).id}`);
|
||||
const prev = this.$(`#${this.collection.at(index - 1).id}`);
|
||||
if (next.length > 0) {
|
||||
view.$el.insertBefore(next);
|
||||
} else if (prev.length > 0) {
|
||||
view.$el.insertAfter(prev);
|
||||
} else {
|
||||
// scan for the right spot
|
||||
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);
|
||||
const mIndex = this.collection.indexOf(m);
|
||||
if (mIndex > index) {
|
||||
view.$el.insertBefore(elements[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.$messages.append(view.el);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.scrollToBottomIfNeeded();
|
||||
},
|
||||
});
|
||||
})();
|
|
@ -1,149 +0,0 @@
|
|||
/* global Whisper: false */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
Whisper.MessageView = Whisper.View.extend({
|
||||
tagName: 'li',
|
||||
id() {
|
||||
return this.model.id;
|
||||
},
|
||||
initialize() {
|
||||
this.listenTo(this.model, 'change', this.onChange);
|
||||
this.listenTo(this.model, 'destroy', this.onDestroy);
|
||||
this.listenTo(this.model, 'unload', this.onUnload);
|
||||
this.listenTo(this.model, 'expired', this.onExpired);
|
||||
|
||||
this.updateHiddenSticker();
|
||||
},
|
||||
updateHiddenSticker() {
|
||||
const sticker = this.model.get('sticker');
|
||||
this.isHiddenSticker = sticker && (!sticker.data || !sticker.data.path);
|
||||
},
|
||||
onChange() {
|
||||
this.addId();
|
||||
},
|
||||
addId() {
|
||||
// The ID is important for other items inserting themselves into the DOM. Because
|
||||
// of ReactWrapperView and this view, there are two layers of DOM elements
|
||||
// between the parent and the elements returned by the React component, so this is
|
||||
// necessary.
|
||||
const { id } = this.model;
|
||||
this.$el.attr('id', id);
|
||||
},
|
||||
onExpired() {
|
||||
setTimeout(() => this.onUnload(), 1000);
|
||||
},
|
||||
onUnload() {
|
||||
if (this.childView) {
|
||||
this.childView.remove();
|
||||
}
|
||||
|
||||
this.remove();
|
||||
},
|
||||
onDestroy() {
|
||||
this.onUnload();
|
||||
},
|
||||
getRenderInfo() {
|
||||
const { Components } = window.Signal;
|
||||
const { type, data: props } = this.model.props;
|
||||
|
||||
if (type === 'unsupportedMessage') {
|
||||
return {
|
||||
Component: Components.UnsupportedMessage,
|
||||
props,
|
||||
};
|
||||
} else if (type === 'timerNotification') {
|
||||
return {
|
||||
Component: Components.TimerNotification,
|
||||
props,
|
||||
};
|
||||
} else if (type === 'safetyNumberNotification') {
|
||||
return {
|
||||
Component: Components.SafetyNumberNotification,
|
||||
props,
|
||||
};
|
||||
} else if (type === 'verificationNotification') {
|
||||
return {
|
||||
Component: Components.VerificationNotification,
|
||||
props,
|
||||
};
|
||||
} else if (type === 'groupNotification') {
|
||||
return {
|
||||
Component: Components.GroupNotification,
|
||||
props,
|
||||
};
|
||||
} else if (type === 'resetSessionNotification') {
|
||||
return {
|
||||
Component: Components.ResetSessionNotification,
|
||||
props,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
Component: Components.Message,
|
||||
props,
|
||||
};
|
||||
},
|
||||
render() {
|
||||
this.addId();
|
||||
|
||||
if (this.childView) {
|
||||
this.childView.remove();
|
||||
this.childView = null;
|
||||
}
|
||||
|
||||
const { Component, props } = this.getRenderInfo();
|
||||
this.childView = new Whisper.ReactWrapperView({
|
||||
className: 'message-wrapper',
|
||||
Component,
|
||||
props,
|
||||
});
|
||||
|
||||
const update = () => {
|
||||
const info = this.getRenderInfo();
|
||||
this.childView.update(info.props, () => {
|
||||
if (!this.isHiddenSticker) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateHiddenSticker();
|
||||
|
||||
if (!this.isHiddenSticker) {
|
||||
this.model.trigger('height-changed');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.listenTo(this.model, 'change', update);
|
||||
this.listenTo(this.model, 'expired', update);
|
||||
|
||||
const applicableConversationChanges =
|
||||
'change:color change:name change:number change:profileName change:profileAvatar';
|
||||
|
||||
this.conversation = this.model.getConversation();
|
||||
this.listenTo(this.conversation, applicableConversationChanges, update);
|
||||
|
||||
this.fromContact = this.model.getIncomingContact();
|
||||
if (this.fromContact) {
|
||||
this.listenTo(this.fromContact, applicableConversationChanges, update);
|
||||
}
|
||||
|
||||
this.quotedContact = this.model.getQuoteContact();
|
||||
if (this.quotedContact) {
|
||||
this.listenTo(
|
||||
this.quotedContact,
|
||||
applicableConversationChanges,
|
||||
update
|
||||
);
|
||||
}
|
||||
|
||||
this.$el.append(this.childView.el);
|
||||
|
||||
return this;
|
||||
},
|
||||
});
|
||||
})();
|
|
@ -1,39 +0,0 @@
|
|||
/* global Whisper, i18n */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
Whisper.ScrollDownButtonView = Whisper.View.extend({
|
||||
className: 'module-scroll-down',
|
||||
templateName: 'scroll-down-button-view',
|
||||
|
||||
initialize(options = {}) {
|
||||
this.count = options.count || 0;
|
||||
},
|
||||
|
||||
increment(count = 0) {
|
||||
this.count += count;
|
||||
this.render();
|
||||
},
|
||||
|
||||
render_attributes() {
|
||||
const buttonClass =
|
||||
this.count > 0 ? 'module-scroll-down__button--new-messages' : '';
|
||||
|
||||
let moreBelow = i18n('scrollDown');
|
||||
if (this.count > 1) {
|
||||
moreBelow = i18n('messagesBelow');
|
||||
} else if (this.count === 1) {
|
||||
moreBelow = i18n('messageBelow');
|
||||
}
|
||||
|
||||
return {
|
||||
buttonClass,
|
||||
moreBelow,
|
||||
};
|
||||
},
|
||||
});
|
||||
})();
|
Loading…
Add table
Add a link
Reference in a new issue