Merge commit signal/master into signal-1.20

This commit is contained in:
Mikunj 2019-01-22 11:17:05 +11:00
commit 1a15ec9c15
69 changed files with 2917 additions and 1876 deletions

View File

@ -1,6 +1,6 @@
<!--
Please fill out this template with all the information you have. We can't do much without
both logs and a detailed description of what you encountered. Please do your best!
both the logs and a detailed description of what you've encountered. Please do your best!
Please note that this tracker is only for bugs and feature requests. Please try these
locations if you have a question or comment:
@ -21,11 +21,11 @@ Lastly, be sure to preview your issue before saving. Thanks!
---
### Bug description
### Bug Description
<!-- Give an overall summary of the issue. -->
### Steps to reproduce
### Steps to Reproduce
<!-- Using bullet points, list the steps that reproduce the bug. -->
@ -33,11 +33,11 @@ Lastly, be sure to preview your issue before saving. Thanks!
2. step two
3. step three
Actual result:
Actual Result:
<!-- Describe the details of the buggy behaviour. -->
Expected result:
Expected Result:
<!-- Describe in detail what the correct behavior should be. -->
@ -48,7 +48,7 @@ How to take screenshots on all OSes: https://www.take-a-screenshot.org/
You can drag and drop images into this text box.
-->
### Platform info
### Platform Info
Loki Messenger version:
@ -58,16 +58,16 @@ Operating System:
<!-- Instructions for finding your OS version are here: http://whatsmyos.com/ -->
Linked device version:
Linked Device Version:
<!-- Android: Settings -> Advanced, iOS: Settings -> General -> About -->
### Link to debug log
### Link to Debug Log
<!--
Immediately after the bug has happened, submit a debug log via View -> Debug Log, then copy that URL here.
In most cases, a log from your other devices is also useful:
Android: https://support.signal.org/hc/en-us/articles/212535838
iOS: https://support.signal.org/hc/en-us/articles/229676507
Android: https://support.signal.org/hc/en-us/articles/360007318591#android_debug
iOS: https://support.signal.org/hc/en-us/articles/360007318591#ios_debug
-->

View File

