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

@ -116,6 +116,9 @@
function mapOldThemeToNew(theme) {
switch (theme) {
case 'dark':
case 'light':
return theme;
case 'android-dark':
return 'dark';
case 'android':

View file

@ -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(),

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

View file

@ -4,7 +4,6 @@ const Backbone = require('../../ts/backbone');
const Crypto = require('./crypto');
const Database = require('./database');
const Emoji = require('../../ts/util/emoji');
const Message = require('./types/message');
const Notifications = require('../../ts/notifications');
const OS = require('../../ts/OS');
const Settings = require('./settings');
@ -18,19 +17,38 @@ const {
const { ContactListItem } = require('../../ts/components/ContactListItem');
const { ContactName } = require('../../ts/components/conversation/ContactName');
const {
ConversationTitle,
} = require('../../ts/components/conversation/ConversationTitle');
ConversationHeader,
} = require('../../ts/components/conversation/ConversationHeader');
const {
EmbeddedContact,
} = require('../../ts/components/conversation/EmbeddedContact');
const { Emojify } = require('../../ts/components/conversation/Emojify');
const {
GroupNotification,
} = require('../../ts/components/conversation/GroupNotification');
const { Lightbox } = require('../../ts/components/Lightbox');
const { LightboxGallery } = require('../../ts/components/LightboxGallery');
const {
MediaGallery,
} = require('../../ts/components/conversation/media-gallery/MediaGallery');
const { Message } = require('../../ts/components/conversation/Message');
const { MessageBody } = require('../../ts/components/conversation/MessageBody');
const {
MessageDetail,
} = require('../../ts/components/conversation/MessageDetail');
const { Quote } = require('../../ts/components/conversation/Quote');
const {
ResetSessionNotification,
} = require('../../ts/components/conversation/ResetSessionNotification');
const {
SafetyNumberNotification,
} = require('../../ts/components/conversation/SafetyNumberNotification');
const {
TimerNotification,
} = require('../../ts/components/conversation/TimerNotification');
const {
VerificationNotification,
} = require('../../ts/components/conversation/VerificationNotification');
// Migrations
const {
@ -42,11 +60,14 @@ const Migrations1DatabaseWithoutAttachmentData = require('./migrations/migration
// Types
const AttachmentType = require('./types/attachment');
const VisualAttachment = require('./types/visual_attachment');
const Contact = require('../../ts/types/Contact');
const Conversation = require('../../ts/types/Conversation');
const Errors = require('./types/errors');
const MediaGalleryMessage = require('../../ts/components/conversation/media-gallery/types/Message');
const MessageType = require('./types/message');
const MIME = require('../../ts/types/MIME');
const PhoneNumber = require('../../ts/types/PhoneNumber');
const SettingsType = require('../../ts/types/Settings');
// Views
@ -57,39 +78,59 @@ const { IdleDetector } = require('./idle_detector');
const MessageDataMigrator = require('./messages_data_migrator');
function initializeMigrations({
Attachments,
userDataPath,
Type,
getRegionCode,
Attachments,
Type,
VisualType,
}) {
if (!Attachments) {
return null;
}
const {
getPath,
createReader,
createAbsolutePathGetter,
createWriterForNew,
createWriterForExisting,
} = Attachments;
const {
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
} = VisualType;
const attachmentsPath = Attachments.getPath(userDataPath);
const readAttachmentData = Attachments.createReader(attachmentsPath);
const attachmentsPath = getPath(userDataPath);
const readAttachmentData = createReader(attachmentsPath);
const loadAttachmentData = Type.loadData(readAttachmentData);
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
return {
attachmentsPath,
deleteAttachmentData: Type.deleteData(
Attachments.createDeleter(attachmentsPath)
),
getAbsoluteAttachmentPath: Attachments.createAbsolutePathGetter(
attachmentsPath
),
getAbsoluteAttachmentPath,
getPlaceholderMigrations,
loadAttachmentData,
loadMessage: Message.createAttachmentLoader(loadAttachmentData),
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
Migrations0DatabaseWithAttachmentData,
Migrations1DatabaseWithoutAttachmentData,
upgradeMessageSchema: message =>
Message.upgradeSchema(message, {
writeNewAttachmentData: Attachments.createWriterForNew(attachmentsPath),
MessageType.upgradeSchema(message, {
writeNewAttachmentData: createWriterForNew(attachmentsPath),
getRegionCode,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
}),
writeMessageAttachments: Message.createAttachmentDataWriter(
Attachments.createWriterForExisting(attachmentsPath)
writeMessageAttachments: MessageType.createAttachmentDataWriter(
createWriterForExisting(attachmentsPath)
),
};
}
@ -98,27 +139,35 @@ exports.setup = (options = {}) => {
const { Attachments, userDataPath, getRegionCode } = options;
const Migrations = initializeMigrations({
Attachments,
userDataPath,
Type: AttachmentType,
getRegionCode,
Attachments,
Type: AttachmentType,
VisualType: VisualAttachment,
});
const Components = {
ContactDetail,
ContactListItem,
ContactName,
ConversationTitle,
ConversationHeader,
EmbeddedContact,
Emojify,
GroupNotification,
Lightbox,
LightboxGallery,
MediaGallery,
Message,
MessageBody,
MessageDetail,
Quote,
ResetSessionNotification,
SafetyNumberNotification,
TimerNotification,
Types: {
Message: MediaGalleryMessage,
},
Quote,
VerificationNotification,
};
const Types = {
@ -126,9 +175,11 @@ exports.setup = (options = {}) => {
Contact,
Conversation,
Errors,
Message,
Message: MessageType,
MIME,
PhoneNumber,
Settings: SettingsType,
VisualAttachment,
};
const Views = {

View file

@ -1,7 +1,9 @@
const is = require('@sindresorhus/is');
const AttachmentTS = require('../../../ts/types/Attachment');
const GoogleChrome = require('../../../ts/util/GoogleChrome');
const MIME = require('../../../ts/types/MIME');
const { toLogFormat } = require('./errors');
const {
arrayBufferToBlob,
blobToArrayBuffer,
@ -181,3 +183,112 @@ exports.deleteData = deleteAttachmentData => {
exports.isVoiceMessage = AttachmentTS.isVoiceMessage;
exports.save = AttachmentTS.save;
const THUMBNAIL_SIZE = 150;
const THUMBNAIL_CONTENT_TYPE = 'image/png';
exports.captureDimensionsAndScreenshot = async (
attachment,
{
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
}
) => {
const { contentType } = attachment;
if (
!GoogleChrome.isImageTypeSupported(contentType) &&
!GoogleChrome.isVideoTypeSupported(contentType)
) {
return attachment;
}
const absolutePath = await getAbsoluteAttachmentPath(attachment.path);
if (GoogleChrome.isImageTypeSupported(contentType)) {
try {
const { width, height } = await getImageDimensions(absolutePath);
const thumbnailBuffer = await blobToArrayBuffer(
await makeImageThumbnail(
THUMBNAIL_SIZE,
absolutePath,
THUMBNAIL_CONTENT_TYPE
)
);
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
return {
...attachment,
width,
height,
thumbnail: {
path: thumbnailPath,
contentType: THUMBNAIL_CONTENT_TYPE,
width: THUMBNAIL_SIZE,
height: THUMBNAIL_SIZE,
},
};
} catch (error) {
console.log(
'captureDimensionsAndScreenshot:',
'error processing image; skipping screenshot generation',
toLogFormat(error)
);
return attachment;
}
}
let screenshotObjectUrl;
try {
const screenshotBuffer = await blobToArrayBuffer(
await makeVideoScreenshot(absolutePath, THUMBNAIL_CONTENT_TYPE)
);
screenshotObjectUrl = makeObjectUrl(
screenshotBuffer,
THUMBNAIL_CONTENT_TYPE
);
const { width, height } = await getImageDimensions(screenshotObjectUrl);
const screenshotPath = await writeNewAttachmentData(screenshotBuffer);
const thumbnailBuffer = await blobToArrayBuffer(
await makeImageThumbnail(
THUMBNAIL_SIZE,
screenshotObjectUrl,
THUMBNAIL_CONTENT_TYPE
)
);
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
return {
...attachment,
screenshot: {
contentType: THUMBNAIL_CONTENT_TYPE,
path: screenshotPath,
width,
height,
},
thumbnail: {
path: thumbnailPath,
contentType: THUMBNAIL_CONTENT_TYPE,
width: THUMBNAIL_SIZE,
height: THUMBNAIL_SIZE,
},
width,
height,
};
} catch (error) {
console.log(
'captureDimensionsAndScreenshot: error processing video; skipping screenshot generation',
toLogFormat(error)
);
return attachment;
} finally {
revokeObjectUrl(screenshotObjectUrl);
}
};

View file

@ -41,6 +41,9 @@ const PRIVATE = 'private';
// - `hasVisualMediaAttachments`: Include all images and video regardless of
// whether Chromium can render it or not.
// - `hasFileAttachments`: Exclude voice messages.
// Version 8
// - Attachments: Capture video/image dimensions and thumbnails, as well as a
// full-size screenshot for video.
const INITIAL_SCHEMA_VERSION = 0;
@ -128,7 +131,7 @@ exports._withSchemaVersion = (schemaVersion, upgrade) => {
upgradedMessage = await upgrade(message, context);
} catch (error) {
console.log(
'Message._withSchemaVersion: error:',
`Message._withSchemaVersion: error updating message ${message.id}:`,
Errors.toLogFormat(error)
);
return message;
@ -242,6 +245,11 @@ const toVersion6 = exports._withSchemaVersion(
// classified:
const toVersion7 = exports._withSchemaVersion(7, initializeAttachmentMetadata);
const toVersion8 = exports._withSchemaVersion(
8,
exports._mapAttachments(Attachment.captureDimensionsAndScreenshot)
);
const VERSIONS = [
toVersion0,
toVersion1,
@ -251,19 +259,47 @@ const VERSIONS = [
toVersion5,
toVersion6,
toVersion7,
toVersion8,
];
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
// UpgradeStep
exports.upgradeSchema = async (
rawMessage,
{ writeNewAttachmentData, getRegionCode } = {}
{
writeNewAttachmentData,
getRegionCode,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
} = {}
) => {
if (!isFunction(writeNewAttachmentData)) {
throw new TypeError('`context.writeNewAttachmentData` is required');
throw new TypeError('context.writeNewAttachmentData is required');
}
if (!isFunction(getRegionCode)) {
throw new TypeError('`context.getRegionCode` is required');
throw new TypeError('context.getRegionCode is required');
}
if (!isFunction(getAbsoluteAttachmentPath)) {
throw new TypeError('context.getAbsoluteAttachmentPath is required');
}
if (!isFunction(makeObjectUrl)) {
throw new TypeError('context.makeObjectUrl is required');
}
if (!isFunction(revokeObjectUrl)) {
throw new TypeError('context.revokeObjectUrl is required');
}
if (!isFunction(getImageDimensions)) {
throw new TypeError('context.getImageDimensions is required');
}
if (!isFunction(makeImageThumbnail)) {
throw new TypeError('context.makeImageThumbnail is required');
}
if (!isFunction(makeVideoScreenshot)) {
throw new TypeError('context.makeVideoScreenshot is required');
}
let message = rawMessage;
@ -275,6 +311,12 @@ exports.upgradeSchema = async (
message = await currentVersion(message, {
writeNewAttachmentData,
regionCode: getRegionCode(),
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
});
}

View file

@ -0,0 +1,126 @@
/* global document, URL, Blob */
const loadImage = require('blueimp-load-image');
const { toLogFormat } = require('./errors');
const dataURLToBlobSync = require('blueimp-canvas-to-blob');
const { blobToArrayBuffer } = require('blob-util');
const {
arrayBufferToObjectURL,
} = require('../../../ts/util/arrayBufferToObjectURL');
exports.blobToArrayBuffer = blobToArrayBuffer;
exports.getImageDimensions = objectUrl =>
new Promise((resolve, reject) => {
const image = document.createElement('img');
image.addEventListener('load', () => {
resolve({
height: image.naturalHeight,
width: image.naturalWidth,
});
});
image.addEventListener('error', error => {
console.log('getImageDimensions error', toLogFormat(error));
reject(error);
});
image.src = objectUrl;
});
exports.makeImageThumbnail = (size, objectUrl, contentType = 'image/png') =>
new Promise((resolve, reject) => {
const image = document.createElement('img');
image.addEventListener('load', () => {
// using components/blueimp-load-image
// first, make the correct size
let canvas = loadImage.scale(image, {
canvas: true,
cover: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
// then crop
canvas = loadImage.scale(canvas, {
canvas: true,
crop: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
const blob = dataURLToBlobSync(canvas.toDataURL(contentType));
resolve(blob);
});
image.addEventListener('error', error => {
console.log('makeImageThumbnail error', toLogFormat(error));
reject(error);
});
image.src = objectUrl;
});
exports.makeVideoScreenshot = (objectUrl, contentType = 'image/png') =>
new Promise((resolve, reject) => {
const video = document.createElement('video');
function capture() {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas
.getContext('2d')
.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = dataURLToBlobSync(canvas.toDataURL(contentType));
video.removeEventListener('canplay', capture);
resolve(image);
}
video.addEventListener('canplay', capture);
video.addEventListener('error', error => {
console.log('makeVideoThumbnail error', toLogFormat(error));
reject(error);
});
video.src = objectUrl;
});
exports.makeVideoThumbnail = async (size, videoObjectUrl) => {
let screenshotObjectUrl;
try {
const type = 'image/png';
const blob = await exports.makeVideoScreenshot(videoObjectUrl, type);
const data = await blobToArrayBuffer(blob);
screenshotObjectUrl = arrayBufferToObjectURL({
data,
type,
});
return exports.makeImageThumbnail(size, screenshotObjectUrl);
} finally {
exports.revokeObjectUrl(screenshotObjectUrl);
}
};
exports.makeObjectUrl = (data, contentType) => {
const blob = new Blob([data], {
type: contentType,
});
return URL.createObjectURL(blob);
};
exports.revokeObjectUrl = objectUrl => {
URL.revokeObjectURL(objectUrl);
};

View file

@ -13,9 +13,6 @@
tagName: 'div',
className: 'contact',
templateName: 'contact',
events: {
click: 'showIdentity',
},
initialize(options) {
this.ourNumber = textsecure.storage.user.getNumber();
this.listenBack = options.listenBack;

View file

@ -1,4 +1,4 @@
/* global Whisper, getInboxCollection */
/* global Whisper, getInboxCollection, $ */
// eslint-disable-next-line func-names
(function() {

View file

@ -2,7 +2,6 @@
/* global _: false */
/* global emojiData: false */
/* global EmojiPanel: false */
/* global moment: false */
/* global extension: false */
/* global i18n: false */
/* global Signal: false */
@ -14,6 +13,7 @@
'use strict';
window.Whisper = window.Whisper || {};
const { Migrations } = Signal;
Whisper.ExpiredToast = Whisper.ToastView.extend({
render_attributes() {
@ -31,42 +31,6 @@
},
});
const MenuView = Whisper.View.extend({
toggleMenu() {
this.$('.menu-list').toggle();
},
});
const TimerMenuView = MenuView.extend({
initialize() {
this.render();
this.listenTo(this.model, 'change:expireTimer', this.render);
},
events: {
'click button': 'toggleMenu',
'click li': 'setTimer',
},
setTimer(e) {
const { seconds } = this.$(e.target).data();
if (seconds > 0) {
this.model.updateExpirationTimer(seconds);
} else {
this.model.updateExpirationTimer(null);
}
},
render() {
const seconds = this.model.get('expireTimer');
if (seconds) {
const s = Whisper.ExpirationTimerOptions.getAbbreviated(seconds);
this.$el.attr('data-time', s);
this.$el.show();
} else {
this.$el.attr('data-time', null);
this.$el.hide();
}
},
});
Whisper.ConversationLoadingScreen = Whisper.View.extend({
templateName: 'conversation-loading-screen',
className: 'conversation-loading-screen',
@ -82,35 +46,23 @@
template: $('#conversation').html(),
render_attributes() {
return {
group: this.model.get('type') === 'group',
isMe: this.model.isMe(),
avatar: this.model.getAvatar(),
expireTimer: this.model.get('expireTimer'),
'show-members': i18n('showMembers'),
'end-session': i18n('resetSession'),
'show-identity': i18n('showSafetyNumber'),
destroy: i18n('deleteMessages'),
'send-message': i18n('sendMessage'),
'disappearing-messages': i18n('disappearingMessages'),
'android-length-warning': i18n('androidMessageLengthWarning'),
timer_options: Whisper.ExpirationTimerOptions.models,
'view-all-media': i18n('viewAllMedia'),
};
},
initialize(options) {
this.listenTo(this.model, 'destroy', this.stopListening);
this.listenTo(this.model, 'change:verified', this.onVerifiedChange);
this.listenTo(this.model, 'change:color', this.updateColor);
this.listenTo(
this.model,
'change:avatar change:profileAvatar',
this.updateAvatar
);
this.listenTo(this.model, 'newmessage', this.addMessage);
this.listenTo(this.model, 'delivered', this.updateMessage);
this.listenTo(this.model, 'read', this.updateMessage);
this.listenTo(this.model, 'opened', this.onOpened);
this.listenTo(this.model, 'prune', this.onPrune);
this.listenTo(
this.model.messageCollection,
'show-identity',
this.showSafetyNumber
);
this.listenTo(this.model.messageCollection, 'force-send', this.forceSend);
this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage);
this.listenTo(
this.model.messageCollection,
'scroll-to-message',
@ -126,11 +78,26 @@
'show-contact-detail',
this.showContactDetail
);
this.listenTo(
this.model.messageCollection,
'show-lightbox',
this.showLightbox
);
this.listenTo(
this.model.messageCollection,
'download',
this.downloadAttachment
);
this.listenTo(
this.model.messageCollection,
'open-conversation',
this.openConversation
);
this.listenTo(
this.model.messageCollection,
'show-message-detail',
this.showMessageDetail
);
this.lazyUpdateVerified = _.debounce(
this.model.updateVerified.bind(this.model),
@ -145,12 +112,7 @@
this.loadingScreen = new Whisper.ConversationLoadingScreen();
this.loadingScreen.render();
this.loadingScreen.$el.prependTo(this.el);
this.timerMenu = new TimerMenuView({
el: this.$('.timer-menu'),
model: this.model,
});
this.loadingScreen.$el.prependTo(this.$('.discussion-container'));
this.window = options.window;
this.fileInput = new Whisper.FileInputView({
@ -158,21 +120,64 @@
window: this.window,
});
const getTitleProps = model => ({
isVerified: model.isVerified(),
name: model.getName(),
phoneNumber: model.getNumber(),
profileName: model.getProfileName(),
});
const getHeaderProps = () => {
const avatar = this.model.getAvatar();
const avatarPath = avatar ? avatar.url : null;
const expireTimer = this.model.get('expireTimer');
const expirationSettingName = expireTimer
? Whisper.ExpirationTimerOptions.getName(expireTimer || 0)
: null;
return {
id: this.model.id,
name: this.model.getName(),
phoneNumber: this.model.getNumber(),
profileName: this.model.getProfileName(),
color: this.model.getColor(),
avatarPath,
isVerified: this.model.isVerified(),
isMe: this.model.isMe(),
isGroup: !this.model.isPrivate(),
expirationSettingName,
showBackButton: Boolean(this.panels && this.panels.length),
timerOptions: Whisper.ExpirationTimerOptions.map(item => ({
name: item.getName(),
value: item.get('seconds'),
})),
onSetDisappearingMessages: seconds =>
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(),
// These are view only and done update the Conversation model, so they
// need a manual update call.
onShowSafetyNumber: () => {
this.showSafetyNumber();
this.updateHeader();
},
onShowAllMedia: async () => {
await this.showAllMedia();
this.updateHeader();
},
onShowGroupMembers: () => {
this.showMembers();
this.updateHeader();
},
onGoBack: () => {
this.resetPanel();
this.updateHeader();
},
};
};
this.titleView = new Whisper.ReactWrapperView({
className: 'title-wrapper',
Component: window.Signal.Components.ConversationTitle,
props: getTitleProps(this.model),
Component: window.Signal.Components.ConversationHeader,
props: getHeaderProps(this.model),
});
this.listenTo(this.model, 'change', () =>
this.titleView.update(getTitleProps(this.model))
);
this.$('.conversation-title').prepend(this.titleView.el);
this.updateHeader = () => this.titleView.update(getHeaderProps());
this.listenTo(this.model, 'change', this.updateHeader);
this.$('.conversation-header').append(this.titleView.el);
this.view = new Whisper.MessageListView({
collection: this.model.messageCollection,
@ -210,20 +215,10 @@
'submit .send': 'checkUnverifiedSendMessage',
'input .send-message': 'updateMessageFieldSize',
'keydown .send-message': 'updateMessageFieldSize',
'click .destroy': 'destroyMessages',
'click .end-session': 'endSession',
'click .leave-group': 'leaveGroup',
'click .update-group': 'newGroupUpdate',
'click .show-identity': 'showSafetyNumber',
'click .show-members': 'showMembers',
'click .view-all-media': 'viewAllMedia',
'click .conversation-menu .hamburger': 'toggleMenu',
click: 'onClick',
'click .bottom-bar': 'focusMessageField',
'click .back': 'resetPanel',
'click .capture-audio .microphone': 'captureAudio',
'click .disappearing-messages': 'enableDisappearingMessages',
'click .scroll-down-button-view': 'scrollToBottom',
'click .module-scroll-down': 'scrollToBottom',
'click button.emoji': 'toggleEmojiPanel',
'focus .send-message': 'focusBottomBar',
'change .file-input': 'toggleMicrophone',
@ -233,10 +228,7 @@
'atBottom .message-list': 'removeScrollDownButton',
'farFromBottom .message-list': 'addScrollDownButton',
'lazyScroll .message-list': 'onLazyScroll',
'close .menu': 'closeMenu',
'select .message-list .entry': 'messageDetail',
'force-resize': 'forceUpdateMessageFieldSize',
'show-identity': 'showSafetyNumber',
dragover: 'sendToFileInput',
drop: 'sendToFileInput',
dragleave: 'sendToFileInput',
@ -269,7 +261,6 @@
reason
);
this.timerMenu.remove();
this.fileInput.remove();
this.titleView.remove();
@ -288,6 +279,9 @@
if (this.quoteView) {
this.quoteView.remove();
}
if (this.lightBoxView) {
this.lightBoxView.remove();
}
if (this.lightboxGalleryView) {
this.lightboxGalleryView.remove();
}
@ -362,7 +356,7 @@
openSafetyNumberScreens(unverified) {
if (unverified.length === 1) {
this.showSafetyNumber(null, unverified.at(0));
this.showSafetyNumber(unverified.at(0));
return;
}
@ -406,11 +400,6 @@
}
},
enableDisappearingMessages() {
if (!this.model.get('expireTimer')) {
this.model.updateExpirationTimer(moment.duration(1, 'day').asSeconds());
}
},
toggleMicrophone() {
if (
this.$('.send-message').val().length > 0 ||
@ -591,11 +580,7 @@
el[0].scrollIntoView();
},
async viewAllMedia() {
// We have to do this manually, since our React component will not propagate click
// events up to its parent elements in the DOM.
this.closeMenu();
async showAllMedia() {
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
const DEFAULT_MEDIA_FETCH_COUNT = 50;
@ -620,7 +605,7 @@
// NOTE: Could we show grid previews from disk as well?
const loadMessages = Signal.Components.Types.Message.loadWithObjectURL(
Signal.Migrations.loadMessage
Migrations.loadMessage
);
const media = await loadMessages(rawMedia);
@ -655,6 +640,7 @@
mediaMessage => mediaMessage.id === message.id
);
this.lightboxGalleryView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: Signal.Components.LightboxGallery,
props: {
messages: mediaWithObjectURL,
@ -673,6 +659,7 @@
};
const view = new Whisper.ReactWrapperView({
className: 'panel-wrapper',
Component: Signal.Components.MediaGallery,
props: {
documents,
@ -840,14 +827,10 @@
}
}
},
updateMessage(message) {
this.model.messageCollection.add(message, { merge: true });
},
onClick(e) {
onClick() {
// If there are sub-panels open, we don't want to respond to clicks
if (!this.panels || !this.panels.length) {
this.closeMenu(e);
this.markRead();
}
},
@ -930,7 +913,31 @@
this.listenBack(view);
},
showSafetyNumber(e, providedModel) {
forceSend({ contact, message }) {
const dialog = new Whisper.ConfirmationDialogView({
message: i18n('identityKeyErrorOnSend'),
okText: i18n('sendAnyway'),
resolve: async () => {
await contact.updateVerified();
if (contact.isUnverified()) {
await contact.setVerifiedDefault();
}
const untrusted = await contact.isUntrusted();
if (untrusted) {
await contact.setApproved();
}
message.resend(contact.id);
},
});
this.$el.prepend(dialog.el);
dialog.focusCancel();
},
showSafetyNumber(providedModel) {
let model = providedModel;
if (!model && this.model.isPrivate()) {
@ -945,26 +952,78 @@
}
},
messageDetail(e, data) {
const view = new Whisper.MessageDetailView({
model: data.message,
conversation: this.model,
// we pass these in to allow nested panels
listenBack: this.listenBack.bind(this),
resetPanel: this.resetPanel.bind(this),
downloadAttachment({ attachment, message }) {
const { getAbsoluteAttachmentPath } = Migrations;
Signal.Types.Attachment.save({
attachment,
document,
getAbsolutePath: getAbsoluteAttachmentPath,
timestamp: message.get('sent_at'),
});
this.listenBack(view);
view.render();
},
// not currently in use
newGroupUpdate() {
const view = new Whisper.NewGroupUpdateView({
model: this.model,
window: this.window,
deleteMessage(message) {
const dialog = new Whisper.ConfirmationDialogView({
message: i18n('deleteWarning'),
okText: i18n('delete'),
resolve: () => {
message.destroy();
this.resetPanel();
this.updateHeader();
},
});
view.render();
this.$el.prepend(dialog.el);
dialog.focusCancel();
},
showLightbox({ attachment, message }) {
const { getAbsoluteAttachmentPath } = Migrations;
const { contentType, path } = attachment;
if (
!Signal.Util.GoogleChrome.isImageTypeSupported(contentType) &&
!Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)
) {
this.downloadAttachment({ attachment, message });
return;
}
const props = {
objectURL: getAbsoluteAttachmentPath(path),
contentType,
onSave: () => this.downloadAttachment({ attachment, message }),
};
this.lightboxView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: Signal.Components.Lightbox,
props,
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
});
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
},
showMessageDetail(message) {
const props = message.getPropsForMessageDetail();
const view = new Whisper.ReactWrapperView({
className: 'message-detail-wrapper',
Component: Signal.Components.MessageDetail,
props,
onClose: () => {
this.stopListening(message, 'change', update);
this.resetPanel();
this.updateHeader();
},
});
const update = () => view.update(message.getPropsForMessageDetail());
this.listenTo(message, 'change', update);
// We could listen to all involved contacts, but we'll call that overkill
this.listenBack(view);
this.updateHeader();
view.render();
},
showContactDetail({ contact, hasSignalAccount }) {
@ -989,7 +1048,10 @@
}
},
},
onClose: () => this.resetPanel(),
onClose: () => {
this.resetPanel();
this.updateHeader();
},
});
this.listenBack(view);
@ -1009,57 +1071,45 @@
this.panels[0].$el.hide();
}
this.panels.unshift(view);
if (this.panels.length === 1) {
this.$('.main.panel, .header-buttons.right').hide();
this.$('.back').show();
}
view.$el.insertBefore(this.$('.panel').first());
},
resetPanel() {
if (!this.panels || !this.panels.length) {
return;
}
const view = this.panels.shift();
if (this.panels.length > 0) {
this.panels[0].$el.show();
}
view.remove();
if (this.panels.length === 0) {
this.$('.main.panel, .header-buttons.right').show();
this.$('.back').hide();
this.$el.trigger('force-resize');
}
},
closeMenu(e) {
if (e && !$(e.target).hasClass('hamburger')) {
this.$('.conversation-menu .menu-list').hide();
}
if (e && !$(e.target).hasClass('clock')) {
this.$('.timer-menu .menu-list').hide();
}
},
endSession() {
this.model.endSession();
this.$('.menu-list').hide();
},
leaveGroup() {
this.model.leaveGroup();
this.$('.menu-list').hide();
},
toggleMenu() {
this.$('.conversation-menu .menu-list').toggle();
setDisappearingMessages(seconds) {
if (seconds > 0) {
this.model.updateExpirationTimer(seconds);
} else {
this.model.updateExpirationTimer(null);
}
},
async destroyMessages() {
this.$('.menu-list').hide();
await this.confirm(i18n('deleteConversationConfirmation'));
this.model.destroyMessages();
this.remove();
try {
await this.confirm(i18n('deleteConversationConfirmation'));
await this.model.destroyMessages();
this.remove();
} catch (error) {
// nothing to see here
}
},
showSendConfirmationDialog(e, contacts) {
@ -1247,24 +1297,21 @@
const contact = this.quotedMessage.getContact();
if (contact) {
this.listenTo(contact, 'change:color', this.renderQuotedMesage);
this.listenTo(contact, 'change', this.renderQuotedMesage);
}
this.quoteView = new Whisper.ReactWrapperView({
className: 'quote-wrapper',
Component: window.Signal.Components.Quote,
props: Object.assign({}, props, {
text: props.text,
withContentAbove: true,
onClose: () => {
this.setQuoteMessage(null);
},
}),
});
const selector =
storage.get('theme-setting') === 'ios' ? '.bottom-bar' : '.send';
this.$(selector).prepend(this.quoteView.el);
this.$('.send').prepend(this.quoteView.el);
this.updateMessageFieldSize({});
},
@ -1319,28 +1366,6 @@
}
},
updateColor(model, color) {
const header = this.$('.conversation-header');
header.removeClass(Whisper.Conversation.COLORS);
if (color) {
header.addClass(color);
}
const avatarView = new (Whisper.View.extend({
templateName: 'avatar',
render_attributes: { avatar: this.model.getAvatar() },
}))();
header.find('.avatar').replaceWith(avatarView.render().$('.avatar'));
},
updateAvatar() {
const header = this.$('.conversation-header');
const avatarView = new (Whisper.View.extend({
templateName: 'avatar',
render_attributes: { avatar: this.model.getAvatar() },
}))();
header.find('.avatar').replaceWith(avatarView.render().$('.avatar'));
},
updateMessageFieldSize(event) {
const keyCode = event.which || event.keyCode;

View file

@ -12,7 +12,7 @@
window.Whisper = window.Whisper || {};
const { MIME } = window.Signal.Types;
const { MIME, VisualAttachment } = window.Signal.Types;
Whisper.FileSizeToast = Whisper.ToastView.extend({
templateName: 'file-size-modal',
@ -28,98 +28,6 @@
template: i18n('unsupportedFileType'),
});
function makeImageThumbnail(size, objectUrl) {
return new Promise((resolve, reject) => {
const img = document.createElement('img');
img.onerror = reject;
img.onload = () => {
// using components/blueimp-load-image
// first, make the correct size
let canvas = loadImage.scale(img, {
canvas: true,
cover: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
// then crop
canvas = loadImage.scale(canvas, {
canvas: true,
crop: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
const blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
resolve(blob);
};
img.src = objectUrl;
});
}
function makeVideoScreenshot(objectUrl) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
function capture() {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas
.getContext('2d')
.drawImage(video, 0, 0, canvas.width, canvas.height);
const image = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
video.removeEventListener('canplay', capture);
resolve(image);
}
video.addEventListener('canplay', capture);
video.addEventListener('error', error => {
console.log(
'makeVideoThumbnail error',
Signal.Types.Errors.toLogFormat(error)
);
reject(error);
});
video.src = objectUrl;
});
}
function blobToArrayBuffer(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = e => resolve(e.target.result);
fileReader.onerror = reject;
fileReader.onabort = reject;
fileReader.readAsArrayBuffer(blob);
});
}
async function makeVideoThumbnail(size, videoObjectUrl) {
const blob = await makeVideoScreenshot(videoObjectUrl);
const data = await blobToArrayBuffer(blob);
const screenshotObjectUrl = Signal.Util.arrayBufferToObjectURL({
data,
type: 'image/png',
});
const thumbnail = await makeImageThumbnail(size, screenshotObjectUrl);
URL.revokeObjectURL(screenshotObjectUrl);
return thumbnail;
}
Whisper.FileInputView = Backbone.View.extend({
tagName: 'span',
className: 'file-input',
@ -252,10 +160,14 @@
const renderVideoPreview = async () => {
// we use the variable on this here to ensure cleanup if we're interrupted
this.previewObjectUrl = URL.createObjectURL(file);
const thumbnail = await makeVideoScreenshot(this.previewObjectUrl);
const type = 'image/png';
const thumbnail = await VisualAttachment.makeVideoScreenshot(
this.previewObjectUrl,
type
);
URL.revokeObjectURL(this.previewObjectUrl);
const data = await blobToArrayBuffer(thumbnail);
const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
this.previewObjectUrl = Signal.Util.arrayBufferToObjectURL({
data,
type: 'image/png',
@ -385,7 +297,10 @@
const objectUrl = URL.createObjectURL(file);
const arrayBuffer = await makeImageThumbnail(size, objectUrl);
const arrayBuffer = await VisualAttachment.makeImageThumbnail(
size,
objectUrl
);
URL.revokeObjectURL(objectUrl);
return this.readFile(arrayBuffer);
@ -482,8 +397,4 @@
}
},
});
Whisper.FileInputView.makeImageThumbnail = makeImageThumbnail;
Whisper.FileInputView.makeVideoThumbnail = makeVideoThumbnail;
Whisper.FileInputView.makeVideoScreenshot = makeVideoScreenshot;
})();

View file

@ -1,55 +0,0 @@
/* global Whisper, i18n */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.IdentityKeySendErrorPanelView = Whisper.View.extend({
className: 'identity-key-send-error panel',
templateName: 'identity-key-send-error',
initialize(options) {
this.listenBack = options.listenBack;
this.resetPanel = options.resetPanel;
this.wasUnverified = this.model.isUnverified();
this.listenTo(this.model, 'change', this.render);
},
events: {
'click .show-safety-number': 'showSafetyNumber',
'click .send-anyway': 'sendAnyway',
'click .cancel': 'cancel',
},
showSafetyNumber() {
const view = new Whisper.KeyVerificationPanelView({
model: this.model,
});
this.listenBack(view);
},
sendAnyway() {
this.resetPanel();
this.trigger('send-anyway');
},
cancel() {
this.resetPanel();
},
render_attributes() {
let send = i18n('sendAnyway');
if (this.wasUnverified && !this.model.isUnverified()) {
send = i18n('resend');
}
const errorExplanation = i18n('identityKeyErrorOnSend', [
this.model.getTitle(),
this.model.getTitle(),
]);
return {
errorExplanation,
showSafetyNumber: i18n('showSafetyNumber'),
sendAnyway: send,
cancel: i18n('cancel'),
};
},
});
})();

View file

@ -108,7 +108,9 @@
const inboxCollection = getInboxCollection();
inboxCollection.on('messageError', () => {
this.networkStatusView.render();
if (this.networkStatusView) {
this.networkStatusView.render();
}
});
this.inboxListView = new Whisper.ConversationListView({

View file

@ -7,7 +7,7 @@
window.Whisper = window.Whisper || {};
Whisper.LastSeenIndicatorView = Whisper.View.extend({
className: 'last-seen-indicator-view',
className: 'module-last-seen-indicator',
templateName: 'last-seen-indicator-view',
initialize(options = {}) {
this.count = options.count || 0;

View file

@ -1,182 +0,0 @@
/* global Whisper, i18n, _, ConversationController, Mustache, moment */
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
const ContactView = Whisper.View.extend({
className: 'contact-detail',
templateName: 'contact-detail',
initialize(options) {
this.listenBack = options.listenBack;
this.resetPanel = options.resetPanel;
this.message = options.message;
const newIdentity = i18n('newIdentity');
this.errors = _.map(options.errors, error => {
if (error.name === 'OutgoingIdentityKeyError') {
// eslint-disable-next-line no-param-reassign
error.message = newIdentity;
}
return error;
});
this.outgoingKeyError = _.find(
this.errors,
error => error.name === 'OutgoingIdentityKeyError'
);
},
events: {
click: 'onClick',
},
onClick() {
if (this.outgoingKeyError) {
const view = new Whisper.IdentityKeySendErrorPanelView({
model: this.model,
listenBack: this.listenBack,
resetPanel: this.resetPanel,
});
this.listenTo(view, 'send-anyway', this.onSendAnyway);
view.render();
this.listenBack(view);
view.$('.cancel').focus();
}
},
forceSend() {
this.model
.updateVerified()
.then(() => {
if (this.model.isUnverified()) {
return this.model.setVerifiedDefault();
}
return null;
})
.then(() => this.model.isUntrusted())
.then(untrusted => {
if (untrusted) {
return this.model.setApproved();
}
return null;
})
.then(() => {
this.message.resend(this.outgoingKeyError.number);
});
},
onSendAnyway() {
if (this.outgoingKeyError) {
this.forceSend();
}
},
render_attributes() {
const showButton = Boolean(this.outgoingKeyError);
return {
status: this.message.getStatus(this.model.id),
name: this.model.getTitle(),
avatar: this.model.getAvatar(),
errors: this.errors,
showErrorButton: showButton,
errorButtonLabel: i18n('view'),
};
},
});
Whisper.MessageDetailView = Whisper.View.extend({
className: 'message-detail panel',
templateName: 'message-detail',
initialize(options) {
this.listenBack = options.listenBack;
this.resetPanel = options.resetPanel;
this.view = new Whisper.MessageView({ model: this.model });
this.view.render();
this.conversation = options.conversation;
this.listenTo(this.model, 'change', this.render);
},
events: {
'click button.delete': 'onDelete',
},
onDelete() {
const dialog = new Whisper.ConfirmationDialogView({
message: i18n('deleteWarning'),
okText: i18n('delete'),
resolve: () => {
this.model.destroy();
this.resetPanel();
},
});
this.$el.prepend(dialog.el);
dialog.focusCancel();
},
getContacts() {
// Return the set of models to be rendered in this view
let ids;
if (this.model.isIncoming()) {
ids = [this.model.get('source')];
} else if (this.model.isOutgoing()) {
ids = this.model.get('recipients');
if (!ids) {
// older messages have no recipients field
// use the current set of recipients
ids = this.conversation.getRecipients();
}
}
return Promise.all(
ids.map(number =>
ConversationController.getOrCreateAndWait(number, 'private')
)
);
},
renderContact(contact) {
const view = new ContactView({
model: contact,
errors: this.grouped[contact.id],
listenBack: this.listenBack,
resetPanel: this.resetPanel,
message: this.model,
}).render();
this.$('.contacts').append(view.el);
},
render() {
const errorsWithoutNumber = _.reject(this.model.get('errors'), error =>
Boolean(error.number)
);
this.$el.html(
Mustache.render(_.result(this, 'template', ''), {
sent_at: moment(this.model.get('sent_at')).format('LLLL'),
received_at: this.model.isIncoming()
? moment(this.model.get('received_at')).format('LLLL')
: null,
tofrom: this.model.isIncoming() ? i18n('from') : i18n('to'),
errors: errorsWithoutNumber,
title: i18n('messageDetail'),
sent: i18n('sent'),
received: i18n('received'),
errorLabel: i18n('error'),
deleteLabel: i18n('deleteMessage'),
})
);
this.view.$el.prependTo(this.$('.message-container'));
this.grouped = _.groupBy(this.model.get('errors'), 'number');
this.getContacts().then(contacts => {
_.sortBy(contacts, c => {
const prefix = this.grouped[c.id] ? '0' : '1';
// this prefix ensures that contacts with errors are listed first;
// otherwise it's alphabetical
return prefix + c.getTitle();
}).forEach(this.renderContact.bind(this));
});
},
});
})();

View file

@ -64,19 +64,10 @@
this.measureScrollPosition();
},
addOne(model) {
let view;
if (model.isExpirationTimerUpdate()) {
view = new Whisper.ExpirationTimerUpdateView({ model }).render();
} else if (model.get('type') === 'keychange') {
view = new Whisper.KeyChangeView({ model }).render();
} else if (model.get('type') === 'verified-change') {
view = new Whisper.VerifiedChangeView({ model }).render();
} else {
// eslint-disable-next-line new-cap
view = new this.itemView({ model }).render();
this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition);
this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded);
}
// eslint-disable-next-line new-cap
const view = new this.itemView({ model }).render();
this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition);
this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded);
const index = this.collection.indexOf(model);
this.measureScrollPosition();

View file

@ -1,743 +1,115 @@
/* global Whisper: false */
/* global i18n: false */
/* global textsecure: false */
/* global _: false */
/* global Mustache: false */
/* global $: false */
/* global storage: false */
/* global Signal: false */
// eslint-disable-next-line func-names
(function() {
'use strict';
const {
loadAttachmentData,
getAbsoluteAttachmentPath,
} = window.Signal.Migrations;
window.Whisper = window.Whisper || {};
const ErrorIconView = Whisper.View.extend({
templateName: 'error-icon',
className: 'error-icon-container',
initialize() {
if (this.model.name === 'UnregisteredUserError') {
this.$el.addClass('unregistered-user-error');
}
},
});
const NetworkErrorView = Whisper.View.extend({
tagName: 'span',
className: 'hasRetry',
templateName: 'hasRetry',
render_attributes() {
let messageNotSent;
if (!this.model.someRecipientsFailed()) {
messageNotSent = i18n('messageNotSent');
}
return {
messageNotSent,
resend: i18n('resend'),
};
},
});
const SomeFailedView = Whisper.View.extend({
tagName: 'span',
className: 'some-failed',
templateName: 'some-failed',
render_attributes() {
return {
someFailed: i18n('someRecipientsFailed'),
};
},
});
const TimerView = Whisper.View.extend({
templateName: 'hourglass',
initialize() {
this.listenTo(this.model, 'unload', this.remove);
},
update() {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
if (this.model.isExpired()) {
return this;
}
if (this.model.isExpiring()) {
this.render();
const totalTime = this.model.get('expireTimer') * 1000;
const remainingTime = this.model.msTilExpire();
const elapsed = (totalTime - remainingTime) / totalTime;
this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`);
this.$el.css('display', 'inline-block');
this.timeout = setTimeout(
this.update.bind(this),
Math.max(totalTime / 100, 500)
);
}
return this;
},
});
Whisper.ExpirationTimerUpdateView = Whisper.View.extend({
Whisper.MessageView = Whisper.View.extend({
tagName: 'li',
className: 'expirationTimerUpdate advisory',
templateName: 'expirationTimerUpdate',
id() {
return this.model.id;
},
initialize() {
this.conversation = this.model.getExpirationTimerUpdateSource();
this.listenTo(this.conversation, 'change', this.render);
this.listenTo(this.model, 'unload', this.remove);
this.listenTo(this.model, 'change', this.onChange);
},
render_attributes() {
const seconds = this.model.get('expirationTimerUpdate').expireTimer;
let timerMessage;
const timerUpdate = this.model.get('expirationTimerUpdate');
const prettySeconds = Whisper.ExpirationTimerOptions.getName(
seconds || 0
);
if (
timerUpdate &&
(timerUpdate.fromSync || timerUpdate.fromGroupUpdate)
) {
timerMessage = i18n('timerSetOnSync', prettySeconds);
} else if (this.conversation.id === textsecure.storage.user.getNumber()) {
timerMessage = i18n('youChangedTheTimer', prettySeconds);
} else {
timerMessage = i18n('theyChangedTheTimer', [
this.conversation.getTitle(),
prettySeconds,
]);
}
return { content: timerMessage };
this.listenTo(this.model, 'destroy', this.onDestroy);
this.listenTo(this.model, 'unload', this.onUnload);
},
onChange() {
this.addId();
},
addId() {
// This is important to enable the lastSeenIndicator when it's just been added.
this.$el.attr('id', this.id());
},
});
Whisper.KeyChangeView = Whisper.View.extend({
tagName: 'li',
className: 'keychange advisory',
templateName: 'keychange',
id() {
return this.model.id;
},
initialize() {
this.conversation = this.model.getModelForKeyChange();
this.listenTo(this.conversation, 'change', this.render);
this.listenTo(this.model, 'unload', this.remove);
},
events: {
'click .content': 'showIdentity',
},
render_attributes() {
return {
content: this.model.getNotificationText(),
};
},
showIdentity() {
this.$el.trigger('show-identity', this.conversation);
},
});
Whisper.VerifiedChangeView = Whisper.View.extend({
tagName: 'li',
className: 'verified-change advisory',
templateName: 'verified-change',
id() {
return this.model.id;
},
initialize() {
this.conversation = this.model.getModelForVerifiedChange();
this.listenTo(this.conversation, 'change', this.render);
this.listenTo(this.model, 'unload', this.remove);
},
events: {
'click .content': 'showIdentity',
},
render_attributes() {
let key;
if (this.model.get('verified')) {
if (this.model.get('local')) {
key = 'youMarkedAsVerified';
} else {
key = 'youMarkedAsVerifiedOtherDevice';
}
return {
icon: 'verified',
content: i18n(key, this.conversation.getTitle()),
};
}
if (this.model.get('local')) {
key = 'youMarkedAsNotVerified';
} else {
key = 'youMarkedAsNotVerifiedOtherDevice';
}
return {
icon: 'shield',
content: i18n(key, this.conversation.getTitle()),
};
},
showIdentity() {
this.$el.trigger('show-identity', this.conversation);
},
});
Whisper.MessageView = Whisper.View.extend({
tagName: 'li',
templateName: 'message',
id() {
return this.model.id;
},
initialize() {
// loadedAttachmentViews :: Promise (Array AttachmentView) | null
this.loadedAttachmentViews = null;
this.listenTo(this.model, 'change:errors', this.onErrorsChanged);
this.listenTo(this.model, 'change:body', this.render);
this.listenTo(this.model, 'change:delivered', this.renderDelivered);
this.listenTo(this.model, 'change:read_by', this.renderRead);
this.listenTo(
this.model,
'change:expirationStartTimestamp',
this.renderExpiring
);
this.listenTo(this.model, 'change', this.onChange);
this.listenTo(
this.model,
'change:flags change:group_update',
this.renderControl
);
this.listenTo(this.model, 'destroy', this.onDestroy);
this.listenTo(this.model, 'unload', this.onUnload);
this.listenTo(this.model, 'expired', this.onExpired);
this.listenTo(this.model, 'pending', this.renderPending);
this.listenTo(this.model, 'done', this.renderDone);
this.timeStampView = new Whisper.ExtendedTimestampView();
this.contact = this.model.isIncoming() ? this.model.getContact() : null;
if (this.contact) {
this.listenTo(this.contact, 'change:color', this.updateColor);
}
},
events: {
'click .retry': 'retryMessage',
'click .error-icon': 'select',
'click .timestamp': 'select',
'click .status': 'select',
'click .some-failed': 'select',
'click .error-message': 'select',
'click .menu-container': 'showMenu',
'click .menu-list .reply': 'onReply',
},
retryMessage() {
const retrys = _.filter(
this.model.get('errors'),
this.model.isReplayableError.bind(this.model)
);
_.map(retrys, 'number').forEach(number => {
this.model.resend(number);
});
},
showMenu(e) {
if (this.menuVisible) {
return;
}
this.menuVisible = true;
e.stopPropagation();
this.$('.menu-list').show();
$(document).one('click', () => {
this.hideMenu();
});
},
hideMenu() {
this.menuVisible = false;
this.$('.menu-list').hide();
},
onReply() {
this.model.trigger('reply', this.model);
},
onExpired() {
this.$el.addClass('expired');
this.$el.find('.bubble').one('webkitAnimationEnd animationend', e => {
if (e.target === this.$('.bubble')[0]) {
this.remove();
}
});
// Failsafe: if in the background, animation events don't fire
setTimeout(this.remove.bind(this), 1000);
// The ID is important for other items inserting themselves into the DOM. Because
// of ReactWrapperView and this view, there are two layers of DOM elements
// between the parent and the elements returned by the React component, so this is
// necessary.
const { id } = this.model;
this.$el.attr('id', id);
},
onUnload() {
if (this.avatarView) {
this.avatarView.remove();
if (this.childView) {
this.childView.remove();
}
if (this.bodyView) {
this.bodyView.remove();
}
if (this.contactView) {
this.contactView.remove();
}
if (this.controlView) {
this.controlView.remove();
}
if (this.errorIconView) {
this.errorIconView.remove();
}
if (this.networkErrorView) {
this.networkErrorView.remove();
}
if (this.quoteView) {
this.quoteView.remove();
}
if (this.someFailedView) {
this.someFailedView.remove();
}
if (this.timeStampView) {
this.timeStampView.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
// the DOM.
// eslint-disable-next-line more/no-then
this.loadAttachmentViews().then(views =>
views.forEach(view => view.unload())
);
// No need to handle this one, since it listens to 'unload' itself:
// this.timerView
this.remove();
},
onDestroy() {
if (this.$el.hasClass('expired')) {
return;
}
this.onUnload();
},
onChange() {
this.renderSent();
this.renderQuote();
this.addId();
},
select(e) {
this.$el.trigger('select', { message: this.model });
e.stopPropagation();
},
className() {
return ['entry', this.model.get('type')].join(' ');
},
renderPending() {
this.$el.addClass('pending');
},
renderDone() {
this.$el.removeClass('pending');
},
renderSent() {
if (this.model.isOutgoing()) {
this.$el.toggleClass('sent', !!this.model.get('sent'));
}
},
renderDelivered() {
if (this.model.get('delivered')) {
this.$el.addClass('delivered');
}
},
renderRead() {
if (!_.isEmpty(this.model.get('read_by'))) {
this.$el.addClass('read');
}
},
onErrorsChanged() {
if (this.model.isIncoming()) {
this.render();
} else {
this.renderErrors();
}
},
renderErrors() {
const errors = this.model.get('errors');
getRenderInfo() {
const { Components } = window.Signal;
this.$('.error-icon-container').remove();
if (this.errorIconView) {
this.errorIconView.remove();
this.errorIconView = null;
}
if (_.size(errors) > 0) {
if (this.model.isIncoming()) {
this.$('.content')
.text(this.model.getDescription())
.addClass('error-message');
}
this.errorIconView = new ErrorIconView({ model: errors[0] });
this.errorIconView.render().$el.appendTo(this.$('.bubble'));
} else if (!this.hasContents()) {
const el = this.$('.content');
if (!el || el.length === 0) {
this.$('.inner-bubble').append("<div class='content'></div>");
}
this.$('.content')
.text(i18n('noContents'))
.addClass('error-message');
if (this.model.isExpirationTimerUpdate()) {
return {
Component: Components.TimerNotification,
props: this.model.getPropsForTimerNotification(),
};
} else if (this.model.isKeyChange()) {
return {
Component: Components.SafetyNumberNotification,
props: this.model.getPropsForSafetyNumberNotification(),
};
} else if (this.model.isVerifiedChange()) {
return {
Component: Components.VerificationNotification,
props: this.model.getPropsForVerificationNotification(),
};
} else if (this.model.isEndSession()) {
return {
Component: Components.ResetSessionNotification,
props: this.model.getPropsForResetSessionNotification(),
};
} else if (this.model.isGroupUpdate()) {
return {
Component: Components.GroupNotification,
props: this.model.getPropsForGroupNotification(),
};
}
this.$('.meta .hasRetry').remove();
if (this.networkErrorView) {
this.networkErrorView.remove();
this.networkErrorView = null;
}
if (this.model.hasNetworkError()) {
this.networkErrorView = new NetworkErrorView({ model: this.model });
this.$('.meta').prepend(this.networkErrorView.render().el);
}
this.$('.meta .some-failed').remove();
if (this.someFailedView) {
this.someFailedView.remove();
this.someFailedView = null;
}
if (this.model.someRecipientsFailed()) {
this.someFailedView = new SomeFailedView();
this.$('.meta').prepend(this.someFailedView.render().el);
}
},
renderControl() {
if (this.model.isEndSession() || this.model.isGroupUpdate()) {
this.$el.addClass('control');
if (this.controlView) {
this.controlView.remove();
this.controlView = null;
}
this.controlView = new Whisper.ReactWrapperView({
className: 'content-wrapper',
Component: window.Signal.Components.Emojify,
props: {
text: this.model.getDescription(),
},
});
this.$('.content').prepend(this.controlView.el);
} else {
this.$el.removeClass('control');
}
},
renderExpiring() {
if (!this.timerView) {
this.timerView = new TimerView({ model: this.model });
}
this.timerView.setElement(this.$('.timer'));
this.timerView.update();
},
renderQuote() {
const props = this.model.getPropsForQuote();
if (!props) {
return;
}
const contact = this.model.getQuoteContact();
if (this.quoteView) {
this.quoteView.remove();
this.quoteView = null;
} else if (contact) {
this.listenTo(contact, 'change:color', this.renderQuote);
}
this.quoteView = new Whisper.ReactWrapperView({
className: 'quote-wrapper',
Component: window.Signal.Components.Quote,
props: Object.assign({}, props, {
text: props.text,
}),
});
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 =
this.contactHasSignalAccount || (number && haveConversation);
// We store this value on this. because a re-render shouldn't kick off another
// profile check, going to the web.
this.contactHasSignalAccount = hasLocalSignalAccount;
const onSendMessage = number
? () => {
this.model.trigger('open-conversation', number);
}
: null;
const onOpenContact = async () => {
// First let's finish our check with the central server to see if this user has
// a signal account. Then we won't have to do it a second time for the detail
// screen.
await this.checkingProfile;
this.model.trigger('show-contact-detail', {
contact,
hasSignalAccount: this.contactHasSignalAccount,
});
return {
Component: Components.Message,
props: this.model.getPropsForMessage(),
};
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
this.checkingProfile = window.textsecure.messaging
.getProfile(number)
.then(() => {
this.contactHasSignalAccount = true;
if (!this.contactView) {
return;
}
this.contactView.update(getProps({ hasSignalAccount: true }));
})
.catch(() => {
// No account available, or network connectivity problem
});
} else {
this.checkingProfile = Promise.resolve();
}
},
isImageWithoutCaption() {
const attachments = this.model.get('attachments');
const body = this.model.get('body');
if (!attachments || attachments.length === 0) {
return false;
}
if (body && body.trim()) {
return false;
}
const first = attachments[0];
if (Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) {
return true;
}
return false;
},
hasContents() {
const attachments = this.model.get('attachments');
const hasAttachments = attachments && attachments.length > 0;
const contacts = this.model.get('contact');
const hasContact = contacts && contacts.length > 0;
return this.hasTextContents() || hasAttachments || hasContact;
},
hasTextContents() {
const body = this.model.get('body');
const isGroupUpdate = this.model.isGroupUpdate();
const isEndSession = this.model.isEndSession();
const errors = this.model.get('errors');
const hasErrors = errors && errors.length > 0;
const errorsCanBeContents = this.model.isIncoming() && hasErrors;
return body || isGroupUpdate || isEndSession || errorsCanBeContents;
},
addId() {
// Because we initially render a sent Message before we've roundtripped with the
// database, we don't have its id for that first render. We do get a change event,
// however, and can add the id manually.
const { id } = this.model;
this.$el.attr('id', id);
},
render() {
const contact = this.model.isIncoming() ? this.model.getContact() : null;
const attachments = this.model.get('attachments');
this.addId();
const errors = this.model.get('errors');
const hasErrors = errors && errors.length > 0;
const hasAttachments = attachments && attachments.length > 0;
const hasBody = this.hasTextContents();
const messageBody = this.model.get('body');
this.$el.html(
Mustache.render(
_.result(this, 'template', ''),
{
message: Boolean(messageBody),
hasBody,
timestamp: this.model.get('sent_at'),
sender: (contact && contact.getTitle()) || '',
avatar: contact && contact.getAvatar(),
profileName: contact && contact.getProfileName(),
innerBubbleClasses: this.isImageWithoutCaption() ? '' : 'with-tail',
hoverIcon: !hasErrors,
hasAttachments,
reply: i18n('replyToMessage'),
},
this.render_partials()
)
);
this.timeStampView.setElement(this.$('.timestamp'));
this.timeStampView.update();
this.renderControl();
if (messageBody) {
if (this.bodyView) {
this.bodyView.remove();
this.bodyView = null;
}
this.bodyView = new Whisper.ReactWrapperView({
className: 'body-wrapper',
Component: window.Signal.Components.MessageBody,
props: {
text: messageBody,
},
});
this.$('.body').append(this.bodyView.el);
if (this.childView) {
this.childView.remove();
this.childView = null;
}
this.renderSent();
this.renderDelivered();
this.renderRead();
this.renderErrors();
this.renderExpiring();
this.renderQuote();
this.renderContact();
const { Component, props } = this.getRenderInfo();
this.childView = new Whisper.ReactWrapperView({
className: 'message-wrapper',
Component,
props,
});
// NOTE: We have to do this in the background (`then` instead of `await`)
// as our code / Backbone seems to rely on `render` synchronously returning
// `this` instead of `Promise MessageView` (this):
// eslint-disable-next-line more/no-then
this.loadAttachmentViews().then(views =>
this.renderAttachmentViews(views)
);
const update = () => {
const info = this.getRenderInfo();
this.childView.update(info.props);
};
this.listenTo(this.model, 'change', update);
this.conversation = this.model.getConversation();
this.listenTo(this.conversation, 'change', update);
this.fromContact = this.model.getIncomingContact();
if (this.fromContact) {
this.listenTo(this.fromContact, 'change', update);
}
this.quotedContact = this.model.getQuoteContact();
if (this.quotedContact) {
this.listenTo(this.quotedContact, 'change', update);
}
this.$el.append(this.childView.el);
return this;
},
updateColor() {
const bubble = this.$('.bubble');
// this.contact is known to be non-null if we're registered for color changes
const color = this.contact.getColor();
if (color) {
bubble.removeClass(Whisper.Conversation.COLORS);
bubble.addClass(color);
}
this.avatarView = new (Whisper.View.extend({
templateName: 'avatar',
render_attributes: { avatar: this.contact.getAvatar() },
}))();
this.$('.avatar').replaceWith(this.avatarView.render().$('.avatar'));
},
loadAttachmentViews() {
if (this.loadedAttachmentViews !== null) {
return this.loadedAttachmentViews;
}
const attachments = this.model.get('attachments') || [];
const loadedAttachmentViews = Promise.all(
attachments.map(
attachment =>
new Promise(async resolve => {
const attachmentWithData = await loadAttachmentData(attachment);
const view = new Whisper.AttachmentView({
model: attachmentWithData,
timestamp: this.model.get('sent_at'),
});
this.listenTo(view, 'update', () => {
// NOTE: Can we do without `updated` flag now that we use promises?
view.updated = true;
resolve(view);
});
view.render();
})
)
);
// Memoize attachment views to avoid double loading:
this.loadedAttachmentViews = loadedAttachmentViews;
return loadedAttachmentViews;
},
renderAttachmentViews(views) {
views.forEach(view => this.renderAttachmentView(view));
},
renderAttachmentView(view) {
if (!view.updated) {
throw new Error(
'Invariant violation:' +
' Cannot render an attachment view that isnt ready'
);
}
const parent = this.$('.attachments')[0];
const isViewAlreadyChild = parent === view.el.parentNode;
if (isViewAlreadyChild) {
return;
}
if (view.el.parentNode) {
view.el.parentNode.removeChild(view.el);
}
this.trigger('beforeChangeHeight');
this.$('.attachments').append(view.el);
view.setElement(view.el);
this.trigger('afterChangeHeight');
},
});
})();

View file

@ -7,7 +7,7 @@
window.Whisper = window.Whisper || {};
Whisper.ScrollDownButtonView = Whisper.View.extend({
className: 'scroll-down-button-view',
className: 'module-scroll-down',
templateName: 'scroll-down-button-view',
initialize(options = {}) {
@ -20,7 +20,8 @@
},
render_attributes() {
const cssClass = this.count > 0 ? 'new-messages' : '';
const buttonClass =
this.count > 0 ? 'module-scroll-down__button--new-messages' : '';
let moreBelow = i18n('scrollDown');
if (this.count > 1) {
@ -30,7 +31,7 @@
}
return {
cssClass,
buttonClass,
moreBelow,
};
},