Migrate to SQLCipher for messages/cache
Quite a few other fixes, including: - Sending to contact with no avatar yet (not synced from mobile) - Left pane doesn't update quickly or at all on new message - Left pane doesn't show sent or error status Also: - Contributing.md: Ensure set of linux dev dependencies is complete
This commit is contained in:
parent
fc461c82ce
commit
3105b77475
29 changed files with 2006 additions and 716 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -13,6 +13,7 @@ release/
|
||||||
/dev-app-update.yml
|
/dev-app-update.yml
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
*.sublime*
|
*.sublime*
|
||||||
|
sql/
|
||||||
|
|
||||||
# generated files
|
# generated files
|
||||||
js/components.js
|
js/components.js
|
||||||
|
|
|
@ -45,8 +45,10 @@ Then you need `git`, if you don't have that yet: https://git-scm.com/
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
1. Pick your favorite package manager.
|
1. Pick your favorite package manager.
|
||||||
1. Install Python 2.x.
|
1. Install `python`
|
||||||
1. Install GCC.
|
1. Install `gcc`
|
||||||
|
1. Install `g++`
|
||||||
|
1. Install `make`
|
||||||
|
|
||||||
### All platforms
|
### All platforms
|
||||||
|
|
||||||
|
|
36
app/attachment_channel.js
Normal file
36
app/attachment_channel.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
const electron = require('electron');
|
||||||
|
const Attachments = require('./attachments');
|
||||||
|
const rimraf = require('rimraf');
|
||||||
|
|
||||||
|
const { ipcMain } = electron;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initialize,
|
||||||
|
};
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
|
||||||
|
|
||||||
|
async function initialize({ configDir }) {
|
||||||
|
if (initialized) {
|
||||||
|
throw new Error('initialze: Already initialized!');
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
console.log('Ensure attachments directory exists');
|
||||||
|
await Attachments.ensureDirectory(configDir);
|
||||||
|
|
||||||
|
const attachmentsDir = Attachments.getPath(configDir);
|
||||||
|
|
||||||
|
ipcMain.on(ERASE_ATTACHMENTS_KEY, async event => {
|
||||||
|
try {
|
||||||
|
rimraf.sync(attachmentsDir);
|
||||||
|
event.sender.send(`${ERASE_ATTACHMENTS_KEY}-done`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorForDisplay = error && error.stack ? error.stack : error;
|
||||||
|
console.log(`sql-erase error: ${errorForDisplay}`);
|
||||||
|
event.sender.send(`${ERASE_ATTACHMENTS_KEY}-done`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
694
app/sql.js
Normal file
694
app/sql.js
Normal file
|
@ -0,0 +1,694 @@
|
||||||
|
const path = require('path');
|
||||||
|
const mkdirp = require('mkdirp');
|
||||||
|
const rimraf = require('rimraf');
|
||||||
|
const sql = require('@journeyapps/sqlcipher');
|
||||||
|
const pify = require('pify');
|
||||||
|
const uuidv4 = require('uuid/v4');
|
||||||
|
const { map, isString } = require('lodash');
|
||||||
|
|
||||||
|
// To get long stack traces
|
||||||
|
// https://github.com/mapbox/node-sqlite3/wiki/API#sqlite3verbose
|
||||||
|
sql.verbose();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initialize,
|
||||||
|
close,
|
||||||
|
removeDB,
|
||||||
|
|
||||||
|
saveMessage,
|
||||||
|
saveMessages,
|
||||||
|
removeMessage,
|
||||||
|
getUnreadByConversation,
|
||||||
|
getMessageBySender,
|
||||||
|
getMessageById,
|
||||||
|
getAllMessageIds,
|
||||||
|
getMessagesBySentAt,
|
||||||
|
getExpiredMessages,
|
||||||
|
getNextExpiringMessage,
|
||||||
|
getMessagesByConversation,
|
||||||
|
|
||||||
|
getAllUnprocessed,
|
||||||
|
saveUnprocessed,
|
||||||
|
getUnprocessedById,
|
||||||
|
saveUnprocesseds,
|
||||||
|
removeUnprocessed,
|
||||||
|
removeAllUnprocessed,
|
||||||
|
|
||||||
|
removeAll,
|
||||||
|
|
||||||
|
getMessagesNeedingUpgrade,
|
||||||
|
getMessagesWithVisualMediaAttachments,
|
||||||
|
getMessagesWithFileAttachments,
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateUUID() {
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectToJSON(data) {
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}
|
||||||
|
function jsonToObject(json) {
|
||||||
|
return JSON.parse(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDatabase(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const instance = new sql.Database(filePath, error => {
|
||||||
|
if (error) {
|
||||||
|
return reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(instance);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function promisify(rawInstance) {
|
||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
rawInstance.close = pify(rawInstance.close.bind(rawInstance));
|
||||||
|
rawInstance.run = pify(rawInstance.run.bind(rawInstance));
|
||||||
|
rawInstance.get = pify(rawInstance.get.bind(rawInstance));
|
||||||
|
rawInstance.all = pify(rawInstance.all.bind(rawInstance));
|
||||||
|
rawInstance.each = pify(rawInstance.each.bind(rawInstance));
|
||||||
|
rawInstance.exec = pify(rawInstance.exec.bind(rawInstance));
|
||||||
|
rawInstance.prepare = pify(rawInstance.prepare.bind(rawInstance));
|
||||||
|
/* eslint-enable */
|
||||||
|
|
||||||
|
return rawInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSQLiteVersion(instance) {
|
||||||
|
const row = await instance.get('select sqlite_version() AS sqlite_version');
|
||||||
|
return row.sqlite_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSchemaVersion(instance) {
|
||||||
|
const row = await instance.get('PRAGMA schema_version;');
|
||||||
|
return row.schema_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSQLCipherVersion(instance) {
|
||||||
|
const row = await instance.get('PRAGMA cipher_version;');
|
||||||
|
try {
|
||||||
|
return row.cipher_version;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID_KEY = /[^0-9A-Fa-f]/;
|
||||||
|
async function setupSQLCipher(instance, { key }) {
|
||||||
|
const match = INVALID_KEY.exec(key);
|
||||||
|
if (match) {
|
||||||
|
throw new Error(`setupSQLCipher: key '${key}' is not valid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
|
||||||
|
await instance.run(`PRAGMA key = "x'${key}'";`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateToSchemaVersion1(currentVersion, instance) {
|
||||||
|
if (currentVersion >= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('updateToSchemaVersion1: starting...');
|
||||||
|
|
||||||
|
await instance.run('BEGIN TRANSACTION;');
|
||||||
|
|
||||||
|
await instance.run(
|
||||||
|
`CREATE TABLE messages(
|
||||||
|
id STRING PRIMARY KEY ASC,
|
||||||
|
json TEXT,
|
||||||
|
|
||||||
|
unread INTEGER,
|
||||||
|
expires_at INTEGER,
|
||||||
|
sent_at INTEGER,
|
||||||
|
schemaVersion INTEGER,
|
||||||
|
conversationId STRING,
|
||||||
|
received_at INTEGER,
|
||||||
|
source STRING,
|
||||||
|
sourceDevice STRING,
|
||||||
|
hasAttachments INTEGER,
|
||||||
|
hasFileAttachments INTEGER,
|
||||||
|
hasVisualMediaAttachments INTEGER
|
||||||
|
);`
|
||||||
|
);
|
||||||
|
|
||||||
|
await instance.run(`CREATE INDEX messages_unread ON messages (
|
||||||
|
unread
|
||||||
|
);`);
|
||||||
|
await instance.run(`CREATE INDEX messages_expires_at ON messages (
|
||||||
|
expires_at
|
||||||
|
);`);
|
||||||
|
await instance.run(`CREATE INDEX messages_receipt ON messages (
|
||||||
|
sent_at
|
||||||
|
);`);
|
||||||
|
await instance.run(`CREATE INDEX messages_schemaVersion ON messages (
|
||||||
|
schemaVersion
|
||||||
|
);`);
|
||||||
|
|
||||||
|
await instance.run(`CREATE INDEX messages_conversation ON messages (
|
||||||
|
conversationId,
|
||||||
|
received_at
|
||||||
|
);`);
|
||||||
|
|
||||||
|
await instance.run(`CREATE INDEX messages_duplicate_check ON messages (
|
||||||
|
source,
|
||||||
|
sourceDevice,
|
||||||
|
sent_at
|
||||||
|
);`);
|
||||||
|
await instance.run(`CREATE INDEX messages_hasAttachments ON messages (
|
||||||
|
conversationId,
|
||||||
|
hasAttachments,
|
||||||
|
received_at
|
||||||
|
);`);
|
||||||
|
await instance.run(`CREATE INDEX messages_hasFileAttachments ON messages (
|
||||||
|
conversationId,
|
||||||
|
hasFileAttachments,
|
||||||
|
received_at
|
||||||
|
);`);
|
||||||
|
await instance.run(`CREATE INDEX messages_hasVisualMediaAttachments ON messages (
|
||||||
|
conversationId,
|
||||||
|
hasVisualMediaAttachments,
|
||||||
|
received_at
|
||||||
|
);`);
|
||||||
|
|
||||||
|
await instance.run(`CREATE TABLE unprocessed(
|
||||||
|
id STRING,
|
||||||
|
timestamp INTEGER,
|
||||||
|
json TEXT
|
||||||
|
);`);
|
||||||
|
await instance.run(`CREATE INDEX unprocessed_id ON unprocessed (
|
||||||
|
id
|
||||||
|
);`);
|
||||||
|
await instance.run(`CREATE INDEX unprocessed_timestamp ON unprocessed (
|
||||||
|
timestamp
|
||||||
|
);`);
|
||||||
|
|
||||||
|
await instance.run('PRAGMA schema_version = 1;');
|
||||||
|
await instance.run('COMMIT TRANSACTION;');
|
||||||
|
|
||||||
|
console.log('updateToSchemaVersion1: success!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMA_VERSIONS = [updateToSchemaVersion1];
|
||||||
|
|
||||||
|
async function updateSchema(instance) {
|
||||||
|
const sqliteVersion = await getSQLiteVersion(instance);
|
||||||
|
const schemaVersion = await getSchemaVersion(instance);
|
||||||
|
const cipherVersion = await getSQLCipherVersion(instance);
|
||||||
|
console.log(
|
||||||
|
'updateSchema:',
|
||||||
|
`Current schema version: ${schemaVersion};`,
|
||||||
|
`Most recent schema version: ${SCHEMA_VERSIONS.length};`,
|
||||||
|
`SQLite version: ${sqliteVersion};`,
|
||||||
|
`SQLCipher version: ${cipherVersion};`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let index = 0, max = SCHEMA_VERSIONS.length; index < max; index += 1) {
|
||||||
|
const runSchemaUpdate = SCHEMA_VERSIONS[index];
|
||||||
|
|
||||||
|
// Yes, we really want to do this asynchronously, in order
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await runSchemaUpdate(schemaVersion, instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let db;
|
||||||
|
let filePath;
|
||||||
|
|
||||||
|
async function initialize({ configDir, key }) {
|
||||||
|
if (db) {
|
||||||
|
throw new Error('Cannot initialize more than once!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isString(configDir)) {
|
||||||
|
throw new Error('initialize: configDir is required!');
|
||||||
|
}
|
||||||
|
if (!isString(key)) {
|
||||||
|
throw new Error('initialize: key` is required!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbDir = path.join(configDir, 'sql');
|
||||||
|
mkdirp.sync(dbDir);
|
||||||
|
|
||||||
|
filePath = path.join(dbDir, 'db.sqlite');
|
||||||
|
const sqlInstance = await openDatabase(filePath);
|
||||||
|
const promisified = promisify(sqlInstance);
|
||||||
|
|
||||||
|
// promisified.on('trace', statement => console._log(statement));
|
||||||
|
|
||||||
|
await setupSQLCipher(promisified, { key });
|
||||||
|
await updateSchema(promisified);
|
||||||
|
|
||||||
|
db = promisified;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close() {
|
||||||
|
const dbRef = db;
|
||||||
|
db = null;
|
||||||
|
await dbRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDB() {
|
||||||
|
if (db) {
|
||||||
|
throw new Error('removeDB: Cannot erase database when it is open!');
|
||||||
|
}
|
||||||
|
|
||||||
|
rimraf.sync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMessage(data, { forceSave } = {}) {
|
||||||
|
const {
|
||||||
|
conversationId,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
expires_at,
|
||||||
|
hasAttachments,
|
||||||
|
hasFileAttachments,
|
||||||
|
hasVisualMediaAttachments,
|
||||||
|
id,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
received_at,
|
||||||
|
schemaVersion,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
sent_at,
|
||||||
|
source,
|
||||||
|
sourceDevice,
|
||||||
|
unread,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (id && !forceSave) {
|
||||||
|
await db.run(
|
||||||
|
`UPDATE messages SET
|
||||||
|
json = $json,
|
||||||
|
conversationId = $conversationId,
|
||||||
|
expires_at = $expires_at,
|
||||||
|
hasAttachments = $hasAttachments,
|
||||||
|
hasFileAttachments = $hasFileAttachments,
|
||||||
|
hasVisualMediaAttachments = $hasVisualMediaAttachments,
|
||||||
|
id = $id,
|
||||||
|
received_at = $received_at,
|
||||||
|
schemaVersion = $schemaVersion,
|
||||||
|
sent_at = $sent_at,
|
||||||
|
source = $source,
|
||||||
|
sourceDevice = $sourceDevice,
|
||||||
|
unread = $unread
|
||||||
|
WHERE id = $id;`,
|
||||||
|
{
|
||||||
|
$id: id,
|
||||||
|
$json: objectToJSON(data),
|
||||||
|
|
||||||
|
$conversationId: conversationId,
|
||||||
|
$expires_at: expires_at,
|
||||||
|
$hasAttachments: hasAttachments,
|
||||||
|
$hasFileAttachments: hasFileAttachments,
|
||||||
|
$hasVisualMediaAttachments: hasVisualMediaAttachments,
|
||||||
|
$received_at: received_at,
|
||||||
|
$schemaVersion: schemaVersion,
|
||||||
|
$sent_at: sent_at,
|
||||||
|
$source: source,
|
||||||
|
$sourceDevice: sourceDevice,
|
||||||
|
$unread: unread,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCreate = {
|
||||||
|
...data,
|
||||||
|
id: id || generateUUID(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO messages (
|
||||||
|
id,
|
||||||
|
json,
|
||||||
|
|
||||||
|
conversationId,
|
||||||
|
expires_at,
|
||||||
|
hasAttachments,
|
||||||
|
hasFileAttachments,
|
||||||
|
hasVisualMediaAttachments,
|
||||||
|
received_at,
|
||||||
|
schemaVersion,
|
||||||
|
sent_at,
|
||||||
|
source,
|
||||||
|
sourceDevice,
|
||||||
|
unread
|
||||||
|
) values (
|
||||||
|
$id,
|
||||||
|
$json,
|
||||||
|
|
||||||
|
$conversationId,
|
||||||
|
$expires_at,
|
||||||
|
$hasAttachments,
|
||||||
|
$hasFileAttachments,
|
||||||
|
$hasVisualMediaAttachments,
|
||||||
|
$received_at,
|
||||||
|
$schemaVersion,
|
||||||
|
$sent_at,
|
||||||
|
$source,
|
||||||
|
$sourceDevice,
|
||||||
|
$unread
|
||||||
|
);`,
|
||||||
|
{
|
||||||
|
$id: toCreate.id,
|
||||||
|
$json: objectToJSON(toCreate),
|
||||||
|
|
||||||
|
$conversationId: conversationId,
|
||||||
|
$expires_at: expires_at,
|
||||||
|
$hasAttachments: hasAttachments,
|
||||||
|
$hasFileAttachments: hasFileAttachments,
|
||||||
|
$hasVisualMediaAttachments: hasVisualMediaAttachments,
|
||||||
|
$received_at: received_at,
|
||||||
|
$schemaVersion: schemaVersion,
|
||||||
|
$sent_at: sent_at,
|
||||||
|
$source: source,
|
||||||
|
$sourceDevice: sourceDevice,
|
||||||
|
$unread: unread,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return toCreate.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMessages(arrayOfMessages, { forceSave } = {}) {
|
||||||
|
await Promise.all([
|
||||||
|
db.run('BEGIN TRANSACTION;'),
|
||||||
|
...map(arrayOfMessages, message => saveMessage(message, { forceSave })),
|
||||||
|
db.run('COMMIT TRANSACTION;'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMessage(id) {
|
||||||
|
if (!Array.isArray(id)) {
|
||||||
|
await db.run('DELETE FROM messages WHERE id = $id;', { $id: id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id.length) {
|
||||||
|
throw new Error('removeMessages: No ids to delete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our node interface doesn't seem to allow you to replace one single ? with an array
|
||||||
|
await db.run(
|
||||||
|
`DELETE FROM messages WHERE id IN ( ${id.map(() => '?').join(', ')} );`,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessageById(id) {
|
||||||
|
const row = await db.get('SELECT * FROM messages WHERE id = $id;', {
|
||||||
|
$id: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonToObject(row.json);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllMessageIds() {
|
||||||
|
const rows = await db.all('SELECT id FROM messages ORDER BY id ASC;');
|
||||||
|
return map(rows, row => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
async function getMessageBySender({ source, sourceDevice, sent_at }) {
|
||||||
|
const rows = db.all(
|
||||||
|
`SELECT json FROM messages WHERE
|
||||||
|
source = $source AND
|
||||||
|
sourceDevice = $sourceDevice AND
|
||||||
|
sent_at = $sent_at;`,
|
||||||
|
{
|
||||||
|
$source: source,
|
||||||
|
$sourceDevice: sourceDevice,
|
||||||
|
$sent_at: sent_at,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUnreadByConversation(conversationId) {
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT json FROM messages WHERE
|
||||||
|
conversationId = $conversationId AND
|
||||||
|
unread = $unread
|
||||||
|
ORDER BY received_at DESC;`,
|
||||||
|
{
|
||||||
|
$conversationId: conversationId,
|
||||||
|
$unread: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessagesByConversation(
|
||||||
|
conversationId,
|
||||||
|
{ limit = 100, receivedAt = Number.MAX_VALUE } = {}
|
||||||
|
) {
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT json FROM messages WHERE
|
||||||
|
conversationId = $conversationId AND
|
||||||
|
received_at < $received_at
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT $limit;`,
|
||||||
|
{
|
||||||
|
$conversationId: conversationId,
|
||||||
|
$received_at: receivedAt,
|
||||||
|
$limit: limit,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessagesBySentAt(sentAt) {
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT * FROM messages
|
||||||
|
WHERE sent_at = $sent_at
|
||||||
|
ORDER BY received_at DESC;`,
|
||||||
|
{
|
||||||
|
$sent_at: sentAt,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExpiredMessages() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT json FROM messages WHERE
|
||||||
|
expires_at IS NOT NULL AND
|
||||||
|
expires_at <= $expires_at
|
||||||
|
ORDER BY expires_at ASC;`,
|
||||||
|
{
|
||||||
|
$expires_at: now,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNextExpiringMessage() {
|
||||||
|
const rows = await db.all(`
|
||||||
|
SELECT json FROM messages
|
||||||
|
WHERE expires_at IS NOT NULL
|
||||||
|
ORDER BY expires_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUnprocessed(data, { forceSave } = {}) {
|
||||||
|
const { id, timestamp } = data;
|
||||||
|
|
||||||
|
if (forceSave) {
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO unprocessed (
|
||||||
|
id,
|
||||||
|
timestamp,
|
||||||
|
json
|
||||||
|
) values (
|
||||||
|
$id,
|
||||||
|
$timestamp,
|
||||||
|
$json
|
||||||
|
);`,
|
||||||
|
{
|
||||||
|
$id: id,
|
||||||
|
$timestamp: timestamp,
|
||||||
|
$json: objectToJSON(data),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`UPDATE unprocessed SET
|
||||||
|
json = $json,
|
||||||
|
timestamp = $timestamp
|
||||||
|
WHERE id = $id;`,
|
||||||
|
{
|
||||||
|
$id: id,
|
||||||
|
$timestamp: timestamp,
|
||||||
|
$json: objectToJSON(data),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUnprocesseds(arrayOfUnprocessed, { forceSave } = {}) {
|
||||||
|
await Promise.all([
|
||||||
|
db.run('BEGIN TRANSACTION;'),
|
||||||
|
...map(arrayOfUnprocessed, unprocessed =>
|
||||||
|
saveUnprocessed(unprocessed, { forceSave })
|
||||||
|
),
|
||||||
|
db.run('COMMIT TRANSACTION;'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUnprocessedById(id) {
|
||||||
|
const row = await db.get('SELECT json FROM unprocessed WHERE id = $id;', {
|
||||||
|
$id: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonToObject(row.json);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllUnprocessed() {
|
||||||
|
const rows = await db.all(
|
||||||
|
'SELECT json FROM unprocessed ORDER BY timestamp ASC;'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUnprocessed(id) {
|
||||||
|
if (!Array.isArray(id)) {
|
||||||
|
await db.run('DELETE FROM unprocessed WHERE id = $id;', { $id: id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id.length) {
|
||||||
|
throw new Error('removeUnprocessed: No ids to delete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our node interface doesn't seem to allow you to replace one single ? with an array
|
||||||
|
await db.run(
|
||||||
|
`DELETE FROM unprocessed WHERE id IN ( ${id.map(() => '?').join(', ')} );`,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAllUnprocessed() {
|
||||||
|
await db.run('DELETE FROM unprocessed;');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAll() {
|
||||||
|
await Promise.all([
|
||||||
|
db.run('BEGIN TRANSACTION;'),
|
||||||
|
db.run('DELETE FROM messages;'),
|
||||||
|
db.run('DELETE FROM unprocessed;'),
|
||||||
|
db.run('COMMIT TRANSACTION;'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessagesNeedingUpgrade(limit, { maxVersion }) {
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT json FROM messages
|
||||||
|
WHERE schemaVersion IS NOT $maxVersion
|
||||||
|
LIMIT $limit;`,
|
||||||
|
{
|
||||||
|
$maxVersion: maxVersion,
|
||||||
|
$limit: limit,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessagesWithVisualMediaAttachments(
|
||||||
|
conversationId,
|
||||||
|
{ limit }
|
||||||
|
) {
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT json FROM messages WHERE
|
||||||
|
conversationId = $conversationId AND
|
||||||
|
hasVisualMediaAttachments = 1
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT $limit;`,
|
||||||
|
{
|
||||||
|
$conversationId: conversationId,
|
||||||
|
$limit: limit,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessagesWithFileAttachments(conversationId, { limit }) {
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT json FROM messages WHERE
|
||||||
|
conversationId = $conversationId AND
|
||||||
|
hasFileAttachments = 1
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT $limit;`,
|
||||||
|
{
|
||||||
|
$conversationId: conversationId,
|
||||||
|
$limit: limit,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
55
app/sql_channel.js
Normal file
55
app/sql_channel.js
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
const electron = require('electron');
|
||||||
|
const sql = require('./sql');
|
||||||
|
|
||||||
|
const { ipcMain } = electron;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initialize,
|
||||||
|
};
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
const SQL_CHANNEL_KEY = 'sql-channel';
|
||||||
|
const ERASE_SQL_KEY = 'erase-sql-key';
|
||||||
|
|
||||||
|
function initialize({ userConfig }) {
|
||||||
|
if (initialized) {
|
||||||
|
throw new Error('sqlChannels: already initialized!');
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
if (!userConfig) {
|
||||||
|
throw new Error('initialize: userConfig is required!');
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.on(SQL_CHANNEL_KEY, async (event, jobId, callName, ...args) => {
|
||||||
|
try {
|
||||||
|
const fn = sql[callName];
|
||||||
|
if (!fn) {
|
||||||
|
throw new Error(
|
||||||
|
`sql channel: ${callName} is not an available function`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fn(...args);
|
||||||
|
event.sender.send(`${SQL_CHANNEL_KEY}-done`, jobId, null, result);
|
||||||
|
} catch (error) {
|
||||||
|
const errorForDisplay = error && error.stack ? error.stack : error;
|
||||||
|
console.log(
|
||||||
|
`sql channel error with call ${callName}: ${errorForDisplay}`
|
||||||
|
);
|
||||||
|
event.sender.send(`${SQL_CHANNEL_KEY}-done`, jobId, errorForDisplay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.on(ERASE_SQL_KEY, async event => {
|
||||||
|
try {
|
||||||
|
userConfig.set('key', null);
|
||||||
|
event.sender.send(`${ERASE_SQL_KEY}-done`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorForDisplay = error && error.stack ? error.stack : error;
|
||||||
|
console.log(`sql-erase error: ${errorForDisplay}`);
|
||||||
|
event.sender.send(`${ERASE_SQL_KEY}-done`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
139
js/background.js
139
js/background.js
|
@ -119,7 +119,10 @@
|
||||||
window.log.info('background page reloaded');
|
window.log.info('background page reloaded');
|
||||||
window.log.info('environment:', window.getEnvironment());
|
window.log.info('environment:', window.getEnvironment());
|
||||||
|
|
||||||
|
let idleDetector;
|
||||||
let initialLoadComplete = false;
|
let initialLoadComplete = false;
|
||||||
|
let newVersion = false;
|
||||||
|
|
||||||
window.owsDesktopApp = {};
|
window.owsDesktopApp = {};
|
||||||
window.document.title = window.getTitle();
|
window.document.title = window.getTitle();
|
||||||
|
|
||||||
|
@ -165,8 +168,49 @@
|
||||||
window.log.info('Storage fetch');
|
window.log.info('Storage fetch');
|
||||||
storage.fetch();
|
storage.fetch();
|
||||||
|
|
||||||
const MINIMUM_VERSION = 7;
|
function mapOldThemeToNew(theme) {
|
||||||
|
switch (theme) {
|
||||||
|
case 'dark':
|
||||||
|
case 'light':
|
||||||
|
return theme;
|
||||||
|
case 'android-dark':
|
||||||
|
return 'dark';
|
||||||
|
case 'android':
|
||||||
|
case 'ios':
|
||||||
|
default:
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need this 'first' check because we don't want to start the app up any other time
|
||||||
|
// than the first time. And storage.fetch() will cause onready() to fire.
|
||||||
|
let first = true;
|
||||||
|
storage.onready(async () => {
|
||||||
|
if (!first) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
const currentVersion = window.getVersion();
|
||||||
|
const lastVersion = storage.get('version');
|
||||||
|
newVersion = !lastVersion || currentVersion !== lastVersion;
|
||||||
|
await storage.put('version', currentVersion);
|
||||||
|
|
||||||
|
if (newVersion) {
|
||||||
|
if (
|
||||||
|
lastVersion &&
|
||||||
|
window.isBeforeVersion(lastVersion, 'v1.15.0-beta.5')
|
||||||
|
) {
|
||||||
|
await window.Signal.Logs.deleteAll();
|
||||||
|
window.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.info(
|
||||||
|
`New version detected: ${currentVersion}; previous: ${lastVersion}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MINIMUM_VERSION = 7;
|
||||||
async function upgradeMessages() {
|
async function upgradeMessages() {
|
||||||
const NUM_MESSAGES_PER_BATCH = 10;
|
const NUM_MESSAGES_PER_BATCH = 10;
|
||||||
window.log.info(
|
window.log.info(
|
||||||
|
@ -205,7 +249,8 @@
|
||||||
BackboneMessageCollection: Whisper.MessageCollection,
|
BackboneMessageCollection: Whisper.MessageCollection,
|
||||||
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
|
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
|
||||||
upgradeMessageSchema,
|
upgradeMessageSchema,
|
||||||
getMessagesNeedingUpgrade: window.Signal.Data.getMessagesNeedingUpgrade,
|
getMessagesNeedingUpgrade:
|
||||||
|
window.Signal.Data.getLegacyMessagesNeedingUpgrade,
|
||||||
saveMessage: window.Signal.Data.saveMessage,
|
saveMessage: window.Signal.Data.saveMessage,
|
||||||
maxVersion: MINIMUM_VERSION,
|
maxVersion: MINIMUM_VERSION,
|
||||||
});
|
});
|
||||||
|
@ -219,9 +264,13 @@
|
||||||
|
|
||||||
await upgradeMessages();
|
await upgradeMessages();
|
||||||
|
|
||||||
const idleDetector = new IdleDetector();
|
idleDetector = new IdleDetector();
|
||||||
let isMigrationWithIndexComplete = false;
|
let isMigrationWithIndexComplete = false;
|
||||||
window.log.info('Starting background data migration. Target version: latest');
|
window.log.info(
|
||||||
|
`Starting background data migration. Target version: ${
|
||||||
|
Message.CURRENT_SCHEMA_VERSION
|
||||||
|
}`
|
||||||
|
);
|
||||||
idleDetector.on('idle', async () => {
|
idleDetector.on('idle', async () => {
|
||||||
const NUM_MESSAGES_PER_BATCH = 1;
|
const NUM_MESSAGES_PER_BATCH = 1;
|
||||||
|
|
||||||
|
@ -231,7 +280,8 @@
|
||||||
BackboneMessageCollection: Whisper.MessageCollection,
|
BackboneMessageCollection: Whisper.MessageCollection,
|
||||||
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
|
numMessagesPerBatch: NUM_MESSAGES_PER_BATCH,
|
||||||
upgradeMessageSchema,
|
upgradeMessageSchema,
|
||||||
getMessagesNeedingUpgrade: window.Signal.Data.getMessagesNeedingUpgrade,
|
getMessagesNeedingUpgrade:
|
||||||
|
window.Signal.Data.getMessagesNeedingUpgrade,
|
||||||
saveMessage: window.Signal.Data.saveMessage,
|
saveMessage: window.Signal.Data.saveMessage,
|
||||||
});
|
});
|
||||||
window.log.info('Upgrade message schema (with index):', batchWithIndex);
|
window.log.info('Upgrade message schema (with index):', batchWithIndex);
|
||||||
|
@ -239,33 +289,22 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMigrationWithIndexComplete) {
|
if (isMigrationWithIndexComplete) {
|
||||||
window.log.info('Background migration complete. Stopping idle detector.');
|
window.log.info(
|
||||||
|
'Background migration complete. Stopping idle detector.'
|
||||||
|
);
|
||||||
idleDetector.stop();
|
idleDetector.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function mapOldThemeToNew(theme) {
|
const db = await Whisper.Database.open();
|
||||||
switch (theme) {
|
await window.Signal.migrateToSQL({
|
||||||
case 'dark':
|
db,
|
||||||
case 'light':
|
clearStores: Whisper.Database.clearStores,
|
||||||
return theme;
|
handleDOMException: Whisper.Database.handleDOMException,
|
||||||
case 'android-dark':
|
});
|
||||||
return 'dark';
|
|
||||||
case 'android':
|
|
||||||
case 'ios':
|
|
||||||
default:
|
|
||||||
return 'light';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We need this 'first' check because we don't want to start the app up any other time
|
// Note: We are not invoking the second set of IndexedDB migrations because it is
|
||||||
// than the first time. And storage.fetch() will cause onready() to fire.
|
// likely that any future migrations will simply extracting things from IndexedDB.
|
||||||
let first = true;
|
|
||||||
storage.onready(async () => {
|
|
||||||
if (!first) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
first = false;
|
|
||||||
|
|
||||||
// These make key operations available to IPC handlers created in preload.js
|
// These make key operations available to IPC handlers created in preload.js
|
||||||
window.Events = {
|
window.Events = {
|
||||||
|
@ -340,14 +379,20 @@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ConversationController.load();
|
await ConversationController.load();
|
||||||
|
} catch (error) {
|
||||||
|
window.log.error(
|
||||||
|
'background.js: ConversationController failed to load:',
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
start();
|
start();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Whisper.events.on('shutdown', async () => {
|
Whisper.events.on('shutdown', async () => {
|
||||||
|
if (idleDetector) {
|
||||||
idleDetector.stop();
|
idleDetector.stop();
|
||||||
|
}
|
||||||
if (messageReceiver) {
|
if (messageReceiver) {
|
||||||
await messageReceiver.close();
|
await messageReceiver.close();
|
||||||
}
|
}
|
||||||
|
@ -376,25 +421,6 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
const currentVersion = window.getVersion();
|
|
||||||
const lastVersion = storage.get('version');
|
|
||||||
const newVersion = !lastVersion || currentVersion !== lastVersion;
|
|
||||||
await storage.put('version', currentVersion);
|
|
||||||
|
|
||||||
if (newVersion) {
|
|
||||||
if (
|
|
||||||
lastVersion &&
|
|
||||||
window.isBeforeVersion(lastVersion, 'v1.15.0-beta.5')
|
|
||||||
) {
|
|
||||||
await window.Signal.Logs.deleteAll();
|
|
||||||
window.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.log.info(
|
|
||||||
`New version detected: ${currentVersion}; previous: ${lastVersion}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.dispatchEvent(new Event('storage_ready'));
|
window.dispatchEvent(new Event('storage_ready'));
|
||||||
|
|
||||||
window.log.info('listening for registration events');
|
window.log.info('listening for registration events');
|
||||||
|
@ -646,15 +672,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.onready(async () => {
|
storage.onready(async () => {
|
||||||
const shouldSkipAttachmentMigrationForNewUsers = firstRun === true;
|
|
||||||
if (shouldSkipAttachmentMigrationForNewUsers) {
|
|
||||||
const database = Migrations0DatabaseWithAttachmentData.getDatabase();
|
|
||||||
const connection = await Signal.Database.open(
|
|
||||||
database.name,
|
|
||||||
database.version
|
|
||||||
);
|
|
||||||
await Signal.Settings.markAttachmentMigrationComplete(connection);
|
|
||||||
}
|
|
||||||
idleDetector.start();
|
idleDetector.start();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1009,8 +1026,14 @@
|
||||||
|
|
||||||
// These two are important to ensure we don't rip through every message
|
// These two are important to ensure we don't rip through every message
|
||||||
// in the database attempting to upgrade it after starting up again.
|
// in the database attempting to upgrade it after starting up again.
|
||||||
textsecure.storage.put(LAST_PROCESSED_INDEX_KEY, lastProcessedIndex);
|
textsecure.storage.put(
|
||||||
textsecure.storage.put(IS_MIGRATION_COMPLETE_KEY, isMigrationComplete);
|
IS_MIGRATION_COMPLETE_KEY,
|
||||||
|
isMigrationComplete || false
|
||||||
|
);
|
||||||
|
textsecure.storage.put(
|
||||||
|
LAST_PROCESSED_INDEX_KEY,
|
||||||
|
lastProcessedIndex || null
|
||||||
|
);
|
||||||
|
|
||||||
window.log.info('Successfully cleared local configuration');
|
window.log.info('Successfully cleared local configuration');
|
||||||
} catch (eraseError) {
|
} catch (eraseError) {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
/* global _, Whisper, Backbone, storage */
|
/* global _, Whisper, Backbone, storage, wrapDeferred */
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
|
||||||
|
@ -181,27 +181,34 @@
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
this._initialPromise = Promise.resolve();
|
this._initialPromise = Promise.resolve();
|
||||||
|
this._initialFetchComplete = false;
|
||||||
conversations.reset([]);
|
conversations.reset([]);
|
||||||
},
|
},
|
||||||
load() {
|
async load() {
|
||||||
window.log.info('ConversationController: starting initial fetch');
|
window.log.info('ConversationController: starting initial fetch');
|
||||||
|
|
||||||
this._initialPromise = new Promise((resolve, reject) => {
|
if (conversations.length) {
|
||||||
conversations.fetch().then(
|
throw new Error('ConversationController: Already loaded!');
|
||||||
() => {
|
}
|
||||||
window.log.info('ConversationController: done with initial fetch');
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
await wrapDeferred(conversations.fetch());
|
||||||
this._initialFetchComplete = true;
|
this._initialFetchComplete = true;
|
||||||
resolve();
|
await Promise.all(
|
||||||
},
|
conversations.map(conversation => conversation.updateLastMessage())
|
||||||
error => {
|
);
|
||||||
|
window.log.info('ConversationController: done with initial fetch');
|
||||||
|
} catch (error) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
'ConversationController: initial fetch failed',
|
'ConversationController: initial fetch failed',
|
||||||
error && error.stack ? error.stack : error
|
error && error.stack ? error.stack : error
|
||||||
);
|
);
|
||||||
reject(error);
|
throw error;
|
||||||
}
|
}
|
||||||
);
|
};
|
||||||
});
|
|
||||||
|
this._initialPromise = load();
|
||||||
|
|
||||||
return this._initialPromise;
|
return this._initialPromise;
|
||||||
},
|
},
|
||||||
|
|
|
@ -116,15 +116,21 @@
|
||||||
|
|
||||||
const debouncedUpdateLastMessage = _.debounce(
|
const debouncedUpdateLastMessage = _.debounce(
|
||||||
this.updateLastMessage.bind(this),
|
this.updateLastMessage.bind(this),
|
||||||
1000
|
200
|
||||||
);
|
);
|
||||||
this.listenTo(
|
this.listenTo(
|
||||||
this.messageCollection,
|
this.messageCollection,
|
||||||
'add remove',
|
'add remove destroy',
|
||||||
debouncedUpdateLastMessage
|
debouncedUpdateLastMessage
|
||||||
);
|
);
|
||||||
this.listenTo(this.model, 'newmessage', debouncedUpdateLastMessage);
|
this.listenTo(this.messageCollection, 'sent', this.updateLastMessage);
|
||||||
|
this.listenTo(
|
||||||
|
this.messageCollection,
|
||||||
|
'send-error',
|
||||||
|
this.updateLastMessage
|
||||||
|
);
|
||||||
|
|
||||||
|
this.on('newmessage', this.updateLastMessage);
|
||||||
this.on('change:avatar', this.updateAvatarUrl);
|
this.on('change:avatar', this.updateAvatarUrl);
|
||||||
this.on('change:profileAvatar', this.updateAvatarUrl);
|
this.on('change:profileAvatar', this.updateAvatarUrl);
|
||||||
this.on('change:profileKey', this.onChangeProfileKey);
|
this.on('change:profileKey', this.onChangeProfileKey);
|
||||||
|
@ -133,10 +139,7 @@
|
||||||
// Listening for out-of-band data updates
|
// Listening for out-of-band data updates
|
||||||
this.on('delivered', this.updateAndMerge);
|
this.on('delivered', this.updateAndMerge);
|
||||||
this.on('read', this.updateAndMerge);
|
this.on('read', this.updateAndMerge);
|
||||||
this.on('sent', this.updateLastMessage);
|
|
||||||
this.on('expired', this.onExpired);
|
this.on('expired', this.onExpired);
|
||||||
|
|
||||||
this.updateLastMessage();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
isMe() {
|
isMe() {
|
||||||
|
@ -378,98 +381,6 @@
|
||||||
|
|
||||||
return Promise.all(promises).then(() => lookup);
|
return Promise.all(promises).then(() => lookup);
|
||||||
},
|
},
|
||||||
replay(error, message) {
|
|
||||||
const replayable = new textsecure.ReplayableError(error);
|
|
||||||
return replayable.replay(message.attributes).catch(e => {
|
|
||||||
window.log.error('replay error:', e && e.stack ? e.stack : e);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
decryptOldIncomingKeyErrors() {
|
|
||||||
// We want to run just once per conversation
|
|
||||||
if (this.get('decryptedOldIncomingKeyErrors')) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
window.log.info(
|
|
||||||
'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 = () => {
|
|
||||||
window.log.info(
|
|
||||||
'decryptOldIncomingKeyErrors complete for',
|
|
||||||
this.idForLogging()
|
|
||||||
);
|
|
||||||
return new Promise(resolve => {
|
|
||||||
this.save({ decryptedOldIncomingKeyErrors: true }).always(resolve);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!messages.length) {
|
|
||||||
return markComplete();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.log.info(
|
|
||||||
'decryptOldIncomingKeyErrors found',
|
|
||||||
messages.length,
|
|
||||||
'messages to process'
|
|
||||||
);
|
|
||||||
const safeDelete = async message => {
|
|
||||||
try {
|
|
||||||
window.Signal.Data.removeMessage(message.id, {
|
|
||||||
Message: Whisper.Message,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// nothing
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 => {
|
|
||||||
window.log.error(
|
|
||||||
'decryptOldIncomingKeyErrors error:',
|
|
||||||
error && error.stack ? error.stack : error
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.then(markComplete);
|
|
||||||
},
|
|
||||||
isVerified() {
|
isVerified() {
|
||||||
if (this.isPrivate()) {
|
if (this.isPrivate()) {
|
||||||
return this.get('verified') === this.verifiedEnum.VERIFIED;
|
return this.get('verified') === this.verifiedEnum.VERIFIED;
|
||||||
|
@ -926,12 +837,8 @@
|
||||||
this.id,
|
this.id,
|
||||||
{ limit: 1, MessageCollection: Whisper.MessageCollection }
|
{ limit: 1, MessageCollection: Whisper.MessageCollection }
|
||||||
);
|
);
|
||||||
if (!messages.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastMessageModel = messages.at(0);
|
const lastMessageModel = messages.at(0);
|
||||||
|
|
||||||
const lastMessageJSON = lastMessageModel
|
const lastMessageJSON = lastMessageModel
|
||||||
? lastMessageModel.toJSON()
|
? lastMessageModel.toJSON()
|
||||||
: null;
|
: null;
|
||||||
|
@ -968,7 +875,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateExpirationTimer(
|
async updateExpirationTimer(
|
||||||
providedExpireTimer,
|
providedExpireTimer,
|
||||||
providedSource,
|
providedSource,
|
||||||
receivedAt,
|
receivedAt,
|
||||||
|
@ -1024,12 +931,13 @@
|
||||||
message.set({ recipients: this.getRecipients() });
|
message.set({ recipients: this.getRecipients() });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all([
|
const id = await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
window.Signal.Data.saveMessage(message.attributes, {
|
|
||||||
Message: Whisper.Message,
|
Message: Whisper.Message,
|
||||||
}),
|
});
|
||||||
wrapDeferred(this.save({ expireTimer })),
|
message.set({ id });
|
||||||
]).then(() => {
|
|
||||||
|
await wrapDeferred(this.save({ expireTimer }));
|
||||||
|
|
||||||
// if change was made remotely, don't send it to the number/group
|
// if change was made remotely, don't send it to the number/group
|
||||||
if (receivedAt) {
|
if (receivedAt) {
|
||||||
return message;
|
return message;
|
||||||
|
@ -1052,18 +960,19 @@
|
||||||
profileKey
|
profileKey
|
||||||
);
|
);
|
||||||
|
|
||||||
return message.send(promise).then(() => message);
|
await message.send(promise);
|
||||||
});
|
|
||||||
|
return message;
|
||||||
},
|
},
|
||||||
|
|
||||||
isSearchable() {
|
isSearchable() {
|
||||||
return !this.get('left') || !!this.get('lastMessage');
|
return !this.get('left') || !!this.get('lastMessage');
|
||||||
},
|
},
|
||||||
|
|
||||||
endSession() {
|
async endSession() {
|
||||||
if (this.isPrivate()) {
|
if (this.isPrivate()) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const message = this.messageCollection.create({
|
const message = this.messageCollection.add({
|
||||||
conversationId: this.id,
|
conversationId: this.id,
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
sent_at: now,
|
sent_at: now,
|
||||||
|
@ -1072,11 +981,17 @@
|
||||||
recipients: this.getRecipients(),
|
recipients: this.getRecipients(),
|
||||||
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
|
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const id = await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
|
Message: Whisper.Message,
|
||||||
|
});
|
||||||
|
message.set({ id });
|
||||||
|
|
||||||
message.send(textsecure.messaging.resetSession(this.id, now));
|
message.send(textsecure.messaging.resetSession(this.id, now));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateGroup(providedGroupUpdate) {
|
async updateGroup(providedGroupUpdate) {
|
||||||
let groupUpdate = providedGroupUpdate;
|
let groupUpdate = providedGroupUpdate;
|
||||||
|
|
||||||
if (this.isPrivate()) {
|
if (this.isPrivate()) {
|
||||||
|
@ -1086,13 +1001,19 @@
|
||||||
groupUpdate = this.pick(['name', 'avatar', 'members']);
|
groupUpdate = this.pick(['name', 'avatar', 'members']);
|
||||||
}
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const message = this.messageCollection.create({
|
const message = this.messageCollection.add({
|
||||||
conversationId: this.id,
|
conversationId: this.id,
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
sent_at: now,
|
sent_at: now,
|
||||||
received_at: now,
|
received_at: now,
|
||||||
group_update: groupUpdate,
|
group_update: groupUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const id = await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
|
Message: Whisper.Message,
|
||||||
|
});
|
||||||
|
message.set({ id });
|
||||||
|
|
||||||
message.send(
|
message.send(
|
||||||
textsecure.messaging.updateGroup(
|
textsecure.messaging.updateGroup(
|
||||||
this.id,
|
this.id,
|
||||||
|
@ -1103,17 +1024,23 @@
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
leaveGroup() {
|
async leaveGroup() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.get('type') === 'group') {
|
if (this.get('type') === 'group') {
|
||||||
this.save({ left: true });
|
this.save({ left: true });
|
||||||
const message = this.messageCollection.create({
|
const message = this.messageCollection.add({
|
||||||
group_update: { left: 'You' },
|
group_update: { left: 'You' },
|
||||||
conversationId: this.id,
|
conversationId: this.id,
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
sent_at: now,
|
sent_at: now,
|
||||||
received_at: now,
|
received_at: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const id = await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
|
Message: Whisper.Message,
|
||||||
|
});
|
||||||
|
message.set({ id });
|
||||||
|
|
||||||
message.send(textsecure.messaging.leaveGroup(this.id));
|
message.send(textsecure.messaging.leaveGroup(this.id));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -22,7 +22,9 @@
|
||||||
const {
|
const {
|
||||||
deleteExternalMessageFiles,
|
deleteExternalMessageFiles,
|
||||||
getAbsoluteAttachmentPath,
|
getAbsoluteAttachmentPath,
|
||||||
} = Signal.Migrations;
|
loadAttachmentData,
|
||||||
|
loadQuoteData,
|
||||||
|
} = window.Signal.Migrations;
|
||||||
|
|
||||||
window.AccountCache = Object.create(null);
|
window.AccountCache = Object.create(null);
|
||||||
window.AccountJobs = Object.create(null);
|
window.AccountJobs = Object.create(null);
|
||||||
|
@ -54,8 +56,6 @@
|
||||||
window.hasSignalAccount = number => window.AccountCache[number];
|
window.hasSignalAccount = number => window.AccountCache[number];
|
||||||
|
|
||||||
window.Whisper.Message = Backbone.Model.extend({
|
window.Whisper.Message = Backbone.Model.extend({
|
||||||
database: Whisper.Database,
|
|
||||||
storeName: 'messages',
|
|
||||||
initialize(attributes) {
|
initialize(attributes) {
|
||||||
if (_.isObject(attributes)) {
|
if (_.isObject(attributes)) {
|
||||||
this.set(
|
this.set(
|
||||||
|
@ -211,9 +211,11 @@
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
onDestroy() {
|
onDestroy() {
|
||||||
|
this.cleanup();
|
||||||
|
},
|
||||||
|
async cleanup() {
|
||||||
this.unload();
|
this.unload();
|
||||||
|
await deleteExternalMessageFiles(this.attributes);
|
||||||
return deleteExternalMessageFiles(this.attributes);
|
|
||||||
},
|
},
|
||||||
unload() {
|
unload() {
|
||||||
if (this.quotedMessage) {
|
if (this.quotedMessage) {
|
||||||
|
@ -269,16 +271,16 @@
|
||||||
disabled,
|
disabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (source === this.OUR_NUMBER) {
|
if (fromSync) {
|
||||||
return {
|
|
||||||
...basicProps,
|
|
||||||
type: 'fromMe',
|
|
||||||
};
|
|
||||||
} else if (fromSync) {
|
|
||||||
return {
|
return {
|
||||||
...basicProps,
|
...basicProps,
|
||||||
type: 'fromSync',
|
type: 'fromSync',
|
||||||
};
|
};
|
||||||
|
} else if (source === this.OUR_NUMBER) {
|
||||||
|
return {
|
||||||
|
...basicProps,
|
||||||
|
type: 'fromMe',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return basicProps;
|
return basicProps;
|
||||||
|
@ -416,7 +418,7 @@
|
||||||
|
|
||||||
const authorColor = contactModel ? contactModel.getColor() : null;
|
const authorColor = contactModel ? contactModel.getColor() : null;
|
||||||
const authorAvatar = contactModel ? contactModel.getAvatar() : null;
|
const authorAvatar = contactModel ? contactModel.getAvatar() : null;
|
||||||
const authorAvatarPath = authorAvatar.url;
|
const authorAvatarPath = authorAvatar ? authorAvatar.url : null;
|
||||||
|
|
||||||
const expirationLength = this.get('expireTimer') * 1000;
|
const expirationLength = this.get('expireTimer') * 1000;
|
||||||
const expireTimerStart = this.get('expirationStartTimestamp');
|
const expireTimerStart = this.get('expirationStartTimestamp');
|
||||||
|
@ -654,15 +656,117 @@
|
||||||
contacts: sortedContacts,
|
contacts: sortedContacts,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
retrySend() {
|
|
||||||
const retries = _.filter(
|
// One caller today: event handler for the 'Retry Send' entry in triple-dot menu
|
||||||
|
async retrySend() {
|
||||||
|
const [retries, errors] = _.partition(
|
||||||
this.get('errors'),
|
this.get('errors'),
|
||||||
this.isReplayableError.bind(this)
|
this.isReplayableError.bind(this)
|
||||||
);
|
);
|
||||||
_.map(retries, 'number').forEach(number => {
|
|
||||||
this.resend(number);
|
// Remove the errors that aren't replayable
|
||||||
});
|
this.set({ errors });
|
||||||
|
|
||||||
|
const profileKey = null;
|
||||||
|
const numbers = retries.map(retry => retry.number);
|
||||||
|
|
||||||
|
if (!numbers.length) {
|
||||||
|
window.log.error(
|
||||||
|
'retrySend: Attempted to retry, but no numbers to send to!'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentsWithData = await Promise.all(
|
||||||
|
(this.get('attachments') || []).map(loadAttachmentData)
|
||||||
|
);
|
||||||
|
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||||
|
|
||||||
|
const conversation = this.getConversation();
|
||||||
|
let promise;
|
||||||
|
|
||||||
|
if (conversation.isPrivate()) {
|
||||||
|
const [number] = numbers;
|
||||||
|
|
||||||
|
promise = textsecure.messaging.sendMessageToNumber(
|
||||||
|
number,
|
||||||
|
this.get('body'),
|
||||||
|
attachmentsWithData,
|
||||||
|
quoteWithData,
|
||||||
|
this.get('sent_at'),
|
||||||
|
this.get('expireTimer'),
|
||||||
|
profileKey
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Because this is a partial group send, we manually construct the request like
|
||||||
|
// sendMessageToGroup does.
|
||||||
|
promise = textsecure.messaging.sendMessage({
|
||||||
|
recipients: numbers,
|
||||||
|
body: this.get('body'),
|
||||||
|
timestamp: this.get('sent_at'),
|
||||||
|
attachments: attachmentsWithData,
|
||||||
|
quote: quoteWithData,
|
||||||
|
needsSync: !this.get('synced'),
|
||||||
|
expireTimer: this.get('expireTimer'),
|
||||||
|
profileKey,
|
||||||
|
group: {
|
||||||
|
id: this.get('conversationId'),
|
||||||
|
type: textsecure.protobuf.GroupContext.Type.DELIVER,
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.send(promise);
|
||||||
|
},
|
||||||
|
isReplayableError(e) {
|
||||||
|
return (
|
||||||
|
e.name === 'MessageError' ||
|
||||||
|
e.name === 'OutgoingMessageError' ||
|
||||||
|
e.name === 'SendMessageNetworkError' ||
|
||||||
|
e.name === 'SignedPreKeyRotationError' ||
|
||||||
|
e.name === 'OutgoingIdentityKeyError'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Called when the user ran into an error with a specific user, wants to send to them
|
||||||
|
// One caller today: ConversationView.forceSend()
|
||||||
|
async resend(number) {
|
||||||
|
const error = this.removeOutgoingErrors(number);
|
||||||
|
if (error) {
|
||||||
|
const profileKey = null;
|
||||||
|
const attachmentsWithData = await Promise.all(
|
||||||
|
(this.get('attachments') || []).map(loadAttachmentData)
|
||||||
|
);
|
||||||
|
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||||
|
|
||||||
|
const promise = textsecure.messaging.sendMessageToNumber(
|
||||||
|
number,
|
||||||
|
this.get('body'),
|
||||||
|
attachmentsWithData,
|
||||||
|
quoteWithData,
|
||||||
|
this.get('sent_at'),
|
||||||
|
this.get('expireTimer'),
|
||||||
|
profileKey
|
||||||
|
);
|
||||||
|
|
||||||
|
this.send(promise);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeOutgoingErrors(number) {
|
||||||
|
const errors = _.partition(
|
||||||
|
this.get('errors'),
|
||||||
|
e =>
|
||||||
|
e.number === number &&
|
||||||
|
(e.name === 'MessageError' ||
|
||||||
|
e.name === 'OutgoingMessageError' ||
|
||||||
|
e.name === 'SendMessageNetworkError' ||
|
||||||
|
e.name === 'SignedPreKeyRotationError' ||
|
||||||
|
e.name === 'OutgoingIdentityKeyError')
|
||||||
|
);
|
||||||
|
this.set({ errors: errors[1] });
|
||||||
|
return errors[0][0];
|
||||||
|
},
|
||||||
|
|
||||||
getConversation() {
|
getConversation() {
|
||||||
// This needs to be an unsafe call, because this method is called during
|
// 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
|
// initial module setup. We may be in the middle of the initial fetch to
|
||||||
|
@ -720,9 +824,12 @@
|
||||||
.then(async result => {
|
.then(async result => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.trigger('done');
|
this.trigger('done');
|
||||||
|
|
||||||
|
// This is used by sendSyncMessage, then set to null
|
||||||
if (result.dataMessage) {
|
if (result.dataMessage) {
|
||||||
this.set({ dataMessage: result.dataMessage });
|
this.set({ dataMessage: result.dataMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sentTo = this.get('sent_to') || [];
|
const sentTo = this.get('sent_to') || [];
|
||||||
this.set({
|
this.set({
|
||||||
sent_to: _.union(sentTo, result.successfulNumbers),
|
sent_to: _.union(sentTo, result.successfulNumbers),
|
||||||
|
@ -739,6 +846,7 @@
|
||||||
.catch(result => {
|
.catch(result => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
this.trigger('done');
|
this.trigger('done');
|
||||||
|
|
||||||
if (result.dataMessage) {
|
if (result.dataMessage) {
|
||||||
this.set({ dataMessage: result.dataMessage });
|
this.set({ dataMessage: result.dataMessage });
|
||||||
}
|
}
|
||||||
|
@ -774,9 +882,9 @@
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all(promises).then(() => {
|
|
||||||
this.trigger('send-error', this.get('errors'));
|
this.trigger('send-error', this.get('errors'));
|
||||||
});
|
|
||||||
|
return Promise.all(promises);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -855,7 +963,6 @@
|
||||||
Message: Whisper.Message,
|
Message: Whisper.Message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
hasNetworkError() {
|
hasNetworkError() {
|
||||||
const error = _.find(
|
const error = _.find(
|
||||||
this.get('errors'),
|
this.get('errors'),
|
||||||
|
@ -867,36 +974,6 @@
|
||||||
);
|
);
|
||||||
return !!error;
|
return !!error;
|
||||||
},
|
},
|
||||||
removeOutgoingErrors(number) {
|
|
||||||
const errors = _.partition(
|
|
||||||
this.get('errors'),
|
|
||||||
e =>
|
|
||||||
e.number === number &&
|
|
||||||
(e.name === 'MessageError' ||
|
|
||||||
e.name === 'OutgoingMessageError' ||
|
|
||||||
e.name === 'SendMessageNetworkError' ||
|
|
||||||
e.name === 'SignedPreKeyRotationError' ||
|
|
||||||
e.name === 'OutgoingIdentityKeyError')
|
|
||||||
);
|
|
||||||
this.set({ errors: errors[1] });
|
|
||||||
return errors[0][0];
|
|
||||||
},
|
|
||||||
isReplayableError(e) {
|
|
||||||
return (
|
|
||||||
e.name === 'MessageError' ||
|
|
||||||
e.name === 'OutgoingMessageError' ||
|
|
||||||
e.name === 'SendMessageNetworkError' ||
|
|
||||||
e.name === 'SignedPreKeyRotationError' ||
|
|
||||||
e.name === 'OutgoingIdentityKeyError'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
resend(number) {
|
|
||||||
const error = this.removeOutgoingErrors(number);
|
|
||||||
if (error) {
|
|
||||||
const promise = new textsecure.ReplayableError(error).replay();
|
|
||||||
this.send(promise);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleDataMessage(dataMessage, confirm) {
|
handleDataMessage(dataMessage, confirm) {
|
||||||
// This function is called from the background script in a few scenarios:
|
// This function is called from the background script in a few scenarios:
|
||||||
// 1. on an incoming message
|
// 1. on an incoming message
|
||||||
|
@ -1217,10 +1294,12 @@
|
||||||
const expiresAt = start + delta;
|
const expiresAt = start + delta;
|
||||||
|
|
||||||
this.set({ expires_at: expiresAt });
|
this.set({ expires_at: expiresAt });
|
||||||
const id = await window.Signal.Data.saveMessage(this.attributes, {
|
const id = this.get('id');
|
||||||
|
if (id) {
|
||||||
|
await window.Signal.Data.saveMessage(this.attributes, {
|
||||||
Message: Whisper.Message,
|
Message: Whisper.Message,
|
||||||
});
|
});
|
||||||
this.set({ id });
|
}
|
||||||
|
|
||||||
Whisper.ExpiringMessagesListener.update();
|
Whisper.ExpiringMessagesListener.update();
|
||||||
window.log.info('Set message expiration', {
|
window.log.info('Set message expiration', {
|
||||||
|
@ -1233,6 +1312,7 @@
|
||||||
|
|
||||||
Whisper.MessageCollection = Backbone.Collection.extend({
|
Whisper.MessageCollection = Backbone.Collection.extend({
|
||||||
model: Whisper.Message,
|
model: Whisper.Message,
|
||||||
|
// Keeping this for legacy upgrade pre-migrate to SQLCipher
|
||||||
database: Whisper.Database,
|
database: Whisper.Database,
|
||||||
storeName: 'messages',
|
storeName: 'messages',
|
||||||
comparator(left, right) {
|
comparator(left, right) {
|
||||||
|
@ -1282,7 +1362,15 @@
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.add(messages.models);
|
const models = messages.filter(message => Boolean(message.id));
|
||||||
|
const eliminated = messages.length - models.length;
|
||||||
|
if (eliminated > 0) {
|
||||||
|
window.log.warn(
|
||||||
|
`fetchConversation: Eliminated ${eliminated} messages without an id`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.add(models);
|
||||||
|
|
||||||
if (unreadCount <= 0) {
|
if (unreadCount <= 0) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
const { map, fromPairs } = require('lodash');
|
||||||
const tmp = require('tmp');
|
const tmp = require('tmp');
|
||||||
const pify = require('pify');
|
const pify = require('pify');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
|
@ -1140,12 +1141,13 @@ function getMessageKey(message) {
|
||||||
const sourceDevice = message.sourceDevice || 1;
|
const sourceDevice = message.sourceDevice || 1;
|
||||||
return `${source}.${sourceDevice} ${message.timestamp}`;
|
return `${source}.${sourceDevice} ${message.timestamp}`;
|
||||||
}
|
}
|
||||||
function loadMessagesLookup(db) {
|
async function loadMessagesLookup(db) {
|
||||||
return window.Signal.Data.getAllMessageIds({
|
const array = await window.Signal.Data.getAllMessageIds({
|
||||||
db,
|
db,
|
||||||
getMessageKey,
|
getMessageKey,
|
||||||
handleDOMException: Whisper.Database.handleDOMException,
|
handleDOMException: Whisper.Database.handleDOMException,
|
||||||
});
|
});
|
||||||
|
return fromPairs(map(array, item => [item, true]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConversationKey(conversation) {
|
function getConversationKey(conversation) {
|
||||||
|
|
|
@ -1,75 +1,233 @@
|
||||||
/* global window */
|
/* global window, setTimeout */
|
||||||
|
|
||||||
|
const electron = require('electron');
|
||||||
|
const { forEach, isFunction, isObject } = require('lodash');
|
||||||
|
|
||||||
const { deferredToPromise } = require('./deferred_to_promise');
|
const { deferredToPromise } = require('./deferred_to_promise');
|
||||||
const MessageType = require('./types/message');
|
const MessageType = require('./types/message');
|
||||||
|
|
||||||
// calls to search for:
|
const { ipcRenderer } = electron;
|
||||||
|
|
||||||
|
// We listen to a lot of events on ipcRenderer, often on the same channel. This prevents
|
||||||
|
// any warnings that might be sent to the console in that case.
|
||||||
|
ipcRenderer.setMaxListeners(0);
|
||||||
|
|
||||||
|
// calls to search for when finding functions to convert:
|
||||||
// .fetch(
|
// .fetch(
|
||||||
// .save(
|
// .save(
|
||||||
// .destroy(
|
// .destroy(
|
||||||
|
|
||||||
async function saveMessage(data, { Message }) {
|
const SQL_CHANNEL_KEY = 'sql-channel';
|
||||||
const message = new Message(data);
|
const ERASE_SQL_KEY = 'erase-sql-key';
|
||||||
await deferredToPromise(message.save());
|
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
|
||||||
return message.id;
|
|
||||||
|
const _jobs = Object.create(null);
|
||||||
|
const _DEBUG = false;
|
||||||
|
let _jobCounter = 0;
|
||||||
|
|
||||||
|
const channels = {};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
_jobs,
|
||||||
|
_cleanData,
|
||||||
|
|
||||||
|
close,
|
||||||
|
removeDB,
|
||||||
|
|
||||||
|
saveMessage,
|
||||||
|
saveMessages,
|
||||||
|
removeMessage,
|
||||||
|
getUnreadByConversation,
|
||||||
|
|
||||||
|
removeAllMessagesInConversation,
|
||||||
|
|
||||||
|
getMessageBySender,
|
||||||
|
getMessageById,
|
||||||
|
getAllMessageIds,
|
||||||
|
getMessagesBySentAt,
|
||||||
|
getExpiredMessages,
|
||||||
|
getNextExpiringMessage,
|
||||||
|
getMessagesByConversation,
|
||||||
|
|
||||||
|
getAllUnprocessed,
|
||||||
|
getUnprocessedById,
|
||||||
|
saveUnprocessed,
|
||||||
|
saveUnprocesseds,
|
||||||
|
updateUnprocessed,
|
||||||
|
removeUnprocessed,
|
||||||
|
removeAllUnprocessed,
|
||||||
|
|
||||||
|
removeAll,
|
||||||
|
removeOtherData,
|
||||||
|
|
||||||
|
// Returning plain JSON
|
||||||
|
getMessagesNeedingUpgrade,
|
||||||
|
getLegacyMessagesNeedingUpgrade,
|
||||||
|
getMessagesWithVisualMediaAttachments,
|
||||||
|
getMessagesWithFileAttachments,
|
||||||
|
};
|
||||||
|
|
||||||
|
// When IPC arguments are prepared for the cross-process send, they are JSON.stringified.
|
||||||
|
// We can't send ArrayBuffers or BigNumbers (what we get from proto library for dates).
|
||||||
|
function _cleanData(data) {
|
||||||
|
const keys = Object.keys(data);
|
||||||
|
for (let index = 0, max = keys.length; index < max; index += 1) {
|
||||||
|
const key = keys[index];
|
||||||
|
const value = data[key];
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFunction(value.toNumber)) {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
data[key] = value.toNumber();
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
data[key] = value.map(item => _cleanData(item));
|
||||||
|
} else if (isObject(value)) {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
data[key] = _cleanData(value);
|
||||||
|
} else if (
|
||||||
|
typeof value !== 'string' &&
|
||||||
|
typeof value !== 'number' &&
|
||||||
|
typeof value !== 'boolean'
|
||||||
|
) {
|
||||||
|
window.log.info(`_cleanData: key ${key} had type ${typeof value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _makeJob(fnName) {
|
||||||
|
_jobCounter += 1;
|
||||||
|
const id = _jobCounter;
|
||||||
|
|
||||||
|
_jobs[id] = {
|
||||||
|
fnName,
|
||||||
|
};
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateJob(id, data) {
|
||||||
|
const { resolve, reject } = data;
|
||||||
|
|
||||||
|
_jobs[id] = {
|
||||||
|
..._jobs[id],
|
||||||
|
...data,
|
||||||
|
resolve: value => {
|
||||||
|
_removeJob(id);
|
||||||
|
return resolve(value);
|
||||||
|
},
|
||||||
|
reject: error => {
|
||||||
|
_removeJob(id);
|
||||||
|
return reject(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _removeJob(id) {
|
||||||
|
if (_DEBUG) {
|
||||||
|
_jobs[id].complete = true;
|
||||||
|
} else {
|
||||||
|
delete _jobs[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getJob(id) {
|
||||||
|
return _jobs[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcRenderer.on(
|
||||||
|
`${SQL_CHANNEL_KEY}-done`,
|
||||||
|
(event, jobId, errorForDisplay, result) => {
|
||||||
|
const job = _getJob(jobId);
|
||||||
|
if (!job) {
|
||||||
|
throw new Error(
|
||||||
|
`Received job reply to job ${jobId}, but did not have it in our registry!`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resolve, reject, fnName } = job;
|
||||||
|
|
||||||
|
if (errorForDisplay) {
|
||||||
|
return reject(
|
||||||
|
new Error(`Error calling channel ${fnName}: ${errorForDisplay}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function makeChannel(fnName) {
|
||||||
|
channels[fnName] = (...args) => {
|
||||||
|
const jobId = _makeJob(fnName);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ipcRenderer.send(SQL_CHANNEL_KEY, jobId, fnName, ...args);
|
||||||
|
|
||||||
|
_updateJob(jobId, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
args: _DEBUG ? args : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(
|
||||||
|
() => resolve(new Error(`Request to ${fnName} timed out`)),
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(module.exports, fn => {
|
||||||
|
if (isFunction(fn)) {
|
||||||
|
makeChannel(fn.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: will need to restart the app after calling this, to set up afresh
|
||||||
|
async function close() {
|
||||||
|
await channels.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: will need to restart the app after calling this, to set up afresh
|
||||||
|
async function removeDB() {
|
||||||
|
await channels.removeDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMessage(data, { forceSave } = {}) {
|
||||||
|
const id = await channels.saveMessage(_cleanData(data), { forceSave });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMessages(arrayOfMessages, { forceSave } = {}) {
|
||||||
|
await channels.saveMessages(_cleanData(arrayOfMessages), { forceSave });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMessage(id, { Message }) {
|
async function removeMessage(id, { Message }) {
|
||||||
const message = await getMessageById(id, { Message });
|
const message = await getMessageById(id, { Message });
|
||||||
|
|
||||||
// Note: It's important to have a fully database-hydrated model to delete here because
|
// Note: It's important to have a fully database-hydrated model to delete here because
|
||||||
// it needs to delete all associated on-disk files along with the database delete.
|
// it needs to delete all associated on-disk files along with the database delete.
|
||||||
if (message) {
|
if (message) {
|
||||||
await deferredToPromise(message.destroy());
|
await channels.removeMessage(id);
|
||||||
|
const model = new Message(message);
|
||||||
|
await model.cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMessageById(id, { Message }) {
|
async function getMessageById(id, { Message }) {
|
||||||
const message = new Message({ id });
|
const message = await channels.getMessageById(id);
|
||||||
try {
|
return new Message(message);
|
||||||
await deferredToPromise(message.fetch());
|
|
||||||
return message;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAllMessageIds({ db, handleDOMException, getMessageKey }) {
|
async function getAllMessageIds() {
|
||||||
const lookup = Object.create(null);
|
const ids = await channels.getAllMessageIds();
|
||||||
const storeName = 'messages';
|
return ids;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const transaction = db.transaction(storeName, 'readwrite');
|
|
||||||
transaction.onerror = () => {
|
|
||||||
handleDOMException(
|
|
||||||
`assembleLookup(${storeName}) transaction error`,
|
|
||||||
transaction.error,
|
|
||||||
reject
|
|
||||||
);
|
|
||||||
};
|
|
||||||
transaction.oncomplete = () => {
|
|
||||||
// not really very useful - fires at unexpected times
|
|
||||||
};
|
|
||||||
|
|
||||||
const store = transaction.objectStore(storeName);
|
|
||||||
const request = store.openCursor();
|
|
||||||
request.onerror = () => {
|
|
||||||
handleDOMException(
|
|
||||||
`assembleLookup(${storeName}) request error`,
|
|
||||||
request.error,
|
|
||||||
reject
|
|
||||||
);
|
|
||||||
};
|
|
||||||
request.onsuccess = event => {
|
|
||||||
const cursor = event.target.result;
|
|
||||||
if (cursor && cursor.value) {
|
|
||||||
lookup[getMessageKey(cursor.value)] = true;
|
|
||||||
cursor.continue();
|
|
||||||
} else {
|
|
||||||
window.log.info(`Done creating ${storeName} lookup`);
|
|
||||||
resolve(lookup);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMessageBySender(
|
async function getMessageBySender(
|
||||||
|
@ -77,186 +235,155 @@ async function getMessageBySender(
|
||||||
{ source, sourceDevice, sent_at },
|
{ source, sourceDevice, sent_at },
|
||||||
{ Message }
|
{ Message }
|
||||||
) {
|
) {
|
||||||
const fetcher = new Message();
|
const messages = await channels.getMessageBySender({
|
||||||
const options = {
|
source,
|
||||||
index: {
|
sourceDevice,
|
||||||
name: 'unique',
|
sent_at,
|
||||||
// eslint-disable-next-line camelcase
|
});
|
||||||
value: [source, sourceDevice, sent_at],
|
if (!messages || !messages.length) {
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deferredToPromise(fetcher.fetch(options));
|
|
||||||
if (fetcher.get('id')) {
|
|
||||||
return fetcher;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new Message(messages[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUnreadByConversation(conversationId, { MessageCollection }) {
|
async function getUnreadByConversation(conversationId, { MessageCollection }) {
|
||||||
const messages = new MessageCollection();
|
const messages = await channels.getUnreadByConversation(conversationId);
|
||||||
|
return new MessageCollection(messages);
|
||||||
await deferredToPromise(
|
|
||||||
messages.fetch({
|
|
||||||
index: {
|
|
||||||
// 'unread' index
|
|
||||||
name: 'unread',
|
|
||||||
lower: [conversationId],
|
|
||||||
upper: [conversationId, Number.MAX_VALUE],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMessagesByConversation(
|
async function getMessagesByConversation(
|
||||||
conversationId,
|
conversationId,
|
||||||
{ limit = 100, receivedAt = Number.MAX_VALUE, MessageCollection }
|
{ limit = 100, receivedAt = Number.MAX_VALUE, MessageCollection }
|
||||||
) {
|
) {
|
||||||
const messages = new MessageCollection();
|
const messages = await channels.getMessagesByConversation(conversationId, {
|
||||||
|
|
||||||
const options = {
|
|
||||||
limit,
|
limit,
|
||||||
index: {
|
receivedAt,
|
||||||
// 'conversation' index on [conversationId, received_at]
|
});
|
||||||
name: 'conversation',
|
|
||||||
lower: [conversationId],
|
|
||||||
upper: [conversationId, receivedAt],
|
|
||||||
order: 'desc',
|
|
||||||
// SELECT messages WHERE conversationId = this.id ORDER
|
|
||||||
// received_at DESC
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await deferredToPromise(messages.fetch(options));
|
|
||||||
|
|
||||||
return messages;
|
return new MessageCollection(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeAllMessagesInConversation(
|
async function removeAllMessagesInConversation(
|
||||||
conversationId,
|
conversationId,
|
||||||
{ MessageCollection }
|
{ MessageCollection }
|
||||||
) {
|
) {
|
||||||
const messages = new MessageCollection();
|
let messages;
|
||||||
|
|
||||||
let loaded;
|
|
||||||
do {
|
do {
|
||||||
// Yes, we really want the await in the loop. We're deleting 100 at a
|
// Yes, we really want the await in the loop. We're deleting 100 at a
|
||||||
// time so we don't use too much memory.
|
// time so we don't use too much memory.
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await deferredToPromise(
|
messages = await getMessagesByConversation(conversationId, {
|
||||||
messages.fetch({
|
|
||||||
limit: 100,
|
limit: 100,
|
||||||
index: {
|
MessageCollection,
|
||||||
// 'conversation' index on [conversationId, received_at]
|
});
|
||||||
name: 'conversation',
|
|
||||||
lower: [conversationId],
|
|
||||||
upper: [conversationId, Number.MAX_VALUE],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
loaded = messages.models;
|
if (!messages.length) {
|
||||||
messages.reset([]);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = messages.map(message => message.id);
|
||||||
|
|
||||||
// Note: It's very important that these models are fully hydrated because
|
// Note: It's very important that these models are fully hydrated because
|
||||||
// we need to delete all associated on-disk files along with the database delete.
|
// we need to delete all associated on-disk files along with the database delete.
|
||||||
loaded.map(message => message.destroy());
|
// eslint-disable-next-line no-await-in-loop
|
||||||
} while (loaded.length > 0);
|
await Promise.all(messages.map(message => message.cleanup()));
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await channels.removeMessage(ids);
|
||||||
|
} while (messages.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMessagesBySentAt(sentAt, { MessageCollection }) {
|
async function getMessagesBySentAt(sentAt, { MessageCollection }) {
|
||||||
const messages = new MessageCollection();
|
const messages = await channels.getMessagesBySentAt(sentAt);
|
||||||
|
return new MessageCollection(messages);
|
||||||
await deferredToPromise(
|
|
||||||
messages.fetch({
|
|
||||||
index: {
|
|
||||||
// 'receipt' index on sent_at
|
|
||||||
name: 'receipt',
|
|
||||||
only: sentAt,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getExpiredMessages({ MessageCollection }) {
|
async function getExpiredMessages({ MessageCollection }) {
|
||||||
window.log.info('Load expired messages');
|
window.log.info('Load expired messages');
|
||||||
const messages = new MessageCollection();
|
const messages = await channels.getExpiredMessages();
|
||||||
|
return new MessageCollection(messages);
|
||||||
await deferredToPromise(
|
|
||||||
messages.fetch({
|
|
||||||
conditions: {
|
|
||||||
expires_at: {
|
|
||||||
$lte: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getNextExpiringMessage({ MessageCollection }) {
|
async function getNextExpiringMessage({ MessageCollection }) {
|
||||||
const messages = new MessageCollection();
|
const messages = await channels.getNextExpiringMessage();
|
||||||
|
return new MessageCollection(messages);
|
||||||
await deferredToPromise(
|
|
||||||
messages.fetch({
|
|
||||||
limit: 1,
|
|
||||||
index: {
|
|
||||||
name: 'expires_at',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveUnprocessed(data, { Unprocessed }) {
|
async function getAllUnprocessed() {
|
||||||
const unprocessed = new Unprocessed(data);
|
return channels.getAllUnprocessed();
|
||||||
return deferredToPromise(unprocessed.save());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAllUnprocessed({ UnprocessedCollection }) {
|
async function getUnprocessedById(id, { Unprocessed }) {
|
||||||
const collection = new UnprocessedCollection();
|
const unprocessed = await channels.getUnprocessedById(id);
|
||||||
await deferredToPromise(collection.fetch());
|
return new Unprocessed(unprocessed);
|
||||||
return collection.map(model => model.attributes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateUnprocessed(id, updates, { Unprocessed }) {
|
async function saveUnprocessed(data, { forceSave } = {}) {
|
||||||
const unprocessed = new Unprocessed({
|
const id = await channels.saveUnprocessed(_cleanData(data), { forceSave });
|
||||||
id,
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUnprocesseds(arrayOfUnprocessed, { forceSave } = {}) {
|
||||||
|
await channels.saveUnprocesseds(_cleanData(arrayOfUnprocessed), {
|
||||||
|
forceSave,
|
||||||
});
|
});
|
||||||
|
|
||||||
await deferredToPromise(unprocessed.fetch());
|
|
||||||
|
|
||||||
unprocessed.set(updates);
|
|
||||||
await saveUnprocessed(unprocessed.attributes, { Unprocessed });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeUnprocessed(id, { Unprocessed }) {
|
async function updateUnprocessed(id, updates) {
|
||||||
const unprocessed = new Unprocessed({
|
const existing = await channels.getUnprocessedById(id);
|
||||||
id,
|
if (!existing) {
|
||||||
});
|
throw new Error(`Unprocessed id ${id} does not exist in the database!`);
|
||||||
|
}
|
||||||
|
const toSave = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
|
||||||
await deferredToPromise(unprocessed.destroy());
|
await saveUnprocessed(toSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUnprocessed(id) {
|
||||||
|
await channels.removeUnprocessed(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeAllUnprocessed() {
|
async function removeAllUnprocessed() {
|
||||||
// erase everything in unprocessed table
|
await channels.removeAllUnprocessed();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeAll() {
|
async function removeAll() {
|
||||||
// erase everything in the database
|
await channels.removeAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMessagesNeedingUpgrade(
|
// Note: will need to restart the app after calling this, to set up afresh
|
||||||
|
async function removeOtherData() {
|
||||||
|
await Promise.all([
|
||||||
|
callChannel(ERASE_SQL_KEY),
|
||||||
|
callChannel(ERASE_ATTACHMENTS_KEY),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callChannel(name) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ipcRenderer.send(name);
|
||||||
|
ipcRenderer.once(`${name}-done`, (event, error) => {
|
||||||
|
if (error) {
|
||||||
|
return reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error(`callChannel call to ${name} timed out`)),
|
||||||
|
5000
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Functions below here return JSON
|
||||||
|
|
||||||
|
async function getLegacyMessagesNeedingUpgrade(
|
||||||
limit,
|
limit,
|
||||||
{ MessageCollection, maxVersion = MessageType.CURRENT_SCHEMA_VERSION }
|
{ MessageCollection, maxVersion = MessageType.CURRENT_SCHEMA_VERSION }
|
||||||
) {
|
) {
|
||||||
|
@ -278,75 +405,28 @@ async function getMessagesNeedingUpgrade(
|
||||||
return models.map(model => model.toJSON());
|
return models.map(model => model.toJSON());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getMessagesNeedingUpgrade(
|
||||||
|
limit,
|
||||||
|
{ maxVersion = MessageType.CURRENT_SCHEMA_VERSION }
|
||||||
|
) {
|
||||||
|
const messages = await channels.getMessagesNeedingUpgrade(limit, {
|
||||||
|
maxVersion,
|
||||||
|
});
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
async function getMessagesWithVisualMediaAttachments(
|
async function getMessagesWithVisualMediaAttachments(
|
||||||
conversationId,
|
conversationId,
|
||||||
{ limit, MessageCollection }
|
{ limit }
|
||||||
) {
|
) {
|
||||||
const messages = new MessageCollection();
|
return channels.getMessagesWithVisualMediaAttachments(conversationId, {
|
||||||
const lowerReceivedAt = 0;
|
|
||||||
const upperReceivedAt = Number.MAX_VALUE;
|
|
||||||
|
|
||||||
await deferredToPromise(
|
|
||||||
messages.fetch({
|
|
||||||
limit,
|
limit,
|
||||||
index: {
|
});
|
||||||
name: 'hasVisualMediaAttachments',
|
|
||||||
lower: [conversationId, lowerReceivedAt, 1],
|
|
||||||
upper: [conversationId, upperReceivedAt, 1],
|
|
||||||
order: 'desc',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return messages.models.map(model => model.toJSON());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMessagesWithFileAttachments(
|
async function getMessagesWithFileAttachments(conversationId, { limit }) {
|
||||||
conversationId,
|
return channels.getMessagesWithFileAttachments(conversationId, {
|
||||||
{ limit, MessageCollection }
|
|
||||||
) {
|
|
||||||
const messages = new MessageCollection();
|
|
||||||
const lowerReceivedAt = 0;
|
|
||||||
const upperReceivedAt = Number.MAX_VALUE;
|
|
||||||
|
|
||||||
await deferredToPromise(
|
|
||||||
messages.fetch({
|
|
||||||
limit,
|
limit,
|
||||||
index: {
|
});
|
||||||
name: 'hasFileAttachments',
|
|
||||||
lower: [conversationId, lowerReceivedAt, 1],
|
|
||||||
upper: [conversationId, upperReceivedAt, 1],
|
|
||||||
order: 'desc',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return messages.models.map(model => model.toJSON());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
saveMessage,
|
|
||||||
removeMessage,
|
|
||||||
getUnreadByConversation,
|
|
||||||
removeAllMessagesInConversation,
|
|
||||||
getMessageBySender,
|
|
||||||
getMessageById,
|
|
||||||
getAllMessageIds,
|
|
||||||
getMessagesBySentAt,
|
|
||||||
getExpiredMessages,
|
|
||||||
getNextExpiringMessage,
|
|
||||||
getMessagesByConversation,
|
|
||||||
|
|
||||||
getAllUnprocessed,
|
|
||||||
saveUnprocessed,
|
|
||||||
updateUnprocessed,
|
|
||||||
removeUnprocessed,
|
|
||||||
removeAllUnprocessed,
|
|
||||||
|
|
||||||
removeAll,
|
|
||||||
|
|
||||||
// Returning plain JSON
|
|
||||||
getMessagesNeedingUpgrade,
|
|
||||||
getMessagesWithVisualMediaAttachments,
|
|
||||||
getMessagesWithFileAttachments,
|
|
||||||
};
|
|
||||||
|
|
167
js/modules/migrate_to_sql.js
Normal file
167
js/modules/migrate_to_sql.js
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
/* global window, IDBKeyRange */
|
||||||
|
|
||||||
|
const { includes, isFunction, isString, last } = require('lodash');
|
||||||
|
const { saveMessages, saveUnprocesseds } = require('./data');
|
||||||
|
const {
|
||||||
|
getMessageExportLastIndex,
|
||||||
|
setMessageExportLastIndex,
|
||||||
|
getUnprocessedExportLastIndex,
|
||||||
|
setUnprocessedExportLastIndex,
|
||||||
|
} = require('./settings');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
migrateToSQL,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function migrateToSQL({ db, clearStores, handleDOMException }) {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('Need db for IndexedDB connection!');
|
||||||
|
}
|
||||||
|
if (!isFunction(clearStores)) {
|
||||||
|
throw new Error('Need clearStores function!');
|
||||||
|
}
|
||||||
|
if (!isFunction(handleDOMException)) {
|
||||||
|
throw new Error('Need handleDOMException function!');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.info('migrateToSQL: start');
|
||||||
|
|
||||||
|
let lastIndex = await getMessageExportLastIndex(db);
|
||||||
|
let complete = false;
|
||||||
|
|
||||||
|
while (!complete) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const status = await migrateStoreToSQLite({
|
||||||
|
db,
|
||||||
|
save: saveMessages,
|
||||||
|
storeName: 'messages',
|
||||||
|
handleDOMException,
|
||||||
|
lastIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
({ complete, lastIndex } = status);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await setMessageExportLastIndex(db, lastIndex);
|
||||||
|
}
|
||||||
|
window.log.info('migrateToSQL: migrate of messages complete');
|
||||||
|
|
||||||
|
lastIndex = await getUnprocessedExportLastIndex(db);
|
||||||
|
complete = false;
|
||||||
|
|
||||||
|
while (!complete) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const status = await migrateStoreToSQLite({
|
||||||
|
db,
|
||||||
|
save: saveUnprocesseds,
|
||||||
|
storeName: 'unprocessed',
|
||||||
|
handleDOMException,
|
||||||
|
lastIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
({ complete, lastIndex } = status);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await setUnprocessedExportLastIndex(db, lastIndex);
|
||||||
|
}
|
||||||
|
window.log.info('migrateToSQL: migrate of unprocessed complete');
|
||||||
|
|
||||||
|
await clearStores(['messages', 'unprocessed']);
|
||||||
|
|
||||||
|
window.log.info('migrateToSQL: complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateStoreToSQLite({
|
||||||
|
db,
|
||||||
|
save,
|
||||||
|
storeName,
|
||||||
|
handleDOMException,
|
||||||
|
lastIndex = null,
|
||||||
|
batchSize = 20,
|
||||||
|
}) {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('Need db for IndexedDB connection!');
|
||||||
|
}
|
||||||
|
if (!isFunction(save)) {
|
||||||
|
throw new Error('Need save function!');
|
||||||
|
}
|
||||||
|
if (!isString(storeName)) {
|
||||||
|
throw new Error('Need storeName!');
|
||||||
|
}
|
||||||
|
if (!isFunction(handleDOMException)) {
|
||||||
|
throw new Error('Need handleDOMException for error handling!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!includes(db.objectStoreNames, storeName)) {
|
||||||
|
return {
|
||||||
|
complete: true,
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryPromise = new Promise((resolve, reject) => {
|
||||||
|
const items = [];
|
||||||
|
const transaction = db.transaction(storeName, 'readonly');
|
||||||
|
transaction.onerror = () => {
|
||||||
|
handleDOMException(
|
||||||
|
'migrateToSQLite transaction error',
|
||||||
|
transaction.error,
|
||||||
|
reject
|
||||||
|
);
|
||||||
|
};
|
||||||
|
transaction.oncomplete = () => {};
|
||||||
|
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
const excludeLowerBound = true;
|
||||||
|
const range = lastIndex
|
||||||
|
? IDBKeyRange.lowerBound(lastIndex, excludeLowerBound)
|
||||||
|
: undefined;
|
||||||
|
const request = store.openCursor(range);
|
||||||
|
request.onerror = () => {
|
||||||
|
handleDOMException(
|
||||||
|
'migrateToSQLite: request error',
|
||||||
|
request.error,
|
||||||
|
reject
|
||||||
|
);
|
||||||
|
};
|
||||||
|
request.onsuccess = event => {
|
||||||
|
const cursor = event.target.result;
|
||||||
|
|
||||||
|
if (!cursor || !cursor.value) {
|
||||||
|
return resolve({
|
||||||
|
complete: true,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = cursor.value;
|
||||||
|
items.push(item);
|
||||||
|
|
||||||
|
if (items.length >= batchSize) {
|
||||||
|
return resolve({
|
||||||
|
complete: false,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursor.continue();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items, complete } = await queryPromise;
|
||||||
|
|
||||||
|
if (items.length) {
|
||||||
|
// We need to pass forceSave parameter, because these items already have an
|
||||||
|
// id key. Normally, this call would be interpreted as an update request.
|
||||||
|
await save(items, { forceSave: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastItem = last(items);
|
||||||
|
const id = lastItem ? lastItem.id : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
complete,
|
||||||
|
count: items.length,
|
||||||
|
lastIndex: id,
|
||||||
|
};
|
||||||
|
}
|
|
@ -3,25 +3,34 @@ const { isObject, isString } = require('lodash');
|
||||||
const ITEMS_STORE_NAME = 'items';
|
const ITEMS_STORE_NAME = 'items';
|
||||||
const LAST_PROCESSED_INDEX_KEY = 'attachmentMigration_lastProcessedIndex';
|
const LAST_PROCESSED_INDEX_KEY = 'attachmentMigration_lastProcessedIndex';
|
||||||
const IS_MIGRATION_COMPLETE_KEY = 'attachmentMigration_isComplete';
|
const IS_MIGRATION_COMPLETE_KEY = 'attachmentMigration_isComplete';
|
||||||
|
const MESSAGE_LAST_INDEX_KEY = 'sqlMigration_messageLastIndex';
|
||||||
|
const UNPROCESSED_LAST_INDEX_KEY = 'sqlMigration_unprocessedLastIndex';
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
exports.READ_RECEIPT_CONFIGURATION_SYNC = 'read-receipt-configuration-sync';
|
exports.READ_RECEIPT_CONFIGURATION_SYNC = 'read-receipt-configuration-sync';
|
||||||
|
|
||||||
exports.getAttachmentMigrationLastProcessedIndex = connection =>
|
exports.getAttachmentMigrationLastProcessedIndex = connection =>
|
||||||
exports._getItem(connection, LAST_PROCESSED_INDEX_KEY);
|
exports._getItem(connection, LAST_PROCESSED_INDEX_KEY);
|
||||||
|
|
||||||
exports.setAttachmentMigrationLastProcessedIndex = (connection, value) =>
|
exports.setAttachmentMigrationLastProcessedIndex = (connection, value) =>
|
||||||
exports._setItem(connection, LAST_PROCESSED_INDEX_KEY, value);
|
exports._setItem(connection, LAST_PROCESSED_INDEX_KEY, value);
|
||||||
|
|
||||||
exports.deleteAttachmentMigrationLastProcessedIndex = connection =>
|
exports.deleteAttachmentMigrationLastProcessedIndex = connection =>
|
||||||
exports._deleteItem(connection, LAST_PROCESSED_INDEX_KEY);
|
exports._deleteItem(connection, LAST_PROCESSED_INDEX_KEY);
|
||||||
|
|
||||||
exports.isAttachmentMigrationComplete = async connection =>
|
exports.isAttachmentMigrationComplete = async connection =>
|
||||||
Boolean(await exports._getItem(connection, IS_MIGRATION_COMPLETE_KEY));
|
Boolean(await exports._getItem(connection, IS_MIGRATION_COMPLETE_KEY));
|
||||||
|
|
||||||
exports.markAttachmentMigrationComplete = connection =>
|
exports.markAttachmentMigrationComplete = connection =>
|
||||||
exports._setItem(connection, IS_MIGRATION_COMPLETE_KEY, true);
|
exports._setItem(connection, IS_MIGRATION_COMPLETE_KEY, true);
|
||||||
|
|
||||||
|
exports.getMessageExportLastIndex = connection =>
|
||||||
|
exports._getItem(connection, MESSAGE_LAST_INDEX_KEY);
|
||||||
|
exports.setMessageExportLastIndex = (connection, lastIndex) =>
|
||||||
|
exports._setItem(connection, MESSAGE_LAST_INDEX_KEY, lastIndex);
|
||||||
|
|
||||||
|
exports.getUnprocessedExportLastIndex = connection =>
|
||||||
|
exports._getItem(connection, UNPROCESSED_LAST_INDEX_KEY);
|
||||||
|
exports.setUnprocessedExportLastIndex = (connection, lastIndex) =>
|
||||||
|
exports._setItem(connection, UNPROCESSED_LAST_INDEX_KEY, lastIndex);
|
||||||
|
|
||||||
// Private API
|
// Private API
|
||||||
exports._getItem = (connection, key) => {
|
exports._getItem = (connection, key) => {
|
||||||
if (!isObject(connection)) {
|
if (!isObject(connection)) {
|
||||||
|
|
|
@ -10,6 +10,7 @@ const OS = require('../../ts/OS');
|
||||||
const Settings = require('./settings');
|
const Settings = require('./settings');
|
||||||
const Startup = require('./startup');
|
const Startup = require('./startup');
|
||||||
const Util = require('../../ts/util');
|
const Util = require('../../ts/util');
|
||||||
|
const { migrateToSQL } = require('./migrate_to_sql');
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
const {
|
const {
|
||||||
|
@ -110,6 +111,7 @@ function initializeMigrations({
|
||||||
const attachmentsPath = getPath(userDataPath);
|
const attachmentsPath = getPath(userDataPath);
|
||||||
const readAttachmentData = createReader(attachmentsPath);
|
const readAttachmentData = createReader(attachmentsPath);
|
||||||
const loadAttachmentData = Type.loadData(readAttachmentData);
|
const loadAttachmentData = Type.loadData(readAttachmentData);
|
||||||
|
const loadQuoteData = MessageType.loadQuoteData(readAttachmentData);
|
||||||
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
||||||
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
|
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
|
||||||
|
|
||||||
|
@ -122,6 +124,7 @@ function initializeMigrations({
|
||||||
getAbsoluteAttachmentPath,
|
getAbsoluteAttachmentPath,
|
||||||
getPlaceholderMigrations,
|
getPlaceholderMigrations,
|
||||||
loadAttachmentData,
|
loadAttachmentData,
|
||||||
|
loadQuoteData,
|
||||||
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
|
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
|
||||||
Migrations0DatabaseWithAttachmentData,
|
Migrations0DatabaseWithAttachmentData,
|
||||||
Migrations1DatabaseWithoutAttachmentData,
|
Migrations1DatabaseWithoutAttachmentData,
|
||||||
|
@ -222,5 +225,6 @@ exports.setup = (options = {}) => {
|
||||||
Util,
|
Util,
|
||||||
Views,
|
Views,
|
||||||
Workflow,
|
Workflow,
|
||||||
|
migrateToSQL,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -166,7 +166,7 @@ exports._mapAttachments = upgradeAttachment => async (message, context) => {
|
||||||
const upgradeWithContext = attachment =>
|
const upgradeWithContext = attachment =>
|
||||||
upgradeAttachment(attachment, context);
|
upgradeAttachment(attachment, context);
|
||||||
const attachments = await Promise.all(
|
const attachments = await Promise.all(
|
||||||
message.attachments.map(upgradeWithContext)
|
(message.attachments || []).map(upgradeWithContext)
|
||||||
);
|
);
|
||||||
return Object.assign({}, message, { attachments });
|
return Object.assign({}, message, { attachments });
|
||||||
};
|
};
|
||||||
|
@ -356,7 +356,9 @@ exports.upgradeSchema = async (
|
||||||
|
|
||||||
exports.createAttachmentLoader = loadAttachmentData => {
|
exports.createAttachmentLoader = loadAttachmentData => {
|
||||||
if (!isFunction(loadAttachmentData)) {
|
if (!isFunction(loadAttachmentData)) {
|
||||||
throw new TypeError('`loadAttachmentData` is required');
|
throw new TypeError(
|
||||||
|
'createAttachmentLoader: loadAttachmentData is required'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return async message =>
|
return async message =>
|
||||||
|
@ -367,6 +369,36 @@ exports.createAttachmentLoader = loadAttachmentData => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.loadQuoteData = loadAttachmentData => {
|
||||||
|
if (!isFunction(loadAttachmentData)) {
|
||||||
|
throw new TypeError('loadQuoteData: loadAttachmentData is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return async quote => {
|
||||||
|
if (!quote) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...quote,
|
||||||
|
attachments: await Promise.all(
|
||||||
|
(quote.attachments || []).map(async attachment => {
|
||||||
|
const { thumbnail } = attachment;
|
||||||
|
|
||||||
|
if (!thumbnail || !thumbnail.path) {
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attachment,
|
||||||
|
thumbnail: await loadAttachmentData(thumbnail),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
||||||
if (!isFunction(deleteAttachmentData)) {
|
if (!isFunction(deleteAttachmentData)) {
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
|
@ -392,7 +424,7 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
||||||
quote.attachments.map(async attachment => {
|
quote.attachments.map(async attachment => {
|
||||||
const { thumbnail } = attachment;
|
const { thumbnail } = attachment;
|
||||||
|
|
||||||
if (thumbnail.path) {
|
if (thumbnail && thumbnail.path) {
|
||||||
await deleteOnDisk(thumbnail.path);
|
await deleteOnDisk(thumbnail.path);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -127,13 +127,7 @@
|
||||||
return this.fetch({ range: [`${number}.1`, `${number}.:`] });
|
return this.fetch({ range: [`${number}.1`, `${number}.:`] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const Unprocessed = Model.extend({ storeName: 'unprocessed' });
|
const Unprocessed = Model.extend();
|
||||||
const UnprocessedCollection = Backbone.Collection.extend({
|
|
||||||
storeName: 'unprocessed',
|
|
||||||
database: Whisper.Database,
|
|
||||||
model: Unprocessed,
|
|
||||||
comparator: 'timestamp',
|
|
||||||
});
|
|
||||||
const IdentityRecord = Model.extend({
|
const IdentityRecord = Model.extend({
|
||||||
storeName: 'identityKeys',
|
storeName: 'identityKeys',
|
||||||
validAttributes: [
|
validAttributes: [
|
||||||
|
@ -946,10 +940,15 @@
|
||||||
|
|
||||||
// Not yet processed messages - for resiliency
|
// Not yet processed messages - for resiliency
|
||||||
getAllUnprocessed() {
|
getAllUnprocessed() {
|
||||||
return window.Signal.Data.getAllUnprocessed({ UnprocessedCollection });
|
return window.Signal.Data.getAllUnprocessed();
|
||||||
},
|
},
|
||||||
addUnprocessed(data) {
|
addUnprocessed(data) {
|
||||||
return window.Signal.Data.saveUnprocessed(data, { Unprocessed });
|
// We need to pass forceSave because the data has an id already, which will cause
|
||||||
|
// an update instead of an insert.
|
||||||
|
return window.Signal.Data.saveUnprocessed(data, {
|
||||||
|
forceSave: true,
|
||||||
|
Unprocessed,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
updateUnprocessed(id, updates) {
|
updateUnprocessed(id, updates) {
|
||||||
return window.Signal.Data.updateUnprocessed(id, updates, { Unprocessed });
|
return window.Signal.Data.updateUnprocessed(id, updates, { Unprocessed });
|
||||||
|
@ -961,6 +960,7 @@
|
||||||
// First the in-memory caches:
|
// First the in-memory caches:
|
||||||
window.storage.reset(); // items store
|
window.storage.reset(); // items store
|
||||||
ConversationController.reset(); // conversations store
|
ConversationController.reset(); // conversations store
|
||||||
|
await ConversationController.load();
|
||||||
|
|
||||||
// Then, the entire database:
|
// Then, the entire database:
|
||||||
await Whisper.Database.clear();
|
await Whisper.Database.clear();
|
||||||
|
|
|
@ -46,7 +46,15 @@
|
||||||
},
|
},
|
||||||
async clearAllData() {
|
async clearAllData() {
|
||||||
try {
|
try {
|
||||||
await Promise.all([Logs.deleteAll(), Database.drop()]);
|
await Promise.all([
|
||||||
|
Logs.deleteAll(),
|
||||||
|
Database.drop(),
|
||||||
|
window.Signal.Data.removeAll(),
|
||||||
|
window.Signal.Data.removeOtherData(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await window.Signal.Data.close();
|
||||||
|
await window.Signal.Data.removeDB();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
'Something went wrong deleting all data:',
|
'Something went wrong deleting all data:',
|
||||||
|
|
|
@ -515,9 +515,7 @@
|
||||||
const messagesLoaded = this.inProgressFetch || Promise.resolve();
|
const messagesLoaded = this.inProgressFetch || Promise.resolve();
|
||||||
|
|
||||||
// eslint-disable-next-line more/no-then
|
// eslint-disable-next-line more/no-then
|
||||||
messagesLoaded
|
messagesLoaded.then(this.onLoaded.bind(this), this.onLoaded.bind(this));
|
||||||
.then(this.model.decryptOldIncomingKeyErrors.bind(this))
|
|
||||||
.then(this.onLoaded.bind(this), this.onLoaded.bind(this));
|
|
||||||
|
|
||||||
this.view.resetScrollPosition();
|
this.view.resetScrollPosition();
|
||||||
this.$el.trigger('force-resize');
|
this.$el.trigger('force-resize');
|
||||||
|
@ -799,11 +797,16 @@
|
||||||
this.inProgressFetch = this.model
|
this.inProgressFetch = this.model
|
||||||
.fetchContacts()
|
.fetchContacts()
|
||||||
.then(() => this.model.fetchMessages())
|
.then(() => this.model.fetchMessages())
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
this.$('.bar-container').hide();
|
this.$('.bar-container').hide();
|
||||||
this.model.messageCollection.where({ unread: 1 }).forEach(m => {
|
await Promise.all(
|
||||||
m.fetch();
|
this.model.messageCollection.where({ unread: 1 }).map(async m => {
|
||||||
|
const latest = await window.Signal.Data.getMessageById(m.id, {
|
||||||
|
Message: Whisper.Message,
|
||||||
});
|
});
|
||||||
|
m.merge(latest);
|
||||||
|
})
|
||||||
|
);
|
||||||
this.inProgressFetch = null;
|
this.inProgressFetch = null;
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
@ -1003,6 +1006,7 @@
|
||||||
Message: Whisper.Message,
|
Message: Whisper.Message,
|
||||||
});
|
});
|
||||||
message.trigger('unload');
|
message.trigger('unload');
|
||||||
|
this.model.messageCollection.remove(message.id);
|
||||||
this.resetPanel();
|
this.resetPanel();
|
||||||
this.updateHeader();
|
this.updateHeader();
|
||||||
},
|
},
|
||||||
|
@ -1138,10 +1142,17 @@
|
||||||
async destroyMessages() {
|
async destroyMessages() {
|
||||||
try {
|
try {
|
||||||
await this.confirm(i18n('deleteConversationConfirmation'));
|
await this.confirm(i18n('deleteConversationConfirmation'));
|
||||||
|
try {
|
||||||
await this.model.destroyMessages();
|
await this.model.destroyMessages();
|
||||||
this.remove();
|
this.remove();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// nothing to see here
|
window.log.error(
|
||||||
|
'destroyMessages: Failed to successfully delete conversation',
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// nothing to see here, user canceled out of dialog
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -2,21 +2,7 @@
|
||||||
|
|
||||||
// eslint-disable-next-line func-names
|
// eslint-disable-next-line func-names
|
||||||
(function() {
|
(function() {
|
||||||
const registeredFunctions = {};
|
|
||||||
const Type = {
|
|
||||||
ENCRYPT_MESSAGE: 1,
|
|
||||||
INIT_SESSION: 2,
|
|
||||||
TRANSMIT_MESSAGE: 3,
|
|
||||||
REBUILD_MESSAGE: 4,
|
|
||||||
RETRY_SEND_MESSAGE_PROTO: 5,
|
|
||||||
};
|
|
||||||
window.textsecure = window.textsecure || {};
|
window.textsecure = window.textsecure || {};
|
||||||
window.textsecure.replay = {
|
|
||||||
Type,
|
|
||||||
registerFunction(func, functionCode) {
|
|
||||||
registeredFunctions[functionCode] = func;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function inherit(Parent, Child) {
|
function inherit(Parent, Child) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
@ -46,23 +32,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
this.functionCode = options.functionCode;
|
this.functionCode = options.functionCode;
|
||||||
this.args = options.args;
|
|
||||||
}
|
}
|
||||||
inherit(Error, ReplayableError);
|
inherit(Error, ReplayableError);
|
||||||
|
|
||||||
ReplayableError.prototype.replay = function replay(...argumentsAsArray) {
|
|
||||||
const args = this.args.concat(argumentsAsArray);
|
|
||||||
return registeredFunctions[this.functionCode].apply(window, args);
|
|
||||||
};
|
|
||||||
|
|
||||||
function IncomingIdentityKeyError(number, message, key) {
|
function IncomingIdentityKeyError(number, message, key) {
|
||||||
// eslint-disable-next-line prefer-destructuring
|
// eslint-disable-next-line prefer-destructuring
|
||||||
this.number = number.split('.')[0];
|
this.number = number.split('.')[0];
|
||||||
this.identityKey = key;
|
this.identityKey = key;
|
||||||
|
|
||||||
ReplayableError.call(this, {
|
ReplayableError.call(this, {
|
||||||
functionCode: Type.INIT_SESSION,
|
|
||||||
args: [number, message],
|
|
||||||
name: 'IncomingIdentityKeyError',
|
name: 'IncomingIdentityKeyError',
|
||||||
message: `The identity of ${this.number} has changed.`,
|
message: `The identity of ${this.number} has changed.`,
|
||||||
});
|
});
|
||||||
|
@ -75,8 +53,6 @@
|
||||||
this.identityKey = identityKey;
|
this.identityKey = identityKey;
|
||||||
|
|
||||||
ReplayableError.call(this, {
|
ReplayableError.call(this, {
|
||||||
functionCode: Type.ENCRYPT_MESSAGE,
|
|
||||||
args: [number, message, timestamp],
|
|
||||||
name: 'OutgoingIdentityKeyError',
|
name: 'OutgoingIdentityKeyError',
|
||||||
message: `The identity of ${this.number} has changed.`,
|
message: `The identity of ${this.number} has changed.`,
|
||||||
});
|
});
|
||||||
|
@ -84,9 +60,10 @@
|
||||||
inherit(ReplayableError, OutgoingIdentityKeyError);
|
inherit(ReplayableError, OutgoingIdentityKeyError);
|
||||||
|
|
||||||
function OutgoingMessageError(number, message, timestamp, httpError) {
|
function OutgoingMessageError(number, message, timestamp, httpError) {
|
||||||
|
// eslint-disable-next-line prefer-destructuring
|
||||||
|
this.number = number.split('.')[0];
|
||||||
|
|
||||||
ReplayableError.call(this, {
|
ReplayableError.call(this, {
|
||||||
functionCode: Type.ENCRYPT_MESSAGE,
|
|
||||||
args: [number, message, timestamp],
|
|
||||||
name: 'OutgoingMessageError',
|
name: 'OutgoingMessageError',
|
||||||
message: httpError ? httpError.message : 'no http error',
|
message: httpError ? httpError.message : 'no http error',
|
||||||
});
|
});
|
||||||
|
@ -98,13 +75,11 @@
|
||||||
}
|
}
|
||||||
inherit(ReplayableError, OutgoingMessageError);
|
inherit(ReplayableError, OutgoingMessageError);
|
||||||
|
|
||||||
function SendMessageNetworkError(number, jsonData, httpError, timestamp) {
|
function SendMessageNetworkError(number, jsonData, httpError) {
|
||||||
this.number = number;
|
this.number = number;
|
||||||
this.code = httpError.code;
|
this.code = httpError.code;
|
||||||
|
|
||||||
ReplayableError.call(this, {
|
ReplayableError.call(this, {
|
||||||
functionCode: Type.TRANSMIT_MESSAGE,
|
|
||||||
args: [number, jsonData, timestamp],
|
|
||||||
name: 'SendMessageNetworkError',
|
name: 'SendMessageNetworkError',
|
||||||
message: httpError.message,
|
message: httpError.message,
|
||||||
});
|
});
|
||||||
|
@ -113,10 +88,8 @@
|
||||||
}
|
}
|
||||||
inherit(ReplayableError, SendMessageNetworkError);
|
inherit(ReplayableError, SendMessageNetworkError);
|
||||||
|
|
||||||
function SignedPreKeyRotationError(numbers, message, timestamp) {
|
function SignedPreKeyRotationError() {
|
||||||
ReplayableError.call(this, {
|
ReplayableError.call(this, {
|
||||||
functionCode: Type.RETRY_SEND_MESSAGE_PROTO,
|
|
||||||
args: [numbers, message, timestamp],
|
|
||||||
name: 'SignedPreKeyRotationError',
|
name: 'SignedPreKeyRotationError',
|
||||||
message: 'Too many signed prekey rotation failures',
|
message: 'Too many signed prekey rotation failures',
|
||||||
});
|
});
|
||||||
|
@ -127,8 +100,6 @@
|
||||||
this.code = httpError.code;
|
this.code = httpError.code;
|
||||||
|
|
||||||
ReplayableError.call(this, {
|
ReplayableError.call(this, {
|
||||||
functionCode: Type.REBUILD_MESSAGE,
|
|
||||||
args: [message],
|
|
||||||
name: 'MessageError',
|
name: 'MessageError',
|
||||||
message: httpError.message,
|
message: httpError.message,
|
||||||
});
|
});
|
||||||
|
|
|
@ -292,7 +292,10 @@ MessageReceiver.prototype.extend({
|
||||||
},
|
},
|
||||||
stringToArrayBuffer(string) {
|
stringToArrayBuffer(string) {
|
||||||
// eslint-disable-next-line new-cap
|
// eslint-disable-next-line new-cap
|
||||||
return new dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer();
|
return dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer();
|
||||||
|
},
|
||||||
|
arrayBufferToString(arrayBuffer) {
|
||||||
|
return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('binary');
|
||||||
},
|
},
|
||||||
getAllFromCache() {
|
getAllFromCache() {
|
||||||
window.log.info('getAllFromCache');
|
window.log.info('getAllFromCache');
|
||||||
|
@ -331,7 +334,7 @@ MessageReceiver.prototype.extend({
|
||||||
const id = this.getEnvelopeId(envelope);
|
const id = this.getEnvelopeId(envelope);
|
||||||
const data = {
|
const data = {
|
||||||
id,
|
id,
|
||||||
envelope: plaintext,
|
envelope: this.arrayBufferToString(plaintext),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
};
|
};
|
||||||
|
@ -340,7 +343,7 @@ MessageReceiver.prototype.extend({
|
||||||
updateCache(envelope, plaintext) {
|
updateCache(envelope, plaintext) {
|
||||||
const id = this.getEnvelopeId(envelope);
|
const id = this.getEnvelopeId(envelope);
|
||||||
const data = {
|
const data = {
|
||||||
decrypted: plaintext,
|
decrypted: this.arrayBufferToString(plaintext),
|
||||||
};
|
};
|
||||||
return textsecure.storage.unprocessed.update(id, data);
|
return textsecure.storage.unprocessed.update(id, data);
|
||||||
},
|
},
|
||||||
|
@ -1153,11 +1156,6 @@ textsecure.MessageReceiver = function MessageReceiverWrapper(
|
||||||
this.getStatus = messageReceiver.getStatus.bind(messageReceiver);
|
this.getStatus = messageReceiver.getStatus.bind(messageReceiver);
|
||||||
this.close = messageReceiver.close.bind(messageReceiver);
|
this.close = messageReceiver.close.bind(messageReceiver);
|
||||||
messageReceiver.connect();
|
messageReceiver.connect();
|
||||||
|
|
||||||
textsecure.replay.registerFunction(
|
|
||||||
messageReceiver.tryMessageAgain.bind(messageReceiver),
|
|
||||||
textsecure.replay.Type.INIT_SESSION
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
textsecure.MessageReceiver.prototype = {
|
textsecure.MessageReceiver.prototype = {
|
||||||
|
|
|
@ -705,8 +705,9 @@ MessageSender.prototype = {
|
||||||
profileKey
|
profileKey
|
||||||
) {
|
) {
|
||||||
return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => {
|
return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => {
|
||||||
if (targetNumbers === undefined)
|
if (targetNumbers === undefined) {
|
||||||
return Promise.reject(new Error('Unknown Group'));
|
return Promise.reject(new Error('Unknown Group'));
|
||||||
|
}
|
||||||
|
|
||||||
const me = textsecure.storage.user.getNumber();
|
const me = textsecure.storage.user.getNumber();
|
||||||
const numbers = targetNumbers.filter(number => number !== me);
|
const numbers = targetNumbers.filter(number => number !== me);
|
||||||
|
@ -895,22 +896,6 @@ textsecure.MessageSender = function MessageSenderWrapper(
|
||||||
cdnUrl
|
cdnUrl
|
||||||
) {
|
) {
|
||||||
const sender = new MessageSender(url, username, password, cdnUrl);
|
const sender = new MessageSender(url, username, password, cdnUrl);
|
||||||
textsecure.replay.registerFunction(
|
|
||||||
sender.tryMessageAgain.bind(sender),
|
|
||||||
textsecure.replay.Type.ENCRYPT_MESSAGE
|
|
||||||
);
|
|
||||||
textsecure.replay.registerFunction(
|
|
||||||
sender.retransmitMessage.bind(sender),
|
|
||||||
textsecure.replay.Type.TRANSMIT_MESSAGE
|
|
||||||
);
|
|
||||||
textsecure.replay.registerFunction(
|
|
||||||
sender.sendMessage.bind(sender),
|
|
||||||
textsecure.replay.Type.REBUILD_MESSAGE
|
|
||||||
);
|
|
||||||
textsecure.replay.registerFunction(
|
|
||||||
sender.retrySendMessageProto.bind(sender),
|
|
||||||
textsecure.replay.Type.RETRY_SEND_MESSAGE_PROTO
|
|
||||||
);
|
|
||||||
|
|
||||||
this.sendExpirationTimerUpdateToNumber = sender.sendExpirationTimerUpdateToNumber.bind(
|
this.sendExpirationTimerUpdateToNumber = sender.sendExpirationTimerUpdateToNumber.bind(
|
||||||
sender
|
sender
|
||||||
|
|
35
main.js
35
main.js
|
@ -4,6 +4,7 @@ const path = require('path');
|
||||||
const url = require('url');
|
const url = require('url');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
const pify = require('pify');
|
const pify = require('pify');
|
||||||
|
@ -23,7 +24,9 @@ const {
|
||||||
|
|
||||||
const packageJson = require('./package.json');
|
const packageJson = require('./package.json');
|
||||||
|
|
||||||
const Attachments = require('./app/attachments');
|
const sql = require('./app/sql');
|
||||||
|
const sqlChannels = require('./app/sql_channel');
|
||||||
|
const attachmentChannel = require('./app/attachment_channel');
|
||||||
const autoUpdate = require('./app/auto_update');
|
const autoUpdate = require('./app/auto_update');
|
||||||
const createTrayIcon = require('./app/tray_icon');
|
const createTrayIcon = require('./app/tray_icon');
|
||||||
const GlobalErrors = require('./app/global_errors');
|
const GlobalErrors = require('./app/global_errors');
|
||||||
|
@ -596,16 +599,13 @@ app.on('ready', async () => {
|
||||||
|
|
||||||
installPermissionsHandler({ session, userConfig });
|
installPermissionsHandler({ session, userConfig });
|
||||||
|
|
||||||
// NOTE: Temporarily allow `then` until we convert the entire file to `async` / `await`:
|
|
||||||
/* eslint-disable more/no-then */
|
|
||||||
let loggingSetupError;
|
let loggingSetupError;
|
||||||
logging
|
try {
|
||||||
.initialize()
|
await logging.initialize();
|
||||||
.catch(error => {
|
} catch (error) {
|
||||||
loggingSetupError = error;
|
loggingSetupError = error;
|
||||||
})
|
}
|
||||||
.then(async () => {
|
|
||||||
/* eslint-enable more/no-then */
|
|
||||||
logger = logging.getLogger();
|
logger = logging.getLogger();
|
||||||
logger.info('app ready');
|
logger.info('app ready');
|
||||||
|
|
||||||
|
@ -614,13 +614,21 @@ app.on('ready', async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!locale) {
|
if (!locale) {
|
||||||
const appLocale =
|
const appLocale = process.env.NODE_ENV === 'test' ? 'en' : app.getLocale();
|
||||||
process.env.NODE_ENV === 'test' ? 'en' : app.getLocale();
|
|
||||||
locale = loadLocale({ appLocale, logger });
|
locale = loadLocale({ appLocale, logger });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Ensure attachments directory exists');
|
await attachmentChannel.initialize({ configDir: userDataPath });
|
||||||
await Attachments.ensureDirectory(userDataPath);
|
|
||||||
|
let key = userConfig.get('key');
|
||||||
|
if (!key) {
|
||||||
|
// https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
|
||||||
|
key = crypto.randomBytes(32).toString('hex');
|
||||||
|
userConfig.set('key', key);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql.initialize({ configDir: userDataPath, key });
|
||||||
|
await sqlChannels.initialize({ userConfig });
|
||||||
|
|
||||||
ready = true;
|
ready = true;
|
||||||
|
|
||||||
|
@ -633,7 +641,6 @@ app.on('ready', async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
setupMenu();
|
setupMenu();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupMenu(options) {
|
function setupMenu(options) {
|
||||||
|
|
|
@ -52,6 +52,7 @@
|
||||||
"electron-config": "^1.0.0",
|
"electron-config": "^1.0.0",
|
||||||
"electron-editor-context-menu": "^1.1.1",
|
"electron-editor-context-menu": "^1.1.1",
|
||||||
"electron-is-dev": "^0.3.0",
|
"electron-is-dev": "^0.3.0",
|
||||||
|
"@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#9682a5c75b1a52c847cfb4b431eb2163d0eeec93",
|
||||||
"electron-unhandled": "https://github.com/scottnonnenberg-signal/electron-unhandled.git#7496187472aa561d39fcd4c843a54ffbef0a388c",
|
"electron-unhandled": "https://github.com/scottnonnenberg-signal/electron-unhandled.git#7496187472aa561d39fcd4c843a54ffbef0a388c",
|
||||||
"electron-updater": "^2.21.10",
|
"electron-updater": "^2.21.10",
|
||||||
"emoji-datasource": "4.0.0",
|
"emoji-datasource": "4.0.0",
|
||||||
|
@ -87,6 +88,7 @@
|
||||||
"tmp": "^0.0.33",
|
"tmp": "^0.0.33",
|
||||||
"to-arraybuffer": "^1.0.1",
|
"to-arraybuffer": "^1.0.1",
|
||||||
"underscore": "^1.9.0",
|
"underscore": "^1.9.0",
|
||||||
|
"uuid": "^3.3.2",
|
||||||
"websocket": "^1.0.25"
|
"websocket": "^1.0.25"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -2107,6 +2107,9 @@
|
||||||
@include color-svg('../images/read.svg', $color-light-35);
|
@include color-svg('../images/read.svg', $color-light-35);
|
||||||
width: 18px;
|
width: 18px;
|
||||||
}
|
}
|
||||||
|
.module-conversation-list-item__message__status-icon--error {
|
||||||
|
@include color-svg('../images/error.svg', $color-core-red);
|
||||||
|
}
|
||||||
|
|
||||||
// Third-party module: react-contextmenu
|
// Third-party module: react-contextmenu
|
||||||
|
|
||||||
|
|
|
@ -71,6 +71,7 @@ function deleteDatabase() {
|
||||||
/* Delete the database before running any tests */
|
/* Delete the database before running any tests */
|
||||||
before(async () => {
|
before(async () => {
|
||||||
await deleteDatabase();
|
await deleteDatabase();
|
||||||
|
await window.Signal.Data.removeAll();
|
||||||
|
|
||||||
await Signal.Migrations.Migrations0DatabaseWithAttachmentData.run({
|
await Signal.Migrations.Migrations0DatabaseWithAttachmentData.run({
|
||||||
Backbone,
|
Backbone,
|
||||||
|
@ -82,4 +83,5 @@ before(async () => {
|
||||||
async function clearDatabase() {
|
async function clearDatabase() {
|
||||||
const db = await Whisper.Database.open();
|
const db = await Whisper.Database.open();
|
||||||
await Whisper.Database.clear();
|
await Whisper.Database.clear();
|
||||||
|
await window.Signal.Data.removeAll();
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,13 +34,12 @@ describe('KeyChangeListener', function() {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a key change notice in the private conversation with this contact', function(done) {
|
it('generates a key change notice in the private conversation with this contact', function(done) {
|
||||||
convo.on('newmessage', function() {
|
convo.on('newmessage', async function() {
|
||||||
return convo.fetchMessages().then(function() {
|
await convo.fetchMessages();
|
||||||
var message = convo.messageCollection.at(0);
|
var message = convo.messageCollection.at(0);
|
||||||
assert.strictEqual(message.get('type'), 'keychange');
|
assert.strictEqual(message.get('type'), 'keychange');
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
store.saveIdentity(address.toString(), newKey);
|
store.saveIdentity(address.toString(), newKey);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -61,13 +60,12 @@ describe('KeyChangeListener', function() {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates a key change notice in the group conversation with this contact', function(done) {
|
it('generates a key change notice in the group conversation with this contact', function(done) {
|
||||||
convo.on('newmessage', function() {
|
convo.on('newmessage', async function() {
|
||||||
return convo.fetchMessages().then(function() {
|
await convo.fetchMessages();
|
||||||
var message = convo.messageCollection.at(0);
|
var message = convo.messageCollection.at(0);
|
||||||
assert.strictEqual(message.get('type'), 'keychange');
|
assert.strictEqual(message.get('type'), 'keychange');
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
store.saveIdentity(address.toString(), newKey);
|
store.saveIdentity(address.toString(), newKey);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -32,6 +32,73 @@
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### All types of status
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div>
|
||||||
|
<ConversationListItem
|
||||||
|
phoneNumber="(202) 555-0011"
|
||||||
|
name="Mr. Fire🔥"
|
||||||
|
color="green"
|
||||||
|
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||||
|
lastMessage={{
|
||||||
|
text: 'Sending',
|
||||||
|
status: 'sending',
|
||||||
|
}}
|
||||||
|
onClick={() => console.log('onClick')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<ConversationListItem
|
||||||
|
phoneNumber="(202) 555-0011"
|
||||||
|
name="Mr. Fire🔥"
|
||||||
|
color="green"
|
||||||
|
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||||
|
lastMessage={{
|
||||||
|
text: 'Sent',
|
||||||
|
status: 'sent',
|
||||||
|
}}
|
||||||
|
onClick={() => console.log('onClick')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<ConversationListItem
|
||||||
|
phoneNumber="(202) 555-0011"
|
||||||
|
name="Mr. Fire🔥"
|
||||||
|
color="green"
|
||||||
|
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||||
|
lastMessage={{
|
||||||
|
text: 'Delivered',
|
||||||
|
status: 'delivered',
|
||||||
|
}}
|
||||||
|
onClick={() => console.log('onClick')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<ConversationListItem
|
||||||
|
phoneNumber="(202) 555-0011"
|
||||||
|
name="Mr. Fire🔥"
|
||||||
|
color="green"
|
||||||
|
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||||
|
lastMessage={{
|
||||||
|
text: 'Read',
|
||||||
|
status: 'read',
|
||||||
|
}}
|
||||||
|
onClick={() => console.log('onClick')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<ConversationListItem
|
||||||
|
phoneNumber="(202) 555-0011"
|
||||||
|
name="Mr. Fire🔥"
|
||||||
|
color="green"
|
||||||
|
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||||
|
lastMessage={{
|
||||||
|
text: 'Error',
|
||||||
|
status: 'error',
|
||||||
|
}}
|
||||||
|
onClick={() => console.log('onClick')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
#### With unread
|
#### With unread
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
|
@ -278,5 +345,15 @@ On platforms that show scrollbars all the time, this is true all the time.
|
||||||
onClick={() => console.log('onClick')}
|
onClick={() => console.log('onClick')}
|
||||||
i18n={util.i18n}
|
i18n={util.i18n}
|
||||||
/>
|
/>
|
||||||
|
<ConversationListItem
|
||||||
|
phoneNumber="(202) 555-0011"
|
||||||
|
lastUpdated={Date.now() - 5 * 60 * 1000}
|
||||||
|
lastMessage={{
|
||||||
|
text: null,
|
||||||
|
status: 'sent',
|
||||||
|
}}
|
||||||
|
onClick={() => console.log('onClick')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
|
@ -139,7 +139,6 @@ export class ConversationListItem extends React.Component<Props> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="module-conversation-list-item__message">
|
<div className="module-conversation-list-item__message">
|
||||||
{lastMessage.text ? (
|
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-conversation-list-item__message__text',
|
'module-conversation-list-item__message__text',
|
||||||
|
@ -149,13 +148,12 @@ export class ConversationListItem extends React.Component<Props> {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageBody
|
<MessageBody
|
||||||
text={lastMessage.text}
|
text={lastMessage.text || ''}
|
||||||
disableJumbomoji={true}
|
disableJumbomoji={true}
|
||||||
disableLinks={true}
|
disableLinks={true}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
{lastMessage.status ? (
|
{lastMessage.status ? (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|
109
yarn.lock
109
yarn.lock
|
@ -22,6 +22,13 @@
|
||||||
"7zip-bin-mac" "~1.0.1"
|
"7zip-bin-mac" "~1.0.1"
|
||||||
"7zip-bin-win" "~2.2.0"
|
"7zip-bin-win" "~2.2.0"
|
||||||
|
|
||||||
|
"@journeyapps/sqlcipher@https://github.com/scottnonnenberg-signal/node-sqlcipher.git#8b4d6046ca3f47aa31c0c26414a0981d6c83a423":
|
||||||
|
version "3.2.1"
|
||||||
|
resolved "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#8b4d6046ca3f47aa31c0c26414a0981d6c83a423"
|
||||||
|
dependencies:
|
||||||
|
nan "^2.10.0"
|
||||||
|
node-pre-gyp "^0.10.0"
|
||||||
|
|
||||||
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
|
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
|
resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
|
||||||
|
@ -2004,7 +2011,7 @@ debug@0.7.4:
|
||||||
version "0.7.4"
|
version "0.7.4"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39"
|
||||||
|
|
||||||
debug@2, debug@2.6.9, debug@^2.3.3, debug@^2.6.0, debug@^2.6.6:
|
debug@2, debug@2.6.9, debug@^2.1.2, debug@^2.3.3, debug@^2.6.0, debug@^2.6.6:
|
||||||
version "2.6.9"
|
version "2.6.9"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -2070,6 +2077,10 @@ deep-equal@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
|
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
|
||||||
|
|
||||||
|
deep-extend@^0.6.0:
|
||||||
|
version "0.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
|
||||||
|
|
||||||
deep-extend@~0.4.0:
|
deep-extend@~0.4.0:
|
||||||
version "0.4.1"
|
version "0.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253"
|
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253"
|
||||||
|
@ -3357,6 +3368,12 @@ fs-extra@^6.0.0:
|
||||||
jsonfile "^4.0.0"
|
jsonfile "^4.0.0"
|
||||||
universalify "^0.1.0"
|
universalify "^0.1.0"
|
||||||
|
|
||||||
|
fs-minipass@^1.2.5:
|
||||||
|
version "1.2.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d"
|
||||||
|
dependencies:
|
||||||
|
minipass "^2.2.1"
|
||||||
|
|
||||||
fs-promise@^0.5.0:
|
fs-promise@^0.5.0:
|
||||||
version "0.5.0"
|
version "0.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/fs-promise/-/fs-promise-0.5.0.tgz#4347d6bf624655a7061a4319213c393276ad3ef3"
|
resolved "https://registry.yarnpkg.com/fs-promise/-/fs-promise-0.5.0.tgz#4347d6bf624655a7061a4319213c393276ad3ef3"
|
||||||
|
@ -4143,7 +4160,7 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17:
|
||||||
version "0.4.19"
|
version "0.4.19"
|
||||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
|
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
|
||||||
|
|
||||||
iconv-lite@^0.4.23:
|
iconv-lite@^0.4.23, iconv-lite@^0.4.4:
|
||||||
version "0.4.23"
|
version "0.4.23"
|
||||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
|
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -4167,6 +4184,12 @@ iferr@^0.1.5:
|
||||||
version "0.1.5"
|
version "0.1.5"
|
||||||
resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
|
resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
|
||||||
|
|
||||||
|
ignore-walk@^3.0.1:
|
||||||
|
version "3.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
|
||||||
|
dependencies:
|
||||||
|
minimatch "^3.0.4"
|
||||||
|
|
||||||
ignore@^3.3.3, ignore@^3.3.5:
|
ignore@^3.3.3, ignore@^3.3.5:
|
||||||
version "3.3.7"
|
version "3.3.7"
|
||||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021"
|
resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021"
|
||||||
|
@ -5441,6 +5464,19 @@ minimist@1.2.0, minimist@^1.1.0, minimist@^1.1.3, minimist@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
|
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
|
||||||
|
|
||||||
|
minipass@^2.2.1, minipass@^2.3.3:
|
||||||
|
version "2.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.3.tgz#a7dcc8b7b833f5d368759cce544dccb55f50f233"
|
||||||
|
dependencies:
|
||||||
|
safe-buffer "^5.1.2"
|
||||||
|
yallist "^3.0.0"
|
||||||
|
|
||||||
|
minizlib@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.0.tgz#11e13658ce46bc3a70a267aac58359d1e0c29ceb"
|
||||||
|
dependencies:
|
||||||
|
minipass "^2.2.1"
|
||||||
|
|
||||||
mississippi@^2.0.0:
|
mississippi@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f"
|
resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f"
|
||||||
|
@ -5574,7 +5610,7 @@ nan@^2.0.0, nan@^2.3.2, nan@^2.3.3:
|
||||||
version "2.6.2"
|
version "2.6.2"
|
||||||
resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.2.tgz#e4ff34e6c95fdfb5aecc08de6596f43605a7db45"
|
resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.2.tgz#e4ff34e6c95fdfb5aecc08de6596f43605a7db45"
|
||||||
|
|
||||||
nan@^2.3.0:
|
nan@^2.10.0, nan@^2.3.0:
|
||||||
version "2.10.0"
|
version "2.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f"
|
resolved "https://registry.yarnpkg.com/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f"
|
||||||
|
|
||||||
|
@ -5609,6 +5645,14 @@ ncp@~2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
|
resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
|
||||||
|
|
||||||
|
needle@^2.2.1:
|
||||||
|
version "2.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.1.tgz#b5e325bd3aae8c2678902fa296f729455d1d3a7d"
|
||||||
|
dependencies:
|
||||||
|
debug "^2.1.2"
|
||||||
|
iconv-lite "^0.4.4"
|
||||||
|
sax "^1.2.4"
|
||||||
|
|
||||||
negotiator@0.6.1:
|
negotiator@0.6.1:
|
||||||
version "0.6.1"
|
version "0.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
|
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
|
||||||
|
@ -5694,6 +5738,21 @@ node-libs-browser@^2.0.0:
|
||||||
util "^0.10.3"
|
util "^0.10.3"
|
||||||
vm-browserify "0.0.4"
|
vm-browserify "0.0.4"
|
||||||
|
|
||||||
|
node-pre-gyp@^0.10.0:
|
||||||
|
version "0.10.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc"
|
||||||
|
dependencies:
|
||||||
|
detect-libc "^1.0.2"
|
||||||
|
mkdirp "^0.5.1"
|
||||||
|
needle "^2.2.1"
|
||||||
|
nopt "^4.0.1"
|
||||||
|
npm-packlist "^1.1.6"
|
||||||
|
npmlog "^4.0.2"
|
||||||
|
rc "^1.2.7"
|
||||||
|
rimraf "^2.6.1"
|
||||||
|
semver "^5.3.0"
|
||||||
|
tar "^4"
|
||||||
|
|
||||||
node-pre-gyp@^0.6.39:
|
node-pre-gyp@^0.6.39:
|
||||||
version "0.6.39"
|
version "0.6.39"
|
||||||
resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz#c00e96860b23c0e1420ac7befc5044e1d78d8649"
|
resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz#c00e96860b23c0e1420ac7befc5044e1d78d8649"
|
||||||
|
@ -5798,10 +5857,21 @@ normalize-url@^1.4.0:
|
||||||
query-string "^4.1.0"
|
query-string "^4.1.0"
|
||||||
sort-keys "^1.0.0"
|
sort-keys "^1.0.0"
|
||||||
|
|
||||||
|
npm-bundled@^1.0.1:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.3.tgz#7e71703d973af3370a9591bafe3a63aca0be2308"
|
||||||
|
|
||||||
npm-install-package@~2.1.0:
|
npm-install-package@~2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/npm-install-package/-/npm-install-package-2.1.0.tgz#d7efe3cfcd7ab00614b896ea53119dc9ab259125"
|
resolved "https://registry.yarnpkg.com/npm-install-package/-/npm-install-package-2.1.0.tgz#d7efe3cfcd7ab00614b896ea53119dc9ab259125"
|
||||||
|
|
||||||
|
npm-packlist@^1.1.6:
|
||||||
|
version "1.1.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.11.tgz#84e8c683cbe7867d34b1d357d893ce29e28a02de"
|
||||||
|
dependencies:
|
||||||
|
ignore-walk "^3.0.1"
|
||||||
|
npm-bundled "^1.0.1"
|
||||||
|
|
||||||
npm-run-path@^2.0.0:
|
npm-run-path@^2.0.0:
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
|
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
|
||||||
|
@ -6943,6 +7013,15 @@ rc@^1.1.7:
|
||||||
minimist "^1.2.0"
|
minimist "^1.2.0"
|
||||||
strip-json-comments "~2.0.1"
|
strip-json-comments "~2.0.1"
|
||||||
|
|
||||||
|
rc@^1.2.7:
|
||||||
|
version "1.2.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
|
||||||
|
dependencies:
|
||||||
|
deep-extend "^0.6.0"
|
||||||
|
ini "~1.3.0"
|
||||||
|
minimist "^1.2.0"
|
||||||
|
strip-json-comments "~2.0.1"
|
||||||
|
|
||||||
react-codemirror2@^4.2.1:
|
react-codemirror2@^4.2.1:
|
||||||
version "4.2.1"
|
version "4.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-4.2.1.tgz#4ad3c5c60ebbcb34880f961721b51527324ec021"
|
resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-4.2.1.tgz#4ad3c5c60ebbcb34880f961721b51527324ec021"
|
||||||
|
@ -7594,6 +7673,10 @@ safe-buffer@^5.0.1:
|
||||||
version "5.0.1"
|
version "5.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
|
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
|
||||||
|
|
||||||
|
safe-buffer@^5.1.2:
|
||||||
|
version "5.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
|
||||||
|
|
||||||
safe-json-stringify@~1:
|
safe-json-stringify@~1:
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.0.4.tgz#81a098f447e4bbc3ff3312a243521bc060ef5911"
|
resolved "https://registry.yarnpkg.com/safe-json-stringify/-/safe-json-stringify-1.0.4.tgz#81a098f447e4bbc3ff3312a243521bc060ef5911"
|
||||||
|
@ -8391,6 +8474,18 @@ tar@^2.0.0, tar@^2.2.1:
|
||||||
fstream "^1.0.2"
|
fstream "^1.0.2"
|
||||||
inherits "2"
|
inherits "2"
|
||||||
|
|
||||||
|
tar@^4:
|
||||||
|
version "4.4.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.4.tgz#ec8409fae9f665a4355cc3b4087d0820232bb8cd"
|
||||||
|
dependencies:
|
||||||
|
chownr "^1.0.1"
|
||||||
|
fs-minipass "^1.2.5"
|
||||||
|
minipass "^2.3.3"
|
||||||
|
minizlib "^1.1.0"
|
||||||
|
mkdirp "^0.5.0"
|
||||||
|
safe-buffer "^5.1.2"
|
||||||
|
yallist "^3.0.2"
|
||||||
|
|
||||||
temp-file@^3.1.2:
|
temp-file@^3.1.2:
|
||||||
version "3.1.2"
|
version "3.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/temp-file/-/temp-file-3.1.2.tgz#54ba4084097558e8ff2ad1e4bd84841ef2804043"
|
resolved "https://registry.yarnpkg.com/temp-file/-/temp-file-3.1.2.tgz#54ba4084097558e8ff2ad1e4bd84841ef2804043"
|
||||||
|
@ -8981,6 +9076,10 @@ uuid@^3.1.0:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
|
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04"
|
||||||
|
|
||||||
|
uuid@^3.3.2:
|
||||||
|
version "3.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
|
||||||
|
|
||||||
validate-npm-package-license@^3.0.1:
|
validate-npm-package-license@^3.0.1:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz#2804babe712ad3379459acfbe24746ab2c303fbc"
|
resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz#2804babe712ad3379459acfbe24746ab2c303fbc"
|
||||||
|
@ -9380,6 +9479,10 @@ yallist@^2.0.0, yallist@^2.1.2:
|
||||||
version "2.1.2"
|
version "2.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
|
resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
|
||||||
|
|
||||||
|
yallist@^3.0.0, yallist@^3.0.2:
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9"
|
||||||
|
|
||||||
yargs-parser@^4.2.0:
|
yargs-parser@^4.2.0:
|
||||||
version "4.2.1"
|
version "4.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-4.2.1.tgz#29cceac0dc4f03c6c87b4a9f217dd18c9f74871c"
|
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-4.2.1.tgz#29cceac0dc4f03c6c87b4a9f217dd18c9f74871c"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue