From 41be7f126beca7a0b494921dbd641e0d4f2c1d2d Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 2 May 2018 19:43:23 -0700 Subject: [PATCH] Visuals for embedded contacts as well as contact detail screen --- _locales/en/messages.json | 20 ++ images/chat-bubble-outline.svg | 20 ++ images/chat-bubble.svg | 20 ++ js/views/conversation_view.js | 45 +++ js/views/message_view.js | 113 +++++++- package.json | 1 + preload.js | 4 + stylesheets/_conversation.scss | 223 +++++++++++++++ stylesheets/_index.scss | 1 - stylesheets/_variables.scss | 5 + test/styleguide/legacy_bridge.js | 9 + ts/components/conversation/ContactDetail.md | 173 ++++++++++++ ts/components/conversation/ContactDetail.tsx | 264 ++++++++++++++++++ ts/components/conversation/EmbeddedContact.md | 244 ++++++++++++++++ .../conversation/EmbeddedContact.tsx | 162 +++++++++++ ts/styleguide/StyleGuideUtil.ts | 17 ++ yarn.lock | 4 + 17 files changed, 1323 insertions(+), 2 deletions(-) create mode 100644 images/chat-bubble-outline.svg create mode 100644 images/chat-bubble.svg create mode 100644 ts/components/conversation/ContactDetail.md create mode 100644 ts/components/conversation/ContactDetail.tsx create mode 100644 ts/components/conversation/EmbeddedContact.md create mode 100644 ts/components/conversation/EmbeddedContact.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 111e06e5e..b9d68892a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -460,6 +460,26 @@ "selectAContact": { "message": "Select a contact or group to start chatting." }, + "sendMessageToContact": { + "message": "Send Message", + "description": "Shown when you are sent a contact and that contact has a signal account" + }, + "home": { + "message": "home", + "description": "Shown on contact detail screen as a label for an address/phone/email" + }, + "work": { + "message": "work", + "description": "Shown on contact detail screen as a label for an address/phone/email" + }, + "mobile": { + "message": "mobile", + "description": "Shown on contact detail screen as a label for aa phone or email" + }, + "poBox": { + "message": "PO Box", + "description": "When rendering an address, used to provide context to a post office box" + }, "replyToMessage": { "message": "Reply to Message", "description": "Shown in triple-dot menu next to message to allow user to start crafting a message with a quotation" diff --git a/images/chat-bubble-outline.svg b/images/chat-bubble-outline.svg new file mode 100644 index 000000000..ae98f5420 --- /dev/null +++ b/images/chat-bubble-outline.svg @@ -0,0 +1,20 @@ + + + + Shape + Created with Sketch. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/chat-bubble.svg b/images/chat-bubble.svg new file mode 100644 index 000000000..6bf652236 --- /dev/null +++ b/images/chat-bubble.svg @@ -0,0 +1,20 @@ + + + + Shape + Created with Sketch. + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 63336b3d7..552d9091f 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -146,6 +146,16 @@ 'reply', this.setQuoteMessage ); + this.listenTo( + this.model.messageCollection, + 'show-contact-detail', + this.showContactDetail + ); + this.listenTo( + this.model.messageCollection, + 'open-conversation', + this.openConversation + ); this.lazyUpdateVerified = _.debounce( this.model.updateVerified.bind(this.model), @@ -996,6 +1006,41 @@ this.listenBack(view); }, + showContactDetail(contact) { + console.log('showContactDetail', contact); // TODO + + // TODO: need to run contact through selector to format email, get absolute path + // think it's probably time to move it to typescript + + const view = new Whisper.ReactWrapperView({ + Component: Signal.Components.MediaGallery, + props: { + contact, + hasSignalAccount: true, + onSendMessage: () => { + const number = + contact.number && contact.number[0] && contact.number[0].value; + if (number) { + this.openConversation(number); + } + }, + }, + onClose: () => this.resetPanel(), + }); + + this.listenBack(view); + }, + + async openConversation(number) { + console.log('openConversation', number); // TODO + + const conversation = await window.ConversationController.getOrCreateAndWait( + number, + 'private' + ); + window.Whisper.Events.trigger('click', conversation); + }, + listenBack(view) { this.panels = this.panels || []; if (this.panels.length > 0) { diff --git a/js/views/message_view.js b/js/views/message_view.js index f3ea8a8e0..994fddb49 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -5,6 +5,8 @@ /* global emoji_util: false */ /* global Mustache: false */ /* global $: false */ +/* global libphonenumber: false */ +/* global storage: false */ // eslint-disable-next-line func-names (function() { @@ -290,6 +292,9 @@ if (this.quoteView) { this.quoteView.remove(); } + if (this.contactView) { + this.contactView.remove(); + } // NOTE: We have to do this in the background (`then` instead of `await`) // as our tests rely on `onUnload` synchronously removing the view from @@ -436,6 +441,108 @@ }); this.$('.inner-bubble').prepend(this.quoteView.el); }, + formatPhoneNumber(number, options = {}) { + const { ourRegionCode } = options; + const parsedNumber = libphonenumber.parse(number); + const regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber); + if (ourRegionCode && regionCode === ourRegionCode) { + return libphonenumber.format( + parsedNumber, + libphonenumber.PhoneNumberFormat.NATIONAL + ); + } + return libphonenumber.format( + parsedNumber, + libphonenumber.PhoneNumberFormat.INTERNATIONAL + ); + }, + contactSelector(contact) { + const { getAbsoluteAttachmentPath } = Signal.Migrations; + const region = storage.get('regionCode'); + + let { avatar } = contact; + if (avatar && avatar.avatar && avatar.avatar.path) { + avatar = Object.assign({}, avatar, { + avatar: Object.assign({}, avatar.avatar, { + path: getAbsoluteAttachmentPath(avatar.avatar.path), + }), + }); + } + return Object.assign({}, contact, { + avatar, + number: + contact.number && + contact.number.map(item => + Object.assign({}, item, { + value: this.formatPhoneNumber(item.value, { + ourRegionCode: region, + }), + }) + ), + }); + }, + renderContact() { + const contacts = this.model.get('contact'); + if (!contacts || !contacts.length) { + return; + } + const contact = contacts[0]; + + const number = + contact.number && contact.number[0] && contact.number[0].value; + const haveConversation = + number && Boolean(window.ConversationController.get(number)); + let hasSignalAccount = number && haveConversation; + + const onSendMessage = number + ? () => { + this.model.trigger('open-conversation', number); + } + : null; + const onOpenContact = () => { + this.model.trigger('show-contact-detail', contact); + }; + + const getProps = () => { + return { + contact: this.contactSelector(contact), + hasSignalAccount, + onSendMessage, + onOpenContact, + }; + }; + + if (this.contactView) { + this.contactView.remove(); + this.contactView = null; + } + + this.contactView = new Whisper.ReactWrapperView({ + className: 'contact-wrapper', + Component: window.Signal.Components.EmbeddedContact, + props: getProps(), + }); + + this.$('.inner-bubble').prepend(this.contactView.el); + + // If we can't verify a signal account locally, we'll go to the Signal Server. + if (number && !hasSignalAccount) { + // eslint-disable-next-line more/no-then + window.textsecure.messaging + .getProfile(number) + .then(() => { + if (!this.contactView) { + return; + } + + hasSignalAccount = true; + this.contactView.update(getProps()); + }) + .catch(() => { + // No account available, or network connectivity problem + }); + } + }, isImageWithoutCaption() { const attachments = this.model.get('attachments'); const body = this.model.get('body'); @@ -458,7 +565,10 @@ const attachments = this.model.get('attachments'); const hasAttachments = attachments && attachments.length > 0; - return this.hasTextContents() || hasAttachments; + const contacts = this.model.get('contact'); + const hasContact = contacts && contacts.length > 0; + + return this.hasTextContents() || hasAttachments || hasContact; }, hasTextContents() { const body = this.model.get('body'); @@ -525,6 +635,7 @@ this.renderErrors(); this.renderExpiring(); this.renderQuote(); + this.renderContact(); // NOTE: We have to do this in the background (`then` instead of `await`) // as our code / Backbone seems to rely on `render` synchronously returning diff --git a/package.json b/package.json index 3213678ba..42ced99da 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "@sindresorhus/is": "^0.8.0", + "@types/google-libphonenumber": "^7.4.14", "archiver": "^2.1.1", "blob-util": "^1.3.0", "blueimp-canvas-to-blob": "^3.14.0", diff --git a/preload.js b/preload.js index 412456a88..a0dfec638 100644 --- a/preload.js +++ b/preload.js @@ -169,6 +169,9 @@ const { MediaGallery, } = require('./ts/components/conversation/media-gallery/MediaGallery'); const { Quote } = require('./ts/components/conversation/Quote'); +const { + EmbeddedContact, +} = require('./ts/components/conversation/EmbeddedContact'); const MediaGalleryMessage = require('./ts/components/conversation/media-gallery/types/Message'); @@ -180,6 +183,7 @@ window.Signal.Components = { Message: MediaGalleryMessage, }, Quote, + EmbeddedContact, }; window.Signal.Migrations = {}; diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 3876c49b9..1b4ec5aef 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -526,6 +526,9 @@ span.status { .quote-wrapper + .content { margin-top: 0.5em; } + .contact-wrapper + .content { + margin-top: 0.5em; + } p { margin: 0; @@ -740,6 +743,226 @@ span.status { } } +.embedded-contact { + margin-top: -9px; + margin-left: -12px; + margin-right: -12px; + + cursor: pointer; + + button { + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + outline: inherit; + } + + .first-line { + display: flex; + flex-direction: row; + align-items: stretch; + margin: 8px; + + .image-container { + flex: initial; + min-width: 50px; + width: 50px; + height: 50px; + + text-align: center; + display: flex; + align-items: center; + justify-content: center; + + object-fit: cover; + + img { + border-radius: 50%; + width: 100%; + height: 100%; + object-fit: cover; + } + + .default-avatar { + border-radius: 50%; + width: 100%; + height: 100%; + background-color: gray; + color: white; + font-size: 25px; + line-height: 52px; + } + } + + .text-container { + flex-grow: 1; + margin-left: 8px; + + .contact-name { + font-size: 16px; + font-weight: 300; + margin-top: 3px; + color: $blue; + } + + .contact-method { + font-size: 14px; + margin-top: 6px; + } + } + } + + .send-message { + margin-top: 8px; + margin-bottom: 3px; + padding: 11px; + border-top: 1px solid $grey_l1_5; + border-bottom: 1px solid $grey_l1_5; + + color: $blue; + font-weight: 300; + display: flex; + flex-direction: column; + align-items: center; + + .inner { + display: flex; + align-items: center; + } + + .bubble-icon { + height: 17px; + width: 18px; + display: inline-block; + margin-right: 5px; + @include color-svg('../images/chat-bubble.svg', $blue); + } + } +} + +.incoming .embedded-contact { + color: white; + + .text-container .contact-name { + color: white; + } + + .send-message { + color: white; + border-top: 1px solid rgba(255, 255, 255, 0.5); + border-bottom: 1px solid rgba(255, 255, 255, 0.5); + + .bubble-icon { + background-color: white; + } + } +} + +.group .incoming .embedded-contact { + margin-top: -2px; +} + +.contact-detail { + text-align: center; + max-width: 300px; + margin-left: auto; + margin-right: auto; + + button { + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + outline: inherit; + } + + .image-container { + height: 80px; + width: 80px; + margin-bottom: 4px; + + text-align: center; + display: inline-block; + object-fit: cover; + + img { + border-radius: 50%; + width: 100%; + height: 100%; + object-fit: cover; + } + + .default-avatar { + border-radius: 50%; + width: 100%; + height: 100%; + background-color: gray; + color: white; + font-size: 50px; + line-height: 82px; + } + } + + .contact-name { + font-size: 20px; + font-weight: bold; + } + + .contact-method { + font-size: 14px; + margin-top: 10px; + } + + .send-message { + cursor: pointer; + + border-radius: 4px; + background-color: $blue; + display: inline-block; + padding: 6px; + margin-top: 20px; + + // TODO: border + // TODO: gradient + + color: white; + + flex-direction: column; + align-items: center; + + .inner { + display: flex; + align-items: center; + } + + .bubble-icon { + height: 17px; + width: 18px; + display: inline-block; + margin-right: 5px; + @include color-svg('../images/chat-bubble.svg', white); + } + } + + .additional-contact { + text-align: left; + border-top: 1px solid $grey_l1_5; + margin-top: 15px; + padding-top: 8px; + + .type { + color: rgba(0, 0, 0, 0.5); + font-size: 12px; + margin-bottom: 3px; + } + } +} + .quoted-message { @include message-replies-colors; @include twenty-percent-colors; diff --git a/stylesheets/_index.scss b/stylesheets/_index.scss index 89ab97749..590fc280c 100644 --- a/stylesheets/_index.scss +++ b/stylesheets/_index.scss @@ -191,7 +191,6 @@ input.search { .last-message { margin: 6px 0 0; font-size: $font-size-small; - font-weight: 300; } .gutter .timestamp { diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index a1d9035b1..21b08bb8a 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -22,6 +22,11 @@ $z-index-modal: 100; font-family: 'Roboto'; src: url('../fonts/Roboto-Regular.ttf') format('truetype'); } +@font-face { + font-family: 'Roboto'; + src: url('../fonts/Roboto-Medium.ttf') format('truetype'); + font-weight: 300; +} @font-face { font-family: 'Roboto'; src: url('../fonts/Roboto-Italic.ttf') format('truetype'); diff --git a/test/styleguide/legacy_bridge.js b/test/styleguide/legacy_bridge.js index 7cd0f9ff9..4375753aa 100644 --- a/test/styleguide/legacy_bridge.js +++ b/test/styleguide/legacy_bridge.js @@ -48,8 +48,17 @@ window.Signal.Migrations = { }, version: 2, }, + { + migrate: (transaction, next) => { + console.log('migration version 3'); + transaction.db.createObjectStore('items'); + next(); + }, + version: 3, + }, ], loadAttachmentData: attachment => Promise.resolve(attachment), + getAbsoluteAttachmentPath: path => path, }; window.Signal.Components = {}; diff --git a/ts/components/conversation/ContactDetail.md b/ts/components/conversation/ContactDetail.md new file mode 100644 index 000000000..c5dde0473 --- /dev/null +++ b/ts/components/conversation/ContactDetail.md @@ -0,0 +1,173 @@ +### With all data types + +```jsx +const contact = { + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 3, + }, + { + value: '(202) 555-0001', + type: 4, + label: 'My favorite custom label', + }, + ], + email: [ + { + value: 'someone@somewhere.com', + type: 2, + }, + + { + value: 'someone2@somewhere.com', + type: 4, + label: 'My second-favorite custom label', + }, + ], + address: [ + { + street: '5 Pike Place', + city: 'Seattle', + region: 'WA', + postcode: '98101', + type: 1, + }, + { + street: '10 Pike Place', + pobox: '3242', + neighborhood: 'Downtown', + city: 'Seattle', + region: 'WA', + postcode: '98101', + country: 'United States', + type: 3, + label: 'My favorite spot!', + }, + ], +}; + console.log('onSendMessage')} +/>; +``` + +### With default avatar + +```jsx +const contact = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, + }, + ], +}; + console.log('onSendMessage')} +/>; +``` + +### Without a Signal account + +```jsx +const contact = { + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0001', + type: 1, + }, + ], +}; + console.log('onSendMessage')} +/>; +``` + +### No phone or email, partial addresses + +```jsx +const contact = { + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + name: { + displayName: 'Someone Somewhere', + }, + address: [ + { + type: 1, + neighborhood: 'Greenwood', + region: 'WA', + }, + { + type: 2, + city: 'Seattle', + region: 'WA', + }, + { + type: 3, + label: 'My label', + region: 'WA', + }, + { + type: 1, + label: 'My label', + postcode: '98101', + region: 'WA', + }, + { + type: 2, + label: 'My label', + postcode: '98101', + }, + ], +}; + console.log('onSendMessage')} +/>; +``` + +### Empty contact + +```jsx +const contact = {}; + console.log('onSendMessage')} +/>; +``` diff --git a/ts/components/conversation/ContactDetail.tsx b/ts/components/conversation/ContactDetail.tsx new file mode 100644 index 000000000..a9f0508c4 --- /dev/null +++ b/ts/components/conversation/ContactDetail.tsx @@ -0,0 +1,264 @@ +import React from 'react'; + +import { missingCaseError } from '../../util/missingCaseError'; + +type Localizer = (key: string, values?: Array) => string; + +interface Props { + contact: Contact; + hasSignalAccount: boolean; + i18n: Localizer; + onSendMessage: () => void; +} + +interface Contact { + name: Name; + number?: Array; + email?: Array; + address?: Array; + avatar?: Avatar; + organization?: string; +} + +interface Name { + givenName?: string; + familyName?: string; + prefix?: string; + suffix?: string; + middleName?: string; + displayName: string; +} + +enum ContactType { + HOME = 1, + MOBILE = 2, + WORK = 3, + CUSTOM = 4, +} + +enum AddressType { + HOME = 1, + WORK = 2, + CUSTOM = 3, +} + +interface Phone { + value: string; + type: ContactType; + label?: string; +} + +interface Email { + value: string; + type: ContactType; + label?: string; +} + +interface PostalAddress { + type: AddressType; + label?: string; + street?: string; + pobox?: string; + neighborhood?: string; + city?: string; + region?: string; + postcode?: string; + country?: string; +} + +interface Avatar { + avatar: Attachment; + isProfile: boolean; +} + +interface Attachment { + path: string; +} + +function getLabelForContactMethod(method: Phone | Email, i18n: Localizer) { + switch (method.type) { + case ContactType.CUSTOM: + return method.label; + case ContactType.HOME: + return i18n('home'); + case ContactType.MOBILE: + return i18n('mobile'); + case ContactType.WORK: + return i18n('work'); + default: + return missingCaseError(method.type); + } +} + +function getLabelForAddress(address: PostalAddress, i18n: Localizer) { + switch (address.type) { + case AddressType.CUSTOM: + return address.label; + case AddressType.HOME: + return i18n('home'); + case AddressType.WORK: + return i18n('work'); + default: + return missingCaseError(address.type); + } +} + +function getInitials(name: string): string { + return name.trim()[0] || '#'; +} + +function getName(contact: Contact): string { + const { name, organization } = contact; + return (name && name.displayName) || organization || ''; +} + +export class ContactDetail extends React.Component { + public renderAvatar() { + const { contact } = this.props; + const { avatar } = contact; + + const path = avatar && avatar.avatar && avatar.avatar.path; + if (!path) { + const name = getName(contact); + const initials = getInitials(name); + return ( +
+
{initials}
+
+ ); + } + + return ( +
+ +
+ ); + } + + public renderName() { + const { contact } = this.props; + + return
{getName(contact)}
; + } + + public renderContactShorthand() { + const { contact } = this.props; + const { number, email } = contact; + const firstNumber = number && number[0] && number[0].value; + const firstEmail = email && email[0] && email[0].value; + + return
{firstNumber || firstEmail}
; + } + + public renderSendMessage() { + const { hasSignalAccount, i18n, onSendMessage } = this.props; + + if (!hasSignalAccount) { + return null; + } + + // We don't want the overall click handler for this element to fire, so we stop + // propagation before handing control to the caller's callback. + const onClick = (e: React.MouseEvent<{}>): void => { + e.stopPropagation(); + onSendMessage(); + }; + + return ( +
+ +
+ ); + } + + public renderAdditionalContact( + items: Array | undefined, + i18n: Localizer + ) { + if (!items || items.length === 0) { + return; + } + + return items.map((item: Phone | Email) => { + return ( +
+
{getLabelForContactMethod(item, i18n)}
+ {item.value} +
+ ); + }); + } + + public renderAddressLineIfTruthy(value: string | undefined) { + if (!value) { + return; + } + + return
{value}
; + } + + public renderPOBox(poBox: string | undefined, i18n: Localizer) { + if (!poBox) { + return null; + } + + return ( +
+ {i18n('poBox')} {poBox} +
+ ); + } + + public renderAddressLineTwo(address: PostalAddress) { + if (address.city || address.region || address.postcode) { + return ( +
+ {address.city} {address.region} {address.postcode} +
+ ); + } + + return null; + } + + public renderAddresses( + addresses: Array | undefined, + i18n: Localizer + ) { + if (!addresses || addresses.length === 0) { + return; + } + + return addresses.map((address: PostalAddress, index: number) => { + return ( +
+
{getLabelForAddress(address, i18n)}
+ {this.renderAddressLineIfTruthy(address.street)} + {this.renderPOBox(address.pobox, i18n)} + {this.renderAddressLineIfTruthy(address.neighborhood)} + {this.renderAddressLineTwo(address)} + {this.renderAddressLineIfTruthy(address.country)} +
+ ); + }); + } + + public render() { + const { contact, i18n } = this.props; + + return ( +
+ {this.renderAvatar()} + {this.renderName()} + {this.renderContactShorthand()} + {this.renderSendMessage()} + {this.renderAdditionalContact(contact.number, i18n)} + {this.renderAdditionalContact(contact.email, i18n)} + {this.renderAddresses(contact.address, i18n)} +
+ ); + } +} diff --git a/ts/components/conversation/EmbeddedContact.md b/ts/components/conversation/EmbeddedContact.md new file mode 100644 index 000000000..0b790e3fa --- /dev/null +++ b/ts/components/conversation/EmbeddedContact.md @@ -0,0 +1,244 @@ +### With a contact + +#### Including all data types + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: util.CONTACTS[0].id, + type: 1, + }, + ], + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + }, + ], +}); +const incoming = new Whisper.Message( + Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + }) +); +const View = Whisper.MessageView; + + + +; +``` + +#### In group conversation + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: util.CONTACTS[0].id, + type: 1, + }, + ], + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + }, + ], +}); +const incoming = new Whisper.Message( + Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + }) +); +const View = Whisper.MessageView; + + + +; +``` + +#### If contact has no signal account + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '+12025551000', + type: 1, + }, + ], + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + }, + ], +}); +const incoming = new Whisper.Message( + Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + }) +); +const View = Whisper.MessageView; + + + +; +``` + +#### With organization name instead of name + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + contact: [ + { + organization: 'United Somewheres, Inc.', + email: [ + { + value: 'someone@somewheres.com', + type: 2, + }, + ], + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + }, + ], +}); +const incoming = new Whisper.Message( + Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + }) +); +const View = Whisper.MessageView; + + + +; +``` + +#### Default avatar + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: util.CONTACTS[0].id, + type: 1, + }, + ], + }, + ], +}); +const incoming = new Whisper.Message( + Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + }) +); +const View = Whisper.MessageView; + + + +; +``` + +#### Empty contact + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + contact: [{}], +}); +const incoming = new Whisper.Message( + Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + }) +); +const View = Whisper.MessageView; + + + +; +``` + +#### Contact with caption (cannot currently be sent) + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + sent_at: Date.now() - 18000000, + body: 'I want to introduce you to Someone...', + contact: [ + { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: util.CONTACTS[0].id, + type: 1, + }, + ], + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + }, + ], +}); +const incoming = new Whisper.Message( + Object.assign({}, outgoing.attributes, { + source: '+12025550011', + type: 'incoming', + }) +); +const View = Whisper.MessageView; + + + +; +``` diff --git a/ts/components/conversation/EmbeddedContact.tsx b/ts/components/conversation/EmbeddedContact.tsx new file mode 100644 index 000000000..d9a6658f0 --- /dev/null +++ b/ts/components/conversation/EmbeddedContact.tsx @@ -0,0 +1,162 @@ +import React from 'react'; + +interface Props { + contact: Contact; + hasSignalAccount: boolean; + i18n: (key: string, values?: Array) => string; + onSendMessage: () => void; + onOpenContact: () => void; +} + +interface Contact { + name: Name; + number?: Array; + email?: Array; + address?: Array; + avatar?: Avatar; + organization?: string; +} + +interface Name { + givenName?: string; + familyName?: string; + prefix?: string; + suffix?: string; + middleName?: string; + displayName: string; +} + +enum ContactType { + HOME = 1, + MOBILE = 2, + WORK = 3, + CUSTOM = 4, +} + +enum AddressType { + HOME = 1, + WORK = 2, + CUSTOM = 3, +} + +interface Phone { + value: string; + type: ContactType; + label?: string; +} + +interface Email { + value: string; + type: ContactType; + label?: string; +} + +interface PostalAddress { + type: AddressType; + label?: string; + street?: string; + pobox?: string; + neighborhood?: string; + city?: string; + region?: string; + postcode?: string; + country?: string; +} + +interface Avatar { + avatar: Attachment; + isProfile: boolean; +} + +interface Attachment { + path: string; +} + +function getInitials(name: string): string { + return name.trim()[0] || '#'; +} + +function getName(contact: Contact): string { + const { name, organization } = contact; + return (name && name.displayName) || organization || ''; +} + +export class EmbeddedContact extends React.Component { + public renderAvatar() { + const { contact } = this.props; + const { avatar } = contact; + + const path = avatar && avatar.avatar && avatar.avatar.path; + if (!path) { + const name = getName(contact); + const initials = getInitials(name); + return ( +
+
{initials}
+
+ ); + } + + return ( +
+ +
+ ); + } + + public renderName() { + const { contact } = this.props; + + return
{getName(contact)}
; + } + + public renderContactShorthand() { + const { contact } = this.props; + const { number, email } = contact; + const firstNumber = number && number[0] && number[0].value; + const firstEmail = email && email[0] && email[0].value; + + return
{firstNumber || firstEmail}
; + } + + public renderSendMessage() { + const { hasSignalAccount, i18n, onSendMessage } = this.props; + + if (!hasSignalAccount) { + return null; + } + + // We don't want the overall click handler for this element to fire, so we stop + // propagation before handing control to the caller's callback. + const onClick = (e: React.MouseEvent<{}>): void => { + e.stopPropagation(); + onSendMessage(); + }; + + return ( +
+ +
+ ); + } + + public render() { + const { onOpenContact } = this.props; + + return ( +
+
+ {this.renderAvatar()} +
+ {this.renderName()} + {this.renderContactShorthand()} +
+
+ {this.renderSendMessage()} +
+ ); + } +} diff --git a/ts/styleguide/StyleGuideUtil.ts b/ts/styleguide/StyleGuideUtil.ts index 1b8167c69..0e5b191e5 100644 --- a/ts/styleguide/StyleGuideUtil.ts +++ b/ts/styleguide/StyleGuideUtil.ts @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { padStart, sample } from 'lodash'; +import libphonenumber from 'google-libphonenumber'; import _ from 'lodash'; import moment from 'moment'; @@ -17,6 +18,7 @@ export { BackboneWrapper } from '../components/utility/BackboneWrapper'; // Here we can make things inside Webpack available to Backbone views like preload.js. import { Quote } from '../components/conversation/Quote'; +import { EmbeddedContact } from '../components/conversation/EmbeddedContact'; import * as HTML from '../html'; import * as Attachment from '../../ts/types/Attachment'; @@ -130,6 +132,7 @@ parent.Signal.Types.MIME = MIME; parent.Signal.Types.Attachment = Attachment; parent.Signal.Components = { Quote, + EmbeddedContact, }; parent.Signal.Util = Util; parent.SignalService = SignalService; @@ -194,6 +197,20 @@ group.contactCollection.add(CONTACTS[2]); export { COLORS, CONTACTS, me, group }; parent.textsecure.storage.user.getNumber = () => ourNumber; +parent.textsecure.messaging = { + getProfile: async (number: string): Promise => { + if (parent.ConversationController.get(number)) { + return true; + } + + throw new Error('User does not have Signal account'); + }, +}; + +parent.libphonenumber = libphonenumber.PhoneNumberUtil.getInstance(); +parent.libphonenumber.PhoneNumberFormat = libphonenumber.PhoneNumberFormat; + +parent.storage.put('regionCode', 'US'); // Telling Lodash to relinquish _ for use by underscore // @ts-ignore diff --git a/yarn.lock b/yarn.lock index 86b435def..f1df7ed2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -91,6 +91,10 @@ version "3.6.0" resolved "https://registry.yarnpkg.com/@types/filesize/-/filesize-3.6.0.tgz#5f1a25c7b4e3d5ee2bc63133d374d096b7008c8d" +"@types/google-libphonenumber@^7.4.14": + version "7.4.14" + resolved "https://registry.yarnpkg.com/@types/google-libphonenumber/-/google-libphonenumber-7.4.14.tgz#3625d7aed0c16df920588428c86f0538bd0612ec" + "@types/jquery@^3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.1.tgz#55758d44d422756d6329cbf54e6d41931d7ba28f"