Download attachments in separate queue from message processing
This commit is contained in:
parent
a43a78731a
commit
1d2c3ae23c
34 changed files with 2062 additions and 214 deletions
104
app/sql.js
104
app/sql.js
|
@ -96,6 +96,13 @@ module.exports = {
|
||||||
removeUnprocessed,
|
removeUnprocessed,
|
||||||
removeAllUnprocessed,
|
removeAllUnprocessed,
|
||||||
|
|
||||||
|
getNextAttachmentDownloadJobs,
|
||||||
|
saveAttachmentDownloadJob,
|
||||||
|
setAttachmentDownloadJobPending,
|
||||||
|
resetAttachmentDownloadPending,
|
||||||
|
removeAttachmentDownloadJob,
|
||||||
|
removeAllAttachmentDownloadJobs,
|
||||||
|
|
||||||
removeAll,
|
removeAll,
|
||||||
removeAllConfiguration,
|
removeAllConfiguration,
|
||||||
|
|
||||||
|
@ -525,6 +532,34 @@ async function updateToSchemaVersion8(currentVersion, instance) {
|
||||||
console.log('updateToSchemaVersion8: success!');
|
console.log('updateToSchemaVersion8: success!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateToSchemaVersion9(currentVersion, instance) {
|
||||||
|
if (currentVersion >= 9) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('updateToSchemaVersion9: starting...');
|
||||||
|
await instance.run('BEGIN TRANSACTION;');
|
||||||
|
|
||||||
|
await instance.run(`CREATE TABLE attachment_downloads(
|
||||||
|
id STRING primary key,
|
||||||
|
timestamp INTEGER,
|
||||||
|
pending INTEGER,
|
||||||
|
json TEXT
|
||||||
|
);`);
|
||||||
|
|
||||||
|
await instance.run(`CREATE INDEX attachment_downloads_timestamp
|
||||||
|
ON attachment_downloads (
|
||||||
|
timestamp
|
||||||
|
) WHERE pending = 0;`);
|
||||||
|
await instance.run(`CREATE INDEX attachment_downloads_pending
|
||||||
|
ON attachment_downloads (
|
||||||
|
pending
|
||||||
|
) WHERE pending != 0;`);
|
||||||
|
|
||||||
|
await instance.run('PRAGMA schema_version = 9;');
|
||||||
|
await instance.run('COMMIT TRANSACTION;');
|
||||||
|
console.log('updateToSchemaVersion9: success!');
|
||||||
|
}
|
||||||
|
|
||||||
const SCHEMA_VERSIONS = [
|
const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion1,
|
updateToSchemaVersion1,
|
||||||
updateToSchemaVersion2,
|
updateToSchemaVersion2,
|
||||||
|
@ -534,6 +569,7 @@ const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion6,
|
updateToSchemaVersion6,
|
||||||
updateToSchemaVersion7,
|
updateToSchemaVersion7,
|
||||||
updateToSchemaVersion8,
|
updateToSchemaVersion8,
|
||||||
|
updateToSchemaVersion9,
|
||||||
];
|
];
|
||||||
|
|
||||||
async function updateSchema(instance) {
|
async function updateSchema(instance) {
|
||||||
|
@ -1476,6 +1512,72 @@ async function removeAllUnprocessed() {
|
||||||
await db.run('DELETE FROM unprocessed;');
|
await db.run('DELETE FROM unprocessed;');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ATTACHMENT_DOWNLOADS_TABLE = 'attachment_downloads';
|
||||||
|
async function getNextAttachmentDownloadJobs(limit, options = {}) {
|
||||||
|
const timestamp = options.timestamp || Date.now();
|
||||||
|
|
||||||
|
const rows = await db.all(
|
||||||
|
`SELECT json FROM attachment_downloads
|
||||||
|
WHERE pending = 0 AND timestamp < $timestamp
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT $limit;`,
|
||||||
|
{
|
||||||
|
$limit: limit,
|
||||||
|
$timestamp: timestamp,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return map(rows, row => jsonToObject(row.json));
|
||||||
|
}
|
||||||
|
async function saveAttachmentDownloadJob(job) {
|
||||||
|
const { id, pending, timestamp } = job;
|
||||||
|
if (!id) {
|
||||||
|
throw new Error(
|
||||||
|
'saveAttachmentDownloadJob: Provided job did not have a truthy id'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR REPLACE INTO attachment_downloads (
|
||||||
|
id,
|
||||||
|
pending,
|
||||||
|
timestamp,
|
||||||
|
json
|
||||||
|
) values (
|
||||||
|
$id,
|
||||||
|
$pending,
|
||||||
|
$timestamp,
|
||||||
|
$json
|
||||||
|
)`,
|
||||||
|
{
|
||||||
|
$id: id,
|
||||||
|
$pending: pending,
|
||||||
|
$timestamp: timestamp,
|
||||||
|
$json: objectToJSON(job),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function setAttachmentDownloadJobPending(id, pending) {
|
||||||
|
await db.run(
|
||||||
|
'UPDATE attachment_downloads SET pending = $pending WHERE id = $id;',
|
||||||
|
{
|
||||||
|
$id: id,
|
||||||
|
$pending: pending,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function resetAttachmentDownloadPending() {
|
||||||
|
await db.run(
|
||||||
|
'UPDATE attachment_downloads SET pending = 0 WHERE pending != 0;'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function removeAttachmentDownloadJob(id) {
|
||||||
|
return removeById(ATTACHMENT_DOWNLOADS_TABLE, id);
|
||||||
|
}
|
||||||
|
async function removeAllAttachmentDownloadJobs() {
|
||||||
|
return removeAllFromTable(ATTACHMENT_DOWNLOADS_TABLE);
|
||||||
|
}
|
||||||
|
|
||||||
// All data in database
|
// All data in database
|
||||||
async function removeAll() {
|
async function removeAll() {
|
||||||
let promise;
|
let promise;
|
||||||
|
@ -1492,6 +1594,8 @@ async function removeAll() {
|
||||||
db.run('DELETE FROM sessions;'),
|
db.run('DELETE FROM sessions;'),
|
||||||
db.run('DELETE FROM signedPreKeys;'),
|
db.run('DELETE FROM signedPreKeys;'),
|
||||||
db.run('DELETE FROM unprocessed;'),
|
db.run('DELETE FROM unprocessed;'),
|
||||||
|
db.run('DELETE FROM attachment_downloads;'),
|
||||||
|
db.run('DELETE FROM messages_fts;'),
|
||||||
db.run('COMMIT TRANSACTION;'),
|
db.run('COMMIT TRANSACTION;'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
9
images/spinner-24.svg
Normal file
9
images/spinner-24.svg
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 53 (72520) - https://sketchapp.com -->
|
||||||
|
<title>Interderminate Spinner - 24</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="Interderminate-Spinner---24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M22.5600116,6.29547931 C23.4784938,7.99216184 24,9.93517878 24,12 C24,18.627417 18.627417,24 12,24 L12,22 C17.5228475,22 22,17.5228475 22,12 C22,10.2995217 21.5755584,8.6981771 20.8268371,7.29612807 L22.5600116,6.29547931 Z" id="Path" fill="#000000" fill-rule="nonzero"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 749 B |
9
images/spinner-56.svg
Normal file
9
images/spinner-56.svg
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="56px" height="56px" viewBox="0 0 56 56" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 53 (72520) - https://sketchapp.com -->
|
||||||
|
<title>Interderminate Spinner - 56</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="Interderminate-Spinner---56" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M52.3599009,14.184516 C54.6768062,18.2609741 56,22.9759628 56,28 C56,43.463973 43.463973,56 28,56 L28,54 C42.3594035,54 54,42.3594035 54,28 C54,23.3403176 52.7742128,18.9669331 50.6275064,15.1847144 L52.3599009,14.184516 Z" id="Path" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 728 B |
9
images/spinner-track-24.svg
Normal file
9
images/spinner-track-24.svg
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 53 (72520) - https://sketchapp.com -->
|
||||||
|
<title>Interderminate Track - 24</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="Interderminate-Track---24" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M12,24 C5.372583,24 0,18.627417 0,12 C0,5.372583 5.372583,0 12,0 C18.627417,0 24,5.372583 24,12 C24,18.627417 18.627417,24 12,24 Z M12,22 C17.5228475,22 22,17.5228475 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,17.5228475 6.4771525,22 12,22 Z" id="Combined-Shape" fill="#000000" fill-rule="nonzero"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 801 B |
9
images/spinner-track-56.svg
Normal file
9
images/spinner-track-56.svg
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="56px" height="56px" viewBox="0 0 56 56" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 53 (72520) - https://sketchapp.com -->
|
||||||
|
<title>Interderminate Track - 56</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<g id="Interderminate-Track---56" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<path d="M28,54 C42.3594035,54 54,42.3594035 54,28 C54,13.6405965 42.3594035,2 28,2 C13.6405965,2 2,13.6405965 2,28 C2,42.3594035 13.6405965,54 28,54 Z M28,56 C12.536027,56 0,43.463973 0,28 C0,12.536027 12.536027,0 28,0 C43.463973,0 56,12.536027 56,28 C56,43.463973 43.463973,56 28,56 Z" id="Oval" fill="#000000" fill-rule="nonzero"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 799 B |
|
@ -602,6 +602,7 @@
|
||||||
if (messageReceiver) {
|
if (messageReceiver) {
|
||||||
messageReceiver.close();
|
messageReceiver.close();
|
||||||
}
|
}
|
||||||
|
window.Signal.AttachmentDownloads.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
let connectCount = 0;
|
let connectCount = 0;
|
||||||
|
@ -666,6 +667,11 @@
|
||||||
messageReceiver.addEventListener('configuration', onConfiguration);
|
messageReceiver.addEventListener('configuration', onConfiguration);
|
||||||
messageReceiver.addEventListener('typing', onTyping);
|
messageReceiver.addEventListener('typing', onTyping);
|
||||||
|
|
||||||
|
window.Signal.AttachmentDownloads.start({
|
||||||
|
getMessageReceiver: () => messageReceiver,
|
||||||
|
logger: window.log,
|
||||||
|
});
|
||||||
|
|
||||||
window.textsecure.messaging = new textsecure.MessageSender(
|
window.textsecure.messaging = new textsecure.MessageSender(
|
||||||
USERNAME,
|
USERNAME,
|
||||||
PASSWORD
|
PASSWORD
|
||||||
|
@ -1138,7 +1144,10 @@
|
||||||
const { thumbnail } = queryFirst;
|
const { thumbnail } = queryFirst;
|
||||||
|
|
||||||
if (thumbnail && thumbnail.path) {
|
if (thumbnail && thumbnail.path) {
|
||||||
firstAttachment.thumbnail = thumbnail;
|
firstAttachment.thumbnail = {
|
||||||
|
...thumbnail,
|
||||||
|
copied: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1148,7 +1157,10 @@
|
||||||
const { image } = queryFirst;
|
const { image } = queryFirst;
|
||||||
|
|
||||||
if (image && image.path) {
|
if (image && image.path) {
|
||||||
firstAttachment.thumbnail = image;
|
firstAttachment.thumbnail = {
|
||||||
|
...image,
|
||||||
|
copied: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,13 +19,11 @@
|
||||||
|
|
||||||
const { Message: TypedMessage, Contact, PhoneNumber } = Signal.Types;
|
const { Message: TypedMessage, Contact, PhoneNumber } = Signal.Types;
|
||||||
const {
|
const {
|
||||||
deleteAttachmentData,
|
|
||||||
deleteExternalMessageFiles,
|
deleteExternalMessageFiles,
|
||||||
getAbsoluteAttachmentPath,
|
getAbsoluteAttachmentPath,
|
||||||
loadAttachmentData,
|
loadAttachmentData,
|
||||||
loadQuoteData,
|
loadQuoteData,
|
||||||
loadPreviewData,
|
loadPreviewData,
|
||||||
writeNewAttachmentData,
|
|
||||||
} = window.Signal.Migrations;
|
} = window.Signal.Migrations;
|
||||||
|
|
||||||
window.AccountCache = Object.create(null);
|
window.AccountCache = Object.create(null);
|
||||||
|
@ -423,9 +421,9 @@
|
||||||
authorProfileName: contact.profileName,
|
authorProfileName: contact.profileName,
|
||||||
authorPhoneNumber: contact.phoneNumber,
|
authorPhoneNumber: contact.phoneNumber,
|
||||||
conversationType: isGroup ? 'group' : 'direct',
|
conversationType: isGroup ? 'group' : 'direct',
|
||||||
attachments: attachments.map(attachment =>
|
attachments: attachments
|
||||||
this.getPropsForAttachment(attachment)
|
.filter(attachment => !attachment.error)
|
||||||
),
|
.map(attachment => this.getPropsForAttachment(attachment)),
|
||||||
previews: this.getPropsForPreview(),
|
previews: this.getPropsForPreview(),
|
||||||
quote: this.getPropsForQuote(),
|
quote: this.getPropsForQuote(),
|
||||||
authorAvatarPath,
|
authorAvatarPath,
|
||||||
|
@ -586,7 +584,7 @@
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path, flags, size, screenshot, thumbnail } = attachment;
|
const { path, pending, flags, size, screenshot, thumbnail } = attachment;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...attachment,
|
...attachment,
|
||||||
|
@ -595,7 +593,8 @@
|
||||||
flags &&
|
flags &&
|
||||||
// eslint-disable-next-line no-bitwise
|
// eslint-disable-next-line no-bitwise
|
||||||
flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
|
flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
|
||||||
url: getAbsoluteAttachmentPath(path),
|
pending,
|
||||||
|
url: path ? getAbsoluteAttachmentPath(path) : null,
|
||||||
screenshot: screenshot
|
screenshot: screenshot
|
||||||
? {
|
? {
|
||||||
...screenshot,
|
...screenshot,
|
||||||
|
@ -1155,6 +1154,116 @@
|
||||||
);
|
);
|
||||||
return !!error;
|
return !!error;
|
||||||
},
|
},
|
||||||
|
async queueAttachmentDownloads() {
|
||||||
|
const messageId = this.id;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
const attachments = await Promise.all(
|
||||||
|
(this.get('attachments') || []).map((attachment, index) => {
|
||||||
|
count += 1;
|
||||||
|
return window.Signal.AttachmentDownloads.addJob(attachment, {
|
||||||
|
messageId,
|
||||||
|
type: 'attachment',
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = await Promise.all(
|
||||||
|
(this.get('preview') || []).map(async (item, index) => {
|
||||||
|
if (!item.image) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
image: await window.Signal.AttachmentDownloads.addJob(item.image, {
|
||||||
|
messageId,
|
||||||
|
type: 'preview',
|
||||||
|
index,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const contact = await Promise.all(
|
||||||
|
(this.get('contact') || []).map(async (item, index) => {
|
||||||
|
if (!item.avatar || !item.avatar.avatar) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
avatar: {
|
||||||
|
...item.avatar,
|
||||||
|
avatar: await window.Signal.AttachmentDownloads.addJob(
|
||||||
|
item.avatar.avatar,
|
||||||
|
{
|
||||||
|
messageId,
|
||||||
|
type: 'contact',
|
||||||
|
index,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let quote = this.get('quote');
|
||||||
|
if (quote && quote.attachments && quote.attachments.length) {
|
||||||
|
quote = {
|
||||||
|
...quote,
|
||||||
|
attachments: await Promise.all(
|
||||||
|
(quote.attachments || []).map(async (item, index) => {
|
||||||
|
// If we already have a path, then we copied this image from the quoted
|
||||||
|
// message and we don't need to download the attachment.
|
||||||
|
if (!item.thumbnail || item.thumbnail.path) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
thumbnail: await window.Signal.AttachmentDownloads.addJob(
|
||||||
|
item.thumbnail,
|
||||||
|
{
|
||||||
|
messageId,
|
||||||
|
type: 'quote',
|
||||||
|
index,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = this.get('group');
|
||||||
|
if (group && group.avatar) {
|
||||||
|
group = {
|
||||||
|
...group,
|
||||||
|
avatar: await window.Signal.AttachmentDownloads.addJob(group.avatar, {
|
||||||
|
messageId,
|
||||||
|
type: 'group-avatar',
|
||||||
|
index: 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
this.set({ attachments, preview, contact, quote, group });
|
||||||
|
|
||||||
|
await window.Signal.Data.saveMessage(this.attributes, {
|
||||||
|
Message: Whisper.Message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
handleDataMessage(dataMessage, confirm) {
|
handleDataMessage(dataMessage, confirm) {
|
||||||
// This function is called from the background script in a few scenarios:
|
// This function is called from the background script in a few scenarios:
|
||||||
// 1. on an incoming message
|
// 1. on an incoming message
|
||||||
|
@ -1194,18 +1303,6 @@
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update this group conversations's avatar on disk if it has changed.
|
|
||||||
if (dataMessage.group.avatar) {
|
|
||||||
attributes = await window.Signal.Types.Conversation.maybeUpdateAvatar(
|
|
||||||
attributes,
|
|
||||||
dataMessage.group.avatar.data,
|
|
||||||
{
|
|
||||||
writeNewAttachmentData,
|
|
||||||
deleteAttachmentData,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
groupUpdate =
|
groupUpdate =
|
||||||
conversation.changedAttributes(
|
conversation.changedAttributes(
|
||||||
_.pick(dataMessage.group, 'name', 'avatar')
|
_.pick(dataMessage.group, 'name', 'avatar')
|
||||||
|
@ -1420,6 +1517,11 @@
|
||||||
});
|
});
|
||||||
message.set({ id });
|
message.set({ id });
|
||||||
|
|
||||||
|
// Note that this can save the message again, if jobs were queued. We need to
|
||||||
|
// call it after we have an id for this message, because the jobs refer back
|
||||||
|
// to their source message.
|
||||||
|
await message.queueAttachmentDownloads();
|
||||||
|
|
||||||
await window.Signal.Data.updateConversation(
|
await window.Signal.Data.updateConversation(
|
||||||
conversationId,
|
conversationId,
|
||||||
conversation.attributes,
|
conversation.attributes,
|
||||||
|
|
410
js/modules/attachment_downloads.js
Normal file
410
js/modules/attachment_downloads.js
Normal file
|
@ -0,0 +1,410 @@
|
||||||
|
/* global Whisper, Signal, setTimeout, clearTimeout */
|
||||||
|
|
||||||
|
const { isFunction, isNumber, omit } = require('lodash');
|
||||||
|
const getGuid = require('uuid/v4');
|
||||||
|
const {
|
||||||
|
getMessageById,
|
||||||
|
getNextAttachmentDownloadJobs,
|
||||||
|
removeAttachmentDownloadJob,
|
||||||
|
resetAttachmentDownloadPending,
|
||||||
|
saveAttachmentDownloadJob,
|
||||||
|
saveMessage,
|
||||||
|
setAttachmentDownloadJobPending,
|
||||||
|
} = require('./data');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
addJob,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_JOB_PARALLELISM = 3;
|
||||||
|
|
||||||
|
const SECOND = 1000;
|
||||||
|
const MINUTE = 60 * SECOND;
|
||||||
|
const HOUR = 60 * MINUTE;
|
||||||
|
const TICK_INTERVAL = MINUTE;
|
||||||
|
|
||||||
|
const RETRY_BACKOFF = {
|
||||||
|
1: 30 * SECOND,
|
||||||
|
2: 30 * MINUTE,
|
||||||
|
3: 6 * HOUR,
|
||||||
|
};
|
||||||
|
|
||||||
|
let enabled = false;
|
||||||
|
let timeout;
|
||||||
|
let getMessageReceiver;
|
||||||
|
let logger;
|
||||||
|
const _activeAttachmentDownloadJobs = {};
|
||||||
|
const _messageCache = {};
|
||||||
|
|
||||||
|
async function start(options = {}) {
|
||||||
|
({ getMessageReceiver, logger } = options);
|
||||||
|
if (!isFunction(getMessageReceiver)) {
|
||||||
|
throw new Error(
|
||||||
|
'attachment_downloads/start: getMessageReceiver must be a function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!logger) {
|
||||||
|
throw new Error('attachment_downloads/start: logger must be provided!');
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled = true;
|
||||||
|
await resetAttachmentDownloadPending();
|
||||||
|
|
||||||
|
_tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
enabled = false;
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addJob(attachment, job = {}) {
|
||||||
|
if (!attachment) {
|
||||||
|
throw new Error('attachments_download/addJob: attachment is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageId, type, index } = job;
|
||||||
|
if (!messageId) {
|
||||||
|
throw new Error('attachments_download/addJob: job.messageId is required');
|
||||||
|
}
|
||||||
|
if (!type) {
|
||||||
|
throw new Error('attachments_download/addJob: job.type is required');
|
||||||
|
}
|
||||||
|
if (!isNumber(index)) {
|
||||||
|
throw new Error('attachments_download/addJob: index must be a number');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getGuid();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const toSave = {
|
||||||
|
...job,
|
||||||
|
id,
|
||||||
|
attachment,
|
||||||
|
timestamp,
|
||||||
|
pending: 0,
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveAttachmentDownloadJob(toSave);
|
||||||
|
|
||||||
|
_maybeStartJob();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attachment,
|
||||||
|
pending: true,
|
||||||
|
downloadJobId: id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _tick() {
|
||||||
|
_maybeStartJob();
|
||||||
|
timeout = setTimeout(_tick, TICK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _maybeStartJob() {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobCount = getActiveJobCount();
|
||||||
|
const limit = MAX_ATTACHMENT_JOB_PARALLELISM - jobCount;
|
||||||
|
if (limit <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextJobs = await getNextAttachmentDownloadJobs(limit);
|
||||||
|
if (nextJobs.length <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// To prevent the race condition caused by two parallel database calls, eached kicked
|
||||||
|
// off because the jobCount wasn't at the max.
|
||||||
|
const secondJobCount = getActiveJobCount();
|
||||||
|
const needed = MAX_ATTACHMENT_JOB_PARALLELISM - secondJobCount;
|
||||||
|
if (needed <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = nextJobs.slice(0, Math.min(needed, nextJobs.length));
|
||||||
|
for (let i = 0, max = jobs.length; i < max; i += 1) {
|
||||||
|
const job = jobs[i];
|
||||||
|
_activeAttachmentDownloadJobs[job.id] = _runJob(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _runJob(job) {
|
||||||
|
const { id, messageId, attachment, type, index, attempts } = job || {};
|
||||||
|
let message;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!job || !attachment || !messageId) {
|
||||||
|
throw new Error(
|
||||||
|
`_runJob: Key information required for job was missing. Job id: ${id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
message = await _getMessage(messageId);
|
||||||
|
if (!message) {
|
||||||
|
logger.error('_runJob: Source message not found, deleting job');
|
||||||
|
await _finishJob(message, id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = true;
|
||||||
|
await setAttachmentDownloadJobPending(id, pending);
|
||||||
|
|
||||||
|
let downloaded;
|
||||||
|
const messageReceiver = getMessageReceiver();
|
||||||
|
if (!messageReceiver) {
|
||||||
|
throw new Error('_runJob: messageReceiver not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
downloaded = await messageReceiver.downloadAttachment(attachment);
|
||||||
|
} catch (error) {
|
||||||
|
// Attachments on the server expire after 30 days, then start returning 404
|
||||||
|
if (error && error.code === 404) {
|
||||||
|
logger.warn(
|
||||||
|
`_runJob: Got 404 from server, marking attachment ${
|
||||||
|
attachment.id
|
||||||
|
} from message ${message.idForLogging()} as permanent error`
|
||||||
|
);
|
||||||
|
|
||||||
|
await _finishJob(message, id);
|
||||||
|
await _addAttachmentToMessage(
|
||||||
|
message,
|
||||||
|
_markAttachmentAsError(attachment),
|
||||||
|
{ type, index }
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgradedAttachment = await Signal.Migrations.processNewAttachment(
|
||||||
|
downloaded
|
||||||
|
);
|
||||||
|
|
||||||
|
await _addAttachmentToMessage(message, upgradedAttachment, { type, index });
|
||||||
|
|
||||||
|
await _finishJob(message, id);
|
||||||
|
} catch (error) {
|
||||||
|
const currentAttempt = (attempts || 0) + 1;
|
||||||
|
|
||||||
|
if (currentAttempt >= 3) {
|
||||||
|
logger.error(
|
||||||
|
`_runJob: ${currentAttempt} failed attempts, marking attachment ${id} from message ${message.idForLogging()} as permament error:`,
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
|
|
||||||
|
await _finishJob(message, id);
|
||||||
|
await _addAttachmentToMessage(
|
||||||
|
message,
|
||||||
|
_markAttachmentAsError(attachment),
|
||||||
|
{ type, index }
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
`_runJob: Failed to download attachment type ${type} for message ${message.idForLogging()}, attempt ${currentAttempt}:`,
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedJob = {
|
||||||
|
...job,
|
||||||
|
pending: 0,
|
||||||
|
attempts: currentAttempt,
|
||||||
|
timestamp: Date.now() + RETRY_BACKOFF[currentAttempt],
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveAttachmentDownloadJob(failedJob);
|
||||||
|
delete _activeAttachmentDownloadJobs[id];
|
||||||
|
_maybeStartJob();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _getMessage(id) {
|
||||||
|
let item = _messageCache[id];
|
||||||
|
if (item) {
|
||||||
|
const fiveMinutesAgo = Date.now() - 5 * MINUTE;
|
||||||
|
if (item.timestamp >= fiveMinutesAgo) {
|
||||||
|
return item.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete _messageCache[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = await getMessageById(id, {
|
||||||
|
Message: Whisper.Message,
|
||||||
|
});
|
||||||
|
if (!message) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once more, checking for race conditions
|
||||||
|
item = _messageCache[id];
|
||||||
|
if (item) {
|
||||||
|
const fiveMinutesAgo = Date.now() - 5 * MINUTE;
|
||||||
|
if (item.timestamp >= fiveMinutesAgo) {
|
||||||
|
return item.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = message.getConversation();
|
||||||
|
if (conversation && conversation.messageCollection.get(id)) {
|
||||||
|
message = conversation.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
_messageCache[id] = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _finishJob(message, id) {
|
||||||
|
if (message) {
|
||||||
|
await saveMessage(message.attributes, {
|
||||||
|
Message: Whisper.Message,
|
||||||
|
});
|
||||||
|
const conversation = message.getConversation();
|
||||||
|
if (conversation) {
|
||||||
|
const fromConversation = conversation.messageCollection.get(message.id);
|
||||||
|
|
||||||
|
if (fromConversation && message !== fromConversation) {
|
||||||
|
fromConversation.set(message.attributes);
|
||||||
|
fromConversation.trigger('change');
|
||||||
|
} else {
|
||||||
|
message.trigger('change');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeAttachmentDownloadJob(id);
|
||||||
|
delete _activeAttachmentDownloadJobs[id];
|
||||||
|
_maybeStartJob();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveJobCount() {
|
||||||
|
return Object.keys(_activeAttachmentDownloadJobs).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _markAttachmentAsError(attachment) {
|
||||||
|
return {
|
||||||
|
...omit(attachment, ['key', 'digest', 'id']),
|
||||||
|
error: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _addAttachmentToMessage(message, attachment, { type, index }) {
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'attachment') {
|
||||||
|
const attachments = message.get('attachments');
|
||||||
|
if (!attachments || attachments.length <= index) {
|
||||||
|
throw new Error(
|
||||||
|
`_addAttachmentToMessage: attachments didn't exist or ${index} was too large`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_replaceAttachment(attachments, index, attachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'preview') {
|
||||||
|
const preview = message.get('preview');
|
||||||
|
if (!preview || preview.length <= index) {
|
||||||
|
throw new Error(
|
||||||
|
`_addAttachmentToMessage: preview didn't exist or ${index} was too large`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const item = preview[index];
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`_addAttachmentToMessage: preview ${index} was falsey`);
|
||||||
|
}
|
||||||
|
_replaceAttachment(item, 'image', attachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'contact') {
|
||||||
|
const contact = message.get('contact');
|
||||||
|
if (!contact || contact.length <= index) {
|
||||||
|
throw new Error(
|
||||||
|
`_addAttachmentToMessage: contact didn't exist or ${index} was too large`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const item = contact[index];
|
||||||
|
if (item && item.avatar && item.avatar.avatar) {
|
||||||
|
_replaceAttachment(item.avatar, 'avatar', attachment);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`_addAttachmentToMessage: Couldn't update contact with avatar attachment for message ${message.idForLogging()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'quote') {
|
||||||
|
const quote = message.get('quote');
|
||||||
|
if (!quote) {
|
||||||
|
throw new Error("_addAttachmentToMessage: quote didn't exist");
|
||||||
|
}
|
||||||
|
const { attachments } = quote;
|
||||||
|
if (!attachments || attachments.length <= index) {
|
||||||
|
throw new Error(
|
||||||
|
`_addAttachmentToMessage: quote attachments didn't exist or ${index} was too large`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = attachments[index];
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(
|
||||||
|
`_addAttachmentToMessage: attachment ${index} was falsey`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_replaceAttachment(item, 'thumbnail', attachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'group-avatar') {
|
||||||
|
const group = message.get('group');
|
||||||
|
if (!group) {
|
||||||
|
throw new Error("_addAttachmentToMessage: group didn't exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAvatar = group.avatar;
|
||||||
|
if (existingAvatar && existingAvatar.path) {
|
||||||
|
await Signal.Migrations.deleteAttachmentData(existingAvatar.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
_replaceAttachment(group, 'avatar', attachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`_addAttachmentToMessage: Unknown job type ${type} for message ${message.idForLogging()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _replaceAttachment(object, key, newAttachment) {
|
||||||
|
const oldAttachment = object[key];
|
||||||
|
if (oldAttachment && oldAttachment.path) {
|
||||||
|
logger.warn(
|
||||||
|
'_replaceAttachment: Old attachment already had path, not replacing'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
object[key] = newAttachment;
|
||||||
|
}
|
|
@ -131,6 +131,13 @@ module.exports = {
|
||||||
removeUnprocessed,
|
removeUnprocessed,
|
||||||
removeAllUnprocessed,
|
removeAllUnprocessed,
|
||||||
|
|
||||||
|
getNextAttachmentDownloadJobs,
|
||||||
|
saveAttachmentDownloadJob,
|
||||||
|
resetAttachmentDownloadPending,
|
||||||
|
setAttachmentDownloadJobPending,
|
||||||
|
removeAttachmentDownloadJob,
|
||||||
|
removeAllAttachmentDownloadJobs,
|
||||||
|
|
||||||
removeAll,
|
removeAll,
|
||||||
removeAllConfiguration,
|
removeAllConfiguration,
|
||||||
|
|
||||||
|
@ -854,6 +861,27 @@ async function removeAllUnprocessed() {
|
||||||
await channels.removeAllUnprocessed();
|
await channels.removeAllUnprocessed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attachment downloads
|
||||||
|
|
||||||
|
async function getNextAttachmentDownloadJobs(limit) {
|
||||||
|
return channels.getNextAttachmentDownloadJobs(limit);
|
||||||
|
}
|
||||||
|
async function saveAttachmentDownloadJob(job) {
|
||||||
|
await channels.saveAttachmentDownloadJob(job);
|
||||||
|
}
|
||||||
|
async function setAttachmentDownloadJobPending(id, pending) {
|
||||||
|
await channels.setAttachmentDownloadJobPending(id, pending);
|
||||||
|
}
|
||||||
|
async function resetAttachmentDownloadPending() {
|
||||||
|
await channels.resetAttachmentDownloadPending();
|
||||||
|
}
|
||||||
|
async function removeAttachmentDownloadJob(id) {
|
||||||
|
await channels.removeAttachmentDownloadJob(id);
|
||||||
|
}
|
||||||
|
async function removeAllAttachmentDownloadJobs() {
|
||||||
|
await channels.removeAllAttachmentDownloadJobs();
|
||||||
|
}
|
||||||
|
|
||||||
// Other
|
// Other
|
||||||
|
|
||||||
async function removeAll() {
|
async function removeAll() {
|
||||||
|
|
|
@ -14,6 +14,7 @@ const { migrateToSQL } = require('./migrate_to_sql');
|
||||||
const Metadata = require('./metadata/SecretSessionCipher');
|
const Metadata = require('./metadata/SecretSessionCipher');
|
||||||
const RefreshSenderCertificate = require('./refresh_sender_certificate');
|
const RefreshSenderCertificate = require('./refresh_sender_certificate');
|
||||||
const LinkPreviews = require('./link_previews');
|
const LinkPreviews = require('./link_previews');
|
||||||
|
const AttachmentDownloads = require('./attachment_downloads');
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
const {
|
const {
|
||||||
|
@ -128,6 +129,7 @@ function initializeMigrations({
|
||||||
const loadQuoteData = MessageType.loadQuoteData(loadAttachmentData);
|
const loadQuoteData = MessageType.loadQuoteData(loadAttachmentData);
|
||||||
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
||||||
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
|
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
|
||||||
|
const writeNewAttachmentData = createWriterForNew(attachmentsPath);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attachmentsPath,
|
attachmentsPath,
|
||||||
|
@ -145,11 +147,22 @@ function initializeMigrations({
|
||||||
loadQuoteData,
|
loadQuoteData,
|
||||||
readAttachmentData,
|
readAttachmentData,
|
||||||
run,
|
run,
|
||||||
|
processNewAttachment: attachment =>
|
||||||
|
MessageType.processNewAttachment(attachment, {
|
||||||
|
writeNewAttachmentData,
|
||||||
|
getAbsoluteAttachmentPath,
|
||||||
|
makeObjectUrl,
|
||||||
|
revokeObjectUrl,
|
||||||
|
getImageDimensions,
|
||||||
|
makeImageThumbnail,
|
||||||
|
makeVideoScreenshot,
|
||||||
|
logger,
|
||||||
|
}),
|
||||||
upgradeMessageSchema: (message, options = {}) => {
|
upgradeMessageSchema: (message, options = {}) => {
|
||||||
const { maxVersion } = options;
|
const { maxVersion } = options;
|
||||||
|
|
||||||
return MessageType.upgradeSchema(message, {
|
return MessageType.upgradeSchema(message, {
|
||||||
writeNewAttachmentData: createWriterForNew(attachmentsPath),
|
writeNewAttachmentData,
|
||||||
getRegionCode,
|
getRegionCode,
|
||||||
getAbsoluteAttachmentPath,
|
getAbsoluteAttachmentPath,
|
||||||
makeObjectUrl,
|
makeObjectUrl,
|
||||||
|
@ -233,6 +246,7 @@ exports.setup = (options = {}) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
AttachmentDownloads,
|
||||||
Backbone,
|
Backbone,
|
||||||
Components,
|
Components,
|
||||||
Crypto,
|
Crypto,
|
||||||
|
|
|
@ -56,6 +56,11 @@ exports.autoOrientJPEG = async attachment => {
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we haven't downloaded the attachment yet, we won't have the data
|
||||||
|
if (!attachment.data) {
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
|
||||||
const dataBlob = await arrayBufferToBlob(
|
const dataBlob = await arrayBufferToBlob(
|
||||||
attachment.data,
|
attachment.data,
|
||||||
attachment.contentType
|
attachment.contentType
|
||||||
|
@ -234,6 +239,11 @@ exports.captureDimensionsAndScreenshot = async (
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the attachment hasn't been downloaded yet, we won't have a path
|
||||||
|
if (!attachment.path) {
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
|
||||||
const absolutePath = await getAbsoluteAttachmentPath(attachment.path);
|
const absolutePath = await getAbsoluteAttachmentPath(attachment.path);
|
||||||
|
|
||||||
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
||||||
|
|
|
@ -9,7 +9,7 @@ const { isArrayBuffer, isFunction, isUndefined, omit } = require('lodash');
|
||||||
// Promise Attachment
|
// Promise Attachment
|
||||||
exports.migrateDataToFileSystem = async (
|
exports.migrateDataToFileSystem = async (
|
||||||
attachment,
|
attachment,
|
||||||
{ writeNewAttachmentData, logger } = {}
|
{ writeNewAttachmentData } = {}
|
||||||
) => {
|
) => {
|
||||||
if (!isFunction(writeNewAttachmentData)) {
|
if (!isFunction(writeNewAttachmentData)) {
|
||||||
throw new TypeError("'writeNewAttachmentData' must be a function");
|
throw new TypeError("'writeNewAttachmentData' must be a function");
|
||||||
|
@ -19,7 +19,6 @@ exports.migrateDataToFileSystem = async (
|
||||||
const hasData = !isUndefined(data);
|
const hasData = !isUndefined(data);
|
||||||
const shouldSkipSchemaUpgrade = !hasData;
|
const shouldSkipSchemaUpgrade = !hasData;
|
||||||
if (shouldSkipSchemaUpgrade) {
|
if (shouldSkipSchemaUpgrade) {
|
||||||
logger.warn('WARNING: `attachment.data` is `undefined`');
|
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -134,8 +134,7 @@ exports._withSchemaVersion = ({ schemaVersion, upgrade }) => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'WARNING: Message._withSchemaVersion: Unexpected version:',
|
'WARNING: Message._withSchemaVersion: Unexpected version:',
|
||||||
`Expected message to have version ${expectedVersion},`,
|
`Expected message to have version ${expectedVersion},`,
|
||||||
`but got ${message.schemaVersion}.`,
|
`but got ${message.schemaVersion}.`
|
||||||
message
|
|
||||||
);
|
);
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
@ -203,7 +202,6 @@ exports._mapQuotedAttachments = upgradeAttachment => async (
|
||||||
if (!context || !isObject(context.logger)) {
|
if (!context || !isObject(context.logger)) {
|
||||||
throw new Error('_mapQuotedAttachments: context must have logger object');
|
throw new Error('_mapQuotedAttachments: context must have logger object');
|
||||||
}
|
}
|
||||||
const { logger } = context;
|
|
||||||
|
|
||||||
const upgradeWithContext = async attachment => {
|
const upgradeWithContext = async attachment => {
|
||||||
const { thumbnail } = attachment;
|
const { thumbnail } = attachment;
|
||||||
|
@ -211,11 +209,6 @@ exports._mapQuotedAttachments = upgradeAttachment => async (
|
||||||
return attachment;
|
return attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!thumbnail.data && !thumbnail.path) {
|
|
||||||
logger.warn('Quoted attachment did not have thumbnail data; removing it');
|
|
||||||
return omit(attachment, ['thumbnail']);
|
|
||||||
}
|
|
||||||
|
|
||||||
const upgradedThumbnail = await upgradeAttachment(thumbnail, context);
|
const upgradedThumbnail = await upgradeAttachment(thumbnail, context);
|
||||||
return Object.assign({}, attachment, {
|
return Object.assign({}, attachment, {
|
||||||
thumbnail: upgradedThumbnail,
|
thumbnail: upgradedThumbnail,
|
||||||
|
@ -247,7 +240,6 @@ exports._mapPreviewAttachments = upgradeAttachment => async (
|
||||||
if (!context || !isObject(context.logger)) {
|
if (!context || !isObject(context.logger)) {
|
||||||
throw new Error('_mapPreviewAttachments: context must have logger object');
|
throw new Error('_mapPreviewAttachments: context must have logger object');
|
||||||
}
|
}
|
||||||
const { logger } = context;
|
|
||||||
|
|
||||||
const upgradeWithContext = async preview => {
|
const upgradeWithContext = async preview => {
|
||||||
const { image } = preview;
|
const { image } = preview;
|
||||||
|
@ -255,11 +247,6 @@ exports._mapPreviewAttachments = upgradeAttachment => async (
|
||||||
return preview;
|
return preview;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!image.data && !image.path) {
|
|
||||||
logger.warn('Preview did not have image data; removing it');
|
|
||||||
return omit(preview, ['image']);
|
|
||||||
}
|
|
||||||
|
|
||||||
const upgradedImage = await upgradeAttachment(image, context);
|
const upgradedImage = await upgradeAttachment(image, context);
|
||||||
return Object.assign({}, preview, {
|
return Object.assign({}, preview, {
|
||||||
image: upgradedImage,
|
image: upgradedImage,
|
||||||
|
@ -413,6 +400,68 @@ exports.upgradeSchema = async (
|
||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Runs on attachments outside of the schema upgrade process, since attachments are
|
||||||
|
// downloaded out of band.
|
||||||
|
exports.processNewAttachment = async (
|
||||||
|
attachment,
|
||||||
|
{
|
||||||
|
writeNewAttachmentData,
|
||||||
|
getAbsoluteAttachmentPath,
|
||||||
|
makeObjectUrl,
|
||||||
|
revokeObjectUrl,
|
||||||
|
getImageDimensions,
|
||||||
|
makeImageThumbnail,
|
||||||
|
makeVideoScreenshot,
|
||||||
|
logger,
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
if (!isFunction(writeNewAttachmentData)) {
|
||||||
|
throw new TypeError('context.writeNewAttachmentData is required');
|
||||||
|
}
|
||||||
|
if (!isFunction(getAbsoluteAttachmentPath)) {
|
||||||
|
throw new TypeError('context.getAbsoluteAttachmentPath is required');
|
||||||
|
}
|
||||||
|
if (!isFunction(makeObjectUrl)) {
|
||||||
|
throw new TypeError('context.makeObjectUrl is required');
|
||||||
|
}
|
||||||
|
if (!isFunction(revokeObjectUrl)) {
|
||||||
|
throw new TypeError('context.revokeObjectUrl is required');
|
||||||
|
}
|
||||||
|
if (!isFunction(getImageDimensions)) {
|
||||||
|
throw new TypeError('context.getImageDimensions is required');
|
||||||
|
}
|
||||||
|
if (!isFunction(makeImageThumbnail)) {
|
||||||
|
throw new TypeError('context.makeImageThumbnail is required');
|
||||||
|
}
|
||||||
|
if (!isFunction(makeVideoScreenshot)) {
|
||||||
|
throw new TypeError('context.makeVideoScreenshot is required');
|
||||||
|
}
|
||||||
|
if (!isObject(logger)) {
|
||||||
|
throw new TypeError('context.logger is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatedAttachment = await Attachment.autoOrientJPEG(attachment);
|
||||||
|
const onDiskAttachment = await Attachment.migrateDataToFileSystem(
|
||||||
|
rotatedAttachment,
|
||||||
|
{ writeNewAttachmentData }
|
||||||
|
);
|
||||||
|
const finalAttachment = await Attachment.captureDimensionsAndScreenshot(
|
||||||
|
onDiskAttachment,
|
||||||
|
{
|
||||||
|
writeNewAttachmentData,
|
||||||
|
getAbsoluteAttachmentPath,
|
||||||
|
makeObjectUrl,
|
||||||
|
revokeObjectUrl,
|
||||||
|
getImageDimensions,
|
||||||
|
makeImageThumbnail,
|
||||||
|
makeVideoScreenshot,
|
||||||
|
logger,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return finalAttachment;
|
||||||
|
};
|
||||||
|
|
||||||
exports.createAttachmentLoader = loadAttachmentData => {
|
exports.createAttachmentLoader = loadAttachmentData => {
|
||||||
if (!isFunction(loadAttachmentData)) {
|
if (!isFunction(loadAttachmentData)) {
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
|
@ -508,7 +557,10 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
||||||
quote.attachments.map(async attachment => {
|
quote.attachments.map(async attachment => {
|
||||||
const { thumbnail } = attachment;
|
const { thumbnail } = attachment;
|
||||||
|
|
||||||
if (thumbnail && thumbnail.path) {
|
// To prevent spoofing, we copy the original image from the quoted message.
|
||||||
|
// If so, it will have a 'copied' field. We don't want to delete it if it has
|
||||||
|
// that field set to true.
|
||||||
|
if (thumbnail && thumbnail.path && !thumbnail.copied) {
|
||||||
await deleteOnDisk(thumbnail.path);
|
await deleteOnDisk(thumbnail.path);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -801,7 +801,12 @@
|
||||||
const media = _.flatten(
|
const media = _.flatten(
|
||||||
rawMedia.map(message => {
|
rawMedia.map(message => {
|
||||||
const { attachments } = message;
|
const { attachments } = message;
|
||||||
return (attachments || []).map((attachment, index) => {
|
return (attachments || [])
|
||||||
|
.filter(
|
||||||
|
attachment =>
|
||||||
|
attachment.thumbnail && !attachment.pending && !attachment.error
|
||||||
|
)
|
||||||
|
.map((attachment, index) => {
|
||||||
const { thumbnail } = attachment;
|
const { thumbnail } = attachment;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -1240,7 +1245,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachments = message.get('attachments') || [];
|
const attachments = message.get('attachments') || [];
|
||||||
if (attachments.length === 1) {
|
|
||||||
|
const media = attachments
|
||||||
|
.filter(item => item.thumbnail && !item.pending && !item.error)
|
||||||
|
.map((item, index) => ({
|
||||||
|
objectURL: getAbsoluteAttachmentPath(item.path),
|
||||||
|
path: item.path,
|
||||||
|
contentType: item.contentType,
|
||||||
|
index,
|
||||||
|
message,
|
||||||
|
attachment: item,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (media.length === 1) {
|
||||||
const props = {
|
const props = {
|
||||||
objectURL: getAbsoluteAttachmentPath(path),
|
objectURL: getAbsoluteAttachmentPath(path),
|
||||||
contentType,
|
contentType,
|
||||||
|
@ -1258,16 +1275,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIndex = _.findIndex(
|
const selectedIndex = _.findIndex(
|
||||||
attachments,
|
media,
|
||||||
item => attachment.path === item.path
|
item => attachment.path === item.path
|
||||||
);
|
);
|
||||||
const media = attachments.map((item, index) => ({
|
|
||||||
objectURL: getAbsoluteAttachmentPath(item.path),
|
|
||||||
contentType: item.contentType,
|
|
||||||
index,
|
|
||||||
message,
|
|
||||||
attachment: item,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const onSave = async (options = {}) => {
|
const onSave = async (options = {}) => {
|
||||||
Signal.Types.Attachment.save({
|
Signal.Types.Attachment.save({
|
||||||
|
|
|
@ -1118,8 +1118,11 @@ MessageReceiver.prototype.extend({
|
||||||
},
|
},
|
||||||
handleContacts(envelope, contacts) {
|
handleContacts(envelope, contacts) {
|
||||||
window.log.info('contact sync');
|
window.log.info('contact sync');
|
||||||
const attachmentPointer = contacts.blob;
|
const { blob } = contacts;
|
||||||
return this.handleAttachment(attachmentPointer).then(() => {
|
|
||||||
|
// Note: we do not return here because we don't want to block the next message on
|
||||||
|
// this attachment download and a lot of processing of that attachment.
|
||||||
|
this.handleAttachment(blob).then(attachmentPointer => {
|
||||||
const results = [];
|
const results = [];
|
||||||
const contactBuffer = new ContactBuffer(attachmentPointer.data);
|
const contactBuffer = new ContactBuffer(attachmentPointer.data);
|
||||||
let contactDetails = contactBuffer.next();
|
let contactDetails = contactBuffer.next();
|
||||||
|
@ -1142,8 +1145,11 @@ MessageReceiver.prototype.extend({
|
||||||
},
|
},
|
||||||
handleGroups(envelope, groups) {
|
handleGroups(envelope, groups) {
|
||||||
window.log.info('group sync');
|
window.log.info('group sync');
|
||||||
const attachmentPointer = groups.blob;
|
const { blob } = groups;
|
||||||
return this.handleAttachment(attachmentPointer).then(() => {
|
|
||||||
|
// Note: we do not return here because we don't want to block the next message on
|
||||||
|
// this attachment download and a lot of processing of that attachment.
|
||||||
|
this.handleAttachment(blob).then(attachmentPointer => {
|
||||||
const groupBuffer = new GroupBuffer(attachmentPointer.data);
|
const groupBuffer = new GroupBuffer(attachmentPointer.data);
|
||||||
let groupDetails = groupBuffer.next();
|
let groupDetails = groupBuffer.next();
|
||||||
const promises = [];
|
const promises = [];
|
||||||
|
@ -1211,32 +1217,32 @@ MessageReceiver.prototype.extend({
|
||||||
isGroupBlocked(groupId) {
|
isGroupBlocked(groupId) {
|
||||||
return textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0;
|
return textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0;
|
||||||
},
|
},
|
||||||
handleAttachment(attachment) {
|
cleanAttachment(attachment) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
return {
|
||||||
attachment.id = attachment.id.toString();
|
..._.omit(attachment, 'thumbnail'),
|
||||||
// eslint-disable-next-line no-param-reassign
|
id: attachment.id.toString(),
|
||||||
attachment.key = attachment.key.toArrayBuffer();
|
key: attachment.key ? attachment.key.toString('base64') : null,
|
||||||
if (attachment.digest) {
|
digest: attachment.digest ? attachment.digest.toString('base64') : null,
|
||||||
// eslint-disable-next-line no-param-reassign
|
};
|
||||||
attachment.digest = attachment.digest.toArrayBuffer();
|
},
|
||||||
}
|
async downloadAttachment(attachment) {
|
||||||
function decryptAttachment(encrypted) {
|
const encrypted = await this.server.getAttachment(attachment.id);
|
||||||
return textsecure.crypto.decryptAttachment(
|
const { key, digest } = attachment;
|
||||||
|
|
||||||
|
const data = await textsecure.crypto.decryptAttachment(
|
||||||
encrypted,
|
encrypted,
|
||||||
attachment.key,
|
window.Signal.Crypto.base64ToArrayBuffer(key),
|
||||||
attachment.digest
|
window.Signal.Crypto.base64ToArrayBuffer(digest)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function updateAttachment(data) {
|
return {
|
||||||
// eslint-disable-next-line no-param-reassign
|
..._.omit(attachment, 'digest', 'key'),
|
||||||
attachment.data = data;
|
data,
|
||||||
}
|
};
|
||||||
|
},
|
||||||
return this.server
|
handleAttachment(attachment) {
|
||||||
.getAttachment(attachment.id)
|
const cleaned = this.cleanAttachment(attachment);
|
||||||
.then(decryptAttachment)
|
return this.downloadAttachment(cleaned);
|
||||||
.then(updateAttachment);
|
|
||||||
},
|
},
|
||||||
async handleEndSession(number) {
|
async handleEndSession(number) {
|
||||||
window.log.info('got end session');
|
window.log.info('got end session');
|
||||||
|
@ -1291,14 +1297,6 @@ MessageReceiver.prototype.extend({
|
||||||
if (decrypted.group !== null) {
|
if (decrypted.group !== null) {
|
||||||
decrypted.group.id = decrypted.group.id.toBinary();
|
decrypted.group.id = decrypted.group.id.toBinary();
|
||||||
|
|
||||||
if (
|
|
||||||
decrypted.group.type === textsecure.protobuf.GroupContext.Type.UPDATE
|
|
||||||
) {
|
|
||||||
if (decrypted.group.avatar !== null) {
|
|
||||||
promises.push(this.handleAttachment(decrypted.group.avatar));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const storageGroups = textsecure.storage.groups;
|
const storageGroups = textsecure.storage.groups;
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
|
@ -1366,65 +1364,67 @@ MessageReceiver.prototype.extend({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < attachmentCount; i += 1) {
|
// Here we go from binary to string/base64 in all AttachmentPointer digest/key fields
|
||||||
const attachment = decrypted.attachments[i];
|
|
||||||
promises.push(this.handleAttachment(attachment));
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewCount = (decrypted.preview || []).length;
|
if (
|
||||||
for (let i = 0; i < previewCount; i += 1) {
|
decrypted.group &&
|
||||||
const preview = decrypted.preview[i];
|
decrypted.group.type === textsecure.protobuf.GroupContext.Type.UPDATE
|
||||||
if (preview.image) {
|
) {
|
||||||
promises.push(this.handleAttachment(preview.image));
|
if (decrypted.group.avatar !== null) {
|
||||||
|
decrypted.group.avatar = this.cleanAttachment(decrypted.group.avatar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decrypted.contact && decrypted.contact.length) {
|
decrypted.attachments = (decrypted.attachments || []).map(
|
||||||
const contacts = decrypted.contact;
|
this.cleanAttachment.bind(this)
|
||||||
|
|
||||||
for (let i = 0, max = contacts.length; i < max; i += 1) {
|
|
||||||
const contact = contacts[i];
|
|
||||||
const { avatar } = contact;
|
|
||||||
|
|
||||||
if (avatar && avatar.avatar) {
|
|
||||||
// We don't want the failure of a thumbnail download to fail the handling of
|
|
||||||
// this message entirely, like we do for full attachments.
|
|
||||||
promises.push(
|
|
||||||
this.handleAttachment(avatar.avatar).catch(error => {
|
|
||||||
window.log.error(
|
|
||||||
'Problem loading avatar for contact',
|
|
||||||
error && error.stack ? error.stack : error
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
decrypted.preview = (decrypted.preview || []).map(item => {
|
||||||
|
const { image } = item;
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
image: this.cleanAttachment(image),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
decrypted.contact = (decrypted.contact || []).map(item => {
|
||||||
|
const { avatar } = item;
|
||||||
|
|
||||||
|
if (!avatar || !avatar.avatar) {
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
avatar: {
|
||||||
|
...item.avatar,
|
||||||
|
avatar: this.cleanAttachment(item.avatar.avatar),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (decrypted.quote && decrypted.quote.id) {
|
if (decrypted.quote && decrypted.quote.id) {
|
||||||
decrypted.quote.id = decrypted.quote.id.toNumber();
|
decrypted.quote.id = decrypted.quote.id.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decrypted.quote && decrypted.quote.attachments) {
|
if (decrypted.quote) {
|
||||||
const { attachments } = decrypted.quote;
|
decrypted.quote.attachments = (decrypted.quote.attachments || []).map(
|
||||||
|
item => {
|
||||||
|
const { thumbnail } = item;
|
||||||
|
|
||||||
for (let i = 0, max = attachments.length; i < max; i += 1) {
|
if (!thumbnail) {
|
||||||
const attachment = attachments[i];
|
return item;
|
||||||
const { thumbnail } = attachment;
|
}
|
||||||
|
|
||||||
if (thumbnail) {
|
return {
|
||||||
// We don't want the failure of a thumbnail download to fail the handling of
|
...item,
|
||||||
// this message entirely, like we do for full attachments.
|
thumbnail: this.cleanAttachment(item.thumbnail),
|
||||||
promises.push(
|
};
|
||||||
this.handleAttachment(thumbnail).catch(error => {
|
|
||||||
window.log.error(
|
|
||||||
'Problem loading thumbnail for quote',
|
|
||||||
error && error.stack ? error.stack : error
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.all(promises).then(() => decrypted);
|
return Promise.all(promises).then(() => decrypted);
|
||||||
|
@ -1454,6 +1454,11 @@ textsecure.MessageReceiver = function MessageReceiverWrapper(
|
||||||
);
|
);
|
||||||
this.getStatus = messageReceiver.getStatus.bind(messageReceiver);
|
this.getStatus = messageReceiver.getStatus.bind(messageReceiver);
|
||||||
this.close = messageReceiver.close.bind(messageReceiver);
|
this.close = messageReceiver.close.bind(messageReceiver);
|
||||||
|
|
||||||
|
this.downloadAttachment = messageReceiver.downloadAttachment.bind(
|
||||||
|
messageReceiver
|
||||||
|
);
|
||||||
|
|
||||||
messageReceiver.connect();
|
messageReceiver.connect();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -132,6 +132,32 @@
|
||||||
background-color: $color-gray-60;
|
background-color: $color-gray-60;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--incoming {
|
||||||
|
background-color: $color-gray-15;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--incoming {
|
||||||
|
background-color: $color-gray-60;
|
||||||
|
}
|
||||||
|
.module-spinner__circle--small-incoming {
|
||||||
|
background-color: $color-gray-15;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--small-incoming {
|
||||||
|
background-color: $color-gray-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--outgoing {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--outgoing {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
.module-spinner__circle--small-outgoing {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--small-outgoing {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
|
||||||
&.dark-theme {
|
&.dark-theme {
|
||||||
// _modules
|
// _modules
|
||||||
|
|
||||||
|
@ -295,5 +321,31 @@
|
||||||
.module-embedded-contact__contact-method--incoming {
|
.module-embedded-contact__contact-method--incoming {
|
||||||
color: $color-gray-25;
|
color: $color-gray-25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--incoming {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--incoming {
|
||||||
|
background-color: $color-gray-25;
|
||||||
|
}
|
||||||
|
.module-spinner__circle--small-incoming {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--small-incoming {
|
||||||
|
background-color: $color-gray-25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--outgoing {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--outgoing {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
.module-spinner__circle--small-outgoing {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--small-outgoing {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -204,7 +204,6 @@
|
||||||
// Entirely to ensure that images are centered if they aren't full width of bubble
|
// Entirely to ensure that images are centered if they aren't full width of bubble
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
margin-left: -12px;
|
margin-left: -12px;
|
||||||
margin-right: -12px;
|
margin-right: -12px;
|
||||||
|
@ -251,6 +250,7 @@
|
||||||
.module-message__generic-attachment {
|
.module-message__generic-attachment {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__generic-attachment--with-content-below {
|
.module-message__generic-attachment--with-content-below {
|
||||||
|
@ -264,6 +264,10 @@
|
||||||
.module-message__generic-attachment__icon-container {
|
.module-message__generic-attachment__icon-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.module-message__generic-attachment__spinner-container {
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__generic-attachment__icon {
|
.module-message__generic-attachment__icon {
|
||||||
background: url('../images/file-gradient.svg') no-repeat center;
|
background: url('../images/file-gradient.svg') no-repeat center;
|
||||||
|
@ -967,7 +971,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-embedded-contact--with-content-above {
|
.module-embedded-contact--with-content-above {
|
||||||
|
@ -978,6 +982,11 @@
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-embedded-contact__spinner-container {
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.module-embedded-contact__text-container {
|
.module-embedded-contact__text-container {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
@ -2157,6 +2166,13 @@
|
||||||
background-color: $color-black-02;
|
background-color: $color-black-02;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-image__loading-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
background-color: $color-black-015;
|
||||||
|
}
|
||||||
|
|
||||||
.module-image__image {
|
.module-image__image {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
// redundant with attachment-container, but we get cursor flashing on move otherwise
|
// redundant with attachment-container, but we get cursor flashing on move otherwise
|
||||||
|
@ -2681,6 +2697,80 @@
|
||||||
@include color-svg('../images/x-16.svg', $color-gray-60);
|
@include color-svg('../images/x-16.svg', $color-gray-60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module: Spinner
|
||||||
|
|
||||||
|
.module-spinner__container {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
position: relative;
|
||||||
|
height: 56px;
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
@include color-svg('../images/spinner-track-56.svg', $color-gray-15);
|
||||||
|
z-index: 2;
|
||||||
|
height: 56px;
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
.module-spinner__arc {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
@include color-svg('../images/spinner-56.svg', $color-gray-60);
|
||||||
|
z-index: 3;
|
||||||
|
height: 56px;
|
||||||
|
width: 56px;
|
||||||
|
|
||||||
|
animation: spinner-arc-animation 1000ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-arc-animation {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-spinner__container--small {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--small {
|
||||||
|
@include color-svg('../images/spinner-track-24.svg', $color-gray-15);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--small {
|
||||||
|
@include color-svg('../images/spinner-24.svg', $color-gray-60);
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--incoming {
|
||||||
|
background-color: $color-gray-75;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--incoming {
|
||||||
|
background-color: $color-gray-15;
|
||||||
|
}
|
||||||
|
.module-spinner__circle--small-incoming {
|
||||||
|
background-color: $color-gray-75;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--small-incoming {
|
||||||
|
background-color: $color-gray-15;
|
||||||
|
}
|
||||||
|
|
||||||
// Third-party module: react-contextmenu
|
// Third-party module: react-contextmenu
|
||||||
|
|
||||||
.react-contextmenu {
|
.react-contextmenu {
|
||||||
|
|
|
@ -1326,10 +1326,18 @@ body.dark-theme {
|
||||||
|
|
||||||
// Module: Image
|
// Module: Image
|
||||||
|
|
||||||
|
.module-image {
|
||||||
|
background-color: $color-black;
|
||||||
|
}
|
||||||
|
|
||||||
.module-image__border-overlay {
|
.module-image__border-overlay {
|
||||||
box-shadow: inset 0px 0px 0px 1px $color-white-015;
|
box-shadow: inset 0px 0px 0px 1px $color-white-015;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-image__loading-placeholder {
|
||||||
|
background-color: $color-white-015;
|
||||||
|
}
|
||||||
|
|
||||||
// Module: Image Grid
|
// Module: Image Grid
|
||||||
|
|
||||||
// Module: Typing Animation
|
// Module: Typing Animation
|
||||||
|
@ -1401,6 +1409,47 @@ body.dark-theme {
|
||||||
@include color-svg('../images/x-16.svg', $color-gray-25);
|
@include color-svg('../images/x-16.svg', $color-gray-25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module: Spinner
|
||||||
|
|
||||||
|
.module-spinner__circle {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
.module-spinner__circle--small {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--small {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--incoming {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--incoming {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
.module-spinner__circle--small-incoming {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--small-incoming {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-spinner__circle--outgoing {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--outgoing {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
.module-spinner__circle--small-outgoing {
|
||||||
|
background-color: $color-gray-45;
|
||||||
|
}
|
||||||
|
.module-spinner__arc--small-outgoing {
|
||||||
|
background-color: $color-gray-05;
|
||||||
|
}
|
||||||
|
|
||||||
// Third-party module: react-contextmenu
|
// Third-party module: react-contextmenu
|
||||||
|
|
||||||
.react-contextmenu {
|
.react-contextmenu {
|
||||||
|
|
|
@ -109,6 +109,7 @@ $color-dark-60: #797a7c;
|
||||||
$color-dark-70: #414347;
|
$color-dark-70: #414347;
|
||||||
$color-dark-85: #1a1c20;
|
$color-dark-85: #1a1c20;
|
||||||
$color-black-008: rgba($color-black, 0.08);
|
$color-black-008: rgba($color-black, 0.08);
|
||||||
|
$color-black-005: rgba($color-black, 0.05);
|
||||||
$color-black-008-no-tranparency: #ededed;
|
$color-black-008-no-tranparency: #ededed;
|
||||||
$color-black-016-no-tranparency: #d9d9d9;
|
$color-black-016-no-tranparency: #d9d9d9;
|
||||||
$color-black-012: rgba($color-black, 0.12);
|
$color-black-012: rgba($color-black, 0.12);
|
||||||
|
|
|
@ -575,17 +575,22 @@ describe('Message', () => {
|
||||||
body: 'hey there!',
|
body: 'hey there!',
|
||||||
quote: {
|
quote: {
|
||||||
text: 'hey!',
|
text: 'hey!',
|
||||||
attachments: [],
|
attachments: [
|
||||||
|
{
|
||||||
|
fileName: 'manifesto.txt',
|
||||||
|
contentType: 'text/plain',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = await upgradeVersion(message, { logger });
|
const result = await upgradeVersion(message, { logger });
|
||||||
assert.deepEqual(result, message);
|
assert.deepEqual(result, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('eliminates thumbnails with no data field', async () => {
|
it('does not eliminate thumbnails with missing data field', async () => {
|
||||||
const upgradeAttachment = sinon
|
const upgradeAttachment = sinon
|
||||||
.stub()
|
.stub()
|
||||||
.throws(new Error("Shouldn't be called"));
|
.returns({ fileName: 'processed!' });
|
||||||
const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment);
|
const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment);
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
|
@ -597,7 +602,7 @@ describe('Message', () => {
|
||||||
fileName: 'cat.gif',
|
fileName: 'cat.gif',
|
||||||
contentType: 'image/gif',
|
contentType: 'image/gif',
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
fileName: 'failed to download!',
|
fileName: 'not yet downloaded!',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -611,6 +616,9 @@ describe('Message', () => {
|
||||||
{
|
{
|
||||||
contentType: 'image/gif',
|
contentType: 'image/gif',
|
||||||
fileName: 'cat.gif',
|
fileName: 'cat.gif',
|
||||||
|
thumbnail: {
|
||||||
|
fileName: 'processed!',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
15
ts/components/Spinner.md
Normal file
15
ts/components/Spinner.md
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
#### Large
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
<Spinner />
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Small
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
<Spinner small />
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
47
ts/components/Spinner.tsx
Normal file
47
ts/components/Spinner.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
small?: boolean;
|
||||||
|
direction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Spinner extends React.Component<Props> {
|
||||||
|
public render() {
|
||||||
|
const { small, direction } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'module-spinner__container',
|
||||||
|
direction ? `module-spinner__container--${direction}` : null,
|
||||||
|
small ? 'module-spinner__container--small' : null,
|
||||||
|
small && direction
|
||||||
|
? `module-spinner__container--small-${direction}`
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'module-spinner__circle',
|
||||||
|
direction ? `module-spinner__circle--${direction}` : null,
|
||||||
|
small ? 'module-spinner__circle--small' : null,
|
||||||
|
small && direction
|
||||||
|
? `module-spinner__circle--small-${direction}`
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'module-spinner__arc',
|
||||||
|
direction ? `module-spinner__arc--${direction}` : null,
|
||||||
|
small ? 'module-spinner__arc--small' : null,
|
||||||
|
small && direction
|
||||||
|
? `module-spinner__arc--small-${direction}`
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -66,6 +66,51 @@ const contact = {
|
||||||
</util.ConversationContext>;
|
</util.ConversationContext>;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Image download pending
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
const contact = {
|
||||||
|
name: {
|
||||||
|
displayName: 'Someone Somewhere',
|
||||||
|
},
|
||||||
|
number: [
|
||||||
|
{
|
||||||
|
value: '(202) 555-0000',
|
||||||
|
type: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
avatar: {
|
||||||
|
avatar: {
|
||||||
|
pending: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onClick: () => console.log('onClick'),
|
||||||
|
onSendMessage: () => console.log('onSendMessage'),
|
||||||
|
hasSignalAccount: true,
|
||||||
|
};
|
||||||
|
<util.ConversationContext theme={util.theme} ios={util.ios}>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
contact={contact}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
status="delivered"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
contact={contact}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</util.ConversationContext>;
|
||||||
|
```
|
||||||
|
|
||||||
#### Really long data
|
#### Really long data
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { Avatar } from '../Avatar';
|
import { Avatar } from '../Avatar';
|
||||||
|
import { Spinner } from '../Spinner';
|
||||||
import { Contact, getName } from '../../types/Contact';
|
import { Contact, getName } from '../../types/Contact';
|
||||||
|
|
||||||
import { Localizer } from '../../types/Util';
|
import { Localizer } from '../../types/Util';
|
||||||
|
@ -27,6 +28,7 @@ export class EmbeddedContact extends React.Component<Props> {
|
||||||
withContentBelow,
|
withContentBelow,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const module = 'embedded-contact';
|
const module = 'embedded-contact';
|
||||||
|
const direction = isIncoming ? 'incoming' : 'outgoing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -42,7 +44,7 @@ export class EmbeddedContact extends React.Component<Props> {
|
||||||
role="button"
|
role="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{renderAvatar({ contact, i18n, size: 48 })}
|
{renderAvatar({ contact, i18n, size: 48, direction })}
|
||||||
<div className="module-embedded-contact__text-container">
|
<div className="module-embedded-contact__text-container">
|
||||||
{renderName({ contact, isIncoming, module })}
|
{renderName({ contact, isIncoming, module })}
|
||||||
{renderContactShorthand({ contact, isIncoming, module })}
|
{renderContactShorthand({ contact, isIncoming, module })}
|
||||||
|
@ -58,16 +60,27 @@ export function renderAvatar({
|
||||||
contact,
|
contact,
|
||||||
i18n,
|
i18n,
|
||||||
size,
|
size,
|
||||||
|
direction,
|
||||||
}: {
|
}: {
|
||||||
contact: Contact;
|
contact: Contact;
|
||||||
i18n: Localizer;
|
i18n: Localizer;
|
||||||
size: number;
|
size: number;
|
||||||
|
direction?: string;
|
||||||
}) {
|
}) {
|
||||||
const { avatar } = contact;
|
const { avatar } = contact;
|
||||||
|
|
||||||
const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
|
const avatarPath = avatar && avatar.avatar && avatar.avatar.path;
|
||||||
|
const pending = avatar && avatar.avatar && avatar.avatar.pending;
|
||||||
const name = getName(contact) || '';
|
const name = getName(contact) || '';
|
||||||
|
|
||||||
|
if (pending) {
|
||||||
|
return (
|
||||||
|
<div className="module-embedded-contact__spinner-container">
|
||||||
|
<Spinner small={size < 50} direction={direction} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
avatarPath={avatarPath}
|
avatarPath={avatarPath}
|
||||||
|
|
|
@ -1,44 +1,203 @@
|
||||||
### Various sizes
|
### Various sizes
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<Image height='200' width='199' url={util.pngObjectUrl} />
|
<util.ConversationContext theme={util.theme}>
|
||||||
<Image height='149' width='149' url={util.pngObjectUrl} />
|
<Image
|
||||||
<Image height='99' width='99' url={util.pngObjectUrl} />
|
height="200"
|
||||||
|
width="199"
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{ pending: true }}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Various curved corners
|
### Various curved corners
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<Image height='149' width='149' curveTopLeft url={util.pngObjectUrl} />
|
<util.ConversationContext theme={util.theme}>
|
||||||
<Image height='149' width='149' curveTopRight url={util.pngObjectUrl} />
|
<Image
|
||||||
<Image height='149' width='149' curveBottomLeft url={util.pngObjectUrl} />
|
height="149"
|
||||||
<Image height='149' width='149' curveBottomRight url={util.pngObjectUrl} />
|
width="149"
|
||||||
|
curveTopLeft
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
curveTopRight
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
curveBottomLeft
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
curveBottomRight
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
curveBottomRight
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{ pending: true }}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
### With bottom overlay
|
### With bottom overlay
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<Image height='149' width='149' bottomOverlay url={util.pngObjectUrl} />
|
<util.ConversationContext theme={util.theme}>
|
||||||
<Image height='149' width='149' bottomOverlay curveBottomRight url={util.pngObjectUrl} />
|
<Image
|
||||||
<Image height='149' width='149' bottomOverlay curveBottomLeft url={util.pngObjectUrl} />
|
height="149"
|
||||||
|
width="149"
|
||||||
|
bottomOverlay
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
bottomOverlay
|
||||||
|
curveBottomRight
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
bottomOverlay
|
||||||
|
curveBottomLeft
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
bottomOverlay
|
||||||
|
curveBottomLeft
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{ pending: true }}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
### With play icon
|
### With play icon
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<Image height='200' width='199' playIconOverlay url={util.pngObjectUrl} />
|
<util.ConversationContext theme={util.theme}>
|
||||||
<Image height='149' width='149' playIconOverlay url={util.pngObjectUrl} />
|
<Image
|
||||||
<Image height='99' width='99' playIconOverlay url={util.pngObjectUrl} />
|
height="200"
|
||||||
|
width="199"
|
||||||
|
playIconOverlay
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
playIconOverlay
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
playIconOverlay
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{}}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
playIconOverlay
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
attachment={{ pending: true }}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
### With dark overlay and text
|
### With dark overlay and text
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<div>
|
<util.ConversationContext theme={util.theme}>
|
||||||
<div>
|
<div>
|
||||||
<Image height="200" width="199" darkOverlay url={util.pngObjectUrl} />
|
<Image
|
||||||
<Image height="149" width="149" darkOverlay url={util.pngObjectUrl} />
|
height="200"
|
||||||
<Image height="99" width="99" darkOverlay url={util.pngObjectUrl} />
|
width="199"
|
||||||
|
darkOverlay
|
||||||
|
attachment={{}}
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="149"
|
||||||
|
width="149"
|
||||||
|
darkOverlay
|
||||||
|
attachment={{}}
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
darkOverlay
|
||||||
|
attachment={{}}
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
darkOverlay
|
||||||
|
attachment={{ pending: true }}
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<div>
|
<div>
|
||||||
|
@ -46,31 +205,46 @@
|
||||||
height="200"
|
height="200"
|
||||||
width="199"
|
width="199"
|
||||||
darkOverlay
|
darkOverlay
|
||||||
|
attachment={{}}
|
||||||
overlayText="+3"
|
overlayText="+3"
|
||||||
url={util.pngObjectUrl}
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
height="149"
|
height="149"
|
||||||
width="149"
|
width="149"
|
||||||
darkOverlay
|
darkOverlay
|
||||||
|
attachment={{}}
|
||||||
overlayText="+3"
|
overlayText="+3"
|
||||||
url={util.pngObjectUrl}
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
height="99"
|
height="99"
|
||||||
width="99"
|
width="99"
|
||||||
darkOverlay
|
darkOverlay
|
||||||
|
attachment={{}}
|
||||||
overlayText="+3"
|
overlayText="+3"
|
||||||
url={util.pngObjectUrl}
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
darkOverlay
|
||||||
|
attachment={{ pending: true }}
|
||||||
|
overlayText="+3"
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
### With caption
|
### With caption
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<div>
|
<util.ConversationContext theme={util.theme}>
|
||||||
<div>
|
<div>
|
||||||
<Image
|
<Image
|
||||||
height="200"
|
height="200"
|
||||||
|
@ -93,6 +267,13 @@
|
||||||
url={util.pngObjectUrl}
|
url={util.pngObjectUrl}
|
||||||
i18n={util.i18n}
|
i18n={util.i18n}
|
||||||
/>
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
attachment={{ caption: 'dogs playing', pending: true }}
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<div>
|
<div>
|
||||||
|
@ -123,18 +304,28 @@
|
||||||
url={util.pngObjectUrl}
|
url={util.pngObjectUrl}
|
||||||
i18n={util.i18n}
|
i18n={util.i18n}
|
||||||
/>
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
attachment={{ caption: 'dogs playing', pending: true }}
|
||||||
|
darkOverlay
|
||||||
|
overlayText="+3"
|
||||||
|
url={util.pngObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
### With top-right X and soft corners
|
### With top-right X and soft corners
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
<div>
|
<util.ConversationContext theme={util.theme}>
|
||||||
<div>
|
<div>
|
||||||
<Image
|
<Image
|
||||||
height="200"
|
height="200"
|
||||||
width="199"
|
width="199"
|
||||||
|
attachment={{}}
|
||||||
closeButton={true}
|
closeButton={true}
|
||||||
onClick={() => console.log('onClick')}
|
onClick={() => console.log('onClick')}
|
||||||
onClickClose={attachment => console.log('onClickClose', attachment)}
|
onClickClose={attachment => console.log('onClickClose', attachment)}
|
||||||
|
@ -145,6 +336,7 @@
|
||||||
<Image
|
<Image
|
||||||
height="149"
|
height="149"
|
||||||
width="149"
|
width="149"
|
||||||
|
attachment={{}}
|
||||||
closeButton={true}
|
closeButton={true}
|
||||||
onClick={() => console.log('onClick')}
|
onClick={() => console.log('onClick')}
|
||||||
onClickClose={attachment => console.log('onClickClose', attachment)}
|
onClickClose={attachment => console.log('onClickClose', attachment)}
|
||||||
|
@ -155,6 +347,18 @@
|
||||||
<Image
|
<Image
|
||||||
height="99"
|
height="99"
|
||||||
width="99"
|
width="99"
|
||||||
|
attachment={{}}
|
||||||
|
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"
|
||||||
|
attachment={{ pending: true }}
|
||||||
closeButton={true}
|
closeButton={true}
|
||||||
onClick={() => console.log('onClick')}
|
onClick={() => console.log('onClick')}
|
||||||
onClickClose={attachment => console.log('onClickClose', attachment)}
|
onClickClose={attachment => console.log('onClickClose', attachment)}
|
||||||
|
@ -168,6 +372,7 @@
|
||||||
<Image
|
<Image
|
||||||
height="200"
|
height="200"
|
||||||
width="199"
|
width="199"
|
||||||
|
attachment={{}}
|
||||||
closeButton={true}
|
closeButton={true}
|
||||||
attachment={{ caption: 'dogs playing' }}
|
attachment={{ caption: 'dogs playing' }}
|
||||||
onClick={() => console.log('onClick')}
|
onClick={() => console.log('onClick')}
|
||||||
|
@ -179,6 +384,7 @@
|
||||||
<Image
|
<Image
|
||||||
height="149"
|
height="149"
|
||||||
width="149"
|
width="149"
|
||||||
|
attachment={{}}
|
||||||
closeButton={true}
|
closeButton={true}
|
||||||
attachment={{ caption: 'dogs playing' }}
|
attachment={{ caption: 'dogs playing' }}
|
||||||
onClick={() => console.log('onClick')}
|
onClick={() => console.log('onClick')}
|
||||||
|
@ -198,6 +404,17 @@
|
||||||
url={util.gifObjectUrl}
|
url={util.gifObjectUrl}
|
||||||
i18n={util.i18n}
|
i18n={util.i18n}
|
||||||
/>
|
/>
|
||||||
|
<Image
|
||||||
|
height="99"
|
||||||
|
width="99"
|
||||||
|
closeButton={true}
|
||||||
|
attachment={{ caption: 'dogs playing', pending: true }}
|
||||||
|
onClick={() => console.log('onClick')}
|
||||||
|
onClickClose={attachment => console.log('onClickClose', attachment)}
|
||||||
|
softCorners={true}
|
||||||
|
url={util.gifObjectUrl}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { Spinner } from '../Spinner';
|
||||||
import { Localizer } from '../../types/Util';
|
import { Localizer } from '../../types/Util';
|
||||||
import { AttachmentType } from './types';
|
import { AttachmentType } from './types';
|
||||||
|
|
||||||
|
@ -59,19 +60,20 @@ export class Image extends React.Component<Props> {
|
||||||
width,
|
width,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const { caption } = attachment || { caption: null };
|
const { caption, pending } = attachment || { caption: null, pending: true };
|
||||||
|
const canClick = onClick && !pending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role={onClick ? 'button' : undefined}
|
role={canClick ? 'button' : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onClick) {
|
if (canClick) {
|
||||||
onClick(attachment);
|
onClick(attachment);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-image',
|
'module-image',
|
||||||
onClick ? 'module-image__with-click-handler' : null,
|
canClick ? '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,
|
||||||
|
@ -80,6 +82,20 @@ export class Image extends React.Component<Props> {
|
||||||
softCorners ? 'module-image--soft-corners' : null
|
softCorners ? 'module-image--soft-corners' : null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{pending ? (
|
||||||
|
<div
|
||||||
|
className="module-image__loading-placeholder"
|
||||||
|
style={{
|
||||||
|
height: `${height}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
lineHeight: `${height}px`,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
// alt={i18n('loading')}
|
||||||
|
>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<img
|
<img
|
||||||
onError={onError}
|
onError={onError}
|
||||||
className="module-image__image"
|
className="module-image__image"
|
||||||
|
@ -88,6 +104,7 @@ export class Image extends React.Component<Props> {
|
||||||
width={width}
|
width={width}
|
||||||
src={url}
|
src={url}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{caption ? (
|
{caption ? (
|
||||||
<img
|
<img
|
||||||
className="module-image__caption-icon"
|
className="module-image__caption-icon"
|
||||||
|
@ -128,7 +145,7 @@ export class Image extends React.Component<Props> {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{playIconOverlay ? (
|
{!pending && playIconOverlay ? (
|
||||||
<div className="module-image__play-overlay__circle">
|
<div className="module-image__play-overlay__circle">
|
||||||
<div className="module-image__play-overlay__icon" />
|
<div className="module-image__play-overlay__icon" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -340,7 +340,11 @@ export function isImageAttachment(attachment: AttachmentType) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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 || attachments[0].pending)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVideo(attachments?: Array<AttachmentType>) {
|
export function isVideo(attachments?: Array<AttachmentType>) {
|
||||||
|
|
|
@ -1166,6 +1166,111 @@ Note that the delivered indicator is always Signal Blue, not the conversation co
|
||||||
</util.ConversationContext>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Pending images
|
||||||
|
|
||||||
|
```
|
||||||
|
<util.ConversationContext theme={util.theme} ios={util.ios}>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="Hey there!"
|
||||||
|
i18n={util.i18n}
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickAttachment={() => console.log('onClickAttachment')}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
status="sent"
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="Hey there!"
|
||||||
|
i18n={util.i18n}
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickAttachment={() => console.log('onClickAttachment')}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
timestamp={Date.now()}
|
||||||
|
i18n={util.i18n}
|
||||||
|
text="Three images"
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickAttachment={() => console.log('onClickAttachment')}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
status="delivered"
|
||||||
|
timestamp={Date.now()}
|
||||||
|
i18n={util.i18n}
|
||||||
|
text="Three images"
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickAttachment={() => console.log('onClickAttachment')}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
#### Image with portrait aspect ratio
|
#### Image with portrait aspect ratio
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
|
@ -2533,6 +2638,84 @@ Voice notes are not shown any differently from audio attachments.
|
||||||
</util.ConversationContext>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Other file type pending
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme} ios={util.ios}>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
text="My manifesto is now complete!"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
fileName: 'my_manifesto.txt',
|
||||||
|
fileSize: '3.05 KB',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickAttachment={() => console.log('onClickAttachment')}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
text="My manifesto is now complete!"
|
||||||
|
status="sent"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
fileName: 'my_manifesto.txt',
|
||||||
|
fileSize: '3.05 KB',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickAttachment={() => console.log('onClickAttachment')}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
fileName: 'my_manifesto.txt',
|
||||||
|
fileSize: '3.05 KB',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickAttachment={() => console.log('onClickAttachment')}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
fileName: 'my_manifesto.txt',
|
||||||
|
fileSize: '3.05 KB',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickAttachment={() => console.log('onClickAttachment')}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
#### Dangerous file type
|
#### Dangerous file type
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
|
@ -2799,6 +2982,103 @@ Voice notes are not shown any differently from audio attachments.
|
||||||
</util.ConversationContext>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Link previews with pending image
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme} ios={util.ios}>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
pending: true,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 800,
|
||||||
|
height: 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sent"
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
pending: true,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 800,
|
||||||
|
height: 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
pending: true,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sent"
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
pending: true,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
#### Link previews, no image
|
#### Link previews, no image
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
|
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { Avatar } from '../Avatar';
|
import { Avatar } from '../Avatar';
|
||||||
|
import { Spinner } from '../Spinner';
|
||||||
import { MessageBody } from './MessageBody';
|
import { MessageBody } from './MessageBody';
|
||||||
import { ExpireTimer, getIncrement } from './ExpireTimer';
|
import { ExpireTimer, getIncrement } from './ExpireTimer';
|
||||||
import {
|
import {
|
||||||
|
@ -345,7 +346,7 @@ export class Message extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (isAudio(attachments)) {
|
} else if (!firstAttachment.pending && isAudio(attachments)) {
|
||||||
return (
|
return (
|
||||||
<audio
|
<audio
|
||||||
controls={true}
|
controls={true}
|
||||||
|
@ -358,12 +359,13 @@ export class Message extends React.Component<Props, State> {
|
||||||
? 'module-message__audio-attachment--with-content-above'
|
? 'module-message__audio-attachment--with-content-above'
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
|
key={firstAttachment.url}
|
||||||
>
|
>
|
||||||
<source src={firstAttachment.url} />
|
<source src={firstAttachment.url} />
|
||||||
</audio>
|
</audio>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const { fileName, fileSize, contentType } = firstAttachment;
|
const { pending, fileName, fileSize, contentType } = firstAttachment;
|
||||||
const extension = getExtension({ contentType, fileName });
|
const extension = getExtension({ contentType, fileName });
|
||||||
const isDangerous = isFileDangerous(fileName || '');
|
const isDangerous = isFileDangerous(fileName || '');
|
||||||
|
|
||||||
|
@ -379,6 +381,11 @@ export class Message extends React.Component<Props, State> {
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{pending ? (
|
||||||
|
<div className="module-message__generic-attachment__spinner-container">
|
||||||
|
<Spinner small={true} direction={direction} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="module-message__generic-attachment__icon-container">
|
<div className="module-message__generic-attachment__icon-container">
|
||||||
<div className="module-message__generic-attachment__icon">
|
<div className="module-message__generic-attachment__icon">
|
||||||
{extension ? (
|
{extension ? (
|
||||||
|
@ -393,6 +400,7 @@ export class Message extends React.Component<Props, State> {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="module-message__generic-attachment__text">
|
<div className="module-message__generic-attachment__text">
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@ -711,9 +719,10 @@ export class Message extends React.Component<Props, State> {
|
||||||
attachments && attachments[0] ? attachments[0].fileName : null;
|
attachments && attachments[0] ? attachments[0].fileName : null;
|
||||||
const isDangerous = isFileDangerous(fileName || '');
|
const isDangerous = isFileDangerous(fileName || '');
|
||||||
const multipleAttachments = attachments && attachments.length > 1;
|
const multipleAttachments = attachments && attachments.length > 1;
|
||||||
|
const firstAttachment = attachments && attachments[0];
|
||||||
|
|
||||||
const downloadButton =
|
const downloadButton =
|
||||||
!multipleAttachments && attachments && attachments[0] ? (
|
!multipleAttachments && firstAttachment && !firstAttachment.pending ? (
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onDownload) {
|
if (onDownload) {
|
||||||
|
@ -983,6 +992,10 @@ export function getExtension({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!contentType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const slash = contentType.indexOf('/');
|
const slash = contentType.indexOf('/');
|
||||||
if (slash >= 0) {
|
if (slash >= 0) {
|
||||||
return contentType.slice(slash + 1);
|
return contentType.slice(slash + 1);
|
||||||
|
|
|
@ -1016,6 +1016,50 @@ messages the color is taken from the contact who wrote the quoted message.
|
||||||
quote={{
|
quote={{
|
||||||
authorColor: 'purple',
|
authorColor: 'purple',
|
||||||
attachment: {
|
attachment: {
|
||||||
|
pending: true,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
fileName: 'pi.gif',
|
||||||
|
},
|
||||||
|
authorPhoneNumber: '(202) 555-0011',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pending image download
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme} ios={util.ios}>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
direction="incoming"
|
||||||
|
timestamp={Date.now()}
|
||||||
|
authorColor="green"
|
||||||
|
text="Yeah, pi. Tough to wrap your head around."
|
||||||
|
i18n={util.i18n}
|
||||||
|
quote={{
|
||||||
|
authorColor: 'purple',
|
||||||
|
attachment: {
|
||||||
|
contentType: 'image/gif',
|
||||||
|
fileName: 'pi.gif',
|
||||||
|
},
|
||||||
|
authorPhoneNumber: '(202) 555-0011',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
direction="outgoing"
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sending"
|
||||||
|
authorColor="green"
|
||||||
|
text="Yeah, pi. Tough to wrap your head around."
|
||||||
|
i18n={util.i18n}
|
||||||
|
quote={{
|
||||||
|
authorColor: 'purple',
|
||||||
|
attachment: {
|
||||||
|
pending: true,
|
||||||
contentType: 'image/gif',
|
contentType: 'image/gif',
|
||||||
fileName: 'pi.gif',
|
fileName: 'pi.gif',
|
||||||
},
|
},
|
||||||
|
|
|
@ -26,6 +26,10 @@ interface Props {
|
||||||
referencedMessageNotFound: boolean;
|
referencedMessageNotFound: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
imageBroken: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuotedAttachmentType {
|
export interface QuotedAttachmentType {
|
||||||
contentType: MIME.MIMEType;
|
contentType: MIME.MIMEType;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
@ -85,7 +89,27 @@ function getTypeLabel({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Quote extends React.Component<Props> {
|
export class Quote extends React.Component<Props, State> {
|
||||||
|
public handleImageErrorBound: () => void;
|
||||||
|
|
||||||
|
public constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.handleImageErrorBound = this.handleImageError.bind(this);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
imageBroken: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleImageError() {
|
||||||
|
// tslint:disable-next-line no-console
|
||||||
|
console.log('Message: Image failed to load; failing over to placeholder');
|
||||||
|
this.setState({
|
||||||
|
imageBroken: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public renderImage(url: string, i18n: Localizer, icon?: string) {
|
public renderImage(url: string, i18n: Localizer, icon?: string) {
|
||||||
const iconElement = icon ? (
|
const iconElement = icon ? (
|
||||||
<div className="module-quote__icon-container__inner">
|
<div className="module-quote__icon-container__inner">
|
||||||
|
@ -102,7 +126,11 @@ export class Quote extends React.Component<Props> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="module-quote__icon-container">
|
<div className="module-quote__icon-container">
|
||||||
<img src={url} alt={i18n('quoteThumbnailAlt')} />
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={i18n('quoteThumbnailAlt')}
|
||||||
|
onError={this.handleImageErrorBound}
|
||||||
|
/>
|
||||||
{iconElement}
|
{iconElement}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -159,6 +187,8 @@ export class Quote extends React.Component<Props> {
|
||||||
|
|
||||||
public renderIconContainer() {
|
public renderIconContainer() {
|
||||||
const { attachment, i18n } = this.props;
|
const { attachment, i18n } = this.props;
|
||||||
|
const { imageBroken } = this.state;
|
||||||
|
|
||||||
if (!attachment) {
|
if (!attachment) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -167,12 +197,12 @@ export class Quote extends React.Component<Props> {
|
||||||
const objectUrl = getObjectUrl(thumbnail);
|
const objectUrl = getObjectUrl(thumbnail);
|
||||||
|
|
||||||
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
if (GoogleChrome.isVideoTypeSupported(contentType)) {
|
||||||
return objectUrl
|
return objectUrl && !imageBroken
|
||||||
? this.renderImage(objectUrl, i18n, 'play')
|
? this.renderImage(objectUrl, i18n, 'play')
|
||||||
: this.renderIcon('movie');
|
: this.renderIcon('movie');
|
||||||
}
|
}
|
||||||
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
||||||
return objectUrl
|
return objectUrl && !imageBroken
|
||||||
? this.renderImage(objectUrl, i18n)
|
? this.renderImage(objectUrl, i18n)
|
||||||
: this.renderIcon('image');
|
: this.renderIcon('image');
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ export interface AttachmentType {
|
||||||
url: string;
|
url: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
fileSize?: string;
|
fileSize?: string;
|
||||||
|
pending?: boolean;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
screenshot?: {
|
screenshot?: {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
|
||||||
import { getName } from '../../types/Contact';
|
import { contactSelector, getName } from '../../types/Contact';
|
||||||
|
|
||||||
describe('Contact', () => {
|
describe('Contact', () => {
|
||||||
describe('getName', () => {
|
describe('getName', () => {
|
||||||
|
@ -61,4 +61,136 @@ describe('Contact', () => {
|
||||||
assert.strictEqual(actual, expected);
|
assert.strictEqual(actual, expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('contactSelector', () => {
|
||||||
|
const regionCode = '1';
|
||||||
|
const hasSignalAccount = true;
|
||||||
|
const getAbsoluteAttachmentPath = (path: string) => `absolute:${path}`;
|
||||||
|
const onSendMessage = () => null;
|
||||||
|
const onClick = () => null;
|
||||||
|
|
||||||
|
it('eliminates avatar if it has had an attachment download error', () => {
|
||||||
|
const contact = {
|
||||||
|
name: {
|
||||||
|
displayName: 'displayName',
|
||||||
|
givenName: 'givenName',
|
||||||
|
familyName: 'familyName',
|
||||||
|
},
|
||||||
|
organization: 'Somewhere, Inc.',
|
||||||
|
avatar: {
|
||||||
|
isProfile: true,
|
||||||
|
avatar: {
|
||||||
|
error: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
name: {
|
||||||
|
displayName: 'displayName',
|
||||||
|
givenName: 'givenName',
|
||||||
|
familyName: 'familyName',
|
||||||
|
},
|
||||||
|
organization: 'Somewhere, Inc.',
|
||||||
|
avatar: undefined,
|
||||||
|
hasSignalAccount,
|
||||||
|
onSendMessage,
|
||||||
|
onClick,
|
||||||
|
number: undefined,
|
||||||
|
};
|
||||||
|
const actual = contactSelector(contact, {
|
||||||
|
regionCode,
|
||||||
|
hasSignalAccount,
|
||||||
|
getAbsoluteAttachmentPath,
|
||||||
|
onSendMessage,
|
||||||
|
onClick,
|
||||||
|
});
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not calculate absolute path if avatar is pending', () => {
|
||||||
|
const contact = {
|
||||||
|
name: {
|
||||||
|
displayName: 'displayName',
|
||||||
|
givenName: 'givenName',
|
||||||
|
familyName: 'familyName',
|
||||||
|
},
|
||||||
|
organization: 'Somewhere, Inc.',
|
||||||
|
avatar: {
|
||||||
|
isProfile: true,
|
||||||
|
avatar: {
|
||||||
|
pending: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
name: {
|
||||||
|
displayName: 'displayName',
|
||||||
|
givenName: 'givenName',
|
||||||
|
familyName: 'familyName',
|
||||||
|
},
|
||||||
|
organization: 'Somewhere, Inc.',
|
||||||
|
avatar: {
|
||||||
|
isProfile: true,
|
||||||
|
avatar: {
|
||||||
|
pending: true,
|
||||||
|
path: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hasSignalAccount,
|
||||||
|
onSendMessage,
|
||||||
|
onClick,
|
||||||
|
number: undefined,
|
||||||
|
};
|
||||||
|
const actual = contactSelector(contact, {
|
||||||
|
regionCode,
|
||||||
|
hasSignalAccount,
|
||||||
|
getAbsoluteAttachmentPath,
|
||||||
|
onSendMessage,
|
||||||
|
onClick,
|
||||||
|
});
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates absolute path', () => {
|
||||||
|
const contact = {
|
||||||
|
name: {
|
||||||
|
displayName: 'displayName',
|
||||||
|
givenName: 'givenName',
|
||||||
|
familyName: 'familyName',
|
||||||
|
},
|
||||||
|
organization: 'Somewhere, Inc.',
|
||||||
|
avatar: {
|
||||||
|
isProfile: true,
|
||||||
|
avatar: {
|
||||||
|
path: 'somewhere',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
name: {
|
||||||
|
displayName: 'displayName',
|
||||||
|
givenName: 'givenName',
|
||||||
|
familyName: 'familyName',
|
||||||
|
},
|
||||||
|
organization: 'Somewhere, Inc.',
|
||||||
|
avatar: {
|
||||||
|
isProfile: true,
|
||||||
|
avatar: {
|
||||||
|
path: 'absolute:somewhere',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hasSignalAccount,
|
||||||
|
onSendMessage,
|
||||||
|
onClick,
|
||||||
|
number: undefined,
|
||||||
|
};
|
||||||
|
const actual = contactSelector(contact, {
|
||||||
|
regionCode,
|
||||||
|
hasSignalAccount,
|
||||||
|
getAbsoluteAttachmentPath,
|
||||||
|
onSendMessage,
|
||||||
|
onClick,
|
||||||
|
});
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -63,7 +63,9 @@ interface Avatar {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
path: string;
|
path?: string;
|
||||||
|
error?: boolean;
|
||||||
|
pending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function contactSelector(
|
export function contactSelector(
|
||||||
|
@ -85,15 +87,21 @@ export function contactSelector(
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let { avatar } = contact;
|
let { avatar } = contact;
|
||||||
if (avatar && avatar.avatar && avatar.avatar.path) {
|
if (avatar && avatar.avatar) {
|
||||||
|
if (avatar.avatar.error) {
|
||||||
|
avatar = undefined;
|
||||||
|
} else {
|
||||||
avatar = {
|
avatar = {
|
||||||
...avatar,
|
...avatar,
|
||||||
avatar: {
|
avatar: {
|
||||||
...avatar.avatar,
|
...avatar.avatar,
|
||||||
path: getAbsoluteAttachmentPath(avatar.avatar.path),
|
path: avatar.avatar.path
|
||||||
|
? getAbsoluteAttachmentPath(avatar.avatar.path)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...contact,
|
...contact,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue