diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e4b2415ad19e..8ffe46b5e0b3 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1583,6 +1583,10 @@ "message": "Note to Self", "description": "Name for the conversation with your own phone number" }, + "noteToSelfHero": { + "message": "You can add notes for yourself in this conversation. If your account has any linked devices, new notes will be synced.", + "description": "Description for the Note to Self conversation" + }, "hideMenuBar": { "message": "Hide menu bar", "description": "Label text for menu bar visibility setting" @@ -2315,5 +2319,219 @@ "ReactionsViewer--all": { "message": "All", "description": "Shown in reaction viewer as the title for the 'all' category" + }, + "MessageRequests--message-direct": { + "message": "Do you want to let $name$ message you? They won't know you've seen their message until you accept.", + "description": "Shown as the message for a message request in a direct message", + "placeholders": { + "name": { + "content": "$1", + "example": "Cayce Pollard" + } + } + }, + "MessageRequests--message-direct-blocked": { + "message": "Unblock $name$ to message and call each other.", + "description": "Shown as the message for a message request in a direct message with a blocked account", + "placeholders": { + "name": { + "content": "$1", + "example": "Cayce Pollard" + } + } + }, + "MessageRequests--message-group": { + "message": "Do you want to join $group$? They won't know you've seen their message until you accept.", + "description": "Shown as the message for a message request in a group", + "placeholders": { + "name": { + "content": "$1", + "example": "Cayce Pollard" + } + } + }, + "MessageRequests--message-group-blocked": { + "message": "Unblock to allow group members to add you to this group again.", + "description": "Shown as the message for a message request in a blocked group" + }, + "MessageRequests--block": { + "message": "Block", + "description": "Shown as a button to let the user block a message request" + }, + "MessageRequests--unblock": { + "message": "Unblock", + "description": "Shown as a button to let the user unblock a message request" + }, + "MessageRequests--unblock-confirm-title": { + "message": "Unblock $name$?", + "description": "Shown as a button to let the user unblock a message request", + "placeholders": { + "name": { + "content": "$1", + "example": "Cayce Pollard" + } + } + }, + "MessageRequests--unblock-direct-confirm-body": { + "message": "You will be able to message and call each other.", + "description": "Shown as the body in the confirmation modal for unblocking a private message request", + "placeholders": { + "name": { + "content": "$1", + "example": "Cayce Pollard" + } + } + }, + "MessageRequests--unblock-group-confirm-body": { + "message": "Group members will be able to add your to this group again.", + "description": "Shown as the body in the confirmation modal for unblocking a group message request", + "placeholders": { + "name": { + "content": "$1", + "example": "Cayce Pollard" + } + } + }, + "MessageRequests--block-and-delete": { + "message": "Block and Delete", + "description": "Shown as a button to let the user block and delete a message request" + }, + "MessageRequests--block-direct-confirm-title": { + "message": "Block $name$?", + "description": "Shown as the title in the confirmation modal for blocking a private message request", + "placeholders": { + "name": { + "content": "$1", + "example": "Cayce Pollard" + } + } + }, + "MessageRequests--block-direct-confirm-body": { + "message": "Blocked people won't be able to call you or send you mesages.", + "description": "Shown as the body in the confirmation modal for blocking a private message request" + }, + "MessageRequests--block-group-confirm-title": { + "message": "Block and Leave $group$?", + "description": "Shown as the title in the confirmation modal for blocking a group message request", + "placeholders": { + "group": { + "content": "$1", + "example": "Friends 🌿" + } + } + }, + "MessageRequests--block-group-confirm-body": { + "message": "You will no longer receive messages or updates from this group and members won't be able to add you to this group again.", + "description": "Shown as the body in the confirmation modal for blocking a group message request" + }, + "MessageRequests--delete": { + "message": "Delete", + "description": "Shown as a button to let the user delete any message request" + }, + "MessageRequests--delete-direct-confirm-title": { + "message": "Delete conversation?", + "description": "Shown as the title in the confirmation modal for deleting a private message request" + }, + "MessageRequests--delete-direct-confirm-body": { + "message": "This conversation will be deleted from all of your devices.", + "description": "Shown as the body in the confirmation modal for deleting a private message request" + }, + "MessageRequests--delete-group-confirm-title": { + "message": "Delete and Leave $group$?", + "description": "Shown as the title in the confirmation modal for deleting a group message request", + "placeholders": { + "group": { + "content": "$1", + "example": "Friends 🌿" + } + } + }, + "MessageRequests--delete-direct": { + "message": "Delete", + "description": "Shown as a button to let the user delete a direct message request" + }, + "MessageRequests--delete-group": { + "message": "Delete and Leave", + "description": "Shown as a button to let the user delete a group message request" + }, + "MessageRequests--delete-group-confirm-body": { + "message": "You will leave this group, and it will be deleted from all your devices.", + "description": "Shown as the body in the confirmation modal for deleting a group message request" + }, + "MessageRequests--accept": { + "message": "Accept", + "description": "Shown as a button to let the user accept a message request" + }, + "ConversationHero--members": { + "message": "$count$ members", + "description": "Specifies the number of members in a group conversation", + "placeholders": { + "count": { + "content": "$1", + "example": "22" + } + } + }, + "ConversationHero--members-1": { + "message": "1 member", + "description": "Specifies the number of members in a group conversation when there is one member", + "placeholders": { + "count": { + "content": "$1", + "example": "22" + } + } + }, + "ConversationHero--membership-1": { + "message": "Member of $group$.", + "description": "Shown in the conversation hero to indicate this user is a member of a mutual group", + "placeholders": { + "group": { + "content": "$1", + "example": "NYC Rock Climbers" + } + } + }, + "ConversationHero--membership-2": { + "message": "Member of $group1$ and $group2$.", + "description": "Shown in the conversation hero to indicate this user is a member of at least two mutual groups", + "placeholders": { + "group1": { + "content": "$1", + "example": "NYC Rock Climbers" + }, + "group2": { + "content": "$2", + "example": "Dinner Party" + } + } + }, + "ConversationHero--membership-3": { + "message": "Member of $group1$, $group2$, and $group3$.", + "description": "Shown in the conversation hero to indicate this user is a member of at least three mutual groups", + "placeholders": { + "group1": { + "content": "$1", + "example": "NYC Rock Climbers" + }, + "group2": { + "content": "$2", + "example": "Dinner Party" + }, + "group3": { + "content": "$3", + "example": "Friends 🌿" + } + } + }, + "ConversationHero--membership-added": { + "message": "$name$ added you to the group.", + "description": "Shown Indicates that you were added to a group by a given individual.", + "placeholders": { + "name": { + "content": "$1", + "example": "Jeff Smith" + } + } } } diff --git a/background.html b/background.html index 9a5a2f9d8b5e..3ae938283a2f 100644 --- a/background.html +++ b/background.html @@ -355,6 +355,7 @@ + diff --git a/fixtures/kitten-4-112-112.jpg b/fixtures/kitten-4-112-112.jpg new file mode 100644 index 000000000000..303878bf142f Binary files /dev/null and b/fixtures/kitten-4-112-112.jpg differ diff --git a/js/background.js b/js/background.js index 0572dca0880e..c241fe4668ca 100644 --- a/js/background.js +++ b/js/background.js @@ -1479,6 +1479,34 @@ }); } }); + + window.Signal.RemoteConfig.initRemoteConfig(); + + // Maybe refresh remote configuration when we become active + window.registerForActive(async () => { + await window.Signal.RemoteConfig.maybeRefreshRemoteConfig(); + }); + + // Listen for changes to the `desktop.messageRequests` remote configuration flag + const removeMessageRequestListener = window.Signal.RemoteConfig.onChange( + 'desktop.messageRequests', + ({ enabled }) => { + if (!enabled) { + return; + } + + const conversations = window.getConversations(); + conversations.forEach(conversation => { + conversation.set({ + messageCountBeforeMessageRequests: + conversation.get('messageCount') || 0, + }); + window.Signal.Data.updateConversation(conversation.attributes); + }); + + removeMessageRequestListener(); + } + ); } window.getSyncRequest = () => @@ -1629,6 +1657,8 @@ addQueuedEventListener('typing', onTyping); addQueuedEventListener('sticker-pack', onStickerPack); addQueuedEventListener('viewSync', onViewSync); + addQueuedEventListener('messageRequestResponse', onMessageRequestResponse); + addQueuedEventListener('profileKeyUpdate', onProfileKeyUpdate); window.Signal.AttachmentDownloads.start({ getMessageReceiver: () => messageReceiver, @@ -2258,7 +2288,10 @@ const result = ConversationController.getOrCreate( messageDescriptor.id, - messageDescriptor.type + messageDescriptor.type, + messageDescriptor.type === 'group' + ? { addedBy: message.getContact().get('id') } + : undefined ); if (messageDescriptor.type === 'private') { @@ -2306,6 +2339,41 @@ return Promise.resolve(); } + async function onProfileKeyUpdate({ data, confirm }) { + const conversation = ConversationController.get( + data.source || data.sourceUuid + ); + + if (!conversation) { + window.log.error( + 'onProfileKeyUpdate: could not find conversation', + data.source, + data.sourceUuid + ); + confirm(); + return; + } + + if (!data.profileKey) { + window.log.error( + 'onProfileKeyUpdate: missing profileKey', + data.profileKey + ); + confirm(); + return; + } + + window.log.info( + 'onProfileKeyUpdate: updating profileKey', + data.source, + data.sourceUuid + ); + + await conversation.setProfileKey(data.profileKey); + + confirm(); + } + async function handleMessageSentProfileUpdate({ data, confirm, @@ -2318,7 +2386,7 @@ type ); - conversation.set({ profileSharing: true }); + conversation.enableProfileSharing(); window.Signal.Data.updateConversation(conversation.attributes); // Then we update our own profileKey if it's different from what we have @@ -2635,6 +2703,25 @@ Whisper.ViewSyncs.onSync(sync); } + async function onMessageRequestResponse(ev) { + ev.confirm(); + + const { threadE164, threadUuid, groupId, messageRequestResponseType } = ev; + + const args = { + threadE164, + threadUuid, + groupId, + type: messageRequestResponseType, + }; + + window.log.info('message request response', args); + + const sync = Whisper.MessageRequests.add(args); + + Whisper.MessageRequests.onResponse(sync); + } + function onReadReceipt(ev) { const readAt = ev.timestamp; const { timestamp } = ev.read; diff --git a/js/message_requests.js b/js/message_requests.js new file mode 100644 index 000000000000..d2c4cb97e75a --- /dev/null +++ b/js/message_requests.js @@ -0,0 +1,91 @@ +/* global + Backbone, + Whisper, + ConversationController, +*/ + +/* eslint-disable more/no-then */ + +// eslint-disable-next-line func-names +(function() { + 'use strict'; + + window.Whisper = window.Whisper || {}; + Whisper.MessageRequests = new (Backbone.Collection.extend({ + forConversation(conversation) { + if (conversation.get('e164')) { + const syncByE164 = this.findWhere({ + e164: conversation.get('e164'), + }); + if (syncByE164) { + window.log.info( + `Found early message request response for E164 ${conversation.get( + 'e164' + )}` + ); + this.remove(syncByE164); + return syncByE164; + } + } + + if (conversation.get('uuid')) { + const syncByUuid = this.findWhere({ + uuid: conversation.get('uuid'), + }); + if (syncByUuid) { + window.log.info( + `Found early message request response for UUID ${conversation.get( + 'uuid' + )}` + ); + this.remove(syncByUuid); + return syncByUuid; + } + } + + if (conversation.get('groupId')) { + const syncByGroupId = this.findWhere({ + uuid: conversation.get('groupId'), + }); + if (syncByGroupId) { + window.log.info( + `Found early message request response for GROUP ID ${conversation.get( + 'groupId' + )}` + ); + this.remove(syncByGroupId); + return syncByGroupId; + } + } + + return null; + }, + async onResponse(sync) { + try { + const threadE164 = sync.get('threadE164'); + const threadUuid = sync.get('threadUuid'); + const groupId = sync.get('groupId'); + const identifier = threadE164 || threadUuid || groupId; + const conversation = ConversationController.get(identifier); + + if (!conversation) { + window.log( + `Received message request response for unknown conversation: ${identifier}` + ); + return; + } + + conversation.applyMessageRequestResponse(sync.get('type'), { + fromSync: true, + }); + + this.remove(sync); + } catch (error) { + window.log.error( + 'MessageRequests.onResponse error:', + error && error.stack ? error.stack : error + ); + } + }, + }))(); +})(); diff --git a/js/models/blockedNumbers.js b/js/models/blockedNumbers.js index 02c7326d983e..35ef61f22b60 100644 --- a/js/models/blockedNumbers.js +++ b/js/models/blockedNumbers.js @@ -1,4 +1,4 @@ -/* global storage, _ */ +/* global storage, _, ConversationController */ // eslint-disable-next-line func-names (function() { @@ -79,4 +79,48 @@ window.log.info(`removing group(${groupId} from blocked list`); storage.put(BLOCKED_GROUPS_ID, _.without(groupIds, groupId)); }; + + /** + * Optimistically adds a conversation to our local block list. + * @param {string} id + */ + storage.blockIdentifier = id => { + const conv = ConversationController.get(id); + if (conv) { + const uuid = conv.get('uuid'); + if (uuid) { + storage.addBlockedUuid(uuid); + } + const e164 = conv.get('e164'); + if (e164) { + storage.addBlockedNumber(e164); + } + const groupId = conv.get('groupId'); + if (groupId) { + storage.addBlockedGroup(groupId); + } + } + }; + + /** + * Optimistically removes a conversation from our local block list. + * @param {string} id + */ + storage.unblockIdentifier = id => { + const conv = ConversationController.get(id); + if (conv) { + const uuid = conv.get('uuid'); + if (uuid) { + storage.removeBlockedUuid(uuid); + } + const e164 = conv.get('e164'); + if (e164) { + storage.removeBlockedNumber(e164); + } + const groupId = conv.get('groupId'); + if (groupId) { + storage.removeBlockedGroup(groupId); + } + } + }; })(); diff --git a/js/models/conversations.js b/js/models/conversations.js index cedaeec70912..580d6dac6f1c 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -8,7 +8,8 @@ libsignal, storage, textsecure, - Whisper + Whisper, + Signal */ /* eslint-disable more/no-then */ @@ -60,6 +61,8 @@ return { unreadCount: 0, verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, + messageCount: 0, + sentMessageCount: 0, }; }, @@ -97,6 +100,8 @@ this.ourNumber = textsecure.storage.user.getNumber(); this.ourUuid = textsecure.storage.user.getUuid(); this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus; + this.messageRequestEnum = + textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; // This may be overridden by ConversationController.getOrCreate, and signify // our first save to the database. Or first fetch from the database. @@ -164,6 +169,52 @@ ); }, + isBlocked() { + const uuid = this.get('uuid'); + if (uuid) { + return window.storage.isUuidBlocked(uuid); + } + + const e164 = this.get('e164'); + if (e164) { + return window.storage.isBlocked(e164); + } + + const groupId = this.get('groupId'); + if (groupId) { + return window.storage.isGroupBlocked(groupId); + } + + return false; + }, + + unblock() { + const uuid = this.get('uuid'); + if (uuid) { + return window.storage.removeBlockedUuid(uuid); + } + + const e164 = this.get('e164'); + if (e164) { + return window.storage.removeBlockedNumber(e164); + } + + const groupId = this.get('groupId'); + if (groupId) { + return window.storage.removeBlockedGroup(groupId); + } + + return false; + }, + + enableProfileSharing() { + this.set({ profileSharing: true }); + }, + + disableProfileSharing() { + this.set({ profileSharing: false }); + }, + hasDraft() { const draftAttachments = this.get('draftAttachments') || []; return ( @@ -320,6 +371,10 @@ this.messageCollection.remove(id); existing.trigger('expired'); existing.cleanup(); + + // An expired message only counts as decrementing the message count, not + // the sent message count + this.decrementMessageCount(); }; // If a fetch is in progress, then we need to wait until that's complete to @@ -390,11 +445,15 @@ const shouldShowDraft = this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp; const inboxPosition = this.get('inbox_position'); + const messageRequestsEnabled = Signal.RemoteConfig.isEnabled( + 'desktop.messageRequests' + ); const result = { id: this.id, isArchived: this.get('isArchived'), + isBlocked: this.isBlocked(), activeAt: this.get('active_at'), avatarPath: this.getAvatarPath(), color, @@ -414,11 +473,17 @@ draftText, phoneNumber: this.getNumber(), + membersCount: this.isPrivate() + ? undefined + : (this.get('members') || []).length, lastMessage: { status: this.get('lastMessageStatus'), text: this.get('lastMessage'), deletedForEveryone: this.get('lastMessageDeletedForEveryone'), }, + + acceptedMessageRequest: this.getAccepted(), + messageRequestsEnabled, }; return result; @@ -449,6 +514,143 @@ } }, + incrementMessageCount() { + this.set({ + messageCount: (this.get('messageCount') || 0) + 1, + }); + window.Signal.Data.updateConversation(this.attributes); + }, + + decrementMessageCount() { + this.set({ + messageCount: Math.max((this.get('messageCount') || 0) - 1, 0), + }); + window.Signal.Data.updateConversation(this.attributes); + }, + + incrementSentMessageCount() { + this.set({ + messageCount: (this.get('messageCount') || 0) + 1, + sentMessageCount: (this.get('sentMessageCount') || 0) + 1, + }); + window.Signal.Data.updateConversation(this.attributes); + }, + + decrementSentMessageCount() { + this.set({ + messageCount: Math.max((this.get('messageCount') || 0) - 1, 0), + sentMessageCount: Math.max((this.get('sentMessageCount') || 0) - 1, 0), + }); + window.Signal.Data.updateConversation(this.attributes); + }, + + /** + * This function is called when a message request is accepted in order to + * handle sending read receipts and download any pending attachments. + */ + async handleReadAndDownloadAttachments() { + let messages; + do { + // eslint-disable-next-line no-await-in-loop + messages = await window.Signal.Data.getOlderMessagesByConversation( + this.get('id'), + { + MessageCollection: Whisper.MessageCollection, + limit: 100, + receivedAt: messages + ? messages.first().get('received_at') + : undefined, + } + ); + + if (!messages.length) { + return; + } + + const readMessages = messages.filter( + m => !m.hasErrors() && m.isIncoming() + ); + const receiptSpecs = readMessages.map(m => ({ + sender: m.get('source') || m.get('sourceUuid'), + timestamp: m.get('sent_at'), + hasErrors: m.hasErrors(), + })); + // eslint-disable-next-line no-await-in-loop + await this.sendReadReceiptsFor(receiptSpecs); + // eslint-disable-next-line no-await-in-loop + await Promise.all(readMessages.map(m => m.queueAttachmentDownloads())); + } while (messages.length > 0); + }, + + async applyMessageRequestResponse(response, { fromSync = false } = {}) { + // Apply message request response locally + this.set({ + messageRequestResponseType: response, + }); + window.Signal.Data.updateConversation(this.attributes); + + if (response === this.messageRequestEnum.ACCEPT) { + this.unblock(); + this.enableProfileSharing(); + this.sendProfileKeyUpdate(); + if (!fromSync) { + // Locally accepted + await this.handleReadAndDownloadAttachments(); + } + } else if (response === this.messageRequestEnum.BLOCK) { + // Block locally, other devices should block upon receiving the sync message + window.storage.blockIdentifier(this.get('id')); + this.disableProfileSharing(); + } else if (response === this.messageRequestEnum.DELETE) { + // Delete messages locally, other devices should delete upon receiving + // the sync message + this.destroyMessages(); + this.disableProfileSharing(); + this.updateLastMessage(); + if (!fromSync) { + this.trigger('unload', 'deleted from message request'); + } + } else if (response === this.messageRequestEnum.BLOCK_AND_DELETE) { + // Delete messages locally, other devices should delete upon receiving + // the sync message + this.destroyMessages(); + this.disableProfileSharing(); + this.updateLastMessage(); + // Block locally, other devices should block upon receiving the sync message + window.storage.blockIdentifier(this.get('id')); + // Leave group if this was a local action + if (!fromSync) { + this.leaveGroup(); + this.trigger('unload', 'blocked and deleted from message request'); + } + } + }, + + async syncMessageRequestResponse(response) { + // Let this run, no await + this.applyMessageRequestResponse(response); + + const { ourNumber, ourUuid } = this; + const { wrap, sendOptions } = ConversationController.prepareForSend( + ourNumber || ourUuid, + { + syncMessage: true, + } + ); + + await wrap( + textsecure.messaging.syncMessageRequestResponse( + { + threadE164: this.get('e164'), + threadUuid: this.get('uuid'), + groupId: this.get('groupId'), + type: response, + }, + sendOptions + ) + ); + }, + onMessageError() { this.updateVerified(); }, @@ -687,6 +889,52 @@ ); }); }, + + getSentMessageCount() { + return this.get('sentMessageCount') || 0; + }, + + getMessageRequestResponseType() { + return this.get('messageRequestResponseType') || 0; + }, + + /** + * Determine if this conversation should be considered "accepted" in terms + * of message requests + */ + getAccepted() { + const messageRequestsEnabled = Signal.RemoteConfig.isEnabled( + 'desktop.messageRequests' + ); + + if (!messageRequestsEnabled) { + return true; + } + + if (this.isMe()) { + return true; + } + + if ( + this.getMessageRequestResponseType() === this.messageRequestEnum.ACCEPT + ) { + return true; + } + + const fromContact = this.getIsAddedByContact(); + const hasSentMessages = this.getSentMessageCount() > 0; + const hasMessagesBeforeMessageRequests = + (this.get('messageCountBeforeMessageRequests') || 0) > 0; + const hasNoMessages = (this.get('messageCount') || 0) === 0; + + return ( + fromContact || + hasSentMessages || + hasMessagesBeforeMessageRequests || + hasNoMessages + ); + }, + onMemberVerifiedChange() { // If the verified state of a member changes, our aggregate state changes. // We trigger both events to replicate the behavior of Backbone.Model.set() @@ -1159,6 +1407,33 @@ }); }, + async sendProfileKeyUpdate() { + const id = this.get('id'); + const recipients = this.isPrivate() + ? [this.get('uuid') || this.get('e164')] + : this.getRecipients(); + if (!this.get('profileSharing')) { + window.log.error( + 'Attempted to send profileKeyUpdate to conversation without profileSharing enabled', + id, + recipients + ); + return; + } + window.log.info( + 'Sending profileKeyUpdate to conversation', + id, + recipients + ); + const profileKey = storage.get('profileKey'); + await textsecure.messaging.sendProfileKeyUpdate( + profileKey, + recipients, + this.getSendOptions(), + this.get('groupId') + ); + }, + sendMessage(body, attachments, quote, preview, sticker) { this.clearTypingTimers(); @@ -1226,6 +1501,7 @@ draft: null, draftTimestamp: null, }); + this.incrementSentMessageCount(); window.Signal.Data.updateConversation(this.attributes); // We're offline! @@ -1487,6 +1763,32 @@ }; }, + getIsContact() { + if (this.isPrivate()) { + return Boolean(this.get('name')); + } + + return false; + }, + + getIsAddedByContact() { + if (this.isPrivate()) { + return this.getIsContact(); + } + + const addedBy = this.get('addedBy'); + if (!addedBy) { + return false; + } + + const conv = ConversationController.get(addedBy); + if (!conv) { + return false; + } + + return conv.getIsContact(); + }, + async updateLastMessage() { if (!this.id) { return; @@ -1793,7 +2095,7 @@ async leaveGroup() { const now = Date.now(); if (this.get('type') === 'group') { - const groupNumbers = this.getRecipients(); + const groupIdentifiers = this.getRecipients(); this.set({ left: true }); window.Signal.Data.updateConversation(this.attributes); @@ -1816,7 +2118,7 @@ const options = this.getSendOptions(); message.send( this.wrapSend( - textsecure.messaging.leaveGroup(this.id, groupNumbers, options) + textsecure.messaging.leaveGroup(this.id, groupIdentifiers, options) ) ); } @@ -1845,11 +2147,10 @@ // Note that this will update the message in the database await m.markRead(options.readAt); - const errors = m.get('errors'); return { - sender: m.get('source'), + sender: m.get('source') || m.get('sourceUuid'), timestamp: m.get('sent_at'), - hasErrors: Boolean(errors && errors.length), + hasErrors: m.hasErrors(), }; }) ); @@ -1870,7 +2171,7 @@ read = read.filter(item => !item.hasErrors); if (read.length && options.sendReadReceipts) { - window.log.info(`Sending ${read.length} read receipts`); + window.log.info(`Sending ${read.length} read syncs`); // Because syncReadMessages sends to our other devices, and sendReadReceipts goes // to a contact, we need accessKeys for both. const { sendOptions } = ConversationController.prepareForSend( @@ -1880,25 +2181,31 @@ await this.wrapSend( textsecure.messaging.syncReadMessages(read, sendOptions) ); + await this.sendReadReceiptsFor(read); + } + }, - if (storage.get('read-receipt-setting')) { - const convoSendOptions = this.getSendOptions(); + async sendReadReceiptsFor(items) { + // Only send read receipts for accepted conversations + if (storage.get('read-receipt-setting') && this.getAccepted()) { + window.log.info(`Sending ${items.length} read receipts`); + const convoSendOptions = this.getSendOptions(); + const receiptsBySender = _.groupBy(items, 'sender'); - await Promise.all( - _.map(_.groupBy(read, 'sender'), async (receipts, identifier) => { - const timestamps = _.map(receipts, 'timestamp'); - const c = ConversationController.get(identifier); - await this.wrapSend( - textsecure.messaging.sendReadReceipts( - c.get('e164'), - c.get('uuid'), - timestamps, - convoSendOptions - ) - ); - }) - ); - } + await Promise.all( + _.map(receiptsBySender, async (receipts, identifier) => { + const timestamps = _.map(receipts, 'timestamp'); + const c = ConversationController.get(identifier); + await this.wrapSend( + textsecure.messaging.sendReadReceipts( + c.get('e164'), + c.get('uuid'), + timestamps, + convoSendOptions + ) + ); + }) + ); } }, diff --git a/js/models/messages.js b/js/models/messages.js index 7fcdd9755558..124a6d2f5466 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -543,6 +543,9 @@ const conversation = this.getConversation(); const isGroup = conversation && !conversation.isPrivate(); + const conversationAccepted = Boolean( + conversation && conversation.getAccepted() + ); const sticker = this.get('sticker'); const isTapToView = this.isTapToView(); @@ -577,6 +580,7 @@ textPending: this.get('bodyPending'), id: this.id, conversationId: this.get('conversationId'), + conversationAccepted, isSticker: Boolean(sticker), direction: this.isIncoming() ? 'incoming' : 'outgoing', timestamp: this.get('sent_at'), @@ -1145,6 +1149,28 @@ }); } }, + isEmpty() { + const body = this.get('body'); + const hasAttachment = (this.get('attachments') || []).length > 0; + const quote = this.get('quote'); + const hasContact = (this.get('contact') || []).length > 0; + const sticker = this.get('sticker'); + const hasPreview = (this.get('preview') || []).length > 0; + const groupUpdate = this.get('group_update'); + const expirationTimerUpdate = this.get('expirationTimerUpdate'); + + const notEmpty = Boolean( + body || + hasAttachment || + quote || + hasContact || + sticker || + hasPreview || + groupUpdate || + expirationTimerUpdate + ); + return !notEmpty; + }, unload() { if (this.quotedMessage) { this.quotedMessage = null; @@ -1454,26 +1480,32 @@ ); }, canReply() { + const isAccepted = this.getConversation().getAccepted(); const errors = this.get('errors'); const isOutgoing = this.get('type') === 'outgoing'; const numDelivered = this.get('delivered'); - // Case 1: We cannot reply if this message is deleted for everyone + // Case 1: We cannot reply if we have accepted the message request + if (!isAccepted) { + return false; + } + + // Case 2: We cannot reply if this message is deleted for everyone if (this.get('deletedForEveryone')) { return false; } - // Case 2: We can reply if this is outgoing and delievered to at least one recipient + // Case 3: We can reply if this is outgoing and delievered to at least one recipient if (isOutgoing && numDelivered > 0) { return true; } - // Case 3: We can reply if there are no errors + // Case 4: We can reply if there are no errors if (!errors || (errors && errors.length === 0)) { return true; } - // Otherwise we cannot reply + // Case 5: default return false; }, @@ -2171,10 +2203,12 @@ } // Send delivery receipts, but only for incoming sealed sender messages + // and not for messages from unaccepted conversations if ( type === 'incoming' && this.get('unidentifiedDeliveryReceived') && - !this.hasErrors() + !this.hasErrors() && + conversation.getAccepted() ) { // Note: We both queue and batch because we want to wait until we are done // processing incoming messages to start sending outgoing delivery receipts. @@ -2344,6 +2378,7 @@ if (conversation.get('left')) { window.log.warn('re-added to a left group'); attributes.left = false; + conversation.set({ addedBy: message.getContact().get('id') }); } } else if (dataMessage.group.type === GROUP_TYPES.QUIT) { const sender = ConversationController.get(source || sourceUuid); @@ -2549,6 +2584,17 @@ } } + // Drop empty messages. This needs to happen after the initial + // message.set call to make sure all possible properties are set + // before we determine that a message is empty. + if (message.isEmpty()) { + window.log.info( + `Dropping empty datamessage ${message.idForLogging()} in conversation ${conversation.idForLogging()}` + ); + confirm(); + return; + } + const conversationTimestamp = conversation.get('timestamp'); if ( !conversationTimestamp || @@ -2561,9 +2607,14 @@ } MessageController.register(message.id, message); + conversation.incrementMessageCount(); window.Signal.Data.updateConversation(conversation.attributes); - await message.queueAttachmentDownloads(); + // Only queue attachments for downloads if this is an outgoing message + // or we've accepted the conversation + if (this.getConversation().getAccepted() || message.isOutgoing()) { + await message.queueAttachmentDownloads(); + } // Does this message have any pending, previously-received associated reactions? const reactions = Whisper.Reactions.forMessage(message); @@ -2589,6 +2640,11 @@ await conversation.notify(message); } + // Increment the sent message count if this is an outgoing message + if (type === 'outgoing') { + conversation.incrementSentMessageCount(); + } + Whisper.events.trigger('incrementProgress'); confirm(); } catch (error) { diff --git a/js/modules/signal.js b/js/modules/signal.js index 4c7b9b2e6437..71e9e1684948 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -11,6 +11,7 @@ const Notifications = require('../../ts/notifications'); const OS = require('../../ts/OS'); const Stickers = require('./stickers'); const Settings = require('./settings'); +const RemoteConfig = require('../../ts/RemoteConfig'); const Util = require('../../ts/util'); const Metadata = require('./metadata/SecretSessionCipher'); const RefreshSenderCertificate = require('./refresh_sender_certificate'); @@ -350,6 +351,7 @@ exports.setup = (options = {}) => { Notifications, OS, RefreshSenderCertificate, + RemoteConfig, Settings, Services, State, diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 9c31ff784cf1..e829f8c8bb05 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -367,6 +367,7 @@ color: this.model.getColor(), avatarPath: this.model.getAvatarPath(), + isAccepted: this.model.getAccepted(), isVerified: this.model.isVerified(), isMe: this.model.isMe(), isGroup: !this.model.isPrivate(), @@ -447,6 +448,9 @@ `)[0]; + const messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + const props = { id: this.model.id, compositionApi, @@ -462,6 +466,26 @@ clearQuotedMessage: () => this.setQuoteMessage(null), micCellEl, attachmentListEl, + onAccept: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.ACCEPT + ), + onBlock: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.BLOCK + ), + onUnblock: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.ACCEPT + ), + onDelete: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.DELETE + ), + onBlockAndDelete: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.BLOCK_AND_DELETE + ), }; this.compositionAreaView = new Whisper.ReactWrapperView({ @@ -1223,7 +1247,13 @@ } return { - ..._.pick(attachment, ['contentType', 'fileName', 'size', 'caption']), + ..._.pick(attachment, [ + 'contentType', + 'fileName', + 'size', + 'caption', + 'blurHash', + ]), data, }; }, @@ -1433,6 +1463,7 @@ }, async handleImageAttachment(file) { + const blurHash = await window.imageToBlurHash(file); if (MIME.isJPEG(file.type)) { const rotatedDataUrl = await window.autoOrientImage(file); const rotatedBlob = window.dataURLToBlobSync(rotatedDataUrl); @@ -1454,6 +1485,7 @@ contentType, data, size: data.byteLength, + blurHash, }; } @@ -1470,6 +1502,7 @@ contentType, data, size: data.byteLength, + blurHash, }; }, @@ -2168,6 +2201,11 @@ }); message.trigger('unload'); this.model.messageCollection.remove(message.id); + if (message.isOutgoing()) { + this.model.decrementSentMessageCount(); + } else { + this.model.decrementMessageCount(); + } this.resetPanel(); }, }); diff --git a/libtextsecure/test/index.html b/libtextsecure/test/index.html index d8b418cb7c87..137d001a95b1 100644 --- a/libtextsecure/test/index.html +++ b/libtextsecure/test/index.html @@ -27,6 +27,7 @@ + diff --git a/package.json b/package.json index c5128d09acb5..e93b7250f167 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "blob-util": "1.3.0", "blueimp-canvas-to-blob": "3.14.0", "blueimp-load-image": "2.18.0", + "blurhash": "1.1.3", "bunyan": "1.8.12", "classnames": "2.2.5", "config": "1.28.1", @@ -111,6 +112,7 @@ "proxy-agent": "3.1.1", "qs": "6.5.1", "react": "16.8.3", + "react-blurhash": "0.1.2", "react-contextmenu": "2.11.0", "react-dom": "16.8.3", "react-dropzone": "10.1.7", @@ -157,6 +159,7 @@ "@storybook/react": "5.1.11", "@types/agent-base": "4.2.0", "@types/backbone": "1.4.3", + "@types/blueimp-load-image": "2.23.6", "@types/chai": "4.1.2", "@types/classnames": "2.2.3", "@types/config": "0.0.34", diff --git a/patches/react-blurhash+0.1.2.patch b/patches/react-blurhash+0.1.2.patch new file mode 100644 index 000000000000..ad3863395976 --- /dev/null +++ b/patches/react-blurhash+0.1.2.patch @@ -0,0 +1,45 @@ +diff --git a/node_modules/react-blurhash/lib/Blurhash.js b/node_modules/react-blurhash/lib/Blurhash.js +index db3115d..746c877 100644 +--- a/node_modules/react-blurhash/lib/Blurhash.js ++++ b/node_modules/react-blurhash/lib/Blurhash.js +@@ -61,8 +61,8 @@ var Blurhash = /** @class */ (function (_super) { + }; + Blurhash.prototype.render = function () { + var _a = this.props, hash = _a.hash, height = _a.height, width = _a.width, punch = _a.punch, resolutionX = _a.resolutionX, resolutionY = _a.resolutionY, style = _a.style, rest = __rest(_a, ["hash", "height", "width", "punch", "resolutionX", "resolutionY", "style"]); +- return (react_1.default.createElement("div", __assign({}, rest, { style: __assign({ display: 'inline-block', height: height, width: width }, style, { position: 'relative' }) }), +- react_1.default.createElement(BlurhashCanvas_1.default, { hash: hash, height: resolutionY, width: resolutionX, punch: punch, style: canvasStyle }))); ++ return (react_1.createElement("div", __assign({}, rest, { style: __assign({ display: 'inline-block', height: height, width: width }, style, { position: 'relative' }) }), ++ react_1.createElement(BlurhashCanvas_1.default, { hash: hash, height: resolutionY, width: resolutionX, punch: punch, style: canvasStyle }))); + }; + Blurhash.defaultProps = { + height: 128, +@@ -71,6 +71,6 @@ var Blurhash = /** @class */ (function (_super) { + resolutionY: 32, + }; + return Blurhash; +-}(react_1.default.PureComponent)); ++}(react_1.PureComponent)); + exports.default = Blurhash; + //# sourceMappingURL=Blurhash.js.map +\ No newline at end of file +diff --git a/node_modules/react-blurhash/lib/BlurhashCanvas.js b/node_modules/react-blurhash/lib/BlurhashCanvas.js +index b2833dc..d666520 100644 +--- a/node_modules/react-blurhash/lib/BlurhashCanvas.js ++++ b/node_modules/react-blurhash/lib/BlurhashCanvas.js +@@ -63,13 +63,13 @@ var BlurhashCanvas = /** @class */ (function (_super) { + }; + BlurhashCanvas.prototype.render = function () { + var _a = this.props, hash = _a.hash, height = _a.height, width = _a.width, rest = __rest(_a, ["hash", "height", "width"]); +- return react_1.default.createElement("canvas", __assign({}, rest, { height: height, width: width, ref: this.handleRef })); ++ return react_1.createElement("canvas", __assign({}, rest, { height: height, width: width, ref: this.handleRef })); + }; + BlurhashCanvas.defaultProps = { + height: 128, + width: 128, + }; + return BlurhashCanvas; +-}(react_1.default.PureComponent)); ++}(react_1.PureComponent)); + exports.default = BlurhashCanvas; + //# sourceMappingURL=BlurhashCanvas.js.map +\ No newline at end of file diff --git a/preload.js b/preload.js index bc9f6cc148db..df47183b4c19 100644 --- a/preload.js +++ b/preload.js @@ -243,9 +243,11 @@ try { }, 1000); const { autoOrientImage } = require('./js/modules/auto_orient_image'); + const { imageToBlurHash } = require('./ts/util/imageToBlurHash'); window.autoOrientImage = autoOrientImage; window.dataURLToBlobSync = require('blueimp-canvas-to-blob'); + window.imageToBlurHash = imageToBlurHash; window.emojiData = require('emoji-datasource'); window.filesize = require('filesize'); window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance(); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index f0cf885c14a0..ab18ff7044bb 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -325,17 +325,33 @@ message SyncMessage { optional uint64 timestamp = 2; } - optional Sent sent = 1; - optional Contacts contacts = 2; - optional Groups groups = 3; - optional Request request = 4; - repeated Read read = 5; - optional Blocked blocked = 6; - optional Verified verified = 7; - optional Configuration configuration = 9; - optional bytes padding = 8; - repeated StickerPackOperation stickerPackOperation = 10; - optional ViewOnceOpen viewOnceOpen = 11; + message MessageRequestResponse { + enum Type { + UNKNOWN = 0; + ACCEPT = 1; + DELETE = 2; + BLOCK = 3; + BLOCK_AND_DELETE = 4; + } + + optional string threadE164 = 1; + optional string threadUuid = 2; + optional bytes groupId = 3; + optional Type type = 4; + } + + optional Sent sent = 1; + optional Contacts contacts = 2; + optional Groups groups = 3; + optional Request request = 4; + repeated Read read = 5; + optional Blocked blocked = 6; + optional Verified verified = 7; + optional Configuration configuration = 9; + optional bytes padding = 8; + repeated StickerPackOperation stickerPackOperation = 10; + optional ViewOnceOpen viewOnceOpen = 11; + optional MessageRequestResponse messageRequestResponse = 14; } message AttachmentPointer { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index d33d16a26d01..2177d6045259 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -619,7 +619,7 @@ left: -12px; right: -12px; top: -10px; - bottom: -10px; + bottom: -15px; } border-radius: 16px; @@ -643,6 +643,10 @@ border-top-left-radius: 0px; border-top-right-radius: 0px; } + + &--with-collapsed-metadata { + margin-bottom: -10px; + } } .module-message__sticker-container { @@ -708,6 +712,11 @@ padding-top: 4px; } +.module-message__generic-attachment--not-active { + cursor: default; + pointer-events: none; +} + .module-message__generic-attachment__icon-container { position: relative; } @@ -3217,6 +3226,138 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', color: $color-gray-45; } +// Module: Conversation Hero + +.module-conversation-hero { + padding: 32px 0 28px 0; + text-align: center; + + &__avatar { + margin-bottom: 12px; + } + + &__profile-name { + @include font-title-2; + margin-bottom: 2px; + + @include light-theme { + color: $color-gray-90; + } + + @include dark-theme { + color: $color-gray-05; + } + } + + &__with { + @include font-body-2; + margin-bottom: 16px; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } + } + + &__membership { + @include font-body-2; + + padding: 0 16px; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } + + &__name { + @include font-body-2-bold; + } + } +} + +// Module: Message Request Actions + +.module-message-request-actions { + padding: 8px 16px 12px 16px; + + @include light-theme { + background: $color-white; + } + + @include dark-theme { + background: $color-gray-95; + } + + &__message { + @include font-body-2; + text-align: center; + margin-bottom: 12px; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } + + &__name { + @include font-body-2-bold; + } + } + + &__buttons { + display: flex; + flex-direction: row; + justify-content: center; + + &__button { + border: none; + border-radius: 4px; + min-width: 80px; + height: 36px; + padding: 0 14px; + text-align: center; + + &:focus { + outline: none; + + @include keyboard-mode { + box-shadow: 0px 0px 0px 2px $ultramarine-ui-light; + } + } + + @include font-body-1-bold; + + @include light-theme { + background-color: $color-gray-05; + } + + @include dark-theme { + background-color: $color-gray-75; + } + + &:not(:last-of-type) { + margin-right: 8px; + } + + &--deny { + color: $color-accent-red; + } + + &--accept { + color: $color-accent-blue; + } + } + } +} + // Module: Conversation List Item .module-conversation-list-item { @@ -3696,6 +3837,31 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', width: 62px; } +.module-avatar--112 { + height: 112px; + width: 112px; + + img { + height: 112px; + width: 112px; + } +} + +.module-avatar__label--112 { + width: 112px; + font-size: 56px; + line-height: 112px; +} + +.module-avatar__icon--112 { + height: 81px; + width: 81px; +} +.module-avatar__icon--112.module-avatar__icon--direct { + height: 87px; + width: 87px; +} + .module-avatar__icon--note-to-self { width: 70%; height: 70%; @@ -3900,6 +4066,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', display: inline-block; margin: 1px; vertical-align: middle; + overflow: hidden; } .module-image--with-background { @@ -6471,8 +6638,13 @@ button.module-image__border-overlay:focus { color: $color-gray-05; } + &__title { + @include font-body-1-bold; + } + &__content { - margin-bottom: 20px; + @include font-body-1; + margin-bottom: 22px; } &__buttons { diff --git a/test/backup_test.js b/test/backup_test.js index 76e03ad55c2f..943e2b3baa9f 100644 --- a/test/backup_test.js +++ b/test/backup_test.js @@ -545,6 +545,8 @@ describe('Backup', () => { timestamp: 1524185933350, type: 'private', unreadCount: 0, + messageCount: 0, + sentMessageCount: 0, verified: 0, sealedSender: 0, version: 2, diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts new file mode 100644 index 000000000000..b7c6e09daf2a --- /dev/null +++ b/ts/RemoteConfig.ts @@ -0,0 +1,94 @@ +// tslint:disable: no-backbone-get-set-outside-model +import { get, throttle } from 'lodash'; +import { WebAPIType } from './textsecure/WebAPI'; + +type ConfigKeyType = 'desktop.messageRequests'; +type ConfigValueType = { + name: ConfigKeyType; + enabled: boolean; + enabledAt?: number; +}; +type ConfigMapType = { [key: string]: ConfigValueType }; +type ConfigListenerType = (value: ConfigValueType) => unknown; +type ConfigListenersMapType = { + [key: string]: Array; +}; + +function getServer(): WebAPIType { + const OLD_USERNAME = window.storage.get('number_id'); + const USERNAME = window.storage.get('uuid_id'); + const PASSWORD = window.storage.get('password'); + + return window.WebAPI.connect({ + username: (USERNAME || OLD_USERNAME) as string, + password: PASSWORD as string, + }); +} + +let config: ConfigMapType = {}; +const listeners: ConfigListenersMapType = {}; + +export async function initRemoteConfig() { + config = window.storage.get('remoteConfig') || {}; + await maybeRefreshRemoteConfig(); +} + +export function onChange(key: ConfigKeyType, fn: ConfigListenerType) { + const keyListeners: Array = get(listeners, key, []); + keyListeners.push(fn); + listeners[key] = keyListeners; + + return () => { + listeners[key] = listeners[key].filter(l => l !== fn); + }; +} + +const refreshRemoteConfig = async () => { + const now = Date.now(); + const server = getServer(); + const newConfig = await server.getConfig(); + + // Process new configuration in light of the old configuration + // The old configuration is not set as the initial value in reduce because + // flags may have been deleted + const oldConfig = config; + config = newConfig.reduce((previous, { name, enabled }) => { + const previouslyEnabled: boolean = get(oldConfig, [name, 'enabled'], false); + // If a flag was previously not enabled and is now enabled, record the time it was enabled + const enabledAt: number | undefined = + previouslyEnabled && enabled ? now : get(oldConfig, [name, 'enabledAt']); + + const value = { + name: name as ConfigKeyType, + enabled, + enabledAt, + }; + + // If enablement changes at all, notify listeners + const currentListeners = listeners[name] || []; + if (previouslyEnabled !== enabled) { + currentListeners.forEach(listener => { + listener(value); + }); + } + + // Return new configuration object + return { + ...previous, + [name]: value, + }; + }, {}); + + window.storage.put('remoteConfig', config); +}; + +export const maybeRefreshRemoteConfig = throttle( + refreshRemoteConfig, + // Only fetch remote configuration if the last fetch was more than two hours ago + 2 * 60 * 60 * 1000, + { trailing: false } +); + +export function isEnabled(name: ConfigKeyType): boolean { + return get(config, [name, 'enabled'], false); +} diff --git a/ts/components/Avatar.md b/ts/components/Avatar.md index 0fd0bf1538ab..abedd4bf513c 100644 --- a/ts/components/Avatar.md +++ b/ts/components/Avatar.md @@ -2,6 +2,13 @@ ```jsx + + + + @@ -184,6 +207,7 @@ ```jsx + @@ -361,6 +385,43 @@ ``` +### 112px + +```jsx + + + + + + + +``` + ### Broken color ```jsx diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index cde997a613de..9c657f88b2d2 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -1,10 +1,10 @@ -import React from 'react'; +import * as React from 'react'; import classNames from 'classnames'; import { getInitials } from '../util/getInitials'; import { ColorType, LocalizerType } from '../types/Util'; -export interface Props { +export type Props = { avatarPath?: string; color?: ColorType; @@ -13,7 +13,7 @@ export interface Props { name?: string; phoneNumber?: string; profileName?: string; - size: 28 | 32 | 52 | 80; + size: 28 | 32 | 52 | 80 | 112; onClick?: () => unknown; @@ -21,7 +21,7 @@ export interface Props { innerRef?: React.Ref; i18n: LocalizerType; -} +} & Pick, 'className'>; interface State { imageBroken: boolean; @@ -139,12 +139,13 @@ export class Avatar extends React.Component { noteToSelf, onClick, size, + className, } = this.props; const { imageBroken } = this.state; const hasImage = !noteToSelf && avatarPath && !imageBroken; - if (![28, 32, 52, 80].includes(size)) { + if (![28, 32, 52, 80, 112].includes(size)) { throw new Error(`Size ${size} is not supported!`); } @@ -166,7 +167,8 @@ export class Avatar extends React.Component { 'module-avatar', `module-avatar--${size}`, hasImage ? 'module-avatar--with-image' : 'module-avatar--no-image', - !hasImage ? `module-avatar--${color}` : null + !hasImage ? `module-avatar--${color}` : null, + className )} ref={innerRef} > diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index a867b1efb80d..9688926252da 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -16,11 +16,17 @@ import { InputApi, Props as CompositionInputProps, } from './CompositionInput'; +import { + MessageRequestActions, + Props as MessageRequestActionsProps, +} from './conversation/MessageRequestActions'; import { countStickers } from './stickers/lib'; import { LocalizerType } from '../types/Util'; export type OwnProps = { readonly i18n: LocalizerType; + readonly messageRequestsEnabled?: boolean; + readonly acceptedMessageRequest?: boolean; readonly compositionApi?: React.MutableRefObject<{ focusInput: () => void; isDirty: () => boolean; @@ -66,6 +72,7 @@ export type Props = Pick< | 'showPickerHint' | 'clearShowPickerHint' > & + MessageRequestActionsProps & OwnProps; const emptyElement = (el: HTMLElement) => { @@ -73,7 +80,7 @@ const emptyElement = (el: HTMLElement) => { el.innerHTML = ''; }; -// tslint:disable-next-line max-func-body-length +// tslint:disable-next-line max-func-body-length cyclomatic-complexity export const CompositionArea = ({ i18n, attachmentListEl, @@ -86,6 +93,8 @@ export const CompositionArea = ({ onEditorStateChange, onTextTooLong, startingText, + clearQuotedMessage, + getQuotedMessage, // EmojiButton onPickEmoji, onSetSkinTone, @@ -104,8 +113,19 @@ export const CompositionArea = ({ clearShowIntroduction, showPickerHint, clearShowPickerHint, - clearQuotedMessage, - getQuotedMessage, + // Message Requests + messageRequestsEnabled, + acceptedMessageRequest, + conversationType, + isBlocked, + name, + onAccept, + onBlock, + onBlockAndDelete, + onUnblock, + onDelete, + profileName, + phoneNumber, }: Props) => { const [disabled, setDisabled] = React.useState(false); const [showMic, setShowMic] = React.useState(!startingText); @@ -299,6 +319,24 @@ export const CompositionArea = ({ }; }, [setLarge]); + if ((!acceptedMessageRequest || isBlocked) && messageRequestsEnabled) { + return ( + + ); + } + return (
diff --git a/ts/components/ConfirmationDialog.md b/ts/components/ConfirmationDialog.md deleted file mode 100644 index 328ce1027831..000000000000 --- a/ts/components/ConfirmationDialog.md +++ /dev/null @@ -1,16 +0,0 @@ -#### All Options - -```jsx - - console.log('onClose')} - onAffirmative={() => console.log('onAffirmative')} - affirmativeText="Affirm" - onNegative={() => console.log('onNegative')} - negativeText="Negate" - > - asdf child - - -``` diff --git a/ts/components/ConfirmationDialog.stories.tsx b/ts/components/ConfirmationDialog.stories.tsx new file mode 100644 index 000000000000..e5f64537b087 --- /dev/null +++ b/ts/components/ConfirmationDialog.stories.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { ConfirmationDialog } from './ConfirmationDialog'; + +// @ts-ignore +import { setup as setupI18n } from '../../js/modules/i18n'; +// @ts-ignore +import enMessages from '../../_locales/en/messages.json'; + +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { text } from '@storybook/addon-knobs'; + +const i18n = setupI18n('en', enMessages); + +storiesOf('Components/ConfirmationDialog', module).add( + 'ConfirmationDialog', + () => { + return ( + + {text('Child text', 'asdf blip')} + + ); + } +); diff --git a/ts/components/ConfirmationDialog.tsx b/ts/components/ConfirmationDialog.tsx index 98ab848da75e..bb836b4b450c 100644 --- a/ts/components/ConfirmationDialog.tsx +++ b/ts/components/ConfirmationDialog.tsx @@ -2,14 +2,18 @@ import * as React from 'react'; import classNames from 'classnames'; import { LocalizerType } from '../types/Util'; +export type ActionSpec = { + text: string; + action: () => unknown; + style?: 'affirmative' | 'negative'; +}; + export type OwnProps = { readonly i18n: LocalizerType; readonly children: React.ReactNode; - readonly affirmativeText?: string; - readonly onAffirmative?: () => unknown; + readonly title?: string | React.ReactNode; + readonly actions: Array; readonly onClose: () => unknown; - readonly negativeText?: string; - readonly onNegative?: () => unknown; }; export type Props = OwnProps; @@ -21,15 +25,7 @@ function focusRef(el: HTMLElement | null) { } export const ConfirmationDialog = React.memo( - ({ - i18n, - onClose, - children, - onAffirmative, - onNegative, - affirmativeText, - negativeText, - }: Props) => { + ({ i18n, onClose, children, title, actions }: Props) => { React.useEffect(() => { const handler = ({ key }: KeyboardEvent) => { if (key === 'Escape') { @@ -52,22 +48,25 @@ export const ConfirmationDialog = React.memo( [onClose] ); - const handleNegative = React.useCallback(() => { - onClose(); - if (onNegative) { - onNegative(); - } - }, [onClose, onNegative]); - - const handleAffirmative = React.useCallback(() => { - onClose(); - if (onAffirmative) { - onAffirmative(); - } - }, [onClose, onAffirmative]); + const handleAction = React.useCallback( + (e: React.MouseEvent) => { + onClose(); + if (e.currentTarget.dataset.action) { + const actionIndex = parseInt(e.currentTarget.dataset.action, 10); + const { action } = actions[actionIndex]; + action(); + } + }, + [onClose, actions] + ); return (
+ {title ? ( +

+ {title} +

+ ) : null}
{children}
@@ -79,28 +78,24 @@ export const ConfirmationDialog = React.memo( > {i18n('confirmation-dialog--Cancel')} - {onNegative && negativeText ? ( + {actions.map((action, i) => ( - ) : null} - {onAffirmative && affirmativeText ? ( - - ) : null} + ))}
); diff --git a/ts/components/ConfirmationModal.tsx b/ts/components/ConfirmationModal.tsx index b4d64714845b..748dd6b8f266 100644 --- a/ts/components/ConfirmationModal.tsx +++ b/ts/components/ConfirmationModal.tsx @@ -1,31 +1,21 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; -import { ConfirmationDialog } from './ConfirmationDialog'; +import { + ConfirmationDialog, + Props as ConfirmationDialogProps, +} from './ConfirmationDialog'; import { LocalizerType } from '../types/Util'; export type OwnProps = { readonly i18n: LocalizerType; - readonly children: React.ReactNode; - readonly affirmativeText?: string; - readonly onAffirmative?: () => unknown; readonly onClose: () => unknown; - readonly negativeText?: string; - readonly onNegative?: () => unknown; }; -export type Props = OwnProps; +export type Props = OwnProps & ConfirmationDialogProps; export const ConfirmationModal = React.memo( // tslint:disable-next-line max-func-body-length - ({ - i18n, - onClose, - children, - onAffirmative, - onNegative, - affirmativeText, - negativeText, - }: Props) => { + ({ i18n, onClose, children, ...rest }: Props) => { const [root, setRoot] = React.useState(null); React.useEffect(() => { @@ -72,14 +62,7 @@ export const ConfirmationModal = React.memo( className="module-confirmation-dialog__overlay" onClick={handleCancel} > - + {children}
, diff --git a/ts/components/MainHeader.tsx b/ts/components/MainHeader.tsx index c0b9f28a41f7..a258a243993f 100644 --- a/ts/components/MainHeader.tsx +++ b/ts/components/MainHeader.tsx @@ -25,7 +25,7 @@ export interface PropsType { phoneNumber: string; isMe: boolean; name?: string; - color: ColorType; + color?: ColorType; verified: boolean; profileName?: string; avatarPath?: string; diff --git a/ts/components/conversation/ContactName.tsx b/ts/components/conversation/ContactName.tsx index 2e82a7bb835e..e9c3bcbda133 100644 --- a/ts/components/conversation/ContactName.tsx +++ b/ts/components/conversation/ContactName.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Emojify } from './Emojify'; -interface Props { +export interface Props { phoneNumber?: string; name?: string; profileName?: string; diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index c43bb6fedb86..88141507a8b5 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -64,6 +64,7 @@ const stories: Array = [ phoneNumber: '(202) 555-0001', id: '1', profileName: '🔥Flames🔥', + isAccepted: true, ...actionProps, ...housekeepingProps, }, @@ -76,6 +77,7 @@ const stories: Array = [ name: 'Someone 🔥 Somewhere', phoneNumber: '(202) 555-0002', id: '2', + isAccepted: true, ...actionProps, ...housekeepingProps, }, @@ -88,6 +90,7 @@ const stories: Array = [ phoneNumber: '(202) 555-0003', id: '3', profileName: '🔥Flames🔥', + isAccepted: true, ...actionProps, ...housekeepingProps, }, @@ -97,6 +100,7 @@ const stories: Array = [ props: { phoneNumber: '(202) 555-0011', id: '11', + isAccepted: true, ...actionProps, ...housekeepingProps, }, @@ -108,6 +112,7 @@ const stories: Array = [ color: 'deep_orange', phoneNumber: '(202) 555-0004', id: '4', + isAccepted: true, ...actionProps, ...housekeepingProps, }, @@ -129,6 +134,7 @@ const stories: Array = [ value: 10, }, ], + isAccepted: true, ...actionProps, ...housekeepingProps, }, @@ -159,6 +165,7 @@ const stories: Array = [ value: 10, }, ], + isAccepted: true, ...actionProps, ...housekeepingProps, }, @@ -183,6 +190,7 @@ const stories: Array = [ value: 10, }, ], + isAccepted: true, ...actionProps, ...housekeepingProps, }, @@ -200,6 +208,25 @@ const stories: Array = [ phoneNumber: '(202) 555-0007', id: '7', isMe: true, + isAccepted: true, + ...actionProps, + ...housekeepingProps, + }, + }, + ], + }, + { + title: 'Unaccepted', + description: 'No safety number entry.', + items: [ + { + title: '1:1 conversation', + props: { + color: 'blue', + phoneNumber: '(202) 555-0007', + id: '7', + isMe: false, + isAccepted: false, ...actionProps, ...housekeepingProps, }, diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index 1a98a44adcbc..07aed6efb240 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -25,6 +25,7 @@ export interface PropsData { color?: ColorType; avatarPath?: string; + isAccepted?: boolean; isVerified?: boolean; isMe?: boolean; isGroup?: boolean; @@ -222,6 +223,7 @@ export class ConversationHeader extends React.Component { public renderMenu(triggerId: string) { const { i18n, + isAccepted, isMe, isGroup, isArchived, @@ -241,7 +243,7 @@ export class ConversationHeader extends React.Component { return ( - {leftGroup ? null : ( + {!leftGroup && isAccepted ? ( {(timerOptions || []).map(item => ( { ))} - )} + ) : null} {i18n('viewRecentMedia')} {isGroup ? ( @@ -266,7 +268,7 @@ export class ConversationHeader extends React.Component { {i18n('showSafetyNumber')} ) : null} - {!isGroup ? ( + {!isGroup && isAccepted ? ( {i18n('resetSession')} ) : null} {isArchived ? ( diff --git a/ts/components/conversation/ConversationHero.stories.tsx b/ts/components/conversation/ConversationHero.stories.tsx new file mode 100644 index 000000000000..f8a0bef4e010 --- /dev/null +++ b/ts/components/conversation/ConversationHero.stories.tsx @@ -0,0 +1,130 @@ +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { number as numberKnob, text } from '@storybook/addon-knobs'; +import { ConversationHero } from './ConversationHero'; + +// @ts-ignore +import { setup as setupI18n } from '../../../js/modules/i18n'; +// @ts-ignore +import enMessages from '../../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const getName = () => text('name', 'Cayce Bollard'); +const getProfileName = () => text('profileName', 'Cayce Bollard'); +const getAvatarPath = () => + text('avatarPath', '/fixtures/kitten-4-112-112.jpg'); +const getPhoneNumber = () => text('phoneNumber', '+1 (646) 327-2700'); + +storiesOf('Components/Conversation/ConversationHero', module) + .add('Direct (Three Other Groups)', () => { + return ( +
+ +
+ ); + }) + .add('Direct (Two Other Groups)', () => { + return ( +
+ +
+ ); + }) + .add('Direct (One Other Group)', () => { + return ( +
+ +
+ ); + }) + .add('Direct (No Other Groups)', () => { + return ( +
+ +
+ ); + }) + .add('Group (many members)', () => { + return ( +
+ +
+ ); + }) + .add('Group (one member)', () => { + return ( +
+ +
+ ); + }) + .add('Group (zero members)', () => { + return ( +
+ +
+ ); + }) + .add('Note to Self', () => { + return ( +
+ +
+ ); + }); diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx new file mode 100644 index 000000000000..07fabeb46727 --- /dev/null +++ b/ts/components/conversation/ConversationHero.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { take } from 'lodash'; +import { Avatar, Props as AvatarProps } from '../Avatar'; +import { ContactName } from './ContactName'; +import { Emojify } from './Emojify'; +import { Intl } from '../Intl'; +import { LocalizerType } from '../../types/Util'; + +export type Props = { + i18n: LocalizerType; + isMe?: boolean; + groups?: Array; + membersCount?: number; + phoneNumber: string; + onHeightChange?: () => unknown; +} & Omit; + +const renderMembershipRow = ({ + i18n, + groups, + conversationType, + isMe, +}: Pick) => { + const className = 'module-conversation-hero__membership'; + const nameClassName = `${className}__name`; + + if (isMe) { + return
{i18n('noteToSelfHero')}
; + } + + if (conversationType === 'direct' && groups && groups.length > 0) { + const firstThreeGroups = take(groups, 3).map((group, i) => ( + + + + )); + + return ( +
+ +
+ ); + } + + return null; +}; + +export const ConversationHero = ({ + i18n, + avatarPath, + color, + conversationType, + isMe, + membersCount, + groups = [], + name, + phoneNumber, + profileName, + onHeightChange, +}: Props) => { + const firstRenderRef = React.useRef(true); + + React.useEffect(() => { + // If any of the depenencies for this hook change then the height of this + // component may have changed. The cleanup function notifies listeners of + // any potential height changes. + return () => { + if (onHeightChange && !firstRenderRef.current) { + onHeightChange(); + } else { + firstRenderRef.current = false; + } + }; + }, [ + firstRenderRef, + onHeightChange, + // Avoid collisions in these dependencies by prefixing them + // These dependencies may be dynamic, and therefore may cause height changes + `mc-${membersCount}`, + `n-${name}`, + `pn-${profileName}`, + ...groups.map(g => `g-${g}`), + ]); + + return ( +
+ +

+ {isMe ? ( + i18n('noteToSelf') + ) : ( + + )} +

+ {!isMe ? ( +
+ {membersCount === 1 + ? i18n('ConversationHero--members-1') + : membersCount !== undefined + ? i18n('ConversationHero--members', [`${membersCount}`]) + : phoneNumber} +
+ ) : null} + {renderMembershipRow({ isMe, groups, conversationType, i18n })} +
+ ); +}; diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index 86cd36ace1c3..147e4f804e53 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -1,5 +1,6 @@ import React from 'react'; import classNames from 'classnames'; +import { Blurhash } from 'react-blurhash'; import { Spinner } from '../Spinner'; import { LocalizerType } from '../../types/Util'; @@ -30,6 +31,7 @@ interface Props { darkOverlay?: boolean; playIconOverlay?: boolean; softCorners?: boolean; + blurHash?: string; i18n: LocalizerType; onClick?: (attachment: AttachmentType) => void; @@ -38,7 +40,21 @@ interface Props { } export class Image extends React.Component { + private canClick() { + const { onClick, attachment, url } = this.props; + const { pending } = attachment || { pending: true }; + + return Boolean(onClick && !pending && url); + } + public handleClick = (event: React.MouseEvent) => { + if (!this.canClick()) { + event.preventDefault(); + event.stopPropagation(); + + return; + } + const { onClick, attachment } = this.props; if (onClick) { @@ -50,6 +66,13 @@ export class Image extends React.Component { }; public handleKeyDown = (event: React.KeyboardEvent) => { + if (!this.canClick()) { + event.preventDefault(); + event.stopPropagation(); + + return; + } + const { onClick, attachment } = this.props; if (onClick && (event.key === 'Enter' || event.key === 'Space')) { @@ -64,6 +87,7 @@ export class Image extends React.Component { const { alt, attachment, + blurHash, bottomOverlay, closeButton, curveBottomLeft, @@ -71,11 +95,10 @@ export class Image extends React.Component { curveTopLeft, curveTopRight, darkOverlay, - height, + height = 0, i18n, noBackground, noBorder, - onClick, onClickClose, onError, overlayText, @@ -84,18 +107,16 @@ export class Image extends React.Component { softCorners, tabIndex, url, - width, + width = 0, } = this.props; const { caption, pending } = attachment || { caption: null, pending: true }; - const canClick = onClick && !pending; + const canClick = this.canClick(); const overlayClassName = classNames( 'module-image__border-overlay', noBorder ? null : 'module-image__border-overlay--with-border', - canClick && onClick - ? 'module-image__border-overlay--with-click-handler' - : null, + canClick ? 'module-image__border-overlay--with-click-handler' : null, curveTopLeft ? 'module-image--curved-top-left' : null, curveTopRight ? 'module-image--curved-top-right' : null, curveBottomLeft ? 'module-image--curved-bottom-left' : null, @@ -105,19 +126,14 @@ export class Image extends React.Component { darkOverlay ? 'module-image__border-overlay--dark' : null ); - let overlay; - if (canClick && onClick) { - overlay = ( - + ) : ( + + )} + + {!isBlocked ? ( + + ) : null} + + + + ); +}; diff --git a/ts/components/conversation/MessageRequestActionsConfirmation.tsx b/ts/components/conversation/MessageRequestActionsConfirmation.tsx new file mode 100644 index 000000000000..7fb2b27dc614 --- /dev/null +++ b/ts/components/conversation/MessageRequestActionsConfirmation.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import { ContactName, Props as ContactNameProps } from './ContactName'; +import { ConfirmationModal } from '../ConfirmationModal'; +import { Intl } from '../Intl'; +import { LocalizerType } from '../../types/Util'; + +export enum MessageRequestState { + blocking, + deleting, + unblocking, + default, +} + +export type Props = { + i18n: LocalizerType; + conversationType: 'group' | 'direct'; + isBlocked?: boolean; + onBlock(): unknown; + onBlockAndDelete(): unknown; + onUnblock(): unknown; + onDelete(): unknown; + state: MessageRequestState; + onChangeState(state: MessageRequestState): unknown; +} & Omit; + +// tslint:disable-next-line: max-func-body-length +export const MessageRequestActionsConfirmation = ({ + i18n, + name, + profileName, + phoneNumber, + conversationType, + onBlock, + onBlockAndDelete, + onUnblock, + onDelete, + state, + onChangeState, +}: Props) => { + if (state === MessageRequestState.blocking) { + return ( + // tslint:disable-next-line: use-simple-attributes + { + onChangeState(MessageRequestState.default); + }} + title={ + , + ]} + /> + } + actions={[ + { + text: i18n('MessageRequests--block'), + action: onBlock, + style: 'negative', + }, + { + text: i18n('MessageRequests--block-and-delete'), + action: onBlockAndDelete, + style: 'negative', + }, + ]} + > + {i18n(`MessageRequests--block-${conversationType}-confirm-body`)} + + ); + } + + if (state === MessageRequestState.unblocking) { + return ( + // tslint:disable-next-line: use-simple-attributes + { + onChangeState(MessageRequestState.default); + }} + title={ + , + ]} + /> + } + actions={[ + { + text: i18n('MessageRequests--unblock'), + action: onUnblock, + style: 'affirmative', + }, + { + text: i18n('MessageRequests--delete'), + action: onDelete, + style: 'negative', + }, + ]} + > + {i18n(`MessageRequests--unblock-${conversationType}-confirm-body`)} + + ); + } + + if (state === MessageRequestState.deleting) { + return ( + // tslint:disable-next-line: use-simple-attributes + { + onChangeState(MessageRequestState.default); + }} + title={ + , + ]} + /> + } + actions={[ + { + text: i18n(`MessageRequests--delete-${conversationType}`), + action: onDelete, + style: 'negative', + }, + ]} + > + {i18n(`MessageRequests--delete-${conversationType}-confirm-body`)} + + ); + } + + return null; +}; diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index b949b9aa4c87..c1830c1a2c4a 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -50,6 +50,7 @@ type PropsHousekeepingType = { actions: Object ) => JSX.Element; renderLastSeenIndicator: (id: string) => JSX.Element; + renderHeroRow: (id: string, resizeHeroRow: () => unknown) => JSX.Element; renderLoadingRow: (id: string) => JSX.Element; renderTypingBubble: (id: string) => JSX.Element; }; @@ -250,6 +251,10 @@ export class Timeline extends React.PureComponent { this.recomputeRowHeights(row || 0); }; + public resizeHeroRow = () => { + this.resize(0); + }; + public onScroll = (data: OnScrollParamsType) => { // Ignore scroll events generated as react-virtualized recursively scrolls and // re-measures to get us where we want to go. @@ -501,6 +506,7 @@ export class Timeline extends React.PureComponent { haveOldest, items, renderItem, + renderHeroRow, renderLoadingRow, renderLastSeenIndicator, renderTypingBubble, @@ -515,7 +521,13 @@ export class Timeline extends React.PureComponent { const typingBubbleRow = this.getTypingBubbleRow(); let rowContents; - if (!haveOldest && row === 0) { + if (haveOldest && row === 0) { + rowContents = ( +
+ {renderHeroRow(id, this.resizeHeroRow)} +
+ ); + } else if (!haveOldest && row === 0) { rowContents = (
{renderLoadingRow(id)} @@ -574,13 +586,10 @@ export class Timeline extends React.PureComponent { }; public fromItemIndexToRow(index: number) { - const { haveOldest, oldestUnreadIndex } = this.props; + const { oldestUnreadIndex } = this.props; - let addition = 0; - - if (!haveOldest) { - addition += 1; - } + // We will always render either the hero row or the loading row + let addition = 1; if (isNumber(oldestUnreadIndex) && index >= oldestUnreadIndex) { addition += 1; @@ -590,15 +599,12 @@ export class Timeline extends React.PureComponent { } public getRowCount() { - const { haveOldest, oldestUnreadIndex, typingContact } = this.props; + const { oldestUnreadIndex, typingContact } = this.props; const { items } = this.props; const itemsCount = items && items.length ? items.length : 0; - let extraRows = 0; - - if (!haveOldest) { - extraRows += 1; - } + // We will always render either the hero row or the loading row + let extraRows = 1; if (isNumber(oldestUnreadIndex)) { extraRows += 1; @@ -612,13 +618,10 @@ export class Timeline extends React.PureComponent { } public fromRowToItemIndex(row: number, props?: Props): number | undefined { - const { haveOldest, items } = props || this.props; + const { items } = props || this.props; - let subtraction = 0; - - if (!haveOldest) { - subtraction += 1; - } + // We will always render either the hero row or the loading row + let subtraction = 1; const oldestUnreadRow = this.getLastSeenIndicatorRow(); if (isNumber(oldestUnreadRow) && row > oldestUnreadRow) { diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index d6ee5291650b..781788f5007b 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -30,6 +30,7 @@ const renderEmojiPicker: TimelineItemProps['renderEmojiPicker'] = ({ const getDefaultProps = () => ({ conversationId: 'conversation-id', + conversationAccepted: true, id: 'asdf', isSelected: false, selectMessage: action('selectMessage'), diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index a96d16458bb0..4f1b46ba8d66 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -77,6 +77,7 @@ export type TimelineItemType = type PropsLocalType = { conversationId: string; + conversationAccepted: boolean; item?: TimelineItemType; id: string; isSelected: boolean; diff --git a/ts/components/stickers/StickerManagerPackRow.tsx b/ts/components/stickers/StickerManagerPackRow.tsx index b7bb7f37a0d1..b9f0a95145b3 100644 --- a/ts/components/stickers/StickerManagerPackRow.tsx +++ b/ts/components/stickers/StickerManagerPackRow.tsx @@ -92,8 +92,13 @@ export const StickerManagerPackRow = React.memo( {i18n('stickers--StickerManager--UninstallWarning')} diff --git a/ts/components/stickers/StickerPreviewModal.tsx b/ts/components/stickers/StickerPreviewModal.tsx index 5be8f9ecdd31..1cb76e9917cd 100644 --- a/ts/components/stickers/StickerPreviewModal.tsx +++ b/ts/components/stickers/StickerPreviewModal.tsx @@ -174,8 +174,13 @@ export const StickerPreviewModal = React.memo( {i18n('stickers--StickerManager--UninstallWarning')} diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 61dd1ae47ab4..3b38ef876080 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -852,8 +852,8 @@ async function searchMessagesInConversation( // Message -async function getMessageCount() { - return channels.getMessageCount(); +async function getMessageCount(conversationId?: string) { + return channels.getMessageCount(conversationId); } async function saveMessage( diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index bd6ca9f4330c..547ff84e58f9 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -85,7 +85,7 @@ export interface DataInterface { options?: { limit?: number } ) => Promise>; - getMessageCount: () => Promise; + getMessageCount: (conversationId?: string) => Promise; saveMessages: ( arrayOfMessages: Array, options: { forceSave?: boolean } diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 7e7cbfa2b00b..d4a7401f6249 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -1542,6 +1542,40 @@ async function updateToSchemaVersion20( } } +async function updateToSchemaVersion21( + currentVersion: number, + instance: PromisifiedSQLDatabase +) { + if (currentVersion >= 21) { + return; + } + try { + await instance.run('BEGIN TRANSACTION;'); + await instance.run(` + UPDATE conversations + SET json = json_set( + json, + '$.messageCount', + (SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id) + ); + `); + await instance.run(` + UPDATE conversations + SET json = json_set( + json, + '$.sentMessageCount', + (SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id AND messages.type = 'outgoing') + ); + `); + await instance.run('PRAGMA user_version = 21;'); + await instance.run('COMMIT TRANSACTION;'); + console.log('updateToSchemaVersion21: success!'); + } catch (error) { + await instance.run('ROLLBACK'); + throw error; + } +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -1563,6 +1597,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion18, updateToSchemaVersion19, updateToSchemaVersion20, + updateToSchemaVersion21, ]; async function updateSchema(instance: PromisifiedSQLDatabase) { @@ -2326,9 +2361,14 @@ async function searchMessagesInConversation( })); } -async function getMessageCount() { +async function getMessageCount(conversationId?: string) { const db = getInstance(); - const row = await db.get('SELECT count(*) from messages;'); + const row = conversationId + ? await db.get( + 'SELECT count(*) from messages WHERE conversationId = $conversationId;', + { $conversationId: conversationId } + ) + : await db.get('SELECT count(*) from messages;'); if (!row) { throw new Error('getMessageCount: Unable to get count of messages'); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index bf1a26a4109e..a4e6ff9104a9 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -12,6 +12,7 @@ import { import { trigger } from '../../shims/events'; import { NoopActionType } from './noop'; import { AttachmentType } from '../../types/Attachment'; +import { ColorType } from '../../types/Util'; // State @@ -24,7 +25,11 @@ export type DBConversationType = { export type ConversationType = { id: string; name?: string; - isArchived: boolean; + profileName?: string; + avatarPath?: string; + color?: ColorType; + isArchived?: boolean; + isBlocked?: boolean; activeAt?: number; timestamp: number; inboxPosition: number; @@ -33,6 +38,7 @@ export type ConversationType = { text: string; }; phoneNumber: string; + membersCount?: number; type: 'direct' | 'group'; isMe: boolean; lastUpdated: number; @@ -49,6 +55,9 @@ export type ConversationType = { shouldShowDraft?: boolean; draftText?: string; draftPreview?: string; + + messageRequestsEnabled?: boolean; + acceptedMessageRequest?: boolean; }; export type ConversationLookupType = { [key: string]: ConversationType; diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index e6c560fd9a6c..7888f9527202 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -71,6 +71,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { recentStickers, showIntroduction, showPickerHint, + // Message Requests + messageRequestsEnabled: conversation.messageRequestsEnabled, + acceptedMessageRequest: conversation.acceptedMessageRequest, + isBlocked: conversation.isBlocked, + conversationType: conversation.type, + name: conversation.name, + profileName: conversation.profileName, + phoneNumber: conversation.phoneNumber, }; }; diff --git a/ts/state/smart/HeroRow.tsx b/ts/state/smart/HeroRow.tsx new file mode 100644 index 000000000000..345a5e3af574 --- /dev/null +++ b/ts/state/smart/HeroRow.tsx @@ -0,0 +1,37 @@ +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; + +import { ConversationHero } from '../../components/conversation/ConversationHero'; + +import { StateType } from '../reducer'; +import { getIntl } from '../selectors/user'; + +type ExternalProps = { + id: string; +}; + +const mapStateToProps = (state: StateType, props: ExternalProps) => { + const { id } = props; + + const conversation = state.conversations.conversationLookup[id]; + + if (!conversation) { + throw new Error(`Did not find conversation ${id} in state!`); + } + + return { + i18n: getIntl(state), + avatarPath: conversation.avatarPath, + color: conversation.color, + conversationType: conversation.type, + isMe: conversation.isMe, + membersCount: conversation.membersCount, + name: conversation.name, + phoneNumber: conversation.phoneNumber, + profileName: conversation.profileName, + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartHeroRow = smart(ConversationHero); diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 2b2c0a06b3c2..f82de38fdb33 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -16,6 +16,7 @@ import { import { SmartTimelineItem } from './TimelineItem'; import { SmartTypingBubble } from './TypingBubble'; import { SmartLastSeenIndicator } from './LastSeenIndicator'; +import { SmartHeroRow } from './HeroRow'; import { SmartTimelineLoadingRow } from './TimelineLoadingRow'; import { SmartEmojiPicker } from './EmojiPicker'; @@ -24,6 +25,7 @@ import { SmartEmojiPicker } from './EmojiPicker'; const FilteredSmartTimelineItem = SmartTimelineItem as any; const FilteredSmartTypingBubble = SmartTypingBubble as any; const FilteredSmartLastSeenIndicator = SmartLastSeenIndicator as any; +const FilteredSmartHeroRow = SmartHeroRow as any; const FilteredSmartTimelineLoadingRow = SmartTimelineLoadingRow as any; type ExternalProps = { @@ -66,6 +68,9 @@ function renderEmojiPicker({ function renderLastSeenIndicator(id: string): JSX.Element { return ; } +function renderHeroRow(id: string, onHeightChange: () => unknown): JSX.Element { + return ; +} function renderLoadingRow(id: string): JSX.Element { return ; } @@ -88,6 +93,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { i18n: getIntl(state), renderItem, renderLastSeenIndicator, + renderHeroRow, renderLoadingRow, renderTypingBubble, ...actions, diff --git a/ts/test/state/selectors/conversations_test.ts b/ts/test/state/selectors/conversations_test.ts index 25b3051d069c..10e5a9a31def 100644 --- a/ts/test/state/selectors/conversations_test.ts +++ b/ts/test/state/selectors/conversations_test.ts @@ -31,6 +31,8 @@ describe('state/selectors/conversations', () => { color: 'blue', phoneNumber: '+18005551111', }, + + acceptedMessageRequest: true, }, id2: { id: 'id2', @@ -51,6 +53,8 @@ describe('state/selectors/conversations', () => { color: 'blue', phoneNumber: '+18005551111', }, + + acceptedMessageRequest: true, }, id3: { id: 'id3', @@ -71,6 +75,8 @@ describe('state/selectors/conversations', () => { color: 'blue', phoneNumber: '+18005551111', }, + + acceptedMessageRequest: true, }, id4: { id: 'id4', @@ -91,6 +97,8 @@ describe('state/selectors/conversations', () => { color: 'blue', phoneNumber: '+18005551111', }, + + acceptedMessageRequest: true, }, id5: { id: 'id5', @@ -111,6 +119,8 @@ describe('state/selectors/conversations', () => { color: 'blue', phoneNumber: '+18005551111', }, + + acceptedMessageRequest: true, }, }; const comparator = _getConversationComparator(i18n, regionCode); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index be66538035a6..88f1f2688658 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -531,6 +531,7 @@ export declare class SyncMessageClass { padding?: ProtoBinaryType; stickerPackOperation?: Array; viewOnceOpen?: SyncMessageClass.ViewOnceOpen; + messageRequestResponse?: SyncMessageClass.MessageRequestResponse; } // Note: we need to use namespaces to express nested classes in Typescript @@ -582,6 +583,13 @@ export declare namespace SyncMessageClass { senderUuid?: string; timestamp?: ProtoBinaryType; } + + class MessageRequestResponse { + threadE164?: string; + threadUuid?: string; + groupId?: ProtoBinaryType; + type?: number; + } } // Note: we need to use namespaces to express nested classes in Typescript @@ -612,6 +620,16 @@ export declare namespace SyncMessageClass.StickerPackOperation { } } +export declare namespace SyncMessageClass.MessageRequestResponse { + class Type { + static UNKNOWN: number; + static ACCEPT: number; + static DELETE: number; + static BLOCK: number; + static BLOCK_AND_DELETE: number; + } +} + export declare class TypingMessageClass { static decode: ( data: ArrayBuffer | ByteBufferClass, diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index ea5590d2cb78..5d61144ce552 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -42,6 +42,8 @@ declare global { deliveryReceipt?: any; error?: any; groupDetails?: any; + groupId?: string; + messageRequestResponseType?: number; proto?: any; read?: any; reason?: any; @@ -51,6 +53,8 @@ declare global { source?: any; sourceUuid?: any; stickerPacks?: any; + threadE164?: string; + threadUuid?: string; timestamp?: any; typing?: any; verified?: any; @@ -1119,6 +1123,22 @@ class MessageReceiverInner extends EventTarget { ) { p = this.handleEndSession(destination); } + + if ( + msg.flags && + msg.flags & + window.textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE + ) { + const ev = new Event('profileKeyUpdate'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.data = { + source: envelope.source, + sourceUuid: envelope.sourceUuid, + profileKey: msg.profileKey.toString('base64'), + }; + return this.dispatchAndWait(ev); + } + return p.then(async () => this.processDecrypted(envelope, msg).then(message => { const groupId = message.group && message.group.id; @@ -1373,6 +1393,11 @@ class MessageReceiverInner extends EventTarget { ); } else if (syncMessage.viewOnceOpen) { return this.handleViewOnceOpen(envelope, syncMessage.viewOnceOpen); + } else if (syncMessage.messageRequestResponse) { + return this.handleMessageRequestResponse( + envelope, + syncMessage.messageRequestResponse + ); } this.removeFromCache(envelope); @@ -1408,6 +1433,27 @@ class MessageReceiverInner extends EventTarget { return this.dispatchAndWait(ev); } + async handleMessageRequestResponse( + envelope: EnvelopeClass, + sync: SyncMessageClass.MessageRequestResponse + ) { + window.log.info('got message request response sync message'); + + const ev = new Event('messageRequestResponse'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.threadE164 = sync.threadE164; + ev.threadUuid = sync.threadUuid; + ev.groupId = sync.groupId ? sync.groupId.toString('binary') : null; + ev.messageRequestResponseType = sync.type; + + window.normalizeUuids( + ev, + ['threadUuid'], + 'MessageReceiver::handleMessageRequestResponse' + ); + + return this.dispatchAndWait(ev); + } async handleStickerPackOperation( envelope: EnvelopeClass, operations: Array diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 34e8b60f1f7b..a8cb6f67f36a 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -74,7 +74,7 @@ type MessageOptionsType = { }; needsSync?: boolean; preview?: Array | null; - profileKey?: string; + profileKey?: ArrayBuffer; quote?: any; recipients: Array; sticker?: any; @@ -93,7 +93,7 @@ class Message { }; needsSync?: boolean; preview: any; - profileKey?: string; + profileKey?: ArrayBuffer; quote?: any; recipients: Array; sticker?: any; @@ -274,6 +274,8 @@ export type AttachmentType = { caption: string; attachmentPointer?: AttachmentPointerClass; + + blurHash?: string; }; export default class MessageSender { @@ -348,6 +350,9 @@ export default class MessageSender { if (attachment.caption) { proto.caption = attachment.caption; } + if (attachment.blurHash) { + proto.blurHash = attachment.blurHash; + } return proto; } @@ -862,6 +867,31 @@ export default class MessageSender { ); } + async sendProfileKeyUpdate( + profileKey: ArrayBuffer, + recipients: Array, + sendOptions: SendOptionsType, + groupId?: string + ) { + return this.sendMessage( + { + recipients, + timestamp: Date.now(), + profileKey, + flags: window.textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE, + ...(groupId + ? { + group: { + id: groupId, + type: window.textsecure.protobuf.GroupContext.Type.DELIVER, + }, + } + : {}), + }, + sendOptions + ); + } + async sendDeliveryReceipt( recipientE164: string, recipientUuid: string, @@ -985,6 +1015,48 @@ export default class MessageSender { ); } + async syncMessageRequestResponse( + responseArgs: { + threadE164?: string; + threadUuid?: string; + groupId?: string; + type: number; + }, + sendOptions?: SendOptionsType + ) { + const myNumber = window.textsecure.storage.user.getNumber(); + const myUuid = window.textsecure.storage.user.getUuid(); + const myDevice = window.textsecure.storage.user.getDeviceId(); + if (myDevice === 1 || myDevice === '1') { + return null; + } + + const syncMessage = this.createSyncMessage(); + + const response = new window.textsecure.protobuf.SyncMessage.MessageRequestResponse(); + response.threadE164 = responseArgs.threadE164; + response.threadUuid = responseArgs.threadUuid; + response.groupId = responseArgs.groupId + ? window.Signal.Crypto.fromEncodedBinaryToArrayBuffer( + responseArgs.groupId + ) + : null; + response.type = responseArgs.type; + syncMessage.messageRequestResponse = response; + + const contentMessage = new window.textsecure.protobuf.Content(); + contentMessage.syncMessage = syncMessage; + + const silent = true; + return this.sendIndividualProto( + myUuid || myNumber, + contentMessage, + Date.now(), + silent, + sendOptions + ); + } + async sendStickerPackSync( operations: Array<{ packId: string; @@ -1152,7 +1224,7 @@ export default class MessageSender { reaction: any, timestamp: number, expireTimer: number | undefined, - profileKey?: string, + profileKey?: ArrayBuffer, flags?: number ) { const attributes = { @@ -1195,7 +1267,7 @@ export default class MessageSender { reaction: any, timestamp: number, expireTimer: number | undefined, - profileKey?: string, + profileKey?: ArrayBuffer, options?: SendOptionsType ) { return this.sendMessage( @@ -1308,7 +1380,7 @@ export default class MessageSender { reaction: any, timestamp: number, expireTimer: number | undefined, - profileKey?: string, + profileKey?: ArrayBuffer, options?: SendOptionsType ) { const myE164 = window.textsecure.storage.user.getNumber(); @@ -1480,7 +1552,7 @@ export default class MessageSender { groupIdentifiers: Array, expireTimer: number | undefined, timestamp: number, - profileKey?: string, + profileKey?: ArrayBuffer, options?: SendOptionsType ) { const myNumber = window.textsecure.storage.user.getNumber(); @@ -1517,7 +1589,7 @@ export default class MessageSender { identifier: string, expireTimer: number | undefined, timestamp: number, - profileKey?: string, + profileKey?: ArrayBuffer, options?: SendOptionsType ) { return this.sendMessage( diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 04dd5716cbce..3595740bc632 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -489,6 +489,7 @@ const URL_CALLS = { signed: 'v2/keys/signed', getStickerPackUpload: 'v1/sticker/pack/form', whoami: 'v1/accounts/whoami', + config: 'v1/config', }; type InitializeOptionsType = { @@ -604,6 +605,7 @@ export type WebAPIType = { setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise; updateDeviceName: (deviceName: string) => Promise; whoami: () => Promise; + getConfig: () => Promise>; }; export type SignedPreKeyType = { @@ -724,9 +726,10 @@ export function initialize({ setSignedPreKey, updateDeviceName, whoami, + getConfig, }; - async function _ajax(param: AjaxOptionsType) { + async function _ajax(param: AjaxOptionsType): Promise { if (!param.urlParameters) { param.urlParameters = ''; } @@ -792,6 +795,21 @@ export function initialize({ }); } + async function getConfig() { + type ResType = { + config: Array<{ name: string; enabled: boolean }>; + }; + const res: ResType = await _ajax({ + call: 'config', + httpType: 'GET', + responseType: 'json', + }); + + return res.config.filter(({ name }: { name: string }) => + name.startsWith('desktop.') + ); + } + async function getSenderCertificate() { return _ajax({ call: 'deliveryCert', diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 3a557ea2f9d7..736798a34c36 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -18,6 +18,7 @@ const MIN_HEIGHT = 50; // Used for display export interface AttachmentType { + blurHash?: string; caption?: string; contentType: MIME.MIMEType; fileName: string; @@ -133,7 +134,7 @@ export function hasImage(attachments?: Array) { return ( attachments && attachments[0] && - (attachments[0].url || attachments[0].pending) + (attachments[0].url || attachments[0].pending || attachments[0].blurHash) ); } diff --git a/ts/util/imageToBlurHash.ts b/ts/util/imageToBlurHash.ts new file mode 100644 index 000000000000..1cddd4add3a4 --- /dev/null +++ b/ts/util/imageToBlurHash.ts @@ -0,0 +1,50 @@ +import loadImage from 'blueimp-load-image'; +import { encode } from 'blurhash'; + +type Input = Parameters[0]; + +const loadImageData = async (input: Input): Promise => { + return new Promise((resolve, reject) => { + loadImage( + input, + canvasOrError => { + if (canvasOrError instanceof Event && canvasOrError.type === 'error') { + const processError = new Error( + 'imageToBlurHash: Failed to process image' + ); + processError.cause = canvasOrError; + reject(processError); + return; + } + if (canvasOrError instanceof HTMLCanvasElement) { + const context = canvasOrError.getContext('2d'); + resolve( + context?.getImageData( + 0, + 0, + canvasOrError.width, + canvasOrError.height + ) + ); + } + const error = new Error( + 'imageToBlurHash: Failed to place image on canvas' + ); + reject(error); + return; + }, + // Calculating the blurhash on large images is a long-running and + // synchronous operation, so here we ensure the images are a reasonable + // size before calculating the blurhash. iOS uses a max size of 200x200 + // and Android uses a max size of 1/16 the original size. 200x200 is + // easier for us. + { canvas: true, orientation: true, maxWidth: 200, maxHeight: 200 } + ); + }); +}; + +export const imageToBlurHash = async (input: Input) => { + const { data, width, height } = await loadImageData(input); + // 4 horizontal components and 3 vertical components + return encode(data, width, height, 4, 3); +}; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 9dc72ba74104..b5a64eb05cf5 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -219,6 +219,14 @@ "reasonCategory": "usageTrusted", "updated": "2020-03-25T15:45:04.024Z" }, + { + "rule": "jQuery-wrap(", + "path": "js/models/conversations.js", + "line": " await wrap(", + "lineNumber": 641, + "reasonCategory": "falseMatch", + "updated": "2020-05-27T21:15:43.044Z" + }, { "rule": "jQuery-append(", "path": "js/modules/debuglogs.js", @@ -570,7 +578,7 @@ "line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {", "lineNumber": 198, "reasonCategory": "usageTrusted", - "updated": "2019-10-21T22:30:15.622Z", + "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Known DOM elements" }, { @@ -579,7 +587,7 @@ "line": " this.$('.conversation:first .recorder').trigger('close');", "lineNumber": 201, "reasonCategory": "usageTrusted", - "updated": "2019-10-21T22:30:15.622Z", + "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Hardcoded selector" }, { @@ -11426,18 +11434,18 @@ "rule": "DOM-innerHTML", "path": "ts/components/CompositionArea.js", "line": " el.innerHTML = '';", - "lineNumber": 22, + "lineNumber": 23, "reasonCategory": "usageTrusted", - "updated": "2019-08-01T14:10:37.481Z", + "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Our code, no user input, only clearing out the dom" }, { "rule": "DOM-innerHTML", "path": "ts/components/CompositionArea.tsx", "line": " el.innerHTML = '';", - "lineNumber": 73, + "lineNumber": 80, "reasonCategory": "usageTrusted", - "updated": "2019-12-16T14:36:25.614ZZ", + "updated": "2020-06-03T19:23:21.195Z", "reasonDetail": "Our code, no user input, only clearing out the dom" }, { @@ -11507,9 +11515,9 @@ "rule": "React-createRef", "path": "ts/components/conversation/ConversationHeader.tsx", "line": " this.menuTriggerRef = React.createRef();", - "lineNumber": 67, + "lineNumber": 68, "reasonCategory": "usageTrusted", - "updated": "2019-07-31T00:19:18.696Z", + "updated": "2020-05-20T20:10:43.540Z", "reasonDetail": "Used to reference popup menu" }, { @@ -11553,7 +11561,7 @@ "line": " public audioRef: React.RefObject = React.createRef();", "lineNumber": 184, "reasonCategory": "usageTrusted", - "updated": "2020-04-30T15:59:13.160Z" + "updated": "2020-05-21T16:56:07.875Z" }, { "rule": "React-createRef", @@ -11561,7 +11569,7 @@ "line": " > = React.createRef();", "lineNumber": 188, "reasonCategory": "usageTrusted", - "updated": "2020-04-30T15:59:13.160Z" + "updated": "2020-05-21T16:56:07.875Z" }, { "rule": "React-createRef", @@ -11784,4 +11792,4 @@ "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" } -] \ No newline at end of file +] diff --git a/ts/window.d.ts b/ts/window.d.ts index adf8e502e807..5a3909aeea50 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -31,7 +31,7 @@ declare global { storage: { put: (key: string, value: any) => void; remove: (key: string) => void; - get: (key: string) => any; + get: (key: string) => T | undefined; }; textsecure: TextSecureType; @@ -48,6 +48,10 @@ declare global { WebAPI: WebAPIConnectType; Whisper: WhisperType; } + + interface Error { + cause?: Event; + } } export type ConversationType = { diff --git a/yarn.lock b/yarn.lock index 028c8fcd5e2d..89c213602fc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2052,6 +2052,13 @@ "@types/jquery" "*" "@types/underscore" "*" +"@types/blueimp-load-image@2.23.6": + version "2.23.6" + resolved "https://registry.yarnpkg.com/@types/blueimp-load-image/-/blueimp-load-image-2.23.6.tgz#2ccf3c69bd17c5bd1f4471470505a7f065a84a9f" + integrity sha512-RF5EQ2miGm/o5XmZk0PhpLnikXOwe1BxGJVikYcNhwr4s7i7LqVc4U51k/zFTD11GeqyK+yz6vWwvvtoKsPYLw== + dependencies: + "@types/node" "*" + "@types/body-parser@*": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897" @@ -3957,6 +3964,11 @@ blueimp-load-image@2.18.0: resolved "https://registry.yarnpkg.com/blueimp-load-image/-/blueimp-load-image-2.18.0.tgz#03b93687eb382a7136cfbcbd4f0e936b6763fc0e" integrity sha512-GUrxVE/7FpzAw/WU6GMiI3v+LpFmlAxp7sF36EQB8rGAg97ND8iTeYZ3FQbhsxS5s2dNarGKZEWhKPNKKSmMuA== +blurhash@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.3.tgz#dc325af7da836d07a0861d830bdd63694382483e" + integrity sha512-yUhPJvXexbqbyijCIE/T2NCXcj9iNPhWmOKbPTuR/cm7Q5snXYIfnVnz6m7MWOXxODMz/Cr3UcVkRdHiuDVRDw== + bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: version "4.11.8" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" @@ -13579,6 +13591,11 @@ rc@^1.1.2: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-blurhash@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/react-blurhash/-/react-blurhash-0.1.2.tgz#16bdce59be4f48dc7816a26e8f0435f73d3a2bb2" + integrity sha512-p7TAQ+Qw78rBO9LSxkURfNDJA8TGAuOW2GVJKzNHrOY61XtA02PYmpAY9lotbFpftczEXZ7h4Su2JRmEUI7/Hw== + react-clientside-effect@^1.2.0: version "1.2.2" resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.2.tgz#6212fb0e07b204e714581dd51992603d1accc837"