Feature: Receive contact (#2349)

Feature: Receive contact
This commit is contained in:
Scott Nonnenberg 2018-05-08 13:27:22 -07:00 committed by GitHub
commit 787d023557
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2424 additions and 160 deletions

View file

@ -15,6 +15,7 @@ test/test.js
ts/**/*.js
ts/protobuf/*.d.ts
ts/protobuf/*.js
stylesheets/manifest.css
# Third-party files
components/**
@ -37,3 +38,4 @@ _locales/**
# Symlink into third-party `components`:
stylesheets/_intlTelInput.scss

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 @@
<svg width="20" height="19" xmlns="http://www.w3.org/2000/svg"><path d="M.503 18.58c1.944-.05 3.635-.87 5.03-2.075l.192-.167.237.096c1.27.519 2.636.792 4.038.792 5.304 0 9.583-3.774 9.583-8.405 0-4.63-4.279-8.404-9.583-8.404S.417 4.19.417 8.82c0 1.94.753 3.777 2.116 5.262l.149.16-.05.214c-.225.989-.688 1.989-1.3 2.955-.303.48-.635.93-.829 1.169z" fill-rule="nonzero" stroke="#000" stroke-width=".833" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 422 B

1
images/chat-bubble.svg Normal file
View file

@ -0,0 +1 @@
<svg width="18" height="17" xmlns="http://www.w3.org/2000/svg"><path d="M9 0C4.029 0 0 3.533 0 7.893c0 1.882.752 3.605 2.004 4.96-.44 1.912-1.91 3.616-1.927 3.635a.32.32 0 0 0-.052.33A.276.276 0 0 0 .28 17c2.331 0 4.078-1.207 4.943-1.95 1.15.466 2.426.736 3.776.736 4.971 0 9-3.533 9-7.893C18 3.533 13.971 0 9 0z" fill="#000" fill-rule="nonzero"/></svg>

After

Width:  |  Height:  |  Size: 353 B

View file

@ -238,6 +238,12 @@
appView.openInbox();
}
});
Whisper.events.on('showConversation', function(conversation) {
if (appView) {
appView.openConversation(conversation);
}
});
Whisper.Notifications.on('click', function(conversation) {
showWindow();
if (conversation) {

View file

@ -711,13 +711,21 @@
},
async makeQuote(quotedMessage) {
const { getName } = Signal.Types.Contact;
const contact = quotedMessage.getContact();
const attachments = quotedMessage.get('attachments');
const body = quotedMessage.get('body');
const embeddedContact = quotedMessage.get('contact');
const embeddedContactName =
embeddedContact && embeddedContact.length > 0
? getName(embeddedContact[0])
: '';
return {
author: contact.id,
id: quotedMessage.get('sent_at'),
text: quotedMessage.get('body'),
text: body || embeddedContactName,
attachments: await Promise.all(
(attachments || []).map(async attachment => {
const { contentType } = attachment;

View file

@ -588,6 +588,7 @@
message.set({
attachments: dataMessage.attachments,
body: dataMessage.body,
contact: dataMessage.contact,
conversationId: conversation.id,
decrypted_at: now,
errors: [],

View file

@ -570,6 +570,64 @@ async function writeAttachments(rawAttachments, options) {
}
}
async function writeAvatar(avatar, options) {
console.log('writeAvatar', { avatar, options });
const { dir, message, index, key, newKey } = options;
const name = _getAnonymousAttachmentFileName(message, index);
const filename = `${name}-contact-avatar`;
const target = path.join(dir, filename);
if (!avatar || !avatar.path) {
return;
}
await writeEncryptedAttachment(target, avatar.data, {
key,
newKey,
filename,
dir,
});
}
async function writeContactAvatars(contact, options) {
const { name } = options;
const { loadAttachmentData } = Signal.Migrations;
const promises = contact.map(async item => {
if (
!item ||
!item.avatar ||
!item.avatar.avatar ||
!item.avatar.avatar.path
) {
return null;
}
return loadAttachmentData(item.avatar.avatar);
});
try {
await Promise.all(
_.map(await Promise.all(promises), (item, index) =>
writeAvatar(
item,
Object.assign({}, options, {
index,
})
)
)
);
} catch (error) {
console.log(
'writeContactAvatars: error exporting conversation',
name,
':',
error && error.stack ? error.stack : error
);
throw error;
}
}
async function writeEncryptedAttachment(target, data, options = {}) {
const { key, newKey, filename, dir } = options;
@ -714,6 +772,21 @@ async function exportConversation(db, conversation, options) {
promiseChain = promiseChain.then(exportQuoteThumbnails);
}
const { contact } = message;
if (contact && contact.length > 0) {
const exportContactAvatars = () =>
writeContactAvatars(contact, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
// eslint-disable-next-line more/no-then
promiseChain = promiseChain.then(exportContactAvatars);
}
count += 1;
cursor.continue();
} else {
@ -870,27 +943,44 @@ function getDirContents(dir) {
});
}
function loadAttachments(dir, getName, options) {
async function loadAttachments(dir, getName, options) {
options = options || {};
const { message } = options;
const attachmentPromises = _.map(message.attachments, (attachment, index) => {
const name = getName(message, index, attachment);
return readAttachment(dir, attachment, name, options);
});
await Promise.all(
_.map(message.attachments, (attachment, index) => {
const name = getName(message, index, attachment);
return readAttachment(dir, attachment, name, options);
})
);
const quoteAttachments = message.quote && message.quote.attachments;
const thumbnailPromises = _.map(quoteAttachments, (attachment, index) => {
const thumbnail = attachment && attachment.thumbnail;
if (!thumbnail) {
return null;
}
await Promise.all(
_.map(quoteAttachments, (attachment, index) => {
const thumbnail = attachment && attachment.thumbnail;
if (!thumbnail) {
return null;
}
const name = `${getName(message, index, thumbnail)}-thumbnail`;
return readAttachment(dir, thumbnail, name, options);
});
const name = `${getName(message, index)}-thumbnail`;
return readAttachment(dir, thumbnail, name, options);
})
);
return Promise.all(attachmentPromises.concat(thumbnailPromises));
const { contact } = message;
await Promise.all(
_.map(contact, (item, index) => {
const avatar = item && item.avatar && item.avatar.avatar;
if (!avatar) {
return null;
}
const name = `${getName(message, index)}-contact-avatar`;
return readAttachment(dir, avatar, name, options);
})
);
console.log('loadAttachments', { message });
}
function saveMessage(db, message) {

142
js/modules/types/contact.js Normal file
View file

@ -0,0 +1,142 @@
const { omit, compact, map } = require('lodash');
const { toLogFormat } = require('./errors');
const { SignalService } = require('../../../ts/protobuf');
const DEFAULT_PHONE_TYPE = SignalService.DataMessage.Contact.Phone.Type.HOME;
const DEFAULT_EMAIL_TYPE = SignalService.DataMessage.Contact.Email.Type.HOME;
const DEFAULT_ADDRESS_TYPE =
SignalService.DataMessage.Contact.PostalAddress.Type.HOME;
exports.parseAndWriteAvatar = upgradeAttachment => async (
contact,
context = {}
) => {
const { message } = context;
const { avatar } = contact;
const contactWithUpdatedAvatar =
avatar && avatar.avatar
? Object.assign({}, contact, {
avatar: Object.assign({}, avatar, {
avatar: await upgradeAttachment(avatar.avatar, context),
}),
})
: omit(contact, ['avatar']);
// eliminates empty numbers, emails, and addresses; adds type if not provided
const parsedContact = parseContact(contactWithUpdatedAvatar);
const error = exports._validate(parsedContact, {
messageId: idForLogging(message),
});
if (error) {
console.log(
'Contact.parseAndWriteAvatar: contact was malformed.',
toLogFormat(error)
);
}
return parsedContact;
};
function parseContact(contact) {
return Object.assign(
{},
omit(contact, ['avatar', 'number', 'email', 'address']),
parseAvatar(contact.avatar),
createArrayKey('number', compact(map(contact.number, parsePhoneItem))),
createArrayKey('email', compact(map(contact.email, parseEmailItem))),
createArrayKey('address', compact(map(contact.address, parseAddress)))
);
}
function idForLogging(message) {
return `${message.source}.${message.sourceDevice} ${message.sent_at}`;
}
exports._validate = (contact, options = {}) => {
const { messageId } = options;
const { name, number, email, address, organization } = contact;
if ((!name || !name.displayName) && !organization) {
return new Error(
`Message ${messageId}: Contact had neither 'displayName' nor 'organization'`
);
}
if (
(!number || !number.length) &&
(!email || !email.length) &&
(!address || !address.length)
) {
return new Error(
`Message ${messageId}: Contact had no included numbers, email or addresses`
);
}
return null;
};
function parsePhoneItem(item) {
if (!item.value) {
return null;
}
return Object.assign({}, item, {
type: item.type || DEFAULT_PHONE_TYPE,
});
}
function parseEmailItem(item) {
if (!item.value) {
return null;
}
return Object.assign({}, item, {
type: item.type || DEFAULT_EMAIL_TYPE,
});
}
function parseAddress(address) {
if (!address) {
return null;
}
if (
!address.street &&
!address.pobox &&
!address.neighborhood &&
!address.city &&
!address.region &&
!address.postcode &&
!address.country
) {
return null;
}
return Object.assign({}, address, {
type: address.type || DEFAULT_ADDRESS_TYPE,
});
}
function parseAvatar(avatar) {
if (!avatar) {
return null;
}
return {
avatar: Object.assign({}, avatar, {
isProfile: avatar.isProfile || false,
}),
};
}
function createArrayKey(key, array) {
if (!array || !array.length) {
return null;
}
return {
[key]: array,
};
}

1
js/modules/types/errors.d.ts vendored Normal file
View file

@ -0,0 +1 @@
export function toLogFormat(error: any): string;

View file

@ -1,5 +1,6 @@
const { isFunction, isString, omit } = require('lodash');
const Contact = require('./contact');
const Attachment = require('./attachment');
const Errors = require('./errors');
const SchemaVersion = require('./schema_version');
@ -29,6 +30,8 @@ const PRIVATE = 'private';
// - `hasAttachments?: 1 | 0`
// - `hasVisualMediaAttachments?: 1 | undefined` (for media gallery Media view)
// - `hasFileAttachments?: 1 | undefined` (for media gallery Documents view)
// Version 6
// - Contact: Write contact avatar to disk, ensure contact data is well-formed
const INITIAL_SCHEMA_VERSION = 0;
@ -37,7 +40,7 @@ const INITIAL_SCHEMA_VERSION = 0;
// add more upgrade steps, we could design a pipeline that does this
// incrementally, e.g. from version 0 / unknown -> 1, 1 --> 2, etc., similar to
// how we do database migrations:
exports.CURRENT_SCHEMA_VERSION = 5;
exports.CURRENT_SCHEMA_VERSION = 6;
// Public API
exports.GROUP = GROUP;
@ -154,6 +157,20 @@ exports._mapAttachments = upgradeAttachment => async (message, context) => {
return Object.assign({}, message, { attachments });
};
// Public API
// _mapContact :: (Contact -> Promise Contact) ->
// (Message, Context) ->
// Promise Message
exports._mapContact = upgradeContact => async (message, context) => {
const contextWithMessage = Object.assign({}, context, { message });
const upgradeWithContext = contact =>
upgradeContact(contact, contextWithMessage);
const contact = await Promise.all(
(message.contact || []).map(upgradeWithContext)
);
return Object.assign({}, message, { contact });
};
// _mapQuotedAttachments :: (QuotedAttachment -> Promise QuotedAttachment) ->
// (Message, Context) ->
// Promise Message
@ -214,6 +231,13 @@ const toVersion4 = exports._withSchemaVersion(
);
const toVersion5 = exports._withSchemaVersion(5, initializeAttachmentMetadata);
const toVersion6 = exports._withSchemaVersion(
6,
exports._mapContact(
Contact.parseAndWriteAvatar(Attachment.migrateDataToFileSystem)
)
);
// UpgradeStep
exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => {
if (!isFunction(writeNewAttachmentData)) {
@ -228,6 +252,7 @@ exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => {
toVersion3,
toVersion4,
toVersion5,
toVersion6,
];
for (let i = 0, max = versions.length; i < max; i += 1) {
@ -269,10 +294,11 @@ exports.createAttachmentDataWriter = writeExistingAttachmentData => {
const message = exports.initializeSchemaVersion(rawMessage);
const { attachments, quote } = message;
const { attachments, quote, contact } = message;
const hasFilesToWrite =
(quote && quote.attachments && quote.attachments.length > 0) ||
(attachments && attachments.length > 0);
(attachments && attachments.length > 0) ||
(contact && contact.length > 0);
if (!hasFilesToWrite) {
return message;
@ -318,10 +344,26 @@ exports.createAttachmentDataWriter = writeExistingAttachmentData => {
return omit(thumbnail, ['data']);
});
const writeContactAvatar = async messageContact => {
const { avatar } = messageContact;
if (avatar && !avatar.avatar) {
return omit(messageContact, ['avatar']);
}
await writeExistingAttachmentData(avatar.avatar);
return Object.assign({}, messageContact, {
avatar: Object.assign({}, avatar, {
avatar: omit(avatar.avatar, ['data']),
}),
});
};
const messageWithoutAttachmentData = Object.assign(
{},
await writeThumbnails(message),
{
contact: await Promise.all((contact || []).map(writeContactAvatar)),
attachments: await Promise.all(
(attachments || []).map(async attachment => {
await writeExistingAttachmentData(attachment);

128
js/signal.js Normal file
View file

@ -0,0 +1,128 @@
// The idea with this file is to make it webpackable for the style guide
const Backbone = require('../ts/backbone');
const Crypto = require('./modules/crypto');
const Database = require('./modules/database');
const HTML = require('../ts/html');
const Message = require('./modules/types/message');
const Notifications = require('../ts/notifications');
const OS = require('../ts/OS');
const Settings = require('./modules/settings');
const Startup = require('./modules/startup');
const Util = require('../ts/util');
// Components
const {
ContactDetail,
} = require('../ts/components/conversation/ContactDetail');
const {
EmbeddedContact,
} = require('../ts/components/conversation/EmbeddedContact');
const { Lightbox } = require('../ts/components/Lightbox');
const { LightboxGallery } = require('../ts/components/LightboxGallery');
const {
MediaGallery,
} = require('../ts/components/conversation/media-gallery/MediaGallery');
const { Quote } = require('../ts/components/conversation/Quote');
// Migrations
const {
getPlaceholderMigrations,
} = require('./modules/migrations/get_placeholder_migrations');
const Migrations0DatabaseWithAttachmentData = require('./modules/migrations/migrations_0_database_with_attachment_data');
const Migrations1DatabaseWithoutAttachmentData = require('./modules/migrations/migrations_1_database_without_attachment_data');
// Types
const AttachmentType = require('./modules/types/attachment');
const Contact = require('../ts/types/Contact');
const Conversation = require('../ts/types/Conversation');
const Errors = require('./modules/types/errors');
const MediaGalleryMessage = require('../ts/components/conversation/media-gallery/types/Message');
const MIME = require('../ts/types/MIME');
const SettingsType = require('../ts/types/Settings');
// Views
const Initialization = require('./modules/views/initialization');
// Workflow
const { IdleDetector } = require('./modules/idle_detector');
const MessageDataMigrator = require('./modules/messages_data_migrator');
exports.setup = (options = {}) => {
const { Attachments, userDataPath } = options;
const Components = {
ContactDetail,
EmbeddedContact,
Lightbox,
LightboxGallery,
MediaGallery,
Types: {
Message: MediaGalleryMessage,
},
Quote,
};
const attachmentsPath = Attachments.getPath(userDataPath);
const readAttachmentData = Attachments.createReader(attachmentsPath);
const loadAttachmentData = AttachmentType.loadData(readAttachmentData);
const Migrations = {
attachmentsPath,
deleteAttachmentData: AttachmentType.deleteData(
Attachments.createDeleter(attachmentsPath)
),
getAbsoluteAttachmentPath: Attachments.createAbsolutePathGetter(
attachmentsPath
),
getPlaceholderMigrations,
loadAttachmentData,
loadMessage: Message.createAttachmentLoader(loadAttachmentData),
Migrations0DatabaseWithAttachmentData,
Migrations1DatabaseWithoutAttachmentData,
upgradeMessageSchema: message =>
Message.upgradeSchema(message, {
writeNewAttachmentData: Attachments.createWriterForNew(attachmentsPath),
}),
writeMessageAttachments: Message.createAttachmentDataWriter(
Attachments.createWriterForExisting(attachmentsPath)
),
};
const Types = {
Attachment: AttachmentType,
Contact,
Conversation,
Errors,
Message,
MIME,
Settings: SettingsType,
};
const Views = {
Initialization,
};
const Workflow = {
IdleDetector,
MessageDataMigrator,
};
return {
Backbone,
Components,
Crypto,
Database,
HTML,
Migrations,
Notifications,
OS,
Settings,
Startup,
Types,
Util,
Views,
Workflow,
};
};

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) {
const regionCode = storage.get('regionCode');
const { contactSelector } = Signal.Types.Contact;
const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
const view = new Whisper.ReactWrapperView({
Component: Signal.Components.ContactDetail,
props: {
contact: contactSelector(contact, {
regionCode,
getAbsoluteAttachmentPath,
}),
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) {
const conversation = await window.ConversationController.getOrCreateAndWait(
number,
'private'
);
window.Whisper.events.trigger('showConversation', conversation);
},
listenBack(view) {
this.panels = this.panels || [];
if (this.panels.length > 0) {
@ -1199,7 +1244,6 @@
if (message) {
const quote = await this.model.makeQuote(this.quotedMessage);
console.log({ quote });
this.quote = quote;
this.focusMessageFieldAndClearDisabled();

View file

@ -5,13 +5,17 @@
/* global emoji_util: false */
/* global Mustache: false */
/* global $: false */
/* global storage: false */
/* global Signal: false */
// eslint-disable-next-line func-names
(function() {
'use strict';
const { Signal } = window;
const { loadAttachmentData } = window.Signal.Migrations;
const {
loadAttachmentData,
getAbsoluteAttachmentPath,
} = window.Signal.Migrations;
window.Whisper = window.Whisper || {};
@ -290,6 +294,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 +443,73 @@
});
this.$('.inner-bubble').prepend(this.quoteView.el);
},
renderContact() {
const contacts = this.model.get('contact');
if (!contacts || !contacts.length) {
return;
}
const contact = contacts[0];
const regionCode = storage.get('regionCode');
const { contactSelector } = Signal.Types.Contact;
const number =
contact.number && contact.number[0] && contact.number[0].value;
const haveConversation =
number && Boolean(window.ConversationController.get(number));
const hasLocalSignalAccount = number && haveConversation;
const onSendMessage = number
? () => {
this.model.trigger('open-conversation', number);
}
: null;
const onOpenContact = () => {
this.model.trigger('show-contact-detail', contact);
};
const getProps = ({ hasSignalAccount }) => ({
contact: contactSelector(contact, {
regionCode,
getAbsoluteAttachmentPath,
}),
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({
hasSignalAccount: hasLocalSignalAccount,
}),
});
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 && !hasLocalSignalAccount) {
// eslint-disable-next-line more/no-then
window.textsecure.messaging
.getProfile(number)
.then(() => {
if (!this.contactView) {
return;
}
this.contactView.update(getProps({ hasSignalAccount: true }));
})
.catch(() => {
// No account available, or network connectivity problem
});
}
},
isImageWithoutCaption() {
const attachments = this.model.get('attachments');
const body = this.model.get('body');
@ -458,7 +532,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 +602,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

@ -1065,6 +1065,14 @@ MessageReceiver.prototype.extend({
promises.push(this.handleAttachment(attachment));
}
if (
decrypted.contact &&
decrypted.contact.avatar &&
decrypted.contact.avatar.avatar
) {
promises.push(this.handleAttachment(decrypted.contact.avatar.avatar));
}
if (decrypted.quote && decrypted.quote.id) {
decrypted.quote.id = decrypted.quote.id.toNumber();
}

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

@ -5,9 +5,6 @@ console.log('preload');
const electron = require('electron');
const Attachment = require('./js/modules/types/attachment');
const Attachments = require('./app/attachments');
const Message = require('./js/modules/types/message');
const { deferredToPromise } = require('./js/modules/deferred_to_promise');
const { app } = electron.remote;
@ -114,10 +111,12 @@ window.React = require('react');
window.ReactDOM = require('react-dom');
window.moment = require('moment');
const { setup } = require('./js/modules/i18n');
const Signal = require('./js/signal');
const i18n = require('./js/modules/i18n');
const Attachments = require('./app/attachments');
const { locale, localeMessages } = window.config;
window.i18n = setup(locale, localeMessages);
window.i18n = i18n.setup(locale, localeMessages);
window.moment.updateLocale(locale, {
relativeTime: {
s: window.i18n('timestamp_s'),
@ -127,100 +126,16 @@ window.moment.updateLocale(locale, {
});
window.moment.locale(locale);
// ES2015+ modules
const attachmentsPath = Attachments.getPath(app.getPath('userData'));
const getAbsoluteAttachmentPath = Attachments.createAbsolutePathGetter(
attachmentsPath
);
const deleteAttachmentData = Attachments.createDeleter(attachmentsPath);
const readAttachmentData = Attachments.createReader(attachmentsPath);
const writeNewAttachmentData = Attachments.createWriterForNew(attachmentsPath);
const writeExistingAttachmentData = Attachments.createWriterForExisting(
attachmentsPath
);
window.Signal = Signal.setup({
Attachments,
userDataPath: app.getPath('userData'),
});
const loadAttachmentData = Attachment.loadData(readAttachmentData);
// Injected context functions to keep `Message` agnostic from Electron:
const upgradeSchemaContext = {
writeNewAttachmentData,
};
const upgradeMessageSchema = message =>
Message.upgradeSchema(message, upgradeSchemaContext);
const {
getPlaceholderMigrations,
} = require('./js/modules/migrations/get_placeholder_migrations');
const { IdleDetector } = require('./js/modules/idle_detector');
window.Signal = {};
window.Signal.Backbone = require('./ts/backbone');
// Pulling these in separately since they access filesystem, electron
window.Signal.Backup = require('./js/modules/backup');
window.Signal.Crypto = require('./js/modules/crypto');
window.Signal.Database = require('./js/modules/database');
window.Signal.Debug = require('./js/modules/debug');
window.Signal.HTML = require('./ts/html');
window.Signal.Logs = require('./js/modules/logs');
// React components
const { Lightbox } = require('./ts/components/Lightbox');
const { LightboxGallery } = require('./ts/components/LightboxGallery');
const {
MediaGallery,
} = require('./ts/components/conversation/media-gallery/MediaGallery');
const { Quote } = require('./ts/components/conversation/Quote');
const MediaGalleryMessage = require('./ts/components/conversation/media-gallery/types/Message');
window.Signal.Components = {
Lightbox,
LightboxGallery,
MediaGallery,
Types: {
Message: MediaGalleryMessage,
},
Quote,
};
window.Signal.Migrations = {};
window.Signal.Migrations.deleteAttachmentData = Attachment.deleteData(
deleteAttachmentData
);
window.Signal.Migrations.getPlaceholderMigrations = getPlaceholderMigrations;
window.Signal.Migrations.writeMessageAttachments = Message.createAttachmentDataWriter(
writeExistingAttachmentData
);
window.Signal.Migrations.getAbsoluteAttachmentPath = getAbsoluteAttachmentPath;
window.Signal.Migrations.loadAttachmentData = loadAttachmentData;
window.Signal.Migrations.loadMessage = Message.createAttachmentLoader(
loadAttachmentData
);
window.Signal.Migrations.Migrations0DatabaseWithAttachmentData = require('./js/modules/migrations/migrations_0_database_with_attachment_data');
window.Signal.Migrations.Migrations1DatabaseWithoutAttachmentData = require('./js/modules/migrations/migrations_1_database_without_attachment_data');
window.Signal.Migrations.upgradeMessageSchema = upgradeMessageSchema;
window.Signal.Notifications = require('./ts/notifications');
window.Signal.OS = require('./ts/OS');
window.Signal.Settings = require('./js/modules/settings');
window.Signal.Startup = require('./js/modules/startup');
window.Signal.Types = {};
window.Signal.Types.Attachment = Attachment;
window.Signal.Types.Conversation = require('./ts/types/Conversation');
window.Signal.Types.Errors = require('./js/modules/types/errors');
window.Signal.Types.Message = Message;
window.Signal.Types.MIME = require('./ts/types/MIME');
window.Signal.Types.Settings = require('./ts/types/Settings');
window.Signal.Util = require('./ts/util');
window.Signal.Views = {};
window.Signal.Views.Initialization = require('./js/modules/views/initialization');
window.Signal.Workflow = {};
window.Signal.Workflow.IdleDetector = IdleDetector;
window.Signal.Workflow.MessageDataMigrator = require('./js/modules/messages_data_migrator');
// We pull this in last, because the native module involved appears to be sensitive to
// /tmp mounted as noexec on Linux.
require('./js/spell_check');
@ -233,7 +148,7 @@ if (window.config.environment === 'test') {
tmp: require('tmp'),
path: require('path'),
basePath: __dirname,
attachmentsPath,
attachmentsPath: window.Signal.Migrations.attachmentsPath,
};
/* eslint-enable global-require, import/no-extraneous-dependencies */
}

View file

@ -84,6 +84,73 @@ message DataMessage {
repeated QuotedAttachment attachments = 4;
}
message Contact {
message Name {
optional string givenName = 1;
optional string familyName = 2;
optional string prefix = 3;
optional string suffix = 4;
optional string middleName = 5;
optional string displayName = 6;
}
message Phone {
enum Type {
HOME = 1;
MOBILE = 2;
WORK = 3;
CUSTOM = 4;
}
optional string value = 1;
optional Type type = 2;
optional string label = 3;
}
message Email {
enum Type {
HOME = 1;
MOBILE = 2;
WORK = 3;
CUSTOM = 4;
}
optional string value = 1;
optional Type type = 2;
optional string label = 3;
}
message PostalAddress {
enum Type {
HOME = 1;
WORK = 2;
CUSTOM = 3;
}
optional Type type = 1;
optional string label = 2;
optional string street = 3;
optional string pobox = 4;
optional string neighborhood = 5;
optional string city = 6;
optional string region = 7;
optional string postcode = 8;
optional string country = 9;
}
message Avatar {
optional AttachmentPointer avatar = 1;
optional bool isProfile = 2;
}
optional Name name = 1;
repeated Phone number = 3;
repeated Email email = 4;
repeated PostalAddress address = 5;
optional Avatar avatar = 6;
optional string organization = 7;
}
optional string body = 1;
repeated AttachmentPointer attachments = 2;
optional GroupContext group = 3;
@ -92,6 +159,7 @@ message DataMessage {
optional bytes profileKey = 6;
optional uint64 timestamp = 7;
optional Quote quote = 8;
repeated Contact contact = 9;
}
message NullMessage {

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,221 @@ span.status {
}
}
.embedded-contact {
margin-top: -9px;
margin-left: -12px;
margin-right: -12px;
cursor: pointer;
button {
@include button-reset;
}
.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;
// We would like to use these border colors for incoming messages, but the version
// of Chromium in our Electron version doesn't render these appropriately. Both
// borders disappear for some reason, and it seems to have to do with transparency.
// 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 {
@include button-reset;
}
.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;
}
}
}
.conversation .contact-detail {
margin-top: 40px;
}
.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

@ -118,6 +118,57 @@ $ios-border-color: rgba(0, 0, 0, 0.1);
}
}
.embedded-contact {
margin: 0;
color: white;
.first-line {
margin: 0;
padding: 10px;
.text-container {
.contact-name {
color: white;
}
}
}
.send-message {
font-weight: normal;
border-bottom: none;
margin-top: 0;
color: white;
.bubble-icon {
@include color-svg('../images/chat-bubble-outline.svg', white);
}
}
}
.incoming .embedded-contact {
.first-line {
.text-container {
.contact-name {
color: $blue;
}
}
}
color: black;
.send-message {
color: $blue;
.bubble-icon {
@include color-svg('../images/chat-bubble-outline.svg', $blue);
}
}
}
.embedded-contact + .content {
border-bottom: 1px solid white;
}
.quoted-message {
// Not ideal, but necessary to override the specificity of the android theme color
// classes used in conversations.scss
@ -378,10 +429,7 @@ $ios-border-color: rgba(0, 0, 0, 0.1);
// bubble wider than an attached image, and we need a background color on the bottom
// section if the image doesn't cover it all.
.outgoing .tail-wrapper {
.attachments {
background-color: $blue;
}
.content {
.inner-bubble {
background-color: $blue;
}

View file

@ -1,8 +1,19 @@
@mixin button-reset {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
@mixin color-svg($svg, $color) {
-webkit-mask: url($svg) no-repeat center;
-webkit-mask-size: 100%;
background-color: $color;
}
@mixin header-icon-white($svg) {
@include color-svg($svg, rgba(255, 255, 255, 0.8));
&:focus,
@ -17,6 +28,7 @@
@include color-svg($svg, black);
}
}
@mixin avatar-colors {
&.red {
background-color: $material_red;

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

@ -239,6 +239,51 @@ $text-dark_l2: darken($text-dark, 30%);
}
}
.embedded-contact {
.first-line {
.image-container {
.default-avatar {
background-color: gray;
color: white;
}
}
.text-container .contact-name {
color: $blue;
}
}
.send-message {
color: $blue;
border-top: 1px solid $grey;
border-bottom: 1px solid $grey;
.bubble-icon {
@include color-svg('../images/chat-bubble.svg', $blue);
}
}
}
.incoming .embedded-contact {
color: white;
.text-container .contact-name {
color: white;
}
.send-message {
color: white;
// Note: would like to use transparency here, but Chromium in Electron doesn't
// render the borders when they are transparent.
border-top: 1px solid $grey_l1_5;
border-bottom: 1px solid $grey_l1_5;
.bubble-icon {
background-color: white;
}
}
}
.outgoing .quoted-message {
background: rgba(255, 255, 255, 0.38);

View file

@ -283,6 +283,7 @@ describe('Backup', () => {
const OUR_NUMBER = '+12025550000';
const CONTACT_ONE_NUMBER = '+12025550001';
const CONTACT_TWO_NUMBER = '+12025550002';
async function wrappedLoadAttachment(attachment) {
return _.omit(await loadAttachmentData(attachment), ['path']);
@ -356,18 +357,31 @@ describe('Backup', () => {
return wrappedLoadAttachment(thumbnail);
});
const promises = (message.attachments || []).map(attachment =>
wrappedLoadAttachment(attachment)
);
return Object.assign({}, await loadThumbnails(message), {
attachments: await Promise.all(promises),
contact: await Promise.all(
(message.contact || []).map(async contact => {
return contact && contact.avatar && contact.avatar.avatar
? Object.assign({}, contact, {
avatar: Object.assign({}, contact.avatar, {
avatar: await wrappedLoadAttachment(
contact.avatar.avatar
),
}),
})
: contact;
})
),
attachments: await Promise.all(
(message.attachments || []).map(attachment =>
wrappedLoadAttachment(attachment)
)
),
});
}
let backupDir;
try {
const ATTACHMENT_COUNT = 2;
const ATTACHMENT_COUNT = 3;
const MESSAGE_COUNT = 1;
const CONVERSATION_COUNT = 1;
@ -473,6 +487,59 @@ describe('Backup', () => {
},
],
},
contact: [
{
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: CONTACT_TWO_NUMBER,
type: 1,
},
],
avatar: {
isProfile: false,
avatar: {
contentType: 'image/png',
data: new Uint8Array([
3,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
]).buffer,
},
},
},
],
};
console.log('Backup test: Clear all data');
@ -494,7 +561,7 @@ describe('Backup', () => {
profileAvatar: {
contentType: 'image/jpeg',
data: new Uint8Array([
3,
4,
2,
3,
4,
@ -530,7 +597,7 @@ describe('Backup', () => {
size: 64,
},
profileKey: new Uint8Array([
4,
5,
2,
3,
4,

View file

@ -0,0 +1,338 @@
const { assert } = require('chai');
const sinon = require('sinon');
const Contact = require('../../../js/modules/types/contact');
const {
stringToArrayBuffer,
} = require('../../../js/modules/string_to_array_buffer');
describe('Contact', () => {
const NUMBER = '+12025550099';
describe('parseAndWriteAvatar', () => {
it('handles message with no avatar in contact', async () => {
const upgradeAttachment = sinon
.stub()
.throws(new Error("Shouldn't be called"));
const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment);
const message = {
body: 'hey there!',
contact: [
{
name: {
displayName: 'Someone Somewhere',
},
number: [
{
type: 1,
value: NUMBER,
},
],
},
],
};
const result = await upgradeVersion(message.contact[0], { message });
assert.deepEqual(result, message.contact[0]);
});
it('removes contact avatar if it has no sub-avatar', async () => {
const upgradeAttachment = sinon
.stub()
.throws(new Error("Shouldn't be called"));
const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment);
const message = {
body: 'hey there!',
contact: [
{
name: {
displayName: 'Someone Somewhere',
},
number: [
{
type: 1,
value: NUMBER,
},
],
avatar: {
isProfile: true,
},
},
],
};
const expected = {
name: {
displayName: 'Someone Somewhere',
},
number: [
{
type: 1,
value: NUMBER,
},
],
};
const result = await upgradeVersion(message.contact[0], { message });
assert.deepEqual(result, expected);
});
it('writes avatar to disk', async () => {
const upgradeAttachment = async () => {
return {
path: 'abc/abcdefg',
};
};
const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment);
const message = {
body: 'hey there!',
contact: [
{
name: {
displayName: 'Someone Somewhere',
},
number: [
{
type: 1,
value: NUMBER,
},
],
email: [
{
type: 2,
value: 'someone@somewhere.com',
},
],
address: [
{
type: 1,
street: '5 Somewhere Ave.',
},
],
avatar: {
otherKey: 'otherValue',
avatar: {
contentType: 'image/png',
data: stringToArrayBuffer('Its easy if you try'),
},
},
},
],
};
const expected = {
name: {
displayName: 'Someone Somewhere',
},
number: [
{
type: 1,
value: NUMBER,
},
],
email: [
{
type: 2,
value: 'someone@somewhere.com',
},
],
address: [
{
type: 1,
street: '5 Somewhere Ave.',
},
],
avatar: {
otherKey: 'otherValue',
isProfile: false,
avatar: {
path: 'abc/abcdefg',
},
},
};
const result = await upgradeVersion(message.contact[0], { message });
assert.deepEqual(result, expected);
});
it('removes number element if it ends up with no value', async () => {
const upgradeAttachment = sinon
.stub()
.throws(new Error("Shouldn't be called"));
const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment);
const message = {
body: 'hey there!',
contact: [
{
name: {
displayName: 'Someone Somewhere',
},
number: [
{
type: 1,
},
],
email: [
{
value: 'someone@somewhere.com',
},
],
},
],
};
const expected = {
name: {
displayName: 'Someone Somewhere',
},
email: [
{
type: 1,
value: 'someone@somewhere.com',
},
],
};
const result = await upgradeVersion(message.contact[0], { message });
assert.deepEqual(result, expected);
});
it('drops address if it has no real values', async () => {
const upgradeAttachment = sinon
.stub()
.throws(new Error("Shouldn't be called"));
const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment);
const message = {
body: 'hey there!',
contact: [
{
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: NUMBER,
},
],
address: [
{
type: 1,
},
],
},
],
};
const expected = {
name: {
displayName: 'Someone Somewhere',
},
number: [
{
value: NUMBER,
type: 1,
},
],
};
const result = await upgradeVersion(message.contact[0], { message });
assert.deepEqual(result, expected);
});
it('removes invalid elements if no values remain in contact', async () => {
const upgradeAttachment = sinon
.stub()
.throws(new Error("Shouldn't be called"));
const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment);
const message = {
body: 'hey there!',
source: NUMBER,
sourceDevice: '1',
sent_at: 1232132,
contact: [
{
name: {
displayName: 'Someone Somewhere',
},
number: [
{
type: 1,
},
],
email: [
{
type: 1,
},
],
},
],
};
const expected = {
name: {
displayName: 'Someone Somewhere',
},
};
const result = await upgradeVersion(message.contact[0], { message });
assert.deepEqual(result, expected);
});
it('handles a contact with just organization', async () => {
const upgradeAttachment = sinon
.stub()
.throws(new Error("Shouldn't be called"));
const upgradeVersion = Contact.parseAndWriteAvatar(upgradeAttachment);
const message = {
contact: [
{
organization: 'Somewhere Consulting',
number: [
{
type: 1,
value: NUMBER,
},
],
},
],
};
const result = await upgradeVersion(message.contact[0], { message });
assert.deepEqual(result, message.contact[0]);
});
});
describe('_validate', () => {
it('returns error if contact has no name.displayName or organization', () => {
const messageId = 'the-message-id';
const contact = {
name: {
name: 'Someone',
},
number: [
{
type: 1,
value: NUMBER,
},
],
};
const expected =
"Message the-message-id: Contact had neither 'displayName' nor 'organization'";
const result = Contact._validate(contact, { messageId });
assert.deepEqual(result.message, expected);
});
it('logs if no values remain in contact', async () => {
const messageId = 'the-message-id';
const contact = {
name: {
displayName: 'Someone Somewhere',
},
number: [],
email: [],
};
const expected =
'Message the-message-id: Contact had no included numbers, email or addresses';
const result = Contact._validate(contact, { messageId });
assert.deepEqual(result.message, expected);
});
});
});

View file

@ -63,6 +63,7 @@ describe('Message', () => {
path: 'ab/abcdefghi',
},
],
contact: [],
};
const writeExistingAttachmentData = attachment => {
@ -108,6 +109,56 @@ describe('Message', () => {
},
],
},
contact: [],
};
const writeExistingAttachmentData = attachment => {
assert.equal(attachment.path, 'ab/abcdefghi');
assert.deepEqual(
attachment.data,
stringToArrayBuffer('Its easy if you try')
);
};
const actual = await Message.createAttachmentDataWriter(
writeExistingAttachmentData
)(input);
assert.deepEqual(actual, expected);
});
it('should process contact avatars', async () => {
const input = {
body: 'Imagine there is no heaven…',
schemaVersion: 4,
attachments: [],
contact: [
{
name: 'john',
avatar: {
isProfile: false,
avatar: {
path: 'ab/abcdefghi',
data: stringToArrayBuffer('Its easy if you try'),
},
},
},
],
};
const expected = {
body: 'Imagine there is no heaven…',
schemaVersion: 4,
attachments: [],
contact: [
{
name: 'john',
avatar: {
isProfile: false,
avatar: {
path: 'ab/abcdefghi',
},
},
},
],
};
const writeExistingAttachmentData = attachment => {
@ -212,6 +263,7 @@ describe('Message', () => {
hasVisualMediaAttachments: undefined,
hasFileAttachments: 1,
schemaVersion: Message.CURRENT_SCHEMA_VERSION,
contact: [],
};
const expectedAttachmentData = stringToArrayBuffer(
@ -458,7 +510,7 @@ describe('Message', () => {
assert.deepEqual(result, message);
});
it('eliminates thumbnails with no data fielkd', async () => {
it('eliminates thumbnails with no data field', async () => {
const upgradeAttachment = sinon
.stub()
.throws(new Error("Shouldn't be called"));
@ -531,4 +583,51 @@ describe('Message', () => {
assert.deepEqual(result, expected);
});
});
describe('_mapContact', () => {
it('handles message with no contact field', async () => {
const upgradeContact = sinon
.stub()
.throws(new Error("Shouldn't be called"));
const upgradeVersion = Message._mapContact(upgradeContact);
const message = {
body: 'hey there!',
};
const expected = {
body: 'hey there!',
contact: [],
};
const result = await upgradeVersion(message);
assert.deepEqual(result, expected);
});
it('handles one contact', async () => {
const upgradeContact = contact => Promise.resolve(contact);
const upgradeVersion = Message._mapContact(upgradeContact);
const message = {
body: 'hey there!',
contact: [
{
name: {
displayName: 'Someone somewhere',
},
},
],
};
const expected = {
body: 'hey there!',
contact: [
{
name: {
displayName: 'Someone somewhere',
},
},
],
};
const result = await upgradeVersion(message);
assert.deepEqual(result, expected);
});
});
});

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

@ -5,8 +5,6 @@ const noop = () => {};
<Lightbox
objectURL="https://placekitten.com/800/600"
contentType="image/jpeg"
onNext={noop}
onPrevious={noop}
onSave={noop}
/>
</div>;

View file

@ -1,16 +1,58 @@
```js
const noop = () => {};
const items = [
{ objectURL: 'https://placekitten.com/800/600', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/900/600', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/980/800', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/656/540', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/762/400', contentType: 'image/jpeg' },
{ objectURL: 'https://placekitten.com/920/620', contentType: 'image/jpeg' },
const messages = [
{
objectURL: 'https://placekitten.com/800/600',
attachments: [
{
contentType: 'image/jpeg',
},
],
},
{
objectURL: 'https://placekitten.com/900/600',
attachments: [
{
contentType: 'image/jpeg',
},
],
},
{
objectURL: 'https://placekitten.com/980/800',
attachments: [
{
contentType: 'image/jpeg',
},
],
},
{
objectURL: 'https://placekitten.com/656/540',
attachments: [
{
contentType: 'image/jpeg',
},
],
},
{
objectURL: 'https://placekitten.com/762/400',
attachments: [
{
contentType: 'image/jpeg',
},
],
},
{
objectURL: 'https://placekitten.com/920/620',
attachments: [
{
contentType: 'image/jpeg',
},
],
},
];
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<LightboxGallery items={items} onSave={noop} />
<LightboxGallery messages={messages} onSave={noop} />
</div>;
```

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,145 @@
import React from 'react';
import {
AddressType,
Contact,
ContactType,
Email,
Phone,
PostalAddress,
} from '../../types/Contact';
import { missingCaseError } from '../../util/missingCaseError';
import {
renderAvatar,
renderContactShorthand,
renderName,
renderSendMessage,
} from './EmbeddedContact';
type Localizer = (key: string, values?: Array<string>) => string;
interface Props {
contact: Contact;
hasSignalAccount: boolean;
i18n: Localizer;
onSendMessage: () => void;
}
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);
}
}
export class ContactDetail extends React.Component<Props, {}> {
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 renderAddressLine(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.renderAddressLine(address.street)}
{this.renderPOBox(address.pobox, i18n)}
{this.renderAddressLine(address.neighborhood)}
{this.renderAddressLineTwo(address)}
{this.renderAddressLine(address.country)}
</div>
);
});
}
public render() {
const { contact, hasSignalAccount, i18n, onSendMessage } = this.props;
return (
<div className="contact-detail">
{renderAvatar(contact)}
{renderName(contact)}
{renderContactShorthand(contact)}
{renderSendMessage({ hasSignalAccount, i18n, onSendMessage })}
{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,102 @@
import React from 'react';
import { Contact, getName } from '../../types/Contact';
interface Props {
contact: Contact;
hasSignalAccount: boolean;
i18n: (key: string, values?: Array<string>) => string;
onSendMessage: () => void;
onOpenContact: () => void;
}
export class EmbeddedContact extends React.Component<Props, {}> {
public render() {
const {
contact,
hasSignalAccount,
i18n,
onOpenContact,
onSendMessage,
} = this.props;
return (
<div className="embedded-contact" onClick={onOpenContact}>
<div className="first-line">
{renderAvatar(contact)}
<div className="text-container">
{renderName(contact)}
{renderContactShorthand(contact)}
</div>
</div>
{renderSendMessage({ hasSignalAccount, i18n, onSendMessage })}
</div>
);
}
}
// Note: putting these below the main component so style guide picks up EmbeddedContact
function getInitials(name: string): string {
return name.trim()[0] || '#';
}
export function renderAvatar(contact: Contact) {
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>
);
}
export function renderName(contact: Contact) {
return <div className="contact-name">{getName(contact)}</div>;
}
export function renderContactShorthand(contact: Contact) {
const { number: phoneNumber, email } = contact;
const firstNumber = phoneNumber && phoneNumber[0] && phoneNumber[0].value;
const firstEmail = email && email[0] && email[0].value;
return <div className="contact-method">{firstNumber || firstEmail}</div>;
}
export function renderSendMessage(props: {
hasSignalAccount: boolean;
i18n: (key: string, values?: Array<string>) => string;
onSendMessage: () => void;
}) {
const { hasSignalAccount, i18n, onSendMessage } = 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>
);
}

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';
@ -14,14 +15,9 @@ export { _ };
export { ConversationContext } from './ConversationContext';
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 * as HTML from '../html';
import * as Attachment from '../../ts/types/Attachment';
import * as MIME from '../../ts/types/MIME';
import { SignalService } from '../../ts/protobuf';
// @ts-ignore
import * as Signal from '../../js/signal';
import { SignalService } from '../protobuf';
// TypeScript wants two things when you import:
// 1) a normal typescript file
@ -103,14 +99,15 @@ import localeMessages from '../../_locales/en/messages.json';
// @ts-ignore
import { setup } from '../../js/modules/i18n';
import * as Util from '../util';
import filesize from 'filesize';
const i18n = setup(locale, localeMessages);
export { theme, locale, i18n };
parent.filesize = filesize;
parent.i18n = i18n;
parent.React = React;
parent.ReactDOM = ReactDOM;
parent.moment = moment;
parent.moment.updateLocale(locale, {
@ -122,18 +119,26 @@ parent.moment.updateLocale(locale, {
});
parent.moment.locale(locale);
parent.React = React;
parent.ReactDOM = ReactDOM;
export { theme, locale, i18n };
parent.Signal.HTML = HTML;
parent.Signal.Types.MIME = MIME;
parent.Signal.Types.Attachment = Attachment;
parent.Signal.Components = {
Quote,
// Used by signal.js to set up code that deals with message attachments/avatars
const Attachments = {
createAbsolutePathGetter: () => () => '/fake/path',
createDeleter: () => async () => undefined,
createReader: () => async () => new ArrayBuffer(10),
createWriterForExisting: () => async () => '/fake/path',
createWriterForNew: () => async () => ({
data: new ArrayBuffer(10),
path: '/fake/path',
}),
getPath: (path: string) => path,
};
parent.Signal.Util = Util;
parent.Signal = Signal.setup({
Attachments,
userDataPath: '/',
});
parent.SignalService = SignalService;
parent.filesize = filesize;
parent.ConversationController._initialFetchComplete = true;
parent.ConversationController._initialPromise = Promise.resolve();
@ -194,6 +199,20 @@ group.contactCollection.add(CONTACTS[2]);
export { COLORS, CONTACTS, me, group };
parent.textsecure.storage.user.getNumber = () => ourNumber;
parent.textsecure.messaging = {
getProfile: async (phoneNumber: string): Promise<boolean> => {
if (parent.ConversationController.get(phoneNumber)) {
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

105
ts/types/Contact.ts Normal file
View file

@ -0,0 +1,105 @@
// @ts-ignore
import Attachments from '../../app/attachments';
import { formatPhoneNumber } from '../util/formatPhoneNumber';
export 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;
}
export enum ContactType {
HOME = 1,
MOBILE = 2,
WORK = 3,
CUSTOM = 4,
}
export enum AddressType {
HOME = 1,
WORK = 2,
CUSTOM = 3,
}
export interface Phone {
value: string;
type: ContactType;
label?: string;
}
export interface Email {
value: string;
type: ContactType;
label?: string;
}
export 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;
}
export function contactSelector(
contact: Contact,
options: {
regionCode: string;
getAbsoluteAttachmentPath: (path: string) => string;
}
) {
const { regionCode, getAbsoluteAttachmentPath } = options;
let { avatar } = contact;
if (avatar && avatar.avatar && avatar.avatar.path) {
avatar = {
...avatar,
avatar: {
...avatar.avatar,
path: getAbsoluteAttachmentPath(avatar.avatar.path),
},
};
}
return {
...contact,
avatar,
number:
contact.number &&
contact.number.map(item => ({
...item,
value: formatPhoneNumber(item.value, {
ourRegionCode: regionCode,
}),
})),
};
}
export function getName(contact: Contact): string | null {
const { name, organization } = contact;
return (name && name.displayName) || organization || null;
}

View file

@ -1,4 +1,5 @@
import { Attachment } from './Attachment';
import { Contact } from './Contact';
import { IndexableBoolean, IndexablePresence } from './IndexedDB';
export type Message = UserMessage | VerifiedChangeMessage;
@ -21,6 +22,7 @@ export type IncomingMessage = Readonly<
sourceDevice?: number;
} & SharedMessageProperties &
MessageSchemaVersion5 &
MessageSchemaVersion6 &
ExpirationTimerUpdate
>;
@ -81,3 +83,9 @@ type MessageSchemaVersion5 = Partial<
hasFileAttachments: IndexablePresence;
}>
>;
type MessageSchemaVersion6 = Partial<
Readonly<{
contact: Array<Contact>;
}>
>;

View file

@ -0,0 +1,22 @@
import { instance, PhoneNumberFormat } from './libphonenumberInstance';
export function formatPhoneNumber(
phoneNumber: string,
options: {
ourRegionCode: string;
}
) {
try {
const { ourRegionCode } = options;
const parsedNumber = instance.parse(phoneNumber);
const regionCode = instance.getRegionCodeForNumber(parsedNumber);
if (ourRegionCode && regionCode === ourRegionCode) {
return instance.format(parsedNumber, PhoneNumberFormat.NATIONAL);
}
return instance.format(parsedNumber, PhoneNumberFormat.INTERNATIONAL);
} catch (error) {
return phoneNumber;
}
}

View file

@ -0,0 +1,6 @@
import libphonenumber from 'google-libphonenumber';
const instance = libphonenumber.PhoneNumberUtil.getInstance();
const PhoneNumberFormat = libphonenumber.PhoneNumberFormat;
export { instance, PhoneNumberFormat };

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"