9619e5b66d
Turns out that we reload thumbnails for every message when any new message is added to the conversation. This fix prevents that by actually checking for the proper sentinel on the message model
1622 lines
48 KiB
JavaScript
1622 lines
48 KiB
JavaScript
/* global storage: false */
|
|
/* global textsecure: false */
|
|
/* global Whisper: false */
|
|
/* global Backbone: false */
|
|
/* global _: false */
|
|
/* global ConversationController: false */
|
|
/* global libphonenumber: false */
|
|
/* global wrapDeferred: false */
|
|
/* global dcodeIO: false */
|
|
/* global libsignal: false */
|
|
|
|
/* eslint-disable more/no-then */
|
|
|
|
// eslint-disable-next-line func-names
|
|
(function () {
|
|
'use strict';
|
|
|
|
window.Whisper = window.Whisper || {};
|
|
|
|
const { Message, MIME } = window.Signal.Types;
|
|
const { upgradeMessageSchema, loadAttachmentData } = 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);
|
|
|
|
this.on('change:avatar', this.updateAvatarUrl);
|
|
this.on('change:profileAvatar', this.updateAvatarUrl);
|
|
this.on('change:profileKey', this.onChangeProfileKey);
|
|
this.on('destroy', this.revokeAvatarUrl);
|
|
},
|
|
|
|
isMe() {
|
|
return this.id === this.ourNumber;
|
|
},
|
|
|
|
addSingleMessage(message) {
|
|
const model = this.messageCollection.add(message, { merge: true });
|
|
this.processQuotes(this.messageCollection);
|
|
return model;
|
|
},
|
|
|
|
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) {
|
|
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 }
|
|
));
|
|
},
|
|
|
|
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);
|
|
},
|
|
|
|
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 makeThumbnailAttachment(attachment) {
|
|
const attachmentWithData = await loadAttachmentData(attachment);
|
|
const { data, contentType } = attachmentWithData;
|
|
const objectUrl = this.makeObjectUrl(data, contentType);
|
|
const thumbnail = await Whisper.FileInputView.makeThumbnail(128, objectUrl);
|
|
URL.revokeObjectURL(objectUrl);
|
|
|
|
const arrayBuffer = await this.blobToArrayBuffer(thumbnail);
|
|
const finalContentType = 'image/png';
|
|
const finalObjectUrl = this.makeObjectUrl(arrayBuffer, finalContentType);
|
|
|
|
return {
|
|
data: arrayBuffer,
|
|
objectUrl: finalObjectUrl,
|
|
contentType: finalContentType,
|
|
};
|
|
},
|
|
|
|
async makeQuote(quotedMessage) {
|
|
const contact = quotedMessage.getContact();
|
|
const attachments = quotedMessage.get('attachments');
|
|
|
|
return {
|
|
author: contact.id,
|
|
id: quotedMessage.get('sent_at'),
|
|
text: quotedMessage.get('body'),
|
|
attachments: await Promise.all((attachments || []).map(async (attachment) => {
|
|
const { contentType } = attachment;
|
|
const willMakeThumbnail = MIME.isImage(contentType);
|
|
|
|
return {
|
|
contentType,
|
|
fileName: attachment.fileName,
|
|
thumbnail: willMakeThumbnail
|
|
? await this.makeThumbnailAttachment(attachment)
|
|
: null,
|
|
};
|
|
})),
|
|
};
|
|
},
|
|
|
|
sendMessage(body, attachments, quote) {
|
|
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: this.id,
|
|
quote,
|
|
attachments,
|
|
sent_at: now,
|
|
received_at: now,
|
|
expireTimer: this.get('expireTimer'),
|
|
recipients: this.getRecipients(),
|
|
});
|
|
const message = this.addSingleMessage(messageWithSchema);
|
|
|
|
if (this.isPrivate()) {
|
|
message.set({ destination: this.id });
|
|
}
|
|
message.save();
|
|
|
|
this.save({
|
|
active_at: now,
|
|
timestamp: now,
|
|
lastMessage: message.getNotificationText(),
|
|
});
|
|
|
|
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}'`);
|
|
}
|
|
})();
|
|
|
|
let profileKey;
|
|
if (this.get('profileSharing')) {
|
|
profileKey = storage.get('profileKey');
|
|
}
|
|
|
|
const attachmentsWithData =
|
|
await Promise.all(messageWithSchema.attachments.map(loadAttachmentData));
|
|
message.send(sendFunction(
|
|
this.get('id'),
|
|
body,
|
|
attachmentsWithData,
|
|
quote,
|
|
now,
|
|
this.get('expireTimer'),
|
|
profileKey
|
|
));
|
|
});
|
|
},
|
|
|
|
async updateLastMessage() {
|
|
const collection = new Whisper.MessageCollection();
|
|
await collection.fetchConversation(this.id, 1);
|
|
const lastMessage = collection.at(0);
|
|
|
|
const lastMessageUpdate = window.Signal.Types.Conversation.createLastMessageUpdate({
|
|
currentLastMessageText: this.get('lastMessage') || null,
|
|
currentTimestamp: this.get('timestamp') || null,
|
|
lastMessage: lastMessage ? lastMessage.toJSON() : null,
|
|
lastMessageNotificationText: lastMessage
|
|
? lastMessage.getNotificationText() : null,
|
|
});
|
|
|
|
this.set(lastMessageUpdate);
|
|
|
|
if (this.hasChanged('lastMessage') || this.hasChanged('timestamp')) {
|
|
this.save();
|
|
}
|
|
},
|
|
|
|
updateExpirationTimer(
|
|
providedExpireTimer,
|
|
providedSource,
|
|
receivedAt,
|
|
providedOptions
|
|
) {
|
|
const options = providedOptions || {};
|
|
let expireTimer = providedExpireTimer;
|
|
let source = providedSource;
|
|
|
|
_.defaults(options, { fromSync: false });
|
|
|
|
if (!expireTimer) {
|
|
expireTimer = null;
|
|
}
|
|
if (this.get('expireTimer') === expireTimer ||
|
|
(!expireTimer && !this.get('expireTimer'))) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
console.log(
|
|
'Updating expireTimer for conversation',
|
|
this.idForLogging(),
|
|
'to',
|
|
expireTimer,
|
|
'via',
|
|
source
|
|
);
|
|
source = source || textsecure.storage.user.getNumber();
|
|
const timestamp = receivedAt || Date.now();
|
|
|
|
const message = this.messageCollection.add({
|
|
conversationId: this.id,
|
|
type: receivedAt ? 'incoming' : 'outgoing',
|
|
sent_at: timestamp,
|
|
received_at: timestamp,
|
|
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
|
expirationTimerUpdate: {
|
|
expireTimer,
|
|
source,
|
|
fromSync: options.fromSync,
|
|
},
|
|
});
|
|
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 (message.isIncoming()) {
|
|
return message;
|
|
}
|
|
|
|
// change was made locally, send it to the number/group
|
|
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());
|
|
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 || MIME.isVideo(contentType) || MIME.isImage(contentType);
|
|
},
|
|
forceRender(message) {
|
|
message.trigger('change', message);
|
|
},
|
|
makeObjectUrl(data, contentType) {
|
|
const blob = new Blob([data], {
|
|
type: contentType,
|
|
});
|
|
return URL.createObjectURL(blob);
|
|
},
|
|
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];
|
|
|
|
// Maybe in the future we could try to pull the thumbnail from a video ourselves,
|
|
// but for now we will rely on incoming thumbnails only.
|
|
if (!MIME.isImage(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;
|
|
}
|
|
|
|
const queryAttachments = queryMessage.attachments || [];
|
|
if (queryAttachments.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const queryFirst = queryAttachments[0];
|
|
try {
|
|
// eslint-disable-next-line no-param-reassign
|
|
message.quoteThumbnail = await this.makeThumbnailAttachment(queryFirst);
|
|
return true;
|
|
} catch (error) {
|
|
console.log(
|
|
'Problem loading attachment data for quoted message from database',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
return false;
|
|
}
|
|
},
|
|
async loadQuotedMessage(message, quotedMessage) {
|
|
// eslint-disable-next-line no-param-reassign
|
|
message.quotedMessage = quotedMessage;
|
|
|
|
const { quote } = message.attributes;
|
|
const { attachments } = quote;
|
|
const first = attachments[0];
|
|
|
|
// Maybe in the future we could try to pull thumbnails video ourselves,
|
|
// but for now we will rely on incoming thumbnails only.
|
|
if (!first || !MIME.isImage(first.contentType)) {
|
|
return;
|
|
}
|
|
|
|
const quotedAttachments = quotedMessage.get('attachments') || [];
|
|
if (quotedAttachments.length === 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const queryFirst = quotedAttachments[0];
|
|
|
|
// eslint-disable-next-line no-param-reassign
|
|
message.quoteThumbnail = await this.makeThumbnailAttachment(queryFirst);
|
|
} catch (error) {
|
|
console.log(
|
|
'Problem loading attachment data for quoted message',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
}
|
|
},
|
|
async loadQuoteThumbnail(message) {
|
|
const { quote } = message.attributes;
|
|
const { attachments } = quote;
|
|
const first = attachments[0];
|
|
if (!first) {
|
|
return false;
|
|
}
|
|
|
|
const { thumbnail } = first;
|
|
|
|
if (!thumbnail) {
|
|
return false;
|
|
}
|
|
const thumbnailWithData = await loadAttachmentData(thumbnail);
|
|
thumbnailWithData.objectUrl = this.makeObjectUrl(
|
|
thumbnailWithData.data,
|
|
thumbnailWithData.contentType
|
|
);
|
|
|
|
// 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 = thumbnailWithData;
|
|
|
|
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. 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) {
|
|
// eslint-disable-next-line no-param-reassign
|
|
await this.loadQuotedMessage(message, quotedMessage);
|
|
|
|
// Note: in the future when we generate our own thumbnail we won't need to rely
|
|
// on incoming thumbnail if we have our local message in hand.
|
|
if (!message.quotedMessage.imageUrl) {
|
|
await this.loadQuoteThumbnail(message, quote);
|
|
}
|
|
|
|
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;
|
|
|
|
// 2. Go to the database for the real referenced attachment
|
|
const loaded = await this.loadQuotedMessageFromDatabase(message, id);
|
|
if (loaded) {
|
|
// Note: in the future when we generate our own thumbnail we won't need to rely
|
|
// on incoming thumbnail if we have our local message in hand.
|
|
if (!message.quotedMessageFromDatabase.imageUrl) {
|
|
await this.loadQuoteThumbnail(message, quote);
|
|
}
|
|
|
|
this.forceRender(message);
|
|
return;
|
|
}
|
|
|
|
// 3. Finally, use the provided thumbnail
|
|
const gotThumbnail = await this.loadQuoteThumbnail(message, quote);
|
|
if (gotThumbnail) {
|
|
this.forceRender(message);
|
|
}
|
|
});
|
|
|
|
return Promise.all(promises);
|
|
},
|
|
|
|
async fetchMessages() {
|
|
if (!this.id) {
|
|
throw new Error('This conversation has no id!');
|
|
}
|
|
|
|
await this.messageCollection.fetchConversation(
|
|
this.id,
|
|
null,
|
|
this.get('unreadCount')
|
|
);
|
|
|
|
// 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);
|
|
},
|
|
|
|
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);
|
|
});
|
|
},
|
|
|
|
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,
|
|
});
|
|
});
|
|
},
|
|
|
|
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 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;
|
|
},
|
|
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) => {
|
|
console.log('adding notification');
|
|
Whisper.Notifications.add({
|
|
title: sender.getTitle(),
|
|
message: message.getNotificationText(),
|
|
iconUrl,
|
|
imageUrl: message.getImageUrl(),
|
|
conversationId,
|
|
messageId: message.id,
|
|
});
|
|
}));
|
|
},
|
|
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);
|
|
});
|
|
},
|
|
});
|
|
}());
|