Visuals for embedded contacts as well as contact detail screen

This commit is contained in:
Scott Nonnenberg 2018-05-02 19:43:23 -07:00
parent 3ea3e4e256
commit 41be7f126b
17 changed files with 1323 additions and 2 deletions

View File

@ -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"

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="20px" height="19px" viewBox="0 0 20 19" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
<title>Shape</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Key-View" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="1.1a-Received-Contact-(iOS)" transform="translate(-879.000000, -412.000000)" fill-rule="nonzero" stroke="#000000" stroke-width="0.833333333">
<g id="Window" transform="translate(320.000000, 135.000000)">
<g id="message" transform="translate(473.000000, 192.000000)">
<g id="Group-4" transform="translate(0.000000, 72.000000)">
<g id="Group" transform="translate(86.000000, 11.000000)">
<path d="M0.503004576,20.5808657 C2.44736376,20.5304175 4.1383603,19.7106004 5.53209251,18.5049654 L5.72548696,18.3376713 L5.96221887,18.4343498 C7.23137666,18.9526585 8.59754256,19.2261905 10,19.2261905 C15.3043498,19.2261905 19.5833333,15.4516724 19.5833333,10.8214286 C19.5833333,6.19118476 15.3043498,2.41666667 10,2.41666667 C4.69565023,2.41666667 0.416666667,6.19118476 0.416666667,10.8214286 C0.416666667,12.7612904 1.1698162,14.5979981 2.53343385,16.082656 L2.68151739,16.243884 L2.63276552,16.4573002 C2.40695363,17.4458143 1.9438817,18.4456758 1.33263084,19.4122874 C1.02870151,19.8929111 0.697423786,20.342753 0.503004576,20.5808657 Z" id="Shape"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

