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 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 Please note that this tracker is only for bugs and feature requests. Please try these
locations if you have a question or comment: 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. --> <!-- Give an overall summary of the issue. -->
### Steps to reproduce ### Steps to Reproduce
<!-- Using bullet points, list the steps that reproduce the bug. --> <!-- 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 2. step two
3. step three 3. step three
Actual result: Actual Result:
<!-- Describe the details of the buggy behaviour. --> <!-- Describe the details of the buggy behaviour. -->
Expected result: Expected Result:
<!-- Describe in detail what the correct behavior should be. --> <!-- 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. You can drag and drop images into this text box.
--> -->
### Platform info ### Platform Info
Loki Messenger version: Loki Messenger version:
@ -58,16 +58,16 @@ Operating System:
<!-- Instructions for finding your OS version are here: http://whatsmyos.com/ --> <!-- Instructions for finding your OS version are here: http://whatsmyos.com/ -->
Linked device version: Linked Device Version:
<!-- Android: Settings -> Advanced, iOS: Settings -> General -> About --> <!-- 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. 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: In most cases, a log from your other devices is also useful:
Android: https://support.signal.org/hc/en-us/articles/212535838 Android: https://support.signal.org/hc/en-us/articles/360007318591#android_debug
iOS: https://support.signal.org/hc/en-us/articles/229676507 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.registerTask('getExpireTime', () => {
grunt.task.requires('gitinfo'); grunt.task.requires('gitinfo');
const gitinfo = grunt.config.get('gitinfo'); const gitinfo = grunt.config.get('gitinfo');
const commited = gitinfo.local.branch.current.lastCommitTime; const committed = gitinfo.local.branch.current.lastCommitTime;
const time = Date.parse(commited) + 1000 * 60 * 60 * 24 * 90; const time = Date.parse(committed) + 1000 * 60 * 60 * 24 * 90;
grunt.file.write( grunt.file.write(
'config/local-production.json', 'config/local-production.json',
`${JSON.stringify({ buildExpiration: time })}\n` `${JSON.stringify({ buildExpiration: time })}\n`
@ -307,7 +307,7 @@ module.exports = grunt => {
app.client app.client
.execute(getMochaResults) .execute(getMochaResults)
.then(data => Boolean(data.value)), .then(data => Boolean(data.value)),
10000, 25000,
'Expected to find window.mochaResults set!' 'Expected to find window.mochaResults set!'
) )
) )

View File

@ -1,5 +1,7 @@
# Loki Messenger # 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). 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 ## Summary

View File