@ -267,8 +267,8 @@ module.exports = grunt => {
grunt.registerTask('getExpireTime', () => {
grunt.task.requires('gitinfo');
const gitinfo = grunt.config.get('gitinfo');
const commited = gitinfo.local.branch.current.lastCommitTime;
const time = Date.parse(commited) + 1000 * 60 * 60 * 24 * 90;
const committed = gitinfo.local.branch.current.lastCommitTime;
const time = Date.parse(committed) + 1000 * 60 * 60 * 24 * 90;
grunt.file.write(
'config/local-production.json',
`${JSON.stringify({ buildExpiration: time })}\n`
@ -307,7 +307,7 @@ module.exports = grunt => {
app.client
.execute(getMochaResults)
.then(data => Boolean(data.value)),
10000,
25000,
'Expected to find window.mochaResults set!'
)
)

View File

@ -1,5 +1,7 @@
# Loki Messenger
[![Build Status](https://travis-ci.org/loki-project/loki-messenger.svg?branch=development)](https://travis-ci.org/loki-project/loki-messenger)
Loki Messenger allows for truly decentralized and end to end and private encrypted chats, Loki Messenger is built to handle both online and fully Asynchronous offline messages , Loki messenger implements the Signal protocol for message encryption, Our Client interface is a fork of [Signal Messenger](https://signal.org/). All communication that passes through Loki messenger is routed through [Lokinet](https://github.com/loki-project/loki-network).
## Summary

View File

@ -172,6 +172,10 @@
"message": "Choose folder",
"description": "Button to allow the user to find a folder on disk"
},
"chooseFile": {
"message": "Choose file",
"description": "Button to allow the user to find a file on disk"
},
"loadDataHeader": {
"message": "Load your data",
"description": "Header shown on the first screen in the data import process"
@ -542,15 +546,37 @@
"message": "Voice Message",
"description": "Name for a voice message attachment"
},
"unsupportedFileType": {
"message": "Unsupported file type",
"description": "Displayed for outgoing unsupported attachment"
},
"dangerousFileType": {
"message": "Attachment type not allowed for security reasons",
"description":
"Shown in toast when user attempts to send .exe file, for example"
},
"stagedImageAttachment": {
"message": "Staged image attachment: $path$",
"description": "Alt text for staged attachments",
"placeholders": {
"path": {
"content": "$1",
"example": "dog.jpg"
}
}
},
"oneNonImageAtATimeToast": {
"message":
"When including a non-image attachment, the limit is one attachment per message.",
"description":
"An error popup when the user has attempted to add an attachment"
},
"cannotMixImageAdnNonImageAttachments": {
"message": "You cannot mix non-image and image attachments in one message.",
"description":
"An error popup when the user has attempted to add an attachment"
},
"maximumAttachments": {
"message": "You cannot add any more attachments to this message.",
"description":
"An error popup when the user has attempted to add an attachment"
},
"fileSizeWarning": {
"message": "Sorry, the selected file exceeds message size restrictions."
},
@ -732,6 +758,12 @@
"description":
"Shown in toast if user clicks on quote references messages not loaded in view, but in database"
},
"voiceNoteMustBeOnlyAttachment": {
"message":
"A voice note must be the only attachment included in a message.",
"description":
"Shown in toast if tries to record a voice note with any staged attachments"
},
"you": {
"message": "You",
"description":
@ -936,6 +968,16 @@
"description":
"Used for the icon layered on top of an image in message bubbles"
},
"addACaption": {
"message": "Add a caption...",
"descripton":
"Used as the placeholder text in the caption editor text field"
},
"save": {
"message": "Save",
"descripton":
"Used as a 'commit changes' button in the Caption Editor for outgoing image attachments"
},
"fileIconAlt": {
"message": "File icon",
"description":

View File

@ -24,6 +24,7 @@ module.exports = {
createOrUpdateGroup,
getGroupById,
getAllGroupIds,
getAllGroups,
bulkAddGroups,
removeGroupById,
removeAllGroups,
@ -92,7 +93,10 @@ module.exports = {
getAllConversationIds,
getAllPrivateConversations,
getAllGroupsInvolvingId,
searchConversations,
searchMessages,
searchMessagesInConversation,
getMessageCount,
saveMessage,
@ -540,6 +544,69 @@ async function updateToSchemaVersion7(currentVersion, instance) {
console.log('updateToSchemaVersion7: success!');
}
async function updateToSchemaVersion8(currentVersion, instance) {
if (currentVersion >= 8) {
return;
}
console.log('updateToSchemaVersion8: starting...');
await instance.run('BEGIN TRANSACTION;');
// First, we pull a new body field out of the message table's json blob
await instance.run(
`ALTER TABLE messages
ADD COLUMN body TEXT;`
);
await instance.run("UPDATE messages SET body = json_extract(json, '$.body')");
// Then we create our full-text search table and populate it
await instance.run(`
CREATE VIRTUAL TABLE messages_fts
USING fts5(id UNINDEXED, body);
`);
await instance.run(`
INSERT INTO messages_fts(id, body)
SELECT id, body FROM messages;
`);
// Then we set up triggers to keep the full-text search table up to date
await instance.run(`
CREATE TRIGGER messages_on_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts (
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE id = old.id;
END;
`);
await instance.run(`
CREATE TRIGGER messages_on_update AFTER UPDATE ON messages BEGIN
DELETE FROM messages_fts WHERE id = old.id;
INSERT INTO messages_fts(
id,
body
) VALUES (
new.id,
new.body
);
END;
`);
// For formatting search results:
// https://sqlite.org/fts5.html#the_highlight_function
// https://sqlite.org/fts5.html#the_snippet_function
await instance.run('PRAGMA schema_version = 8;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion8: success!');
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -548,6 +615,7 @@ const SCHEMA_VERSIONS = [
() => null, // version 5 was dropped
updateToSchemaVersion6,
updateToSchemaVersion7,
updateToSchemaVersion8,
];
async function updateSchema(instance) {
@ -598,7 +666,7 @@ async function initialize({ configDir, key }) {
const promisified = promisify(sqlInstance);
// promisified.on('trace', async statement => {
// if (!db) {
// if (!db || statement.startsWith('--')) {
// console._log(statement);
// return;
// }
@ -669,6 +737,10 @@ async function getAllGroupIds() {
const rows = await db.all('SELECT id FROM groups ORDER BY id ASC;');
return map(rows, row => row.id);
}
async function getAllGroups() {
const rows = await db.all('SELECT id FROM groups ORDER BY id ASC;');
return map(rows, row => jsonToObject(row.json));
}
async function bulkAddGroups(array) {
return bulkAdd(GROUPS_TABLE, array);
}
@ -1232,9 +1304,11 @@ async function getAllGroupsInvolvingId(id) {
async function searchConversations(query) {
const rows = await db.all(
`SELECT json FROM conversations WHERE
id LIKE $id OR
name LIKE $name OR
profileName LIKE $profileName
(
id LIKE $id OR
name LIKE $name OR
profileName LIKE $profileName
)
ORDER BY id ASC;`,
{
$id: `%${query}%`,
@ -1246,6 +1320,58 @@ async function searchConversations(query) {
return map(rows, row => jsonToObject(row.json));
}
async function searchMessages(query, { limit } = {}) {
const rows = await db.all(
`SELECT
messages.json,
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 15) as snippet
FROM messages_fts
INNER JOIN messages on messages_fts.id = messages.id
WHERE
messages_fts match $query
ORDER BY messages.received_at DESC
LIMIT $limit;`,
{
$query: query,
$limit: limit || 100,
}
);
return map(rows, row => ({
...jsonToObject(row.json),
snippet: row.snippet,
}));
}
async function searchMessagesInConversation(
query,
conversationId,
{ limit } = {}
) {
const rows = await db.all(
`SELECT
messages.json,
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 15) as snippet
FROM messages_fts
INNER JOIN messages on messages_fts.id = messages.id
WHERE
messages_fts match $query AND
messages.conversationId = $conversationId
ORDER BY messages.received_at DESC
LIMIT $limit;`,
{
$query: query,
$conversationId: conversationId,
$limit: limit || 100,
}
);
return map(rows, row => ({
...jsonToObject(row.json),
snippet: row.snippet,
}));
}
async function getMessageCount() {
const row = await db.get('SELECT count(*) from messages;');
@ -1258,6 +1384,7 @@ async function getMessageCount() {
async function saveMessage(data, { forceSave } = {}) {
const {
body,
conversationId,
// eslint-disable-next-line camelcase
expires_at,
@ -1283,6 +1410,7 @@ async function saveMessage(data, { forceSave } = {}) {
$id: id,
$json: objectToJSON(data),
$body: body,
$conversationId: conversationId,
$expirationStartTimestamp: expirationStartTimestamp,
$expires_at: expires_at,
@ -1296,7 +1424,7 @@ async function saveMessage(data, { forceSave } = {}) {
$sent_at: sent_at,
$source: source,
$sourceDevice: sourceDevice,
$type: type,
$type: type || '',
$unread: unread,
};
@ -1304,6 +1432,7 @@ async function saveMessage(data, { forceSave } = {}) {
await db.run(
`UPDATE messages SET
json = $json,
body = $body,
conversationId = $conversationId,
expirationStartTimestamp = $expirationStartTimestamp,
expires_at = $expires_at,
@ -1337,6 +1466,7 @@ async function saveMessage(data, { forceSave } = {}) {
id,
json,
body,
conversationId,
expirationStartTimestamp,
expires_at,
@ -1356,6 +1486,7 @@ async function saveMessage(data, { forceSave } = {}) {
$id,
$json,
$body,
$conversationId,
$expirationStartTimestamp,
$expires_at,

View File

@ -17,6 +17,17 @@ function createTrayIcon(getMainWindow, messages) {
tray = new Tray(iconNoNewMessages);
tray.forceOnTop = mainWindow => {
if (mainWindow) {
// On some versions of GNOME the window may not be on top when restored.
// This trick should fix it.
// Thanks to: https://github.com/Enrico204/Whatsapp-Desktop/commit/6b0dc86b64e481b455f8fce9b4d797e86d000dc1
mainWindow.setAlwaysOnTop(true);
mainWindow.focus();
mainWindow.setAlwaysOnTop(false);
}
};
tray.toggleWindowVisibility = () => {
const mainWindow = getMainWindow();
if (mainWindow) {
@ -25,17 +36,24 @@ function createTrayIcon(getMainWindow, messages) {
} else {
mainWindow.show();
// On some versions of GNOME the window may not be on top when restored.
// This trick should fix it.
// Thanks to: https://github.com/Enrico204/Whatsapp-Desktop/commit/6b0dc86b64e481b455f8fce9b4d797e86d000dc1
mainWindow.setAlwaysOnTop(true);
mainWindow.focus();
mainWindow.setAlwaysOnTop(false);
tray.forceOnTop(mainWindow);
}
}
tray.updateContextMenu();
};
tray.showWindow = () => {
const mainWindow = getMainWindow();
if (mainWindow) {
if (!mainWindow.isVisible()) {
mainWindow.show();
}
tray.forceOnTop(mainWindow);
}
tray.updateContextMenu();
};
tray.updateContextMenu = () => {
const mainWindow = getMainWindow();
@ -70,7 +88,7 @@ function createTrayIcon(getMainWindow, messages) {
}
};
tray.on('click', tray.toggleWindowVisibility);
tray.on('click', tray.showWindow);
tray.setToolTip(messages.trayTooltip.message);
tray.updateContextMenu();

View File

@ -143,9 +143,9 @@
<div class='bottom-bar' id='footer'>
<div class='emoji-panel-container'></div>
<div class='attachment-list'></div>
<div class='compose'>
<form class='send clearfix'>
<div class='attachment-previews'></div>
<form class='send clearfix file-input'>
<div class='flex'>
<button class='emoji' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
<textarea maxlength='2000' class='send-message' placeholder='{{ send-message }}' rows='1' dir='auto' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></textarea>
@ -157,7 +157,7 @@
</div>
<div class='choose-file hide'>
<button class='paperclip thumbnail' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button>
<input type='file' class='file-input'>
<input type='file' class='file-input' multiple='multiple'>
</div>
</div>
</form>
@ -735,7 +735,6 @@
<script type='text/javascript' src='js/views/last_seen_indicator_view.js'></script>
<script type='text/javascript' src='js/views/scroll_down_button_view.js'></script>
<script type='text/javascript' src='js/views/toast_view.js'></script>
<script type='text/javascript' src='js/views/attachment_preview_view.js'></script>
<script type='text/javascript' src='js/views/file_input_view.js'></script>
<script type='text/javascript' src='js/views/list_view.js'></script>
<script type='text/javascript' src='js/views/conversation_list_item_view.js'></script>

View File

@ -0,0 +1 @@
<svg id="Shape" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>add-caption-24</title><rect x="16" y="14" width="7" height="1"/><rect x="16" y="14" width="7" height="1" transform="translate(5 34) rotate(-90)"/><rect x="2" y="11" width="15" height="1"/><rect x="2" y="8" width="18" height="1"/><rect x="2" y="14" width="12" height="1"/></svg>

After

Width:  |  Height:  |  Size: 355 B

1
images/plus-36.svg Normal file
View File

@ -0,0 +1 @@
<svg id="Export" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><title>plus-36</title><polygon points="32 17.25 18.75 17.25 18.75 4 17.25 4 17.25 17.25 4 17.25 4 18.75 17.25 18.75 17.25 32 18.75 32 18.75 18.75 32 18.75 32 17.25"/></svg>

After

Width:  |  Height:  |  Size: 244 B

1
images/x-16.svg Normal file
View File

@ -0,0 +1 @@
<svg id="Shape" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><title>x-16</title><polygon points="14.35 2.35 13.65 1.65 8 7.29 2.35 1.65 1.65 2.35 7.29 8 1.65 13.65 2.35 14.35 8 8.71 13.65 14.35 14.35 13.65 8.71 8 14.35 2.35"/></svg>

After

Width:  |  Height:  |  Size: 242 B

1
images/x-shadow-16.svg Normal file
View File

@ -0,0 +1 @@
<svg id="Shape" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 12 12"><defs><style>.cls-1{opacity:0.5;filter:url(#shadow_blur_2);}.cls-2{fill:#fff;}</style><filter id="shadow_blur_2" name="shadow_blur_2"><feGaussianBlur stdDeviation="0.5" in="SourceGraphic"/></filter></defs><title>x-shadow-12</title><g class="cls-1"><polygon points="10.6 2.6 9.9 1.9 6 5.79 2.1 1.9 1.4 2.6 5.29 6.5 1.4 10.4 2.1 11.1 6 7.21 9.9 11.1 10.6 10.4 6.71 6.5 10.6 2.6"/></g><polygon class="cls-2" points="10.6 2.1 9.9 1.4 6 5.29 2.1 1.4 1.4 2.1 5.29 6 1.4 9.9 2.1 10.6 6 6.71 9.9 10.6 10.6 9.9 6.71 6 10.6 2.1"/></svg>

After

Width:  |  Height:  |  Size: 641 B

View File

@ -786,6 +786,19 @@
textsecure.storage.user.getDeviceId() != '1'
) {
window.getSyncRequest();
try {
const manager = window.getAccountManager();
await Promise.all([
manager.maybeUpdateDeviceName(),
manager.maybeDeleteSignalingKey(),
]);
} catch (e) {
window.log.error(
'Problem with account manager updates after starting new version: ',
e && e.stack ? e.stack : e
);
}
}
// const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery';

View File

@ -2063,6 +2063,21 @@
return this.id;
},
getInitials(name) {
if (!name) {
return null;
}
const cleaned = name.replace(/[^A-Za-z\s]+/g, '').replace(/\s+/g, ' ');
const parts = cleaned.split(' ');
const initials = parts.map(part => part.trim()[0]);
if (!initials.length) {
return null;
}
return initials.slice(0, 2).join('');
},
isPrivate() {
return this.get('type') === 'private';
},
@ -2099,7 +2114,7 @@
const symbol = this.isValid() ? '#' : '!';
return {
color,
content: title ? title.trim()[0] : symbol,
content: this.getInitials(title) || symbol,
};
}
return { url: 'images/group_default.png', color };

View File

@ -1266,9 +1266,12 @@
conversation,
message
);
receipts.forEach(() =>
receipts.forEach(receipt =>
message.set({
delivered: (message.get('delivered') || 0) + 1,
delivered_to: _.union(message.get('delivered_to') || [], [
receipt.get('source'),
]),
})
);
}

View File

@ -7,22 +7,20 @@
/* eslint-env browser */
/* eslint-env node */
/* eslint-disable no-param-reassign, guard-for-in, no-unreachable */
/* eslint-disable no-param-reassign, guard-for-in */
const fs = require('fs');
const path = require('path');
const { map, fromPairs } = require('lodash');
const tar = require('tar');
const tmp = require('tmp');
const pify = require('pify');
const archiver = require('archiver');
const rimraf = require('rimraf');
const electronRemote = require('electron').remote;
const Attachment = require('./types/attachment');
const crypto = require('./crypto');
const decompress = () => null;
const { dialog, BrowserWindow } = electronRemote;
module.exports = {
@ -111,100 +109,55 @@ function createOutputStream(writer) {
};
}
async function exportContactAndGroupsToFile(db, parent) {
async function exportContactAndGroupsToFile(parent) {
const writer = await createFileAndWriter(parent, 'db.json');
return exportContactsAndGroups(db, writer);
return exportContactsAndGroups(writer);
}
function exportContactsAndGroups(db, fileWriter) {
return new Promise((resolve, reject) => {
let storeNames = db.objectStoreNames;
storeNames = _.without(
storeNames,
'messages',
'items',
'signedPreKeys',
'preKeys',
'identityKeys',
'sessions',
'unprocessed'
);
function writeArray(stream, array) {
stream.write('[');
const exportedStoreNames = [];
if (storeNames.length === 0) {
throw new Error('No stores to export');
for (let i = 0, max = array.length; i < max; i += 1) {
if (i > 0) {
stream.write(',');
}
window.log.info('Exporting from these stores:', storeNames.join(', '));
const stream = createOutputStream(fileWriter);
const item = array[i];
stream.write('{');
// We don't back up avatars; we'll get them in a future contact sync or profile fetch
const cleaned = _.omit(item, ['avatar', 'profileAvatar']);
_.each(storeNames, storeName => {
// Both the readwrite permission and the multi-store transaction are required to
// keep this function working. They serve to serialize all of these transactions,
// one per store to be exported.
const transaction = db.transaction(storeNames, 'readwrite');
transaction.onerror = () => {
Whisper.Database.handleDOMException(
`exportToJsonFile transaction error (store: ${storeName})`,
transaction.error,
reject
);
};
transaction.oncomplete = () => {
window.log.info('transaction complete');
};
stream.write(JSON.stringify(stringify(cleaned)));
}
const store = transaction.objectStore(storeName);
const request = store.openCursor();
let count = 0;
request.onerror = () => {
Whisper.Database.handleDOMException(
`exportToJsonFile request error (store: ${storeNames})`,
request.error,
reject
);
};
request.onsuccess = async event => {
if (count === 0) {
window.log.info('cursor opened');
stream.write(`"${storeName}": [`);
}
stream.write(']');
}
const cursor = event.target.result;
if (cursor) {
if (count > 0) {
stream.write(',');
}
function getPlainJS(collection) {
return collection.map(model => model.attributes);
}
// Preventing base64'd images from reaching the disk, making db.json too big
const item = _.omit(cursor.value, ['avatar', 'profileAvatar']);
async function exportContactsAndGroups(fileWriter) {
const stream = createOutputStream(fileWriter);
const jsonString = JSON.stringify(stringify(item));
stream.write(jsonString);
cursor.continue();
count += 1;
} else {
// no more
stream.write(']');
window.log.info('Exported', count, 'items from store', storeName);
stream.write('{');
exportedStoreNames.push(storeName);
if (exportedStoreNames.length < storeNames.length) {
stream.write(',');
} else {
window.log.info('Exported all stores');
stream.write('}');
await stream.close();
window.log.info('Finished writing all stores to disk');
resolve();
}
}
};
});
stream.write('"conversations": ');
const conversations = await window.Signal.Data.getAllConversations({
ConversationCollection: Whisper.ConversationCollection,
});
window.log.info(`Exporting ${conversations.length} conversations`);
writeArray(stream, getPlainJS(conversations));
stream.write(',');
stream.write('"groups": ');
const groups = await window.Signal.Data.getAllGroups();
window.log.info(`Exporting ${groups.length} groups`);
writeArray(stream, groups);
stream.write('}');
await stream.close();
}
async function importNonMessages(parent, options) {
@ -414,6 +367,14 @@ function readFileAsText(parent, name) {
});
}
// Buffer instances are also Uint8Array instances, but they might be a view
// https://nodejs.org/docs/latest/api/buffer.html#buffer_buffers_and_typedarray
const toArrayBuffer = nodeBuffer =>
nodeBuffer.buffer.slice(
nodeBuffer.byteOffset,
nodeBuffer.byteOffset + nodeBuffer.byteLength
);
function readFileAsArrayBuffer(targetPath) {
return new Promise((resolve, reject) => {
// omitting the encoding to get a buffer back
@ -422,9 +383,7 @@ function readFileAsArrayBuffer(targetPath) {
return reject(error);
}
// Buffer instances are also Uint8Array instances
// https://nodejs.org/docs/latest/api/buffer.html#buffer_buffers_and_typedarray
return resolve(buffer.buffer);
return resolve(toArrayBuffer(buffer));
});
});
}
@ -468,7 +427,7 @@ function _getAnonymousAttachmentFileName(message, index) {
return `${message.id}-${index}`;
}
async function readAttachment(dir, attachment, name, options) {
async function readEncryptedAttachment(dir, attachment, name, options) {
options = options || {};
const { key } = options;
@ -485,26 +444,29 @@ async function readAttachment(dir, attachment, name, options) {
const isEncrypted = !_.isUndefined(key);
if (isEncrypted) {
attachment.data = await crypto.decryptSymmetric(key, data);
attachment.data = await crypto.decryptAttachment(
key,
attachment.path,
data
);
} else {
attachment.data = data;
}
}
async function writeThumbnail(attachment, options) {
async function writeQuoteThumbnail(attachment, options) {
if (!attachment || !attachment.thumbnail || !attachment.thumbnail.path) {
return;
}
const { dir, message, index, key, newKey } = options;
const filename = `${_getAnonymousAttachmentFileName(
message,
index
)}-thumbnail`;
)}-quote-thumbnail`;
const target = path.join(dir, filename);
const { thumbnail } = attachment;
if (!thumbnail || !thumbnail.data) {
return;
}
await writeEncryptedAttachment(target, thumbnail.data, {
await writeEncryptedAttachment(target, attachment.thumbnail.path, {
key,
newKey,
filename,
@ -512,25 +474,13 @@ async function writeThumbnail(attachment, options) {
});
}
async function writeThumbnails(rawQuotedAttachments, options) {
async function writeQuoteThumbnails(quotedAttachments, options) {
const { name } = options;
const { loadAttachmentData } = Signal.Migrations;
const promises = rawQuotedAttachments.map(async attachment => {
if (!attachment || !attachment.thumbnail || !attachment.thumbnail.path) {
return attachment;
}
return Object.assign({}, attachment, {
thumbnail: await loadAttachmentData(attachment.thumbnail),
});
});
const attachments = await Promise.all(promises);
try {
await Promise.all(
_.map(attachments, (attachment, index) =>
writeThumbnail(
_.map(quotedAttachments, (attachment, index) =>
writeQuoteThumbnail(
attachment,
Object.assign({}, options, {
index,
@ -550,26 +500,57 @@ async function writeThumbnails(rawQuotedAttachments, options) {
}
async function writeAttachment(attachment, options) {
if (!_.isString(attachment.path)) {
throw new Error('writeAttachment: attachment.path was not a string!');
}
const { dir, message, index, key, newKey } = options;
const filename = _getAnonymousAttachmentFileName(message, index);
const target = path.join(dir, filename);
if (!Attachment.hasData(attachment)) {
throw new TypeError("'attachment.data' is required");
}
await writeEncryptedAttachment(target, attachment.data, {
await writeEncryptedAttachment(target, attachment.path, {
key,
newKey,
filename,
dir,
});
if (attachment.thumbnail && _.isString(attachment.thumbnail.path)) {
const thumbnailName = `${_getAnonymousAttachmentFileName(
message,
index
)}-thumbnail`;
const thumbnailTarget = path.join(dir, thumbnailName);
await writeEncryptedAttachment(thumbnailTarget, attachment.thumbnail.path, {
key,
newKey,
filename: thumbnailName,
dir,
});
}
if (attachment.screenshot && _.isString(attachment.screenshot.path)) {
const screenshotName = `${_getAnonymousAttachmentFileName(
message,
index
)}-screenshot`;
const screenshotTarget = path.join(dir, screenshotName);
await writeEncryptedAttachment(
screenshotTarget,
attachment.screenshot.path,
{
key,
newKey,
filename: screenshotName,
dir,
}
);
}
}
async function writeAttachments(rawAttachments, options) {
async function writeAttachments(attachments, options) {
const { name } = options;
const { loadAttachmentData } = Signal.Migrations;
const attachments = await Promise.all(rawAttachments.map(loadAttachmentData));
const promises = _.map(attachments, (attachment, index) =>
writeAttachment(
attachment,
@ -591,17 +572,18 @@ async function writeAttachments(rawAttachments, options) {
}
}
async function writeAvatar(avatar, options) {
const { dir, message, index, key, newKey } = options;
const name = _getAnonymousAttachmentFileName(message, index);
const filename = `${name}-contact-avatar`;
const target = path.join(dir, filename);
if (!avatar || !avatar.path) {
async function writeAvatar(contact, options) {
const { avatar } = contact || {};
if (!avatar || !avatar.avatar || !avatar.avatar.path) {
return;
}
await writeEncryptedAttachment(target, avatar.data, {
const { dir, message, index, key, newKey } = options;
const name = _getAnonymousAttachmentFileName(message, index);
const filename = `${name}-contact-avatar`;
const target = path.join(dir, filename);
await writeEncryptedAttachment(target, avatar.avatar.path, {
key,
newKey,
filename,
@ -612,23 +594,9 @@ async function writeAvatar(avatar, options) {
async function writeContactAvatars(contact, options) {
const { name } = options;
const { loadAttachmentData } = Signal.Migrations;
const promises = contact.map(async item => {
if (
!item ||
!item.avatar ||
!item.avatar.avatar ||
!item.avatar.avatar.path
) {
return null;
}
return loadAttachmentData(item.avatar.avatar);
});
try {
await Promise.all(
_.map(await Promise.all(promises), (item, index) =>
_.map(contact, (item, index) =>
writeAvatar(
item,
Object.assign({}, options, {
@ -648,7 +616,7 @@ async function writeContactAvatars(contact, options) {
}
}
async function writeEncryptedAttachment(target, data, options = {}) {
async function writeEncryptedAttachment(target, source, options = {}) {
const { key, newKey, filename, dir } = options;
if (fs.existsSync(target)) {
@ -661,7 +629,9 @@ async function writeEncryptedAttachment(target, data, options = {}) {
}
}
const ciphertext = await crypto.encryptSymmetric(key, data);
const { readAttachmentData } = Signal.Migrations;
const data = await readAttachmentData(source);
const ciphertext = await crypto.encryptAttachment(key, source, data);
const writer = await createFileAndWriter(dir, filename);
const stream = createOutputStream(writer);
@ -673,9 +643,9 @@ function _sanitizeFileName(filename) {
return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_');
}
async function exportConversation(db, conversation, options) {
options = options || {};
async function exportConversation(conversation, options = {}) {
const { name, dir, attachmentsDir, key, newKey } = options;
if (!name) {
throw new Error('Need a name!');
}
@ -691,143 +661,111 @@ async function exportConversation(db, conversation, options) {
window.log.info('exporting conversation', name);
const writer = await createFileAndWriter(dir, 'messages.json');
const stream = createOutputStream(writer);
stream.write('{"messages":[');
return new Promise(async (resolve, reject) => {
// TODO: need to iterate through message ids, export using window.Signal.Data
const transaction = db.transaction('messages', 'readwrite');
transaction.onerror = () => {
Whisper.Database.handleDOMException(
`exportConversation transaction error (conversation: ${name})`,
transaction.error,
reject
);
};
transaction.oncomplete = () => {
// this doesn't really mean anything - we may have attachment processing to do
};
const CHUNK_SIZE = 50;
let count = 0;
let complete = false;
const store = transaction.objectStore('messages');
const index = store.index('conversation');
const range = window.IDBKeyRange.bound(
[conversation.id, 0],
[conversation.id, Number.MAX_VALUE]
);
// We're looping from the most recent to the oldest
let lastReceivedAt = Number.MAX_VALUE;
let promiseChain = Promise.resolve();
let count = 0;
const request = index.openCursor(range);
const stream = createOutputStream(writer);
stream.write('{"messages":[');
request.onerror = () => {
Whisper.Database.handleDOMException(
`exportConversation request error (conversation: ${name})`,
request.error,
reject
);
};
request.onsuccess = async event => {
const cursor = event.target.result;
if (cursor) {
const message = cursor.value;
const { attachments } = message;
// skip message if it is disappearing, no matter the amount of time left
if (message.expireTimer) {
cursor.continue();
return;
}
if (count !== 0) {
stream.write(',');
}
// eliminate attachment data from the JSON, since it will go to disk
// Note: this is for legacy messages only, which stored attachment data in the db
message.attachments = _.map(attachments, attachment =>
_.omit(attachment, ['data'])
);
// completely drop any attachments in messages cached in error objects
// TODO: move to lodash. Sadly, a number of the method signatures have changed!
message.errors = _.map(message.errors, error => {
if (error && error.args) {
error.args = [];
}
if (error && error.stack) {
error.stack = '';
}
return error;
});
const jsonString = JSON.stringify(stringify(message));
stream.write(jsonString);
if (attachments && attachments.length > 0) {
const exportAttachments = () =>
writeAttachments(attachments, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
// eslint-disable-next-line more/no-then
promiseChain = promiseChain.then(exportAttachments);
}
const quoteThumbnails = message.quote && message.quote.attachments;
if (quoteThumbnails && quoteThumbnails.length > 0) {
const exportQuoteThumbnails = () =>
writeThumbnails(quoteThumbnails, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
// eslint-disable-next-line more/no-then
promiseChain = promiseChain.then(exportQuoteThumbnails);
}
const { contact } = message;
if (contact && contact.length > 0) {
const exportContactAvatars = () =>
writeContactAvatars(contact, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
// eslint-disable-next-line more/no-then
promiseChain = promiseChain.then(exportContactAvatars);
}
count += 1;
cursor.continue();
} else {
try {
await Promise.all([stream.write(']}'), promiseChain, stream.close()]);
} catch (error) {
window.log.error(
'exportConversation: error exporting conversation',
name,
':',
error && error.stack ? error.stack : error
);
reject(error);
return;
}
window.log.info('done exporting conversation', name);
resolve();
while (!complete) {
// eslint-disable-next-line no-await-in-loop
const collection = await window.Signal.Data.getMessagesByConversation(
conversation.id,
{
limit: CHUNK_SIZE,
receivedAt: lastReceivedAt,
MessageCollection: Whisper.MessageCollection,
}
};
});
);
const messages = getPlainJS(collection);
for (let i = 0, max = messages.length; i < max; i += 1) {
const message = messages[i];
if (count > 0) {
stream.write(',');
}
count += 1;
// skip message if it is disappearing, no matter the amount of time left
if (message.expireTimer) {
// eslint-disable-next-line no-continue
continue;
}
const { attachments } = message;
// eliminate attachment data from the JSON, since it will go to disk
// Note: this is for legacy messages only, which stored attachment data in the db
message.attachments = _.map(attachments, attachment =>
_.omit(attachment, ['data'])
);
// completely drop any attachments in messages cached in error objects
// TODO: move to lodash. Sadly, a number of the method signatures have changed!
message.errors = _.map(message.errors, error => {
if (error && error.args) {
error.args = [];
}
if (error && error.stack) {
error.stack = '';
}
return error;
});
const jsonString = JSON.stringify(stringify(message));
stream.write(jsonString);
if (attachments && attachments.length > 0) {
// eslint-disable-next-line no-await-in-loop
await writeAttachments(attachments, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
}
const quoteThumbnails = message.quote && message.quote.attachments;
if (quoteThumbnails && quoteThumbnails.length > 0) {
// eslint-disable-next-line no-await-in-loop
await writeQuoteThumbnails(quoteThumbnails, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
}
const { contact } = message;
if (contact && contact.length > 0) {
// eslint-disable-next-line no-await-in-loop
await writeContactAvatars(contact, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
}
}
const last = messages.length > 0 ? messages[messages.length - 1] : null;
if (last) {
lastReceivedAt = last.received_at;
}
if (messages.length < CHUNK_SIZE) {
complete = true;
}
}
stream.write(']}');
await stream.close();
}
// Goals for directory names:
@ -857,74 +795,40 @@ function _getConversationLoggingName(conversation) {
return name;
}
function exportConversations(db, options) {
async function exportConversations(options) {
options = options || {};
const { messagesDir, attachmentsDir, key, newKey } = options;
if (!messagesDir) {
return Promise.reject(new Error('Need a messages directory!'));
throw new Error('Need a messages directory!');
}
if (!attachmentsDir) {
return Promise.reject(new Error('Need an attachments directory!'));
throw new Error('Need an attachments directory!');
}
return new Promise((resolve, reject) => {
const transaction = db.transaction('conversations', 'readwrite');
transaction.onerror = () => {
Whisper.Database.handleDOMException(
'exportConversations transaction error',
transaction.error,
reject
);
};
transaction.oncomplete = () => {
// not really very useful - fires at unexpected times
};
let promiseChain = Promise.resolve();
const store = transaction.objectStore('conversations');
const request = store.openCursor();
request.onerror = () => {
Whisper.Database.handleDOMException(
'exportConversations request error',
request.error,
reject
);
};
request.onsuccess = async event => {
const cursor = event.target.result;
if (cursor && cursor.value) {
const conversation = cursor.value;
const dirName = _getConversationDirName(conversation);
const name = _getConversationLoggingName(conversation);
const process = async () => {
const dir = await createDirectory(messagesDir, dirName);
return exportConversation(db, conversation, {
name,
dir,
attachmentsDir,
key,
newKey,
});
};
window.log.info('scheduling export for conversation', name);
// eslint-disable-next-line more/no-then
promiseChain = promiseChain.then(process);
cursor.continue();
} else {
window.log.info('Done scheduling conversation exports');
try {
await promiseChain;
} catch (error) {
reject(error);
return;
}
resolve();
}
};
const collection = await window.Signal.Data.getAllConversations({
ConversationCollection: Whisper.ConversationCollection,
});
const conversations = collection.models;
for (let i = 0, max = conversations.length; i < max; i += 1) {
const conversation = conversations[i];
const dirName = _getConversationDirName(conversation);
const name = _getConversationLoggingName(conversation);
// eslint-disable-next-line no-await-in-loop
const dir = await createDirectory(messagesDir, dirName);
// eslint-disable-next-line no-await-in-loop
await exportConversation(conversation, {
name,
dir,
attachmentsDir,
key,
newKey,
});
}
window.log.info('Done exporting conversations!');
}
function getDirectory(options = {}) {
@ -968,9 +872,30 @@ async function loadAttachments(dir, getName, options) {
const { message } = options;
await Promise.all(
_.map(message.attachments, (attachment, index) => {
_.map(message.attachments, async (attachment, index) => {
const name = getName(message, index, attachment);
return readAttachment(dir, attachment, name, options);
await readEncryptedAttachment(dir, attachment, name, options);
if (attachment.thumbnail && _.isString(attachment.thumbnail.path)) {
const thumbnailName = `${name}-thumbnail`;
await readEncryptedAttachment(
dir,
attachment.thumbnail,
thumbnailName,
options
);
}
if (attachment.screenshot && _.isString(attachment.screenshot.path)) {
const screenshotName = `${name}-screenshot`;
await readEncryptedAttachment(
dir,
attachment.screenshot,
screenshotName,
options
);
}
})
);
@ -982,8 +907,8 @@ async function loadAttachments(dir, getName, options) {
return null;
}
const name = `${getName(message, index)}-thumbnail`;
return readAttachment(dir, thumbnail, name, options);
const name = `${getName(message, index)}-quote-thumbnail`;
return readEncryptedAttachment(dir, thumbnail, name, options);
})
);
@ -996,7 +921,7 @@ async function loadAttachments(dir, getName, options) {
}
const name = `${getName(message, index)}-contact-avatar`;
return readAttachment(dir, avatar, name, options);
return readEncryptedAttachment(dir, avatar, name, options);
})
);
@ -1179,31 +1104,22 @@ function getDirectoryForExport() {
return getDirectory();
}
function createZip(zipDir, targetDir) {
return new Promise((resolve, reject) => {
const target = path.join(zipDir, 'messages.zip');
const output = fs.createWriteStream(target);
const archive = archiver('zip', {
async function compressArchive(file, targetDir) {
const items = fs.readdirSync(targetDir);
return tar.c(
{
gzip: true,
file,
cwd: targetDir,
});
},
items
);
}
output.on('close', () => {
resolve(target);
});
archive.on('warning', error => {
window.log.warn(`Archive generation warning: ${error.stack}`);
});
archive.on('error', reject);
archive.pipe(output);
// The empty string ensures that the base location of the files added to the zip
// is nothing. If you provide null, you get the absolute path you pulled the files
// from in the first place.
archive.directory(targetDir, '');
archive.finalize();
async function decompressArchive(file, targetDir) {
return tar.x({
file,
cwd: targetDir,
});
}
@ -1211,6 +1127,13 @@ function writeFile(targetPath, contents) {
return pify(fs.writeFile)(targetPath, contents);
}
// prettier-ignore
const UNIQUE_ID = new Uint8Array([
1, 3, 4, 5, 6, 7, 8, 11,
23, 34, 1, 34, 3, 5, 45, 45,
1, 3, 4, 5, 6, 7, 8, 11,
23, 34, 1, 34, 3, 5, 45, 45,
]);
async function encryptFile(sourcePath, targetPath, options) {
options = options || {};
@ -1220,8 +1143,8 @@ async function encryptFile(sourcePath, targetPath, options) {
}
const plaintext = await readFileAsArrayBuffer(sourcePath);
const ciphertext = await crypto.encryptSymmetric(key, plaintext);
return writeFile(targetPath, ciphertext);
const ciphertext = await crypto.encryptFile(key, UNIQUE_ID, plaintext);
return writeFile(targetPath, Buffer.from(ciphertext));
}
async function decryptFile(sourcePath, targetPath, options) {
@ -1233,7 +1156,7 @@ async function decryptFile(sourcePath, targetPath, options) {
}
const ciphertext = await readFileAsArrayBuffer(sourcePath);
const plaintext = await crypto.decryptSymmetric(key, ciphertext);
const plaintext = await crypto.decryptFile(key, UNIQUE_ID, ciphertext);
return writeFile(targetPath, Buffer.from(plaintext));
}
@ -1246,9 +1169,9 @@ function deleteAll(pattern) {
return pify(rimraf)(pattern);
}
async function exportToDirectory(directory, options) {
throw new Error('Encrypted export/import is disabled');
const ARCHIVE_NAME = 'messages.tar.gz';
async function exportToDirectory(directory, options) {
options = options || {};
if (!options.key) {
@ -1261,20 +1184,19 @@ async function exportToDirectory(directory, options) {
stagingDir = await createTempDir();
encryptionDir = await createTempDir();
const db = await Whisper.Database.open();
const attachmentsDir = await createDirectory(directory, 'attachments');
await exportContactAndGroupsToFile(db, stagingDir);
await exportContactAndGroupsToFile(stagingDir);
await exportConversations(
db,
Object.assign({}, options, {
messagesDir: stagingDir,
attachmentsDir,
})
);
const zip = await createZip(encryptionDir, stagingDir);
await encryptFile(zip, path.join(directory, 'messages.zip'), options);
const archivePath = path.join(directory, ARCHIVE_NAME);
await compressArchive(archivePath, stagingDir);
await encryptFile(archivePath, path.join(directory, ARCHIVE_NAME), options);
window.log.info('done backing up!');
return directory;
@ -1317,10 +1239,8 @@ async function importFromDirectory(directory, options) {
groupLookup,
});
const zipPath = path.join(directory, 'messages.zip');
if (fs.existsSync(zipPath)) {
throw new Error('Encrypted export/import is disabled');
const archivePath = path.join(directory, ARCHIVE_NAME);
if (fs.existsSync(archivePath)) {
// we're in the world of an encrypted, zipped backup
if (!options.key) {
throw new Error(
@ -1336,9 +1256,9 @@ async function importFromDirectory(directory, options) {
const attachmentsDir = path.join(directory, 'attachments');
const decryptedZip = path.join(decryptionDir, 'messages.zip');
await decryptFile(zipPath, decryptedZip, options);
await decompress(decryptedZip, stagingDir);
const decryptedArchivePath = path.join(decryptionDir, ARCHIVE_NAME);
await decryptFile(archivePath, decryptedArchivePath, options);
await decompressArchive(decryptedArchivePath, stagingDir);
options = Object.assign({}, options, {
attachmentsDir,

View File

@ -1,5 +1,5 @@
/* eslint-env browser */
/* global dcodeIO */
/* global dcodeIO, libsignal */
/* eslint-disable camelcase, no-bitwise */
@ -10,9 +10,15 @@ module.exports = {
concatenateBytes,
constantTimeEqual,
decryptAesCtr,
decryptDeviceName,
decryptAttachment,
decryptFile,
decryptSymmetric,
deriveAccessKey,
encryptAesCtr,
encryptDeviceName,
encryptAttachment,
encryptFile,
encryptSymmetric,
fromEncodedBinaryToArrayBuffer,
getAccessKeyVerifier,
@ -28,8 +34,117 @@ module.exports = {
verifyAccessKey,
};
function arrayBufferToBase64(arrayBuffer) {
return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');
}
function base64ToArrayBuffer(base64string) {
return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();
}
function fromEncodedBinaryToArrayBuffer(key) {
return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();
}
function bytesFromString(string) {
return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();
}
function stringFromBytes(buffer) {
return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');
}
// High-level Operations
async function encryptDeviceName(deviceName, identityPublic) {
const plaintext = bytesFromString(deviceName);
const ephemeralKeyPair = await libsignal.KeyHelper.generateIdentityKeyPair();
const masterSecret = await libsignal.Curve.async.calculateAgreement(
identityPublic,
ephemeralKeyPair.privKey
);
const key1 = await hmacSha256(masterSecret, bytesFromString('auth'));
const syntheticIv = _getFirstBytes(await hmacSha256(key1, plaintext), 16);
const key2 = await hmacSha256(masterSecret, bytesFromString('cipher'));
const cipherKey = await hmacSha256(key2, syntheticIv);
const counter = getZeroes(16);
const ciphertext = await encryptAesCtr(cipherKey, plaintext, counter);
return {
ephemeralPublic: ephemeralKeyPair.pubKey,
syntheticIv,
ciphertext,
};
}
async function decryptDeviceName(
{ ephemeralPublic, syntheticIv, ciphertext } = {},
identityPrivate
) {
const masterSecret = await libsignal.Curve.async.calculateAgreement(
ephemeralPublic,
identityPrivate
);
const key2 = await hmacSha256(masterSecret, bytesFromString('cipher'));
const cipherKey = await hmacSha256(key2, syntheticIv);
const counter = getZeroes(16);
const plaintext = await decryptAesCtr(cipherKey, ciphertext, counter);
const key1 = await hmacSha256(masterSecret, bytesFromString('auth'));
const ourSyntheticIv = _getFirstBytes(await hmacSha256(key1, plaintext), 16);
if (!constantTimeEqual(ourSyntheticIv, syntheticIv)) {
throw new Error('decryptDeviceName: synthetic IV did not match');
}
return stringFromBytes(plaintext);
}
// Path structure: 'fa/facdf99c22945b1c9393345599a276f4b36ad7ccdc8c2467f5441b742c2d11fa'
function getAttachmentLabel(path) {
const filename = path.slice(3);
return base64ToArrayBuffer(filename);
}
const PUB_KEY_LENGTH = 32;
async function encryptAttachment(staticPublicKey, path, plaintext) {
const uniqueId = getAttachmentLabel(path);
return encryptFile(staticPublicKey, uniqueId, plaintext);
}
async function decryptAttachment(staticPrivateKey, path, data) {
const uniqueId = getAttachmentLabel(path);
return decryptFile(staticPrivateKey, uniqueId, data);
}
async function encryptFile(staticPublicKey, uniqueId, plaintext) {
const ephemeralKeyPair = await libsignal.KeyHelper.generateIdentityKeyPair();
const agreement = await libsignal.Curve.async.calculateAgreement(
staticPublicKey,
ephemeralKeyPair.privKey
);
const key = await hmacSha256(agreement, uniqueId);
const prefix = ephemeralKeyPair.pubKey.slice(1);
return concatenateBytes(prefix, await encryptSymmetric(key, plaintext));
}
async function decryptFile(staticPrivateKey, uniqueId, data) {
const ephemeralPublicKey = _getFirstBytes(data, PUB_KEY_LENGTH);
const ciphertext = _getBytes(data, PUB_KEY_LENGTH, data.byteLength);
const agreement = await libsignal.Curve.async.calculateAgreement(
ephemeralPublicKey,
staticPrivateKey
);
const key = await hmacSha256(agreement, uniqueId);
return decryptSymmetric(key, ciphertext);
}
async function deriveAccessKey(profileKey) {
const iv = getZeroes(12);
const plaintext = getZeroes(16);
@ -267,24 +382,6 @@ function trimBytes(buffer, length) {
return _getFirstBytes(buffer, length);
}
function arrayBufferToBase64(arrayBuffer) {
return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');
}
function base64ToArrayBuffer(base64string) {
return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();
}
function fromEncodedBinaryToArrayBuffer(key) {
return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();
}
function bytesFromString(string) {
return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();
}
function stringFromBytes(buffer) {
return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');
}
function getViewOfArrayBuffer(buffer, start, finish) {
const source = new Uint8Array(buffer);
const result = source.slice(start, finish);

View File

@ -52,6 +52,7 @@ module.exports = {
createOrUpdateGroup,
getGroupById,
getAllGroupIds,
getAllGroups,
bulkAddGroups,
removeGroupById,
removeAllGroups,
@ -428,6 +429,10 @@ async function getAllGroupIds() {
const ids = await channels.getAllGroupIds();
return ids;
}
async function getAllGroups() {
const groups = await channels.getAllGroups();
return groups;
}
async function bulkAddGroups(array) {
await channels.bulkAddGroups(array);
}

View File

@ -15,6 +15,10 @@ const Metadata = require('./metadata/SecretSessionCipher');
const RefreshSenderCertificate = require('./refresh_sender_certificate');
// Components
const {
AttachmentList,
} = require('../../ts/components/conversation/AttachmentList');
const { CaptionEditor } = require('../../ts/components/CaptionEditor');
const {
ContactDetail,
} = require('../../ts/components/conversation/ContactDetail');
@ -136,6 +140,7 @@ function initializeMigrations({
loadAttachmentData,
loadQuoteData,
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
readAttachmentData,
run,
upgradeMessageSchema: (message, options = {}) => {
const { maxVersion } = options;
@ -174,6 +179,8 @@ exports.setup = (options = {}) => {
});
const Components = {
AttachmentList,
CaptionEditor,
ContactDetail,
ContactListItem,
ContactName,

View File

@ -545,8 +545,6 @@ exports.createAttachmentDataWriter = ({
});
};
// TODO: need to handle attachment thumbnails and video screenshots
const messageWithoutAttachmentData = Object.assign(
{},
await writeThumbnails(message, { logger }),
@ -555,7 +553,23 @@ exports.createAttachmentDataWriter = ({
attachments: await Promise.all(
(attachments || []).map(async attachment => {
await writeExistingAttachmentData(attachment);
return omit(attachment, ['data']);
if (attachment.screenshot && attachment.screenshot.data) {
await writeExistingAttachmentData(attachment.screenshot);
}
if (attachment.thumbnail && attachment.thumbnail.data) {
await writeExistingAttachmentData(attachment.thumbnail);
}
return {
...omit(attachment, ['data']),
...(attachment.thumbnail
? { thumbnail: omit(attachment.thumbnail, ['data']) }
: null),
...(attachment.screenshot
? { screenshot: omit(attachment.screenshot, ['data']) }
: null),
};
})
),
}

View File

@ -332,6 +332,8 @@ function HTTPError(message, providedCode, response, stack) {
const URL_CALLS = {
accounts: 'v1/accounts',
updateDeviceName: 'v1/accounts/name',
removeSignalingKey: 'v1/accounts/signaling_key',
attachment: 'v1/attachments',
deliveryCert: 'v1/certificate/delivery',
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
@ -393,6 +395,8 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
sendMessages,
sendMessagesUnauth,
setSignedPreKey,
updateDeviceName,
removeSignalingKey,
};
function _ajax(param) {
@ -523,14 +527,12 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
number,
code,
newPassword,
signalingKey,
registrationId,
deviceName,
options = {}
) {
const { accessKey } = options;
const jsonData = {
signalingKey: _btoa(_getString(signalingKey)),
supportsSms: false,
fetchesMessages: true,
registrationId,
@ -575,6 +577,23 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
return response;
}
function updateDeviceName(deviceName) {
return _ajax({
call: 'updateDeviceName',
httpType: 'PUT',
jsonData: {
deviceName,
},
});
}
function removeSignalingKey() {
return _ajax({
call: 'removeSignalingKey',
httpType: 'DELETE',
});
}
function getDevices() {
return _ajax({
call: 'devices',

View File

@ -1,16 +0,0 @@
/* global Whisper */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.AttachmentPreviewView = Whisper.View.extend({
className: 'attachment-preview',
templateName: 'attachment-preview',
render_attributes() {
return { source: this.src };
},
});
})();

View File

@ -58,6 +58,11 @@
return { toastMessage: i18n('messageFoundButNotLoaded') };
},
});
Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: i18n('voiceNoteMustBeOnlyAttachment') };
},
});
Whisper.ConversationLoadingScreen = Whisper.View.extend({
templateName: 'conversation-loading-screen',
@ -150,8 +155,16 @@
this.window = options.window;
this.fileInput = new Whisper.FileInputView({
el: this.$('form.send'),
window: this.window,
el: this.$('.attachment-list'),
});
this.listenTo(
this.fileInput,
'choose-attachment',
this.onChooseAttachment
);
this.listenTo(this.fileInput, 'staged-attachments-changed', () => {
this.view.resetScrollPosition();
this.toggleMicrophone();
});
const getHeaderProps = () => {
@ -185,7 +198,7 @@
onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(),
// These are view only and done update the Conversation model, so they
// These are view only and don't update the Conversation model, so they
// need a manual update call.
onShowSafetyNumber: () => {
this.showSafetyNumber();
@ -290,15 +303,49 @@
'farFromBottom .message-list': 'addScrollDownButton',
'lazyScroll .message-list': 'onLazyScroll',
'force-resize': 'forceUpdateMessageFieldSize',
dragover: 'sendToFileInput',
drop: 'sendToFileInput',
dragleave: 'sendToFileInput',
'click button.paperclip': 'onChooseAttachment',
'change input.file-input': 'onChoseAttachment',
dragover: 'onDragOver',
dragleave: 'onDragLeave',
drop: 'onDrop',
paste: 'onPaste',
},
sendToFileInput(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
onChooseAttachment(e) {
if (e) {
e.stopPropagation();
e.preventDefault();
}
this.fileInput.$el.trigger(e);
this.$('input.file-input').click();
},
async onChoseAttachment() {
const fileField = this.$('input.file-input');
const files = fileField.prop('files');
for (let i = 0, max = files.length; i < max; i += 1) {
const file = files[i];
// eslint-disable-next-line no-await-in-loop
await this.fileInput.maybeAddAttachment(file);
this.toggleMicrophone();
}
fileField.val(null);
},
onDragOver(e) {
this.fileInput.onDragOver(e);
},
onDragLeave(e) {
this.fileInput.onDragLeave(e);
},
onDrop(e) {
this.fileInput.onDrop(e);
},
onPaste(e) {
this.fileInput.onPaste(e);
},
onPrune() {
@ -546,6 +593,13 @@
captureAudio(e) {
e.preventDefault();
if (this.fileInput.hasFiles()) {
const toast = new Whisper.VoiceNoteMustBeOnlyAttachmentToast();
toast.$el.appendTo(this.$el);
toast.render();
return;
}
// Note - clicking anywhere will close the audio capture panel, due to
// the onClick handler in InboxView, which calls its closeRecording method.
@ -566,9 +620,11 @@
this.$('.microphone').hide();
},
handleAudioCapture(blob) {
this.fileInput.file = blob;
this.fileInput.isVoiceNote = true;
this.fileInput.previewImages();
this.fileInput.addAttachment({
contentType: blob.type,
file: blob,
isVoiceNote: true,
});
this.$('.bottom-bar form').submit();
},
endCaptureAudio() {
@ -1229,6 +1285,7 @@
const props = {
objectURL: getAbsoluteAttachmentPath(path),
contentType,
caption: attachment.caption,
onSave: () => this.downloadAttachment({ attachment, message }),
};
this.lightboxView = new Whisper.ReactWrapperView({
@ -1496,7 +1553,6 @@
if (event.key !== 'Escape') {
return;
}
this.closeEmojiPanel();
},
openEmojiPanel() {
@ -1504,6 +1560,7 @@
this.emojiPanel = new EmojiPanel(this.$emojiPanelContainer[0], {
onClick: this.insertEmoji.bind(this),
});
this.view.resetScrollPosition();
this.updateMessageFieldSize({});
},
closeEmojiPanel() {
@ -1513,6 +1570,7 @@
this.$emojiPanelContainer.empty().outerHeight(0);
this.emojiPanel = null;
this.view.resetScrollPosition();
this.updateMessageFieldSize({});
},
insertEmoji(e) {
@ -1560,6 +1618,7 @@
this.quoteView = null;
}
if (!this.quotedMessage) {
this.view.restoreBottomOffset();
this.updateMessageFieldSize({});
return;
}
@ -1583,16 +1642,18 @@
this.quoteView = new Whisper.ReactWrapperView({
className: 'quote-wrapper',
Component: window.Signal.Components.Quote,
elCallback: el => this.$('.send').prepend(el),
props: Object.assign({}, props, {
withContentAbove: true,
onClose: () => {
this.setQuoteMessage(null);
},
}),
onInitialRender: () => {
this.view.restoreBottomOffset();
this.updateMessageFieldSize({});
},
});
this.$('.send').prepend(this.quoteView.el);
this.updateMessageFieldSize({});
},
async sendMessage(e) {
@ -1647,7 +1708,7 @@
this.setQuoteMessage(null);
this.focusMessageFieldAndClearDisabled();
this.forceUpdateMessageFieldSize(e);
this.fileInput.deleteFiles();
this.fileInput.clearAttachments();
} catch (error) {
window.log.error(
'Error pulling attached files before send',

View File

@ -30,91 +30,412 @@
},
});
Whisper.UnsupportedFileTypeToast = Whisper.ToastView.extend({
template: i18n('unsupportedFileType'),
});
Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
template: i18n('dangerousFileType'),
});
Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({
template: i18n('oneNonImageAtATimeToast'),
});
Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({
template: i18n('cannotMixImageAdnNonImageAttachments'),
});
Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
template: i18n('maximumAttachments'),
});
Whisper.FileInputView = Backbone.View.extend({
tagName: 'span',
className: 'file-input',
initialize(options) {
this.$input = this.$('input[type=file]');
this.$input.click(e => {
e.stopPropagation();
initialize() {
this.attachments = [];
this.attachmentListView = new Whisper.ReactWrapperView({
el: this.el,
Component: window.Signal.Components.AttachmentList,
props: this.getPropsForAttachmentList(),
});
this.thumb = new Whisper.AttachmentPreviewView();
this.$el.addClass('file-input');
this.window = options.window;
this.previewObjectUrl = null;
},
events: {
'change .choose-file': 'previewImages',
'click .close': 'deleteFiles',
'click .choose-file': 'open',
drop: 'openDropped',
dragover: 'showArea',
dragleave: 'hideArea',
paste: 'onPaste',
remove() {
if (this.attachmentListView) {
this.attachmentListView.remove();
}
if (this.captionEditorView) {
this.captionEditorView.remove();
}
Backbone.View.prototype.remove.call(this);
},
open(e) {
render() {
this.attachmentListView.update(this.getPropsForAttachmentList());
this.trigger('staged-attachments-changed');
},
getPropsForAttachmentList() {
const { attachments } = this;
// We never want to display voice notes in our attachment list
if (_.any(attachments, attachment => Boolean(attachment.isVoiceNote))) {
return {
attachments: [],
};
}
return {
attachments,
onAddAttachment: this.onAddAttachment.bind(this),
onClickAttachment: this.onClickAttachment.bind(this),
onCloseAttachment: this.onCloseAttachment.bind(this),
onClose: this.onClose.bind(this),
};
},
onClickAttachment(attachment) {
const getProps = () => ({
url: attachment.videoUrl || attachment.url,
caption: attachment.caption,
attachment,
onSave,
});
const onSave = caption => {
// eslint-disable-next-line no-param-reassign
attachment.caption = caption;
this.captionEditorView.remove();
Signal.Backbone.Views.Lightbox.hide();
this.render();
};
this.captionEditorView = new Whisper.ReactWrapperView({
className: 'attachment-list-wrapper',
Component: window.Signal.Components.CaptionEditor,
props: getProps(),
onClose: () => Signal.Backbone.Views.Lightbox.hide(),
});
Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el);
},
onCloseAttachment(attachment) {
this.attachments = _.without(this.attachments, attachment);
this.render();
},
onAddAttachment() {
this.trigger('choose-attachment');
},
onClose() {
this.attachments = [];
this.render();
},
// These event handlers are called by ConversationView, which listens for these events
onDragOver(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
// hack
if (this.window && this.window.chrome && this.window.chrome.fileSystem) {
this.window.chrome.fileSystem.chooseEntry(
{ type: 'openFile' },
entry => {
if (!entry) {
return;
}
entry.file(file => {
this.file = file;
this.previewImages();
});
}
);
} else {
this.$input.click();
this.$el.addClass('dropoff');
},
onDragLeave(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
this.$el.removeClass('dropoff');
},
async onDrop(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
const { files } = e.originalEvent.dataTransfer;
for (let i = 0, max = files.length; i < max; i += 1) {
const file = files[i];
// eslint-disable-next-line no-await-in-loop
await this.maybeAddAttachment(file);
}
this.$el.removeClass('dropoff');
},
onPaste(e) {
const { items } = e.originalEvent.clipboardData;
let imgBlob = null;
for (let i = 0; i < items.length; i += 1) {
if (items[i].type.split('/')[0] === 'image') {
imgBlob = items[i].getAsFile();
}
}
if (imgBlob !== null) {
const file = imgBlob;
this.maybeAddAttachment(file);
e.stopPropagation();
e.preventDefault();
}
},
addThumb(src, options = {}) {
_.defaults(options, { addPlayIcon: false });
this.$('.avatar').hide();
this.thumb.src = src;
this.$('.attachment-previews').append(this.thumb.render().el);
// Public interface
if (options.addPlayIcon) {
this.$el.addClass('video-attachment');
} else {
this.$el.removeClass('video-attachment');
}
this.thumb.$('img')[0].onload = () => {
this.$el.trigger('force-resize');
};
this.thumb.$('img')[0].onerror = () => {
this.unableToLoadAttachment();
};
hasFiles() {
return this.attachments.length > 0;
},
unableToLoadAttachment() {
async getFiles() {
const files = await Promise.all(
this.attachments.map(attachment => this.getFile(attachment))
);
this.clearAttachments();
return files;
},
clearAttachments() {
this.attachments.forEach(attachment => {
if (attachment.url) {
URL.revokeObjectURL(attachment.url);
}
if (attachment.videoUrl) {
URL.revokeObjectURL(attachment.videoUrl);
}
});
this.attachments = [];
this.render();
this.$el.trigger('force-resize');
},
// Show errors
showLoadFailure() {
const toast = new Whisper.UnableToLoadToast();
toast.$el.insertAfter(this.$el);
toast.render();
this.deleteFiles();
},
autoScale(file) {
if (file.type.split('/')[0] !== 'image' || file.type === 'image/tiff') {
showDangerousError() {
const toast = new Whisper.DangerousFileTypeToast();
toast.$el.insertAfter(this.$el);
toast.render();
},
showFileSizeError({ limit, units, u }) {
const toast = new Whisper.FileSizeToast({
model: { limit, units: units[u] },
});
toast.$el.insertAfter(this.$el);
toast.render();
},
showCannotMixError() {
const toast = new Whisper.CannotMixImageAndNonImageAttachmentsToast();
toast.$el.insertAfter(this.$el);
toast.render();
},
showMultipleNonImageError() {
const toast = new Whisper.OneNonImageAtATimeToast();
toast.$el.insertAfter(this.$el);
toast.render();
},
showMaximumAttachmentsError() {
const toast = new Whisper.MaxAttachmentsToast();
toast.$el.insertAfter(this.$el);
toast.render();
},
// Housekeeping
addAttachment(attachment) {
if (attachment.isVoiceNote && this.attachments.length > 0) {
throw new Error('A voice note cannot be sent with other attachments');
}
this.attachments.push(attachment);
this.render();
},
async maybeAddAttachment(file) {
if (!file) {
return;
}
const fileName = file.name;
const contentType = file.type;
if (window.Signal.Util.isFileDangerous(fileName)) {
this.showDangerousError();
return;
}
if (this.attachments.length >= 32) {
this.showMaximumAttachmentsError();
return;
}
const haveNonImage = _.any(
this.attachments,
attachment => !MIME.isImage(attachment.contentType)
);
// You can't add another attachment if you already have a non-image staged
if (haveNonImage) {
this.showMultipleNonImageError();
return;
}
// You can't add a non-image attachment if you already have attachments staged
if (!MIME.isImage(contentType) && this.attachments.length > 0) {
this.showCannotMixError();
return;
}
const renderVideoPreview = async () => {
const objectUrl = URL.createObjectURL(file);
try {
const type = 'image/png';
const thumbnail = await VisualAttachment.makeVideoScreenshot({
objectUrl,
contentType: type,
logger: window.log,
});
const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
const url = Signal.Util.arrayBufferToObjectURL({
data,
type,
});
this.addAttachment({
file,
size: file.size,
fileName,
contentType,
videoUrl: objectUrl,
url,
});
} catch (error) {
URL.revokeObjectURL(objectUrl);
}
};
const renderImagePreview = async () => {
if (!MIME.isJPEG(contentType)) {
const url = URL.createObjectURL(file);
if (!url) {
throw new Error('Failed to create object url for image!');
}
this.addAttachment({
file,
size: file.size,
fileName,
contentType,
url,
});
return;
}
const url = await window.autoOrientImage(file);
this.addAttachment({
file,
size: file.size,
fileName,
contentType,
url,
});
};
try {
const blob = await this.autoScale({
contentType,
file,
});
let limitKb = 1000000;
const blobType =
file.type === 'image/gif' ? 'gif' : contentType.split('/')[0];
switch (blobType) {
case 'image':
limitKb = 6000;
break;
case 'gif':
limitKb = 25000;
break;
case 'audio':
limitKb = 100000;
break;
case 'video':
limitKb = 100000;
break;
default:
limitKb = 100000;
break;
}
if ((blob.size / 1024).toFixed(4) >= limitKb) {
const units = ['kB', 'MB', 'GB'];
let u = -1;
let limit = limitKb * 1000;
do {
limit /= 1000;
u += 1;
} while (limit >= 1000 && u < units.length - 1);
this.showFileSizeError({ limit, units, u });
return;
}
} catch (error) {
window.log.error(
'Error ensuring that image is properly sized:',
error && error.stack ? error.stack : error
);
this.showLoadFailure();
return;
}
try {
if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) {
await renderImagePreview();
} else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) {
await renderVideoPreview();
} else {
this.addAttachment({
file,
size: file.size,
contentType,
fileName,
});
}
} catch (e) {
window.log.error(
`Was unable to generate thumbnail for file type ${contentType}`,
e && e.stack ? e.stack : e
);
this.addAttachment({
file,
size: file.size,
contentType,
fileName,
});
}
},
autoScale(attachment) {
const { contentType, file } = attachment;
if (
contentType.split('/')[0] !== 'image' ||
contentType === 'image/tiff'
) {
// nothing to do
return Promise.resolve(file);
return Promise.resolve(attachment);
}
return new Promise((resolve, reject) => {
@ -132,13 +453,13 @@
img.naturalHeight <= maxHeight &&
file.size <= maxSize
) {
resolve(file);
resolve(attachment);
return;
}
const gifMaxSize = 25000 * 1024;
if (file.type === 'image/gif' && file.size <= gifMaxSize) {
resolve(file);
resolve(attachment);
return;
}
@ -170,285 +491,47 @@
}
} while (i > 0 && blob.size > maxSize);
resolve(blob);
resolve({
...attachment,
file: blob,
});
};
img.src = url;
});
},
async previewImages() {
this.clearForm();
const file = this.file || this.$input.prop('files')[0];
if (!file) {
return;
}
const { name } = file;
if (window.Signal.Util.isFileDangerous(name)) {
this.deleteFiles();
const toast = new Whisper.DangerousFileTypeToast();
toast.$el.insertAfter(this.$el);
toast.render();
return;
}
const contentType = file.type;
const renderVideoPreview = async () => {
// we use the variable on this here to ensure cleanup if we're interrupted
this.previewObjectUrl = URL.createObjectURL(file);
const type = 'image/png';
const thumbnail = await VisualAttachment.makeVideoThumbnail({
size: 100,
videoObjectUrl: this.previewObjectUrl,
contentType: type,
logger: window.log,
});
URL.revokeObjectURL(this.previewObjectUrl);
const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
this.previewObjectUrl = Signal.Util.arrayBufferToObjectURL({
data,
type,
});
this.addThumb(this.previewObjectUrl, { addPlayIcon: true });
};
const renderImagePreview = async () => {
if (!MIME.isJPEG(file.type)) {
this.previewObjectUrl = URL.createObjectURL(file);
if (!this.previewObjectUrl) {
throw new Error('Failed to create object url for image!');
}
this.addThumb(this.previewObjectUrl);
return;
}
const dataUrl = await window.autoOrientImage(file);
this.addThumb(dataUrl);
};
try {
if (Signal.Util.GoogleChrome.isImageTypeSupported(contentType)) {
await renderImagePreview();
} else if (Signal.Util.GoogleChrome.isVideoTypeSupported(contentType)) {
await renderVideoPreview();
} else if (MIME.isAudio(contentType)) {
this.addThumb('images/audio.svg');
} else {
this.addThumb('images/file.svg');
}
} catch (e) {
window.log.error(
`Was unable to generate thumbnail for file type ${contentType}`,
e && e.stack ? e.stack : e
);
this.addThumb('images/file.svg');
}
try {
const blob = await this.autoScale(file);
let limitKb = 1000000;
const blobType =
file.type === 'image/gif' ? 'gif' : contentType.split('/')[0];
switch (blobType) {
case 'image':
limitKb = 6000;
break;
case 'gif':
limitKb = 25000;
break;
case 'audio':
limitKb = 100000;
break;
case 'video':
limitKb = 100000;
break;
default:
limitKb = 100000;
break;
}
if ((blob.size / 1024).toFixed(4) >= limitKb) {
const units = ['kB', 'MB', 'GB'];
let u = -1;
let limit = limitKb * 1000;
do {
limit /= 1000;
u += 1;
} while (limit >= 1000 && u < units.length - 1);
const toast = new Whisper.FileSizeToast({
model: { limit, units: units[u] },
});
toast.$el.insertAfter(this.$el);
toast.render();
this.deleteFiles();
}
} catch (error) {
window.log.error(
'Error ensuring that image is properly sized:',
error && error.message ? error.message : error
);
this.unableToLoadAttachment();
}
},
hasFiles() {
const files = this.file ? [this.file] : this.$input.prop('files');
return files && files.length && files.length > 0;
},
getFiles() {
const files = this.file
? [this.file]
: Array.from(this.$input.prop('files'));
const promise = Promise.all(files.map(file => this.getFile(file)));
this.clearForm();
return promise;
},
getFile(rawFile) {
const file = rawFile || this.file || this.$input.prop('files')[0];
if (!file) {
async getFile(attachment) {
if (!attachment) {
return Promise.resolve();
}
const attachmentFlags = this.isVoiceNote
const attachmentFlags = attachment.isVoiceNote
? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE
: null;
const setFlags = flags => attachment => {
const newAttachment = Object.assign({}, attachment);
if (flags) {
newAttachment.flags = flags;
}
return newAttachment;
const scaled = await this.autoScale(attachment);
const fileRead = await this.readFile(scaled);
return {
...fileRead,
url: undefined,
videoUrl: undefined,
flags: attachmentFlags || null,
};
// NOTE: Temporarily allow `then` until we convert the entire file
// to `async` / `await`:
// eslint-disable-next-line more/no-then
return this.autoScale(file)
.then(this.readFile)
.then(setFlags(attachmentFlags));
},
async getThumbnail() {
// Scale and crop an image to 256px square
const size = 256;
const file = this.file || this.$input.prop('files')[0];
if (
file === undefined ||
file.type.split('/')[0] !== 'image' ||
file.type === 'image/gif'
) {
// nothing to do
return Promise.resolve();
}
const objectUrl = URL.createObjectURL(file);
const arrayBuffer = await VisualAttachment.makeImageThumbnail({
size,
objectUrl,
logger: window.log,
});
URL.revokeObjectURL(objectUrl);
return this.readFile(arrayBuffer);
},
// File -> Promise Attachment
readFile(file) {
readFile(attachment) {
return new Promise((resolve, reject) => {
const FR = new FileReader();
FR.onload = e => {
resolve({
...attachment,
data: e.target.result,
contentType: file.type,
fileName: file.name,
size: file.size,
});
};
FR.onerror = reject;
FR.onabort = reject;
FR.readAsArrayBuffer(file);
FR.readAsArrayBuffer(attachment.file);
});
},
clearForm() {
if (this.previewObjectUrl) {
URL.revokeObjectURL(this.previewObjectUrl);
this.previewObjectUrl = null;
}
this.thumb.remove();
this.$('.avatar').show();
this.$el.trigger('force-resize');
},
deleteFiles(e) {
if (e) {
e.stopPropagation();
}
this.clearForm();
this.$input
.wrap('<form>')
.parent('form')
.trigger('reset');
this.$input.unwrap();
this.file = null;
this.$input.trigger('change');
this.isVoiceNote = false;
},
openDropped(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
// eslint-disable-next-line prefer-destructuring
this.file = e.originalEvent.dataTransfer.files[0];
this.previewImages();
this.$el.removeClass('dropoff');
},
showArea(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
this.$el.addClass('dropoff');
},
hideArea(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
return;
}
e.stopPropagation();
e.preventDefault();
this.$el.removeClass('dropoff');
},
onPaste(e) {
const { items } = e.originalEvent.clipboardData;
let imgBlob = null;
for (let i = 0; i < items.length; i += 1) {
if (items[i].type.split('/')[0] === 'image') {
imgBlob = items[i].getAsFile();
}
}
if (imgBlob !== null) {
this.file = imgBlob;
this.previewImages();
}
},
});
})();

View File

@ -51,8 +51,8 @@
blue: '#336ba3',
teal: '#067589',
green: '#3b7845',
light_green: '#895d66',
blue_grey: '#607d8b',
light_green: '#1c8260',
blue_grey: '#895d66',
grey: '#6b6b78',
};
})();

View File

@ -70,6 +70,15 @@
resetScrollPosition() {
this.$el.scrollTop(this.scrollPosition - this.$el.outerHeight());
},
restoreBottomOffset() {
if (_.isNumber(this.bottomOffset)) {
// + 10 is necessary to account for padding
const height = this.$el.height() + 10;
const topOfBottomScreen = this.el.scrollHeight - height;
this.$el.scrollTop(topOfBottomScreen - this.bottomOffset);
}
},
scrollToBottomIfNeeded() {
// This is counter-intuitive. Our current bottomOffset is reflective of what
// we last measured, not necessarily the current state. And this is called

View File

@ -12,20 +12,43 @@
window.Whisper.ReactWrapperView = Backbone.View.extend({
className: 'react-wrapper',
initialize(options) {
const { Component, props, onClose } = options;
const {
Component,
props,
onClose,
tagName,
className,
onInitialRender,
elCallback,
} = options;
this.render();
if (elCallback) {
elCallback(this.el);
}
this.tagName = options.tagName;
this.className = options.className;
this.tagName = tagName;
this.className = className;
this.Component = Component;
this.onClose = onClose;
this.onInitialRender = onInitialRender;
this.update(props);
this.hasRendered = false;
},
update(props) {
const updatedProps = this.augmentProps(props);
const reactElement = React.createElement(this.Component, updatedProps);
ReactDOM.render(reactElement, this.el);
ReactDOM.render(reactElement, this.el, () => {
if (this.hasRendered) {
return;
}
this.hasRendered = true;
if (this.onInitialRender) {
this.onInitialRender();
}
});
},
augmentProps(props) {
return Object.assign({}, props, {

View File

@ -4,6 +4,7 @@
libsignal,
mnemonic,
btoa,
Signal,
getString,
Event,
dcodeIO,
@ -51,6 +52,65 @@
requestSMSVerification(number) {
// return this.server.requestVerificationSMS(number);
},
async encryptDeviceName(name, providedIdentityKey) {
const identityKey =
providedIdentityKey ||
(await textsecure.storage.protocol.getIdentityKeyPair());
if (!identityKey) {
throw new Error(
'Identity key was not provided and is not in database!'
);
}
const encrypted = await Signal.Crypto.encryptDeviceName(
name,
identityKey.pubKey
);
const proto = new textsecure.protobuf.DeviceName();
proto.ephemeralPublic = encrypted.ephemeralPublic;
proto.syntheticIv = encrypted.syntheticIv;
proto.ciphertext = encrypted.ciphertext;
const arrayBuffer = proto.encode().toArrayBuffer();
return Signal.Crypto.arrayBufferToBase64(arrayBuffer);
},
async decryptDeviceName(base64) {
const identityKey = await textsecure.storage.protocol.getIdentityKeyPair();
const arrayBuffer = Signal.Crypto.base64ToArrayBuffer(base64);
const proto = textsecure.protobuf.DeviceName.decode(arrayBuffer);
const encrypted = {
ephemeralPublic: proto.ephemeralPublic.toArrayBuffer(),
syntheticIv: proto.syntheticIv.toArrayBuffer(),
ciphertext: proto.ciphertext.toArrayBuffer(),
};
const name = await Signal.Crypto.decryptDeviceName(
encrypted,
identityKey.privKey
);
return name;
},
async maybeUpdateDeviceName() {
const isNameEncrypted = textsecure.storage.user.getDeviceNameEncrypted();
if (isNameEncrypted) {
return;
}
const deviceName = await textsecure.storage.user.getDeviceName();
const base64 = await this.encryptDeviceName(deviceName);
await this.server.updateDeviceName(base64);
},
async deviceNameIsEncrypted() {
await textsecure.storage.user.setDeviceNameEncrypted();
},
async maybeDeleteSignalingKey() {
const key = await textsecure.storage.user.getSignalingKey();
if (key) {
await this.server.removeSignalingKey();
}
},
registerSingleDevice(mnemonic, mnemonicLanguage, profileName) {
const createAccount = this.createAccount.bind(this);
const clearSessionsAndPreKeys = this.clearSessionsAndPreKeys.bind(this);
@ -362,11 +422,11 @@
await textsecure.storage.user.setNumberAndDeviceId(pubKeyString, 1);
});
},
clearSessionsAndPreKeys() {
async clearSessionsAndPreKeys() {
const store = textsecure.storage.protocol;
window.log.info('clearing all sessions, prekeys, and signed prekeys');
return Promise.all([
await Promise.all([
store.clearPreKeyStore(),
store.clearContactPreKeysStore(),
store.clearSignedPreKeysStore(),

View File

@ -180,7 +180,6 @@ MessageReceiver.prototype.extend({
// We do the message decryption here, instead of in the ordered pending queue,
// to avoid exposing the time it took us to process messages through the time-to-ack.
// TODO: handle different types of requests.
if (request.path !== '/api/v1/message') {
window.log.info('got request', request.verb, request.path);
request.respond(200, 'OK');
@ -192,7 +191,6 @@ MessageReceiver.prototype.extend({
}
const promise = Promise.resolve(request.body.toArrayBuffer()) // textsecure.crypto
// .decryptWebsocketMessage(request.body, this.signalingKey)
.then(plaintext => {
const envelope = textsecure.protobuf.Envelope.decode(plaintext);
// After this point, decoding errors are not the server's

View File

@ -43,7 +43,7 @@ function OutgoingMessage(
this.failoverNumbers = [];
this.unidentifiedDeliveries = [];
const { numberInfo, senderCertificate, online, messageType } = options;
const { numberInfo, senderCertificate, online, messageType } = options || {};
this.numberInfo = numberInfo;
this.senderCertificate = senderCertificate;
this.online = online;

View File

@ -36,6 +36,9 @@
loadProtoBufs('SubProtocol.proto');
loadProtoBufs('DeviceMessages.proto');
// Just for encrypting device names
loadProtoBufs('DeviceName.proto');
// Metadata-specific protos
loadProtoBufs('UnidentifiedDelivery.proto');
})();

View File

@ -183,15 +183,26 @@ MessageSender.prototype = {
proto.id = id;
proto.contentType = attachment.contentType;
proto.digest = result.digest;
if (attachment.fileName) {
proto.fileName = attachment.fileName;
}
if (attachment.size) {
proto.size = attachment.size;
}
if (attachment.fileName) {
proto.fileName = attachment.fileName;
}
if (attachment.flags) {
proto.flags = attachment.flags;
}
if (attachment.width) {
proto.width = attachment.width;
}
if (attachment.height) {
proto.height = attachment.height;
}
if (attachment.caption) {
proto.caption = attachment.caption;
}
return proto;
})
);

View File

@ -31,5 +31,17 @@
getDeviceName() {
return textsecure.storage.get('device_name');
},
setDeviceNameEncrypted() {
return textsecure.storage.put('deviceNameEncrypted', true);
},
getDeviceNameEncrypted() {
return textsecure.storage.get('deviceNameEncrypted');
},
getSignalingKey() {
return textsecure.storage.get('signaling_key');
},
};
})();

View File

@ -1,3 +1,5 @@
/* global libsignal */
describe('AccountManager', () => {
let accountManager;
@ -10,9 +12,14 @@ describe('AccountManager', () => {
let signedPreKeys;
const DAY = 1000 * 60 * 60 * 24;
beforeEach(() => {
beforeEach(async () => {
const identityKey = await libsignal.KeyHelper.generateIdentityKeyPair();
originalProtocolStorage = window.textsecure.storage.protocol;
window.textsecure.storage.protocol = {
getIdentityKeyPair() {
return identityKey;
},
loadSignedPreKeys() {
return Promise.resolve(signedPreKeys);
},
@ -22,6 +29,17 @@ describe('AccountManager', () => {
window.textsecure.storage.protocol = originalProtocolStorage;
});
describe('encrypted device name', () => {
it('roundtrips', async () => {
const deviceName = 'v2.5.0 on Ubunto 20.04';
const encrypted = await accountManager.encryptDeviceName(deviceName);
assert.strictEqual(typeof encrypted, 'string');
const decrypted = await accountManager.decryptDeviceName(encrypted);
assert.strictEqual(decrypted, deviceName);
});
});
it('keeps three confirmed keys even if over a week old', () => {
const now = Date.now();
signedPreKeys = [

View File

@ -27,6 +27,7 @@
const Request = function Request(options) {
this.verb = options.verb || options.type;
this.path = options.path || options.url;
this.headers = options.headers;
this.body = options.body || options.data;
this.success = options.success;
this.error = options.error;
@ -50,6 +51,7 @@
this.verb = request.verb;
this.path = request.path;
this.body = request.body;
this.headers = request.headers;
this.respond = (status, message) => {
socket.send(
@ -77,6 +79,7 @@
verb: request.verb,
path: request.path,
body: request.body,
headers: request.headers,
id: request.id,
},
})
@ -105,6 +108,7 @@
verb: message.request.verb,
path: message.request.path,
body: message.request.body,
headers: message.request.headers,
id: message.request.id,
socket,
})

View File

@ -3,7 +3,7 @@
"productName": "Loki Messenger",
"description": "Private messaging from your desktop",
"repository": "https://github.com/sloki-project/loki-messenger.git",
"version": "1.19.0",
"version": "1.20.0",
"license": "GPL-3.0",
"author": {
"name": "Open Whisper Systems",
@ -46,9 +46,8 @@
"pow-metrics": "node metrics_app.js localhost 9000"
},
"dependencies": {
"@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#ed4f4d179ac010c6347b291cbd4c2ebe5c773741",
"@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#36149a4b03ccf11ec18b9205e1bfd9056015cf07",
"@sindresorhus/is": "0.8.0",
"archiver": "2.1.1",
"backbone": "1.3.3",
"blob-util": "1.3.0",
"blueimp-canvas-to-blob": "3.14.0",
@ -95,6 +94,7 @@
"rimraf": "2.6.2",
"semver": "5.4.1",
"spellchecker": "3.4.4",
"tar": "4.4.8",
"testcheck": "1.0.0-rc.2",
"tmp": "0.0.33",
"to-arraybuffer": "1.0.1",
@ -120,7 +120,7 @@
"asar": "0.14.0",
"bower": "1.8.2",
"chai": "4.1.2",
"electron": "3.0.9",
"electron": "3.0.14",
"electron-builder": "20.13.5",
"electron-icon-maker": "0.0.3",
"eslint": "4.14.0",

View File

@ -346,3 +346,16 @@ window.Signal.Logs = require('./js/modules/logs');
// We pull this in last, because the native module involved appears to be sensitive to
// /tmp mounted as noexec on Linux.
require('./js/spell_check');
if (config.environment === 'test') {
/* eslint-disable global-require, import/no-extraneous-dependencies */
window.test = {
glob: require('glob'),
fse: require('fs-extra'),
tmp: require('tmp'),
path: require('path'),
basePath: __dirname,
attachmentsPath: window.Signal.Migrations.attachmentsPath,
};
/* eslint-enable global-require, import/no-extraneous-dependencies */
}

7
protos/DeviceName.proto Normal file
View File

@ -0,0 +1,7 @@
package signalservice;
message DeviceName {
optional bytes ephemeralPublic = 1;
optional bytes syntheticIv = 2;
optional bytes ciphertext = 3;
}

View File

@ -19,27 +19,29 @@ package signalservice;
option java_package = "org.whispersystems.websocket.messages.protobuf";
message WebSocketRequestMessage {
optional string verb = 1;
optional string path = 2;
optional bytes body = 3;
optional uint64 id = 4;
optional string verb = 1;
optional string path = 2;
optional bytes body = 3;
repeated string headers = 5;
optional uint64 id = 4;
}
message WebSocketResponseMessage {
optional uint64 id = 1;
optional uint32 status = 2;
optional string message = 3;
optional bytes body = 4;
optional uint64 id = 1;
optional uint32 status = 2;
optional string message = 3;
repeated string headers = 5;
optional bytes body = 4;
}
message WebSocketMessage {
enum Type {
UNKNOWN = 0;
REQUEST = 1;
RESPONSE = 2;
}
enum Type {
UNKNOWN = 0;
REQUEST = 1;
RESPONSE = 2;
}
optional Type type = 1;
optional WebSocketRequestMessage request = 2;
optional WebSocketResponseMessage response = 3;
optional Type type = 1;
optional WebSocketRequestMessage request = 2;
optional WebSocketResponseMessage response = 3;
}

View File

@ -184,15 +184,10 @@
// things in the composition area. A margin on an inner div won't be included in that
// height calculation.
.bottom-bar .quote-wrapper {
margin-right: 5px;
margin-bottom: 6px;
margin-top: 3px;
}
.send .quote-wrapper {
margin-left: 37px;
margin-right: 73px;
margin-bottom: 5px;
margin-top: 3px;
margin-bottom: -5px;
}
.bottom-bar {
@ -206,6 +201,7 @@
}
form.send {
margin-bottom: 0px;
background: $color-white;
&.video-attachment {
@ -282,6 +278,8 @@
padding: 10px;
border-radius: 4px;
background-color: $color-loki-light-gray;
margin-top: 3px;
margin-bottom: 6px;
color: $color-light-90;
border: 1px solid rgba(0, 0, 0, 0.2);
outline: 0;

View File

@ -88,8 +88,10 @@ button.emoji {
opacity: 0.5;
border: none;
background: transparent;
margin-top: 3px;
&:before {
margin-top: 4px;
content: '';
display: inline-block;
width: $button-height;
@ -114,7 +116,6 @@ button.emoji {
.emoji-panel-container {
height: 0px;
margin-bottom: 3px;
.ep-emojies {
background-color: $color-white;

View File

@ -108,8 +108,10 @@ a {
opacity: 0.5;
border: none;
background: transparent;
margin-top: 2px;
&:before {
margin-top: 4px;
content: '';
display: inline-block;
width: $button-height;
@ -411,15 +413,6 @@ $loading-height: 16px;
}
}
input[type='text'],
input[type='search'],
textarea {
&:active,
&:focus {
outline: 1px solid $blue;
}
}
.expiredAlert {
background: #f3f3a7;
padding: 10px;

View File

@ -128,6 +128,14 @@
color: $color-light-60;
}
.module-typing-animation__dot {
background-color: $color-gray-60;
}
.module-typing-animation__dot--light {
background-color: $color-gray-60;
}
&.dark-theme {
// _modules

View File

@ -748,6 +748,7 @@
font-size: 14px;
line-height: 18px;
color: $color-gray-90;
text-align: start;
a {
color: $color-gray-90;
@ -803,6 +804,7 @@
flex: initial;
min-width: 54px;
width: 54px;
max-height: 54px;
position: relative;
img {
@ -2194,6 +2196,7 @@
position: relative;
display: inline-block;
margin: 1px;
vertical-align: middle;
}
.module-image__caption-icon {
@ -2202,6 +2205,14 @@
left: 6px;
}
.module-image__with-click-handler {
cursor: pointer;
}
.module-image--soft-corners {
border-radius: 4px;
}
.module-image--curved-top-left {
border-top-left-radius: 16px;
}
@ -2304,6 +2315,17 @@
text-align: center;
}
.module-image__close-button {
cursor: pointer;
position: absolute;
top: 5px;
right: 5px;
width: 16px;
height: 16px;
z-index: 2;
background-image: url('../images/x-shadow-16.svg');
}
// Module: Image Grid
.module-image-grid {
@ -2417,6 +2439,272 @@
flex-grow: 1;
}
// Module: Attachments
.module-attachments {
border-top: 1px solid $color-black-015;
}
.module-attachments__header {
height: 24px;
position: relative;
}
.module-attachments__close-button {
cursor: pointer;
position: absolute;
top: 8px;
right: 16px;
width: 20px;
height: 20px;
z-index: 2;
@include color-svg('../images/x-16.svg', $color-black);
}
.module-attachments__rail {
margin-top: 12px;
margin-left: 16px;
padding-right: 16px;
overflow-x: scroll;
max-height: 142px;
white-space: nowrap;
overflow-y: hidden;
margin-bottom: 6px;
}
// Module: Staged Generic Attachment
.module-staged-generic-attachment {
height: 120px;
width: 120px;
margin: 1px;
display: inline-block;
position: relative;
border-radius: 4px;
box-shadow: inset 0px 0px 0px 1px $color-black-015;
background-color: $color-gray-05;
vertical-align: middle;
}
.module-staged-generic-attachment__close-button {
cursor: pointer;
position: absolute;
top: 5px;
right: 5px;
width: 16px;
height: 16px;
z-index: 2;
@include color-svg('../images/x-16.svg', $color-black);
}
.module-staged-generic-attachment__icon {
margin-top: 30px;
background: url('../images/file-gradient.svg') no-repeat center;
height: 44px;
width: 56px;
margin-left: 32px;
margin-right: 32px;
margin-bottom: -4px;
// So we can center the extension text inside this icon
display: flex;
flex-direction: row;
align-items: center;
}
.module-staged-generic-attachment__icon__extension {
font-size: 10px;
line-height: 13px;
letter-spacing: 0.1px;
text-transform: uppercase;
// Along with flow layout in parent item, centers text
text-align: center;
width: 25px;
margin-left: auto;
margin-right: auto;
// We don't have much room for text here, cut it off without ellipse
overflow-x: hidden;
white-space: nowrap;
text-overflow: clip;
color: $color-gray-90;
}
.module-staged-generic-attachment__filename {
margin: 7px;
margin-top: 5px;
text-align: center;
font-family: Roboto;
font-size: 14px;
overflow: hidden;
height: 2.4em;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
// Module: Caption Editor
.module-caption-editor {
background-color: $color-black;
z-index: 20;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.module-caption-editor__close-button {
z-index: 21;
cursor: pointer;
position: absolute;
top: 12px;
right: 16px;
width: 30px;
height: 30px;
z-index: 2;
@include color-svg('../images/x-16.svg', $color-white);
}
.module-caption-editor__media-container {
flex-grow: 1;
flex-shrink: 1;
background-color: $color-black;
text-align: center;
margin: 50px;
overflow: hidden;
height: 100%;
}
.module-caption-editor__image {
width: 100%;
height: 100%;
object-fit: contain;
flex-grow: 1;
flex-shrink: 1;
}
.module-caption-editor__video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
flex-grow: 1;
flex-shrink: 1;
}
.module-caption-editor__placeholder {
width: 100%;
height: 100%;
object-fit: contain;
flex-grow: 1;
flex-shrink: 1;
}
.module-caption-editor__bottom-bar {
flex-grow: 0;
flex-shrink: 0;
height: 52px;
padding: 8px;
display: inline-flex;
flex-direction: row;
align-items: middle;
margin-left: auto;
margin-right: auto;
}
.module-caption-editor__input-container {
position: relative;
}
.module-caption-editor__caption-input {
height: 36px;
width: 40em;
font-size: 14px;
color: $color-white;
border: 1px solid $color-white;
border-radius: 18px;
background-color: $color-black;
padding: 9px;
padding-left: 12px;
padding-right: 65px;
&::placeholder {
color: $color-white-07;
}
&:focus {
border: 1px solid $color-signal-blue;
outline: none;
}
}
.module-caption-editor__save-button {
position: absolute;
background-color: $color-signal-blue;
color: $color-white;
cursor: pointer;
height: 28px;
border-radius: 15px;
padding: 5px;
padding-left: 12px;
padding-right: 12px;
right: 4px;
top: 4px;
}
// Module: Staged Placeholder Attachment
.module-staged-placeholder-attachment {
margin: 1px;
border-radius: 4px;
border: 1px solid $color-gray-25;
height: 120px;
width: 120px;
display: inline-block;
vertical-align: middle;
cursor: pointer;
position: relative;
&:hover {
background: $color-gray-05;
}
}
.module-staged-placeholder-attachment__plus-icon {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: 36px;
width: 36px;
@include color-svg('../images/plus-36.svg', $color-gray-45);
}
// Third-party module: react-contextmenu
.react-contextmenu {
@ -2503,6 +2791,11 @@
display: none;
}
// To limit messages with things forcing them wider, like long attachment names
.module-message {
max-width: 300px;
}
/* Spec: container > 438px and container < 593px*/
@media (min-width: 800px) and (max-width: 925px) {
.module-message {

View File

@ -9,6 +9,7 @@
background: transparent;
padding: 0;
border: none;
margin-top: 2px;
&:focus,
&:hover {
@ -16,6 +17,7 @@
}
&:before {
margin-top: 4px;
content: '';
display: inline-block;
height: 24px;
@ -36,6 +38,7 @@
opacity: 0.5;
text-align: center;
padding: 0;
margin-top: 5px;
&:focus,
&:hover {

View File

@ -265,50 +265,6 @@ body.dark-theme {
}
}
.dropoff {
outline: solid 1px $blue;
}
.avatar {
color: $color-white;
background-color: $grey;
}
.group-info-input {
background: $color-white;
.thumbnail:after {
border-bottom: 10px solid $grey;
border-left: 10px solid transparent;
}
input.name {
border: solid 1px #ccc;
}
}
.group-member-list,
.new-group-update {
.members .contact {
border-bottom: 1px solid $color-dark-60;
}
}
.banner {
// what's the right color?
background-color: $blue_l;
color: black;
box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2);
.warning {
@include color-svg('../images/warning.svg', black);
}
.dismiss {
@include color-svg('../images/x.svg', black);
}
}
.contact-details {
.number {
color: $color-dark-30;
@ -353,15 +309,6 @@ body.dark-theme {
}
}
input[type='text'],
input[type='search'],
textarea {
&:active,
&:focus {
outline: 1px solid $blue;
}
}
.expiredAlert {
background: #f3f3a7;
@ -749,14 +696,9 @@ body.dark-theme {
}
.module-message__generic-attachment__icon {
// TODO: this will eventually be a different image
// background: url('../images/file-gradient.svg') no-repeat center;
}
.module-message__generic-attachment__icon__extension {
// TODO: probably need color
}
.module-message__generic-attachment__file-name {
color: $color-dark-05;
}
@ -1051,10 +993,6 @@ body.dark-theme {
@include color-svg('../images/movie.svg', $color-signal-blue);
}
.module-quote__generic-file__icon {
// TODO: this will eventually be a different icon
// background: url('../images/file-gradient.svg');
}
.module-quote__generic-file__text {
color: $color-dark-05;
}
@ -1415,6 +1353,10 @@ body.dark-theme {
color: $color-dark-05;
}
// Module: Image
// Module: Image Grid
// Module: Typing Animation
.module-typing-animation__dot {
@ -1425,6 +1367,50 @@ body.dark-theme {
background-color: $color-white;
}
// Module: Attachments
.module-attachments {
border-top: 1px solid $color-gray-75;
}
.module-attachments__close-button {
@include color-svg('../images/x.svg', $color-gray-45);
}
// Module: Staged Generic Attachment
.module-staged-generic-attachment {
box-shadow: inset 0px 0px 0px 1px $color-gray-45;
background-color: $color-gray-75;
color: $color-dark-05;
}
.module-staged-generic-attachment__close-button {
@include color-svg('../images/x.svg', $color-gray-45);
}
.module-staged-generic-attachment__icon {
background: url('../images/file-gradient.svg') no-repeat center;
}
.module-staged-generic-attachment__icon__extension {
color: $color-gray-90;
}
// Module: Staged Placeholder Attachment
.module-staged-placeholder-attachment {
border: 1px solid $color-gray-60;
&:hover {
background: $color-gray-75;
}
}
.module-staged-placeholder-attachment__plus-icon {
@include color-svg('../images/plus-36.svg', $color-gray-60);
}
// Third-party module: react-contextmenu
.react-contextmenu {

View File

@ -1,10 +1,6 @@
/* global Signal: false */
/* global Whisper: false */
/* global assert: false */
/* global textsecure: false */
/* global _: false */
/* global Signal, Whisper, assert, textsecure, _, libsignal */
/* eslint-disable no-unreachable, no-console */
/* eslint-disable no-console */
'use strict';
@ -240,8 +236,8 @@ describe('Backup', () => {
});
describe('end-to-end', () => {
it('exports then imports to produce the same data we started with', async () => {
return;
it('exports then imports to produce the same data we started with', async function thisNeeded() {
this.timeout(6000);
const { attachmentsPath, fse, glob, path, tmp } = window.test;
const {
@ -249,46 +245,32 @@ describe('Backup', () => {
loadAttachmentData,
} = window.Signal.Migrations;
const key = new Uint8Array([
1,
3,
4,
5,
6,
7,
8,
11,
23,
34,
1,
34,
3,
5,
45,
45,
1,
3,
4,
5,
6,
7,
8,
11,
23,
34,
1,
34,
3,
5,
45,
45,
]);
const staticKeyPair = await libsignal.KeyHelper.generateIdentityKeyPair();
const attachmentsPattern = path.join(attachmentsPath, '**');
const OUR_NUMBER = '+12025550000';
const CONTACT_ONE_NUMBER = '+12025550001';
const CONTACT_TWO_NUMBER = '+12025550002';
const toArrayBuffer = nodeBuffer =>
nodeBuffer.buffer.slice(
nodeBuffer.byteOffset,
nodeBuffer.byteOffset + nodeBuffer.byteLength
);
const getFixture = target => toArrayBuffer(fse.readFileSync(target));
const FIXTURES = {
gif: getFixture('fixtures/giphy-7GFfijngKbeNy.gif'),
mp4: getFixture('fixtures/pixabay-Soap-Bubble-7141.mp4'),
jpg: getFixture('fixtures/koushik-chowdavarapu-105425-unsplash.jpg'),
mp3: getFixture('fixtures/incompetech-com-Agnus-Dei-X.mp3'),
txt: getFixture('fixtures/lorem-ipsum.txt'),
png: getFixture(
'fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png'
),
};
async function wrappedLoadAttachment(attachment) {
return _.omit(await loadAttachmentData(attachment), ['path']);
}
@ -376,16 +358,30 @@ describe('Backup', () => {
})
),
attachments: await Promise.all(
(message.attachments || []).map(attachment =>
wrappedLoadAttachment(attachment)
)
(message.attachments || []).map(async attachment => {
await wrappedLoadAttachment(attachment);
if (attachment.thumbnail) {
await wrappedLoadAttachment(attachment.thumbnail);
}
if (attachment.screenshot) {
await wrappedLoadAttachment(attachment.screenshot);
}
return attachment;
})
),
});
}
let backupDir;
try {
const ATTACHMENT_COUNT = 3;
// Seven total:
// - Five from image/video attachments
// - One from embedded contact avatar
// - Another from embedded quoted attachment thumbnail
const ATTACHMENT_COUNT = 7;
const MESSAGE_COUNT = 1;
const CONVERSATION_COUNT = 1;
@ -397,47 +393,20 @@ describe('Backup', () => {
timestamp: 1524185933350,
errors: [],
attachments: [
// Note: generates two more files: screenshot and thumbnail
{
contentType: 'image/gif',
fileName: 'sad_cat.gif',
data: new Uint8Array([
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
]).buffer,
contentType: 'video/mp4',
fileName: 'video.mp4',
data: FIXTURES.mp4,
},
// Note: generates one more file: thumbnail
{
contentType: 'image/png',
fileName: 'landscape.png',
data: FIXTURES.png,
},
],
hasAttachments: 1,
hasFileAttachments: undefined,
hasVisualMediaAttachments: 1,
quote: {
text: "Isn't it cute?",
@ -450,43 +419,10 @@ describe('Backup', () => {
},
{
contentType: 'image/gif',
fileName: 'happy_cat.gif',
fileName: 'avatar.gif',
thumbnail: {
contentType: 'image/png',
data: new Uint8Array([
2,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
]).buffer,
data: FIXTURES.gif,
},
},
],
@ -506,40 +442,7 @@ describe('Backup', () => {
isProfile: false,
avatar: {
contentType: 'image/png',
data: new Uint8Array([
3,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
]).buffer,
data: FIXTURES.png,
},
},
},
@ -552,107 +455,30 @@ describe('Backup', () => {
console.log('Backup test: Create models, save to db/disk');
const message = await upgradeMessageSchema(messageWithAttachments);
console.log({ message });
const messageModel = new Whisper.Message(message);
const id = await window.Signal.Data.saveMessage(
messageModel.attributes,
{
Message: Whisper.Message,
}
);
messageModel.set({ id });
await window.Signal.Data.saveMessage(message, {
Message: Whisper.Message,
});
const conversation = {
active_at: 1524185933350,
color: 'orange',
expireTimer: 0,
id: CONTACT_ONE_NUMBER,
lastMessage: 'Heyo!',
name: 'Someone Somewhere',
profileAvatar: {
contentType: 'image/jpeg',
data: new Uint8Array([
4,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
]).buffer,
data: FIXTURES.jpeg,
size: 64,
},
profileKey: new Uint8Array([
5,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
1,
2,
3,
4,
5,
6,
7,
8,
]).buffer,
profileKey: 'BASE64KEY',
profileName: 'Someone! 🤔',
profileSharing: true,
timestamp: 1524185933350,
tokens: [
'someone somewhere',
'someone',
'somewhere',
'2025550001',
'12025550001',
],
type: 'private',
unreadCount: 0,
verified: 0,
sealedSender: 0,
version: 2,
};
console.log({ conversation });
await window.Signal.Data.saveConversation(conversation, {
@ -669,11 +495,13 @@ describe('Backup', () => {
console.log('Backup test: Export!');
backupDir = tmp.dirSync().name;
console.log({ backupDir });
await Signal.Backup.exportToDirectory(backupDir, { key });
await Signal.Backup.exportToDirectory(backupDir, {
key: staticKeyPair.pubKey,
});
console.log('Backup test: Ensure that messages.zip exists');
const zipPath = path.join(backupDir, 'messages.zip');
const messageZipExists = fse.existsSync(zipPath);
console.log('Backup test: Ensure that messages.tar.gz exists');
const archivePath = path.join(backupDir, 'messages.tar.gz');
const messageZipExists = fse.existsSync(archivePath);
assert.strictEqual(true, messageZipExists);
console.log(
@ -688,43 +516,9 @@ describe('Backup', () => {
await clearAllData();
console.log('Backup test: Import!');
await Signal.Backup.importFromDirectory(backupDir, { key });
console.log('Backup test: ensure that all attachments were imported');
const recreatedAttachmentFiles = removeDirs(
glob.sync(attachmentsPattern)
);
console.log({ recreatedAttachmentFiles });
assert.strictEqual(ATTACHMENT_COUNT, recreatedAttachmentFiles.length);
assert.deepEqual(attachmentFiles, recreatedAttachmentFiles);
console.log('Backup test: Check messages');
const messageCollection = await window.Signal.Data.getAllMessages({
MessageCollection: Whisper.MessageCollection,
await Signal.Backup.importFromDirectory(backupDir, {
key: staticKeyPair.privKey,
});
assert.strictEqual(messageCollection.length, MESSAGE_COUNT);
const messageFromDB = removeId(messageCollection.at(0).attributes);
const expectedMessage = omitUndefinedKeys(message);
console.log({ messageFromDB, expectedMessage });
assert.deepEqual(messageFromDB, expectedMessage);
console.log(
'Backup test: Check that all attachments were successfully imported'
);
const messageWithAttachmentsFromDB = await loadAllFilesFromDisk(
messageFromDB
);
const expectedMessageWithAttachments = omitUndefinedKeys(
messageWithAttachments
);
console.log({
messageWithAttachmentsFromDB,
expectedMessageWithAttachments,
});
assert.deepEqual(
_.omit(messageWithAttachmentsFromDB, ['schemaVersion']),
expectedMessageWithAttachments
);
console.log('Backup test: Check conversations');
const conversationCollection = await window.Signal.Data.getAllConversations(
@ -734,11 +528,55 @@ describe('Backup', () => {
);
assert.strictEqual(conversationCollection.length, CONVERSATION_COUNT);
// We need to ommit any custom fields we have added
const ommited = [
'profileAvatar',
'swarmNodes',
'friendRequestStatus',
'unlockTimestamp',
'sessionResetStatus',
];
const conversationFromDB = conversationCollection.at(0).attributes;
console.log({ conversationFromDB, conversation });
assert.deepEqual(
conversationFromDB,
_.omit(conversation, ['profileAvatar'])
_.omit(conversationFromDB, ommited),
_.omit(conversation, ommited)
);
console.log('Backup test: Check messages');
const messageCollection = await window.Signal.Data.getAllMessages({
MessageCollection: Whisper.MessageCollection,
});
assert.strictEqual(messageCollection.length, MESSAGE_COUNT);
const messageFromDB = removeId(messageCollection.at(0).attributes);
const expectedMessage = messageFromDB;
console.log({ messageFromDB, expectedMessage });
assert.deepEqual(messageFromDB, expectedMessage);
console.log('Backup test: ensure that all attachments were imported');
const recreatedAttachmentFiles = removeDirs(
glob.sync(attachmentsPattern)
);
console.log({ recreatedAttachmentFiles });
assert.strictEqual(ATTACHMENT_COUNT, recreatedAttachmentFiles.length);
assert.deepEqual(attachmentFiles, recreatedAttachmentFiles);
console.log(
'Backup test: Check that all attachments were successfully imported'
);
const messageWithAttachmentsFromDB = await loadAllFilesFromDisk(
messageFromDB
);
const expectedMessageWithAttachments = await loadAllFilesFromDisk(
omitUndefinedKeys(message)
);
console.log({
messageWithAttachmentsFromDB,
expectedMessageWithAttachments,
});
assert.deepEqual(
_.omit(messageWithAttachmentsFromDB, ['sent']),
expectedMessageWithAttachments
);
console.log('Backup test: Clear all data');

View File

@ -1,4 +1,4 @@
/* global Signal, textsecure */
/* global Signal, textsecure, libsignal */
'use strict';
@ -44,7 +44,7 @@ describe('Crypto', () => {
const encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext);
const uintArray = new Uint8Array(encrypted);
uintArray[2] = 9;
uintArray[2] += 2;
try {
await Signal.Crypto.decryptSymmetric(key, uintArray.buffer);
@ -69,7 +69,7 @@ describe('Crypto', () => {
const encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext);
const uintArray = new Uint8Array(encrypted);
uintArray[uintArray.length - 3] = 9;
uintArray[uintArray.length - 3] += 2;
try {
await Signal.Crypto.decryptSymmetric(key, uintArray.buffer);
@ -94,7 +94,7 @@ describe('Crypto', () => {
const encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext);
const uintArray = new Uint8Array(encrypted);
uintArray[35] = 9;
uintArray[35] += 9;
try {
await Signal.Crypto.decryptSymmetric(key, uintArray.buffer);
@ -109,4 +109,67 @@ describe('Crypto', () => {
throw new Error('Expected error to be thrown');
});
});
describe('encrypted device name', () => {
it('roundtrips', async () => {
const deviceName = 'v1.19.0 on Windows 10';
const identityKey = await libsignal.KeyHelper.generateIdentityKeyPair();
const encrypted = await Signal.Crypto.encryptDeviceName(
deviceName,
identityKey.pubKey
);
const decrypted = await Signal.Crypto.decryptDeviceName(
encrypted,
identityKey.privKey
);
assert.strictEqual(decrypted, deviceName);
});
it('fails if iv is changed', async () => {
const deviceName = 'v1.19.0 on Windows 10';
const identityKey = await libsignal.KeyHelper.generateIdentityKeyPair();
const encrypted = await Signal.Crypto.encryptDeviceName(
deviceName,
identityKey.pubKey
);
encrypted.syntheticIv = Signal.Crypto.getRandomBytes(16);
try {
await Signal.Crypto.decryptDeviceName(encrypted, identityKey.privKey);
} catch (error) {
assert.strictEqual(
error.message,
'decryptDeviceName: synthetic IV did not match'
);
}
});
});
describe('attachment encryption', () => {
it('roundtrips', async () => {
const staticKeyPair = await libsignal.KeyHelper.generateIdentityKeyPair();
const message = 'this is my message';
const plaintext = Signal.Crypto.bytesFromString(message);
const path =
'fa/facdf99c22945b1c9393345599a276f4b36ad7ccdc8c2467f5441b742c2d11fa';
const encrypted = await Signal.Crypto.encryptAttachment(
staticKeyPair.pubKey.slice(1),
path,
plaintext
);
const decrypted = await Signal.Crypto.decryptAttachment(
staticKeyPair.privKey,
path,
encrypted
);
const equal = Signal.Crypto.constantTimeEqual(plaintext, decrypted);
if (!equal) {
throw new Error('The output and input did not match!');
}
});
});
});

View File

@ -148,7 +148,9 @@ InMemorySignalProtocolStore.prototype = {
};
describe('SecretSessionCipher', () => {
it('successfully roundtrips', async () => {
it('successfully roundtrips', async function thisNeeded() {
this.timeout(4000);
const aliceStore = new InMemorySignalProtocolStore();
const bobStore = new InMemorySignalProtocolStore();
@ -187,7 +189,9 @@ describe('SecretSessionCipher', () => {
assert.strictEqual(decryptResult.sender.toString(), '+14151111111.1');
});
it('fails when untrusted', async () => {
it('fails when untrusted', async function thisNeeded() {
this.timeout(4000);
const aliceStore = new InMemorySignalProtocolStore();
const bobStore = new InMemorySignalProtocolStore();
@ -226,7 +230,9 @@ describe('SecretSessionCipher', () => {
}
});
it('fails when expired', async () => {
it('fails when expired', async function thisNeeded() {
this.timeout(4000);
const aliceStore = new InMemorySignalProtocolStore();
const bobStore = new InMemorySignalProtocolStore();
@ -264,7 +270,9 @@ describe('SecretSessionCipher', () => {
}
});
it('fails when wrong identity', async () => {
it('fails when wrong identity', async function thisNeeded() {
this.timeout(4000);
const aliceStore = new InMemorySignalProtocolStore();
const bobStore = new InMemorySignalProtocolStore();

View File

@ -0,0 +1,76 @@
## Image
```js
let caption = null;
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<CaptionEditor
url={util.gifObjectUrl}
attachment={{
contentType: 'image/jpeg',
}}
onSave={caption => console.log('onSave', caption)}
close={() => console.log('close')}
i18n={util.i18n}
/>
</div>;
```
## Image with caption
```js
let caption =
"This is the user-provided caption. We show it overlaid on the image. If it's really long, then it wraps, but it doesn't get too close to the edges of the image.";
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<CaptionEditor
url="https://placekitten.com/800/600"
attachment={{
contentType: 'image/jpeg',
}}
caption={caption}
contentType="image/jpeg"
onSave={caption => console.log('onSave', caption)}
close={() => console.log('close')}
i18n={util.i18n}
/>
</div>;
```
## Video
```js
let caption = null;
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<CaptionEditor
url="fixtures/pixabay-Soap-Bubble-7141.mp4"
attachment={{
contentType: 'video/mp4',
}}
onSave={caption => console.log('onSave', caption)}
close={() => console.log('close')}
i18n={util.i18n}
/>
</div>;
```
## Video with caption
```js
let caption =
"This is the user-provided caption. We show it overlaid on the image. If it's really long, then it wraps, but it doesn't get too close to the edges of the image.";
<div style={{ position: 'relative', width: '100%', height: 500 }}>
<CaptionEditor
url="fixtures/pixabay-Soap-Bubble-7141.mp4"
attachment={{
contentType: 'video/mp4',
}}
caption={caption}
onSave={caption => console.log('onSave', caption)}
close={() => console.log('close')}
i18n={util.i18n}
/>
</div>;
```

View File

@ -0,0 +1,168 @@
// tslint:disable:react-a11y-anchors
import React from 'react';
import * as GoogleChrome from '../util/GoogleChrome';
import { AttachmentType } from './conversation/types';
import { Localizer } from '../types/Util';
interface Props {
attachment: AttachmentType;
i18n: Localizer;
url: string;
caption?: string;
onSave?: (caption: string) => void;
close?: () => void;
}
interface State {
caption: string;
}
export class CaptionEditor extends React.Component<Props, State> {
private handleKeyUpBound: (
event: React.KeyboardEvent<HTMLInputElement>
) => void;
private setFocusBound: () => void;
// TypeScript doesn't like our React.Ref typing here, so we omit it
private captureRefBound: () => void;
private onChangeBound: () => void;
private onSaveBound: () => void;
private inputRef: React.Ref<HTMLInputElement> | null;
constructor(props: Props) {
super(props);
const { caption } = props;
this.state = {
caption: caption || '',
};
this.handleKeyUpBound = this.handleKeyUp.bind(this);
this.setFocusBound = this.setFocus.bind(this);
this.captureRefBound = this.captureRef.bind(this);
this.onChangeBound = this.onChange.bind(this);
this.onSaveBound = this.onSave.bind(this);
this.inputRef = null;
}
public handleKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
const { close, onSave } = this.props;
if (close && event.key === 'Escape') {
close();
}
if (onSave && event.key === 'Enter') {
const { caption } = this.state;
onSave(caption);
}
}
public setFocus() {
if (this.inputRef) {
// @ts-ignore
this.inputRef.focus();
}
}
public captureRef(ref: React.Ref<HTMLInputElement>) {
this.inputRef = ref;
// Forcing focus after a delay due to some focus contention with ConversationView
setTimeout(() => {
this.setFocus();
}, 200);
}
public onSave() {
const { onSave } = this.props;
const { caption } = this.state;
if (onSave) {
onSave(caption);
}
}
public onChange(event: React.FormEvent<HTMLInputElement>) {
// @ts-ignore
const { value } = event.target;
this.setState({
caption: value,
});
}
public renderObject() {
const { url, i18n, attachment } = this.props;
const { contentType } = attachment || { contentType: null };
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
if (isImageTypeSupported) {
return (
<img
className="module-caption-editor__image"
alt={i18n('imageAttachmentAlt')}
src={url}
/>
);
}
const isVideoTypeSupported = GoogleChrome.isVideoTypeSupported(contentType);
if (isVideoTypeSupported) {
return (
<video className="module-caption-editor__video" controls={true}>
<source src={url} />
</video>
);
}
return <div className="module-caption-editor__placeholder" />;
}
public render() {
const { i18n, close } = this.props;
const { caption } = this.state;
return (
<div
role="dialog"
onClick={this.setFocusBound}
className="module-caption-editor"
>
<div
role="button"
onClick={close}
className="module-caption-editor__close-button"
/>
<div className="module-caption-editor__media-container">
{this.renderObject()}
</div>
<div className="module-caption-editor__bottom-bar">
<div className="module-caption-editor__input-container">
<input
type="text"
ref={this.captureRefBound}
value={caption}
maxLength={200}
placeholder={i18n('addACaption')}
className="module-caption-editor__caption-input"
onKeyUp={close ? this.handleKeyUpBound : undefined}
onChange={this.onChangeBound}
/>
{caption ? (
<div
role="button"
onClick={this.onSaveBound}
className="module-caption-editor__save-button"
>
{i18n('save')}
</div>
) : null}
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,118 @@
### One image
```jsx
const attachments = [
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
];
<util.ConversationContext theme={util.theme}>
<AttachmentList
attachments={attachments}
onClose={() => console.log('onClose')}
onClickAttachment={attachment => {
console.log('onClickAttachment', attachment);
}}
onCloseAttachment={attachment => {
console.log('onCloseAttachment', attachment);
}}
onAddAttachment={() => console.log('onAddAttachment')}
i18n={util.i18n}
/>
</util.ConversationContext>;
```
### Four images
```jsx
const attachments = [
{
url: util.gifObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 800,
height: 1200,
},
{
url: util.landscapeObjectUrl,
contentType: 'image/png',
width: 4496,
height: 3000,
},
{
url: util.landscapeGreenObjectUrl,
contentType: 'image/png',
width: 1000,
height: 50,
},
];
<util.ConversationContext theme={util.theme}>
<AttachmentList
attachments={attachments}
onClose={() => console.log('onClose')}
onClickAttachment={attachment => {
console.log('onClickAttachment', attachment);
}}
onCloseAttachment={attachment => {
console.log('onCloseAttachment', attachment);
}}
onAddAttachment={() => console.log('onAddAttachment')}
i18n={util.i18n}
/>
</util.ConversationContext>;
```
### A mix of attachment types
```jsx
const attachments = [
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
{
contentType: 'text/plain',
fileName: 'manifesto.txt',
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 800,
height: 1200,
},
];
<util.ConversationContext theme={util.theme}>
<AttachmentList
attachments={attachments}
onClose={() => console.log('onClose')}
onClickAttachment={attachment => {
console.log('onClickAttachment', attachment);
}}
onCloseAttachment={attachment => {
console.log('onCloseAttachment', attachment);
}}
onAddAttachment={() => console.log('onAddAttachment')}
i18n={util.i18n}
/>
</util.ConversationContext>;
```
### No attachments provided
Nothing is shown if attachment list is empty.
```jsx
<AttachmentList attachments={[]} i18n={util.i18n} />
```

View File

@ -0,0 +1,117 @@
import React from 'react';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../../util/GoogleChrome';
import { AttachmentType } from './types';
import { Image } from './Image';
import { areAllAttachmentsVisual } from './ImageGrid';
import { StagedGenericAttachment } from './StagedGenericAttachment';
import { StagedPlaceholderAttachment } from './StagedPlaceholderAttachment';
import { Localizer } from '../../types/Util';
interface Props {
attachments: Array<AttachmentType>;
i18n: Localizer;
// onError: () => void;
onClickAttachment: (attachment: AttachmentType) => void;
onCloseAttachment: (attachment: AttachmentType) => void;
onAddAttachment: () => void;
onClose: () => void;
}
const IMAGE_WIDTH = 120;
const IMAGE_HEIGHT = 120;
export class AttachmentList extends React.Component<Props> {
// tslint:disable-next-line max-func-body-length */
public render() {
const {
attachments,
i18n,
onAddAttachment,
onClickAttachment,
onCloseAttachment,
onClose,
} = this.props;
if (!attachments.length) {
return null;
}
const allVisualAttachments = areAllAttachmentsVisual(attachments);
return (
<div className="module-attachments">
{attachments.length > 1 ? (
<div className="module-attachments__header">
<div
role="button"
onClick={onClose}
className="module-attachments__close-button"
/>
</div>
) : null}
<div className="module-attachments__rail">
{(attachments || []).map((attachment, index) => {
const { contentType } = attachment;
if (
isImageTypeSupported(contentType) ||
isVideoTypeSupported(contentType)
) {
return (
<Image
key={getUrl(attachment) || attachment.fileName || index}
alt={i18n('stagedImageAttachment', [
getUrl(attachment) || attachment.fileName,
])}
i18n={i18n}
attachment={attachment}
softCorners={true}
playIconOverlay={isVideoAttachment(attachment)}
height={IMAGE_HEIGHT}
width={IMAGE_WIDTH}
url={getUrl(attachment)}
closeButton={true}
onClick={
attachments.length > 1 ? onClickAttachment : undefined
}
onClickClose={onCloseAttachment}
/>
);
}
return (
<StagedGenericAttachment
key={getUrl(attachment) || attachment.fileName || index}
attachment={attachment}
i18n={i18n}
onClose={onCloseAttachment}
/>
);
})}
{allVisualAttachments ? (
<StagedPlaceholderAttachment onClick={onAddAttachment} />
) : null}
</div>
</div>
);
}
}
export function isVideoAttachment(attachment?: AttachmentType) {
return (
attachment &&
attachment.contentType &&
isVideoTypeSupported(attachment.contentType)
);
}
function getUrl(attachment: AttachmentType) {
if (attachment.screenshot) {
return attachment.screenshot.url;
}
return attachment.url;
}

View File

@ -8,6 +8,7 @@ import {
getRegex,
getReplacementData,
getTitle,
SizeClassType,
} from '../../util/emoji';
import { Localizer, RenderTextCallback } from '../../types/Util';
@ -20,7 +21,7 @@ function getImageTag({
i18n,
}: {
match: any;
sizeClass: string | undefined;
sizeClass?: SizeClassType;
key: string | number;
i18n: Localizer;
}) {
@ -51,7 +52,7 @@ function getImageTag({
interface Props {
text: string;
/** A class name to be added to the generated emoji images */
sizeClass?: '' | 'small' | 'medium' | 'large' | 'jumbo';
sizeClass?: SizeClassType;
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
renderNonEmoji?: RenderTextCallback;
i18n: Localizer;

View File

@ -77,18 +77,21 @@
width="199"
attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl}
i18n={util.i18n}
/>
<Image
height="149"
width="149"
attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl}
i18n={util.i18n}
/>
<Image
height="99"
width="99"
attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl}
i18n={util.i18n}
/>
</div>
<hr />
@ -100,6 +103,7 @@
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
i18n={util.i18n}
/>
<Image
height="149"
@ -108,6 +112,7 @@
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
i18n={util.i18n}
/>
<Image
height="99"
@ -116,6 +121,82 @@
darkOverlay
overlayText="+3"
url={util.pngObjectUrl}
i18n={util.i18n}
/>
</div>
</div>
```
### With top-right X and soft corners
```jsx
<div>
<div>
<Image
height="200"
width="199"
closeButton={true}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
<Image
height="149"
width="149"
closeButton={true}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
<Image
height="99"
width="99"
closeButton={true}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
</div>
<br />
<div>
<Image
height="200"
width="199"
closeButton={true}
attachment={{ caption: 'dogs playing' }}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
<Image
height="149"
width="149"
closeButton={true}
attachment={{ caption: 'dogs playing' }}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
<Image
height="99"
width="99"
closeButton={true}
attachment={{ caption: 'dogs playing' }}
onClick={() => console.log('onClick')}
onClickClose={attachment => console.log('onClickClose', attachment)}
softCorners={true}
url={util.gifObjectUrl}
i18n={util.i18n}
/>
</div>
</div>

View File

@ -15,24 +15,29 @@ interface Props {
overlayText?: string;
bottomOverlay?: boolean;
closeButton?: boolean;
curveBottomLeft?: boolean;
curveBottomRight?: boolean;
curveTopLeft?: boolean;
curveTopRight?: boolean;
darkOverlay?: boolean;
playIconOverlay?: boolean;
softCorners?: boolean;
i18n: Localizer;
onClick?: (attachment: AttachmentType) => void;
onClickClose?: (attachment: AttachmentType) => void;
onError?: () => void;
}
export class Image extends React.Component<Props> {
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
public render() {
const {
alt,
attachment,
bottomOverlay,
closeButton,
curveBottomLeft,
curveBottomRight,
curveTopLeft,
@ -41,9 +46,11 @@ export class Image extends React.Component<Props> {
height,
i18n,
onClick,
onClickClose,
onError,
overlayText,
playIconOverlay,
softCorners,
url,
width,
} = this.props;
@ -52,18 +59,20 @@ export class Image extends React.Component<Props> {
return (
<div
role={onClick ? 'button' : undefined}
onClick={() => {
if (onClick) {
onClick(attachment);
}
}}
role="button"
className={classNames(
'module-image',
onClick ? 'module-image__with-click-handler' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null,
curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null
curveTopRight ? 'module-image--curved-top-right' : null,
softCorners ? 'module-image--soft-corners' : null
)}
>
<img
@ -88,9 +97,22 @@ export class Image extends React.Component<Props> {
curveTopRight ? 'module-image--curved-top-right' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null,
softCorners ? 'module-image--soft-corners' : null,
darkOverlay ? 'module-image__border-overlay--dark' : null
)}
/>
{closeButton ? (
<div
role="button"
onClick={(e: React.MouseEvent<{}>) => {
e.stopPropagation();
if (onClickClose) {
onClickClose(attachment);
}
}}
className="module-image__close-button"
/>
) : null}
{bottomOverlay ? (
<div
className={classNames(

View File

@ -352,3 +352,35 @@ const attachments = [
</div>
</div>;
```
### Mixing attachment types
```
const attachments = [
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
{
contentType: 'text/plain',
},
{
url: util.pngObjectUrl,
contentType: 'image/png',
width: 320,
height: 240,
},
];
<div>
<div>
<ImageGrid attachments={attachments} i18n={util.i18n} />
</div>
<hr />
<div>
<ImageGrid withContentAbove withContentBelow attachments={attachments} i18n={util.i18n} />
</div>
</div>;
```

View File

@ -24,7 +24,7 @@ interface Props {
const MAX_WIDTH = 300;
const MAX_HEIGHT = MAX_WIDTH * 1.5;
const MIN_WIDTH = 200;
const MIN_HEIGHT = 25;
const MIN_HEIGHT = 50;
export class ImageGrid extends React.Component<Props> {
// tslint:disable-next-line max-func-body-length */
@ -50,7 +50,7 @@ export class ImageGrid extends React.Component<Props> {
return null;
}
if (attachments.length === 1) {
if (attachments.length === 1 || !areAllAttachmentsVisual(attachments)) {
const { height, width } = getImageDimensions(attachments[0]);
return (
@ -93,7 +93,7 @@ export class ImageGrid extends React.Component<Props> {
playIconOverlay={isVideoAttachment(attachments[0])}
height={149}
width={149}
url={getUrl(attachments[0])}
url={getThumbnailUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
@ -107,7 +107,7 @@ export class ImageGrid extends React.Component<Props> {
height={149}
width={149}
attachment={attachments[1]}
url={getUrl(attachments[1])}
url={getThumbnailUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
@ -141,7 +141,7 @@ export class ImageGrid extends React.Component<Props> {
width={99}
attachment={attachments[1]}
playIconOverlay={isVideoAttachment(attachments[1])}
url={getUrl(attachments[1])}
url={getThumbnailUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
@ -154,7 +154,7 @@ export class ImageGrid extends React.Component<Props> {
width={99}
attachment={attachments[2]}
playIconOverlay={isVideoAttachment(attachments[2])}
url={getUrl(attachments[2])}
url={getThumbnailUrl(attachments[2])}
onClick={onClickAttachment}
onError={onError}
/>
@ -176,7 +176,7 @@ export class ImageGrid extends React.Component<Props> {
playIconOverlay={isVideoAttachment(attachments[0])}
height={149}
width={149}
url={getUrl(attachments[0])}
url={getThumbnailUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
@ -188,7 +188,7 @@ export class ImageGrid extends React.Component<Props> {
height={149}
width={149}
attachment={attachments[1]}
url={getUrl(attachments[1])}
url={getThumbnailUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
@ -203,7 +203,7 @@ export class ImageGrid extends React.Component<Props> {
height={149}
width={149}
attachment={attachments[2]}
url={getUrl(attachments[2])}
url={getThumbnailUrl(attachments[2])}
onClick={onClickAttachment}
onError={onError}
/>
@ -216,7 +216,7 @@ export class ImageGrid extends React.Component<Props> {
height={149}
width={149}
attachment={attachments[3]}
url={getUrl(attachments[3])}
url={getThumbnailUrl(attachments[3])}
onClick={onClickAttachment}
onError={onError}
/>
@ -238,7 +238,7 @@ export class ImageGrid extends React.Component<Props> {
playIconOverlay={isVideoAttachment(attachments[0])}
height={149}
width={149}
url={getUrl(attachments[0])}
url={getThumbnailUrl(attachments[0])}
onClick={onClickAttachment}
onError={onError}
/>
@ -250,7 +250,7 @@ export class ImageGrid extends React.Component<Props> {
height={149}
width={149}
attachment={attachments[1]}
url={getUrl(attachments[1])}
url={getThumbnailUrl(attachments[1])}
onClick={onClickAttachment}
onError={onError}
/>
@ -265,7 +265,7 @@ export class ImageGrid extends React.Component<Props> {
height={99}
width={99}
attachment={attachments[2]}
url={getUrl(attachments[2])}
url={getThumbnailUrl(attachments[2])}
onClick={onClickAttachment}
onError={onError}
/>
@ -277,7 +277,7 @@ export class ImageGrid extends React.Component<Props> {
height={99}
width={98}
attachment={attachments[3]}
url={getUrl(attachments[3])}
url={getThumbnailUrl(attachments[3])}
onClick={onClickAttachment}
onError={onError}
/>
@ -296,7 +296,7 @@ export class ImageGrid extends React.Component<Props> {
: undefined
}
attachment={attachments[4]}
url={getUrl(attachments[4])}
url={getThumbnailUrl(attachments[4])}
onClick={onClickAttachment}
onError={onError}
/>
@ -307,6 +307,14 @@ export class ImageGrid extends React.Component<Props> {
}
}
function getThumbnailUrl(attachment: AttachmentType) {
if (attachment.thumbnail) {
return attachment.thumbnail.url;
}
return getUrl(attachment);
}
function getUrl(attachment: AttachmentType) {
if (attachment.screenshot) {
return attachment.screenshot.url;
@ -324,6 +332,13 @@ export function isImage(attachments?: Array<AttachmentType>) {
);
}
export function isImageAttachment(attachment: AttachmentType) {
return (
attachment &&
attachment.contentType &&
isImageTypeSupported(attachment.contentType)
);
}
export function hasImage(attachments?: Array<AttachmentType>) {
return attachments && attachments[0] && attachments[0].url;
}
@ -374,6 +389,24 @@ function getImageDimensions(attachment: AttachmentType): DimensionsType {
};
}
export function areAllAttachmentsVisual(
attachments?: Array<AttachmentType>
): boolean {
if (!attachments) {
return false;
}
const max = attachments.length;
for (let i = 0; i < max; i += 1) {
const attachment = attachments[i];
if (!isImageAttachment(attachment) && !isVideoAttachment(attachment)) {
return false;
}
}
return true;
}
export function getGridDimensions(
attachments?: Array<AttachmentType>
): null | DimensionsType {

View File

@ -79,52 +79,6 @@ interface State {
imageBroken: boolean;
}
function isAudio(attachments?: Array<AttachmentType>) {
return (
attachments &&
attachments[0] &&
attachments[0].contentType &&
MIME.isAudio(attachments[0].contentType)
);
}
function canDisplayImage(attachments?: Array<AttachmentType>) {
const { height, width } =
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
return (
height &&
height > 0 &&
height <= 4096 &&
width &&
width > 0 &&
width <= 4096
);
}
function getExtension({
fileName,
contentType,
}: {
fileName: string;
contentType: MIME.MIMEType;
}): string | null {
if (fileName && fileName.indexOf('.') >= 0) {
const lastPeriod = fileName.lastIndexOf('.');
const extension = fileName.slice(lastPeriod + 1);
if (extension.length) {
return extension;
}
}
const slash = contentType.indexOf('/');
if (slash >= 0) {
return contentType.slice(slash + 1);
}
return null;
}
const EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600;
@ -847,3 +801,49 @@ export class Message extends React.Component<Props, State> {
);
}
}
export function getExtension({
fileName,
contentType,
}: {
fileName: string;
contentType: MIME.MIMEType;
}): string | null {
if (fileName && fileName.indexOf('.') >= 0) {
const lastPeriod = fileName.lastIndexOf('.');
const extension = fileName.slice(lastPeriod + 1);
if (extension.length) {
return extension;
}
}
const slash = contentType.indexOf('/');
if (slash >= 0) {
return contentType.slice(slash + 1);
}
return null;
}
function isAudio(attachments?: Array<AttachmentType>) {
return (
attachments &&
attachments[0] &&
attachments[0].contentType &&
MIME.isAudio(attachments[0].contentType)
);
}
function canDisplayImage(attachments?: Array<AttachmentType>) {
const { height, width } =
attachments && attachments[0] ? attachments[0] : { height: 0, width: 0 };
return (
height &&
height > 0 &&
height <= 4096 &&
width &&
width > 0 &&
width <= 4096
);
}

View File

@ -44,3 +44,9 @@
```jsx
<MessageBody text="http://somewhere.com" disableLinks i18n={util.i18n} />
```
### Emoji in link
```jsx
<MessageBody text="http://somewhere.com?s=🔥\nCool, huh?" i18n={util.i18n} />
```

View File

@ -1,6 +1,6 @@
import React from 'react';
import { getSizeClass } from '../../util/emoji';
import { getSizeClass, SizeClassType } from '../../util/emoji';
import { Emojify } from './Emojify';
import { AddNewLines } from './AddNewLines';
import { Linkify } from './Linkify';
@ -21,8 +21,26 @@ const renderNewLines: RenderTextCallback = ({
key,
}) => <AddNewLines key={key} text={textWithNewLines} />;
const renderLinks: RenderTextCallback = ({ text: textWithLinks, key }) => (
<Linkify key={key} text={textWithLinks} renderNonLink={renderNewLines} />
const renderEmoji = ({
i18n,
text,
key,
sizeClass,
renderNonEmoji,
}: {
i18n: Localizer;
text: string;
key: number;
sizeClass?: SizeClassType;
renderNonEmoji: RenderTextCallback;
}) => (
<Emojify
i18n={i18n}
key={key}
text={text}
sizeClass={sizeClass}
renderNonEmoji={renderNonEmoji}
/>
);
/**
@ -34,14 +52,30 @@ const renderLinks: RenderTextCallback = ({ text: textWithLinks, key }) => (
export class MessageBody extends React.Component<Props> {
public render() {
const { text, disableJumbomoji, disableLinks, i18n } = this.props;
const sizeClass = disableJumbomoji ? '' : getSizeClass(text);
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
if (disableLinks) {
return renderEmoji({
i18n,
text,
sizeClass,
key: 0,
renderNonEmoji: renderNewLines,
});
}
return (
<Emojify
<Linkify
text={text}
sizeClass={sizeClass}
renderNonEmoji={disableLinks ? renderNewLines : renderLinks}
i18n={i18n}
renderNonLink={({ key, text: nonLinkText }) => {
return renderEmoji({
i18n,
text: nonLinkText,
sizeClass,
key,
renderNonEmoji: renderNewLines,
});
}}
/>
);
}

View File

@ -0,0 +1,50 @@
Text file
```js
const attachment = {
contentType: 'text/plain',
fileName: 'manifesto.txt',
};
<util.ConversationContext theme={util.theme}>
<StagedGenericAttachment
attachment={attachment}
i18n={util.i18n}
onClose={attachment => console.log('onClose', attachment)}
/>
</util.ConversationContext>;
```
File with long name
```js
const attachment = {
contentType: 'text/plain',
fileName: 'this-is-my-very-important-manifesto-you-must-read-it.txt',
};
<util.ConversationContext theme={util.theme}>
<StagedGenericAttachment
attachment={attachment}
i18n={util.i18n}
onClose={attachment => console.log('onClose', attachment)}
/>
</util.ConversationContext>;
```
File with long extension
```js
const attachment = {
contentType: 'text/plain',
fileName: 'manifesto.reallylongtxt',
};
<util.ConversationContext theme={util.theme}>
<StagedGenericAttachment
attachment={attachment}
i18n={util.i18n}
onClose={attachment => console.log('onClose', attachment)}
/>
</util.ConversationContext>;
```

View File

@ -0,0 +1,44 @@
import React from 'react';
import { getExtension } from './Message';
import { Localizer } from '../../types/Util';
import { AttachmentType } from './types';
interface Props {
attachment: AttachmentType;
onClose: (attachment: AttachmentType) => void;
i18n: Localizer;
}
export class StagedGenericAttachment extends React.Component<Props> {
public render() {
const { attachment, onClose } = this.props;
const { fileName, contentType } = attachment;
const extension = getExtension({ contentType, fileName });
return (
<div className="module-staged-generic-attachment">
<div
className="module-staged-generic-attachment__close-button"
role="button"
onClick={() => {
if (onClose) {
onClose(attachment);
}
}}
/>
<div className="module-staged-generic-attachment__icon">
{extension ? (
<div className="module-staged-generic-attachment__icon__extension">
{extension}
</div>
) : null}
</div>
<div className="module-staged-generic-attachment__filename">
{fileName}
</div>
</div>
);
}
}

View File

@ -0,0 +1,10 @@
```js
const attachment = {
contentType: 'text/plain',
fileName: 'manifesto.txt',
};
<util.ConversationContext theme={util.theme}>
<StagedPlaceholderAttachment onClick={attachment => console.log('onClick')} />
</util.ConversationContext>;
```

View File

@ -0,0 +1,21 @@
import React from 'react';
interface Props {
onClick: () => void;
}
export class StagedPlaceholderAttachment extends React.Component<Props> {
public render() {
const { onClick } = this.props;
return (
<div
className="module-staged-placeholder-attachment"
role="button"
onClick={onClick}
>
<div className="module-staged-placeholder-attachment__plus-icon" />
</div>
);
}
}

View File

@ -1,7 +1,7 @@
### In message bubble
```jsx
<util.ConversationContext theme={util.theme}>
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<TypingBubble conversationType="direct" i18n={util.i18n} />
</li>
@ -14,7 +14,7 @@
### In message bubble, group conversation
```jsx
<util.ConversationContext theme={util.theme}>
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<TypingBubble color="red" conversationType="group" i18n={util.i18n} />
</li>

View File

@ -10,6 +10,8 @@ instance.include_title = true;
instance.replace_mode = 'img';
instance.supports_css = false; // needed to avoid spans with background-image
export type SizeClassType = '' | 'small' | 'medium' | 'large' | 'jumbo';
export function getRegex(): RegExp {
return instance.rx_unified;
}
@ -56,7 +58,7 @@ function hasNormalCharacters(str: string) {
return noEmoji.length > 0;
}
export function getSizeClass(str: string) {
export function getSizeClass(str: string): SizeClassType {
if (hasNormalCharacters(str)) {
return '';
}

View File

@ -244,7 +244,7 @@
"rule": "jQuery-wrap(",
"path": "js/background.js",
"line": " wrap(",
"lineNumber": 727,
"lineNumber": 740,
"reasonCategory": "falseMatch",
"updated": "2018-10-18T22:23:00.485Z"
},
@ -252,7 +252,7 @@
"rule": "jQuery-wrap(",
"path": "js/background.js",
"line": " await wrap(",
"lineNumber": 1257,
"lineNumber": 1270,
"reasonCategory": "falseMatch",
"updated": "2018-10-26T22:43:23.229Z"
},
@ -319,7 +319,7 @@
"rule": "jQuery-wrap(",
"path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');",
"lineNumber": 271,
"lineNumber": 38,
"reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z"
},
@ -327,7 +327,7 @@
"rule": "jQuery-wrap(",
"path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();",
"lineNumber": 274,
"lineNumber": 41,
"reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z"
},
@ -335,7 +335,7 @@
"rule": "jQuery-wrap(",
"path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();",
"lineNumber": 278,
"lineNumber": 45,
"reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z"
},
@ -343,7 +343,7 @@
"rule": "jQuery-wrap(",
"path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();",
"lineNumber": 282,
"lineNumber": 49,
"reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z"
},
@ -351,7 +351,7 @@
"rule": "jQuery-wrap(",
"path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');",
"lineNumber": 285,
"lineNumber": 52,
"reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z"
},
@ -655,474 +655,6 @@
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " template: $('#conversation').html(),",
"lineNumber": 73,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-html(",
"path": "js/views/conversation_view.js",
"line": " template: $('#conversation').html(),",
"lineNumber": 73,
"reasonCategory": "usageTrusted",
"updated": "2018-09-15T00:38:04.183Z",
"reasonDetail": "Getting the value, not setting it"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.loadingScreen.$el.prependTo(this.$('.discussion-container'));",
"lineNumber": 143,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T19:09:08.182Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-prependTo(",
"path": "js/views/conversation_view.js",
"line": " this.loadingScreen.$el.prependTo(this.$('.discussion-container'));",
"lineNumber": 143,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T19:07:46.079Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " el: this.$('form.send'),",
"lineNumber": 147,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T19:07:46.079Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.conversation-header').append(this.titleView.el);",
"lineNumber": 205,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-append(",
"path": "js/views/conversation_view.js",
"line": " this.$('.conversation-header').append(this.titleView.el);",
"lineNumber": 205,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.discussion-container').append(this.view.el);",
"lineNumber": 211,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-append(",
"path": "js/views/conversation_view.js",
"line": " this.$('.discussion-container').append(this.view.el);",
"lineNumber": 211,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$messageField = this.$('.send-message');",
"lineNumber": 214,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.send-message').focus(this.focusBottomBar.bind(this));",
"lineNumber": 232,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$emojiPanelContainer = this.$('.emoji-panel-container');",
"lineNumber": 235,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " const container = this.$('.discussion-container');",
"lineNumber": 421,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-append(",
"path": "js/views/conversation_view.js",
"line": " container.append(this.banner.el);",
"lineNumber": 422,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.typingBubbleView.$el.appendTo(this.$('.typing-container'));",
"lineNumber": 459,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "$() parameter is a hard-coded string"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js",
"line": " this.typingBubbleView.$el.appendTo(this.$('.typing-container'));",
"lineNumber": 459,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "Both parameters are known elements from the DOM"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.send-message').val().length > 0 ||",
"lineNumber": 468,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.capture-audio').hide();",
"lineNumber": 471,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.capture-audio').show();",
"lineNumber": 473,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " if (this.$('.send-message').val().length > 2000) {",
"lineNumber": 477,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.android-length-warning').hide();",
"lineNumber": 480,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " view.$el.appendTo(this.$('.capture-audio'));",
"lineNumber": 500,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js",
"line": " view.$el.appendTo(this.$('.capture-audio'));",
"lineNumber": 500,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.send-message').attr('disabled', true);",
"lineNumber": 502,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.bottom-bar form').submit();",
"lineNumber": 509,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.send-message').removeAttr('disabled');",
"lineNumber": 512,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.bottom-bar form').removeClass('active');",
"lineNumber": 518,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.bottom-bar form').addClass('active');",
"lineNumber": 521,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " const container = this.$('.discussion-container');",
"lineNumber": 609,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-append(",
"path": "js/views/conversation_view.js",
"line": " container.append(this.scrollDownButton.el);",
"lineNumber": 610,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 637,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 670,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 674,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " const el = this.$(`#${databaseId}`);",
"lineNumber": 681,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 684,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));",
"lineNumber": 861,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-insertBefore(",
"path": "js/views/conversation_view.js",
"line": " lastSeenEl.insertBefore(this.$(`#${oldestUnread.get('id')}`));",
"lineNumber": 861,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.bar-container').show();",
"lineNumber": 916,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.bar-container').hide();",
"lineNumber": 928,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " const el = this.$(`#${message.id}`);",
"lineNumber": 1025,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js",
"line": " this.$el.prepend(dialog.el);",
"lineNumber": 1098,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 1121,
"reasonCategory": "usageTrusted",
"updated": "2018-10-11T19:22:47.331Z",
"reasonDetail": "Operating on already-existing DOM elements"
},
{
"rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js",
"line": " this.$el.prepend(dialog.el);",
"lineNumber": 1149,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " view.$el.insertBefore(this.$('.panel').first());",
"lineNumber": 1283,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-insertBefore(",
"path": "js/views/conversation_view.js",
"line": " view.$el.insertBefore(this.$('.panel').first());",
"lineNumber": 1283,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js",
"line": " this.$el.prepend(dialog.el);",
"lineNumber": 1361,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.send').prepend(this.quoteView.el);",
"lineNumber": 1531,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-prepend(",
"path": "js/views/conversation_view.js",
"line": " this.$('.send').prepend(this.quoteView.el);",
"lineNumber": 1531,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-appendTo(",
"path": "js/views/conversation_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 1555,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.bottom-bar form').submit();",
"lineNumber": 1610,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " const $attachmentPreviews = this.$('.attachment-previews');",
"lineNumber": 1619,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/conversation_view.js",
"line": " this.$('.panel').css('display') === 'none'",
"lineNumber": 1650,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/debug_log_view.js",
@ -1195,105 +727,6 @@
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js",
"line": " this.$input = this.$('input[type=file]');",
"lineNumber": 45,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js",
"line": " this.$('.avatar').hide();",
"lineNumber": 88,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js",
"line": " this.$('.attachment-previews').append(this.thumb.render().el);",
"lineNumber": 90,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-append(",
"path": "js/views/file_input_view.js",
"line": " this.$('.attachment-previews').append(this.thumb.render().el);",
"lineNumber": 90,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js",
"line": " this.thumb.$('img')[0].onload = () => {",
"lineNumber": 98,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js",
"line": " this.thumb.$('img')[0].onerror = () => {",
"lineNumber": 101,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-insertAfter(",
"path": "js/views/file_input_view.js",
"line": " toast.$el.insertAfter(this.$el);",
"lineNumber": 108,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-insertAfter(",
"path": "js/views/file_input_view.js",
"line": " toast.$el.insertAfter(this.$el);",
"lineNumber": 190,
"reasonCategory": "usageTrusted",
"updated": "2018-10-11T19:22:47.331Z",
"reasonDetail": "Operating on already-existing DOM elements"
},
{
"rule": "jQuery-insertAfter(",
"path": "js/views/file_input_view.js",
"line": " toast.$el.insertAfter(this.$el);",
"lineNumber": 284,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/file_input_view.js",
"line": " this.$('.avatar').show();",
"lineNumber": 388,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-wrap(",
"path": "js/views/file_input_view.js",
"line": " .wrap('<form>')",
"lineNumber": 398,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Hard-coded value"
},
{
"rule": "jQuery-$(",
"path": "js/views/group_member_list_view.js",
@ -1692,7 +1125,7 @@
"rule": "jQuery-append(",
"path": "js/views/message_list_view.js",
"line": " this.$messages.append(view.el);",
"lineNumber": 102,
"lineNumber": 111,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "view.el is a known DOM element"
@ -1701,7 +1134,7 @@
"rule": "jQuery-prepend(",
"path": "js/views/message_list_view.js",
"line": " this.$messages.prepend(view.el);",
"lineNumber": 105,
"lineNumber": 114,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "view.el is a known DOM element"
@ -1710,7 +1143,7 @@
"rule": "jQuery-$(",
"path": "js/views/message_list_view.js",
"line": " const next = this.$(`#${this.collection.at(index + 1).id}`);",
"lineNumber": 108,
"lineNumber": 117,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "Message ids are GUIDs, and therefore the resultant string for $() is an id"
@ -1719,7 +1152,7 @@
"rule": "jQuery-insertBefore(",
"path": "js/views/message_list_view.js",
"line": " view.$el.insertBefore(next);",
"lineNumber": 111,
"lineNumber": 120,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "next is a known DOM element"
@ -1728,7 +1161,7 @@
"rule": "jQuery-insertAfter(",
"path": "js/views/message_list_view.js",
"line": " view.$el.insertAfter(prev);",
"lineNumber": 113,
"lineNumber": 122,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "prev is a known DOM element"
@ -1737,7 +1170,7 @@
"rule": "jQuery-insertBefore(",
"path": "js/views/message_list_view.js",
"line": " view.$el.insertBefore(elements[i]);",
"lineNumber": 122,
"lineNumber": 131,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "elements[i] is a known DOM element"
@ -1746,7 +1179,7 @@
"rule": "jQuery-append(",
"path": "js/views/message_list_view.js",
"line": " this.$messages.append(view.el);",
"lineNumber": 127,
"lineNumber": 136,
"reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "view.el is a known DOM element"
@ -2352,7 +1785,7 @@
"rule": "jQuery-wrap(",
"path": "libtextsecure/message_receiver.js",
"line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);",
"lineNumber": 785,
"lineNumber": 794,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
@ -2360,7 +1793,7 @@
"rule": "jQuery-wrap(",
"path": "libtextsecure/message_receiver.js",
"line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);",
"lineNumber": 810,
"lineNumber": 819,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
@ -3174,7 +2607,7 @@
"rule": "jQuery-append(",
"path": "node_modules/electron/electron.d.ts",
"line": " append(menuItem: MenuItem): void;",
"lineNumber": 3243,
"lineNumber": 3232,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
@ -3493,7 +2926,7 @@
"lineNumber": 4136,
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "<optional>"
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-wrap(",
@ -4083,7 +3516,7 @@
"lineNumber": 483,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "<optional>"
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-appendTo(",
@ -5849,7 +5282,7 @@
"lineNumber": 1699,
"reasonCategory": "falseMatch",
"updated": "2018-09-18T19:19:27.699Z",
"reasonDetail": "<optional>"
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "DOM-innerHTML",

View File

@ -49,6 +49,10 @@ const allSourceFiles = glob.sync(searchPattern, { nodir: true });
const results: Array<ExceptionType> = [];
const excludedFiles = [
// High-traffic files in our project
'^js/views/conversation_view.js',
'^js/views/file_input_view.js',
// Generated files
'^js/components.js',
'^js/libtextsecure.js',

View File

@ -22,9 +22,9 @@
"7zip-bin-mac" "~1.0.1"
"7zip-bin-win" "~2.2.0"
"@journeyapps/sqlcipher@https://github.com/scottnonnenberg-signal/node-sqlcipher.git#ed4f4d179ac010c6347b291cbd4c2ebe5c773741":
"@journeyapps/sqlcipher@https://github.com/scottnonnenberg-signal/node-sqlcipher.git#36149a4b03ccf11ec18b9205e1bfd9056015cf07":
version "3.2.1"
resolved "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#ed4f4d179ac010c6347b291cbd4c2ebe5c773741"
resolved "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#36149a4b03ccf11ec18b9205e1bfd9056015cf07"
dependencies:
nan "^2.10.0"
node-pre-gyp "^0.10.0"
@ -391,20 +391,6 @@ archiver-utils@^1.3.0:
normalize-path "^2.0.0"
readable-stream "^2.0.0"
archiver@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/archiver/-/archiver-2.1.1.tgz#ff662b4a78201494a3ee544d3a33fe7496509ebc"
integrity sha1-/2YrSnggFJSj7lRNOjP+dJZQnrw=
dependencies:
archiver-utils "^1.3.0"
async "^2.0.0"
buffer-crc32 "^0.2.1"
glob "^7.0.0"
lodash "^4.8.0"
readable-stream "^2.0.0"
tar-stream "^1.5.0"
zip-stream "^1.2.0"
archiver@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/archiver/-/archiver-2.1.0.tgz#d2df2e8d5773a82c1dcce925ccc41450ea999afd"
@ -1363,6 +1349,11 @@ chownr@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181"
chownr@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494"
integrity sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==
chrome-trace-event@^0.1.1:
version "0.1.2"
resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-0.1.2.tgz#90f36885d5345a50621332f0717b595883d5d982"
@ -2526,10 +2517,10 @@ electron-updater@2.21.10:
semver "^5.5.0"
source-map-support "^0.5.5"
electron@3.0.9:
version "3.0.9"
resolved "https://registry.yarnpkg.com/electron/-/electron-3.0.9.tgz#79bd25dfd5496918a00d579e702fb83082f1a036"
integrity sha512-OoSoeUWo9PzbArgrwS1yTfTRSlpXmIgrFGWUuUZCjKAk4DGR70elHDNeRnnBJ9NTwXXZVifChcfx73Ah3GnlVQ==
electron@3.0.14:
version "3.0.14"
resolved "https://registry.yarnpkg.com/electron/-/electron-3.0.14.tgz#d54c51de3651c0fe48a6a6e9aef1ca98e5ea5796"
integrity sha512-1fG9bE0LzL5QXeEq2MC0dHdVO0pbZOnNlVAIyOyJaCFAu/TjLhxQfWj38bFUEojzuVlaR87tZz0iy2qlVZj3sw==
dependencies:
"@types/node" "^8.0.24"
electron-download "^4.1.0"
@ -3104,6 +3095,7 @@ file-sync-cmp@^0.1.0:
file-type@^3.1.0:
version "3.9.0"
resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9"
integrity sha1-JXoHg4TR24CHvESdEH1SpSZyuek=
file-uri-to-path@1:
version "1.0.0"
@ -5500,12 +5492,27 @@ minipass@^2.2.1, minipass@^2.3.3:
safe-buffer "^5.1.2"
yallist "^3.0.0"
minipass@^2.3.4:
version "2.3.5"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848"
integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==
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"
minizlib@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.1.tgz#6734acc045a46e61d596a43bb9d9cd326e19cc42"
integrity sha512-TrfjCjk4jLhcJyGMYymBH6oTXcWjYbUAXTHDbtnWHjZC25h0cdajHuPE1zxb4DVmu8crfh+HwH/WMuyLG0nHBg==
dependencies:
minipass "^2.2.1"
mississippi@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f"
@ -8340,6 +8347,7 @@ string-width@^2.1.0, string-width@^2.1.1:
string_decoder@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
dependencies:
safe-buffer "~5.1.0"
@ -8507,6 +8515,19 @@ tar-stream@^1.5.0:
readable-stream "^2.0.0"
xtend "^4.0.0"
tar@4.4.8:
version "4.4.8"
resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d"
integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==
dependencies:
chownr "^1.1.1"
fs-minipass "^1.2.5"
minipass "^2.3.4"
minizlib "^1.1.1"
mkdirp "^0.5.0"
safe-buffer "^5.1.2"
yallist "^3.0.2"
tar@^2.0.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1"