From 5ebd8bc69094a0f47b6146b2940b22cbea6205ed Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 31 May 2019 15:42:01 -0700 Subject: [PATCH] Virtualize Messages List - only render what's visible --- _locales/en/messages.json | 5 - app/sql.js | 117 ++- background.html | 24 +- js/background.js | 6 + js/conversation_controller.js | 9 - js/models/conversations.js | 149 +-- js/models/messages.js | 173 +-- js/modules/backup.js | 2 +- js/modules/data.js | 39 +- js/modules/signal.js | 40 +- js/notifications.js | 2 +- js/views/conversation_view.js | 984 ++++++++---------- js/views/group_update_view.js | 32 - js/views/inbox_view.js | 46 +- js/views/last_seen_indicator_view.js | 36 - js/views/message_list_view.js | 143 --- js/views/message_view.js | 149 --- js/views/scroll_down_button_view.js | 39 - package.json | 3 +- patches/react-virtualized+9.21.0.patch | 357 +++++++ stylesheets/_conversation.scss | 130 +-- stylesheets/_index.scss | 9 - stylesheets/_modules.scss | 204 +++- stylesheets/_theme_dark.scss | 7 + test/index.html | 7 - test/keychange_listener_test.js | 29 +- test/models/conversations_test.js | 16 - test/views/last_seen_indicator_view_test.js | 32 - test/views/scroll_down_button_view_test.js | 35 - ts/components/ConversationListItem.md | 8 +- ts/components/ConversationListItem.tsx | 27 +- ts/components/Countdown.tsx | 29 +- ts/components/SearchResults.tsx | 5 +- ts/components/Spinner.tsx | 2 +- ts/components/conversation/EmbeddedContact.md | 158 +-- ts/components/conversation/ExpireTimer.md | 72 +- ts/components/conversation/Image.tsx | 7 +- ts/components/conversation/ImageGrid.tsx | 3 + ts/components/conversation/Message.md | 824 +++++++-------- ts/components/conversation/Message.tsx | 79 +- ts/components/conversation/Quote.md | 346 +++--- .../conversation/SafetyNumberNotification.tsx | 1 - .../conversation/ScrollDownButton.md | 21 +- .../conversation/ScrollDownButton.tsx | 16 +- ts/components/conversation/Timeline.md | 199 +++- ts/components/conversation/Timeline.tsx | 809 +++++++++++++- ts/components/conversation/TimelineItem.md | 50 +- ts/components/conversation/TimelineItem.tsx | 37 +- .../conversation/TimelineLoadingRow.md | 28 + .../conversation/TimelineLoadingRow.tsx | 48 + .../conversation/TimerNotification.tsx | 6 +- ts/components/conversation/Timestamp.md | 64 +- ts/components/conversation/TypingBubble.md | 20 +- ts/components/conversation/TypingBubble.tsx | 19 +- .../conversation/UnsupportedMessage.tsx | 8 +- ts/components/conversation/_contactUtil.tsx | 2 +- ts/shims/Whisper.ts | 13 +- ts/state/ducks/conversations.ts | 739 ++++++++++++- ts/state/ducks/search.ts | 51 +- ts/state/roots/createTimeline.tsx | 4 +- ts/state/selectors/conversations.ts | 224 +++- ts/state/selectors/search.ts | 7 +- ts/state/smart/LastSeenIndicator.tsx | 32 + ts/state/smart/LeftPane.tsx | 6 +- ts/state/smart/Timeline.tsx | 50 +- ts/state/smart/TimelineItem.tsx | 3 +- ts/state/smart/TimelineLoadingRow.tsx | 50 + ts/state/smart/TypingBubble.tsx | 30 + ts/styleguide/ConversationContext.tsx | 12 +- ts/test/state/selectors/conversations_test.ts | 30 +- ts/util/lint/exceptions.json | 417 ++++---- tslint.json | 8 +- yarn.lock | 74 +- 73 files changed, 4717 insertions(+), 2745 deletions(-) delete mode 100644 js/views/group_update_view.js delete mode 100644 js/views/last_seen_indicator_view.js delete mode 100644 js/views/message_list_view.js delete mode 100644 js/views/message_view.js delete mode 100644 js/views/scroll_down_button_view.js create mode 100644 patches/react-virtualized+9.21.0.patch delete mode 100644 test/views/last_seen_indicator_view_test.js delete mode 100644 test/views/scroll_down_button_view_test.js create mode 100644 ts/components/conversation/TimelineLoadingRow.md create mode 100644 ts/components/conversation/TimelineLoadingRow.tsx create mode 100644 ts/state/smart/LastSeenIndicator.tsx create mode 100644 ts/state/smart/TimelineLoadingRow.tsx create mode 100644 ts/state/smart/TypingBubble.tsx 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 { } public renderMessage() { - const { lastMessage, isTyping, unreadCount, i18n } = this.props; - if (!lastMessage && !isTyping) { + const { lastMessage, typingContact, unreadCount, i18n } = this.props; + if (!lastMessage && !typingContact) { return null; } const text = lastMessage && lastMessage.text ? lastMessage.text : ''; @@ -150,15 +150,22 @@ export class ConversationListItem extends React.PureComponent { : null )} > - {isTyping ? ( + {typingContact ? ( ) : ( - + <> + {shouldShowDraft ? ( + + {i18n('ConversationListItem--draft-prefix')} + + ) : null} + + )} {lastMessage && lastMessage.status ? ( diff --git a/ts/components/Countdown.tsx b/ts/components/Countdown.tsx index 3c86b7774b..f624096b14 100644 --- a/ts/components/Countdown.tsx +++ b/ts/components/Countdown.tsx @@ -73,16 +73,25 @@ export class Countdown extends React.Component { const strokeDashoffset = ratio * CIRCUMFERENCE; return ( - - - +
+ + + + +
); } } diff --git a/ts/components/SearchResults.tsx b/ts/components/SearchResults.tsx index 5314c108a7..f645700ed9 100644 --- a/ts/components/SearchResults.tsx +++ b/ts/components/SearchResults.tsx @@ -3,10 +3,7 @@ import { ConversationListItem, PropsData as ConversationListItemPropsType, } from './ConversationListItem'; -import { - MessageSearchResult, - PropsData as MessageSearchResultPropsType, -} from './MessageSearchResult'; +import { MessageSearchResult } from './MessageSearchResult'; import { StartNewConversation } from './StartNewConversation'; import { LocalizerType } from '../types/Util'; diff --git a/ts/components/Spinner.tsx b/ts/components/Spinner.tsx index 663b743ed4..946fa7242f 100644 --- a/ts/components/Spinner.tsx +++ b/ts/components/Spinner.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; interface Props { size?: string; svgSize: 'small' | 'normal'; - direction?: string; + direction?: 'outgoing' | 'incoming' | 'on-background'; } export class Spinner extends React.Component { diff --git a/ts/components/conversation/EmbeddedContact.md b/ts/components/conversation/EmbeddedContact.md index 0a113364cc..c2cb3ceae8 100644 --- a/ts/components/conversation/EmbeddedContact.md +++ b/ts/components/conversation/EmbeddedContact.md @@ -23,7 +23,7 @@ const contact = { signalAccount: '+12025550000', }; -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ; ``` @@ -89,7 +89,7 @@ const contact = { signalAccount: '+12025550000', }; -
  • +
    -
  • -
  • + +
    -
  • +
    ; ``` @@ -131,15 +131,15 @@ const contact = { }, }; -
  • +
    -
  • -
  • + +
    -
  • +
    ; ``` @@ -171,8 +171,8 @@ const contact = { }, signalAccount: '+12025550000', }; - -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ; ``` @@ -231,7 +231,7 @@ const contact = { }, }; -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ; ``` @@ -292,7 +292,7 @@ const contact = { }, }; -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ; ``` @@ -356,7 +356,7 @@ const contact = { signalAccount: '+12025551000', }; -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ; ``` @@ -414,7 +414,7 @@ const contact = { ], }; -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ; ``` @@ -462,7 +462,7 @@ const contact = { ```jsx const contact = {}; -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ; ``` @@ -542,7 +542,7 @@ const contactWithoutAccount = { }, }; -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ; ``` diff --git a/ts/components/conversation/ExpireTimer.md b/ts/components/conversation/ExpireTimer.md index f67f326992..3ec7a44391 100644 --- a/ts/components/conversation/ExpireTimer.md +++ b/ts/components/conversation/ExpireTimer.md @@ -2,7 +2,7 @@ ```jsx -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ``` @@ -57,7 +57,7 @@ ```jsx -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ``` diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index a996b73dfe..454c14f330 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -15,6 +15,7 @@ interface Props { overlayText?: string; + isSelected?: boolean; noBorder?: boolean; noBackground?: boolean; bottomOverlay?: boolean; @@ -51,6 +52,7 @@ export class Image extends React.Component { darkOverlay, height, i18n, + isSelected, noBackground, noBorder, onClick, @@ -118,7 +120,7 @@ export class Image extends React.Component { alt={i18n('imageCaptionIconAlt')} /> ) : null} - {!noBorder ? ( + {!noBorder || isSelected ? (
    { curveBottomRight ? 'module-image--curved-bottom-right' : null, smallCurveTopLeft ? 'module-image--small-curved-top-left' : null, softCorners ? 'module-image--soft-corners' : null, - darkOverlay ? 'module-image__border-overlay--dark' : null + darkOverlay ? 'module-image__border-overlay--dark' : null, + isSelected ? 'module-image__border-overlay--selected' : null )} /> ) : null} diff --git a/ts/components/conversation/ImageGrid.tsx b/ts/components/conversation/ImageGrid.tsx index 617dde0741..5a2231d0b2 100644 --- a/ts/components/conversation/ImageGrid.tsx +++ b/ts/components/conversation/ImageGrid.tsx @@ -21,6 +21,7 @@ interface Props { withContentBelow?: boolean; bottomOverlay?: boolean; isSticker?: boolean; + isSelected?: boolean; stickerSize?: number; i18n: LocalizerType; @@ -37,6 +38,7 @@ export class ImageGrid extends React.Component { bottomOverlay, i18n, isSticker, + isSelected, stickerSize, onError, onClick, @@ -83,6 +85,7 @@ export class ImageGrid extends React.Component { curveBottomRight={curveBottomRight} attachment={attachments[0]} playIconOverlay={isVideoAttachment(attachments[0])} + isSelected={isSelected} height={finalHeight} width={finalWidth} url={getUrl(attachments[0])} diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md index f4cc2a3e04..51d350a13e 100644 --- a/ts/components/conversation/Message.md +++ b/ts/components/conversation/Message.md @@ -4,7 +4,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean ```jsx -
  • +
  • -
  • +
  • +
    +
    +
    +
    +
    +
    +
    +
    +
    ``` @@ -149,7 +149,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean ```jsx -
  • +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • +
    ``` @@ -324,7 +324,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean ```jsx -
  • +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • +
    ``` @@ -451,7 +451,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean ```jsx -
  • +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • +
    ``` @@ -516,7 +516,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean ```jsx -
  • +
  • -
  • + +
  • +
    ``` @@ -554,7 +554,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean ```jsx -
  • +
  • -
  • + +
  • -
  • + +
  • -
  • + +
  • +
    ``` @@ -646,7 +646,7 @@ First, showing the metadata overlay on dark and light images, then a message wit ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -766,7 +766,7 @@ Stickers have no background, but they have all the standard message bubble featu ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -913,7 +913,7 @@ First set is in a 1:1 conversation, second set is in a group. ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -1003,7 +1003,7 @@ A sticker with no attachments (what our selectors produce for a pending sticker) ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -1063,7 +1063,7 @@ A sticker with no attachments (what our selectors produce for a pending sticker) ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -1245,7 +1245,7 @@ A sticker with no attachments (what our selectors produce for a pending sticker) ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -1434,7 +1434,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -1513,7 +1513,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ``` -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -1618,7 +1618,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -1696,7 +1696,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1820,7 +1820,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -1898,7 +1898,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -1980,7 +1980,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -2074,7 +2074,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -2171,7 +2171,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -2350,7 +2350,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -2442,7 +2442,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -2533,7 +2533,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -2620,7 +2620,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -2694,7 +2694,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -2768,7 +2768,7 @@ Voice notes are not shown any differently from audio attachments. ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    -
  • +
    ``` @@ -2907,7 +2907,7 @@ Voice notes are not shown any differently from audio attachments. ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -2985,7 +2985,7 @@ Voice notes are not shown any differently from audio attachments. ```jsx -
  • +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • +
    ``` @@ -3063,7 +3063,7 @@ Voice notes are not shown any differently from audio attachments. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -3109,7 +3109,7 @@ Voice notes are not shown any differently from audio attachments. ```jsx -
  • +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • +
    ``` @@ -3220,7 +3220,7 @@ Sticker link previews are forced to use the small link preview form, no matter t ```jsx -
  • +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • +
    ``` @@ -3274,7 +3274,7 @@ Sticker link previews are forced to use the small link preview form, no matter t ```jsx -
  • +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • +
    ``` @@ -3385,7 +3385,7 @@ Sticker link previews are forced to use the small link preview form, no matter t ```jsx -
  • +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • +
    ``` @@ -3482,7 +3482,7 @@ Sticker link previews are forced to use the small link preview form, no matter t ```jsx -
  • +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • +
    ``` @@ -3569,7 +3569,7 @@ Sticker link previews are forced to use the small link preview form, no matter t ```jsx -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ``` @@ -4005,7 +4005,7 @@ Note that the author avatar goes away if `collapseMetadata` is set. ```jsx -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    console.log('showVisualAttachment')} authorAvatarPath={util.gifObjectUrl} /> -
  • -
  • + +
    console.log('showVisualAttachment')} authorAvatarPath={util.gifObjectUrl} /> -
  • -
  • + +
    console.log('showVisualAttachment')} authorAvatarPath={util.gifObjectUrl} /> -
  • -
  • + +
    console.log('showVisualAttachment')} /> -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    console.log('onClickLinkPreview', url)} /> -
  • -
  • + +
    -
  • +
    ``` diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 0814e732ae..208fcf31c0 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -40,6 +40,7 @@ interface Trigger { // Same as MIN_WIDTH in ImageGrid.tsx const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; const STICKER_SIZE = 128; +const SELECTED_TIMEOUT = 1000; interface LinkPreviewType { title: string; @@ -54,6 +55,8 @@ export type PropsData = { text?: string; textPending?: boolean; isSticker: boolean; + isSelected: boolean; + isSelectedCounter: number; direction: 'incoming' | 'outgoing'; timestamp: number; status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; @@ -97,6 +100,8 @@ type PropsHousekeeping = { }; export type PropsActions = { + clearSelectedMessage: () => unknown; + replyToMessage: (id: string) => void; retrySend: (id: string) => void; deleteMessage: (id: string) => void; @@ -120,11 +125,10 @@ export type PropsActions = { displayTapToViewMessage: (messageId: string) => unknown; openLink: (url: string) => void; - scrollToMessage: ( + scrollToQuotedMessage: ( options: { author: string; sentAt: number; - referencedMessageNotFound: boolean; } ) => void; }; @@ -135,6 +139,9 @@ interface State { expiring: boolean; expired: boolean; imageBroken: boolean; + + isSelected: boolean; + prevSelectedCounter: number; } const EXPIRATION_CHECK_MINIMUM = 2000; @@ -148,6 +155,7 @@ export class Message extends React.PureComponent { public menuTriggerRef: Trigger | undefined; public expirationCheckInterval: any; public expiredTimeout: any; + public selectedTimeout: any; public constructor(props: Props) { super(props); @@ -160,10 +168,30 @@ export class Message extends React.PureComponent { expiring: false, expired: false, imageBroken: false, + + isSelected: props.isSelected, + prevSelectedCounter: props.isSelectedCounter, }; } + public static getDerivedStateFromProps(props: Props, state: State): State { + if ( + props.isSelected && + props.isSelectedCounter !== state.prevSelectedCounter + ) { + return { + ...state, + isSelected: props.isSelected, + prevSelectedCounter: props.isSelectedCounter, + }; + } + + return state; + } + public componentDidMount() { + this.startSelectedTimer(); + const { expirationLength } = this.props; if (!expirationLength) { return; @@ -180,6 +208,9 @@ export class Message extends React.PureComponent { } public componentWillUnmount() { + if (this.selectedTimeout) { + clearInterval(this.selectedTimeout); + } if (this.expirationCheckInterval) { clearInterval(this.expirationCheckInterval); } @@ -189,9 +220,26 @@ export class Message extends React.PureComponent { } public componentDidUpdate() { + this.startSelectedTimer(); + this.checkExpired(); } + public startSelectedTimer() { + const { isSelected } = this.state; + if (!isSelected) { + return; + } + + if (!this.selectedTimeout) { + this.selectedTimeout = setTimeout(() => { + this.selectedTimeout = undefined; + this.setState({ isSelected: false }); + this.props.clearSelectedMessage(); + }, SELECTED_TIMEOUT); + } + } + public checkExpired() { const now = Date.now(); const { isExpired, expirationTimestamp, expirationLength } = this.props; @@ -379,7 +427,7 @@ export class Message extends React.PureComponent { isSticker, text, } = this.props; - const { imageBroken } = this.state; + const { imageBroken, isSelected } = this.state; if (!attachments || !attachments[0]) { return null; @@ -422,6 +470,7 @@ export class Message extends React.PureComponent { withContentAbove={isSticker || withContentAbove} withContentBelow={isSticker || withContentBelow} isSticker={isSticker} + isSelected={isSticker && isSelected} stickerSize={STICKER_SIZE} bottomOverlay={bottomOverlay} i18n={i18n} @@ -622,7 +671,7 @@ export class Message extends React.PureComponent { disableScroll, i18n, quote, - scrollToMessage, + scrollToQuotedMessage, } = this.props; if (!quote) { @@ -633,15 +682,14 @@ export class Message extends React.PureComponent { conversationType === 'group' && direction === 'incoming'; const quoteColor = direction === 'incoming' ? authorColor : quote.authorColor; - const { referencedMessageNotFound } = quote; + const clickHandler = disableScroll ? undefined : () => { - scrollToMessage({ + scrollToQuotedMessage({ author: quote.authorId, sentAt: quote.sentAt, - referencedMessageNotFound, }); }; @@ -1195,12 +1243,24 @@ export class Message extends React.PureComponent { ); } + public renderSelectionHighlight() { + const { isSticker } = this.props; + const { isSelected } = this.state; + + if (!isSelected || isSticker) { + return; + } + + return
    ; + } + // tslint:disable-next-line cyclomatic-complexity public render() { const { authorPhoneNumber, authorColor, attachments, + conversationType, direction, displayTapToViewMessage, id, @@ -1211,6 +1271,7 @@ export class Message extends React.PureComponent { timestamp, } = this.props; const { expired, expiring, imageBroken } = this.state; + const isAttachmentPending = this.isAttachmentPending(); const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending; @@ -1236,7 +1297,8 @@ export class Message extends React.PureComponent { className={classNames( 'module-message', `module-message--${direction}`, - expiring ? 'module-message--expired' : null + expiring ? 'module-message--expired' : null, + conversationType === 'group' ? 'module-message--group' : null )} > {this.renderError(direction === 'incoming')} @@ -1271,6 +1333,7 @@ export class Message extends React.PureComponent { > {this.renderAuthor()} {this.renderContents()} + {this.renderSelectionHighlight()} {this.renderAvatar()}
    {this.renderError(direction === 'outgoing')} diff --git a/ts/components/conversation/Quote.md b/ts/components/conversation/Quote.md index ee62ccec0d..363bf8bc6a 100644 --- a/ts/components/conversation/Quote.md +++ b/ts/components/conversation/Quote.md @@ -4,7 +4,7 @@ ```jsx -
  • +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • +
    ``` @@ -42,7 +42,7 @@ ```jsx -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ``` @@ -113,7 +113,7 @@ ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -149,7 +149,7 @@ ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -187,7 +187,7 @@ ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -232,7 +232,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • -
  • + +
    console.log('onClick'), }} /> -
  • +
    ``` @@ -611,7 +611,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ``` #### Long names and context ```jsx - -
  • + +
    -
  • -
  • + +
    -
  • +
    ``` @@ -730,7 +730,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -774,7 +774,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -826,7 +826,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -886,7 +886,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -938,7 +938,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -988,7 +988,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1031,7 +1031,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1074,7 +1074,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1126,7 +1126,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1176,7 +1176,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1224,7 +1224,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1268,7 +1268,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1310,7 +1310,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1356,7 +1356,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1439,7 +1439,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1483,7 +1483,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1533,7 +1533,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1581,7 +1581,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1629,7 +1629,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1687,7 +1687,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -1731,7 +1731,7 @@ messages the color is taken from the contact who wrote the quoted message. ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` diff --git a/ts/components/conversation/SafetyNumberNotification.tsx b/ts/components/conversation/SafetyNumberNotification.tsx index 1cd840cfc8..c35e0c2336 100644 --- a/ts/components/conversation/SafetyNumberNotification.tsx +++ b/ts/components/conversation/SafetyNumberNotification.tsx @@ -1,5 +1,4 @@ import React from 'react'; -// import classNames from 'classnames'; import { ContactName } from './ContactName'; import { Intl } from '../Intl'; diff --git a/ts/components/conversation/ScrollDownButton.md b/ts/components/conversation/ScrollDownButton.md index 01e9933857..2b0b66ba72 100644 --- a/ts/components/conversation/ScrollDownButton.md +++ b/ts/components/conversation/ScrollDownButton.md @@ -1,9 +1,9 @@ -### None +### No new messages ```jsx console.log('scrollDown', id)} i18n={util.i18n} @@ -11,28 +11,15 @@ ``` -### One +### With new messages ```jsx console.log('scrollDown', id)} i18n={util.i18n} /> ``` - -### More than one - -```jsx - - console.log('scrollDown', id)} - i18n={util.i18n} - /> - -``` diff --git a/ts/components/conversation/ScrollDownButton.tsx b/ts/components/conversation/ScrollDownButton.tsx index 8746c61ac9..09a6fdeef9 100644 --- a/ts/components/conversation/ScrollDownButton.tsx +++ b/ts/components/conversation/ScrollDownButton.tsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import { LocalizerType } from '../../types/Util'; type Props = { - count: number; + withNewMessages: boolean; conversationId: string; scrollDown: (conversationId: string) => void; @@ -14,21 +14,17 @@ type Props = { export class ScrollDownButton extends React.Component { public render() { - const { conversationId, count, i18n, scrollDown } = this.props; - - let altText = i18n('scrollDown'); - if (count > 1) { - altText = i18n('messagesBelow'); - } else if (count === 1) { - altText = i18n('messageBelow'); - } + const { conversationId, withNewMessages, i18n, scrollDown } = this.props; + const altText = withNewMessages + ? i18n('messagesBelow') + : i18n('scrollDown'); return (
    +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - -
  • +
  • +
    - +
    ; ``` diff --git a/ts/components/conversation/TypingBubble.md b/ts/components/conversation/TypingBubble.md index 06c8555d44..297cd90344 100644 --- a/ts/components/conversation/TypingBubble.md +++ b/ts/components/conversation/TypingBubble.md @@ -2,12 +2,12 @@ ```jsx -
  • +
    -
  • -
  • + +
    -
  • +
    ``` @@ -15,24 +15,24 @@ ```jsx -
  • +
    -
  • -
  • + +
    -
  • -
  • + +
    -
  • +
    ``` diff --git a/ts/components/conversation/TypingBubble.tsx b/ts/components/conversation/TypingBubble.tsx index d4566f6776..cb40d0218d 100644 --- a/ts/components/conversation/TypingBubble.tsx +++ b/ts/components/conversation/TypingBubble.tsx @@ -9,14 +9,14 @@ import { LocalizerType } from '../../types/Util'; interface Props { avatarPath?: string; color: string; - name: string; + name?: string; phoneNumber: string; - profileName: string; - conversationType: string; + profileName?: string; + conversationType: 'group' | 'direct'; i18n: LocalizerType; } -export class TypingBubble extends React.Component { +export class TypingBubble extends React.PureComponent { public renderAvatar() { const { avatarPath, @@ -49,10 +49,17 @@ export class TypingBubble extends React.Component { } public render() { - const { i18n, color } = this.props; + const { i18n, color, conversationType } = this.props; + const isGroup = conversationType === 'group'; return ( -
    +
    unknown; }; +type PropsHousekeeping = { + i18n: LocalizerType; +}; + type Props = PropsData & PropsHousekeeping & PropsActions; export class UnsupportedMessage extends React.Component { diff --git a/ts/components/conversation/_contactUtil.tsx b/ts/components/conversation/_contactUtil.tsx index 9a01df86f9..e37a2c3cb5 100644 --- a/ts/components/conversation/_contactUtil.tsx +++ b/ts/components/conversation/_contactUtil.tsx @@ -18,7 +18,7 @@ export function renderAvatar({ contact: ContactType; i18n: LocalizerType; size: number; - direction?: string; + direction?: 'outgoing' | 'incoming'; }) { const { avatar } = contact; diff --git a/ts/shims/Whisper.ts b/ts/shims/Whisper.ts index 1280f6c004..4d4a45f402 100644 --- a/ts/shims/Whisper.ts +++ b/ts/shims/Whisper.ts @@ -1,4 +1,13 @@ -export function getMessageModel(attributes: any) { +export function getSearchResultsProps(attributes: any) { // @ts-ignore - return new window.Whisper.Message(attributes); + const model = new window.Whisper.Message(attributes); + + return model.getPropsForSearchResult(); +} + +export function getBubbleProps(attributes: any) { + // @ts-ignore + const model = new window.Whisper.Message(attributes); + + return model.getPropsForBubble(); } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 229be1c49f..6ac35bcf32 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -1,6 +1,14 @@ -import { AnyAction } from 'redux'; -import { omit } from 'lodash'; - +import { + difference, + fromPairs, + intersection, + omit, + orderBy, + pick, + uniq, + values, + without, +} from 'lodash'; import { trigger } from '../../shims/events'; import { NoopActionType } from './noop'; @@ -48,29 +56,65 @@ export type ConversationType = { lastUpdated: number; unreadCount: number; isSelected: boolean; - isTyping: boolean; + typingContact?: { + avatarPath?: string; + color: string; + name?: string; + phoneNumber: string; + profileName?: string; + }; }; export type ConversationLookupType = { [key: string]: ConversationType; }; export type MessageType = { id: string; + conversationId: string; + source: string; + type: 'incoming' | 'outgoing' | 'group' | 'keychange' | 'verified-change'; + quote?: { author: string }; + received_at: number; + hasSignalAccount?: boolean; + + // No need to go beyond this; unused at this stage, since this goes into + // a reducer still in plain JavaScript and comes out well-formed }; + +type MessagePointerType = { + id: string; + received_at: number; +}; +type MessageMetricsType = { + newest?: MessagePointerType; + oldest?: MessagePointerType; + oldestUnread?: MessagePointerType; + totalUnread: number; +}; + export type MessageLookupType = { [key: string]: MessageType; }; export type ConversationMessageType = { - // And perhaps this could be part of our ConversationType? What if we moved all the selectors as part of this set of changes? - // We have the infrastructure for it now... - messages: Array; + heightChangeMessageIds: Array; + isLoadingMessages: boolean; + isNearBottom?: boolean; + loadCountdownStart?: number; + messageIds: Array; + metrics: MessageMetricsType; + resetCounter: number; + scrollToMessageId?: string; + scrollToMessageCounter: number; }; + export type MessagesByConversationType = { - [key: string]: ConversationMessageType; + [key: string]: ConversationMessageType | null; }; export type ConversationsStateType = { conversationLookup: ConversationLookupType; selectedConversation?: string; + selectedMessage?: string; + selectedMessageCounter: number; showArchived: boolean; // Note: it's very important that both of these locations are always kept up to date @@ -100,15 +144,91 @@ type ConversationRemovedActionType = { id: string; }; }; +type ConversationUnloadedActionType = { + type: 'CONVERSATION_UNLOADED'; + payload: { + id: string; + }; +}; export type RemoveAllConversationsActionType = { type: 'CONVERSATIONS_REMOVE_ALL'; payload: null; }; -export type MessageExpiredActionType = { - type: 'MESSAGE_EXPIRED'; +export type MessageChangedActionType = { + type: 'MESSAGE_CHANGED'; payload: { id: string; conversationId: string; + data: MessageType; + }; +}; +export type MessageDeletedActionType = { + type: 'MESSAGE_DELETED'; + payload: { + id: string; + conversationId: string; + }; +}; +export type MessagesAddedActionType = { + type: 'MESSAGES_ADDED'; + payload: { + conversationId: string; + messages: Array; + isNewMessage: boolean; + isFocused: boolean; + }; +}; +export type MessagesResetActionType = { + type: 'MESSAGES_RESET'; + payload: { + conversationId: string; + messages: Array; + metrics: MessageMetricsType; + scrollToMessageId?: string; + }; +}; +export type SetMessagesLoadingActionType = { + type: 'SET_MESSAGES_LOADING'; + payload: { + conversationId: string; + isLoadingMessages: boolean; + }; +}; +export type SetLoadCountdownStartActionType = { + type: 'SET_LOAD_COUNTDOWN_START'; + payload: { + conversationId: string; + loadCountdownStart?: number; + }; +}; +export type SetIsNearBottomActionType = { + type: 'SET_NEAR_BOTTOM'; + payload: { + conversationId: string; + isNearBottom: boolean; + }; +}; +export type ScrollToMessageActionType = { + type: 'SCROLL_TO_MESSAGE'; + payload: { + conversationId: string; + messageId: string; + }; +}; +export type ClearChangedMessagesActionType = { + type: 'CLEAR_CHANGED_MESSAGES'; + payload: { + conversationId: string; + }; +}; +export type ClearSelectedMessageActionType = { + type: 'CLEAR_SELECTED_MESSAGE'; + payload: null; +}; +export type ClearUnreadMetricsActionType = { + type: 'CLEAR_UNREAD_METRICS'; + payload: { + conversationId: string; }; }; export type SelectedConversationChangedActionType = { @@ -128,14 +248,24 @@ type ShowArchivedConversationsActionType = { }; export type ConversationActionType = - | AnyAction | ConversationAddedActionType | ConversationChangedActionType | ConversationRemovedActionType + | ConversationUnloadedActionType | RemoveAllConversationsActionType - | MessageExpiredActionType + | MessageChangedActionType + | MessageDeletedActionType + | MessagesAddedActionType + | MessagesResetActionType + | SetMessagesLoadingActionType + | SetIsNearBottomActionType + | SetLoadCountdownStartActionType + | ClearChangedMessagesActionType + | ClearSelectedMessageActionType + | ClearUnreadMetricsActionType + | ScrollToMessageActionType | SelectedConversationChangedActionType - | MessageExpiredActionType + | MessageDeletedActionType | SelectedConversationChangedActionType | ShowInboxActionType | ShowArchivedConversationsActionType; @@ -146,8 +276,19 @@ export const actions = { conversationAdded, conversationChanged, conversationRemoved, + conversationUnloaded, removeAllConversations, - messageExpired, + messageDeleted, + messageChanged, + messagesAdded, + messagesReset, + setMessagesLoading, + setLoadCountdownStart, + setIsNearBottom, + clearChangedMessages, + clearSelectedMessage, + clearUnreadMetrics, + scrollToMessage, openConversationInternal, openConversationExternal, showInbox, @@ -186,6 +327,14 @@ function conversationRemoved(id: string): ConversationRemovedActionType { }, }; } +function conversationUnloaded(id: string): ConversationUnloadedActionType { + return { + type: 'CONVERSATION_UNLOADED', + payload: { + id, + }, + }; +} function removeAllConversations(): RemoveAllConversationsActionType { return { type: 'CONVERSATIONS_REMOVE_ALL', @@ -193,22 +342,144 @@ function removeAllConversations(): RemoveAllConversationsActionType { }; } -function messageExpired( +function messageChanged( + id: string, + conversationId: string, + data: MessageType +): MessageChangedActionType { + return { + type: 'MESSAGE_CHANGED', + payload: { + id, + conversationId, + data, + }, + }; +} +function messageDeleted( id: string, conversationId: string -): MessageExpiredActionType { +): MessageDeletedActionType { return { - type: 'MESSAGE_EXPIRED', + type: 'MESSAGE_DELETED', payload: { id, conversationId, }, }; } +function messagesAdded( + conversationId: string, + messages: Array, + isNewMessage: boolean, + isFocused: boolean +): MessagesAddedActionType { + return { + type: 'MESSAGES_ADDED', + payload: { + conversationId, + messages, + isNewMessage, + isFocused, + }, + }; +} +function messagesReset( + conversationId: string, + messages: Array, + metrics: MessageMetricsType, + scrollToMessageId?: string +): MessagesResetActionType { + return { + type: 'MESSAGES_RESET', + payload: { + conversationId, + messages, + metrics, + scrollToMessageId, + }, + }; +} +function setMessagesLoading( + conversationId: string, + isLoadingMessages: boolean +): SetMessagesLoadingActionType { + return { + type: 'SET_MESSAGES_LOADING', + payload: { + conversationId, + isLoadingMessages, + }, + }; +} +function setLoadCountdownStart( + conversationId: string, + loadCountdownStart?: number +): SetLoadCountdownStartActionType { + return { + type: 'SET_LOAD_COUNTDOWN_START', + payload: { + conversationId, + loadCountdownStart, + }, + }; +} +function setIsNearBottom( + conversationId: string, + isNearBottom: boolean +): SetIsNearBottomActionType { + return { + type: 'SET_NEAR_BOTTOM', + payload: { + conversationId, + isNearBottom, + }, + }; +} +function clearChangedMessages( + conversationId: string +): ClearChangedMessagesActionType { + return { + type: 'CLEAR_CHANGED_MESSAGES', + payload: { + conversationId, + }, + }; +} +function clearSelectedMessage(): ClearSelectedMessageActionType { + return { + type: 'CLEAR_SELECTED_MESSAGE', + payload: null, + }; +} +function clearUnreadMetrics( + conversationId: string +): ClearUnreadMetricsActionType { + return { + type: 'CLEAR_UNREAD_METRICS', + payload: { + conversationId, + }, + }; +} + +function scrollToMessage( + conversationId: string, + messageId: string +): ScrollToMessageActionType { + return { + type: 'SCROLL_TO_MESSAGE', + payload: { + conversationId, + messageId, + }, + }; +} // Note: we need two actions here to simplify. Operations outside of the left pane can -// trigger an 'openConversation' so we go through Whisper.events for all conversation -// selection. +// trigger an 'openConversation' so we go through Whisper.events for all +// conversation selection. Internal just triggers the Whisper.event, and External +// makes the changes to the store. function openConversationInternal( id: string, messageId?: string @@ -251,12 +522,24 @@ function showArchivedConversations() { function getEmptyState(): ConversationsStateType { return { conversationLookup: {}, - showArchived: false, - messagesLookup: {}, messagesByConversation: {}, + messagesLookup: {}, + selectedMessageCounter: 0, + showArchived: false, }; } +function hasMessageHeightChanged( + message: MessageType, + previous: MessageType +): Boolean { + return ( + Boolean(message.hasSignalAccount || previous.hasSignalAccount) && + message.hasSignalAccount !== previous.hasSignalAccount + ); +} + +// tslint:disable-next-line cyclomatic-complexity max-func-body-length export function reducer( state: ConversationsStateType = getEmptyState(), action: ConversationActionType @@ -322,11 +605,421 @@ export function reducer( conversationLookup: omit(conversationLookup, [id]), }; } + if (action.type === 'CONVERSATION_UNLOADED') { + const { payload } = action; + const { id } = payload; + const existingConversation = state.messagesByConversation[id]; + if (!existingConversation) { + return state; + } + + const { messageIds } = existingConversation; + + return { + ...state, + messagesLookup: omit(state.messagesLookup, messageIds), + messagesByConversation: omit(state.messagesByConversation, [id]), + }; + } if (action.type === 'CONVERSATIONS_REMOVE_ALL') { return getEmptyState(); } - if (action.type === 'MESSAGE_EXPIRED') { - // noop - for now this is only important for search + if (action.type === 'MESSAGE_CHANGED') { + const { id, conversationId, data } = action.payload; + const existingConversation = state.messagesByConversation[conversationId]; + + // We don't keep track of messages unless their conversation is loaded... + if (!existingConversation) { + return state; + } + // ...and we've already loaded that message once + const existingMessage = state.messagesLookup[id]; + if (!existingMessage) { + return state; + } + + // Check for changes which could affect height - that's why we need this + // heightChangeMessageIds field. It tells Timeline to recalculate all of its heights + const hasHeightChanged = hasMessageHeightChanged(data, existingMessage); + + const { heightChangeMessageIds } = existingConversation; + const updatedChanges = hasHeightChanged + ? uniq([...heightChangeMessageIds, id]) + : heightChangeMessageIds; + + return { + ...state, + messagesLookup: { + ...state.messagesLookup, + [id]: data, + }, + messagesByConversation: { + ...state.messagesByConversation, + [conversationId]: { + ...existingConversation, + heightChangeMessageIds: updatedChanges, + }, + }, + }; + } + if (action.type === 'MESSAGES_RESET') { + const { + conversationId, + messages, + metrics, + scrollToMessageId, + } = action.payload; + const { messagesByConversation, messagesLookup } = state; + + const existingConversation = messagesByConversation[conversationId]; + const resetCounter = existingConversation + ? existingConversation.resetCounter + 1 + : 0; + + const sorted = orderBy(messages, ['received_at'], ['ASC']); + const messageIds = sorted.map(message => message.id); + + const lookup = fromPairs(messages.map(message => [message.id, message])); + + return { + ...state, + selectedMessage: scrollToMessageId, + selectedMessageCounter: state.selectedMessageCounter + 1, + messagesLookup: { + ...messagesLookup, + ...lookup, + }, + messagesByConversation: { + ...messagesByConversation, + [conversationId]: { + isLoadingMessages: false, + scrollToMessageId, + scrollToMessageCounter: 0, + messageIds, + metrics, + resetCounter, + heightChangeMessageIds: [], + }, + }, + }; + } + if (action.type === 'SET_MESSAGES_LOADING') { + const { payload } = action; + const { conversationId, isLoadingMessages } = payload; + + const { messagesByConversation } = state; + const existingConversation = messagesByConversation[conversationId]; + + if (!existingConversation) { + return state; + } + + return { + ...state, + messagesByConversation: { + ...messagesByConversation, + [conversationId]: { + ...existingConversation, + loadCountdownStart: undefined, + isLoadingMessages, + }, + }, + }; + } + if (action.type === 'SET_LOAD_COUNTDOWN_START') { + const { payload } = action; + const { conversationId, loadCountdownStart } = payload; + + const { messagesByConversation } = state; + const existingConversation = messagesByConversation[conversationId]; + + if (!existingConversation) { + return state; + } + + return { + ...state, + messagesByConversation: { + ...messagesByConversation, + [conversationId]: { + ...existingConversation, + loadCountdownStart, + }, + }, + }; + } + if (action.type === 'SET_NEAR_BOTTOM') { + const { payload } = action; + const { conversationId, isNearBottom } = payload; + + const { messagesByConversation } = state; + const existingConversation = messagesByConversation[conversationId]; + + if (!existingConversation) { + return state; + } + + return { + ...state, + messagesByConversation: { + ...messagesByConversation, + [conversationId]: { + ...existingConversation, + isNearBottom, + }, + }, + }; + } + if (action.type === 'SCROLL_TO_MESSAGE') { + const { payload } = action; + const { conversationId, messageId } = payload; + + const { messagesByConversation, messagesLookup } = state; + const existingConversation = messagesByConversation[conversationId]; + + if (!existingConversation) { + return state; + } + if (!messagesLookup[messageId]) { + return state; + } + if (!existingConversation.messageIds.includes(messageId)) { + return state; + } + + return { + ...state, + selectedMessage: messageId, + selectedMessageCounter: state.selectedMessageCounter + 1, + messagesByConversation: { + ...messagesByConversation, + [conversationId]: { + ...existingConversation, + isLoadingMessages: false, + scrollToMessageId: messageId, + scrollToMessageCounter: + existingConversation.scrollToMessageCounter + 1, + }, + }, + }; + } + if (action.type === 'MESSAGE_DELETED') { + const { id, conversationId } = action.payload; + const { messagesByConversation, messagesLookup } = state; + + const existingConversation = messagesByConversation[conversationId]; + if (!existingConversation) { + return state; + } + + // Assuming that we always have contiguous groups of messages in memory, the removal + // of one message at one end of our message set be replaced with the message right + // next to it. + const oldIds = existingConversation.messageIds; + let { newest, oldest } = existingConversation.metrics; + + if (oldIds.length > 1) { + const firstId = oldIds[0]; + const lastId = oldIds[oldIds.length - 1]; + + if (oldest && oldest.id === firstId && firstId === id) { + const second = messagesLookup[oldIds[1]]; + oldest = second ? pick(second, ['id', 'received_at']) : undefined; + } + if (newest && newest.id === lastId && lastId === id) { + const penultimate = messagesLookup[oldIds[oldIds.length - 2]]; + newest = penultimate + ? pick(penultimate, ['id', 'received_at']) + : undefined; + } + } + + // Removing it from our caches + const messageIds = without(existingConversation.messageIds, id); + const heightChangeMessageIds = without( + existingConversation.heightChangeMessageIds, + id + ); + + return { + ...state, + messagesLookup: omit(messagesLookup, id), + messagesByConversation: { + [conversationId]: { + ...existingConversation, + messageIds, + heightChangeMessageIds, + metrics: { + ...existingConversation.metrics, + oldest, + newest, + }, + }, + }, + }; + } + if (action.type === 'MESSAGES_ADDED') { + const { + conversationId, + isFocused, + isNewMessage, + messages, + } = action.payload; + const { messagesByConversation, messagesLookup } = state; + + const existingConversation = messagesByConversation[conversationId]; + if (!existingConversation) { + return state; + } + + let { + newest, + oldest, + oldestUnread, + totalUnread, + } = existingConversation.metrics; + + const existingTotal = existingConversation.messageIds.length; + if (isNewMessage && existingTotal > 0) { + const lastMessageId = existingConversation.messageIds[existingTotal - 1]; + + // If our messages in memory don't include the most recent messages, then we + // won't add new messages to our message list. + const haveLatest = newest && newest.id === lastMessageId; + if (!haveLatest) { + return state; + } + } + + const newIds = messages.map(message => message.id); + const newChanges = intersection(newIds, existingConversation.messageIds); + const heightChangeMessageIds = uniq([ + ...newChanges, + ...existingConversation.heightChangeMessageIds, + ]); + + const lookup = fromPairs( + existingConversation.messageIds.map(id => [id, messagesLookup[id]]) + ); + + messages.forEach(message => { + lookup[message.id] = message; + }); + + const sorted = orderBy(values(lookup), ['received_at'], ['ASC']); + const messageIds = sorted.map(message => message.id); + + const first = sorted[0]; + const last = sorted.length > 0 ? sorted[sorted.length - 1] : null; + + if (first && oldest && first.received_at < oldest.received_at) { + oldest = pick(first, ['id', 'received_at']); + } + if (last && newest && last.received_at > newest.received_at) { + newest = pick(last, ['id', 'received_at']); + } + + const newMessageIds = difference(newIds, existingConversation.messageIds); + const { isNearBottom } = existingConversation; + + if ((!isNearBottom || !isFocused) && !oldestUnread) { + const oldestId = newMessageIds.find(messageId => { + const message = lookup[messageId]; + + return Boolean(message.unread); + }); + + if (oldestId) { + oldestUnread = pick(lookup[oldestId], [ + 'id', + 'received_at', + ]) as MessagePointerType; + } + } + + if (oldestUnread) { + const newUnread: number = newMessageIds.reduce((sum, messageId) => { + const message = lookup[messageId]; + + return sum + (message && message.unread ? 1 : 0); + }, 0); + totalUnread = (totalUnread || 0) + newUnread; + } + + return { + ...state, + messagesLookup: { + ...messagesLookup, + ...lookup, + }, + messagesByConversation: { + ...messagesByConversation, + [conversationId]: { + ...existingConversation, + isLoadingMessages: false, + messageIds, + heightChangeMessageIds, + scrollToMessageId: undefined, + metrics: { + ...existingConversation.metrics, + newest, + oldest, + totalUnread, + oldestUnread, + }, + }, + }, + }; + } + if (action.type === 'CLEAR_SELECTED_MESSAGE') { + return { + ...state, + selectedMessage: undefined, + }; + } + if (action.type === 'CLEAR_CHANGED_MESSAGES') { + const { payload } = action; + const { conversationId } = payload; + const existingConversation = state.messagesByConversation[conversationId]; + + if (!existingConversation) { + return state; + } + + return { + ...state, + messagesByConversation: { + ...state.messagesByConversation, + [conversationId]: { + ...existingConversation, + heightChangeMessageIds: [], + }, + }, + }; + } + if (action.type === 'CLEAR_UNREAD_METRICS') { + const { payload } = action; + const { conversationId } = payload; + const existingConversation = state.messagesByConversation[conversationId]; + + if (!existingConversation) { + return state; + } + + return { + ...state, + messagesByConversation: { + ...state.messagesByConversation, + [conversationId]: { + ...existingConversation, + metrics: { + ...existingConversation.metrics, + oldestUnread: undefined, + totalUnread: 0, + }, + }, + }, + }; } if (action.type === 'SELECTED_CONVERSATION_CHANGED') { const { payload } = action; diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index 9bd372075d..4230d687a4 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -1,18 +1,14 @@ -import { AnyAction } from 'redux'; import { omit, reject } from 'lodash'; import { normalize } from '../../types/PhoneNumber'; import { trigger } from '../../shims/events'; -// import { getMessageModel } from '../../shims/Whisper'; -// import { cleanSearchTerm } from '../../util/cleanSearchTerm'; -import { - searchConversations /*, searchMessages */, -} from '../../../js/modules/data'; +import { cleanSearchTerm } from '../../util/cleanSearchTerm'; +import { searchConversations, searchMessages } from '../../../js/modules/data'; import { makeLookup } from '../../util/makeLookup'; import { ConversationType, - MessageExpiredActionType, + MessageDeletedActionType, MessageSearchResultType, RemoveAllConversationsActionType, SelectedConversationChangedActionType, @@ -64,11 +60,10 @@ type ClearSearchActionType = { }; export type SEARCH_TYPES = - | AnyAction | SearchResultsFulfilledActionType | UpdateSearchTermActionType | ClearSearchActionType - | MessageExpiredActionType + | MessageDeletedActionType | RemoveAllConversationsActionType | SelectedConversationChangedActionType; @@ -101,9 +96,9 @@ async function doSearch( ): Promise { const { regionCode, ourNumber, noteToSelf } = options; - const [discussions /*, messages */] = await Promise.all([ + const [discussions, messages] = await Promise.all([ queryConversationsAndContacts(query, { ourNumber, noteToSelf }), - // queryMessages(query), + queryMessages(query), ]); const { conversations, contacts } = discussions; @@ -112,7 +107,7 @@ async function doSearch( normalizedPhoneNumber: normalize(query, { regionCode }), conversations, contacts, - messages: [], // getMessageProps(messages) || [], + messages, }; } function clearSearch(): ClearSearchActionType { @@ -146,29 +141,15 @@ function startNewConversation( }; } -// Helper functions for search +async function queryMessages(query: string) { + try { + const normalized = cleanSearchTerm(query); -// const getMessageProps = (messages: Array) => { -// if (!messages || !messages.length) { -// return []; -// } - -// return messages.map(message => { -// const model = getMessageModel(message); - -// return model.propsForSearchResult; -// }); -// }; - -// async function queryMessages(query: string) { -// try { -// const normalized = cleanSearchTerm(query); - -// return searchMessages(normalized); -// } catch (e) { -// return []; -// } -// } + return searchMessages(normalized); + } catch (e) { + return []; + } +} async function queryConversationsAndContacts( providedQuery: string, @@ -271,7 +252,7 @@ export function reducer( }; } - if (action.type === 'MESSAGE_EXPIRED') { + if (action.type === 'MESSAGE_DELETED') { const { messages, messageLookup } = state; if (!messages.length) { return state; diff --git a/ts/state/roots/createTimeline.tsx b/ts/state/roots/createTimeline.tsx index 740a7df458..36ccf29ce4 100644 --- a/ts/state/roots/createTimeline.tsx +++ b/ts/state/roots/createTimeline.tsx @@ -9,8 +9,8 @@ import { SmartTimeline } from '../smart/Timeline'; // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 const FilteredTimeline = SmartTimeline as any; -export const createTimeline = (store: Store) => ( +export const createTimeline = (store: Store, props: Object) => ( - + ); diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 10d67fc5df..d2a4869cb2 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -6,12 +6,16 @@ import { LocalizerType } from '../../types/Util'; import { StateType } from '../reducer'; import { ConversationLookupType, + ConversationMessageType, ConversationsStateType, ConversationType, MessageLookupType, MessagesByConversationType, MessageType, } from '../ducks/conversations'; +import { getBubbleProps } from '../../shims/Whisper'; +import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline'; +import { TimelineItemType } from '../../components/conversation/TimelineItem'; import { getIntl, getRegionCode, getUserNumber } from './user'; @@ -32,6 +36,24 @@ export const getSelectedConversation = createSelector( } ); +type SelectedMessageType = { + id: string; + counter: number; +}; +export const getSelectedMessage = createSelector( + getConversations, + (state: ConversationsStateType): SelectedMessageType | undefined => { + if (!state.selectedMessage) { + return; + } + + return { + id: state.selectedMessage, + counter: state.selectedMessageCounter, + }; + } +); + export const getShowArchived = createSelector( getConversations, (state: ConversationsStateType): boolean => { @@ -160,9 +182,12 @@ export const getMe = createSelector( ); // This is where we will put Conversation selector logic, replicating what -// is currently in models/conversation.getProps() -// Blockers: -// 1) contactTypingTimers - that UI-only state needs to be moved to redux +// is currently in models/conversation.getProps() +// What needs to happen to pull that selector logic here? +// 1) contactTypingTimers - that UI-only state needs to be moved to redux +// 2) all of the message selectors need to be reselect-based; today those +// Backbone-based prop-generation functions expect to get Conversation information +// directly via ConversationController export function _conversationSelector( conversation: ConversationType // regionCode: string, @@ -180,6 +205,8 @@ export const getCachedSelectorForConversation = createSelector( getRegionCode, getUserNumber, (): CachedConversationSelectorType => { + // Note: memoizee will check all parameters provided, and only run our selector + // if any of them have changed. return memoizee(_conversationSelector, { max: 100 }); } ); @@ -203,49 +230,200 @@ export const getConversationSelector = createSelector( } ); -// For now we pass through, as selector logic is still happening in the Backbone Model. -// Blockers: -// 1) it's a lot of code to pull over - ~500 lines -// 2) a couple places still rely on all that code - will need to move these to Roots: -// - quote compose -// - message details +// For now we use a shim, as selector logic is still happening in the Backbone Model. +// What needs to happen to pull that selector logic here? +// 1) translate ~500 lines of selector logic into TypeScript +// 2) other places still rely on that prop-gen code - need to put these under Roots: +// - quote compose +// - message details export function _messageSelector( - message: MessageType - // ourNumber: string, - // regionCode: string, - // conversation?: ConversationType, - // sender?: ConversationType, - // quoted?: ConversationType -): MessageType { - return message; + message: MessageType, + // @ts-ignore + ourNumber: string, + // @ts-ignore + regionCode: string, + // @ts-ignore + conversation?: ConversationType, + // @ts-ignore + author?: ConversationType, + // @ts-ignore + quoted?: ConversationType, + selectedMessageId?: string, + selectedMessageCounter?: number +): TimelineItemType { + // Note: We don't use all of those parameters here, but the shim we call does. + // We want to call this function again if any of those parameters change. + const props = getBubbleProps(message); + + if (selectedMessageId === message.id) { + return { + ...props, + data: { + ...props.data, + isSelected: true, + isSelectedCounter: selectedMessageCounter, + }, + }; + } + + return props; } // A little optimization to reset our selector cache whenever high-level application data // changes: regionCode and userNumber. -type CachedMessageSelectorType = (message: MessageType) => MessageType; +type CachedMessageSelectorType = ( + message: MessageType, + ourNumber: string, + regionCode: string, + conversation?: ConversationType, + author?: ConversationType, + quoted?: ConversationType, + selectedMessageId?: string, + selectedMessageCounter?: number +) => TimelineItemType; export const getCachedSelectorForMessage = createSelector( getRegionCode, getUserNumber, (): CachedMessageSelectorType => { + // Note: memoizee will check all parameters provided, and only run our selector + // if any of them have changed. return memoizee(_messageSelector, { max: 500 }); } ); -type GetMessageByIdType = (id: string) => MessageType | undefined; +type GetMessageByIdType = (id: string) => TimelineItemType | undefined; export const getMessageSelector = createSelector( getCachedSelectorForMessage, getMessages, + getSelectedMessage, + getConversationSelector, + getRegionCode, + getUserNumber, ( - selector: CachedMessageSelectorType, - lookup: MessageLookupType + messageSelector: CachedMessageSelectorType, + messageLookup: MessageLookupType, + selectedMessage: SelectedMessageType | undefined, + conversationSelector: GetConversationByIdType, + regionCode: string, + ourNumber: string ): GetMessageByIdType => { return (id: string) => { - const message = lookup[id]; + const message = messageLookup[id]; if (!message) { return; } - return selector(message); + const { conversationId, source, type, quote } = message; + const conversation = conversationSelector(conversationId); + let author: ConversationType | undefined; + let quoted: ConversationType | undefined; + + if (type === 'incoming') { + author = conversationSelector(source); + } else if (type === 'outgoing') { + author = conversationSelector(ourNumber); + } + + if (quote) { + quoted = conversationSelector(quote.author); + } + + return messageSelector( + message, + ourNumber, + regionCode, + conversation, + author, + quoted, + selectedMessage ? selectedMessage.id : undefined, + selectedMessage ? selectedMessage.counter : undefined + ); + }; + } +); + +export function _conversationMessagesSelector( + conversation: ConversationMessageType +): TimelinePropsType { + const { + heightChangeMessageIds, + isLoadingMessages, + loadCountdownStart, + messageIds, + metrics, + resetCounter, + scrollToMessageId, + scrollToMessageCounter, + } = conversation; + + const firstId = messageIds[0]; + const lastId = + messageIds.length === 0 ? undefined : messageIds[messageIds.length - 1]; + + const { oldestUnread } = metrics; + + const haveNewest = !metrics.newest || !lastId || lastId === metrics.newest.id; + const haveOldest = + !metrics.oldest || !firstId || firstId === metrics.oldest.id; + + const items = messageIds; + const messageHeightChanges = Boolean( + heightChangeMessageIds && heightChangeMessageIds.length + ); + const oldestUnreadIndex = oldestUnread + ? messageIds.findIndex(id => id === oldestUnread.id) + : undefined; + const scrollToIndex = scrollToMessageId + ? messageIds.findIndex(id => id === scrollToMessageId) + : undefined; + const { totalUnread } = metrics; + + return { + haveNewest, + haveOldest, + isLoadingMessages, + loadCountdownStart, + items, + messageHeightChanges, + oldestUnreadIndex: + oldestUnreadIndex && oldestUnreadIndex >= 0 + ? oldestUnreadIndex + : undefined, + resetCounter, + scrollToIndex: + scrollToIndex && scrollToIndex >= 0 ? scrollToIndex : undefined, + scrollToIndexCounter: scrollToMessageCounter, + totalUnread, + }; +} + +type CachedConversationMessagesSelectorType = ( + conversation: ConversationMessageType +) => TimelinePropsType; +export const getCachedSelectorForConversationMessages = createSelector( + getRegionCode, + getUserNumber, + (): CachedConversationMessagesSelectorType => { + // Note: memoizee will check all parameters provided, and only run our selector + // if any of them have changed. + return memoizee(_conversationMessagesSelector, { max: 50 }); + } +); + +export const getConversationMessagesSelector = createSelector( + getCachedSelectorForConversationMessages, + getMessagesByConversation, + ( + conversationMessagesSelector: CachedConversationMessagesSelectorType, + messagesByConversation: MessagesByConversationType + ) => { + return (id: string): TimelinePropsType | undefined => { + const conversation = messagesByConversation[id]; + if (!conversation) { + return; + } + + return conversationMessagesSelector(conversation); }; } ); diff --git a/ts/state/selectors/search.ts b/ts/state/selectors/search.ts index f638006b83..6d0a09415d 100644 --- a/ts/state/selectors/search.ts +++ b/ts/state/selectors/search.ts @@ -1,5 +1,6 @@ import { compact } from 'lodash'; import { createSelector } from 'reselect'; +import { getSearchResultsProps } from '../../shims/Whisper'; import { StateType } from '../reducer'; @@ -79,14 +80,16 @@ export const getSearchResults = createSelector( ), hideMessagesHeader: false, messages: state.messages.map(message => { + const props = getSearchResultsProps(message); + if (message.id === selectedMessage) { return { - ...message, + ...props, isSelected: true, }; } - return message; + return props; }), regionCode: regionCode, searchTerm: state.query, diff --git a/ts/state/smart/LastSeenIndicator.tsx b/ts/state/smart/LastSeenIndicator.tsx new file mode 100644 index 0000000000..55f613f4e4 --- /dev/null +++ b/ts/state/smart/LastSeenIndicator.tsx @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; + +import { LastSeenIndicator } from '../../components/conversation/LastSeenIndicator'; + +import { StateType } from '../reducer'; +import { getIntl } from '../selectors/user'; +import { getConversationMessagesSelector } from '../selectors/conversations'; + +type ExternalProps = { + id: string; +}; + +const mapStateToProps = (state: StateType, props: ExternalProps) => { + const { id } = props; + + const conversation = getConversationMessagesSelector(state)(id); + if (!conversation) { + throw new Error(`Did not find conversation ${id} in state!`); + } + + const { totalUnread } = conversation; + + return { + count: totalUnread, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartLastSeenIndicator = smart(LastSeenIndicator); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index 6ee0c97184..86c7c48751 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -14,6 +14,10 @@ import { SmartMainHeader } from './MainHeader'; // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 const FilteredSmartMainHeader = SmartMainHeader as any; +function renderMainHeader(): JSX.Element { + return ; +} + const mapStateToProps = (state: StateType) => { const showSearch = isSearching(state); @@ -25,7 +29,7 @@ const mapStateToProps = (state: StateType) => { searchResults, showArchived: getShowArchived(state), i18n: getIntl(state), - renderMainHeader: () => , + renderMainHeader, }; }; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 1a67b2fdef..fe16aac994 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -1,3 +1,4 @@ +import { pick } from 'lodash'; import React from 'react'; import { connect } from 'react-redux'; import { mapDispatchToProps } from '../actions'; @@ -5,32 +6,59 @@ import { Timeline } from '../../components/conversation/Timeline'; import { StateType } from '../reducer'; import { getIntl } from '../selectors/user'; -import { getConversationSelector } from '../selectors/conversations'; +import { + getConversationMessagesSelector, + getConversationSelector, +} from '../selectors/conversations'; import { SmartTimelineItem } from './TimelineItem'; +import { SmartTypingBubble } from './TypingBubble'; +import { SmartLastSeenIndicator } from './LastSeenIndicator'; +import { SmartTimelineLoadingRow } from './TimelineLoadingRow'; // Workaround: A react component's required properties are filtering up through connect() // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 const FilteredSmartTimelineItem = SmartTimelineItem as any; +const FilteredSmartTypingBubble = SmartTypingBubble as any; +const FilteredSmartLastSeenIndicator = SmartLastSeenIndicator as any; +const FilteredSmartTimelineLoadingRow = SmartTimelineLoadingRow as any; type ExternalProps = { id: string; + + // Note: most action creators are not wired into redux; for now they + // are provided by ConversationView in setupTimeline(). }; -const mapStateToProps = (state: StateType, props: ExternalProps) => { - const { id } = props; +function renderItem(messageId: string, actionProps: Object): JSX.Element { + return ; +} +function renderLastSeenIndicator(id: string): JSX.Element { + return ; +} +function renderLoadingRow(id: string): JSX.Element { + return ; +} +function renderTypingBubble(id: string): JSX.Element { + return ; +} - const conversationSelector = getConversationSelector(state); - const conversation = conversationSelector(id); - const items: Array = []; +const mapStateToProps = (state: StateType, props: ExternalProps) => { + const { id, ...actions } = props; + + const conversation = getConversationSelector(state)(id); + const conversationMessages = getConversationMessagesSelector(state)(id); return { - ...conversation, - items, + id, + ...pick(conversation, ['unreadCount', 'typingContact']), + ...conversationMessages, i18n: getIntl(state), - renderTimelineItem: (messageId: string) => { - return ; - }, + renderItem, + renderLastSeenIndicator, + renderLoadingRow, + renderTypingBubble, + ...actions, }; }; diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx index 9801996afd..089f229c55 100644 --- a/ts/state/smart/TimelineItem.tsx +++ b/ts/state/smart/TimelineItem.tsx @@ -14,9 +14,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { const { id } = props; const messageSelector = getMessageSelector(state); + const item = messageSelector(id); return { - ...messageSelector(id), + item, i18n: getIntl(state), }; }; diff --git a/ts/state/smart/TimelineLoadingRow.tsx b/ts/state/smart/TimelineLoadingRow.tsx new file mode 100644 index 0000000000..fa1623d7e9 --- /dev/null +++ b/ts/state/smart/TimelineLoadingRow.tsx @@ -0,0 +1,50 @@ +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; +import { isNumber } from 'lodash'; + +import { + STATE_ENUM, + TimelineLoadingRow, +} from '../../components/conversation/TimelineLoadingRow'; +import { LOAD_COUNTDOWN } from '../../components/conversation/Timeline'; + +import { StateType } from '../reducer'; +import { getIntl } from '../selectors/user'; +import { getConversationMessagesSelector } from '../selectors/conversations'; + +type ExternalProps = { + id: string; +}; + +const mapStateToProps = (state: StateType, props: ExternalProps) => { + const { id } = props; + + const conversation = getConversationMessagesSelector(state)(id); + if (!conversation) { + throw new Error(`Did not find conversation ${id} in state!`); + } + + const { isLoadingMessages, loadCountdownStart } = conversation; + + const loadingState: STATE_ENUM = isLoadingMessages + ? 'loading' + : isNumber(loadCountdownStart) + ? 'countdown' + : 'idle'; + const duration = loadingState === 'countdown' ? LOAD_COUNTDOWN : undefined; + const expiresAt = + loadingState === 'countdown' && loadCountdownStart + ? loadCountdownStart + LOAD_COUNTDOWN + : undefined; + + return { + state: loadingState, + duration, + expiresAt, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartTimelineLoadingRow = smart(TimelineLoadingRow); diff --git a/ts/state/smart/TypingBubble.tsx b/ts/state/smart/TypingBubble.tsx new file mode 100644 index 0000000000..6f89efa4a2 --- /dev/null +++ b/ts/state/smart/TypingBubble.tsx @@ -0,0 +1,30 @@ +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; +import { TypingBubble } from '../../components/conversation/TypingBubble'; +import { StateType } from '../reducer'; + +import { getIntl } from '../selectors/user'; +import { getConversationSelector } from '../selectors/conversations'; + +type ExternalProps = { + id: string; +}; + +const mapStateToProps = (state: StateType, props: ExternalProps) => { + const { id } = props; + + const conversation = getConversationSelector(state)(id); + if (!conversation) { + throw new Error(`Did not find conversation ${id} in state!`); + } + + return { + ...conversation.typingContact, + conversationType: conversation.type, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartTypingBubble = smart(TypingBubble); diff --git a/ts/styleguide/ConversationContext.tsx b/ts/styleguide/ConversationContext.tsx index c59b2fb0b0..4578def7e5 100644 --- a/ts/styleguide/ConversationContext.tsx +++ b/ts/styleguide/ConversationContext.tsx @@ -7,7 +7,6 @@ interface Props { */ ios: boolean; theme: 'light-theme' | 'dark-theme'; - type: 'private' | 'group'; } /** @@ -16,16 +15,17 @@ interface Props { */ export class ConversationContext extends React.Component { public render() { - const { ios, theme, type } = this.props; + const { ios, theme } = this.props; return (
    -
    -
    -
      {this.props.children}
    -
    +
    +
    {this.props.children}
    ); diff --git a/ts/test/state/selectors/conversations_test.ts b/ts/test/state/selectors/conversations_test.ts index c56f50f2aa..52cd60288a 100644 --- a/ts/test/state/selectors/conversations_test.ts +++ b/ts/test/state/selectors/conversations_test.ts @@ -25,7 +25,11 @@ describe('state/selectors/conversations', () => { lastUpdated: Date.now(), unreadCount: 1, isSelected: false, - isTyping: false, + typingContact: { + name: 'Someone There', + color: 'blue', + phoneNumber: '+18005551111', + }, }, id2: { id: 'id2', @@ -40,7 +44,11 @@ describe('state/selectors/conversations', () => { lastUpdated: Date.now(), unreadCount: 1, isSelected: false, - isTyping: false, + typingContact: { + name: 'Someone There', + color: 'blue', + phoneNumber: '+18005551111', + }, }, id3: { id: 'id3', @@ -55,7 +63,11 @@ describe('state/selectors/conversations', () => { lastUpdated: Date.now(), unreadCount: 1, isSelected: false, - isTyping: false, + typingContact: { + name: 'Someone There', + color: 'blue', + phoneNumber: '+18005551111', + }, }, id4: { id: 'id4', @@ -70,7 +82,11 @@ describe('state/selectors/conversations', () => { lastUpdated: Date.now(), unreadCount: 1, isSelected: false, - isTyping: false, + typingContact: { + name: 'Someone There', + color: 'blue', + phoneNumber: '+18005551111', + }, }, id5: { id: 'id5', @@ -85,7 +101,11 @@ describe('state/selectors/conversations', () => { lastUpdated: Date.now(), unreadCount: 1, isSelected: false, - isTyping: false, + typingContact: { + name: 'Someone There', + color: 'blue', + phoneNumber: '+18005551111', + }, }, }; const comparator = _getConversationComparator(i18n, regionCode); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1a523d7c96..39f3b1f10b 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -164,17 +164,17 @@ "rule": "jQuery-load(", "path": "js/conversation_controller.js", "line": " async load() {", - "lineNumber": 178, + "lineNumber": 169, "reasonCategory": "falseMatch", - "updated": "2018-10-02T21:00:44.007Z" + "updated": "2019-07-31T00:19:18.696Z" }, { "rule": "jQuery-load(", "path": "js/conversation_controller.js", "line": " this._initialPromise = load();", - "lineNumber": 213, + "lineNumber": 204, "reasonCategory": "falseMatch", - "updated": "2018-10-02T21:00:44.007Z" + "updated": "2019-07-31T00:19:18.696Z" }, { "rule": "jQuery-$(", @@ -363,8 +363,8 @@ "line": " this.$el.append(this.contactView.el);", "lineNumber": 46, "reasonCategory": "usageTrusted", - "updated": "2018-10-02T21:18:39.026Z", - "reasonDetail": "Operating on previously-existing DOM elements" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-$(", @@ -474,148 +474,139 @@ "updated": "2018-09-15T00:38:04.183Z" }, { - "rule": "jQuery-$(", + "rule": "jQuery-appendTo(", "path": "js/views/inbox_view.js", - "line": " let $el = this.$(`#${id}`);", - "lineNumber": 34, + "line": " view.$el.appendTo(this.el);", + "lineNumber": 32, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", - "reasonDetail": "Protected from arbitrary input" - }, - { - "rule": "jQuery-prependTo(", - "path": "js/views/inbox_view.js", - "line": " $el.prependTo(this.el);", - "lineNumber": 43, - "reasonCategory": "usageTrusted", - "updated": "2018-09-19T18:13:29.628Z", - "reasonDetail": "Interacting with already-existing DOM nodes" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.message').text(message);", - "lineNumber": 61, + "lineNumber": 58, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " el: this.$('.conversation-stack'),", - "lineNumber": 78, + "lineNumber": 75, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-prependTo(", "path": "js/views/inbox_view.js", "line": " this.appLoadingScreen.$el.prependTo(this.el);", - "lineNumber": 85, + "lineNumber": 82, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T18:13:29.628Z", - "reasonDetail": "Interacting with already-existing DOM nodes" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-append(", "path": "js/views/inbox_view.js", "line": " .append(this.networkStatusView.render().el);", - "lineNumber": 100, + "lineNumber": 97, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T18:13:29.628Z", - "reasonDetail": "Interacting with already-existing DOM nodes" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-prependTo(", "path": "js/views/inbox_view.js", "line": " banner.$el.prependTo(this.$el);", - "lineNumber": 104, + "lineNumber": 101, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T18:13:29.628Z", - "reasonDetail": "Interacting with already-existing DOM nodes" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-appendTo(", "path": "js/views/inbox_view.js", "line": " toast.$el.appendTo(this.$el);", - "lineNumber": 110, + "lineNumber": 107, "reasonCategory": "usageTrusted", - "updated": "2019-05-10T00:25:51.515Z", - "reasonDetail": "Interacting with already-existing DOM nodes" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", - "lineNumber": 130, + "lineNumber": 126, "reasonCategory": "usageTrusted", - "updated": "2019-03-08T23:49:08.796Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-append(", "path": "js/views/inbox_view.js", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", - "lineNumber": 130, + "lineNumber": 126, "reasonCategory": "usageTrusted", - "updated": "2019-03-08T23:49:08.796Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " if (e && this.$(e.target).closest('.placeholder').length) {", - "lineNumber": 171, + "lineNumber": 167, "reasonCategory": "usageTrusted", - "updated": "2019-03-08T23:49:08.796Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('#header, .gutter').addClass('inactive');", - "lineNumber": 175, + "lineNumber": 171, "reasonCategory": "usageTrusted", - "updated": "2019-03-08T23:49:08.796Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation-stack').addClass('inactive');", - "lineNumber": 179, + "lineNumber": 175, "reasonCategory": "usageTrusted", - "updated": "2019-03-08T23:49:08.796Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation:first .menu').trigger('close');", - "lineNumber": 181, + "lineNumber": 177, "reasonCategory": "usageTrusted", - "updated": "2019-03-08T23:49:08.796Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {", - "lineNumber": 201, + "lineNumber": 197, "reasonCategory": "usageTrusted", - "updated": "2019-03-08T23:49:08.796Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation:first .recorder').trigger('close');", - "lineNumber": 204, + "lineNumber": 200, "reasonCategory": "usageTrusted", - "updated": "2019-03-08T23:49:08.796Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-$(", @@ -722,8 +713,8 @@ "line": " new QRCode(this.$('.qr')[0]).makeCode(", "lineNumber": 39, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-wrap(", @@ -731,7 +722,7 @@ "line": " dcodeIO.ByteBuffer.wrap(this.ourKey).toString('base64')", "lineNumber": 40, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-07-31T00:19:18.696Z" }, { "rule": "jQuery-insertBefore(", @@ -739,8 +730,8 @@ "line": " dialog.$el.insertBefore(this.el);", "lineNumber": 75, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T18:13:29.628Z", - "reasonDetail": "Interacting with already-existing DOM nodes" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Known DOM elements" }, { "rule": "jQuery-$(", @@ -748,8 +739,8 @@ "line": " this.$('button.verify').attr('disabled', true);", "lineNumber": 79, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-$(", @@ -757,8 +748,8 @@ "line": " this.$('button.verify').removeAttr('disabled');", "lineNumber": 110, "reasonCategory": "usageTrusted", - "updated": "2018-09-19T21:59:32.770Z", - "reasonDetail": "Protected from arbitrary input" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Hardcoded selector" }, { "rule": "jQuery-append(", @@ -778,105 +769,6 @@ "updated": "2018-09-15T00:38:04.183Z", "reasonDetail": "Hard-coded value" }, - { - "rule": "jQuery-$(", - "path": "js/views/message_list_view.js", - "line": " template: $('#message-list').html(),", - "lineNumber": 13, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "Parameter is a hard-coded string" - }, - { - "rule": "jQuery-html(", - "path": "js/views/message_list_view.js", - "line": " template: $('#message-list').html(),", - "lineNumber": 13, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "This is run at JS load time, which means we control the contents of the target element" - }, - { - "rule": "jQuery-$(", - "path": "js/views/message_list_view.js", - "line": " this.$messages = this.$('.messages');", - "lineNumber": 30, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "Parameter is a hard-coded string" - }, - { - "rule": "jQuery-append(", - "path": "js/views/message_list_view.js", - "line": " this.$messages.append(view.el);", - "lineNumber": 111, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "view.el is a known DOM element" - }, - { - "rule": "jQuery-prepend(", - "path": "js/views/message_list_view.js", - "line": " this.$messages.prepend(view.el);", - "lineNumber": 114, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "view.el is a known DOM element" - }, - { - "rule": "jQuery-$(", - "path": "js/views/message_list_view.js", - "line": " const next = this.$(`#${this.collection.at(index + 1).id}`);", - "lineNumber": 117, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "Message ids are GUIDs, and therefore the resultant string for $() is an id" - }, - { - "rule": "jQuery-insertBefore(", - "path": "js/views/message_list_view.js", - "line": " view.$el.insertBefore(next);", - "lineNumber": 120, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "next is a known DOM element" - }, - { - "rule": "jQuery-insertAfter(", - "path": "js/views/message_list_view.js", - "line": " view.$el.insertAfter(prev);", - "lineNumber": 122, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "prev is a known DOM element" - }, - { - "rule": "jQuery-insertBefore(", - "path": "js/views/message_list_view.js", - "line": " view.$el.insertBefore(elements[i]);", - "lineNumber": 131, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "elements[i] is a known DOM element" - }, - { - "rule": "jQuery-append(", - "path": "js/views/message_list_view.js", - "line": " this.$messages.append(view.el);", - "lineNumber": 136, - "reasonCategory": "usageTrusted", - "updated": "2018-11-14T18:51:15.180Z", - "reasonDetail": "view.el is a known DOM element" - }, - { - "rule": "jQuery-append(", - "path": "js/views/message_view.js", - "line": " this.$el.append(this.childView.el);", - "lineNumber": 144, - "reasonCategory": "usageTrusted", - "updated": "2018-09-19T18:13:29.628Z", - "reasonDetail": "Interacting with already-existing DOM nodes" - }, { "rule": "jQuery-$(", "path": "js/views/phone-input-view.js", @@ -1453,6 +1345,45 @@ "reasonCategory": "falseMatch", "updated": "2018-09-15T00:38:04.183Z" }, + { + "rule": "jQuery-$(", + "path": "node_modules/@yarnpkg/lockfile/index.js", + "lineNumber": 6546, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-load(", + "path": "node_modules/@yarnpkg/lockfile/index.js", + "line": "function load() {", + "lineNumber": 8470, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-load(", + "path": "node_modules/@yarnpkg/lockfile/index.js", + "line": "exports.enable(load());", + "lineNumber": 8488, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-load(", + "path": "node_modules/@yarnpkg/lockfile/index.js", + "line": "function load() {", + "lineNumber": 8689, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-load(", + "path": "node_modules/@yarnpkg/lockfile/index.js", + "line": "exports.enable(load());", + "lineNumber": 8713, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, { "rule": "jQuery-after(", "path": "node_modules/archiver-utils/node_modules/lodash/after.js", @@ -3590,20 +3521,36 @@ "updated": "2018-09-19T18:13:29.628Z" }, { - "rule": "jQuery-wrap(", - "path": "node_modules/extglob/index.js", - "line": " o[id] = wrap(inner, prefix, opts.escape);", - "lineNumber": 85, + "rule": "jQuery-load(", + "path": "node_modules/extglob/node_modules/debug/src/browser.js", + "line": "function load() {", + "lineNumber": 150, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-07-31T00:19:18.696Z" }, { - "rule": "jQuery-wrap(", - "path": "node_modules/extglob/index.js", - "line": "function wrap(inner, prefix, esc) {", - "lineNumber": 119, + "rule": "jQuery-load(", + "path": "node_modules/extglob/node_modules/debug/src/browser.js", + "line": "exports.enable(load());", + "lineNumber": 168, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-load(", + "path": "node_modules/extglob/node_modules/debug/src/node.js", + "line": "function load() {", + "lineNumber": 156, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-load(", + "path": "node_modules/extglob/node_modules/debug/src/node.js", + "line": "exports.enable(load());", + "lineNumber": 248, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" }, { "rule": "DOM-innerHTML", @@ -5219,46 +5166,6 @@ "reasonCategory": "falseMatch", "updated": "2018-09-19T18:06:35.446Z" }, - { - "rule": "jQuery-before(", - "path": "node_modules/micromatch/node_modules/braces/index.js", - "line": " str = tokens.before(str, es6Regex());", - "lineNumber": 92, - "reasonCategory": "falseMatch", - "updated": "2018-09-15T00:38:04.183Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/micromatch/node_modules/braces/index.js", - "line": " return braces(str.replace(outter, wrap(segs, '|')), opts);", - "lineNumber": 121, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/micromatch/node_modules/braces/index.js", - "line": " segs[0] = wrap(segs[0], '\\\\');", - "lineNumber": 126, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" - }, - { - "rule": "jQuery-after(", - "path": "node_modules/micromatch/node_modules/braces/index.js", - "line": " arr.push(es6 ? tokens.after(val) : val);", - "lineNumber": 150, - "reasonCategory": "falseMatch", - "updated": "2018-09-15T00:38:04.183Z" - }, - { - "rule": "jQuery-wrap(", - "path": "node_modules/micromatch/node_modules/braces/index.js", - "line": "function wrap(val, ch) {", - "lineNumber": 216, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" - }, { "rule": "DOM-innerHTML", "path": "node_modules/min-document/serialize.js", @@ -7161,6 +7068,62 @@ "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, + { + "rule": "jQuery-before(", + "path": "node_modules/test-exclude/node_modules/braces/index.js", + "line": " str = tokens.before(str, es6Regex());", + "lineNumber": 92, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-wrap(", + "path": "node_modules/test-exclude/node_modules/braces/index.js", + "line": " return braces(str.replace(outter, wrap(segs, '|')), opts);", + "lineNumber": 121, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-wrap(", + "path": "node_modules/test-exclude/node_modules/braces/index.js", + "line": " segs[0] = wrap(segs[0], '\\\\');", + "lineNumber": 126, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-after(", + "path": "node_modules/test-exclude/node_modules/braces/index.js", + "line": " arr.push(es6 ? tokens.after(val) : val);", + "lineNumber": 150, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-wrap(", + "path": "node_modules/test-exclude/node_modules/braces/index.js", + "line": "function wrap(val, ch) {", + "lineNumber": 216, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-wrap(", + "path": "node_modules/test-exclude/node_modules/extglob/index.js", + "line": " o[id] = wrap(inner, prefix, opts.escape);", + "lineNumber": 85, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, + { + "rule": "jQuery-wrap(", + "path": "node_modules/test-exclude/node_modules/extglob/index.js", + "line": "function wrap(inner, prefix, esc) {", + "lineNumber": 119, + "reasonCategory": "falseMatch", + "updated": "2019-07-31T00:19:18.696Z" + }, { "rule": "eval", "path": "node_modules/thenify/index.js", @@ -7849,8 +7812,8 @@ "line": " this.menuTriggerRef = react_1.default.createRef();", "lineNumber": 14, "reasonCategory": "usageTrusted", - "updated": "2019-03-09T00:08:44.242Z", - "reasonDetail": "Used only to trigger menu display" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Used to reference popup menu" }, { "rule": "React-createRef", @@ -7858,17 +7821,17 @@ "line": " this.menuTriggerRef = React.createRef();", "lineNumber": 59, "reasonCategory": "usageTrusted", - "updated": "2019-03-09T00:08:44.242Z", - "reasonDetail": "Used only to trigger menu display" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Used to reference popup menu" }, { "rule": "React-createRef", "path": "ts/components/conversation/Timeline.js", "line": " this.listRef = react_1.default.createRef();", - "lineNumber": 17, + "lineNumber": 27, "reasonCategory": "usageTrusted", - "updated": "2019-04-17T18:44:33.207Z", - "reasonDetail": "Necessary to interact with child react-virtualized/List" + "updated": "2019-07-31T00:19:18.696Z", + "reasonDetail": "Timeline needs to interact with its child List directly" }, { "rule": "jQuery-wrap(", diff --git a/tslint.json b/tslint.json index f5357882dc..811be49ef3 100644 --- a/tslint.json +++ b/tslint.json @@ -93,7 +93,13 @@ "allow-pascal-case" ], - "function-name": [true, { "function-regex": "^_?[a-z][\\w\\d]+$" }], + "function-name": [ + true, + { + "function-regex": "^_?[a-z][\\w\\d]+$", + "static-method-regex": "^_?[a-z][\\w\\d]+$" + } + ], // Adding select dev dependencies here for now, may turn on all in the future "no-implicit-dependencies": [true, ["dashdash", "electron"]], diff --git a/yarn.lock b/yarn.lock index d1c801cbdf..3004dc16da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -313,6 +313,11 @@ dependencies: common-tags "^1.7.2" +"@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + abbrev@1: version "1.1.0" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f" @@ -2067,7 +2072,7 @@ cross-spawn@^4: lru-cache "^4.0.1" which "^1.2.9" -cross-spawn@^6.0.0: +cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== @@ -3526,6 +3531,14 @@ find-up@^3.0.0: dependencies: locate-path "^3.0.0" +find-yarn-workspace-root@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-1.2.1.tgz#40eb8e6e7c2502ddfaa2577c176f221422f860db" + integrity sha512-dVtfb0WuQG+8Ag2uWkbG79hOUzEsRrhBzgfn86g2sJPkzmcpGdghbNTfUKGTxymFrY/tLIodDzLoW9nOJ4FY8Q== + dependencies: + fs-extra "^4.0.3" + micromatch "^3.1.4" + findup-sync@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.3.0.tgz#37930aa5d816b777c03445e1966cc6790a4c0b16" @@ -3684,6 +3697,15 @@ fs-extra@^2.0.0: graceful-fs "^4.1.2" jsonfile "^2.1.0" +fs-extra@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" + integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg== + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + fs-extra@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9" @@ -3916,6 +3938,18 @@ glob@^7.0.3, glob@~7.0.0: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.1.3: + version "7.1.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" + integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@~5.0.0: version "5.0.15" resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1" @@ -5402,6 +5436,13 @@ kind-of@^6.0.0, kind-of@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" +klaw-sync@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" + integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== + dependencies: + graceful-fs "^4.1.11" + klaw@^1.0.0: version "1.3.1" resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.1.tgz#4088433b46b3b1ba259d78785d8e96f73ba02439" @@ -6935,6 +6976,25 @@ pascalcase@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" +patch-package@6.1.2: + version "6.1.2" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-6.1.2.tgz#9ed0b3defb5c34ecbef3f334ddfb13e01b3d3ff6" + integrity sha512-5GnzR8lEyeleeariG+hGabUnD2b1yL7AIGFjlLo95zMGRWhZCel58IpeKD46wwPb7i+uNhUI8unV56ogk8Bgqg== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^2.4.2" + cross-spawn "^6.0.5" + find-yarn-workspace-root "^1.2.1" + fs-extra "^7.0.1" + is-ci "^2.0.0" + klaw-sync "^6.0.0" + minimist "^1.2.0" + rimraf "^2.6.3" + semver "^5.6.0" + slash "^2.0.0" + tmp "^0.0.33" + update-notifier "^2.5.0" + path-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" @@ -8514,6 +8574,13 @@ rimraf@2.6.2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2: dependencies: glob "^7.0.5" +rimraf@^2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" + integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + dependencies: + glob "^7.1.3" + rimraf@~2.4.0: version "2.4.5" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" @@ -8791,6 +8858,11 @@ slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + slice-ansi@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d"