diff --git a/_locales/en/messages.json b/_locales/en/messages.json index cbaf3db355..444ed2052c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -309,11 +309,6 @@ "description": "Alt text for button to take user down to bottom of conversation, shown when user scrolls up" }, - "messageBelow": { - "message": "New message below", - "description": - "Alt text for button to take user down to bottom of conversation with a new message out of screen" - }, "messagesBelow": { "message": "New messages below", "description": diff --git a/app/sql.js b/app/sql.js index 9dfb2bcb8d..3c2a09f95d 100644 --- a/app/sql.js +++ b/app/sql.js @@ -16,6 +16,7 @@ const { isString, last, map, + pick, } = require('lodash'); // To get long stack traces @@ -93,9 +94,11 @@ module.exports = { getExpiredMessages, getOutgoingWithoutExpiresAt, getNextExpiringMessage, - getMessagesByConversation, getNextTapToViewMessageToAgeOut, getTapToViewMessagesNeedingErase, + getOlderMessagesByConversation, + getNewerMessagesByConversation, + getMessageMetricsForConversation, getUnprocessedCount, getAllUnprocessed, @@ -1840,7 +1843,7 @@ async function getUnreadByConversation(conversationId) { return map(rows, row => jsonToObject(row.json)); } -async function getMessagesByConversation( +async function getOlderMessagesByConversation( conversationId, { limit = 100, receivedAt = Number.MAX_VALUE } = {} ) { @@ -1857,8 +1860,118 @@ async function getMessagesByConversation( } ); + return map(rows.reverse(), row => jsonToObject(row.json)); +} + +async function getNewerMessagesByConversation( + conversationId, + { limit = 100, receivedAt = 0 } = {} +) { + const rows = await db.all( + `SELECT json FROM messages WHERE + conversationId = $conversationId AND + received_at > $received_at + ORDER BY received_at ASC + LIMIT $limit;`, + { + $conversationId: conversationId, + $received_at: receivedAt, + $limit: limit, + } + ); + return map(rows, row => jsonToObject(row.json)); } +async function getOldestMessageForConversation(conversationId) { + const row = await db.get( + `SELECT * FROM messages WHERE + conversationId = $conversationId + ORDER BY received_at ASC + LIMIT 1;`, + { + $conversationId: conversationId, + } + ); + + if (!row) { + return null; + } + + return row; +} +async function getNewestMessageForConversation(conversationId) { + const row = await db.get( + `SELECT * FROM messages WHERE + conversationId = $conversationId + ORDER BY received_at DESC + LIMIT 1;`, + { + $conversationId: conversationId, + } + ); + + if (!row) { + return null; + } + + return row; +} +async function getOldestUnreadMessageForConversation(conversationId) { + const row = await db.get( + `SELECT * FROM messages WHERE + conversationId = $conversationId AND + unread = 1 + ORDER BY received_at ASC + LIMIT 1;`, + { + $conversationId: conversationId, + } + ); + + if (!row) { + return null; + } + + return row; +} + +async function getTotalUnreadForConversation(conversationId) { + const row = await db.get( + `SELECT count(id) from messages WHERE + conversationId = $conversationId AND + unread = 1; + `, + { + $conversationId: conversationId, + } + ); + + if (!row) { + throw new Error('getTotalUnreadForConversation: Unable to get count'); + } + + return row['count(id)']; +} + +async function getMessageMetricsForConversation(conversationId) { + const results = await Promise.all([ + getOldestMessageForConversation(conversationId), + getNewestMessageForConversation(conversationId), + getOldestUnreadMessageForConversation(conversationId), + getTotalUnreadForConversation(conversationId), + ]); + + const [oldest, newest, oldestUnread, totalUnread] = results; + + return { + oldest: oldest ? pick(oldest, ['received_at', 'id']) : null, + newest: newest ? pick(newest, ['received_at', 'id']) : null, + oldestUnread: oldestUnread + ? pick(oldestUnread, ['received_at', 'id']) + : null, + totalUnread, + }; +} async function getMessagesBySentAt(sentAt) { const rows = await db.all( diff --git a/background.html b/background.html index 787c00fbcd..be98209693 100644 --- a/background.html +++ b/background.html @@ -71,19 +71,6 @@
- - - - - - - - diff --git a/js/background.js b/js/background.js index e2fe47861b..821c0e74ce 100644 --- a/js/background.js +++ b/js/background.js @@ -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(), diff --git a/js/conversation_controller.js b/js/conversation_controller.js index a13eae1242..127e2946cf 100644 --- a/js/conversation_controller.js +++ b/js/conversation_controller.js @@ -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; diff --git a/js/models/conversations.js b/js/models/conversations.js index 5c6783b17f..d3b3c697d5 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -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(' '); diff --git a/js/models/messages.js b/js/models/messages.js index 404fd2e4ab..1e428add29 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -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); - }, }); })(); diff --git a/js/modules/backup.js b/js/modules/backup.js index c9b659e091..46dde74f74 100644 --- a/js/modules/backup.js +++ b/js/modules/backup.js @@ -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, diff --git a/js/modules/data.js b/js/modules/data.js index 2d3dd93dc6..6b5ffa11f1 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -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, }); diff --git a/js/modules/signal.js b/js/modules/signal.js index fd0a618815..5d610075bb 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -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, }; diff --git a/js/notifications.js b/js/notifications.js index 2abb9d6c21..b6e5d419cb 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -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 diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 43477c74c5..667d387604 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -1,7 +1,8 @@ /* global $, _, - ConversationController + ConversationController, + MessageController, extension, i18n, Signal, @@ -23,6 +24,13 @@ getAbsoluteTempPath, deleteTempFile, } = window.Signal.Migrations; + const { + getOlderMessagesByConversation, + getMessageMetricsForConversation, + getMessageById, + getMessagesBySentAt, + getNewerMessagesByConversation, + } = window.Signal.Data; Whisper.ExpiredToast = Whisper.ToastView.extend({ render_attributes() { @@ -91,14 +99,19 @@ }; }, initialize(options) { + // Events on Conversation model this.listenTo(this.model, 'destroy', this.stopListening); 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); + this.listenTo(this.model, 'scroll-to-message', this.scrollToMessage); + this.listenTo(this.model, 'unload', reason => + this.unload(`model trigger - ${reason}`) + ); + + // Events on Message models - we still listen to these here because they + // can be emitted by the non-reduxified MessageDetail pane this.listenTo( this.model.messageCollection, 'show-identity', @@ -106,35 +119,11 @@ ); this.listenTo(this.model.messageCollection, 'force-send', this.forceSend); this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage); - this.listenTo(this.model.messageCollection, 'height-changed', () => - this.view.scrollToBottomIfNeeded() - ); this.listenTo( this.model.messageCollection, - 'scroll-to-message', - this.scrollToMessage - ); - this.listenTo( - this.model.messageCollection, - 'reply', - this.setQuoteMessage - ); - this.listenTo(this.model.messageCollection, 'retry', this.retrySend); - this.listenTo( - this.model.messageCollection, - 'show-contact-detail', - this.showContactDetail - ); - this.listenTo( - this.model.messageCollection, - 'show-lightbox', + 'show-visual-attachment', this.showLightbox ); - this.listenTo( - this.model.messageCollection, - 'download', - this.downloadAttachment - ); this.listenTo( this.model.messageCollection, 'display-tap-to-view-message', @@ -142,23 +131,13 @@ ); this.listenTo( this.model.messageCollection, - 'open-conversation', - this.openConversation + 'navigate-to', + this.navigateTo ); - this.listenTo( - this.model.messageCollection, - 'show-message-detail', - this.showMessageDetail - ); - this.listenTo(this.model.messageCollection, 'navigate-to', url => { - window.location = url; - }); this.listenTo( this.model.messageCollection, 'download-new-version', - () => { - window.location = 'https://signal.org/download'; - } + this.downloadNewVersion ); this.lazyUpdateVerified = _.debounce( @@ -193,37 +172,21 @@ this.onChooseAttachment ); this.listenTo(this.fileInput, 'staged-attachments-changed', () => { - this.view.restoreBottomOffset(); this.toggleMicrophone(); if (this.fileInput.hasFiles()) { this.removeLinkPreview(); } }); - this.view = new Whisper.MessageListView({ - collection: this.model.messageCollection, - window: this.window, - }); - this.$('.discussion-container').append(this.view.el); - this.view.render(); - - this.onFocus = () => { - if (this.$el.css('display') !== 'none') { - this.markRead(); - } - }; - this.window.addEventListener('focus', this.onFocus); - extension.windows.onClosed(() => { this.unload('windows closed'); }); - this.fetchMessages(); - this.$('.send-message').focus(this.focusBottomBar.bind(this)); this.$('.send-message').blur(this.unfocusBottomBar.bind(this)); this.setupHeader(); + this.setupTimeline(); this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] }); }, @@ -232,15 +195,8 @@ 'click .composition-area-placeholder': 'onClickPlaceholder', 'click .bottom-bar': 'focusMessageField', 'click .capture-audio .microphone': 'captureAudio', - 'click .module-scroll-down': 'scrollToBottom', 'focus .send-message': 'focusBottomBar', 'blur .send-message': 'unfocusBottomBar', - 'loadMore .message-list': 'loadMoreMessages', - 'newOffscreenMessage .message-list': 'addScrollDownButtonWithCount', - 'atBottom .message-list': 'removeScrollDownButton', - 'farFromBottom .message-list': 'addScrollDownButton', - 'lazyScroll .message-list': 'onLazyScroll', - 'click button.paperclip': 'onChooseAttachment', 'change input.file-input': 'onChoseAttachment', @@ -342,7 +298,6 @@ onSubmit: message => this.sendMessage(message), onEditorStateChange: (msg, caretLocation) => this.onEditorStateChange(msg, caretLocation), - onEditorSizeChange: rect => this.onEditorSizeChange(rect), micCellEl, attCellEl, attachmentListEl, @@ -359,6 +314,387 @@ ); }, + setupTimeline() { + const { id } = this.model; + + const replyToMessage = messageId => { + this.setQuoteMessage(messageId); + }; + const retrySend = messageId => { + this.retrySend(messageId); + }; + const deleteMessage = messageId => { + this.deleteMessage(messageId); + }; + const showMessageDetail = messageId => { + this.showMessageDetail(messageId); + }; + const openConversation = (conversationId, messageId) => { + this.openConversation(conversationId, messageId); + }; + const showContactDetail = options => { + this.showContactDetail(options); + }; + const showVisualAttachment = options => { + this.showLightbox(options); + }; + const downloadAttachment = options => { + this.downloadAttachment(options); + }; + const displayTapToViewMessage = messageId => + this.displayTapToViewMessage(messageId); + const showIdentity = conversationId => { + this.showSafetyNumber(conversationId); + }; + const openLink = url => { + this.navigateTo(url); + }; + const downloadNewVersion = () => { + this.downloadNewVersion(); + }; + const scrollToQuotedMessage = async options => { + const { author, sentAt } = options; + + const conversationId = this.model.id; + const messages = await getMessagesBySentAt(sentAt, { + MessageCollection: Whisper.MessageCollection, + }); + const message = messages.find( + item => + item.get('conversationId') === conversationId && + item.getSource() === author + ); + + if (!message) { + const toast = new Whisper.OriginalNotFoundToast(); + toast.$el.appendTo(this.$el); + toast.render(); + return; + } + + this.scrollToMessage(message.id); + }; + + const loadOlderMessages = async oldestMessageId => { + const { + messagesAdded, + setMessagesLoading, + } = window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + const message = await getMessageById(oldestMessageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `loadOlderMessages: failed to load message ${oldestMessageId}` + ); + } + + const receivedAt = message.get('received_at'); + const models = await getOlderMessagesByConversation(conversationId, { + receivedAt, + limit: 500, + MessageCollection: Whisper.MessageCollection, + }); + + if (models.length < 1) { + window.log.warn( + 'loadOlderMessages: requested, but loaded no messages' + ); + return; + } + + const cleaned = await this.cleanModels(models); + this.model.messageCollection.add(cleaned); + + const isNewMessage = false; + messagesAdded( + id, + models.map(model => model.getReduxData()), + isNewMessage, + document.hasFocus() + ); + } catch (error) { + setMessagesLoading(conversationId, true); + throw error; + } finally { + finish(); + } + }; + const loadNewerMessages = async newestMessageId => { + const { + messagesAdded, + setMessagesLoading, + } = window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + const message = await getMessageById(newestMessageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `loadNewerMessages: failed to load message ${newestMessageId}` + ); + } + + const receivedAt = message.get('received_at'); + const models = await getNewerMessagesByConversation(this.model.id, { + receivedAt, + limit: 500, + MessageCollection: Whisper.MessageCollection, + }); + + if (models.length < 1) { + window.log.warn( + 'loadNewerMessages: requested, but loaded no messages' + ); + return; + } + + const cleaned = await this.cleanModels(models); + this.model.messageCollection.add(cleaned); + + const isNewMessage = false; + messagesAdded( + id, + models.map(model => model.getReduxData()), + isNewMessage, + document.hasFocus() + ); + } catch (error) { + setMessagesLoading(conversationId, false); + throw error; + } finally { + finish(); + } + }; + const markMessageRead = async messageId => { + if (!document.hasFocus()) { + return; + } + + const message = await getMessageById(messageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `markMessageRead: failed to load message ${messageId}` + ); + } + + await this.model.markRead(message.get('received_at')); + }; + + this.timelineView = new Whisper.ReactWrapperView({ + className: 'timeline-wrapper', + JSX: Signal.State.Roots.createTimeline(window.reduxStore, { + id, + + deleteMessage, + displayTapToViewMessage, + downloadAttachment, + downloadNewVersion, + loadNewerMessages, + loadNewestMessages: this.loadNewestMessages.bind(this), + loadAndScroll: this.loadAndScroll.bind(this), + loadOlderMessages, + markMessageRead, + openConversation, + openLink, + replyToMessage, + retrySend, + scrollToQuotedMessage, + showContactDetail, + showIdentity, + showMessageDetail, + showVisualAttachment, + }), + }); + + this.$('.timeline-placeholder').append(this.timelineView.el); + }, + + async cleanModels(collection) { + const result = collection + .filter(message => Boolean(message.id)) + .map(message => MessageController.register(message.id, message)); + + const eliminated = collection.length - result.length; + if (eliminated > 0) { + window.log.warn( + `cleanModels: Eliminated ${eliminated} messages without an id` + ); + } + + for (let max = result.length, i = 0; i < max; i += 1) { + const message = result[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, + }); + } + } + + return result; + }, + + async scrollToMessage(messageId) { + const message = await getMessageById(messageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error(`scrollToMessage: failed to load message ${messageId}`); + } + + if (this.model.messageCollection.get(messageId)) { + const { scrollToMessage } = window.reduxActions.conversations; + scrollToMessage(this.model.id, messageId); + return; + } + + this.loadAndScroll(messageId); + }, + + setInProgressFetch() { + let resolvePromise; + this.model.inProgressFetch = new Promise(resolve => { + resolvePromise = resolve; + }); + + const finish = () => { + resolvePromise(); + this.model.inProgressFinish = null; + }; + + return finish; + }, + + async loadAndScroll(messageId, options) { + const { disableScroll } = options || {}; + const { + messagesReset, + setMessagesLoading, + } = window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + const message = await getMessageById(messageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `loadMoreAndScroll: failed to load message ${messageId}` + ); + } + + const receivedAt = message.get('received_at'); + const older = await getOlderMessagesByConversation(conversationId, { + limit: 250, + receivedAt, + MessageCollection: Whisper.MessageCollection, + }); + const newer = await getNewerMessagesByConversation(conversationId, { + limit: 250, + receivedAt, + MessageCollection: Whisper.MessageCollection, + }); + const metrics = await getMessageMetricsForConversation(conversationId); + + const all = [...older.models, message, ...newer.models]; + + const cleaned = await this.cleanModels(all); + this.model.messageCollection.reset(cleaned); + + messagesReset( + conversationId, + cleaned.map(model => model.getReduxData()), + metrics, + disableScroll ? undefined : messageId + ); + } catch (error) { + setMessagesLoading(conversationId, false); + throw error; + } finally { + finish(); + } + }, + + async loadNewestMessages(newestMessageId) { + const { + messagesReset, + setMessagesLoading, + } = window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + let scrollToLatestUnread = true; + + if (newestMessageId) { + const message = await getMessageById(newestMessageId, { + Message: Whisper.Message, + }); + if (!message) { + window.log.warn( + `loadNewestMessages: did not find message ${newestMessageId}` + ); + } + + // If newest in-memory message is unread, scrolling down would mean going to + // the very bottom, not the oldest unread. + scrollToLatestUnread = !message.isUnread(); + } + + const metrics = await getMessageMetricsForConversation(conversationId); + + if (scrollToLatestUnread && metrics.oldestUnread) { + this.loadAndScroll(metrics.oldestUnread.id, { disableScroll: true }); + return; + } + + const messages = await getOlderMessagesByConversation(conversationId, { + limit: 500, + MessageCollection: Whisper.MessageCollection, + }); + + const cleaned = await this.cleanModels(messages); + this.model.messageCollection.reset(cleaned); + + messagesReset( + conversationId, + cleaned.map(model => model.getReduxData()), + metrics + ); + } catch (error) { + setMessagesLoading(conversationId, false); + throw error; + } finally { + finish(); + } + }, + // We need this, or clicking the reactified buttons will submit the form and send any // mid-composition message content. onClickPlaceholder(e) { @@ -400,19 +736,6 @@ this.fileInput.onPaste(e); }, - onPrune() { - if (!this.model.messageCollection.length || !this.lastActivity) { - return; - } - - const oneHourAgo = Date.now() - 60 * 60 * 1000; - if (this.isHidden() && this.lastActivity < oneHourAgo) { - this.unload('inactivity'); - } else if (this.view.atBottom()) { - this.trim(); - } - }, - unload(reason) { window.log.info( 'unloading conversation', @@ -421,16 +744,21 @@ reason ); + const { conversationUnloaded } = window.reduxActions.conversations; + if (conversationUnloaded) { + conversationUnloaded(this.model.id); + } + this.fileInput.remove(); this.titleView.remove(); + this.timelineView.remove(); + if (this.stickerButtonView) { this.stickerButtonView.remove(); } - if (this.stickerPreviewModalView) { this.stickerPreviewModalView.remove(); } - if (this.captureAudioView) { this.captureAudioView.remove(); } @@ -461,45 +789,17 @@ this.window.removeEventListener('focus', this.onFocus); - this.view.remove(); - this.remove(); - this.model.messageCollection.forEach(model => { - model.trigger('unload'); - }); this.model.messageCollection.reset([]); }, - trim() { - const MAX = 100; - const toRemove = this.model.messageCollection.length - MAX; - if (toRemove <= 0) { - return; - } + navigateTo(url) { + window.location = url; + }, - const models = []; - for (let i = 0; i < toRemove; i += 1) { - const model = this.model.messageCollection.at(i); - models.push(model); - } - - if (!models.length) { - return; - } - - window.log.info( - 'trimming conversation', - this.model.idForLogging(), - 'of', - models.length, - 'old messages' - ); - - this.model.messageCollection.remove(models); - _.forEach(models, model => { - model.trigger('unload'); - }); + downloadNewVersion() { + window.location = 'https://signal.org/download'; }, markAllAsVerifiedDefault(unverified) { @@ -520,7 +820,7 @@ openSafetyNumberScreens(unverified) { if (unverified.length === 1) { - this.showSafetyNumber(unverified.at(0)); + this.showSafetyNumber(unverified.at(0).id); return; } @@ -564,43 +864,6 @@ } }, - 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() { this.compositionApi.current.setShowMic(!this.fileInput.hasFiles()); }, @@ -657,41 +920,12 @@ this.$('.bottom-bar form').addClass('active'); }, - onLazyScroll() { - // The in-progress fetch check is important, because while that happens, lots - // of messages are added to the DOM, one by one, changing window size and - // generating scroll events. - if (!this.isHidden() && window.isFocused() && !this.inProgressFetch) { - this.lastActivity = Date.now(); - this.markRead(); - } - }, - updateUnread() { - this.resetLastSeenIndicator(); - // Waiting for scrolling caused by resetLastSeenIndicator to settle down - setTimeout(this.markRead.bind(this), 1); - }, - - onLoaded() { - const view = this.loadingScreen; - if (view) { - const openDelta = Date.now() - this.openStart; - window.log.info( - 'Conversation', - this.model.idForLogging(), - 'took', - openDelta, - 'milliseconds to load' - ); - this.loadingScreen = null; - view.remove(); - } - }, - - onOpened() { + async onOpened(messageId) { this.openStart = Date.now(); this.lastActivity = Date.now(); + this.focusMessageField(); + this.model.updateLastMessage(); const statusPromise = this.throttledGetProfiles(); @@ -705,61 +939,20 @@ }) ); - // We schedule our catch-up decrypt right after any in-progress fetch of - // messages from the database, then ensure that the loading screen is only - // dismissed when that is complete. - const messagesLoaded = this.inProgressFetch || Promise.resolve(); + if (messageId) { + const message = await getMessageById(messageId, { + Message: Whisper.Message, + }); - // eslint-disable-next-line more/no-then - messagesLoaded.then(this.onLoaded.bind(this), this.onLoaded.bind(this)); + if (message) { + this.loadAndScroll(messageId); + return; + } - this.view.resetScrollPosition(); - this.focusMessageField(); - this.renderTypingBubble(); - - if (this.inProgressFetch) { - // eslint-disable-next-line more/no-then - this.inProgressFetch.then(this.updateUnread.bind(this)); - } else { - this.updateUnread(); + window.log.warn(`onOpened: Did not find message ${messageId}`); } - }, - addScrollDownButtonWithCount() { - this.updateScrollDownButton(1); - }, - - addScrollDownButton() { - if (!this.scrollDownButton) { - this.updateScrollDownButton(); - } - }, - - updateScrollDownButton(count) { - if (this.scrollDownButton) { - this.scrollDownButton.increment(count); - } else { - this.scrollDownButton = new Whisper.ScrollDownButtonView({ count }); - this.scrollDownButton.render(); - const container = this.$('.discussion-container'); - container.append(this.scrollDownButton.el); - } - }, - - removeScrollDownButton() { - if (this.scrollDownButton) { - const button = this.scrollDownButton; - this.scrollDownButton = null; - button.remove(); - } - }, - - removeLastSeenIndicator() { - if (this.lastSeenIndicator) { - const indicator = this.lastSeenIndicator; - this.lastSeenIndicator = null; - indicator.remove(); - } + this.loadNewestMessages(); }, async retrySend(messageId) { @@ -770,77 +963,6 @@ await message.retrySend(); }, - async scrollToMessage(options = {}) { - const { author, sentAt, referencedMessageNotFound } = options; - - // For simplicity's sake, we show the 'not found' toast no matter what if we were - // not able to find the referenced message when the quote was received. - if (referencedMessageNotFound) { - const toast = new Whisper.OriginalNotFoundToast(); - toast.$el.appendTo(this.$el); - toast.render(); - return; - } - - // Look for message in memory first, which would tell us if we could scroll to it - const targetMessage = this.model.messageCollection.find(item => { - const messageAuthor = item.getContact(); - - if (!messageAuthor || author !== messageAuthor.id) { - return false; - } - if (sentAt !== item.get('sent_at')) { - return false; - } - - return true; - }); - - // If there's no message already in memory, we won't be scrolling. So we'll gather - // some more information then show an informative toast to the user. - if (!targetMessage) { - const collection = await window.Signal.Data.getMessagesBySentAt( - sentAt, - { - MessageCollection: Whisper.MessageCollection, - } - ); - - const found = Boolean( - collection.find(item => { - const messageAuthor = item.getContact(); - return messageAuthor && author === messageAuthor.id; - }) - ); - - if (found) { - const toast = new Whisper.FoundButNotLoadedToast(); - toast.$el.appendTo(this.$el); - toast.render(); - } else { - const toast = new Whisper.OriginalNoLongerAvailableToast(); - toast.$el.appendTo(this.$el); - toast.render(); - } - return; - } - - const databaseId = targetMessage.id; - const el = this.$(`#${databaseId}`); - if (!el || el.length === 0) { - const toast = new Whisper.OriginalNoLongerAvailableToast(); - toast.$el.appendTo(this.$el); - toast.render(); - - window.log.info( - `Error: had target message ${targetMessage.idForLogging()} in messageCollection, but it was not in DOM` - ); - return; - } - - el[0].scrollIntoView(); - }, - async showAllMedia() { // We fetch more documents than media as they don’t require to be loaded // into memory right away. Revisit this once we have infinite scrolling: @@ -990,67 +1112,6 @@ this.listenBack(view); }, - scrollToBottom() { - // If we're above the last seen indicator, we should scroll there instead - // Note: if we don't end up at the bottom of the conversation, button won't go away! - if (this.lastSeenIndicator) { - const location = this.lastSeenIndicator.$el.position().top; - if (location > 0) { - this.lastSeenIndicator.el.scrollIntoView(); - return; - } - this.removeLastSeenIndicator(); - } - this.view.scrollToBottom(); - }, - - resetLastSeenIndicator(options = {}) { - _.defaults(options, { scroll: true }); - - let unreadCount = 0; - let oldestUnread = null; - - // We need to iterate here because unseen non-messages do not contribute to - // the badge number, but should be reflected in the indicator's count. - this.model.messageCollection.forEach(model => { - if (!model.get('unread')) { - return; - } - - unreadCount += 1; - if (!oldestUnread) { - oldestUnread = model; - } - }); - - this.removeLastSeenIndicator(); - - if (oldestUnread) { - this.lastSeenIndicator = new Whisper.LastSeenIndicatorView({ - count: unreadCount, - }); - const lastSeenEl = this.lastSeenIndicator.render().$el; - - lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`)); - - if (this.view.atBottom() || options.scroll) { - lastSeenEl[0].scrollIntoView(); - } - - // scrollIntoView is an async operation, but we have no way to listen for - // completion of the resultant scroll. - setTimeout(() => { - if (!this.view.atBottom()) { - this.addScrollDownButtonWithCount(unreadCount); - } - }, 1); - } else if (this.view.atBottom()) { - // If we already thought we were at the bottom, then ensure that's the case. - // Attempting to account for unpredictable completion of message rendering. - setTimeout(() => this.view.scrollToBottom(), 1); - } - }, - focusMessageField() { if (this.panels && this.panels.length) { return; @@ -1080,63 +1141,7 @@ this.compositionApi.current.resetEmojiResults(false); }, - async loadMoreMessages() { - if (this.inProgressFetch) { - return; - } - - this.view.measureScrollPosition(); - const startingHeight = this.view.scrollHeight; - - await this.fetchMessages(); - // We delay this work to let scrolling/layout settle down first - setTimeout(() => { - this.view.measureScrollPosition(); - const endingHeight = this.view.scrollHeight; - const delta = endingHeight - startingHeight; - const height = this.view.outerHeight; - - const newScrollPosition = this.view.scrollPosition + delta - height; - this.view.$el.scrollTop(newScrollPosition); - }, 1); - }, - fetchMessages() { - window.log.info('fetchMessages'); - this.$('.bar-container').show(); - if (this.inProgressFetch) { - window.log.warn('Multiple fetchMessage calls!'); - } - - // Avoiding await, since we want to capture the promise and make it available via - // this.inProgressFetch - // eslint-disable-next-line more/no-then - this.inProgressFetch = this.model - .fetchContacts() - .then(() => this.model.fetchMessages()) - .then(async () => { - this.$('.bar-container').hide(); - await Promise.all( - this.model.messageCollection.where({ unread: 1 }).map(async m => { - const latest = await window.Signal.Data.getMessageById(m.id, { - Message: Whisper.Message, - }); - m.merge(latest); - }) - ); - this.inProgressFetch = null; - }) - .catch(error => { - window.log.error( - 'fetchMessages error:', - error && error.stack ? error.stack : error - ); - this.inProgressFetch = null; - }); - - return this.inProgressFetch; - }, - - addMessage(message) { + async addMessage(message) { // This is debounced, so it won't hit the database too often. this.lazyUpdateVerified(); @@ -1144,109 +1149,6 @@ // anything in it unless it has an associated view. This is so, when we // fetch on open, it's clean. this.model.addSingleMessage(message); - - if (message.isOutgoing()) { - this.removeLastSeenIndicator(); - } - if (this.lastSeenIndicator) { - this.lastSeenIndicator.increment(1); - } - - if (!this.isHidden() && !window.isFocused()) { - // The conversation is visible, but window is not focused - if (!this.lastSeenIndicator) { - this.resetLastSeenIndicator({ scroll: false }); - } else if ( - this.view.atBottom() && - this.model.get('unreadCount') === this.lastSeenIndicator.getCount() - ) { - // The count check ensures that the last seen indicator is still in - // sync with the real number of unread, so we can scroll to it. - // We only do this if we're at the bottom, because that signals that - // the user is okay with us changing scroll around so they see the - // right unseen message first. - this.resetLastSeenIndicator({ scroll: true }); - } - } else if (!this.isHidden() && window.isFocused()) { - // The conversation is visible and in focus - this.markRead(); - - // When we're scrolled up and we don't already have a last seen indicator - // we add a new one. - if (!this.view.atBottom() && !this.lastSeenIndicator) { - this.resetLastSeenIndicator({ scroll: false }); - } - } - }, - - onClick() { - // If there are sub-panels open, we don't want to respond to clicks - if (!this.panels || !this.panels.length) { - this.markRead(); - } - }, - - findNewestVisibleUnread() { - const collection = this.model.messageCollection; - const { length } = collection; - const viewportBottom = this.view.outerHeight; - const unreadCount = this.model.get('unreadCount') || 0; - - // Start with the most recent message, search backwards in time - let foundUnread = 0; - for (let i = length - 1; i >= 0; i -= 1) { - // Search the latest 30, then stop if we believe we've covered all known - // unread messages. The unread should be relatively recent. - // Why? local notifications can be unread but won't be reflected the - // conversation's unread count. - if (i > 30 && foundUnread >= unreadCount) { - return null; - } - - const message = collection.at(i); - if (!message.get('unread')) { - // eslint-disable-next-line no-continue - continue; - } - - foundUnread += 1; - - const el = this.$(`#${message.id}`); - const position = el.position(); - const { top } = position; - - // We're fully below the viewport, continue searching up. - if (top > viewportBottom) { - // eslint-disable-next-line no-continue - continue; - } - - // If the bottom fits on screen, we'll call it visible. Even if the - // message is really tall. - const height = el.height(); - const bottom = top + height; - if (bottom <= viewportBottom) { - return message; - } - - // Continue searching up. - } - - return null; - }, - - markRead() { - let unread; - - if (this.view.atBottom()) { - unread = this.model.messageCollection.last(); - } else { - unread = this.findNewestVisibleUnread(); - } - - if (unread) { - this.model.markRead(unread.get('received_at')); - } }, async showMembers(e, providedMembers, options = {}) { @@ -1671,8 +1573,8 @@ try { await this.confirm(i18n('deleteConversationConfirmation')); try { - await this.model.destroyMessages(); this.unload('delete messages'); + await this.model.destroyMessages(); this.model.updateLastMessage(); } catch (error) { window.log.error( @@ -1800,7 +1702,6 @@ this.quoteView = null; } if (!this.quotedMessage) { - this.view.restoreBottomOffset(); return; } @@ -1813,7 +1714,9 @@ const props = message.getPropsForQuote(); - this.listenTo(message, 'scroll-to-message', this.scrollToMessage); + this.listenTo(message, 'scroll-to-message', () => { + this.scrollToMessage(message.quotedMessage.id); + }); const contact = this.quotedMessage.getContact(); if (contact) { @@ -1831,9 +1734,6 @@ this.setQuoteMessage(null); }, }), - onInitialRender: () => { - this.view.restoreBottomOffset(); - }, }); }, @@ -1863,7 +1763,6 @@ return; } - this.removeLastSeenIndicator(); this.model.clearTypingTimers(); let toast; @@ -1925,10 +1824,6 @@ this.debouncedMaybeGrabLinkPreview(messageText, caretLocation); }, - onEditorSizeChange() { - this.view.scrollToBottomIfNeeded(); - }, - maybeGrabLinkPreview(message, caretLocation) { // Don't generate link previews if user has turned them off if (!storage.get('linkPreviews', false)) { @@ -2260,7 +2155,6 @@ this.previewView = null; } if (!this.currentlyMatchedLink) { - this.view.restoreBottomOffset(); return; } @@ -2281,9 +2175,6 @@ elCallback: el => this.$(this.compositionApi.current.attSlotRef.current).prepend(el), props, - onInitialRender: () => { - this.view.restoreBottomOffset(); - }, }); }, @@ -2317,12 +2208,5 @@ this.model.throttledBumpTyping(); } }, - - isHidden() { - return ( - this.$el.css('display') === 'none' || - this.$('.panel').css('display') === 'none' - ); - }, }); })(); diff --git a/js/views/group_update_view.js b/js/views/group_update_view.js deleted file mode 100644 index f3515647c9..0000000000 --- a/js/views/group_update_view.js +++ /dev/null @@ -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; - }, - }); -})(); diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 12ebb48575..3a27fe8805 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -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) { diff --git a/js/views/last_seen_indicator_view.js b/js/views/last_seen_indicator_view.js deleted file mode 100644 index 12049d59e3..0000000000 --- a/js/views/last_seen_indicator_view.js +++ /dev/null @@ -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, - }; - }, - }); -})(); diff --git a/js/views/message_list_view.js b/js/views/message_list_view.js deleted file mode 100644 index 84a8d58bc7..0000000000 --- a/js/views/message_list_view.js +++ /dev/null @@ -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(); - }, - }); -})(); diff --git a/js/views/message_view.js b/js/views/message_view.js deleted file mode 100644 index 8b1301ce49..0000000000 --- a/js/views/message_view.js +++ /dev/null @@ -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; - }, - }); -})(); diff --git a/js/views/scroll_down_button_view.js b/js/views/scroll_down_button_view.js deleted file mode 100644 index 3f98be35d4..0000000000 --- a/js/views/scroll_down_button_view.js +++ /dev/null @@ -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, - }; - }, - }); -})(); diff --git a/package.json b/package.json index 0c71e07b81..943f4f95c2 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "main": "main.js", "scripts": { - "postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider", + "postinstall": "patch-package && electron-builder install-app-deps && rimraf node_modules/dtrace-provider", "start": "electron .", "grunt": "grunt", "icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build", @@ -169,6 +169,7 @@ "mocha-testcheck": "1.0.0-rc.0", "node-sass-import-once": "1.2.0", "nyc": "11.4.1", + "patch-package": "6.1.2", "prettier": "1.12.0", "react-docgen-typescript": "1.2.6", "react-styleguidist": "7.0.1", diff --git a/patches/react-virtualized+9.21.0.patch b/patches/react-virtualized+9.21.0.patch new file mode 100644 index 0000000000..9748972144 --- /dev/null +++ b/patches/react-virtualized+9.21.0.patch @@ -0,0 +1,357 @@ +diff --git a/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js b/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js +index d9716a0..e7a9f9f 100644 +--- a/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js ++++ b/node_modules/react-virtualized/dist/commonjs/CellMeasurer/CellMeasurer.js +@@ -166,13 +166,19 @@ var CellMeasurer = function (_React$PureComponent) { + height = _getCellMeasurements2.height, + width = _getCellMeasurements2.width; + ++ + cache.set(rowIndex, columnIndex, width, height); + + // If size has changed, let Grid know to re-render. + if (parent && typeof parent.invalidateCellSizeAfterRender === 'function') { ++ const heightChange = height - cache.defaultHeight; ++ const widthChange = width - cache.defaultWidth; ++ + parent.invalidateCellSizeAfterRender({ + columnIndex: columnIndex, +- rowIndex: rowIndex ++ rowIndex: rowIndex, ++ heightChange: heightChange, ++ widthChange: widthChange, + }); + } + } +diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js b/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js +index e1b959a..1e3a269 100644 +--- a/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js ++++ b/node_modules/react-virtualized/dist/commonjs/Grid/Grid.js +@@ -132,6 +132,9 @@ var Grid = function (_React$PureComponent) { + _this._renderedRowStopIndex = 0; + _this._styleCache = {}; + _this._cellCache = {}; ++ _this._cellUpdates = []; ++ this._hasScrolledToColumnTarget = false; ++ this._hasScrolledToRowTarget = false; + + _this._debounceScrollEndedCallback = function () { + _this._disablePointerEventsTimeoutId = null; +@@ -345,7 +348,11 @@ var Grid = function (_React$PureComponent) { + scrollLeft: scrollLeft, + scrollTop: scrollTop, + totalColumnsWidth: totalColumnsWidth, +- totalRowsHeight: totalRowsHeight ++ totalRowsHeight: totalRowsHeight, ++ scrollToColumn: this.props.scrollToColumn, ++ _hasScrolledToColumnTarget: this._hasScrolledToColumnTarget, ++ scrollToRow: this.props.scrollToRow, ++ _hasScrolledToRowTarget: this._hasScrolledToRowTarget, + }); + } + +@@ -362,6 +369,9 @@ var Grid = function (_React$PureComponent) { + value: function invalidateCellSizeAfterRender(_ref3) { + var columnIndex = _ref3.columnIndex, + rowIndex = _ref3.rowIndex; ++ if (!this._disableCellUpdates) { ++ this._cellUpdates.push(_ref3); ++ } + + this._deferredInvalidateColumnIndex = typeof this._deferredInvalidateColumnIndex === 'number' ? Math.min(this._deferredInvalidateColumnIndex, columnIndex) : columnIndex; + this._deferredInvalidateRowIndex = typeof this._deferredInvalidateRowIndex === 'number' ? Math.min(this._deferredInvalidateRowIndex, rowIndex) : rowIndex; +@@ -381,8 +391,12 @@ var Grid = function (_React$PureComponent) { + rowCount = _props2.rowCount; + var instanceProps = this.state.instanceProps; + +- instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(columnCount - 1); +- instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(rowCount - 1); ++ if (columnCount > 0) { ++ instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(columnCount - 1); ++ } ++ if (rowCount > 0) { ++ instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(rowCount - 1); ++ } + } + + /** +@@ -415,6 +429,15 @@ var Grid = function (_React$PureComponent) { + this._recomputeScrollLeftFlag = scrollToColumn >= 0 && (this.state.scrollDirectionHorizontal === _defaultOverscanIndicesGetter.SCROLL_DIRECTION_FORWARD ? columnIndex <= scrollToColumn : columnIndex >= scrollToColumn); + this._recomputeScrollTopFlag = scrollToRow >= 0 && (this.state.scrollDirectionVertical === _defaultOverscanIndicesGetter.SCROLL_DIRECTION_FORWARD ? rowIndex <= scrollToRow : rowIndex >= scrollToRow); + ++ // Global notification that we should retry our scroll to props-requested indices ++ this._hasScrolledToColumnTarget = false; ++ this._hasScrolledToRowTarget = false; ++ ++ // Disable cell updates for global reset ++ if (rowIndex >= this.props.rowCount - 1 || columnIndex >= this.props.columnCount - 1) { ++ this._disableCellUpdates = true; ++ } ++ + // Clear cell cache in case we are scrolling; + // Invalid row heights likely mean invalid cached content as well. + this._styleCache = {}; +@@ -526,7 +549,11 @@ var Grid = function (_React$PureComponent) { + scrollLeft: scrollLeft || 0, + scrollTop: scrollTop || 0, + totalColumnsWidth: instanceProps.columnSizeAndPositionManager.getTotalSize(), +- totalRowsHeight: instanceProps.rowSizeAndPositionManager.getTotalSize() ++ totalRowsHeight: instanceProps.rowSizeAndPositionManager.getTotalSize(), ++ scrollToColumn: scrollToColumn, ++ _hasScrolledToColumnTarget: this._hasScrolledToColumnTarget, ++ scrollToRow: scrollToRow, ++ _hasScrolledToRowTarget: this._hasScrolledToRowTarget, + }); + + this._maybeCallOnScrollbarPresenceChange(); +@@ -584,6 +611,51 @@ var Grid = function (_React$PureComponent) { + } + } + ++ if (scrollToColumn >= 0) { ++ const scrollRight = scrollLeft + width; ++ const targetColumn = instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(scrollToColumn); ++ ++ let isVisible = false; ++ if (targetColumn.size <= width) { ++ const targetColumnRight = targetColumn.offset + targetColumn.size; ++ isVisible = (targetColumn.offset >= scrollLeft && targetColumnRight <= scrollRight); ++ } else { ++ isVisible = (targetColumn.offset >= scrollLeft && targetColumn.offset <= scrollRight); ++ } ++ ++ if (isVisible) { ++ const totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); ++ const maxScroll = totalColumnsWidth - width; ++ this._hasScrolledToColumnTarget = (scrollLeft >= maxScroll || targetColumn.offset === scrollLeft); ++ } else if (scrollToColumn !== prevProps.scrollToColumn) { ++ this._hasScrolledToColumnTarget = false; ++ } ++ } else { ++ this._hasScrolledToColumnTarget = false; ++ } ++ if (scrollToRow >= 0) { ++ const scrollBottom = scrollTop + height; ++ const targetRow = instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(scrollToRow); ++ ++ let isVisible = false; ++ if (targetRow.size <= height) { ++ const targetRowBottom = targetRow.offset + targetRow.size; ++ isVisible = (targetRow.offset >= scrollTop && targetRowBottom <= scrollBottom); ++ } else { ++ isVisible = (targetRow.offset >= scrollTop && targetRow.offset <= scrollBottom); ++ } ++ ++ if (isVisible) { ++ const totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); ++ const maxScroll = totalRowsHeight - height; ++ this._hasScrolledToRowTarget = (scrollTop >= maxScroll || targetRow.offset === scrollTop); ++ } else if (scrollToRow !== prevProps.scrollToRow) { ++ this._hasScrolledToRowTarget = false; ++ } ++ } else { ++ this._hasScrolledToRowTarget = false; ++ } ++ + // Special case where the previous size was 0: + // In this case we don't show any windowed cells at all. + // So we should always recalculate offset afterwards. +@@ -594,6 +666,8 @@ var Grid = function (_React$PureComponent) { + if (this._recomputeScrollLeftFlag) { + this._recomputeScrollLeftFlag = false; + this._updateScrollLeftForScrollToColumn(this.props); ++ } else if (this.props.scrollToColumn >= 0 && !this._hasScrolledToColumnTarget) { ++ this._updateScrollLeftForScrollToColumn(this.props); + } else { + (0, _updateScrollIndexHelper2.default)({ + cellSizeAndPositionManager: instanceProps.columnSizeAndPositionManager, +@@ -616,6 +690,8 @@ var Grid = function (_React$PureComponent) { + if (this._recomputeScrollTopFlag) { + this._recomputeScrollTopFlag = false; + this._updateScrollTopForScrollToRow(this.props); ++ } else if (this.props.scrollToRow >= 0 && !this._hasScrolledToRowTarget) { ++ this._updateScrollTopForScrollToRow(this.props); + } else { + (0, _updateScrollIndexHelper2.default)({ + cellSizeAndPositionManager: instanceProps.rowSizeAndPositionManager, +@@ -630,11 +706,56 @@ var Grid = function (_React$PureComponent) { + size: height, + sizeJustIncreasedFromZero: sizeJustIncreasedFromZero, + updateScrollIndexCallback: function updateScrollIndexCallback() { +- return _this2._updateScrollTopForScrollToRow(_this2.props); ++ _this2._updateScrollLeftForScrollToColumn(_this2.props); + } + }); + } + ++ this._disableCellUpdates = false; ++ if (scrollPositionChangeReason !== SCROLL_POSITION_CHANGE_REASONS.OBSERVED) { ++ this._cellUpdates = []; ++ } ++ if (this._cellUpdates.length) { ++ const currentScrollTop = this.state.scrollTop; ++ const currentScrollBottom = currentScrollTop + height; ++ const currentScrollLeft = this.state.scrollLeft; ++ const currentScrollRight = currentScrollLeft + width; ++ ++ let item; ++ let verticalDelta = 0; ++ let horizontalDelta = 0; ++ ++ while (item = this._cellUpdates.shift()) { ++ const rowData = instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell(item.rowIndex); ++ const columnData = instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell(item.columnIndex); ++ ++ const bottomOfItem = rowData.offset + rowData.size; ++ const rightSideOfItem = columnData.offset + columnData.size; ++ ++ if (bottomOfItem < currentScrollBottom) { ++ verticalDelta += item.heightChange; ++ } ++ if (rightSideOfItem < currentScrollRight) { ++ horizontalDelta += item.widthChange; ++ } ++ } ++ ++ if (this.props.scrollToRow >= 0 && !this._hasScrolledToRowTarget) { ++ verticalDelta = 0; ++ } ++ if (this.props.scrollToColumn >= 0 && !this._hasScrolledToColumnTarget) { ++ horizontalDelta = 0; ++ } ++ ++ if (verticalDelta !== 0 || horizontalDelta !== 0) { ++ this.setState(Grid._getScrollToPositionStateUpdate({ ++ prevState: this.state, ++ scrollTop: scrollTop + verticalDelta, ++ scrollLeft: scrollLeft + horizontalDelta, ++ })); ++ } ++ } ++ + // Update onRowsRendered callback if start/stop indices have changed + this._invokeOnGridRenderedHelper(); + +@@ -647,7 +768,11 @@ var Grid = function (_React$PureComponent) { + scrollLeft: scrollLeft, + scrollTop: scrollTop, + totalColumnsWidth: totalColumnsWidth, +- totalRowsHeight: totalRowsHeight ++ totalRowsHeight: totalRowsHeight, ++ scrollToColumn: scrollToColumn, ++ _hasScrolledToColumnTarget: this._hasScrolledToColumnTarget, ++ scrollToRow: scrollToRow, ++ _hasScrolledToRowTarget: this._hasScrolledToRowTarget, + }); + } + +@@ -962,7 +1087,11 @@ var Grid = function (_React$PureComponent) { + var scrollLeft = _ref6.scrollLeft, + scrollTop = _ref6.scrollTop, + totalColumnsWidth = _ref6.totalColumnsWidth, +- totalRowsHeight = _ref6.totalRowsHeight; ++ totalRowsHeight = _ref6.totalRowsHeight, ++ scrollToColumn = _ref6.scrollToColumn, ++ _hasScrolledToColumnTarget = _ref6._hasScrolledToColumnTarget, ++ scrollToRow = _ref6.scrollToRow, ++ _hasScrolledToRowTarget = _ref6._hasScrolledToRowTarget; + + this._onScrollMemoizer({ + callback: function callback(_ref7) { +@@ -973,19 +1102,26 @@ var Grid = function (_React$PureComponent) { + onScroll = _props7.onScroll, + width = _props7.width; + +- + onScroll({ + clientHeight: height, + clientWidth: width, + scrollHeight: totalRowsHeight, + scrollLeft: scrollLeft, + scrollTop: scrollTop, +- scrollWidth: totalColumnsWidth ++ scrollWidth: totalColumnsWidth, ++ scrollToColumn: scrollToColumn, ++ _hasScrolledToColumnTarget: _hasScrolledToColumnTarget, ++ scrollToRow: scrollToRow, ++ _hasScrolledToRowTarget: _hasScrolledToRowTarget, + }); + }, + indices: { + scrollLeft: scrollLeft, +- scrollTop: scrollTop ++ scrollTop: scrollTop, ++ scrollToColumn: scrollToColumn, ++ _hasScrolledToColumnTarget: _hasScrolledToColumnTarget, ++ scrollToRow: scrollToRow, ++ _hasScrolledToRowTarget: _hasScrolledToRowTarget, + } + }); + } +diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js b/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js +index 70b0abe..2f04448 100644 +--- a/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js ++++ b/node_modules/react-virtualized/dist/commonjs/Grid/accessibilityOverscanIndicesGetter.js +@@ -32,15 +32,8 @@ function defaultOverscanIndicesGetter(_ref) { + // For more info see issues #625 + overscanCellsCount = Math.max(1, overscanCellsCount); + +- if (scrollDirection === SCROLL_DIRECTION_FORWARD) { +- return { +- overscanStartIndex: Math.max(0, startIndex - 1), +- overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount) +- }; +- } else { +- return { +- overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), +- overscanStopIndex: Math.min(cellCount - 1, stopIndex + 1) +- }; +- } ++ return { ++ overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), ++ overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount), ++ }; + } +diff --git a/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js b/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js +index d5f6d04..e01e69a 100644 +--- a/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js ++++ b/node_modules/react-virtualized/dist/commonjs/Grid/defaultOverscanIndicesGetter.js +@@ -27,15 +27,8 @@ function defaultOverscanIndicesGetter(_ref) { + startIndex = _ref.startIndex, + stopIndex = _ref.stopIndex; + +- if (scrollDirection === SCROLL_DIRECTION_FORWARD) { +- return { +- overscanStartIndex: Math.max(0, startIndex), +- overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount) +- }; +- } else { +- return { +- overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), +- overscanStopIndex: Math.min(cellCount - 1, stopIndex) +- }; +- } ++ return { ++ overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), ++ overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount), ++ }; + } +diff --git a/node_modules/react-virtualized/dist/commonjs/List/List.js b/node_modules/react-virtualized/dist/commonjs/List/List.js +index b5ad0eb..efb2cd7 100644 +--- a/node_modules/react-virtualized/dist/commonjs/List/List.js ++++ b/node_modules/react-virtualized/dist/commonjs/List/List.js +@@ -112,13 +112,8 @@ var List = function (_React$PureComponent) { + }, _this._setRef = function (ref) { + _this.Grid = ref; + }, _this._onScroll = function (_ref3) { +- var clientHeight = _ref3.clientHeight, +- scrollHeight = _ref3.scrollHeight, +- scrollTop = _ref3.scrollTop; + var onScroll = _this.props.onScroll; +- +- +- onScroll({ clientHeight: clientHeight, scrollHeight: scrollHeight, scrollTop: scrollTop }); ++ onScroll(_ref3); + }, _this._onSectionRendered = function (_ref4) { + var rowOverscanStartIndex = _ref4.rowOverscanStartIndex, + rowOverscanStopIndex = _ref4.rowOverscanStopIndex, diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 96a04e279f..0b60d9c818 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -32,7 +32,7 @@ } @include dark-theme() { - background-color: $color-black; + background-color: $color-gray-95; } } @@ -66,24 +66,21 @@ } .main.panel { - .discussion-container { + .timeline-placeholder { flex-grow: 1; position: relative; max-width: 100%; margin: 0; + margin-bottom: 10px; - .bar-container { - height: 5px; - } - - .message-list { + .timeline-wrapper { -webkit-padding-start: 0px; position: absolute; top: 0; height: 100%; width: 100%; margin: 0; - padding: 10px 0 0 0; + padding: 0; overflow-y: auto; overflow-x: hidden; } @@ -177,38 +174,6 @@ } } -.message-container, -.message-list { - list-style: none; - - .message-wrapper { - margin-left: 16px; - margin-right: 16px; - } - - li { - margin-bottom: 10px; - - &::after { - visibility: hidden; - display: block; - font-size: 0; - content: ' '; - clear: both; - height: 0; - } - } -} - -.group { - .message-container, - .message-list { - .message-wrapper { - margin-left: 44px; - } - } -} - .typing-bubble-wrapper { margin-bottom: 20px; } @@ -282,31 +247,6 @@ } } - .attachment-previews { - padding: 0 36px; - margin-bottom: 3px; - - .attachment-preview { - padding: 13px 10px 0; - } - img { - border: 2px solid #ddd; - border-radius: $border-radius; - max-height: 100px; - } - - .close { - position: absolute; - top: 5px; - right: 2px; - background: #999; - - &:hover { - background: $grey; - } - } - } - .flex { display: flex; flex-direction: row; @@ -462,63 +402,3 @@ } } } - -.module-last-seen-indicator { - padding-top: 25px; - padding-bottom: 35px; - margin-left: 28px; - margin-right: 28px; -} - -.module-last-seen-indicator__bar { - background-color: $color-light-60; - width: 100%; - height: 4px; -} - -.module-last-seen-indicator__text { - margin-top: 3px; - font-size: 11px; - line-height: 16px; - letter-spacing: 0.3px; - text-transform: uppercase; - - text-align: center; - color: $color-light-90; -} - -.module-scroll-down { - z-index: 100; - position: absolute; - right: 20px; - bottom: 10px; -} - -.module-scroll-down__button { - height: 44px; - width: 44px; - border-radius: 22px; - text-align: center; - background-color: $color-light-35; - border: none; - box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2); - outline: none; - - &:hover { - background-color: $color-light-45; - } -} - -.module-scroll-down__button--new-messages { - background-color: $color-signal-blue; - - &:hover { - background-color: #1472bd; - } -} - -.module-scroll-down__icon { - @include color-svg('../images/down.svg', $color-white); - height: 100%; - width: 100%; -} diff --git a/stylesheets/_index.scss b/stylesheets/_index.scss index 6c11abee15..8fe0e13eb5 100644 --- a/stylesheets/_index.scss +++ b/stylesheets/_index.scss @@ -81,15 +81,6 @@ height: 100%; } -.conversation-stack { - .conversation { - display: none; - } - .conversation:first-child { - display: block; - } -} - .tool-bar { color: $color-light-90; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index b7da648888..03a0611f56 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -8,6 +8,22 @@ // Module: Message +// Note: this does the same thing as module-timeline__message-container but +// can be used outside tht Timeline contact more easily. +.module-message-container { + width: 100%; + margin-top: 10px; + + &:after { + visibility: hidden; + display: block; + font-size: 0; + content: ' '; + clear: both; + height: 0; + } +} + .module-message { position: relative; display: inline-flex; @@ -39,15 +55,19 @@ // Spec: container < 438px .module-message--incoming { - margin-left: 0; + margin-left: 16px; margin-right: 32px; } .module-message--outgoing { float: right; - margin-right: 0; + margin-right: 16px; margin-left: 32px; } +.module-message--incoming.module-message--group { + margin-left: 44px; +} + .module-message__buttons { position: absolute; top: 0; @@ -165,6 +185,37 @@ background-color: $color-light-10; } +.module-message__container__selection { + position: absolute; + display: block; + top: 0; + left: 0; + right: 0; + bottom: 0; + + border-radius: 16px; + + background-color: $color-black; + opacity: 0; + + animation: message--selected 1s ease-in-out; +} + +@keyframes message--selected { + 0% { + opacity: 0; + } + 20% { + opacity: 0.3; + } + 80% { + opacity: 0.3; + } + 100% { + opacity: 0; + } +} + // In case the color gets messed up .module-message__container--incoming { background-color: $color-conversation-grey; @@ -704,10 +755,10 @@ .module-message__metadata__status-icon--sending { @include color-svg('../images/sending.svg', $color-gray-60); - animation: module-message__metdata__status-icon--spinning 4s linear infinite; + animation: module-message__metadata__status-icon--spinning 4s linear infinite; } -@keyframes module-message__metdata__status-icon--spinning { +@keyframes module-message__metadata__status-icon--spinning { 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); @@ -842,6 +893,9 @@ } .module-quote { + // To leave room for image thumbnail + min-height: 54px; + position: relative; border-radius: 4px; border-top-left-radius: 10px; @@ -1286,6 +1340,8 @@ .module-group-notification { margin-top: 14px; + margin-left: 1em; + margin-right: 1em; font-size: 14px; line-height: 20px; letter-spacing: 0.3px; @@ -2420,6 +2476,13 @@ background-color: $color-black-02; } +.module-image__border-overlay--selected { + background-color: $color-black; + opacity: 0; + + animation: message--selected 1s ease-in-out; +} + .module-image__loading-placeholder { display: inline-flex; flex-direction: row; @@ -2999,9 +3062,16 @@ // In these --small and --mini sizes, we're exploding our @color-svg mixin so we don't // have to duplicate our background colors for the dark/ios/size matrix. +.module-spinner__container--small { + height: 24px; + width: 24px; +} + .module-spinner__circle--small { -webkit-mask: url('../images/spinner-track-24.svg') no-repeat center; -webkit-mask-size: 100%; + height: 24px; + width: 24px; } .module-spinner__arc--small { -webkit-mask: url('../images/spinner-24.svg') no-repeat center; @@ -3023,6 +3093,8 @@ .module-spinner__arc--mini { -webkit-mask: url('../images/spinner-24.svg') no-repeat center; -webkit-mask-size: 100%; + height: 14px; + width: 14px; } .module-spinner__circle--incoming { @@ -3032,6 +3104,13 @@ background-color: $color-white; } +.module-spinner__circle--on-background { + background-color: $color-gray-05; +} +.module-spinner__arc--on-background { + background-color: $color-gray-60; +} + // Module: Highlighted Message Body .module-message-body__highlight { @@ -3306,10 +3385,31 @@ font-size: 13px; } +// Module: Timeline Loading Row + +.module-timeline-loading-row { + height: 48px; + padding: 12px; + + display: flex; + flex-direction: columns; + justify-content: center; + align-items: center; + + @include light-theme { + color: $color-gray-75; + } + + @include dark-theme { + color: $color-gray-25; + } +} + // Module: Timeline .module-timeline { height: 100%; + overflow: hidden; } .module-timeline__message-container { @@ -4686,13 +4786,35 @@ .module-countdown { display: block; - width: 100%; + width: 24px; + height: 24px; } -.module-countdown__path { +// Note: the colors here should match the module-spinner's on-background colors +.module-countdown__front-path { fill-opacity: 0; - stroke: $color-white; stroke-width: 2; + + @include light-theme { + stroke: $color-gray-60; + } + + @include dark-theme { + stroke: $color-gray-25; + } +} + +.module-countdown__back-path { + fill-opacity: 0; + stroke-width: 2; + + @include light-theme { + stroke: $color-gray-05; + } + + @include dark-theme { + stroke: $color-gray-75; + } } // Module: CompositionInput @@ -4913,6 +5035,70 @@ } } +// Module: Last Seen Indicator + +.module-last-seen-indicator { + padding-top: 25px; + padding-bottom: 35px; + margin-left: 28px; + margin-right: 28px; +} + +.module-last-seen-indicator__bar { + background-color: $color-light-60; + width: 100%; + height: 4px; +} + +.module-last-seen-indicator__text { + margin-top: 3px; + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + text-transform: uppercase; + + text-align: center; + color: $color-light-90; +} + +// Module: Scroll Down Button + +.module-scroll-down { + z-index: 100; + position: absolute; + right: 20px; + bottom: 10px; +} + +.module-scroll-down__button { + height: 44px; + width: 44px; + border-radius: 22px; + text-align: center; + background-color: $color-light-35; + border: none; + box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2); + outline: none; + + &:hover { + background-color: $color-light-45; + } +} + +.module-scroll-down__button--new-messages { + background-color: $color-signal-blue; + + &:hover { + background-color: #1472bd; + } +} + +.module-scroll-down__icon { + @include color-svg('../images/down.svg', $color-white); + height: 100%; + width: 100%; +} + // Third-party module: react-contextmenu .react-contextmenu { @@ -5016,11 +5202,9 @@ // Spec: container < 438px .module-message--incoming { - margin-left: 0; margin-right: auto; } .module-message--outgoing { - margin-right: 0; margin-left: auto; } @@ -5051,11 +5235,9 @@ } .module-message--incoming { - margin-left: 0; margin-right: auto; } .module-message--outgoing { - margin-right: 0; margin-left: auto; } diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index 0db47c64fa..688de2351f 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -1509,6 +1509,13 @@ body.dark-theme { background-color: $color-gray-05; } + .module-spinner__circle--on-background { + background-color: $color-gray-75; + } + .module-spinner__arc--on-background { + background-color: $color-gray-25; + } + // Module: Caption Editor .module-caption-editor { diff --git a/test/index.html b/test/index.html index 479d341176..0baf2f4a9e 100644 --- a/test/index.html +++ b/test/index.html @@ -487,9 +487,7 @@ - - @@ -497,19 +495,14 @@ - - - - - diff --git a/test/keychange_listener_test.js b/test/keychange_listener_test.js index 43a8c759bc..6cb7da08bf 100644 --- a/test/keychange_listener_test.js +++ b/test/keychange_listener_test.js @@ -34,17 +34,19 @@ describe('KeyChangeListener', () => { }); after(async () => { - await convo.destroyMessages(); + await window.Signal.Data.removeAllMessagesInConversation(convo.id, { + MessageCollection: Whisper.MessageCollection, + }); await window.Signal.Data.saveConversation(convo.id); }); it('generates a key change notice in the private conversation with this contact', done => { - convo.once('newmessage', async () => { - await convo.fetchMessages(); - const message = convo.messageCollection.at(0); - assert.strictEqual(message.get('type'), 'keychange'); + const original = convo.addKeyChange; + convo.addKeyChange = keyChangedId => { + assert.equal(address.getName(), keyChangedId); + convo.addKeyChange = original; done(); - }); + }; store.saveIdentity(address.toString(), newKey); }); }); @@ -62,17 +64,20 @@ describe('KeyChangeListener', () => { }); }); after(async () => { - await convo.destroyMessages(); + await window.Signal.Data.removeAllMessagesInConversation(convo.id, { + MessageCollection: Whisper.MessageCollection, + }); await window.Signal.Data.saveConversation(convo.id); }); it('generates a key change notice in the group conversation with this contact', done => { - convo.once('newmessage', async () => { - await convo.fetchMessages(); - const message = convo.messageCollection.at(0); - assert.strictEqual(message.get('type'), 'keychange'); + const original = convo.addKeyChange; + convo.addKeyChange = keyChangedId => { + assert.equal(address.getName(), keyChangedId); + convo.addKeyChange = original; done(); - }); + }; + store.saveIdentity(address.toString(), newKey); }); }); diff --git a/test/models/conversations_test.js b/test/models/conversations_test.js index 121f12ee53..4f12b100fd 100644 --- a/test/models/conversations_test.js +++ b/test/models/conversations_test.js @@ -72,22 +72,6 @@ describe('Conversation', () => { assert.strictEqual(convo.contactCollection.at('2').get('name'), 'C'); }); - it('contains its own messages', async () => { - const convo = new Whisper.ConversationCollection().add({ - id: '+18085555555', - }); - await convo.fetchMessages(); - assert.notEqual(convo.messageCollection.length, 0); - }); - - it('contains only its own messages', async () => { - const convo = new Whisper.ConversationCollection().add({ - id: '+18085556666', - }); - await convo.fetchMessages(); - assert.strictEqual(convo.messageCollection.length, 0); - }); - it('adds conversation to message collection upon leaving group', async () => { const convo = new Whisper.ConversationCollection().add({ type: 'group', diff --git a/test/views/last_seen_indicator_view_test.js b/test/views/last_seen_indicator_view_test.js deleted file mode 100644 index c24d1d80de..0000000000 --- a/test/views/last_seen_indicator_view_test.js +++ /dev/null @@ -1,32 +0,0 @@ -/* global Whisper */ - -describe('LastSeenIndicatorView', () => { - it('renders provided count', () => { - const view = new Whisper.LastSeenIndicatorView({ count: 10 }); - assert.equal(view.count, 10); - - view.render(); - assert.match(view.$el.html(), /10 Unread Messages/); - }); - - it('renders count of 1', () => { - const view = new Whisper.LastSeenIndicatorView({ count: 1 }); - assert.equal(view.count, 1); - - view.render(); - assert.match(view.$el.html(), /1 Unread Message/); - }); - - it('increments count', () => { - const view = new Whisper.LastSeenIndicatorView({ count: 4 }); - - assert.equal(view.count, 4); - view.render(); - assert.match(view.$el.html(), /4 Unread Messages/); - - view.increment(3); - assert.equal(view.count, 7); - view.render(); - assert.match(view.$el.html(), /7 Unread Messages/); - }); -}); diff --git a/test/views/scroll_down_button_view_test.js b/test/views/scroll_down_button_view_test.js deleted file mode 100644 index 27ff41072f..0000000000 --- a/test/views/scroll_down_button_view_test.js +++ /dev/null @@ -1,35 +0,0 @@ -/* global Whisper */ - -describe('ScrollDownButtonView', () => { - it('renders with count = 0', () => { - const view = new Whisper.ScrollDownButtonView(); - view.render(); - assert.equal(view.count, 0); - assert.match(view.$el.html(), /Scroll to bottom/); - }); - - it('renders with count = 1', () => { - const view = new Whisper.ScrollDownButtonView({ count: 1 }); - view.render(); - assert.equal(view.count, 1); - assert.match(view.$el.html(), /New message below/); - }); - - it('renders with count = 2', () => { - const view = new Whisper.ScrollDownButtonView({ count: 2 }); - view.render(); - assert.equal(view.count, 2); - - assert.match(view.$el.html(), /New messages below/); - }); - - it('increments count and re-renders', () => { - const view = new Whisper.ScrollDownButtonView(); - view.render(); - assert.equal(view.count, 0); - assert.notMatch(view.$el.html(), /New message below/); - view.increment(1); - assert.equal(view.count, 1); - assert.match(view.$el.html(), /New message below/); - }); -}); diff --git a/ts/components/ConversationListItem.md b/ts/components/ConversationListItem.md index e350b17a22..ac997c1d2f 100644 --- a/ts/components/ConversationListItem.md +++ b/ts/components/ConversationListItem.md @@ -152,7 +152,9 @@ conversationType={'direct'} unreadCount={4} lastUpdated={Date.now() - 5 * 60 * 1000} - isTyping={true} + typingContact={{ + name: 'Someone Here', + }} onClick={result => console.log('onClick', result)} i18n={util.i18n} /> @@ -164,7 +166,9 @@ conversationType={'direct'} unreadCount={4} lastUpdated={Date.now() - 5 * 60 * 1000} - isTyping={true} + typingContact={{ + name: 'Someone Here', + }} lastMessage={{ status: 'read', }} diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index d27773cb35..157536ed64 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -23,7 +23,7 @@ export type PropsData = { unreadCount: number; isSelected: boolean; - isTyping: boolean; + typingContact?: Object; lastMessage?: { status: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; text: string; @@ -134,8 +134,8 @@ export class ConversationListItem extends React.PureComponent