session-desktop/js/models/conversations.js
Scott Nonnenberg c2c8dc5090 Make quote props consistent, white circle dark theme play overlay
Prevents errors in the logs about attempting to load images from their
relative patn instead of the absolute path. No effect on the user.
2018-07-18 12:20:14 -07:00

1883 lines
54 KiB
JavaScript

/* global _: false */
/* global Backbone: false */
/* global dcodeIO: false */
/* global libphonenumber: false */
/* global ConversationController: false */
/* global libsignal: false */
/* global storage: false */
/* global textsecure: false */
/* global Whisper: false */
/* global wrapDeferred: false */
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
const { Util } = window.Signal;
const { GoogleChrome } = Util;
const {
Conversation,
Contact,
Errors,
Message,
PhoneNumber,
} = window.Signal.Types;
const {
upgradeMessageSchema,
loadAttachmentData,
getAbsoluteAttachmentPath,
} = window.Signal.Migrations;
// TODO: Factor out private and group subclasses of Conversation
const COLORS = [
'red',
'pink',
'purple',
'deep_purple',
'indigo',
'blue',
'light_blue',
'cyan',
'teal',
'green',
'light_green',
'orange',
'deep_orange',
'amber',
'blue_grey',
];
function constantTimeEqualArrayBuffers(ab1, ab2) {
if (!(ab1 instanceof ArrayBuffer && ab2 instanceof ArrayBuffer)) {
return false;
}
if (ab1.byteLength !== ab2.byteLength) {
return false;
}
let result = 0;
const ta1 = new Uint8Array(ab1);
const ta2 = new Uint8Array(ab2);
for (let i = 0; i < ab1.byteLength; i += 1) {
// eslint-disable-next-line no-bitwise
result |= ta1[i] ^ ta2[i];
}
return result === 0;
}
Whisper.Conversation = Backbone.Model.extend({
database: Whisper.Database,
storeName: 'conversations',
defaults() {
return {
unreadCount: 0,
verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT,
};
},
idForLogging() {
if (this.isPrivate()) {
return this.id;
}
return `group(${this.id})`;
},
handleMessageError(message, errors) {
this.trigger('messageError', message, errors);
},
initialize() {
this.ourNumber = textsecure.storage.user.getNumber();
this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus;
// This may be overridden by ConversationController.getOrCreate, and signify
// our first save to the database. Or first fetch from the database.
this.initialPromise = Promise.resolve();
this.contactCollection = new Backbone.Collection();
const collator = new Intl.Collator();
this.contactCollection.comparator = (left, right) => {
const leftLower = left.getTitle().toLowerCase();
const rightLower = right.getTitle().toLowerCase();
return collator.compare(leftLower, rightLower);
};
this.messageCollection = new Whisper.MessageCollection([], {
conversation: this,
});
this.messageCollection.on('change:errors', this.handleMessageError, this);
this.messageCollection.on('send-error', this.onMessageError, this);
const debouncedUpdateLastMessage = _.debounce(
this.updateLastMessage.bind(this),
1000
);
this.listenTo(
this.messageCollection,
'add remove',
debouncedUpdateLastMessage
);
this.listenTo(this.model, 'newmessage', debouncedUpdateLastMessage);
this.on('change:avatar', this.updateAvatarUrl);
this.on('change:profileAvatar', this.updateAvatarUrl);
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('sent', this.updateLastMessage);
this.on('expired', this.onExpired);
this.listenTo(
this.messageCollection,
'expired',
this.onExpiredCollection
);
},
isMe() {
return this.id === this.ourNumber;
},
onExpired(message) {
const mine = this.messageCollection.get(message.id);
if (mine && mine.cid !== message.cid) {
mine.trigger('expired', mine);
}
},
async onExpiredCollection(message) {
console.log('onExpiredCollection', message.attributes);
const removeMessage = () => {
console.log('Remove expired message from collection', {
sentAt: message.get('sent_at'),
});
this.messageCollection.remove(message.id);
};
// If a fetch is in progress, then we need to wait until that's complete to
// do this removal. Otherwise we could remove from messageCollection, then
// the async database fetch could include the removed message.
await this.inProgressFetch;
removeMessage();
},
// Used to update existing messages when updated from out-of-band db access,
// like read and delivery receipts.
updateMessage(message) {
this.updateLastMessage();
this.messageCollection.add(message, { merge: true });
},
addSingleMessage(message) {
const model = this.messageCollection.add(message, { merge: true });
model.setToExpire();
this.processQuotes(this.messageCollection);
return model;
},
format() {
const { format } = PhoneNumber;
const regionCode = storage.get('regionCode');
const avatar = this.getAvatar();
const color = this.getColor();
return {
phoneNumber: format(this.id, {
ourRegionCode: regionCode,
}),
color,
avatarPath: avatar ? avatar.url : null,
name: this.getName(),
profileName: this.getProfileName(),
title: this.getTitle(),
};
},
getPropsForListItem() {
const result = {
...this.format(),
lastUpdated: this.get('timestamp'),
hasUnread: Boolean(this.get('unreadCount')),
isSelected: this.isSelected,
lastMessage: {
status: this.get('lastMessageStatus'),
text: this.get('lastMessage'),
},
onClick: () => this.trigger('select', this),
};
return result;
},
onMessageError() {
this.updateVerified();
},
safeGetVerified() {
const promise = textsecure.storage.protocol.getVerified(this.id);
return promise.catch(
() => textsecure.storage.protocol.VerifiedStatus.DEFAULT
);
},
updateVerified() {
if (this.isPrivate()) {
return Promise.all([this.safeGetVerified(), this.initialPromise]).then(
results => {
const trust = results[0];
// we don't return here because we don't need to wait for this to finish
this.save({ verified: trust });
}
);
}
const promise = this.fetchContacts();
return promise
.then(() =>
Promise.all(
this.contactCollection.map(contact => {
if (!contact.isMe()) {
return contact.updateVerified();
}
return Promise.resolve();
})
)
)
.then(this.onMemberVerifiedChange.bind(this));
},
setVerifiedDefault(options) {
const { DEFAULT } = this.verifiedEnum;
return this.queueJob(() => this._setVerified(DEFAULT, options));
},
setVerified(options) {
const { VERIFIED } = this.verifiedEnum;
return this.queueJob(() => this._setVerified(VERIFIED, options));
},
setUnverified(options) {
const { UNVERIFIED } = this.verifiedEnum;
return this.queueJob(() => this._setVerified(UNVERIFIED, options));
},
_setVerified(verified, providedOptions) {
const options = providedOptions || {};
_.defaults(options, {
viaSyncMessage: false,
viaContactSync: false,
key: null,
});
const { VERIFIED, UNVERIFIED } = this.verifiedEnum;
if (!this.isPrivate()) {
throw new Error(
'You cannot verify a group conversation. ' +
'You must verify individual contacts.'
);
}
const beginningVerified = this.get('verified');
let promise;
if (options.viaSyncMessage) {
// handle the incoming key from the sync messages - need different
// behavior if that key doesn't match the current key
promise = textsecure.storage.protocol.processVerifiedMessage(
this.id,
verified,
options.key
);
} else {
promise = textsecure.storage.protocol.setVerified(this.id, verified);
}
let keychange;
return promise
.then(updatedKey => {
keychange = updatedKey;
return new Promise(resolve =>
this.save({ verified }).always(resolve)
);
})
.then(() => {
// Three situations result in a verification notice in the conversation:
// 1) The message came from an explicit verification in another client (not
// a contact sync)
// 2) The verification value received by the contact sync is different
// from what we have on record (and it's not a transition to UNVERIFIED)
// 3) Our local verification status is VERIFIED and it hasn't changed,
// but the key did change (Key1/VERIFIED to Key2/VERIFIED - but we don't
// want to show DEFAULT->DEFAULT or UNVERIFIED->UNVERIFIED)
if (
!options.viaContactSync ||
(beginningVerified !== verified && verified !== UNVERIFIED) ||
(keychange && verified === VERIFIED)
) {
return this.addVerifiedChange(this.id, verified === VERIFIED, {
local: !options.viaSyncMessage,
});
}
if (!options.viaSyncMessage) {
return this.sendVerifySyncMessage(this.id, verified);
}
return Promise.resolve();
});
},
sendVerifySyncMessage(number, state) {
const promise = textsecure.storage.protocol.loadIdentityKey(number);
return promise.then(key =>
textsecure.messaging.syncVerification(number, state, key)
);
},
getIdentityKeys() {
const lookup = {};
if (this.isPrivate()) {
return textsecure.storage.protocol
.loadIdentityKey(this.id)
.then(key => {
lookup[this.id] = key;
return lookup;
})
.catch(error => {
console.log(
'getIdentityKeys error for conversation',
this.idForLogging(),
error && error.stack ? error.stack : error
);
return lookup;
});
}
const promises = this.contactCollection.map(contact =>
textsecure.storage.protocol.loadIdentityKey(contact.id).then(
key => {
lookup[contact.id] = key;
},
error => {
console.log(
'getIdentityKeys error for group member',
contact.idForLogging(),
error && error.stack ? error.stack : error
);
}
)
);
return Promise.all(promises).then(() => lookup);
},
replay(error, message) {
const replayable = new textsecure.ReplayableError(error);
return replayable.replay(message.attributes).catch(e => {
console.log('replay error:', e && e.stack ? e.stack : e);
});
},
decryptOldIncomingKeyErrors() {
// We want to run just once per conversation
if (this.get('decryptedOldIncomingKeyErrors')) {
return Promise.resolve();
}
console.log('decryptOldIncomingKeyErrors start for', this.idForLogging());
const messages = this.messageCollection.filter(message => {
const errors = message.get('errors');
if (!errors || !errors[0]) {
return false;
}
const error = _.find(
errors,
e => e.name === 'IncomingIdentityKeyError'
);
return Boolean(error);
});
const markComplete = () => {
console.log(
'decryptOldIncomingKeyErrors complete for',
this.idForLogging()
);
return new Promise(resolve => {
this.save({ decryptedOldIncomingKeyErrors: true }).always(resolve);
});
};
if (!messages.length) {
return markComplete();
}
console.log(
'decryptOldIncomingKeyErrors found',
messages.length,
'messages to process'
);
const safeDelete = message =>
new Promise(resolve => {
message.destroy().always(resolve);
});
const promise = this.getIdentityKeys();
return promise
.then(lookup =>
Promise.all(
_.map(messages, message => {
const source = message.get('source');
const error = _.find(
message.get('errors'),
e => e.name === 'IncomingIdentityKeyError'
);
const key = lookup[source];
if (!key) {
return Promise.resolve();
}
if (constantTimeEqualArrayBuffers(key, error.identityKey)) {
return this.replay(error, message).then(() =>
safeDelete(message)
);
}
return Promise.resolve();
})
)
)
.catch(error => {
console.log(
'decryptOldIncomingKeyErrors error:',
error && error.stack ? error.stack : error
);
})
.then(markComplete);
},
isVerified() {
if (this.isPrivate()) {
return this.get('verified') === this.verifiedEnum.VERIFIED;
}
if (!this.contactCollection.length) {
return false;
}
return this.contactCollection.every(contact => {
if (contact.isMe()) {
return true;
}
return contact.isVerified();
});
},
isUnverified() {
if (this.isPrivate()) {
const verified = this.get('verified');
return (
verified !== this.verifiedEnum.VERIFIED &&
verified !== this.verifiedEnum.DEFAULT
);
}
if (!this.contactCollection.length) {
return true;
}
return this.contactCollection.any(contact => {
if (contact.isMe()) {
return false;
}
return contact.isUnverified();
});
},
getUnverified() {
if (this.isPrivate()) {
return this.isUnverified()
? new Backbone.Collection([this])
: new Backbone.Collection();
}
return new Backbone.Collection(
this.contactCollection.filter(contact => {
if (contact.isMe()) {
return false;
}
return contact.isUnverified();
})
);
},
setApproved() {
if (!this.isPrivate()) {
throw new Error(
'You cannot set a group conversation as trusted. ' +
'You must set individual contacts as trusted.'
);
}
return textsecure.storage.protocol.setApproval(this.id, true);
},
safeIsUntrusted() {
return textsecure.storage.protocol
.isUntrusted(this.id)
.catch(() => false);
},
isUntrusted() {
if (this.isPrivate()) {
return this.safeIsUntrusted();
}
if (!this.contactCollection.length) {
return Promise.resolve(false);
}
return Promise.all(
this.contactCollection.map(contact => {
if (contact.isMe()) {
return false;
}
return contact.safeIsUntrusted();
})
).then(results => _.any(results, result => result));
},
getUntrusted() {
// This is a bit ugly because isUntrusted() is async. Could do the work to cache
// it locally, but we really only need it for this call.
if (this.isPrivate()) {
return this.isUntrusted().then(untrusted => {
if (untrusted) {
return new Backbone.Collection([this]);
}
return new Backbone.Collection();
});
}
return Promise.all(
this.contactCollection.map(contact => {
if (contact.isMe()) {
return [false, contact];
}
return Promise.all([contact.isUntrusted(), contact]);
})
).then(results => {
const filtered = _.filter(results, result => {
const untrusted = result[0];
return untrusted;
});
return new Backbone.Collection(
_.map(filtered, result => {
const contact = result[1];
return contact;
})
);
});
},
onMemberVerifiedChange() {
// If the verified state of a member changes, our aggregate state changes.
// We trigger both events to replicate the behavior of Backbone.Model.set()
this.trigger('change:verified');
this.trigger('change');
},
toggleVerified() {
if (this.isVerified()) {
return this.setVerifiedDefault();
}
return this.setVerified();
},
addKeyChange(id) {
console.log(
'adding key change advisory for',
this.idForLogging(),
id,
this.get('timestamp')
);
const timestamp = Date.now();
const message = new Whisper.Message({
conversationId: this.id,
type: 'keychange',
sent_at: this.get('timestamp'),
received_at: timestamp,
key_changed: id,
unread: 1,
});
message.save().then(this.trigger.bind(this, 'newmessage', message));
},
addVerifiedChange(id, verified, providedOptions) {
const options = providedOptions || {};
_.defaults(options, { local: true });
if (this.isMe()) {
console.log(
'refusing to add verified change advisory for our own number'
);
return;
}
const lastMessage = this.get('timestamp') || Date.now();
console.log(
'adding verified change advisory for',
this.idForLogging(),
id,
lastMessage
);
const timestamp = Date.now();
const message = new Whisper.Message({
conversationId: this.id,
type: 'verified-change',
sent_at: lastMessage,
received_at: timestamp,
verifiedChanged: id,
verified,
local: options.local,
unread: 1,
});
message.save().then(this.trigger.bind(this, 'newmessage', message));
if (this.isPrivate()) {
ConversationController.getAllGroupsInvolvingId(id).then(groups => {
_.forEach(groups, group => {
group.addVerifiedChange(id, verified, options);
});
});
}
},
onReadMessage(message, readAt) {
if (this.messageCollection.get(message.id)) {
this.messageCollection.get(message.id).fetch();
}
// We mark as read everything older than this message - to clean up old stuff
// still marked unread in the database. If the user generally doesn't read in
// the desktop app, so the desktop app only gets read syncs, we can very
// easily end up with messages never marked as read (our previous early read
// sync handling, read syncs never sent because app was offline)
// We queue it because we often get a whole lot of read syncs at once, and
// their markRead calls could very easily overlap given the async pull from DB.
// Lastly, we don't send read syncs for any message marked read due to a read
// sync. That's a notification explosion we don't need.
return this.queueJob(() =>
this.markRead(message.get('received_at'), {
sendReadReceipts: false,
readAt,
})
);
},
getUnread() {
const conversationId = this.id;
const unreadMessages = new Whisper.MessageCollection();
return new Promise(resolve =>
unreadMessages
.fetch({
index: {
// 'unread' index
name: 'unread',
lower: [conversationId],
upper: [conversationId, Number.MAX_VALUE],
},
})
.always(() => {
resolve(unreadMessages);
})
);
},
validate(attributes) {
const required = ['id', 'type'];
const missing = _.filter(required, attr => !attributes[attr]);
if (missing.length) {
return `Conversation must have ${missing}`;
}
if (attributes.type !== 'private' && attributes.type !== 'group') {
return `Invalid conversation type: ${attributes.type}`;
}
const error = this.validateNumber();
if (error) {
return error;
}
this.updateTokens();
return null;
},
validateNumber() {
if (this.isPrivate()) {
const regionCode = storage.get('regionCode');
const number = libphonenumber.util.parseNumber(this.id, regionCode);
if (number.isValidNumber) {
this.set({ id: number.e164 });
return null;
}
return number.error || 'Invalid phone number';
}
return null;
},
updateTokens() {
let tokens = [];
const name = this.get('name');
if (typeof name === 'string') {
tokens.push(name.toLowerCase());
tokens = tokens.concat(
name
.trim()
.toLowerCase()
.split(/[\s\-_()+]+/)
);
}
if (this.isPrivate()) {
const regionCode = storage.get('regionCode');
const number = libphonenumber.util.parseNumber(this.id, regionCode);
tokens.push(
number.nationalNumber,
number.countryCode + number.nationalNumber
);
}
this.set({ tokens });
},
queueJob(callback) {
const previous = this.pending || Promise.resolve();
const taskWithTimeout = textsecure.createTaskWithTimeout(
callback,
`conversation ${this.idForLogging()}`
);
this.pending = previous.then(taskWithTimeout, taskWithTimeout);
const current = this.pending;
current.then(() => {
if (this.pending === current) {
delete this.pending;
}
});
return current;
},
getRecipients() {
if (this.isPrivate()) {
return [this.id];
}
const me = textsecure.storage.user.getNumber();
return _.without(this.get('members'), me);
},
async makeQuote(quotedMessage) {
const { getName } = Contact;
const contact = quotedMessage.getContact();
const attachments = quotedMessage.get('attachments');
const body = quotedMessage.get('body');
const embeddedContact = quotedMessage.get('contact');
const embeddedContactName =
embeddedContact && embeddedContact.length > 0
? getName(embeddedContact[0])
: '';
return {
author: contact.id,
id: quotedMessage.get('sent_at'),
text: body || embeddedContactName,
attachments: await Promise.all(
(attachments || []).map(async attachment => {
const { contentType, fileName, thumbnail } = attachment;
return {
contentType,
fileName,
thumbnail: thumbnail
? {
...(await loadAttachmentData(thumbnail)),
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
}
: null,
};
})
),
};
},
sendMessage(body, attachments, quote) {
const destination = this.id;
const expireTimer = this.get('expireTimer');
const recipients = this.getRecipients();
let profileKey;
if (this.get('profileSharing')) {
profileKey = storage.get('profileKey');
}
this.queueJob(async () => {
const now = Date.now();
console.log(
'Sending message to conversation',
this.idForLogging(),
'with timestamp',
now
);
const messageWithSchema = await upgradeMessageSchema({
type: 'outgoing',
body,
conversationId: destination,
quote,
attachments,
sent_at: now,
received_at: now,
expireTimer,
recipients,
});
const message = this.addSingleMessage(messageWithSchema);
if (this.isPrivate()) {
message.set({ destination });
}
message.save();
this.save({
active_at: now,
timestamp: now,
lastMessage: message.getNotificationText(),
lastMessageStatus: 'sending',
});
const conversationType = this.get('type');
const sendFunction = (() => {
switch (conversationType) {
case Message.PRIVATE:
return textsecure.messaging.sendMessageToNumber;
case Message.GROUP:
return textsecure.messaging.sendMessageToGroup;
default:
throw new TypeError(
`Invalid conversation type: '${conversationType}'`
);
}
})();
const attachmentsWithData = await Promise.all(
messageWithSchema.attachments.map(loadAttachmentData)
);
message.send(
sendFunction(
destination,
body,
attachmentsWithData,
quote,
now,
expireTimer,
profileKey
)
);
});
},
async updateLastMessage() {
const collection = new Whisper.MessageCollection();
await collection.fetchConversation(this.id, 1);
const lastMessage = collection.at(0);
const lastMessageJSON = lastMessage ? lastMessage.toJSON() : null;
const lastMessageStatus = lastMessage
? lastMessage.getMessagePropStatus()
: null;
const lastMessageUpdate = Conversation.createLastMessageUpdate({
currentLastMessageText: this.get('lastMessage') || null,
currentTimestamp: this.get('timestamp') || null,
lastMessage: lastMessageJSON,
lastMessageStatus,
lastMessageNotificationText: lastMessage
? lastMessage.getNotificationText()
: null,
});
this.set(lastMessageUpdate);
if (this.hasChanged('lastMessage') || this.hasChanged('timestamp')) {
this.save();
}
},
updateExpirationTimer(
providedExpireTimer,
providedSource,
receivedAt,
options = {}
) {
let expireTimer = providedExpireTimer;
let source = providedSource;
_.defaults(options, { fromSync: false, fromGroupUpdate: false });
if (!expireTimer) {
expireTimer = null;
}
if (
this.get('expireTimer') === expireTimer ||
(!expireTimer && !this.get('expireTimer'))
) {
return Promise.resolve();
}
console.log("Update conversation 'expireTimer'", {
id: this.idForLogging(),
expireTimer,
source,
});
source = source || textsecure.storage.user.getNumber();
// When we add a disappearing messages notification to the conversation, we want it
// to be above the message that initiated that change, hence the subtraction.
const timestamp = (receivedAt || Date.now()) - 1;
const message = this.messageCollection.add({
// Even though this isn't reflected to the user, we want to place the last seen
// indicator above it. We set it to 'unread' to trigger that placement.
unread: 1,
conversationId: this.id,
// No type; 'incoming' messages are specially treated by conversation.markRead()
sent_at: timestamp,
received_at: timestamp,
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
expirationTimerUpdate: {
expireTimer,
source,
fromSync: options.fromSync,
fromGroupUpdate: options.fromGroupUpdate,
},
});
if (this.isPrivate()) {
message.set({ destination: this.id });
}
if (message.isOutgoing()) {
message.set({ recipients: this.getRecipients() });
}
return Promise.all([
wrapDeferred(message.save()),
wrapDeferred(this.save({ expireTimer })),
]).then(() => {
// if change was made remotely, don't send it to the number/group
if (receivedAt) {
return message;
}
let sendFunc;
if (this.get('type') === 'private') {
sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber;
} else {
sendFunc = textsecure.messaging.sendExpirationTimerUpdateToGroup;
}
let profileKey;
if (this.get('profileSharing')) {
profileKey = storage.get('profileKey');
}
const promise = sendFunc(
this.get('id'),
this.get('expireTimer'),
message.get('sent_at'),
profileKey
);
return message.send(promise).then(() => message);
});
},
isSearchable() {
return !this.get('left') || !!this.get('lastMessage');
},
endSession() {
if (this.isPrivate()) {
const now = Date.now();
const message = this.messageCollection.create({
conversationId: this.id,
type: 'outgoing',
sent_at: now,
received_at: now,
destination: this.id,
recipients: this.getRecipients(),
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
});
message.send(textsecure.messaging.resetSession(this.id, now));
}
},
updateGroup(providedGroupUpdate) {
let groupUpdate = providedGroupUpdate;
if (this.isPrivate()) {
throw new Error('Called update group on private conversation');
}
if (groupUpdate === undefined) {
groupUpdate = this.pick(['name', 'avatar', 'members']);
}
const now = Date.now();
const message = this.messageCollection.create({
conversationId: this.id,
type: 'outgoing',
sent_at: now,
received_at: now,
group_update: groupUpdate,
});
message.send(
textsecure.messaging.updateGroup(
this.id,
this.get('name'),
this.get('avatar'),
this.get('members')
)
);
},
leaveGroup() {
const now = Date.now();
if (this.get('type') === 'group') {
this.save({ left: true });
const message = this.messageCollection.create({
group_update: { left: 'You' },
conversationId: this.id,
type: 'outgoing',
sent_at: now,
received_at: now,
});
message.send(textsecure.messaging.leaveGroup(this.id));
}
},
markRead(newestUnreadDate, providedOptions) {
const options = providedOptions || {};
_.defaults(options, { sendReadReceipts: true });
const conversationId = this.id;
Whisper.Notifications.remove(
Whisper.Notifications.where({
conversationId,
})
);
return this.getUnread().then(providedUnreadMessages => {
let unreadMessages = providedUnreadMessages;
const promises = [];
const oldUnread = unreadMessages.filter(
message => message.get('received_at') <= newestUnreadDate
);
let read = _.map(oldUnread, providedM => {
let m = providedM;
if (this.messageCollection.get(m.id)) {
m = this.messageCollection.get(m.id);
} else {
console.log(
'Marked a message as read in the database, but ' +
'it was not in messageCollection.'
);
}
promises.push(m.markRead(options.readAt));
const errors = m.get('errors');
return {
sender: m.get('source'),
timestamp: m.get('sent_at'),
hasErrors: Boolean(errors && errors.length),
};
});
// Some messages we're marking read are local notifications with no sender
read = _.filter(read, m => Boolean(m.sender));
unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming()));
const unreadCount = unreadMessages.length - read.length;
const promise = new Promise((resolve, reject) => {
this.save({ unreadCount }).then(resolve, reject);
});
promises.push(promise);
// If a message has errors, we don't want to send anything out about it.
// read syncs - let's wait for a client that really understands the message
// to mark it read. we'll mark our local error read locally, though.
// read receipts - here we can run into infinite loops, where each time the
// conversation is viewed, another error message shows up for the contact
read = read.filter(item => !item.hasErrors);
if (read.length && options.sendReadReceipts) {
console.log('Sending', read.length, 'read receipts');
promises.push(textsecure.messaging.syncReadMessages(read));
if (storage.get('read-receipt-setting')) {
_.each(_.groupBy(read, 'sender'), (receipts, sender) => {
const timestamps = _.map(receipts, 'timestamp');
promises.push(
textsecure.messaging.sendReadReceipts(sender, timestamps)
);
});
}
}
return Promise.all(promises);
});
},
onChangeProfileKey() {
if (this.isPrivate()) {
this.getProfiles();
}
},
getProfiles() {
// request all conversation members' keys
let ids = [];
if (this.isPrivate()) {
ids = [this.id];
} else {
ids = this.get('members');
}
return Promise.all(_.map(ids, this.getProfile));
},
getProfile(id) {
if (!textsecure.messaging) {
const message =
'Conversation.getProfile: textsecure.messaging not available';
return Promise.reject(new Error(message));
}
return textsecure.messaging
.getProfile(id)
.then(profile => {
const identityKey = dcodeIO.ByteBuffer.wrap(
profile.identityKey,
'base64'
).toArrayBuffer();
return textsecure.storage.protocol
.saveIdentity(`${id}.1`, identityKey, false)
.then(changed => {
if (changed) {
// save identity will close all sessions except for .1, so we
// must close that one manually.
const address = new libsignal.SignalProtocolAddress(id, 1);
console.log('closing session for', address.toString());
const sessionCipher = new libsignal.SessionCipher(
textsecure.storage.protocol,
address
);
return sessionCipher.closeOpenSessionForDevice();
}
return Promise.resolve();
})
.then(() => {
const c = ConversationController.get(id);
return Promise.all([
c.setProfileName(profile.name),
c.setProfileAvatar(profile.avatar),
]).then(
// success
() =>
new Promise((resolve, reject) => {
c.save().then(resolve, reject);
}),
// fail
e => {
if (e.name === 'ProfileDecryptError') {
// probably the profile key has changed.
console.log(
'decryptProfile error:',
id,
profile,
e && e.stack ? e.stack : e
);
}
}
);
});
})
.catch(error => {
console.log(
'getProfile error:',
error && error.stack ? error.stack : error
);
});
},
setProfileName(encryptedName) {
const key = this.get('profileKey');
if (!key) {
return Promise.resolve();
}
try {
// decode
const data = dcodeIO.ByteBuffer.wrap(
encryptedName,
'base64'
).toArrayBuffer();
// decrypt
return textsecure.crypto
.decryptProfileName(data, key)
.then(decrypted => {
// encode
const name = dcodeIO.ByteBuffer.wrap(decrypted).toString('utf8');
// set
this.set({ profileName: name });
});
} catch (e) {
return Promise.reject(e);
}
},
setProfileAvatar(avatarPath) {
if (!avatarPath) {
return Promise.resolve();
}
return textsecure.messaging.getAvatar(avatarPath).then(avatar => {
const key = this.get('profileKey');
if (!key) {
return Promise.resolve();
}
// decrypt
return textsecure.crypto.decryptProfile(avatar, key).then(decrypted => {
// set
this.set({
profileAvatar: {
data: decrypted,
contentType: 'image/jpeg',
size: decrypted.byteLength,
},
});
});
});
},
setProfileKey(key) {
return new Promise((resolve, reject) => {
if (!constantTimeEqualArrayBuffers(this.get('profileKey'), key)) {
this.save({ profileKey: key }).then(resolve, reject);
} else {
resolve();
}
});
},
makeKey(author, id) {
return `${author}-${id}`;
},
doesMessageMatch(id, author, message) {
const messageAuthor = message.getContact().id;
if (author !== messageAuthor) {
return false;
}
if (id !== message.get('sent_at')) {
return false;
}
return true;
},
needData(attachments) {
if (!attachments || attachments.length === 0) {
return false;
}
const first = attachments[0];
const { thumbnail, contentType } = first;
return (
thumbnail ||
GoogleChrome.isImageTypeSupported(contentType) ||
GoogleChrome.isVideoTypeSupported(contentType)
);
},
forceRender(message) {
message.trigger('change', message);
},
makeMessagesLookup(messages) {
return messages.reduce((acc, message) => {
const { source, sent_at: sentAt } = message.attributes;
// Checking for notification messages (safety number change, timer change)
if (!source && message.isIncoming()) {
return acc;
}
const contact = message.getContact();
if (!contact) {
return acc;
}
const author = contact.id;
const key = this.makeKey(author, sentAt);
acc[key] = message;
return acc;
}, {});
},
async loadQuotedMessageFromDatabase(message) {
const { quote } = message.attributes;
const { attachments, id, author } = quote;
const first = attachments[0];
if (!first || message.quoteThumbnail) {
return false;
}
if (
!GoogleChrome.isImageTypeSupported(first.contentType) &&
!GoogleChrome.isVideoTypeSupported(first.contentType)
) {
return false;
}
const collection = new Whisper.MessageCollection();
await collection.fetchSentAt(id);
const queryMessage = collection.find(m =>
this.doesMessageMatch(id, author, m)
);
if (!queryMessage) {
return false;
}
try {
if (
queryMessage.get('schemaVersion') < Message.CURRENT_SCHEMA_VERSION
) {
const upgradedMessage = await upgradeMessageSchema(
queryMessage.attributes
);
queryMessage.set(upgradedMessage);
await wrapDeferred(message.save());
}
} catch (error) {
console.log(
'Problem upgrading message quoted message from database',
Errors.toLogFormat(error)
);
return false;
}
const queryAttachments = queryMessage.attachments || [];
if (queryAttachments.length === 0) {
return false;
}
const queryFirst = queryAttachments[0];
const { thumbnail } = queryFirst;
// eslint-disable-next-line no-param-reassign
message.quoteThumbnail = {
...thumbnail,
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
};
return true;
},
loadQuotedMessage(message, quotedMessage) {
// eslint-disable-next-line no-param-reassign
message.quotedMessage = quotedMessage;
const { quote } = message.attributes;
const { attachments } = quote;
const first = attachments[0];
if (!first || message.quoteThumbnail) {
return;
}
if (
!GoogleChrome.isImageTypeSupported(first.contentType) &&
!GoogleChrome.isVideoTypeSupported(first.contentType)
) {
return;
}
const quotedAttachments = quotedMessage.get('attachments') || [];
if (quotedAttachments.length === 0) {
return;
}
const queryFirst = quotedAttachments[0];
const { thumbnail } = queryFirst;
if (!thumbnail) {
return;
}
// eslint-disable-next-line no-param-reassign
message.quoteThumbnail = {
...thumbnail,
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
};
},
loadQuoteThumbnail(message) {
const { quote } = message.attributes;
const { attachments } = quote;
const first = attachments[0];
if (!first || message.quoteThumbnail) {
return false;
}
const { thumbnail } = first;
if (!thumbnail) {
return false;
}
// If we update this data in place, there's the risk that this data could be
// saved back to the database
// eslint-disable-next-line no-param-reassign
message.quoteThumbnail = {
...thumbnail,
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
};
return true;
},
async processQuotes(messages) {
const lookup = this.makeMessagesLookup(messages);
const promises = messages.map(async message => {
const { quote } = message.attributes;
if (!quote) {
return;
}
// If we already have a quoted message, then we exit early. If we don't have it,
// then we'll continue to look again for an in-memory message to use. Why? This
// will enable us to scroll to it when the user clicks.
if (message.quotedMessage) {
return;
}
// 1. Load provided thumbnail
const gotThumbnail = this.loadQuoteThumbnail(message, quote);
// 2. Check to see if we've already loaded the target message into memory
const { author, id } = quote;
const key = this.makeKey(author, id);
const quotedMessage = lookup[key];
if (quotedMessage) {
this.loadQuotedMessage(message, quotedMessage);
this.forceRender(message);
return;
}
// Even if we got the thumbnail locall, we wanted to populate the referenced
// message so a click can navigate to it.
if (gotThumbnail) {
this.forceRender(message);
return;
}
// We only go further if we need more data for this message. It's always important
// to grab the quoted message to allow for navigating to it by clicking.
const { attachments } = quote;
if (!this.needData(attachments)) {
return;
}
// We've don't want to go to the database or load thumbnails a second time.
if (message.quoteIsProcessed) {
return;
}
// eslint-disable-next-line no-param-reassign
message.quoteIsProcessed = true;
// 3. As a last resort, go to the database to generate a thumbnail on-demand
const loaded = await this.loadQuotedMessageFromDatabase(message, id);
if (loaded) {
this.forceRender(message);
}
});
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) {
// Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop
const upgradedMessage = await upgradeMessageSchema(attributes);
message.set(upgradedMessage);
// 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,
null,
this.get('unreadCount')
);
await this.inProgressFetch;
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) {
return _.contains(this.get('members'), number);
},
fetchContacts() {
if (this.isPrivate()) {
this.contactCollection.reset([this]);
return Promise.resolve();
}
const members = this.get('members') || [];
const promises = members.map(number =>
ConversationController.getOrCreateAndWait(number, 'private')
);
return Promise.all(promises).then(contacts => {
_.forEach(contacts, contact => {
this.listenTo(
contact,
'change:verified',
this.onMemberVerifiedChange
);
});
this.contactCollection.reset(contacts);
});
},
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() {
if (this.isPrivate()) {
return this.get('name');
}
return this.get('name') || 'Unknown group';
},
getTitle() {
if (this.isPrivate()) {
return this.get('name') || this.getNumber();
}
return this.get('name') || 'Unknown group';
},
getProfileName() {
if (this.isPrivate() && !this.get('name')) {
return this.get('profileName');
}
return null;
},
getDisplayName() {
if (!this.isPrivate()) {
return this.getTitle();
}
const name = this.get('name');
if (name) {
return name;
}
const profileName = this.get('profileName');
if (profileName) {
return `${this.getNumber()} ~${profileName}`;
}
return this.getNumber();
},
getNumber() {
if (!this.isPrivate()) {
return '';
}
const number = this.id;
try {
const parsedNumber = libphonenumber.parse(number);
const regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber);
if (regionCode === storage.get('regionCode')) {
return libphonenumber.format(
parsedNumber,
libphonenumber.PhoneNumberFormat.NATIONAL
);
}
return libphonenumber.format(
parsedNumber,
libphonenumber.PhoneNumberFormat.INTERNATIONAL
);
} catch (e) {
return number;
}
},
isPrivate() {
return this.get('type') === 'private';
},
revokeAvatarUrl() {
if (this.avatarUrl) {
URL.revokeObjectURL(this.avatarUrl);
this.avatarUrl = null;
}
},
updateAvatarUrl(silent) {
this.revokeAvatarUrl();
const avatar = this.get('avatar') || this.get('profileAvatar');
if (avatar) {
this.avatarUrl = URL.createObjectURL(
new Blob([avatar.data], { type: avatar.contentType })
);
} else {
this.avatarUrl = null;
}
if (!silent) {
this.trigger('change');
}
},
getColor() {
const { migrateColor } = Util;
return migrateColor(this.get('color'));
},
getAvatar() {
if (this.avatarUrl === undefined) {
this.updateAvatarUrl(true);
}
const title = this.get('name');
const color = this.getColor();
if (this.avatarUrl) {
return { url: this.avatarUrl, color };
} else if (this.isPrivate()) {
return {
color,
content: title ? title.trim()[0] : '#',
};
}
return { url: 'images/group_default.png', color };
},
getNotificationIcon() {
return new Promise(resolve => {
const avatar = this.getAvatar();
if (avatar.url) {
resolve(avatar.url);
} else {
resolve(new Whisper.IdenticonSVGView(avatar).getDataUrl());
}
});
},
notify(message) {
if (!message.isIncoming()) {
return Promise.resolve();
}
const conversationId = this.id;
return ConversationController.getOrCreateAndWait(
message.get('source'),
'private'
).then(sender =>
sender.getNotificationIcon().then(iconUrl => {
const messageJSON = message.toJSON();
const messageSentAt = messageJSON.sent_at;
const messageId = message.id;
const isExpiringMessage = Message.hasExpiration(messageJSON);
console.log('Add notification', {
conversationId: this.idForLogging(),
isExpiringMessage,
messageSentAt,
});
Whisper.Notifications.add({
conversationId,
iconUrl,
imageUrl: message.getImageUrl(),
isExpiringMessage,
message: message.getNotificationText(),
messageId,
messageSentAt,
title: sender.getTitle(),
});
})
);
},
hashCode() {
if (this.hash === undefined) {
const string = this.getTitle() || '';
if (string.length === 0) {
return 0;
}
let hash = 0;
for (let i = 0; i < string.length; i += 1) {
// eslint-disable-next-line no-bitwise
hash = (hash << 5) - hash + string.charCodeAt(i);
// eslint-disable-next-line no-bitwise
hash &= hash; // Convert to 32bit integer
}
this.hash = hash;
}
return this.hash;
},
});
Whisper.ConversationCollection = Backbone.Collection.extend({
database: Whisper.Database,
storeName: 'conversations',
model: Whisper.Conversation,
comparator(m) {
return -m.get('timestamp');
},
destroyAll() {
return Promise.all(
this.models.map(
m =>
new Promise((resolve, reject) => {
m
.destroy()
.then(resolve)
.fail(reject);
})
)
);
},
search(providedQuery) {
let query = providedQuery.trim().toLowerCase();
if (query.length > 0) {
query = query.replace(/[-.()]*/g, '').replace(/^\+(\d*)$/, '$1');
const lastCharCode = query.charCodeAt(query.length - 1);
const nextChar = String.fromCharCode(lastCharCode + 1);
const upper = query.slice(0, -1) + nextChar;
return new Promise(resolve => {
this.fetch({
index: {
name: 'search', // 'search' index on tokens array
lower: query,
upper,
excludeUpper: true,
},
}).always(resolve);
});
}
return Promise.resolve();
},
fetchAlphabetical() {
return new Promise(resolve => {
this.fetch({
index: {
name: 'search', // 'search' index on tokens array
},
limit: 100,
}).always(resolve);
});
},
fetchGroups(number) {
return new Promise(resolve => {
this.fetch({
index: {
name: 'group',
only: number,
},
}).always(resolve);
});
},
});
Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' ');
// Special collection for fetching all the groups a certain number appears in
Whisper.GroupCollection = Backbone.Collection.extend({
database: Whisper.Database,
storeName: 'conversations',
model: Whisper.Conversation,
fetchGroups(number) {
return new Promise(resolve => {
this.fetch({
index: {
name: 'group',
only: number,
},
}).always(resolve);
});
},
});
})();