20
images/chat-bubble.svg Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="18px" height="17px" viewBox="0 0 18 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
<title>Shape</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Key-View" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="1.1b-Received-Contact-(Android-Light)" transform="translate(-888.000000, -377.000000)" fill="#000000" fill-rule="nonzero">
<g id="Window" transform="translate(320.000000, 135.000000)">
<g id="Group-4" transform="translate(500.000000, 166.000000)">
<g id="Group-3" transform="translate(0.000000, 64.000000)">
<g id="Group-2" transform="translate(68.000000, 12.000000)">
<path d="M9,0 C4.02890625,0 0,3.5328125 0,7.89285714 C0,9.775 0.75234375,11.4977679 2.00390625,12.8524554 C1.56445312,14.7649554 0.094921875,16.46875 0.07734375,16.4877232 C0,16.575 -0.02109375,16.7040179 0.024609375,16.8178571 C0.0703125,16.9316964 0.16875,17 0.28125,17 C2.61210937,17 4.359375,15.7933036 5.22421875,15.0495536 C6.37382813,15.5162946 7.65,15.7857143 9,15.7857143 C13.9710937,15.7857143 18,12.2529018 18,7.89285714 C18,3.5328125 13.9710937,0 9,0 Z" id="Shape"></path>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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) {

View File

@ -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

View File

@ -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",

View File

@ -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 = {};

View File

@ -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;

View File

@ -191,7 +191,6 @@ input.search {
.last-message {
margin: 6px 0 0;
font-size: $font-size-small;
font-weight: 300;
}
.gutter .timestamp {

View File

@ -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');

View File

@ -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 = {};

View File

@ -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!',
},
],
};
<ContactDetail
contact={contact}
hasSignalAccount={true}
i18n={util.i18n}
onSendMessage={() => console.log('onSendMessage')}
/>;
```
### With default avatar
```jsx
const contact = {
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: '(202) 555-0000',
type: 1,
},
],
};
<ContactDetail
contact={contact}
hasSignalAccount={true}
i18n={util.i18n}
onSendMessage={() => 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,
},
],
};
<ContactDetail
contact={contact}
hasSignalAccount={false}
i18n={util.i18n}
onSendMessage={() => 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',
},
],
};
<ContactDetail
contact={contact}
hasSignalAccount={false}
i18n={util.i18n}
onSendMessage={() => console.log('onSendMessage')}
/>;
```
### Empty contact
```jsx
const contact = {};
<ContactDetail
contact={contact}
hasSignalAccount={false}
i18n={util.i18n}
onSendMessage={() => console.log('onSendMessage')}
/>;
```

View File

@ -0,0 +1,264 @@
import React from 'react';
import { missingCaseError } from '../../util/missingCaseError';
type Localizer = (key: string, values?: Array<string>) => string;
interface Props {
contact: Contact;
hasSignalAccount: boolean;
i18n: Localizer;
onSendMessage: () => void;
}
interface Contact {
name: Name;
number?: Array<Phone>;
email?: Array<Email>;
address?: Array<PostalAddress>;
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<Props, {}> {
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 (
<div className="image-container">
<div className="default-avatar">{initials}</div>
</div>
);
}
return (
<div className="image-container">
<img src={path} />
</div>
);
}
public renderName() {
const { contact } = this.props;
return <div className="contact-name">{getName(contact)}</div>;
}
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 <div className="contact-method">{firstNumber || firstEmail}</div>;
}
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 (
<div className="send-message" onClick={onClick}>
<button className="inner">
<div className="icon bubble-icon" />
{i18n('sendMessageToContact')}
</button>
</div>
);
}
public renderAdditionalContact(
items: Array<Phone | Email> | undefined,
i18n: Localizer
) {
if (!items || items.length === 0) {
return;
}
return items.map((item: Phone | Email) => {
return (
<div key={item.value} className="additional-contact">
<div className="type">{getLabelForContactMethod(item, i18n)}</div>
{item.value}
</div>
);
});
}
public renderAddressLineIfTruthy(value: string | undefined) {
if (!value) {
return;
}
return <div>{value}</div>;
}
public renderPOBox(poBox: string | undefined, i18n: Localizer) {
if (!poBox) {
return null;
}
return (
<div>
{i18n('poBox')} {poBox}
</div>
);
}
public renderAddressLineTwo(address: PostalAddress) {
if (address.city || address.region || address.postcode) {
return (
<div>
{address.city} {address.region} {address.postcode}
</div>
);
}
return null;
}
public renderAddresses(
addresses: Array<PostalAddress> | undefined,
i18n: Localizer
) {
if (!addresses || addresses.length === 0) {
return;
}
return addresses.map((address: PostalAddress, index: number) => {
return (
<div key={index} className="additional-contact">
<div className="type">{getLabelForAddress(address, i18n)}</div>
{this.renderAddressLineIfTruthy(address.street)}
{this.renderPOBox(address.pobox, i18n)}
{this.renderAddressLineIfTruthy(address.neighborhood)}
{this.renderAddressLineTwo(address)}
{this.renderAddressLineIfTruthy(address.country)}
</div>
);
});
}
public render() {
const { contact, i18n } = this.props;
return (
<div className="contact-detail">
{this.renderAvatar()}
{this.renderName()}
{this.renderContactShorthand()}
{this.renderSendMessage()}
{this.renderAdditionalContact(contact.number, i18n)}
{this.renderAdditionalContact(contact.email, i18n)}
{this.renderAddresses(contact.address, i18n)}
</div>
);
}
}

View File

@ -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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```
#### 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;
<util.ConversationContext theme={util.theme} type="group">
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```
#### 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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```
#### 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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```
#### 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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```
#### 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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```
#### 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;
<util.ConversationContext theme={util.theme}>
<util.BackboneWrapper View={View} options={{ model: incoming }} />
<util.BackboneWrapper View={View} options={{ model: outgoing }} />
</util.ConversationContext>;
```

View File

@ -0,0 +1,162 @@
import React from 'react';
interface Props {
contact: Contact;
hasSignalAccount: boolean;
i18n: (key: string, values?: Array<string>) => string;
onSendMessage: () => void;
onOpenContact: () => void;
}
interface Contact {
name: Name;
number?: Array<Phone>;
email?: Array<Email>;
address?: Array<PostalAddress>;
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<Props, {}> {
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 (
<div className="image-container">
<div className="default-avatar">{initials}</div>
</div>
);
}
return (
<div className="image-container">
<img src={path} />
</div>
);
}
public renderName() {
const { contact } = this.props;
return <div className="contact-name">{getName(contact)}</div>;
}
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 <div className="contact-method">{firstNumber || firstEmail}</div>;
}
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 (
<div className="send-message" onClick={onClick}>
<button className="inner">
<div className="icon bubble-icon" />
{i18n('sendMessageToContact')}
</button>
</div>
);
}
public render() {
const { onOpenContact } = this.props;
return (
<div className="embedded-contact" onClick={onOpenContact}>
<div className="first-line">
{this.renderAvatar()}
<div className="text-container">
{this.renderName()}
{this.renderContactShorthand()}
</div>
</div>
{this.renderSendMessage()}
</div>
);
}
}

View File

@ -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<Boolean> => {
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

View File

@ -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"