@ -172,6 +172,10 @@
"message": "Choose folder", "message": "Choose folder",
"description": "Button to allow the user to find a folder on disk" "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": { "loadDataHeader": {
"message": "Load your data", "message": "Load your data",
"description": "Header shown on the first screen in the data import process" "description": "Header shown on the first screen in the data import process"
@ -542,15 +546,37 @@
"message": "Voice Message", "message": "Voice Message",
"description": "Name for a voice message attachment" "description": "Name for a voice message attachment"
}, },
"unsupportedFileType": {
"message": "Unsupported file type",
"description": "Displayed for outgoing unsupported attachment"
},
"dangerousFileType": { "dangerousFileType": {
"message": "Attachment type not allowed for security reasons", "message": "Attachment type not allowed for security reasons",
"description": "description":
"Shown in toast when user attempts to send .exe file, for example" "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": { "fileSizeWarning": {
"message": "Sorry, the selected file exceeds message size restrictions." "message": "Sorry, the selected file exceeds message size restrictions."
}, },
@ -732,6 +758,12 @@
"description": "description":
"Shown in toast if user clicks on quote references messages not loaded in view, but in database" "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": { "you": {
"message": "You", "message": "You",
"description": "description":
@ -936,6 +968,16 @@
"description": "description":
"Used for the icon layered on top of an image in message bubbles" "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": { "fileIconAlt": {
"message": "File icon", "message": "File icon",
"description": "description":

View File

@ -24,6 +24,7 @@ module.exports = {
createOrUpdateGroup, createOrUpdateGroup,
getGroupById, getGroupById,
getAllGroupIds, getAllGroupIds,
getAllGroups,
bulkAddGroups, bulkAddGroups,
removeGroupById, removeGroupById,
removeAllGroups, removeAllGroups,
@ -92,7 +93,10 @@ module.exports = {
getAllConversationIds, getAllConversationIds,
getAllPrivateConversations, getAllPrivateConversations,
getAllGroupsInvolvingId, getAllGroupsInvolvingId,
searchConversations, searchConversations,
searchMessages,
searchMessagesInConversation,
getMessageCount, getMessageCount,
saveMessage, saveMessage,
@ -540,6 +544,69 @@ async function updateToSchemaVersion7(currentVersion, instance) {
console.log('updateToSchemaVersion7: success!'); 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 = [ const SCHEMA_VERSIONS = [
updateToSchemaVersion1, updateToSchemaVersion1,
updateToSchemaVersion2, updateToSchemaVersion2,
@ -548,6 +615,7 @@ const SCHEMA_VERSIONS = [
() => null, // version 5 was dropped () => null, // version 5 was dropped
updateToSchemaVersion6, updateToSchemaVersion6,
updateToSchemaVersion7, updateToSchemaVersion7,
updateToSchemaVersion8,
]; ];
async function updateSchema(instance) { async function updateSchema(instance) {
@ -598,7 +666,7 @@ async function initialize({ configDir, key }) {
const promisified = promisify(sqlInstance); const promisified = promisify(sqlInstance);
// promisified.on('trace', async statement => { // promisified.on('trace', async statement => {
// if (!db) { // if (!db || statement.startsWith('--')) {
// console._log(statement); // console._log(statement);
// return; // return;
// } // }
@ -669,6 +737,10 @@ async function getAllGroupIds() {
const rows = await db.all('SELECT id FROM groups ORDER BY id ASC;'); const rows = await db.all('SELECT id FROM groups ORDER BY id ASC;');
return map(rows, row => row.id); 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) { async function bulkAddGroups(array) {
return bulkAdd(GROUPS_TABLE, array); return bulkAdd(GROUPS_TABLE, array);
} }
@ -1232,9 +1304,11 @@ async function getAllGroupsInvolvingId(id) {
async function searchConversations(query) { async function searchConversations(query) {
const rows = await db.all( const rows = await db.all(
`SELECT json FROM conversations WHERE `SELECT json FROM conversations WHERE
id LIKE $id OR (
name LIKE $name OR id LIKE $id OR
profileName LIKE $profileName name LIKE $name OR
profileName LIKE $profileName
)
ORDER BY id ASC;`, ORDER BY id ASC;`,
{ {
$id: `%${query}%`, $id: `%${query}%`,
@ -1246,6 +1320,58 @@ async function searchConversations(query) {
return map(rows, row => jsonToObject(row.json)); 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() { async function getMessageCount() {
const row = await db.get('SELECT count(*) from messages;'); const row = await db.get('SELECT count(*) from messages;');
@ -1258,6 +1384,7 @@ async function getMessageCount() {
async function saveMessage(data, { forceSave } = {}) { async function saveMessage(data, { forceSave } = {}) {
const { const {
body,
conversationId, conversationId,
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
expires_at, expires_at,
@ -1283,6 +1410,7 @@ async function saveMessage(data, { forceSave } = {}) {
$id: id, $id: id,
$json: objectToJSON(data), $json: objectToJSON(data),
$body: body,
$conversationId: conversationId, $conversationId: conversationId,
$expirationStartTimestamp: expirationStartTimestamp, $expirationStartTimestamp: expirationStartTimestamp,
$expires_at: expires_at, $expires_at: expires_at,
@ -1296,7 +1424,7 @@ async function saveMessage(data, { forceSave } = {}) {
$sent_at: sent_at, $sent_at: sent_at,
$source: source, $source: source,
$sourceDevice: sourceDevice, $sourceDevice: sourceDevice,
$type: type, $type: type || '',
$unread: unread, $unread: unread,
}; };
@ -1304,6 +1432,7 @@ async function saveMessage(data, { forceSave } = {}) {
await db.run( await db.run(
`UPDATE messages SET `UPDATE messages SET
json = $json, json = $json,
body = $body,
conversationId = $conversationId, conversationId = $conversationId,
expirationStartTimestamp = $expirationStartTimestamp, expirationStartTimestamp = $expirationStartTimestamp,
expires_at = $expires_at, expires_at = $expires_at,
@ -1337,6 +1466,7 @@ async function saveMessage(data, { forceSave } = {}) {
id, id,
json, json,
body,
conversationId, conversationId,
expirationStartTimestamp, expirationStartTimestamp,
expires_at, expires_at,
@ -1356,6 +1486,7 @@ async function saveMessage(data, { forceSave } = {}) {
$id, $id,
$json, $json,
$body,
$conversationId, $conversationId,
$expirationStartTimestamp, $expirationStartTimestamp,
$expires_at, $expires_at,

View File

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

View File

@ -143,9 +143,9 @@
<div class='bottom-bar' id='footer'> <div class='bottom-bar' id='footer'>
<div class='emoji-panel-container'></div> <div class='emoji-panel-container'></div>
<div class='attachment-list'></div>
<div class='compose'> <div class='compose'>
<form class='send clearfix'> <form class='send clearfix file-input'>
<div class='attachment-previews'></div>
<div class='flex'> <div class='flex'>
<button class='emoji' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button> <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> <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>
<div class='choose-file hide'> <div class='choose-file hide'>
<button class='paperclip thumbnail' {{#disable-inputs}} disabled="disabled" {{/disable-inputs}}></button> <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>
</div> </div>
</form> </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/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/scroll_down_button_view.js'></script>
<script type='text/javascript' src='js/views/toast_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/file_input_view.js'></script>
<script type='text/javascript' src='js/views/list_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> <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' textsecure.storage.user.getDeviceId() != '1'
) { ) {
window.getSyncRequest(); 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'; // const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery';

View File

@ -2063,6 +2063,21 @@
return this.id; 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() { isPrivate() {
return this.get('type') === 'private'; return this.get('type') === 'private';
}, },
@ -2099,7 +2114,7 @@
const symbol = this.isValid() ? '#' : '!'; const symbol = this.isValid() ? '#' : '!';
return { return {
color, color,
content: title ? title.trim()[0] : symbol, content: this.getInitials(title) || symbol,
}; };
} }
return { url: 'images/group_default.png', color }; return { url: 'images/group_default.png', color };

View File

@ -1266,9 +1266,12 @@
conversation, conversation,
message message
); );
receipts.forEach(() => receipts.forEach(receipt =>
message.set({ message.set({
delivered: (message.get('delivered') || 0) + 1, 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 browser */
/* eslint-env node */ /* 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 fs = require('fs');
const path = require('path'); const path = require('path');
const { map, fromPairs } = require('lodash'); const { map, fromPairs } = require('lodash');
const tar = require('tar');
const tmp = require('tmp'); const tmp = require('tmp');
const pify = require('pify'); const pify = require('pify');
const archiver = require('archiver');
const rimraf = require('rimraf'); const rimraf = require('rimraf');
const electronRemote = require('electron').remote; const electronRemote = require('electron').remote;
const Attachment = require('./types/attachment');
const crypto = require('./crypto'); const crypto = require('./crypto');
const decompress = () => null;
const { dialog, BrowserWindow } = electronRemote; const { dialog, BrowserWindow } = electronRemote;
module.exports = { 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'); const writer = await createFileAndWriter(parent, 'db.json');
return exportContactsAndGroups(db, writer); return exportContactsAndGroups(writer);
} }
function exportContactsAndGroups(db, fileWriter) { function writeArray(stream, array) {
return new Promise((resolve, reject) => { stream.write('[');
let storeNames = db.objectStoreNames;
storeNames = _.without(
storeNames,
'messages',
'items',
'signedPreKeys',
'preKeys',
'identityKeys',
'sessions',
'unprocessed'
);
const exportedStoreNames = []; for (let i = 0, max = array.length; i < max; i += 1) {
if (storeNames.length === 0) { if (i > 0) {
throw new Error('No stores to export'); 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 => { stream.write(JSON.stringify(stringify(cleaned)));
// 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');
};
const store = transaction.objectStore(storeName); stream.write(']');
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}": [`);
}
const cursor = event.target.result; function getPlainJS(collection) {
if (cursor) { return collection.map(model => model.attributes);
if (count > 0) { }
stream.write(',');
}
// Preventing base64'd images from reaching the disk, making db.json too big async function exportContactsAndGroups(fileWriter) {
const item = _.omit(cursor.value, ['avatar', 'profileAvatar']); const stream = createOutputStream(fileWriter);
const jsonString = JSON.stringify(stringify(item)); stream.write('{');
stream.write(jsonString);
cursor.continue();
count += 1;
} else {
// no more
stream.write(']');
window.log.info('Exported', count, 'items from store', storeName);
exportedStoreNames.push(storeName); stream.write('"conversations": ');
if (exportedStoreNames.length < storeNames.length) { const conversations = await window.Signal.Data.getAllConversations({
stream.write(','); ConversationCollection: Whisper.ConversationCollection,
} else {
window.log.info('Exported all stores');
stream.write('}');
await stream.close();
window.log.info('Finished writing all stores to disk');
resolve();
}
}
};
});
}); });
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) { 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) { function readFileAsArrayBuffer(targetPath) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// omitting the encoding to get a buffer back // omitting the encoding to get a buffer back
@ -422,9 +383,7 @@ function readFileAsArrayBuffer(targetPath) {
return reject(error); return reject(error);
} }
// Buffer instances are also Uint8Array instances return resolve(toArrayBuffer(buffer));
// https://nodejs.org/docs/latest/api/buffer.html#buffer_buffers_and_typedarray
return resolve(buffer.buffer);
}); });
}); });
} }
@ -468,7 +427,7 @@ function _getAnonymousAttachmentFileName(message, index) {
return `${message.id}-${index}`; return `${message.id}-${index}`;
} }
async function readAttachment(dir, attachment, name, options) { async function readEncryptedAttachment(dir, attachment, name, options) {
options = options || {}; options = options || {};
const { key } = options; const { key } = options;
@ -485,26 +444,29 @@ async function readAttachment(dir, attachment, name, options) {
const isEncrypted = !_.isUndefined(key); const isEncrypted = !_.isUndefined(key);
if (isEncrypted) { if (isEncrypted) {
attachment.data = await crypto.decryptSymmetric(key, data); attachment.data = await crypto.decryptAttachment(
key,
attachment.path,
data
);
} else { } else {
attachment.data = data; 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 { dir, message, index, key, newKey } = options;
const filename = `${_getAnonymousAttachmentFileName( const filename = `${_getAnonymousAttachmentFileName(
message, message,
index index
)}-thumbnail`; )}-quote-thumbnail`;
const target = path.join(dir, filename); const target = path.join(dir, filename);
const { thumbnail } = attachment;
if (!thumbnail || !thumbnail.data) { await writeEncryptedAttachment(target, attachment.thumbnail.path, {
return;
}
await writeEncryptedAttachment(target, thumbnail.data, {
key, key,
newKey, newKey,
filename, filename,
@ -512,25 +474,13 @@ async function writeThumbnail(attachment, options) {
}); });
} }
async function writeThumbnails(rawQuotedAttachments, options) { async function writeQuoteThumbnails(quotedAttachments, options) {
const { name } = 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 { try {
await Promise.all( await Promise.all(
_.map(attachments, (attachment, index) => _.map(quotedAttachments, (attachment, index) =>
writeThumbnail( writeQuoteThumbnail(
attachment, attachment,
Object.assign({}, options, { Object.assign({}, options, {
index, index,
@ -550,26 +500,57 @@ async function writeThumbnails(rawQuotedAttachments, options) {
} }
async function writeAttachment(attachment, 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 { dir, message, index, key, newKey } = options;
const filename = _getAnonymousAttachmentFileName(message, index); const filename = _getAnonymousAttachmentFileName(message, index);
const target = path.join(dir, filename); 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, key,
newKey, newKey,
filename, filename,
dir, 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 { name } = options;
const { loadAttachmentData } = Signal.Migrations;
const attachments = await Promise.all(rawAttachments.map(loadAttachmentData));
const promises = _.map(attachments, (attachment, index) => const promises = _.map(attachments, (attachment, index) =>
writeAttachment( writeAttachment(
attachment, attachment,
@ -591,17 +572,18 @@ async function writeAttachments(rawAttachments, options) {
} }
} }
async function writeAvatar(avatar, options) { async function writeAvatar(contact, options) {
const { dir, message, index, key, newKey } = options; const { avatar } = contact || {};
const name = _getAnonymousAttachmentFileName(message, index); if (!avatar || !avatar.avatar || !avatar.avatar.path) {
const filename = `${name}-contact-avatar`;
const target = path.join(dir, filename);
if (!avatar || !avatar.path) {
return; 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, key,
newKey, newKey,
filename, filename,
@ -612,23 +594,9 @@ async function writeAvatar(avatar, options) {
async function writeContactAvatars(contact, options) { async function writeContactAvatars(contact, options) {
const { name } = 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 { try {
await Promise.all( await Promise.all(
_.map(await Promise.all(promises), (item, index) => _.map(contact, (item, index) =>
writeAvatar( writeAvatar(
item, item,
Object.assign({}, options, { 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; const { key, newKey, filename, dir } = options;
if (fs.existsSync(target)) { 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 writer = await createFileAndWriter(dir, filename);
const stream = createOutputStream(writer); const stream = createOutputStream(writer);
@ -673,9 +643,9 @@ function _sanitizeFileName(filename) {
return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_'); return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_');
} }
async function exportConversation(db, conversation, options) { async function exportConversation(conversation, options = {}) {
options = options || {};
const { name, dir, attachmentsDir, key, newKey } = options; const { name, dir, attachmentsDir, key, newKey } = options;
if (!name) { if (!name) {
throw new Error('Need a name!'); throw new Error('Need a name!');
} }
@ -691,143 +661,111 @@ async function exportConversation(db, conversation, options) {
window.log.info('exporting conversation', name); window.log.info('exporting conversation', name);
const writer = await createFileAndWriter(dir, 'messages.json'); const writer = await createFileAndWriter(dir, 'messages.json');
const stream = createOutputStream(writer);
stream.write('{"messages":[');
return new Promise(async (resolve, reject) => { const CHUNK_SIZE = 50;
// TODO: need to iterate through message ids, export using window.Signal.Data let count = 0;
const transaction = db.transaction('messages', 'readwrite'); let complete = false;
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 store = transaction.objectStore('messages'); // We're looping from the most recent to the oldest
const index = store.index('conversation'); let lastReceivedAt = Number.MAX_VALUE;
const range = window.IDBKeyRange.bound(
[conversation.id, 0],
[conversation.id, Number.MAX_VALUE]
);
let promiseChain = Promise.resolve(); while (!complete) {
let count = 0; // eslint-disable-next-line no-await-in-loop
const request = index.openCursor(range); const collection = await window.Signal.Data.getMessagesByConversation(
conversation.id,
const stream = createOutputStream(writer); {
stream.write('{"messages":['); limit: CHUNK_SIZE,
receivedAt: lastReceivedAt,
request.onerror = () => { MessageCollection: Whisper.MessageCollection,
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();
} }
}; );
}); 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: // Goals for directory names:
@ -857,74 +795,40 @@ function _getConversationLoggingName(conversation) {
return name; return name;
} }
function exportConversations(db, options) { async function exportConversations(options) {
options = options || {}; options = options || {};
const { messagesDir, attachmentsDir, key, newKey } = options; const { messagesDir, attachmentsDir, key, newKey } = options;
if (!messagesDir) { if (!messagesDir) {
return Promise.reject(new Error('Need a messages directory!')); throw new Error('Need a messages directory!');
} }
if (!attachmentsDir) { if (!attachmentsDir) {
return Promise.reject(new Error('Need an attachments directory!')); throw new Error('Need an attachments directory!');
} }
return new Promise((resolve, reject) => { const collection = await window.Signal.Data.getAllConversations({
const transaction = db.transaction('conversations', 'readwrite'); ConversationCollection: Whisper.ConversationCollection,
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 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 = {}) { function getDirectory(options = {}) {
@ -968,9 +872,30 @@ async function loadAttachments(dir, getName, options) {
const { message } = options; const { message } = options;
await Promise.all( await Promise.all(
_.map(message.attachments, (attachment, index) => { _.map(message.attachments, async (attachment, index) => {
const name = getName(message, index, attachment); 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; return null;
} }
const name = `${getName(message, index)}-thumbnail`; const name = `${getName(message, index)}-quote-thumbnail`;
return readAttachment(dir, thumbnail, name, options); return readEncryptedAttachment(dir, thumbnail, name, options);
}) })
); );
@ -996,7 +921,7 @@ async function loadAttachments(dir, getName, options) {
} }
const name = `${getName(message, index)}-contact-avatar`; 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(); return getDirectory();
} }
function createZip(zipDir, targetDir) { async function compressArchive(file, targetDir) {
return new Promise((resolve, reject) => { const items = fs.readdirSync(targetDir);
const target = path.join(zipDir, 'messages.zip'); return tar.c(
const output = fs.createWriteStream(target); {
const archive = archiver('zip', { gzip: true,
file,
cwd: targetDir, cwd: targetDir,
}); },
items
);
}
output.on('close', () => { async function decompressArchive(file, targetDir) {
resolve(target); return tar.x({
}); file,
cwd: targetDir,
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();
}); });
} }
@ -1211,6 +1127,13 @@ function writeFile(targetPath, contents) {
return pify(fs.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) { async function encryptFile(sourcePath, targetPath, options) {
options = options || {}; options = options || {};
@ -1220,8 +1143,8 @@ async function encryptFile(sourcePath, targetPath, options) {
} }
const plaintext = await readFileAsArrayBuffer(sourcePath); const plaintext = await readFileAsArrayBuffer(sourcePath);
const ciphertext = await crypto.encryptSymmetric(key, plaintext); const ciphertext = await crypto.encryptFile(key, UNIQUE_ID, plaintext);
return writeFile(targetPath, ciphertext); return writeFile(targetPath, Buffer.from(ciphertext));
} }
async function decryptFile(sourcePath, targetPath, options) { async function decryptFile(sourcePath, targetPath, options) {
@ -1233,7 +1156,7 @@ async function decryptFile(sourcePath, targetPath, options) {
} }
const ciphertext = await readFileAsArrayBuffer(sourcePath); 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)); return writeFile(targetPath, Buffer.from(plaintext));
} }
@ -1246,9 +1169,9 @@ function deleteAll(pattern) {
return pify(rimraf)(pattern); return pify(rimraf)(pattern);
} }
async function exportToDirectory(directory, options) { const ARCHIVE_NAME = 'messages.tar.gz';
throw new Error('Encrypted export/import is disabled');
async function exportToDirectory(directory, options) {
options = options || {}; options = options || {};
if (!options.key) { if (!options.key) {
@ -1261,20 +1184,19 @@ async function exportToDirectory(directory, options) {
stagingDir = await createTempDir(); stagingDir = await createTempDir();
encryptionDir = await createTempDir(); encryptionDir = await createTempDir();
const db = await Whisper.Database.open();
const attachmentsDir = await createDirectory(directory, 'attachments'); const attachmentsDir = await createDirectory(directory, 'attachments');
await exportContactAndGroupsToFile(db, stagingDir); await exportContactAndGroupsToFile(stagingDir);
await exportConversations( await exportConversations(
db,
Object.assign({}, options, { Object.assign({}, options, {
messagesDir: stagingDir, messagesDir: stagingDir,
attachmentsDir, attachmentsDir,
}) })
); );
const zip = await createZip(encryptionDir, stagingDir); const archivePath = path.join(directory, ARCHIVE_NAME);
await encryptFile(zip, path.join(directory, 'messages.zip'), options); await compressArchive(archivePath, stagingDir);
await encryptFile(archivePath, path.join(directory, ARCHIVE_NAME), options);
window.log.info('done backing up!'); window.log.info('done backing up!');
return directory; return directory;
@ -1317,10 +1239,8 @@ async function importFromDirectory(directory, options) {
groupLookup, groupLookup,
}); });
const zipPath = path.join(directory, 'messages.zip'); const archivePath = path.join(directory, ARCHIVE_NAME);
if (fs.existsSync(zipPath)) { if (fs.existsSync(archivePath)) {
throw new Error('Encrypted export/import is disabled');
// we're in the world of an encrypted, zipped backup // we're in the world of an encrypted, zipped backup
if (!options.key) { if (!options.key) {
throw new Error( throw new Error(
@ -1336,9 +1256,9 @@ async function importFromDirectory(directory, options) {
const attachmentsDir = path.join(directory, 'attachments'); const attachmentsDir = path.join(directory, 'attachments');
const decryptedZip = path.join(decryptionDir, 'messages.zip'); const decryptedArchivePath = path.join(decryptionDir, ARCHIVE_NAME);
await decryptFile(zipPath, decryptedZip, options); await decryptFile(archivePath, decryptedArchivePath, options);
await decompress(decryptedZip, stagingDir); await decompressArchive(decryptedArchivePath, stagingDir);
options = Object.assign({}, options, { options = Object.assign({}, options, {
attachmentsDir, attachmentsDir,

View File

@ -1,5 +1,5 @@
/* eslint-env browser */ /* eslint-env browser */
/* global dcodeIO */ /* global dcodeIO, libsignal */
/* eslint-disable camelcase, no-bitwise */ /* eslint-disable camelcase, no-bitwise */
@ -10,9 +10,15 @@ module.exports = {
concatenateBytes, concatenateBytes,
constantTimeEqual, constantTimeEqual,
decryptAesCtr, decryptAesCtr,
decryptDeviceName,
decryptAttachment,
decryptFile,
decryptSymmetric, decryptSymmetric,
deriveAccessKey, deriveAccessKey,
encryptAesCtr, encryptAesCtr,
encryptDeviceName,
encryptAttachment,
encryptFile,
encryptSymmetric, encryptSymmetric,
fromEncodedBinaryToArrayBuffer, fromEncodedBinaryToArrayBuffer,
getAccessKeyVerifier, getAccessKeyVerifier,
@ -28,8 +34,117 @@ module.exports = {
verifyAccessKey, 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 // 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) { async function deriveAccessKey(profileKey) {
const iv = getZeroes(12); const iv = getZeroes(12);
const plaintext = getZeroes(16); const plaintext = getZeroes(16);
@ -267,24 +382,6 @@ function trimBytes(buffer, length) {
return _getFirstBytes(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) { function getViewOfArrayBuffer(buffer, start, finish) {
const source = new Uint8Array(buffer); const source = new Uint8Array(buffer);
const result = source.slice(start, finish); const result = source.slice(start, finish);

View File

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

View File

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

View File

@ -545,8 +545,6 @@ exports.createAttachmentDataWriter = ({
}); });
}; };
// TODO: need to handle attachment thumbnails and video screenshots
const messageWithoutAttachmentData = Object.assign( const messageWithoutAttachmentData = Object.assign(
{}, {},
await writeThumbnails(message, { logger }), await writeThumbnails(message, { logger }),
@ -555,7 +553,23 @@ exports.createAttachmentDataWriter = ({
attachments: await Promise.all( attachments: await Promise.all(
(attachments || []).map(async attachment => { (attachments || []).map(async attachment => {
await writeExistingAttachmentData(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 = { const URL_CALLS = {
accounts: 'v1/accounts', accounts: 'v1/accounts',
updateDeviceName: 'v1/accounts/name',
removeSignalingKey: 'v1/accounts/signaling_key',
attachment: 'v1/attachments', attachment: 'v1/attachments',
deliveryCert: 'v1/certificate/delivery', deliveryCert: 'v1/certificate/delivery',
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery', supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
@ -393,6 +395,8 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
sendMessages, sendMessages,
sendMessagesUnauth, sendMessagesUnauth,
setSignedPreKey, setSignedPreKey,
updateDeviceName,
removeSignalingKey,
}; };
function _ajax(param) { function _ajax(param) {
@ -523,14 +527,12 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
number, number,
code, code,
newPassword, newPassword,
signalingKey,
registrationId, registrationId,
deviceName, deviceName,
options = {} options = {}
) { ) {
const { accessKey } = options; const { accessKey } = options;
const jsonData = { const jsonData = {
signalingKey: _btoa(_getString(signalingKey)),
supportsSms: false, supportsSms: false,
fetchesMessages: true, fetchesMessages: true,
registrationId, registrationId,
@ -575,6 +577,23 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
return response; return response;
} }
function updateDeviceName(deviceName) {
return _ajax({
call: 'updateDeviceName',
httpType: 'PUT',
jsonData: {
deviceName,
},
});
}
function removeSignalingKey() {
return _ajax({
call: 'removeSignalingKey',
httpType: 'DELETE',
});
}
function getDevices() { function getDevices() {
return _ajax({ return _ajax({
call: 'devices', 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') }; return { toastMessage: i18n('messageFoundButNotLoaded') };
}, },
}); });
Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: i18n('voiceNoteMustBeOnlyAttachment') };
},
});
Whisper.ConversationLoadingScreen = Whisper.View.extend({ Whisper.ConversationLoadingScreen = Whisper.View.extend({
templateName: 'conversation-loading-screen', templateName: 'conversation-loading-screen',
@ -150,8 +155,16 @@
this.window = options.window; this.window = options.window;
this.fileInput = new Whisper.FileInputView({ this.fileInput = new Whisper.FileInputView({
el: this.$('form.send'), el: this.$('.attachment-list'),
window: this.window, });
this.listenTo(
this.fileInput,
'choose-attachment',
this.onChooseAttachment
);
this.listenTo(this.fileInput, 'staged-attachments-changed', () => {
this.view.resetScrollPosition();
this.toggleMicrophone();
}); });
const getHeaderProps = () => { const getHeaderProps = () => {
@ -185,7 +198,7 @@
onDeleteMessages: () => this.destroyMessages(), onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(), 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. // need a manual update call.
onShowSafetyNumber: () => { onShowSafetyNumber: () => {
this.showSafetyNumber(); this.showSafetyNumber();
@ -290,15 +303,49 @@
'farFromBottom .message-list': 'addScrollDownButton', 'farFromBottom .message-list': 'addScrollDownButton',
'lazyScroll .message-list': 'onLazyScroll', 'lazyScroll .message-list': 'onLazyScroll',
'force-resize': 'forceUpdateMessageFieldSize', 'force-resize': 'forceUpdateMessageFieldSize',
dragover: 'sendToFileInput',
drop: 'sendToFileInput', 'click button.paperclip': 'onChooseAttachment',
dragleave: 'sendToFileInput', 'change input.file-input': 'onChoseAttachment',
dragover: 'onDragOver',
dragleave: 'onDragLeave',
drop: 'onDrop',
paste: 'onPaste',
}, },
sendToFileInput(e) {
if (e.originalEvent.dataTransfer.types[0] !== 'Files') { onChooseAttachment(e) {
return; 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() { onPrune() {
@ -546,6 +593,13 @@
captureAudio(e) { captureAudio(e) {
e.preventDefault(); 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 // Note - clicking anywhere will close the audio capture panel, due to
// the onClick handler in InboxView, which calls its closeRecording method. // the onClick handler in InboxView, which calls its closeRecording method.
@ -566,9 +620,11 @@
this.$('.microphone').hide(); this.$('.microphone').hide();
}, },
handleAudioCapture(blob) { handleAudioCapture(blob) {
this.fileInput.file = blob; this.fileInput.addAttachment({
this.fileInput.isVoiceNote = true; contentType: blob.type,
this.fileInput.previewImages(); file: blob,
isVoiceNote: true,
});
this.$('.bottom-bar form').submit(); this.$('.bottom-bar form').submit();
}, },
endCaptureAudio() { endCaptureAudio() {
@ -1229,6 +1285,7 @@
const props = { const props = {
objectURL: getAbsoluteAttachmentPath(path), objectURL: getAbsoluteAttachmentPath(path),
contentType, contentType,
caption: attachment.caption,
onSave: () => this.downloadAttachment({ attachment, message }), onSave: () => this.downloadAttachment({ attachment, message }),
}; };
this.lightboxView = new Whisper.ReactWrapperView({ this.lightboxView = new Whisper.ReactWrapperView({
@ -1496,7 +1553,6 @@
if (event.key !== 'Escape') { if (event.key !== 'Escape') {
return; return;
} }
this.closeEmojiPanel(); this.closeEmojiPanel();
}, },
openEmojiPanel() { openEmojiPanel() {
@ -1504,6 +1560,7 @@
this.emojiPanel = new EmojiPanel(this.$emojiPanelContainer[0], { this.emojiPanel = new EmojiPanel(this.$emojiPanelContainer[0], {
onClick: this.insertEmoji.bind(this), onClick: this.insertEmoji.bind(this),
}); });
this.view.resetScrollPosition();
this.updateMessageFieldSize({}); this.updateMessageFieldSize({});
}, },
closeEmojiPanel() { closeEmojiPanel() {
@ -1513,6 +1570,7 @@
this.$emojiPanelContainer.empty().outerHeight(0); this.$emojiPanelContainer.empty().outerHeight(0);
this.emojiPanel = null; this.emojiPanel = null;
this.view.resetScrollPosition();
this.updateMessageFieldSize({}); this.updateMessageFieldSize({});
}, },
insertEmoji(e) { insertEmoji(e) {
@ -1560,6 +1618,7 @@
this.quoteView = null; this.quoteView = null;
} }
if (!this.quotedMessage) { if (!this.quotedMessage) {
this.view.restoreBottomOffset();
this.updateMessageFieldSize({}); this.updateMessageFieldSize({});
return; return;
} }
@ -1583,16 +1642,18 @@
this.quoteView = new Whisper.ReactWrapperView({ this.quoteView = new Whisper.ReactWrapperView({
className: 'quote-wrapper', className: 'quote-wrapper',
Component: window.Signal.Components.Quote, Component: window.Signal.Components.Quote,
elCallback: el => this.$('.send').prepend(el),
props: Object.assign({}, props, { props: Object.assign({}, props, {
withContentAbove: true, withContentAbove: true,
onClose: () => { onClose: () => {
this.setQuoteMessage(null); this.setQuoteMessage(null);
}, },
}), }),
onInitialRender: () => {
this.view.restoreBottomOffset();
this.updateMessageFieldSize({});
},
}); });
this.$('.send').prepend(this.quoteView.el);
this.updateMessageFieldSize({});
}, },
async sendMessage(e) { async sendMessage(e) {
@ -1647,7 +1708,7 @@
this.setQuoteMessage(null); this.setQuoteMessage(null);
this.focusMessageFieldAndClearDisabled(); this.focusMessageFieldAndClearDisabled();
this.forceUpdateMessageFieldSize(e); this.forceUpdateMessageFieldSize(e);
this.fileInput.deleteFiles(); this.fileInput.clearAttachments();
} catch (error) { } catch (error) {
window.log.error( window.log.error(
'Error pulling attached files before send', '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({ Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
template: i18n('dangerousFileType'), 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({ Whisper.FileInputView = Backbone.View.extend({
tagName: 'span', tagName: 'span',
className: 'file-input', className: 'file-input',
initialize(options) { initialize() {
this.$input = this.$('input[type=file]'); this.attachments = [];
this.$input.click(e => {
e.stopPropagation(); 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: { remove() {
'change .choose-file': 'previewImages', if (this.attachmentListView) {
'click .close': 'deleteFiles', this.attachmentListView.remove();
'click .choose-file': 'open', }
drop: 'openDropped', if (this.captionEditorView) {
dragover: 'showArea', this.captionEditorView.remove();
dragleave: 'hideArea', }
paste: 'onPaste',
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(); e.preventDefault();
// hack this.$el.addClass('dropoff');
if (this.window && this.window.chrome && this.window.chrome.fileSystem) { },
this.window.chrome.fileSystem.chooseEntry(
{ type: 'openFile' }, onDragLeave(e) {
entry => { if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
if (!entry) { return;
return; }
}
entry.file(file => { e.stopPropagation();
this.file = file; e.preventDefault();
this.previewImages(); this.$el.removeClass('dropoff');
}); },
}
); async onDrop(e) {
} else { if (e.originalEvent.dataTransfer.types[0] !== 'Files') {
this.$input.click(); 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 = {}) { // Public interface
_.defaults(options, { addPlayIcon: false });
this.$('.avatar').hide();
this.thumb.src = src;
this.$('.attachment-previews').append(this.thumb.render().el);
if (options.addPlayIcon) { hasFiles() {
this.$el.addClass('video-attachment'); return this.attachments.length > 0;
} else {
this.$el.removeClass('video-attachment');
}
this.thumb.$('img')[0].onload = () => {
this.$el.trigger('force-resize');
};
this.thumb.$('img')[0].onerror = () => {
this.unableToLoadAttachment();
};
}, },
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(); const toast = new Whisper.UnableToLoadToast();
toast.$el.insertAfter(this.$el); toast.$el.insertAfter(this.$el);
toast.render(); toast.render();
this.deleteFiles();
}, },
autoScale(file) { showDangerousError() {
if (file.type.split('/')[0] !== 'image' || file.type === 'image/tiff') { 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 // nothing to do
return Promise.resolve(file); return Promise.resolve(attachment);
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -132,13 +453,13 @@
img.naturalHeight <= maxHeight && img.naturalHeight <= maxHeight &&
file.size <= maxSize file.size <= maxSize
) { ) {
resolve(file); resolve(attachment);
return; return;
} }
const gifMaxSize = 25000 * 1024; const gifMaxSize = 25000 * 1024;
if (file.type === 'image/gif' && file.size <= gifMaxSize) { if (file.type === 'image/gif' && file.size <= gifMaxSize) {
resolve(file); resolve(attachment);
return; return;
} }
@ -170,285 +491,47 @@
} }
} while (i > 0 && blob.size > maxSize); } while (i > 0 && blob.size > maxSize);
resolve(blob); resolve({
...attachment,
file: blob,
});
}; };
img.src = url; img.src = url;
}); });
}, },
async previewImages() { async getFile(attachment) {
this.clearForm(); if (!attachment) {
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) {
return Promise.resolve(); return Promise.resolve();
} }
const attachmentFlags = this.isVoiceNote const attachmentFlags = attachment.isVoiceNote
? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE ? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE
: null; : null;
const setFlags = flags => attachment => { const scaled = await this.autoScale(attachment);
const newAttachment = Object.assign({}, attachment); const fileRead = await this.readFile(scaled);
if (flags) { return {
newAttachment.flags = flags; ...fileRead,
} url: undefined,
return newAttachment; 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() { readFile(attachment) {
// 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) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const FR = new FileReader(); const FR = new FileReader();
FR.onload = e => { FR.onload = e => {
resolve({ resolve({
...attachment,
data: e.target.result, data: e.target.result,
contentType: file.type,
fileName: file.name,
size: file.size,
}); });
}; };
FR.onerror = reject; FR.onerror = reject;
FR.onabort = 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', blue: '#336ba3',
teal: '#067589', teal: '#067589',
green: '#3b7845', green: '#3b7845',
light_green: '#895d66', light_green: '#1c8260',
blue_grey: '#607d8b', blue_grey: '#895d66',
grey: '#6b6b78', grey: '#6b6b78',
}; };
})(); })();

View File

@ -70,6 +70,15 @@
resetScrollPosition() { resetScrollPosition() {
this.$el.scrollTop(this.scrollPosition - this.$el.outerHeight()); 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() { scrollToBottomIfNeeded() {
// This is counter-intuitive. Our current bottomOffset is reflective of what // This is counter-intuitive. Our current bottomOffset is reflective of what
// we last measured, not necessarily the current state. And this is called // we last measured, not necessarily the current state. And this is called

View File

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

View File

@ -4,6 +4,7 @@
libsignal, libsignal,
mnemonic, mnemonic,
btoa, btoa,
Signal,
getString, getString,
Event, Event,
dcodeIO, dcodeIO,
@ -51,6 +52,65 @@
requestSMSVerification(number) { requestSMSVerification(number) {
// return this.server.requestVerificationSMS(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) { registerSingleDevice(mnemonic, mnemonicLanguage, profileName) {
const createAccount = this.createAccount.bind(this); const createAccount = this.createAccount.bind(this);
const clearSessionsAndPreKeys = this.clearSessionsAndPreKeys.bind(this); const clearSessionsAndPreKeys = this.clearSessionsAndPreKeys.bind(this);
@ -362,11 +422,11 @@
await textsecure.storage.user.setNumberAndDeviceId(pubKeyString, 1); await textsecure.storage.user.setNumberAndDeviceId(pubKeyString, 1);
}); });
}, },
clearSessionsAndPreKeys() { async clearSessionsAndPreKeys() {
const store = textsecure.storage.protocol; const store = textsecure.storage.protocol;
window.log.info('clearing all sessions, prekeys, and signed prekeys'); window.log.info('clearing all sessions, prekeys, and signed prekeys');
return Promise.all([ await Promise.all([
store.clearPreKeyStore(), store.clearPreKeyStore(),
store.clearContactPreKeysStore(), store.clearContactPreKeysStore(),
store.clearSignedPreKeysStore(), store.clearSignedPreKeysStore(),

View File

@ -180,7 +180,6 @@ MessageReceiver.prototype.extend({
// We do the message decryption here, instead of in the ordered pending queue, // 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. // 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') { if (request.path !== '/api/v1/message') {
window.log.info('got request', request.verb, request.path); window.log.info('got request', request.verb, request.path);
request.respond(200, 'OK'); request.respond(200, 'OK');
@ -192,7 +191,6 @@ MessageReceiver.prototype.extend({
} }
const promise = Promise.resolve(request.body.toArrayBuffer()) // textsecure.crypto const promise = Promise.resolve(request.body.toArrayBuffer()) // textsecure.crypto
// .decryptWebsocketMessage(request.body, this.signalingKey)
.then(plaintext => { .then(plaintext => {
const envelope = textsecure.protobuf.Envelope.decode(plaintext); const envelope = textsecure.protobuf.Envelope.decode(plaintext);
// After this point, decoding errors are not the server's // After this point, decoding errors are not the server's

View File

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

View File

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

View File

@ -183,15 +183,26 @@ MessageSender.prototype = {
proto.id = id; proto.id = id;
proto.contentType = attachment.contentType; proto.contentType = attachment.contentType;
proto.digest = result.digest; proto.digest = result.digest;
if (attachment.fileName) {
proto.fileName = attachment.fileName;
}
if (attachment.size) { if (attachment.size) {
proto.size = attachment.size; proto.size = attachment.size;
} }
if (attachment.fileName) {
proto.fileName = attachment.fileName;
}
if (attachment.flags) { if (attachment.flags) {
proto.flags = 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; return proto;
}) })
); );

View File

@ -31,5 +31,17 @@
getDeviceName() { getDeviceName() {
return textsecure.storage.get('device_name'); 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', () => { describe('AccountManager', () => {
let accountManager; let accountManager;
@ -10,9 +12,14 @@ describe('AccountManager', () => {
let signedPreKeys; let signedPreKeys;
const DAY = 1000 * 60 * 60 * 24; const DAY = 1000 * 60 * 60 * 24;
beforeEach(() => { beforeEach(async () => {
const identityKey = await libsignal.KeyHelper.generateIdentityKeyPair();
originalProtocolStorage = window.textsecure.storage.protocol; originalProtocolStorage = window.textsecure.storage.protocol;
window.textsecure.storage.protocol = { window.textsecure.storage.protocol = {
getIdentityKeyPair() {
return identityKey;
},
loadSignedPreKeys() { loadSignedPreKeys() {
return Promise.resolve(signedPreKeys); return Promise.resolve(signedPreKeys);
}, },
@ -22,6 +29,17 @@ describe('AccountManager', () => {
window.textsecure.storage.protocol = originalProtocolStorage; 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', () => { it('keeps three confirmed keys even if over a week old', () => {
const now = Date.now(); const now = Date.now();
signedPreKeys = [ signedPreKeys = [

View File

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

View File

@ -3,7 +3,7 @@
"productName": "Loki Messenger", "productName": "Loki Messenger",
"description": "Private messaging from your desktop", "description": "Private messaging from your desktop",
"repository": "https://github.com/sloki-project/loki-messenger.git", "repository": "https://github.com/sloki-project/loki-messenger.git",
"version": "1.19.0", "version": "1.20.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"author": { "author": {
"name": "Open Whisper Systems", "name": "Open Whisper Systems",
@ -46,9 +46,8 @@
"pow-metrics": "node metrics_app.js localhost 9000" "pow-metrics": "node metrics_app.js localhost 9000"
}, },
"dependencies": { "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", "@sindresorhus/is": "0.8.0",
"archiver": "2.1.1",
"backbone": "1.3.3", "backbone": "1.3.3",
"blob-util": "1.3.0", "blob-util": "1.3.0",
"blueimp-canvas-to-blob": "3.14.0", "blueimp-canvas-to-blob": "3.14.0",
@ -95,6 +94,7 @@
"rimraf": "2.6.2", "rimraf": "2.6.2",
"semver": "5.4.1", "semver": "5.4.1",
"spellchecker": "3.4.4", "spellchecker": "3.4.4",
"tar": "4.4.8",
"testcheck": "1.0.0-rc.2", "testcheck": "1.0.0-rc.2",
"tmp": "0.0.33", "tmp": "0.0.33",
"to-arraybuffer": "1.0.1", "to-arraybuffer": "1.0.1",
@ -120,7 +120,7 @@
"asar": "0.14.0", "asar": "0.14.0",
"bower": "1.8.2", "bower": "1.8.2",
"chai": "4.1.2", "chai": "4.1.2",
"electron": "3.0.9", "electron": "3.0.14",
"electron-builder": "20.13.5", "electron-builder": "20.13.5",
"electron-icon-maker": "0.0.3", "electron-icon-maker": "0.0.3",
"eslint": "4.14.0", "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 // We pull this in last, because the native module involved appears to be sensitive to
// /tmp mounted as noexec on Linux. // /tmp mounted as noexec on Linux.
require('./js/spell_check'); 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"; option java_package = "org.whispersystems.websocket.messages.protobuf";
message WebSocketRequestMessage { message WebSocketRequestMessage {
optional string verb = 1; optional string verb = 1;
optional string path = 2; optional string path = 2;
optional bytes body = 3; optional bytes body = 3;
optional uint64 id = 4; repeated string headers = 5;
optional uint64 id = 4;
} }
message WebSocketResponseMessage { message WebSocketResponseMessage {
optional uint64 id = 1; optional uint64 id = 1;
optional uint32 status = 2; optional uint32 status = 2;
optional string message = 3; optional string message = 3;
optional bytes body = 4; repeated string headers = 5;
optional bytes body = 4;
} }
message WebSocketMessage { message WebSocketMessage {
enum Type { enum Type {
UNKNOWN = 0; UNKNOWN = 0;
REQUEST = 1; REQUEST = 1;
RESPONSE = 2; RESPONSE = 2;
} }
optional Type type = 1; optional Type type = 1;
optional WebSocketRequestMessage request = 2; optional WebSocketRequestMessage request = 2;
optional WebSocketResponseMessage response = 3; 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 // things in the composition area. A margin on an inner div won't be included in that
// height calculation. // height calculation.
.bottom-bar .quote-wrapper { .bottom-bar .quote-wrapper {
margin-right: 5px;
margin-bottom: 6px;
margin-top: 3px;
}
.send .quote-wrapper {
margin-left: 37px; margin-left: 37px;
margin-right: 73px; margin-right: 73px;
margin-bottom: 5px; margin-top: 3px;
margin-bottom: -5px;
} }
.bottom-bar { .bottom-bar {
@ -206,6 +201,7 @@
} }
form.send { form.send {
margin-bottom: 0px;
background: $color-white; background: $color-white;
&.video-attachment { &.video-attachment {
@ -282,6 +278,8 @@
padding: 10px; padding: 10px;
border-radius: 4px; border-radius: 4px;
background-color: $color-loki-light-gray; background-color: $color-loki-light-gray;
margin-top: 3px;
margin-bottom: 6px;
color: $color-light-90; color: $color-light-90;
border: 1px solid rgba(0, 0, 0, 0.2); border: 1px solid rgba(0, 0, 0, 0.2);
outline: 0; outline: 0;

View File

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

View File

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

View File

@ -128,6 +128,14 @@
color: $color-light-60; 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 { &.dark-theme {
// _modules // _modules

View File

@ -748,6 +748,7 @@
font-size: 14px; font-size: 14px;
line-height: 18px; line-height: 18px;
color: $color-gray-90; color: $color-gray-90;
text-align: start;
a { a {
color: $color-gray-90; color: $color-gray-90;
@ -803,6 +804,7 @@
flex: initial; flex: initial;
min-width: 54px; min-width: 54px;
width: 54px; width: 54px;
max-height: 54px;
position: relative; position: relative;
img { img {
@ -2194,6 +2196,7 @@
position: relative; position: relative;
display: inline-block; display: inline-block;
margin: 1px; margin: 1px;
vertical-align: middle;
} }
.module-image__caption-icon { .module-image__caption-icon {
@ -2202,6 +2205,14 @@
left: 6px; left: 6px;
} }
.module-image__with-click-handler {
cursor: pointer;
}
.module-image--soft-corners {
border-radius: 4px;
}
.module-image--curved-top-left { .module-image--curved-top-left {
border-top-left-radius: 16px; border-top-left-radius: 16px;
} }
@ -2304,6 +2315,17 @@
text-align: center; 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
.module-image-grid { .module-image-grid {
@ -2417,6 +2439,272 @@
flex-grow: 1; 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 // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {
@ -2503,6 +2791,11 @@
display: none; 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*/ /* Spec: container > 438px and container < 593px*/
@media (min-width: 800px) and (max-width: 925px) { @media (min-width: 800px) and (max-width: 925px) {
.module-message { .module-message {

View File

@ -9,6 +9,7 @@
background: transparent; background: transparent;
padding: 0; padding: 0;
border: none; border: none;
margin-top: 2px;
&:focus, &:focus,
&:hover { &:hover {
@ -16,6 +17,7 @@
} }
&:before { &:before {
margin-top: 4px;
content: ''; content: '';
display: inline-block; display: inline-block;
height: 24px; height: 24px;
@ -36,6 +38,7 @@
opacity: 0.5; opacity: 0.5;
text-align: center; text-align: center;
padding: 0; padding: 0;
margin-top: 5px;
&:focus, &:focus,
&:hover { &: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 { .contact-details {
.number { .number {
color: $color-dark-30; 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 { .expiredAlert {
background: #f3f3a7; background: #f3f3a7;
@ -749,14 +696,9 @@ body.dark-theme {
} }
.module-message__generic-attachment__icon { .module-message__generic-attachment__icon {
// TODO: this will eventually be a different image
// background: url('../images/file-gradient.svg') no-repeat center; // 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 { .module-message__generic-attachment__file-name {
color: $color-dark-05; color: $color-dark-05;
} }
@ -1051,10 +993,6 @@ body.dark-theme {
@include color-svg('../images/movie.svg', $color-signal-blue); @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 { .module-quote__generic-file__text {
color: $color-dark-05; color: $color-dark-05;
} }
@ -1415,6 +1353,10 @@ body.dark-theme {
color: $color-dark-05; color: $color-dark-05;
} }
// Module: Image
// Module: Image Grid
// Module: Typing Animation // Module: Typing Animation
.module-typing-animation__dot { .module-typing-animation__dot {
@ -1425,6 +1367,50 @@ body.dark-theme {
background-color: $color-white; 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 // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {

View File

@ -1,10 +1,6 @@
/* global Signal: false */ /* global Signal, Whisper, assert, textsecure, _, libsignal */
/* global Whisper: false */
/* global assert: false */
/* global textsecure: false */
/* global _: false */
/* eslint-disable no-unreachable, no-console */ /* eslint-disable no-console */
'use strict'; 'use strict';
@ -240,8 +236,8 @@ describe('Backup', () => {
}); });
describe('end-to-end', () => { describe('end-to-end', () => {
it('exports then imports to produce the same data we started with', async () => { it('exports then imports to produce the same data we started with', async function thisNeeded() {
return; this.timeout(6000);
const { attachmentsPath, fse, glob, path, tmp } = window.test; const { attachmentsPath, fse, glob, path, tmp } = window.test;
const { const {
@ -249,46 +245,32 @@ describe('Backup', () => {
loadAttachmentData, loadAttachmentData,
} = window.Signal.Migrations; } = window.Signal.Migrations;
const key = new Uint8Array([ const staticKeyPair = await libsignal.KeyHelper.generateIdentityKeyPair();
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 attachmentsPattern = path.join(attachmentsPath, '**'); const attachmentsPattern = path.join(attachmentsPath, '**');
const OUR_NUMBER = '+12025550000'; const OUR_NUMBER = '+12025550000';
const CONTACT_ONE_NUMBER = '+12025550001'; const CONTACT_ONE_NUMBER = '+12025550001';
const CONTACT_TWO_NUMBER = '+12025550002'; 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) { async function wrappedLoadAttachment(attachment) {
return _.omit(await loadAttachmentData(attachment), ['path']); return _.omit(await loadAttachmentData(attachment), ['path']);
} }
@ -376,16 +358,30 @@ describe('Backup', () => {
}) })
), ),
attachments: await Promise.all( attachments: await Promise.all(
(message.attachments || []).map(attachment => (message.attachments || []).map(async attachment => {
wrappedLoadAttachment(attachment) await wrappedLoadAttachment(attachment);
)
if (attachment.thumbnail) {
await wrappedLoadAttachment(attachment.thumbnail);
}
if (attachment.screenshot) {
await wrappedLoadAttachment(attachment.screenshot);
}
return attachment;
})
), ),
}); });
} }
let backupDir; let backupDir;
try { 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 MESSAGE_COUNT = 1;
const CONVERSATION_COUNT = 1; const CONVERSATION_COUNT = 1;
@ -397,47 +393,20 @@ describe('Backup', () => {
timestamp: 1524185933350, timestamp: 1524185933350,
errors: [], errors: [],
attachments: [ attachments: [
// Note: generates two more files: screenshot and thumbnail
{ {
contentType: 'image/gif', contentType: 'video/mp4',
fileName: 'sad_cat.gif', fileName: 'video.mp4',
data: new Uint8Array([ data: FIXTURES.mp4,
1, },
2, // Note: generates one more file: thumbnail
3, {
4, contentType: 'image/png',
5, fileName: 'landscape.png',
6, data: FIXTURES.png,
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,
}, },
], ],
hasAttachments: 1, hasAttachments: 1,
hasFileAttachments: undefined,
hasVisualMediaAttachments: 1, hasVisualMediaAttachments: 1,
quote: { quote: {
text: "Isn't it cute?", text: "Isn't it cute?",
@ -450,43 +419,10 @@ describe('Backup', () => {
}, },
{ {
contentType: 'image/gif', contentType: 'image/gif',
fileName: 'happy_cat.gif', fileName: 'avatar.gif',
thumbnail: { thumbnail: {
contentType: 'image/png', contentType: 'image/png',
data: new Uint8Array([ data: FIXTURES.gif,
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,
}, },
}, },
], ],
@ -506,40 +442,7 @@ describe('Backup', () => {
isProfile: false, isProfile: false,
avatar: { avatar: {
contentType: 'image/png', contentType: 'image/png',
data: new Uint8Array([ data: FIXTURES.png,
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,
}, },
}, },
}, },
@ -552,107 +455,30 @@ describe('Backup', () => {
console.log('Backup test: Create models, save to db/disk'); console.log('Backup test: Create models, save to db/disk');
const message = await upgradeMessageSchema(messageWithAttachments); const message = await upgradeMessageSchema(messageWithAttachments);
console.log({ message }); console.log({ message });
const messageModel = new Whisper.Message(message); await window.Signal.Data.saveMessage(message, {
const id = await window.Signal.Data.saveMessage( Message: Whisper.Message,
messageModel.attributes, });
{
Message: Whisper.Message,
}
);
messageModel.set({ id });
const conversation = { const conversation = {
active_at: 1524185933350, active_at: 1524185933350,
color: 'orange', color: 'orange',
expireTimer: 0, expireTimer: 0,
id: CONTACT_ONE_NUMBER, id: CONTACT_ONE_NUMBER,
lastMessage: 'Heyo!',
name: 'Someone Somewhere', name: 'Someone Somewhere',
profileAvatar: { profileAvatar: {
contentType: 'image/jpeg', contentType: 'image/jpeg',
data: new Uint8Array([ data: FIXTURES.jpeg,
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,
size: 64, size: 64,
}, },
profileKey: new Uint8Array([ profileKey: 'BASE64KEY',
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,
profileName: 'Someone! 🤔', profileName: 'Someone! 🤔',
profileSharing: true, profileSharing: true,
timestamp: 1524185933350, timestamp: 1524185933350,
tokens: [
'someone somewhere',
'someone',
'somewhere',
'2025550001',
'12025550001',
],
type: 'private', type: 'private',
unreadCount: 0, unreadCount: 0,
verified: 0, verified: 0,
sealedSender: 0,
version: 2,
}; };
console.log({ conversation }); console.log({ conversation });
await window.Signal.Data.saveConversation(conversation, { await window.Signal.Data.saveConversation(conversation, {
@ -669,11 +495,13 @@ describe('Backup', () => {
console.log('Backup test: Export!'); console.log('Backup test: Export!');
backupDir = tmp.dirSync().name; backupDir = tmp.dirSync().name;
console.log({ backupDir }); 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'); console.log('Backup test: Ensure that messages.tar.gz exists');
const zipPath = path.join(backupDir, 'messages.zip'); const archivePath = path.join(backupDir, 'messages.tar.gz');
const messageZipExists = fse.existsSync(zipPath); const messageZipExists = fse.existsSync(archivePath);
assert.strictEqual(true, messageZipExists); assert.strictEqual(true, messageZipExists);
console.log( console.log(
@ -688,43 +516,9 @@ describe('Backup', () => {
await clearAllData(); await clearAllData();
console.log('Backup test: Import!'); console.log('Backup test: Import!');
await Signal.Backup.importFromDirectory(backupDir, { key }); await Signal.Backup.importFromDirectory(backupDir, {
key: staticKeyPair.privKey,
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,
}); });
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'); console.log('Backup test: Check conversations');
const conversationCollection = await window.Signal.Data.getAllConversations( const conversationCollection = await window.Signal.Data.getAllConversations(
@ -734,11 +528,55 @@ describe('Backup', () => {
); );
assert.strictEqual(conversationCollection.length, CONVERSATION_COUNT); 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; const conversationFromDB = conversationCollection.at(0).attributes;
console.log({ conversationFromDB, conversation }); console.log({ conversationFromDB, conversation });
assert.deepEqual( assert.deepEqual(
conversationFromDB, _.omit(conversationFromDB, ommited),
_.omit(conversation, ['profileAvatar']) _.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'); console.log('Backup test: Clear all data');

View File

@ -1,4 +1,4 @@
/* global Signal, textsecure */ /* global Signal, textsecure, libsignal */
'use strict'; 'use strict';
@ -44,7 +44,7 @@ describe('Crypto', () => {
const encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); const encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext);
const uintArray = new Uint8Array(encrypted); const uintArray = new Uint8Array(encrypted);
uintArray[2] = 9; uintArray[2] += 2;
try { try {
await Signal.Crypto.decryptSymmetric(key, uintArray.buffer); await Signal.Crypto.decryptSymmetric(key, uintArray.buffer);
@ -69,7 +69,7 @@ describe('Crypto', () => {
const encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); const encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext);
const uintArray = new Uint8Array(encrypted); const uintArray = new Uint8Array(encrypted);
uintArray[uintArray.length - 3] = 9; uintArray[uintArray.length - 3] += 2;
try { try {
await Signal.Crypto.decryptSymmetric(key, uintArray.buffer); await Signal.Crypto.decryptSymmetric(key, uintArray.buffer);
@ -94,7 +94,7 @@ describe('Crypto', () => {
const encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext); const encrypted = await Signal.Crypto.encryptSymmetric(key, plaintext);
const uintArray = new Uint8Array(encrypted); const uintArray = new Uint8Array(encrypted);
uintArray[35] = 9; uintArray[35] += 9;
try { try {
await Signal.Crypto.decryptSymmetric(key, uintArray.buffer); await Signal.Crypto.decryptSymmetric(key, uintArray.buffer);
@ -109,4 +109,67 @@ describe('Crypto', () => {
throw new Error('Expected error to be thrown'); 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', () => { describe('SecretSessionCipher', () => {
it('successfully roundtrips', async () => { it('successfully roundtrips', async function thisNeeded() {
this.timeout(4000);
const aliceStore = new InMemorySignalProtocolStore(); const aliceStore = new InMemorySignalProtocolStore();
const bobStore = new InMemorySignalProtocolStore(); const bobStore = new InMemorySignalProtocolStore();
@ -187,7 +189,9 @@ describe('SecretSessionCipher', () => {
assert.strictEqual(decryptResult.sender.toString(), '+14151111111.1'); 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 aliceStore = new InMemorySignalProtocolStore();
const bobStore = 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 aliceStore = new InMemorySignalProtocolStore();
const bobStore = 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 aliceStore = new InMemorySignalProtocolStore();
const bobStore = 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, getRegex,
getReplacementData, getReplacementData,
getTitle, getTitle,
SizeClassType,
} from '../../util/emoji'; } from '../../util/emoji';
import { Localizer, RenderTextCallback } from '../../types/Util'; import { Localizer, RenderTextCallback } from '../../types/Util';
@ -20,7 +21,7 @@ function getImageTag({
i18n, i18n,
}: { }: {
match: any; match: any;
sizeClass: string | undefined; sizeClass?: SizeClassType;
key: string | number; key: string | number;
i18n: Localizer; i18n: Localizer;
}) { }) {
@ -51,7 +52,7 @@ function getImageTag({
interface Props { interface Props {
text: string; text: string;
/** A class name to be added to the generated emoji images */ /** 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>. */ /** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
renderNonEmoji?: RenderTextCallback; renderNonEmoji?: RenderTextCallback;
i18n: Localizer; i18n: Localizer;

View File

@ -77,18 +77,21 @@
width="199" width="199"
attachment={{ caption: 'dogs playing' }} attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
<Image <Image
height="149" height="149"
width="149" width="149"
attachment={{ caption: 'dogs playing' }} attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
<Image <Image
height="99" height="99"
width="99" width="99"
attachment={{ caption: 'dogs playing' }} attachment={{ caption: 'dogs playing' }}
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
</div> </div>
<hr /> <hr />
@ -100,6 +103,7 @@
darkOverlay darkOverlay
overlayText="+3" overlayText="+3"
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
<Image <Image
height="149" height="149"
@ -108,6 +112,7 @@
darkOverlay darkOverlay
overlayText="+3" overlayText="+3"
url={util.pngObjectUrl} url={util.pngObjectUrl}
i18n={util.i18n}
/> />
<Image <Image
height="99" height="99"
@ -116,6 +121,82 @@
darkOverlay darkOverlay
overlayText="+3" overlayText="+3"
url={util.pngObjectUrl} 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>
</div> </div>

View File

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

View File

@ -352,3 +352,35 @@ const attachments = [
</div> </div>
</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_WIDTH = 300;
const MAX_HEIGHT = MAX_WIDTH * 1.5; const MAX_HEIGHT = MAX_WIDTH * 1.5;
const MIN_WIDTH = 200; const MIN_WIDTH = 200;
const MIN_HEIGHT = 25; const MIN_HEIGHT = 50;
export class ImageGrid extends React.Component<Props> { export class ImageGrid extends React.Component<Props> {
// tslint:disable-next-line max-func-body-length */ // tslint:disable-next-line max-func-body-length */
@ -50,7 +50,7 @@ export class ImageGrid extends React.Component<Props> {
return null; return null;
} }
if (attachments.length === 1) { if (attachments.length === 1 || !areAllAttachmentsVisual(attachments)) {
const { height, width } = getImageDimensions(attachments[0]); const { height, width } = getImageDimensions(attachments[0]);
return ( return (
@ -93,7 +93,7 @@ export class ImageGrid extends React.Component<Props> {
playIconOverlay={isVideoAttachment(attachments[0])} playIconOverlay={isVideoAttachment(attachments[0])}
height={149} height={149}
width={149} width={149}
url={getUrl(attachments[0])} url={getThumbnailUrl(attachments[0])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -107,7 +107,7 @@ export class ImageGrid extends React.Component<Props> {
height={149} height={149}
width={149} width={149}
attachment={attachments[1]} attachment={attachments[1]}
url={getUrl(attachments[1])} url={getThumbnailUrl(attachments[1])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -141,7 +141,7 @@ export class ImageGrid extends React.Component<Props> {
width={99} width={99}
attachment={attachments[1]} attachment={attachments[1]}
playIconOverlay={isVideoAttachment(attachments[1])} playIconOverlay={isVideoAttachment(attachments[1])}
url={getUrl(attachments[1])} url={getThumbnailUrl(attachments[1])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -154,7 +154,7 @@ export class ImageGrid extends React.Component<Props> {
width={99} width={99}
attachment={attachments[2]} attachment={attachments[2]}
playIconOverlay={isVideoAttachment(attachments[2])} playIconOverlay={isVideoAttachment(attachments[2])}
url={getUrl(attachments[2])} url={getThumbnailUrl(attachments[2])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -176,7 +176,7 @@ export class ImageGrid extends React.Component<Props> {
playIconOverlay={isVideoAttachment(attachments[0])} playIconOverlay={isVideoAttachment(attachments[0])}
height={149} height={149}
width={149} width={149}
url={getUrl(attachments[0])} url={getThumbnailUrl(attachments[0])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -188,7 +188,7 @@ export class ImageGrid extends React.Component<Props> {
height={149} height={149}
width={149} width={149}
attachment={attachments[1]} attachment={attachments[1]}
url={getUrl(attachments[1])} url={getThumbnailUrl(attachments[1])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -203,7 +203,7 @@ export class ImageGrid extends React.Component<Props> {
height={149} height={149}
width={149} width={149}
attachment={attachments[2]} attachment={attachments[2]}
url={getUrl(attachments[2])} url={getThumbnailUrl(attachments[2])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -216,7 +216,7 @@ export class ImageGrid extends React.Component<Props> {
height={149} height={149}
width={149} width={149}
attachment={attachments[3]} attachment={attachments[3]}
url={getUrl(attachments[3])} url={getThumbnailUrl(attachments[3])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -238,7 +238,7 @@ export class ImageGrid extends React.Component<Props> {
playIconOverlay={isVideoAttachment(attachments[0])} playIconOverlay={isVideoAttachment(attachments[0])}
height={149} height={149}
width={149} width={149}
url={getUrl(attachments[0])} url={getThumbnailUrl(attachments[0])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -250,7 +250,7 @@ export class ImageGrid extends React.Component<Props> {
height={149} height={149}
width={149} width={149}
attachment={attachments[1]} attachment={attachments[1]}
url={getUrl(attachments[1])} url={getThumbnailUrl(attachments[1])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -265,7 +265,7 @@ export class ImageGrid extends React.Component<Props> {
height={99} height={99}
width={99} width={99}
attachment={attachments[2]} attachment={attachments[2]}
url={getUrl(attachments[2])} url={getThumbnailUrl(attachments[2])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -277,7 +277,7 @@ export class ImageGrid extends React.Component<Props> {
height={99} height={99}
width={98} width={98}
attachment={attachments[3]} attachment={attachments[3]}
url={getUrl(attachments[3])} url={getThumbnailUrl(attachments[3])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} onError={onError}
/> />
@ -296,7 +296,7 @@ export class ImageGrid extends React.Component<Props> {
: undefined : undefined
} }
attachment={attachments[4]} attachment={attachments[4]}
url={getUrl(attachments[4])} url={getThumbnailUrl(attachments[4])}
onClick={onClickAttachment} onClick={onClickAttachment}
onError={onError} 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) { function getUrl(attachment: AttachmentType) {
if (attachment.screenshot) { if (attachment.screenshot) {
return attachment.screenshot.url; 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>) { export function hasImage(attachments?: Array<AttachmentType>) {
return attachments && attachments[0] && attachments[0].url; 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( export function getGridDimensions(
attachments?: Array<AttachmentType> attachments?: Array<AttachmentType>
): null | DimensionsType { ): null | DimensionsType {

View File

@ -79,52 +79,6 @@ interface State {
imageBroken: boolean; 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 EXPIRATION_CHECK_MINIMUM = 2000;
const EXPIRED_DELAY = 600; 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 ```jsx
<MessageBody text="http://somewhere.com" disableLinks i18n={util.i18n} /> <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 React from 'react';
import { getSizeClass } from '../../util/emoji'; import { getSizeClass, SizeClassType } from '../../util/emoji';
import { Emojify } from './Emojify'; import { Emojify } from './Emojify';
import { AddNewLines } from './AddNewLines'; import { AddNewLines } from './AddNewLines';
import { Linkify } from './Linkify'; import { Linkify } from './Linkify';
@ -21,8 +21,26 @@ const renderNewLines: RenderTextCallback = ({
key, key,
}) => <AddNewLines key={key} text={textWithNewLines} />; }) => <AddNewLines key={key} text={textWithNewLines} />;
const renderLinks: RenderTextCallback = ({ text: textWithLinks, key }) => ( const renderEmoji = ({
<Linkify key={key} text={textWithLinks} renderNonLink={renderNewLines} /> 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> { export class MessageBody extends React.Component<Props> {
public render() { public render() {
const { text, disableJumbomoji, disableLinks, i18n } = this.props; 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 ( return (
<Emojify <Linkify
text={text} text={text}
sizeClass={sizeClass} renderNonLink={({ key, text: nonLinkText }) => {
renderNonEmoji={disableLinks ? renderNewLines : renderLinks} return renderEmoji({
i18n={i18n} 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 ### In message bubble
```jsx ```jsx
<util.ConversationContext theme={util.theme}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <li>
<TypingBubble conversationType="direct" i18n={util.i18n} /> <TypingBubble conversationType="direct" i18n={util.i18n} />
</li> </li>
@ -14,7 +14,7 @@
### In message bubble, group conversation ### In message bubble, group conversation
```jsx ```jsx
<util.ConversationContext theme={util.theme}> <util.ConversationContext theme={util.theme} ios={util.ios}>
<li> <li>
<TypingBubble color="red" conversationType="group" i18n={util.i18n} /> <TypingBubble color="red" conversationType="group" i18n={util.i18n} />
</li> </li>

View File

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

View File

@ -244,7 +244,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/background.js", "path": "js/background.js",
"line": " wrap(", "line": " wrap(",
"lineNumber": 727, "lineNumber": 740,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-18T22:23:00.485Z" "updated": "2018-10-18T22:23:00.485Z"
}, },
@ -252,7 +252,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/background.js", "path": "js/background.js",
"line": " await wrap(", "line": " await wrap(",
"lineNumber": 1257, "lineNumber": 1270,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-26T22:43:23.229Z" "updated": "2018-10-26T22:43:23.229Z"
}, },
@ -319,7 +319,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/modules/crypto.js", "path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');", "line": " return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');",
"lineNumber": 271, "lineNumber": 38,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
@ -327,7 +327,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/modules/crypto.js", "path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();", "line": " return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();",
"lineNumber": 274, "lineNumber": 41,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
@ -335,7 +335,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/modules/crypto.js", "path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();", "line": " return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();",
"lineNumber": 278, "lineNumber": 45,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
@ -343,7 +343,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/modules/crypto.js", "path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();", "line": " return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();",
"lineNumber": 282, "lineNumber": 49,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
@ -351,7 +351,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "js/modules/crypto.js", "path": "js/modules/crypto.js",
"line": " return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');", "line": " return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');",
"lineNumber": 285, "lineNumber": 52,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-10-05T23:12:28.961Z" "updated": "2018-10-05T23:12:28.961Z"
}, },
@ -655,474 +655,6 @@
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "Interacting with already-existing DOM nodes" "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-$(", "rule": "jQuery-$(",
"path": "js/views/debug_log_view.js", "path": "js/views/debug_log_view.js",
@ -1195,105 +727,6 @@
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "Protected from arbitrary input" "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-$(", "rule": "jQuery-$(",
"path": "js/views/group_member_list_view.js", "path": "js/views/group_member_list_view.js",
@ -1692,7 +1125,7 @@
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/message_list_view.js", "path": "js/views/message_list_view.js",
"line": " this.$messages.append(view.el);", "line": " this.$messages.append(view.el);",
"lineNumber": 102, "lineNumber": 111,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z", "updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "view.el is a known DOM element" "reasonDetail": "view.el is a known DOM element"
@ -1701,7 +1134,7 @@
"rule": "jQuery-prepend(", "rule": "jQuery-prepend(",
"path": "js/views/message_list_view.js", "path": "js/views/message_list_view.js",
"line": " this.$messages.prepend(view.el);", "line": " this.$messages.prepend(view.el);",
"lineNumber": 105, "lineNumber": 114,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z", "updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "view.el is a known DOM element" "reasonDetail": "view.el is a known DOM element"
@ -1710,7 +1143,7 @@
"rule": "jQuery-$(", "rule": "jQuery-$(",
"path": "js/views/message_list_view.js", "path": "js/views/message_list_view.js",
"line": " const next = this.$(`#${this.collection.at(index + 1).id}`);", "line": " const next = this.$(`#${this.collection.at(index + 1).id}`);",
"lineNumber": 108, "lineNumber": 117,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z", "updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "Message ids are GUIDs, and therefore the resultant string for $() is an id" "reasonDetail": "Message ids are GUIDs, and therefore the resultant string for $() is an id"
@ -1719,7 +1152,7 @@
"rule": "jQuery-insertBefore(", "rule": "jQuery-insertBefore(",
"path": "js/views/message_list_view.js", "path": "js/views/message_list_view.js",
"line": " view.$el.insertBefore(next);", "line": " view.$el.insertBefore(next);",
"lineNumber": 111, "lineNumber": 120,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z", "updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "next is a known DOM element" "reasonDetail": "next is a known DOM element"
@ -1728,7 +1161,7 @@
"rule": "jQuery-insertAfter(", "rule": "jQuery-insertAfter(",
"path": "js/views/message_list_view.js", "path": "js/views/message_list_view.js",
"line": " view.$el.insertAfter(prev);", "line": " view.$el.insertAfter(prev);",
"lineNumber": 113, "lineNumber": 122,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z", "updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "prev is a known DOM element" "reasonDetail": "prev is a known DOM element"
@ -1737,7 +1170,7 @@
"rule": "jQuery-insertBefore(", "rule": "jQuery-insertBefore(",
"path": "js/views/message_list_view.js", "path": "js/views/message_list_view.js",
"line": " view.$el.insertBefore(elements[i]);", "line": " view.$el.insertBefore(elements[i]);",
"lineNumber": 122, "lineNumber": 131,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z", "updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "elements[i] is a known DOM element" "reasonDetail": "elements[i] is a known DOM element"
@ -1746,7 +1179,7 @@
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "js/views/message_list_view.js", "path": "js/views/message_list_view.js",
"line": " this.$messages.append(view.el);", "line": " this.$messages.append(view.el);",
"lineNumber": 127, "lineNumber": 136,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-11-14T18:51:15.180Z", "updated": "2018-11-14T18:51:15.180Z",
"reasonDetail": "view.el is a known DOM element" "reasonDetail": "view.el is a known DOM element"
@ -2352,7 +1785,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "libtextsecure/message_receiver.js", "path": "libtextsecure/message_receiver.js",
"line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);",
"lineNumber": 785, "lineNumber": 794,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
@ -2360,7 +1793,7 @@
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
"path": "libtextsecure/message_receiver.js", "path": "libtextsecure/message_receiver.js",
"line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);",
"lineNumber": 810, "lineNumber": 819,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
@ -3174,7 +2607,7 @@
"rule": "jQuery-append(", "rule": "jQuery-append(",
"path": "node_modules/electron/electron.d.ts", "path": "node_modules/electron/electron.d.ts",
"line": " append(menuItem: MenuItem): void;", "line": " append(menuItem: MenuItem): void;",
"lineNumber": 3243, "lineNumber": 3232,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
@ -3493,7 +2926,7 @@
"lineNumber": 4136, "lineNumber": 4136,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z", "updated": "2018-09-19T18:13:29.628Z",
"reasonDetail": "<optional>" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-wrap(", "rule": "jQuery-wrap(",
@ -4083,7 +3516,7 @@
"lineNumber": 483, "lineNumber": 483,
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z", "updated": "2018-09-19T21:59:32.770Z",
"reasonDetail": "<optional>" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "jQuery-appendTo(", "rule": "jQuery-appendTo(",
@ -5849,7 +5282,7 @@
"lineNumber": 1699, "lineNumber": 1699,
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-18T19:19:27.699Z", "updated": "2018-09-18T19:19:27.699Z",
"reasonDetail": "<optional>" "reasonDetail": "Protected from arbitrary input"
}, },
{ {
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",

View File

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

View File

@ -22,9 +22,9 @@
"7zip-bin-mac" "~1.0.1" "7zip-bin-mac" "~1.0.1"
"7zip-bin-win" "~2.2.0" "7zip-bin-win" "~2.2.0"
"@journeyapps/sqlcipher@https://github.com/scottnonnenberg-signal/node-sqlcipher.git#ed4f4d179ac010c6347b291cbd4c2ebe5c773741": "@journeyapps/sqlcipher@https://github.com/scottnonnenberg-signal/node-sqlcipher.git#36149a4b03ccf11ec18b9205e1bfd9056015cf07":
version "3.2.1" 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: dependencies:
nan "^2.10.0" nan "^2.10.0"
node-pre-gyp "^0.10.0" node-pre-gyp "^0.10.0"
@ -391,20 +391,6 @@ archiver-utils@^1.3.0:
normalize-path "^2.0.0" normalize-path "^2.0.0"
readable-stream "^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: archiver@~2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/archiver/-/archiver-2.1.0.tgz#d2df2e8d5773a82c1dcce925ccc41450ea999afd" resolved "https://registry.yarnpkg.com/archiver/-/archiver-2.1.0.tgz#d2df2e8d5773a82c1dcce925ccc41450ea999afd"
@ -1363,6 +1349,11 @@ chownr@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" 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: chrome-trace-event@^0.1.1:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-0.1.2.tgz#90f36885d5345a50621332f0717b595883d5d982" 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" semver "^5.5.0"
source-map-support "^0.5.5" source-map-support "^0.5.5"
electron@3.0.9: electron@3.0.14:
version "3.0.9" version "3.0.14"
resolved "https://registry.yarnpkg.com/electron/-/electron-3.0.9.tgz#79bd25dfd5496918a00d579e702fb83082f1a036" resolved "https://registry.yarnpkg.com/electron/-/electron-3.0.14.tgz#d54c51de3651c0fe48a6a6e9aef1ca98e5ea5796"
integrity sha512-OoSoeUWo9PzbArgrwS1yTfTRSlpXmIgrFGWUuUZCjKAk4DGR70elHDNeRnnBJ9NTwXXZVifChcfx73Ah3GnlVQ== integrity sha512-1fG9bE0LzL5QXeEq2MC0dHdVO0pbZOnNlVAIyOyJaCFAu/TjLhxQfWj38bFUEojzuVlaR87tZz0iy2qlVZj3sw==
dependencies: dependencies:
"@types/node" "^8.0.24" "@types/node" "^8.0.24"
electron-download "^4.1.0" electron-download "^4.1.0"
@ -3104,6 +3095,7 @@ file-sync-cmp@^0.1.0:
file-type@^3.1.0: file-type@^3.1.0:
version "3.9.0" version "3.9.0"
resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9"
integrity sha1-JXoHg4TR24CHvESdEH1SpSZyuek=
file-uri-to-path@1: file-uri-to-path@1:
version "1.0.0" version "1.0.0"
@ -5500,12 +5492,27 @@ minipass@^2.2.1, minipass@^2.3.3:
safe-buffer "^5.1.2" safe-buffer "^5.1.2"
yallist "^3.0.0" 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: minizlib@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.0.tgz#11e13658ce46bc3a70a267aac58359d1e0c29ceb" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.0.tgz#11e13658ce46bc3a70a267aac58359d1e0c29ceb"
dependencies: dependencies:
minipass "^2.2.1" 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: mississippi@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f" resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f"
@ -8340,6 +8347,7 @@ string-width@^2.1.0, string-width@^2.1.1:
string_decoder@^1.0.0: string_decoder@^1.0.0:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
dependencies: dependencies:
safe-buffer "~5.1.0" safe-buffer "~5.1.0"
@ -8507,6 +8515,19 @@ tar-stream@^1.5.0:
readable-stream "^2.0.0" readable-stream "^2.0.0"
xtend "^4.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: tar@^2.0.0:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1"