From a43a78731a40c3458b2db3953beda22945b36a56 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 30 Jan 2019 17:45:58 -0800 Subject: [PATCH] Note to Self --- _locales/en/messages.json | 4 + images/note-28.svg | 1 + js/models/conversations.js | 24 ++- js/models/messages.js | 118 ++++++++-- js/views/conversation_search_view.js | 20 +- libtextsecure/sendmessage.js | 32 +++ stylesheets/_modules.scss | 12 +- stylesheets/_theme_dark.scss | 12 +- ts/components/Avatar.md | 39 ++++ ts/components/Avatar.tsx | 19 +- ts/components/ConversationListItem.md | 21 ++ ts/components/ConversationListItem.tsx | 20 +- .../conversation/ConversationHeader.md | 202 ++++++++++-------- .../conversation/ConversationHeader.tsx | 19 +- ts/util/lint/exceptions.json | 16 +- 15 files changed, 411 insertions(+), 148 deletions(-) create mode 100644 images/note-28.svg diff --git a/_locales/en/messages.json b/_locales/en/messages.json index df5d1a8e6..37be8253c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1544,6 +1544,10 @@ "message": "Dark", "description": "Label text for dark theme" }, + "noteToSelf": { + "message": "Note to Self", + "description": "Name for the conversation with your own phone number" + }, "hideMenuBar": { "message": "Hide menu bar", "description": "Label text for menu bar visibility setting" diff --git a/images/note-28.svg b/images/note-28.svg new file mode 100644 index 000000000..a37aefc54 --- /dev/null +++ b/images/note-28.svg @@ -0,0 +1 @@ +note-28 \ No newline at end of file diff --git a/js/models/conversations.js b/js/models/conversations.js index e1dc3f8ee..160be22a2 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -312,6 +312,7 @@ const result = { ...this.format(), + isMe: this.isMe(), conversationType: this.isPrivate() ? 'direct' : 'group', lastUpdated: this.get('timestamp'), @@ -908,6 +909,25 @@ return null; } + const attachmentsWithData = await Promise.all( + messageWithSchema.attachments.map(loadAttachmentData) + ); + + // Special-case the self-send case - we send only a sync message + if (this.isMe()) { + const dataMessage = await textsecure.messaging.getMessageProto( + destination, + body, + attachmentsWithData, + quote, + preview, + now, + expireTimer, + profileKey + ); + return message.sendSyncMessageOnly(dataMessage); + } + const conversationType = this.get('type'); const sendFunction = (() => { switch (conversationType) { @@ -922,10 +942,6 @@ } })(); - const attachmentsWithData = await Promise.all( - messageWithSchema.attachments.map(loadAttachmentData) - ); - const options = this.getSendOptions(); return message.send( this.wrapSend( diff --git a/js/models/messages.js b/js/models/messages.js index 36f2a2c64..9b342a076 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -736,10 +736,25 @@ const quoteWithData = await loadQuoteData(this.get('quote')); const previewWithData = await loadPreviewData(this.get('preview')); - const conversation = this.getConversation(); - const options = conversation.getSendOptions(); + // Special-case the self-send case - we send only a sync message + if (numbers.length === 1 && numbers[0] === this.OUR_NUMBER) { + const [number] = numbers; + const dataMessage = await textsecure.messaging.getMessageProto( + number, + this.get('body'), + attachmentsWithData, + quoteWithData, + previewWithData, + this.get('sent_at'), + this.get('expireTimer'), + profileKey + ); + return this.sendSyncMessageOnly(dataMessage); + } let promise; + const conversation = this.getConversation(); + const options = conversation.getSendOptions(); if (conversation.isPrivate()) { const [number] = numbers; @@ -794,18 +809,21 @@ // One caller today: ConversationView.forceSend() async resend(number) { const error = this.removeOutgoingErrors(number); - if (error) { - const profileKey = null; - const attachmentsWithData = await Promise.all( - (this.get('attachments') || []).map(loadAttachmentData) - ); - const quoteWithData = await loadQuoteData(this.get('quote')); - const previewWithData = await loadPreviewData(this.get('preview')); + if (!error) { + window.log.warn('resend: requested number was not present in errors'); + return null; + } - const { wrap, sendOptions } = ConversationController.prepareForSend( - number - ); - const promise = textsecure.messaging.sendMessageToNumber( + const profileKey = null; + const attachmentsWithData = await Promise.all( + (this.get('attachments') || []).map(loadAttachmentData) + ); + const quoteWithData = await loadQuoteData(this.get('quote')); + const previewWithData = await loadPreviewData(this.get('preview')); + + // Special-case the self-send case - we send only a sync message + if (number === this.OUR_NUMBER) { + const dataMessage = await textsecure.messaging.getMessageProto( number, this.get('body'), attachmentsWithData, @@ -813,12 +831,27 @@ previewWithData, this.get('sent_at'), this.get('expireTimer'), - profileKey, - sendOptions + profileKey ); - - this.send(wrap(promise)); + return this.sendSyncMessageOnly(dataMessage); } + + const { wrap, sendOptions } = ConversationController.prepareForSend( + number + ); + const promise = textsecure.messaging.sendMessageToNumber( + number, + this.get('body'), + attachmentsWithData, + quoteWithData, + previewWithData, + this.get('sent_at'), + this.get('expireTimer'), + profileKey, + sendOptions + ); + + return this.send(wrap(promise)); }, removeOutgoingErrors(number) { const errors = _.partition( @@ -912,7 +945,7 @@ this.trigger('done'); // This is used by sendSyncMessage, then set to null - if (result.dataMessage) { + if (!this.get('synced') && result.dataMessage) { this.set({ dataMessage: result.dataMessage }); } @@ -1013,6 +1046,35 @@ return false; }, + async sendSyncMessageOnly(dataMessage) { + this.set({ dataMessage }); + + try { + await this.sendSyncMessage(); + this.set({ + delivered_to: [this.OUR_NUMBER], + read_by: [this.OUR_NUMBER], + }); + } catch (result) { + const errors = (result && result.errors) || [ + new Error('Unknown error'), + ]; + this.set({ errors }); + } finally { + await window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); + this.trigger('done'); + + const errors = this.get('errors'); + if (errors) { + this.trigger('send-error', errors); + } else { + this.trigger('sent'); + } + } + }, + sendSyncMessage() { const ourNumber = textsecure.storage.user.getNumber(); const { wrap, sendOptions } = ConversationController.prepareForSend( @@ -1021,7 +1083,7 @@ ); this.syncPromise = this.syncPromise || Promise.resolve(); - this.syncPromise = this.syncPromise.then(() => { + const next = () => { const dataMessage = this.get('dataMessage'); if (this.get('synced') || !dataMessage) { return Promise.resolve(); @@ -1036,16 +1098,20 @@ this.get('unidentifiedDeliveries'), sendOptions ) - ).then(() => { + ).then(result => { this.set({ synced: true, dataMessage: null, }); return window.Signal.Data.saveMessage(this.attributes, { Message: Whisper.Message, - }); + }).then(() => result); }); - }); + }; + + this.syncPromise = this.syncPromise.then(next, next); + + return this.syncPromise; }, async saveErrors(providedErrors) { @@ -1312,6 +1378,14 @@ }); } + // A sync'd message to ourself is automatically considered read and delivered + if (conversation.isMe()) { + message.set({ + read_by: conversation.getRecipients(), + delivered_to: conversation.getRecipients(), + }); + } + message.set({ recipients: conversation.getRecipients() }); } diff --git a/js/views/conversation_search_view.js b/js/views/conversation_search_view.js index cc63c9115..d1aa848e7 100644 --- a/js/views/conversation_search_view.js +++ b/js/views/conversation_search_view.js @@ -1,6 +1,4 @@ -/* global ConversationController: false */ -/* global i18n: false */ -/* global Whisper: false */ +/* global ConversationController, i18n, textsecure, Whisper */ // eslint-disable-next-line func-names (function() { @@ -81,9 +79,19 @@ /* eslint-disable more/no-then */ this.pending = this.pending.then(() => this.typeahead.search(query).then(() => { - this.typeahead_view.collection.reset( - this.typeahead.filter(isSearchable) - ); + let results = this.typeahead.filter(isSearchable); + const noteToSelf = i18n('noteToSelf'); + if (noteToSelf.toLowerCase().indexOf(query.toLowerCase()) !== -1) { + const ourNumber = textsecure.storage.user.getNumber(); + const conversation = ConversationController.get(ourNumber); + if (conversation) { + // ensure that we don't have duplicates in our results + results = results.filter(item => item.id !== ourNumber); + results.unshift(conversation); + } + } + + this.typeahead_view.collection.reset(results); }) ); /* eslint-enable more/no-then */ diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index ab070ff55..08ade9036 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -759,6 +759,37 @@ MessageSender.prototype = { }); }, + async getMessageProto( + number, + body, + attachments, + quote, + preview, + timestamp, + expireTimer, + profileKey + ) { + const attributes = { + recipients: [number], + body, + timestamp, + attachments, + quote, + preview, + expireTimer, + profileKey, + }; + + const message = new Message(attributes); + await Promise.all([ + this.uploadAttachments(message), + this.uploadThumbnails(message), + this.uploadLinkPreviews(message), + ]); + + return message.toArrayBuffer(); + }, + sendMessageToNumber( number, messageText, @@ -1110,6 +1141,7 @@ textsecure.MessageSender = function MessageSenderWrapper( this.sendReadReceipts = sender.sendReadReceipts.bind(sender); this.makeProxiedRequest = sender.makeProxiedRequest.bind(sender); this.getProxiedSize = sender.getProxiedSize.bind(sender); + this.getMessageProto = sender.getMessageProto.bind(sender); }; textsecure.MessageSender.prototype = { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 54352b98b..478e20e68 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1348,7 +1348,7 @@ } .module-conversation-header__title { - margin-left: 8px; + margin-left: 6px; min-width: 0; font-size: 16px; @@ -1356,8 +1356,8 @@ font-weight: 300; color: $color-gray-90; - // width of avatar (28px) and our 8px left margin - max-width: calc(100% - 36px); + // width of avatar (28px) and our 6px left margin + max-width: calc(100% - 34px); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -2036,6 +2036,12 @@ width: 42px; } +.module-avatar__icon--note-to-self { + width: 70%; + height: 70%; + @include color-svg('../images/note-28.svg', $color-white); +} + .module-avatar--no-image { background-color: $color-conversation-grey; } diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index 13b244f15..928ef8064 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -1098,6 +1098,10 @@ body.dark-theme { color: $color-dark-05; } + .module-conversation-header__note-to-self { + color: $color-dark-05; + } + .module-conversation-header__title__verified-icon { @include color-svg('../images/verified-check.svg', $color-dark-05); } @@ -1262,11 +1266,15 @@ body.dark-theme { } .module-avatar__icon--group { - @include color-svg('../images/profile-group.svg', $color-gray-05); + background-color: $color-gray-05; } .module-avatar__icon--direct { - @include color-svg('../images/profile-individual.svg', $color-gray-05); + background-color: $color-gray-05; + } + + .module-avatar__icon--note-to-self { + background-color: $color-gray-05; } .module-avatar--no-image { diff --git a/ts/components/Avatar.md b/ts/components/Avatar.md index a6e1146b4..94a7120be 100644 --- a/ts/components/Avatar.md +++ b/ts/components/Avatar.md @@ -63,6 +63,45 @@ ``` +### Note to self + +```jsx + + + + + + +``` + ### All colors ```jsx diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 9a921ac1d..23003685f 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -9,6 +9,7 @@ interface Props { color?: string; conversationType: 'group' | 'direct'; i18n: Localizer; + noteToSelf?: boolean; name?: string; phoneNumber?: string; profileName?: string; @@ -63,11 +64,23 @@ export class Avatar extends React.Component { } public renderNoImage() { - const { conversationType, name, size } = this.props; + const { conversationType, name, noteToSelf, size } = this.props; const initials = getInitials(name); const isGroup = conversationType === 'group'; + if (noteToSelf) { + return ( +
+ ); + } + if (!isGroup && initials) { return (
{ } public render() { - const { avatarPath, color, size } = this.props; + const { avatarPath, color, size, noteToSelf } = this.props; const { imageBroken } = this.state; - const hasImage = avatarPath && !imageBroken; + const hasImage = !noteToSelf && avatarPath && !imageBroken; if (size !== 28 && size !== 36 && size !== 48 && size !== 80) { throw new Error(`Size ${size} is not supported!`); diff --git a/ts/components/ConversationListItem.md b/ts/components/ConversationListItem.md index 77c0994d4..25bdeba9c 100644 --- a/ts/components/ConversationListItem.md +++ b/ts/components/ConversationListItem.md @@ -38,6 +38,27 @@ ``` +#### Conversation with yourself + +```jsx + + console.log('onClick')} + i18n={util.i18n} + /> + +``` + #### All types of status ```jsx diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index 7b0066262..9383a12b7 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -16,6 +16,7 @@ interface Props { color?: string; conversationType: 'group' | 'direct'; avatarPath?: string; + isMe: boolean; lastUpdated: number; unreadCount: number; @@ -38,6 +39,7 @@ export class ConversationListItem extends React.Component { color, conversationType, i18n, + isMe, name, phoneNumber, profileName, @@ -48,6 +50,7 @@ export class ConversationListItem extends React.Component { { const { unreadCount, i18n, + isMe, lastUpdated, name, phoneNumber, @@ -94,12 +98,16 @@ export class ConversationListItem extends React.Component { : null )} > - + {isMe ? ( + i18n('noteToSelf') + ) : ( + + )}
- console.log('onSetDisappearingMessages', seconds) - } - onDeleteMessages={() => console.log('onDeleteMessages')} - onResetSession={() => console.log('onResetSession')} - onShowSafetyNumber={() => console.log('onShowSafetyNumber')} - onShowAllMedia={() => console.log('onShowAllMedia')} - onShowGroupMembers={() => console.log('onShowGroupMembers')} - onGoBack={() => console.log('onGoBack')} -/> + + + console.log('onSetDisappearingMessages', seconds) + } + onDeleteMessages={() => console.log('onDeleteMessages')} + onResetSession={() => console.log('onResetSession')} + onShowSafetyNumber={() => console.log('onShowSafetyNumber')} + onShowAllMedia={() => console.log('onShowAllMedia')} + onShowGroupMembers={() => console.log('onShowGroupMembers')} + onGoBack={() => console.log('onGoBack')} + /> + ``` #### With name, not verified, no avatar ```jsx - + + + ``` #### Profile, no name ```jsx - + + + ``` #### No name, no profile, no color ```jsx - + + + ``` ### With back button ```jsx - + + + ``` ### Disappearing messages set ```jsx - - console.log('onSetDisappearingMessages', seconds) - } - onDeleteMessages={() => console.log('onDeleteMessages')} - onResetSession={() => console.log('onResetSession')} - onShowSafetyNumber={() => console.log('onShowSafetyNumber')} - onShowAllMedia={() => console.log('onShowAllMedia')} - onShowGroupMembers={() => console.log('onShowGroupMembers')} - onGoBack={() => console.log('onGoBack')} -/> + + + console.log('onSetDisappearingMessages', seconds) + } + onDeleteMessages={() => console.log('onDeleteMessages')} + onResetSession={() => console.log('onResetSession')} + onShowSafetyNumber={() => console.log('onShowSafetyNumber')} + onShowAllMedia={() => console.log('onShowAllMedia')} + onShowGroupMembers={() => console.log('onShowGroupMembers')} + onGoBack={() => console.log('onGoBack')} + /> + ``` ### In a group @@ -106,34 +118,38 @@ Note the five items in gear menu, and the second-level menu with disappearing me Note that the menu should includes 'Show Members' instead of 'Show Safety Number' ```jsx - - console.log('onSetDisappearingMessages', seconds) - } - onDeleteMessages={() => console.log('onDeleteMessages')} - onResetSession={() => console.log('onResetSession')} - onShowSafetyNumber={() => console.log('onShowSafetyNumber')} - onShowAllMedia={() => console.log('onShowAllMedia')} - onShowGroupMembers={() => console.log('onShowGroupMembers')} - onGoBack={() => console.log('onGoBack')} -/> + + + console.log('onSetDisappearingMessages', seconds) + } + onDeleteMessages={() => console.log('onDeleteMessages')} + onResetSession={() => console.log('onResetSession')} + onShowSafetyNumber={() => console.log('onShowSafetyNumber')} + onShowAllMedia={() => console.log('onShowAllMedia')} + onShowGroupMembers={() => console.log('onShowGroupMembers')} + onGoBack={() => console.log('onGoBack')} + /> + ``` ### In chat with yourself -Note that the menu should not have a 'Show Safety Number' entry. +This is the 'Note to self' conversation. Note that the menu should not have a 'Show Safety Number' entry. ```jsx - + + + ``` diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index a81ded580..3a0fca47d 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -84,7 +84,22 @@ export class ConversationHeader extends React.Component { } public renderTitle() { - const { name, phoneNumber, i18n, profileName, isVerified } = this.props; + const { + name, + phoneNumber, + i18n, + isMe, + profileName, + isVerified, + } = this.props; + + if (isMe) { + return ( +
+ {i18n('noteToSelf')} +
+ ); + } return (
@@ -113,6 +128,7 @@ export class ConversationHeader extends React.Component { color, i18n, isGroup, + isMe, name, phoneNumber, profileName, @@ -125,6 +141,7 @@ export class ConversationHeader extends React.Component { color={color} conversationType={isGroup ? 'group' : 'direct'} i18n={i18n} + noteToSelf={isMe} name={name} phoneNumber={phoneNumber} profileName={profileName} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index e30b40eeb..599c7f503 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -206,8 +206,8 @@ { "rule": "jQuery-wrap(", "path": "js/models/messages.js", - "line": " this.send(wrap(promise));", - "lineNumber": 820, + "line": " return this.send(wrap(promise));", + "lineNumber": 854, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" }, @@ -215,7 +215,7 @@ "rule": "jQuery-wrap(", "path": "js/models/messages.js", "line": " return wrap(", - "lineNumber": 1029, + "lineNumber": 1091, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" }, @@ -527,7 +527,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_search_view.js", "line": " this.$new_contact = this.$('.new-contact');", - "lineNumber": 42, + "lineNumber": 40, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -536,7 +536,7 @@ "rule": "jQuery-append(", "path": "js/views/conversation_search_view.js", "line": " this.$el.append(this.typeahead_view.el);", - "lineNumber": 59, + "lineNumber": 57, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -545,7 +545,7 @@ "rule": "jQuery-$(", "path": "js/views/conversation_search_view.js", "line": " this.new_contact_view.$('.number').text(i18n('invalidNumberError'));", - "lineNumber": 111, + "lineNumber": 119, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -554,7 +554,7 @@ "rule": "jQuery-insertAfter(", "path": "js/views/conversation_search_view.js", "line": " this.hintView.$el.insertAfter(this.$input);", - "lineNumber": 147, + "lineNumber": 155, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -6215,4 +6215,4 @@ "updated": "2018-09-17T20:50:40.689Z", "reasonDetail": "Hard-coded value" } -] +] \ No newline at end of file