Finish new Message component, integrate into application

Also:
- New schema version 8 with video/image thumbnails, screenshots, sizes
- Upgrade messages not at current schema version when loading messages
  to show in conversation
- New MessageDetail react component
- New ConversationHeader react component
This commit is contained in:
Scott Nonnenberg 2018-07-09 14:29:13 -07:00
parent 69f11c4a7b
commit 3c69886320
102 changed files with 9644 additions and 7381 deletions

View file

@ -1,6 +1,7 @@
/* global _: false */
/* global Backbone: false */
/* global storage: false */
/* global filesize: false */
/* global ConversationController: false */
/* global getAccountManager: false */
/* global i18n: false */
@ -17,8 +18,41 @@
window.Whisper = window.Whisper || {};
const { Message: TypedMessage, Contact } = Signal.Types;
const { deleteAttachmentData } = Signal.Migrations;
const { Message: TypedMessage, Contact, PhoneNumber } = Signal.Types;
const {
// loadAttachmentData,
deleteAttachmentData,
getAbsoluteAttachmentPath,
} = Signal.Migrations;
window.AccountCache = Object.create(null);
window.AccountJobs = Object.create(null);
window.doesAcountCheckJobExist = number =>
Boolean(window.AccountJobs[number]);
window.checkForSignalAccount = number => {
if (window.AccountJobs[number]) {
return window.AccountJobs[number];
}
// eslint-disable-next-line more/no-then
const job = textsecure.messaging
.getProfile(number)
.then(() => {
window.AccountCache[number] = true;
})
.catch(() => {
window.AccountCache[number] = false;
});
window.AccountJobs[number] = job;
return job;
};
window.isSignalAccountCheckComplete = number =>
window.AccountCache[number] !== undefined;
window.hasSignalAccount = number => window.AccountCache[number];
window.Whisper.Message = Backbone.Model.extend({
database: Whisper.Database,
@ -28,6 +62,8 @@
this.set(TypedMessage.initializeSchemaVersion(attributes));
}
this.OUR_NUMBER = textsecure.storage.user.getNumber();
this.on('change:attachments', this.updateImageUrl);
this.on('destroy', this.onDestroy);
this.on('change:expirationStartTimestamp', this.setToExpire);
@ -113,7 +149,7 @@
return i18n('leftTheGroup', this.getNameForNumber(groupUpdate.left));
}
const messages = [i18n('updatedTheGroup')];
const messages = [];
if (groupUpdate.name) {
messages.push(i18n('titleIsNow', groupUpdate.name));
}
@ -129,7 +165,7 @@
}
}
return messages.join(' ');
return messages.join(', ');
}
if (this.isEndSession()) {
return i18n('sessionEnded');
@ -139,6 +175,9 @@
}
return this.get('body');
},
isVerifiedChange() {
return this.get('type') === 'verified-change';
},
isKeyChange() {
return this.get('type') === 'keychange';
},
@ -158,8 +197,12 @@
);
}
if (this.isKeyChange()) {
const conversation = this.getModelForKeyChange();
return i18n('keychanged', conversation.getTitle());
const phoneNumber = this.get('key_changed');
const conversation = this.findContact(phoneNumber);
return i18n(
'safetyNumberChangedGroup',
conversation ? conversation.getTitle() : null
);
}
const contacts = this.get('contact');
if (contacts && contacts.length) {
@ -252,6 +295,268 @@
thumbnail: thumbnailWithObjectUrl,
});
},
getPropsForTimerNotification() {
const { expireTimer, fromSync, source } = this.get(
'expirationTimerUpdate'
);
const timespan = Whisper.ExpirationTimerOptions.getName(expireTimer || 0);
const basicProps = {
type: 'fromOther',
...this.findAndFormatContact(source),
timespan,
};
if (source === this.OUR_NUMBER) {
return {
...basicProps,
type: 'fromMe',
};
} else if (fromSync) {
return {
...basicProps,
type: 'fromSync',
};
}
return basicProps;
},
getPropsForSafetyNumberNotification() {
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const phoneNumber = this.get('key_changed');
const onVerify = () =>
this.trigger('show-identity', this.findContact(phoneNumber));
return {
isGroup,
contact: this.findAndFormatContact(phoneNumber),
onVerify,
};
},
getPropsForVerificationNotification() {
const type = this.get('verified') ? 'markVerified' : 'markNotVerified';
const isLocal = this.get('local');
const phoneNumber = this.get('verifiedChanged');
return {
type,
isLocal,
contact: this.findAndFormatContact(phoneNumber),
};
},
getPropsForResetSessionNotification() {
// It doesn't need anything right now!
return {};
},
findContact(phoneNumber) {
return ConversationController.get(phoneNumber);
},
findAndFormatContact(phoneNumber) {
const { format } = PhoneNumber;
const regionCode = storage.get('regionCode');
const contactModel = this.findContact(phoneNumber);
const avatar = contactModel ? contactModel.getAvatar() : null;
const color = contactModel ? contactModel.getColor() : null;
return {
phoneNumber: format(phoneNumber, {
ourRegionCode: regionCode,
}),
color,
avatarPath: avatar ? avatar.url : null,
name: contactModel ? contactModel.getName() : null,
profileName: contactModel ? contactModel.getProfileName() : null,
title: contactModel ? contactModel.getTitle() : null,
};
},
getPropsForGroupNotification() {
const groupUpdate = this.get('group_update');
const changes = [];
if (!groupUpdate.name && !groupUpdate.left && !groupUpdate.joined) {
changes.push({
type: 'general',
});
}
if (groupUpdate.joined) {
changes.push({
type: 'add',
contacts: _.map(
Array.isArray(groupUpdate.joined)
? groupUpdate.joined
: [groupUpdate.joined],
phoneNumber => this.findAndFormatContact(phoneNumber)
),
});
}
if (groupUpdate.left === 'You') {
changes.push({
type: 'remove',
isMe: true,
});
} else if (groupUpdate.left) {
changes.push({
type: 'remove',
contacts: _.map(
Array.isArray(groupUpdate.left)
? groupUpdate.left
: [groupUpdate.left],
phoneNumber => this.findAndFormatContact(phoneNumber)
),
});
}
if (groupUpdate.name) {
changes.push({
type: 'name',
newName: groupUpdate.name,
});
}
return {
changes,
};
},
getMessagePropStatus() {
if (this.hasErrors()) {
return 'error';
}
const readBy = this.get('read_by') || [];
if (readBy.length > 0) {
return 'read';
}
const delivered = this.get('delivered');
const deliveredTo = this.get('delivered_to') || [];
if (delivered || deliveredTo.length > 0) {
return 'delivered';
}
const sent = this.get('sent');
const sentTo = this.get('sent_to') || [];
if (sent || sentTo.length > 0) {
return 'sent';
}
return 'sending';
},
getPropsForMessage() {
const phoneNumber = this.getSource();
const contact = this.findAndFormatContact(phoneNumber);
const contactModel = this.findContact(phoneNumber);
const authorColor = contactModel ? contactModel.getColor() : null;
const authorAvatar = contactModel ? contactModel.getAvatar() : null;
const authorAvatarPath = authorAvatar.url;
const expirationLength = this.get('expireTimer') * 1000;
const expireTimerStart = this.get('expirationStartTimestamp');
const expirationTimestamp =
expirationLength && expireTimerStart
? expireTimerStart + expirationLength
: null;
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const attachments = this.get('attachments');
const firstAttachment = attachments && attachments[0];
return {
text: this.createNonBreakingLastSeparator(this.get('body')),
id: this.id,
direction: this.isIncoming() ? 'incoming' : 'outgoing',
timestamp: this.get('sent_at'),
status: this.getMessagePropStatus(),
contact: this.getPropsForEmbeddedContact(),
authorName: contact.name,
authorProfileName: contact.profileName,
authorPhoneNumber: contact.phoneNumber,
authorColor,
conversationType: isGroup ? 'group' : 'direct',
attachment: this.getPropsForAttachment(firstAttachment),
quote: this.getPropsForQuote(),
authorAvatarPath,
expirationLength,
expirationTimestamp,
onReply: () => this.trigger('reply', this),
onRetrySend: () => this.retrySend(),
onShowDetail: () => this.trigger('show-message-detail', this),
onDelete: () => this.trigger('delete', this),
onClickAttachment: () =>
this.trigger('show-lightbox', {
attachment: firstAttachment,
message: this,
}),
onDownload: () =>
this.trigger('download', {
attachment: firstAttachment,
message: this,
}),
};
},
createNonBreakingLastSeparator(text) {
if (!text) {
return null;
}
const nbsp = '\xa0';
const regex = /(\S)( +)(\S+\s*)$/;
return text.replace(regex, (match, start, spaces, end) => {
const newSpaces = _.reduce(
spaces,
accumulator => accumulator + nbsp,
''
);
return `${start}${newSpaces}${end}`;
});
},
getPropsForEmbeddedContact() {
const regionCode = storage.get('regionCode');
const { contactSelector } = Contact;
const contacts = this.get('contact');
if (!contacts || !contacts.length) {
return null;
}
const contact = contacts[0];
const firstNumber =
contact.number && contact.number[0] && contact.number[0].value;
const onSendMessage = firstNumber
? () => {
this.trigger('open-conversation', firstNumber);
}
: null;
const onClick = async () => {
// First let's be sure that the signal account check is complete.
await window.checkForSignalAccount(firstNumber);
this.trigger('show-contact-detail', {
contact,
hasSignalAccount: window.hasSignalAccount(firstNumber),
});
};
// Would be nice to do this before render, on initial load of message
if (!window.isSignalAccountCheckComplete(firstNumber)) {
window.checkForSignalAccount(firstNumber).then(() => {
this.trigger('change');
});
}
return contactSelector(contact, {
regionCode,
getAbsoluteAttachmentPath,
onSendMessage,
onClick,
hasSignalAccount: window.hasSignalAccount(firstNumber),
});
},
getPropsForQuote() {
const quote = this.get('quote');
if (!quote) {
@ -259,15 +564,14 @@
}
const objectUrl = this.getQuoteObjectUrl();
const OUR_NUMBER = textsecure.storage.user.getNumber();
const { author } = quote;
const contact = this.getQuoteContact();
const authorTitle = contact ? contact.getTitle() : author;
const authorPhoneNumber = author;
const authorProfileName = contact ? contact.getProfileName() : null;
const authorName = contact ? contact.getName() : null;
const authorColor = contact ? contact.getColor() : 'grey';
const isFromMe = contact ? contact.id === OUR_NUMBER : false;
const isIncoming = this.isIncoming();
const isFromMe = contact ? contact.id === this.OUR_NUMBER : false;
const onClick = () => {
const { quotedMessage } = this;
if (quotedMessage) {
@ -275,55 +579,150 @@
}
};
const firstAttachment = quote.attachments && quote.attachments[1];
return {
attachments: (quote.attachments || []).map(attachment =>
this.processAttachment(attachment, objectUrl)
),
authorColor,
authorProfileName,
authorTitle,
text: this.createNonBreakingLastSeparator(quote.text),
attachment: firstAttachment
? this.processAttachment(firstAttachment, objectUrl)
: null,
isFromMe,
isIncoming,
authorPhoneNumber,
authorProfileName,
authorName,
authorColor,
onClick: this.quotedMessage ? onClick : null,
text: quote.text,
};
},
getPropsForAttachment(attachment) {
if (!attachment) {
return null;
}
const { path, flags, size, screenshot, thumbnail } = attachment;
return {
...attachment,
fileSize: size ? filesize(size) : null,
isVoiceMessage:
flags &&
// eslint-disable-next-line no-bitwise
flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
url: getAbsoluteAttachmentPath(path),
screenshot: screenshot
? {
...screenshot,
url: getAbsoluteAttachmentPath(screenshot.path),
}
: null,
thumbnail: thumbnail
? {
...thumbnail,
url: getAbsoluteAttachmentPath(thumbnail.path),
}
: null,
};
},
getPropsForMessageDetail() {
const newIdentity = i18n('newIdentity');
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
// Older messages don't have the recipients included on the message, so we fall
// back to the conversation's current recipients
const phoneNumbers = this.isIncoming()
? [this.get('source')]
: this.get('recipients') || this.conversation.getRecipients();
// This will make the error message for outgoing key errors a bit nicer
const allErrors = (this.get('errors') || []).map(error => {
if (error.name === OUTGOING_KEY_ERROR) {
// eslint-disable-next-line no-param-reassign
error.message = newIdentity;
}
return error;
});
// If an error has a specific number it's associated with, we'll show it next to
// that contact. Otherwise, it will be a standalone entry.
const errors = _.reject(allErrors, error => Boolean(error.number));
const errorsGroupedById = _.groupBy(allErrors, 'number');
const finalContacts = (phoneNumbers || []).map(id => {
const errorsForContact = errorsGroupedById[id];
const isOutgoingKeyError = Boolean(
_.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR)
);
return {
...this.findAndFormatContact(id),
status: this.getStatus(id),
errors: errorsForContact,
isOutgoingKeyError,
onSendAnyway: () =>
this.trigger('force-send', {
contact: this.findContact(id),
message: this,
}),
onShowSafetyNumber: () =>
this.trigger('show-identity', this.findContact(id)),
};
});
// The prefix created here ensures that contacts with errors are listed
// first; otherwise it's alphabetical
const sortedContacts = _.sortBy(
finalContacts,
contact => `${contact.errors ? '0' : '1'}${contact.title}`
);
return {
sentAt: this.get('sent_at'),
receivedAt: this.get('received_at'),
message: {
...this.getPropsForMessage(),
disableMenu: true,
// To ensure that group avatar doesn't show up
conversationType: 'direct',
},
errors,
contacts: sortedContacts,
};
},
retrySend() {
const retries = _.filter(
this.get('errors'),
this.isReplayableError.bind(this)
);
_.map(retries, 'number').forEach(number => {
this.resend(number);
});
},
getConversation() {
// This needs to be an unsafe call, because this method is called during
// initial module setup. We may be in the middle of the initial fetch to
// the database.
return ConversationController.getUnsafe(this.get('conversationId'));
},
getExpirationTimerUpdateSource() {
if (!this.isExpirationTimerUpdate()) {
throw new Error('Message is not a timer update!');
getIncomingContact() {
if (!this.isIncoming()) {
return null;
}
const source = this.get('source');
if (!source) {
return null;
}
const conversationId = this.get('expirationTimerUpdate').source;
return ConversationController.getOrCreate(conversationId, 'private');
return ConversationController.getOrCreate(source, 'private');
},
getSource() {
if (this.isIncoming()) {
return this.get('source');
}
return this.OUR_NUMBER;
},
getContact() {
let conversationId = this.get('source');
if (!this.isIncoming()) {
conversationId = textsecure.storage.user.getNumber();
}
return ConversationController.getOrCreate(conversationId, 'private');
},
getModelForKeyChange() {
const id = this.get('key_changed');
if (!this.modelForKeyChange) {
const c = ConversationController.getOrCreate(id, 'private');
this.modelForKeyChange = c;
}
return this.modelForKeyChange;
},
getModelForVerifiedChange() {
const id = this.get('verifiedChanged');
if (!this.modelForVerifiedChange) {
const c = ConversationController.getOrCreate(id, 'private');
this.modelForVerifiedChange = c;
}
return this.modelForVerifiedChange;
return ConversationController.getOrCreate(this.getSource(), 'private');
},
isOutgoing() {
return this.get('type') === 'outgoing';