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:
parent
69f11c4a7b
commit
3c69886320
102 changed files with 9644 additions and 7381 deletions
|
@ -5,7 +5,6 @@
|
|||
|
||||
/* global ConversationController: false */
|
||||
/* global libsignal: false */
|
||||
/* global Signal: false */
|
||||
/* global storage: false */
|
||||
/* global textsecure: false */
|
||||
/* global Whisper: false */
|
||||
|
@ -19,7 +18,15 @@
|
|||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const { Message } = window.Signal.Types;
|
||||
const { Util } = window.Signal;
|
||||
const { GoogleChrome } = Util;
|
||||
const {
|
||||
Conversation,
|
||||
Contact,
|
||||
Errors,
|
||||
Message,
|
||||
VisualAttachment,
|
||||
} = window.Signal.Types;
|
||||
const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations;
|
||||
|
||||
// TODO: Factor out private and group subclasses of Conversation
|
||||
|
@ -108,7 +115,10 @@
|
|||
this.on('change:profileKey', this.onChangeProfileKey);
|
||||
this.on('destroy', this.revokeAvatarUrl);
|
||||
|
||||
// Listening for out-of-band data updates
|
||||
this.on('newmessage', this.addSingleMessage);
|
||||
this.on('delivered', this.updateMessage);
|
||||
this.on('read', this.updateMessage);
|
||||
this.on('expired', this.onExpired);
|
||||
this.listenTo(
|
||||
this.messageCollection,
|
||||
|
@ -127,6 +137,7 @@
|
|||
mine.trigger('expired', mine);
|
||||
}
|
||||
},
|
||||
|
||||
async onExpiredCollection(message) {
|
||||
console.log('onExpiredCollection', message.attributes);
|
||||
const removeMessage = () => {
|
||||
|
@ -144,6 +155,12 @@
|
|||
removeMessage();
|
||||
},
|
||||
|
||||
// Used to update existing messages when updated from out-of-band db access,
|
||||
// like read and delivery receipts.
|
||||
updateMessage(message) {
|
||||
this.messageCollection.add(message, { merge: true });
|
||||
},
|
||||
|
||||
addSingleMessage(message) {
|
||||
const model = this.messageCollection.add(message, { merge: true });
|
||||
model.setToExpire();
|
||||
|
@ -716,24 +733,23 @@
|
|||
},
|
||||
|
||||
async makeThumbnailAttachment(attachment) {
|
||||
const { arrayBufferToObjectURL } = Util;
|
||||
const attachmentWithData = await loadAttachmentData(attachment);
|
||||
const { data, contentType } = attachmentWithData;
|
||||
const objectUrl = Signal.Util.arrayBufferToObjectURL({
|
||||
const objectUrl = arrayBufferToObjectURL({
|
||||
data,
|
||||
type: contentType,
|
||||
});
|
||||
|
||||
const thumbnail = Signal.Util.GoogleChrome.isImageTypeSupported(
|
||||
contentType
|
||||
)
|
||||
? await Whisper.FileInputView.makeImageThumbnail(128, objectUrl)
|
||||
: await Whisper.FileInputView.makeVideoThumbnail(128, objectUrl);
|
||||
const thumbnail = GoogleChrome.isImageTypeSupported(contentType)
|
||||
? await VisualAttachment.makeImageThumbnail(128, objectUrl)
|
||||
: await VisualAttachment.makeVideoThumbnail(128, objectUrl);
|
||||
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
const arrayBuffer = await this.blobToArrayBuffer(thumbnail);
|
||||
const finalContentType = 'image/png';
|
||||
const finalObjectUrl = Signal.Util.arrayBufferToObjectURL({
|
||||
const finalObjectUrl = arrayBufferToObjectURL({
|
||||
data: arrayBuffer,
|
||||
type: finalContentType,
|
||||
});
|
||||
|
@ -746,7 +762,7 @@
|
|||
},
|
||||
|
||||
async makeQuote(quotedMessage) {
|
||||
const { getName } = Signal.Types.Contact;
|
||||
const { getName } = Contact;
|
||||
const contact = quotedMessage.getContact();
|
||||
const attachments = quotedMessage.get('attachments');
|
||||
|
||||
|
@ -765,8 +781,8 @@
|
|||
(attachments || []).map(async attachment => {
|
||||
const { contentType } = attachment;
|
||||
const willMakeThumbnail =
|
||||
Signal.Util.GoogleChrome.isImageTypeSupported(contentType) ||
|
||||
Signal.Util.GoogleChrome.isVideoTypeSupported(contentType);
|
||||
GoogleChrome.isImageTypeSupported(contentType) ||
|
||||
GoogleChrome.isVideoTypeSupported(contentType);
|
||||
const makeThumbnail = async () => {
|
||||
try {
|
||||
if (willMakeThumbnail) {
|
||||
|
@ -873,16 +889,14 @@
|
|||
const lastMessage = collection.at(0);
|
||||
|
||||
const lastMessageJSON = lastMessage ? lastMessage.toJSON() : null;
|
||||
const lastMessageUpdate = window.Signal.Types.Conversation.createLastMessageUpdate(
|
||||
{
|
||||
currentLastMessageText: this.get('lastMessage') || null,
|
||||
currentTimestamp: this.get('timestamp') || null,
|
||||
lastMessage: lastMessageJSON,
|
||||
lastMessageNotificationText: lastMessage
|
||||
? lastMessage.getNotificationText()
|
||||
: null,
|
||||
}
|
||||
);
|
||||
const lastMessageUpdate = Conversation.createLastMessageUpdate({
|
||||
currentLastMessageText: this.get('lastMessage') || null,
|
||||
currentTimestamp: this.get('timestamp') || null,
|
||||
lastMessage: lastMessageJSON,
|
||||
lastMessageNotificationText: lastMessage
|
||||
? lastMessage.getNotificationText()
|
||||
: null,
|
||||
});
|
||||
|
||||
console.log('Conversation: Update last message:', {
|
||||
id: this.idForLogging() || null,
|
||||
|
@ -1284,8 +1298,8 @@
|
|||
|
||||
return (
|
||||
thumbnail ||
|
||||
Signal.Util.GoogleChrome.isImageTypeSupported(contentType) ||
|
||||
Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)
|
||||
GoogleChrome.isImageTypeSupported(contentType) ||
|
||||
GoogleChrome.isVideoTypeSupported(contentType)
|
||||
);
|
||||
},
|
||||
forceRender(message) {
|
||||
|
@ -1323,8 +1337,8 @@
|
|||
}
|
||||
|
||||
if (
|
||||
!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
|
||||
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)
|
||||
!GoogleChrome.isImageTypeSupported(first.contentType) &&
|
||||
!GoogleChrome.isVideoTypeSupported(first.contentType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
@ -1352,7 +1366,7 @@
|
|||
} catch (error) {
|
||||
console.log(
|
||||
'Problem loading attachment data for quoted message from database',
|
||||
Signal.Types.Errors.toLogFormat(error)
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
@ -1370,8 +1384,8 @@
|
|||
}
|
||||
|
||||
if (
|
||||
!Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) &&
|
||||
!Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType)
|
||||
!GoogleChrome.isImageTypeSupported(first.contentType) &&
|
||||
!GoogleChrome.isVideoTypeSupported(first.contentType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
@ -1410,7 +1424,7 @@
|
|||
try {
|
||||
const thumbnailWithData = await loadAttachmentData(thumbnail);
|
||||
const { data, contentType } = thumbnailWithData;
|
||||
thumbnailWithData.objectUrl = Signal.Util.arrayBufferToObjectURL({
|
||||
thumbnailWithData.objectUrl = Util.arrayBufferToObjectURL({
|
||||
data,
|
||||
type: contentType,
|
||||
});
|
||||
|
@ -1489,10 +1503,30 @@
|
|||
return Promise.all(promises);
|
||||
},
|
||||
|
||||
async upgradeMessages(messages) {
|
||||
for (let max = messages.length, i = 0; i < max; i += 1) {
|
||||
const message = messages.at(i);
|
||||
const { attributes } = message;
|
||||
const { schemaVersion } = attributes;
|
||||
|
||||
if (schemaVersion < Message.CURRENT_SCHEMA_VERSION) {
|
||||
const upgradedMessage = upgradeMessageSchema(attributes);
|
||||
message.set(upgradedMessage);
|
||||
// Yep, we really do want to wait for each of these
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await wrapDeferred(message.save());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async fetchMessages() {
|
||||
if (!this.id) {
|
||||
throw new Error('This conversation has no id!');
|
||||
}
|
||||
if (this.inProgressFetch) {
|
||||
console.log('Attempting to start a parallel fetchMessages() call');
|
||||
return;
|
||||
}
|
||||
|
||||
this.inProgressFetch = this.messageCollection.fetchConversation(
|
||||
this.id,
|
||||
|
@ -1501,11 +1535,24 @@
|
|||
);
|
||||
|
||||
await this.inProgressFetch;
|
||||
this.inProgressFetch = null;
|
||||
|
||||
try {
|
||||
// We are now doing the work to upgrade messages before considering the load from
|
||||
// the database complete. Note that we do save messages back, so it is a
|
||||
// one-time hit. We do this so we have guarantees about message structure.
|
||||
await this.upgradeMessages(this.messageCollection);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'fetchMessages: failed to upgrade messages',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
|
||||
// We kick this process off, but don't wait for it. If async updates happen on a
|
||||
// given Message, 'change' will be triggered
|
||||
this.processQuotes(this.messageCollection);
|
||||
|
||||
this.inProgressFetch = null;
|
||||
},
|
||||
|
||||
hasMember(number) {
|
||||
|
@ -1534,28 +1581,36 @@
|
|||
});
|
||||
},
|
||||
|
||||
destroyMessages() {
|
||||
this.messageCollection
|
||||
.fetch({
|
||||
index: {
|
||||
// 'conversation' index on [conversationId, received_at]
|
||||
name: 'conversation',
|
||||
lower: [this.id],
|
||||
upper: [this.id, Number.MAX_VALUE],
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
const { models } = this.messageCollection;
|
||||
this.messageCollection.reset([]);
|
||||
_.each(models, message => {
|
||||
message.destroy();
|
||||
});
|
||||
this.save({
|
||||
lastMessage: null,
|
||||
timestamp: null,
|
||||
active_at: null,
|
||||
});
|
||||
async destroyMessages() {
|
||||
let loaded;
|
||||
do {
|
||||
// Yes, we really want the await in the loop. We're deleting 100 at a
|
||||
// time so we don't use too much memory.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await wrapDeferred(
|
||||
this.messageCollection.fetch({
|
||||
limit: 100,
|
||||
index: {
|
||||
// 'conversation' index on [conversationId, received_at]
|
||||
name: 'conversation',
|
||||
lower: [this.id],
|
||||
upper: [this.id, Number.MAX_VALUE],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
loaded = this.messageCollection.models;
|
||||
this.messageCollection.reset([]);
|
||||
_.each(loaded, message => {
|
||||
message.destroy();
|
||||
});
|
||||
} while (loaded.length > 0);
|
||||
|
||||
this.save({
|
||||
lastMessage: null,
|
||||
timestamp: null,
|
||||
active_at: null,
|
||||
});
|
||||
},
|
||||
|
||||
getName() {
|
||||
|
@ -1646,20 +1701,8 @@
|
|||
}
|
||||
},
|
||||
getColor() {
|
||||
const title = this.get('name');
|
||||
let color = this.get('color');
|
||||
if (!color) {
|
||||
if (this.isPrivate()) {
|
||||
if (title) {
|
||||
color = COLORS[Math.abs(this.hashCode()) % 15];
|
||||
} else {
|
||||
color = 'grey';
|
||||
}
|
||||
} else {
|
||||
color = 'default';
|
||||
}
|
||||
}
|
||||
return color;
|
||||
const { migrateColor } = Util;
|
||||
return migrateColor(this.get('color'));
|
||||
},
|
||||
getAvatar() {
|
||||
if (this.avatarUrl === undefined) {
|
||||
|
@ -1705,9 +1748,7 @@
|
|||
const messageJSON = message.toJSON();
|
||||
const messageSentAt = messageJSON.sent_at;
|
||||
const messageId = message.id;
|
||||
const isExpiringMessage = Signal.Types.Message.hasExpiration(
|
||||
messageJSON
|
||||
);
|
||||
const isExpiringMessage = Message.hasExpiration(messageJSON);
|
||||
|
||||
console.log('Add notification', {
|
||||
conversationId: this.idForLogging(),
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue