Download attachments in separate queue from message processing

This commit is contained in:
Scott Nonnenberg 2019-01-30 12:15:07 -08:00
parent a43a78731a
commit 1d2c3ae23c
34 changed files with 2062 additions and 214 deletions

View file

@ -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
View 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
View 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

View 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

View 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

View file

@ -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,
};
} }
} }

View file

@ -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,

View 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;
}

View file

@ -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() {

View file

@ -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,

View file

@ -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)) {

View file

@ -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;
} }

View file

@ -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);
} }
}) })

View file

@ -801,20 +801,25 @@
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 || [])
const { thumbnail } = attachment; .filter(
attachment =>
attachment.thumbnail && !attachment.pending && !attachment.error
)
.map((attachment, index) => {
const { thumbnail } = attachment;
return { return {
objectURL: getAbsoluteAttachmentPath(attachment.path), objectURL: getAbsoluteAttachmentPath(attachment.path),
thumbnailObjectUrl: thumbnail thumbnailObjectUrl: thumbnail
? getAbsoluteAttachmentPath(thumbnail.path) ? getAbsoluteAttachmentPath(thumbnail.path)
: null, : null,
contentType: attachment.contentType, contentType: attachment.contentType,
index, index,
attachment, attachment,
message, message,
}; };
}); });
}) })
); );
@ -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({

View file

@ -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;
}, },
cleanAttachment(attachment) {
return {
..._.omit(attachment, 'thumbnail'),
id: attachment.id.toString(),
key: attachment.key ? attachment.key.toString('base64') : null,
digest: attachment.digest ? attachment.digest.toString('base64') : null,
};
},
async downloadAttachment(attachment) {
const encrypted = await this.server.getAttachment(attachment.id);
const { key, digest } = attachment;
const data = await textsecure.crypto.decryptAttachment(
encrypted,
window.Signal.Crypto.base64ToArrayBuffer(key),
window.Signal.Crypto.base64ToArrayBuffer(digest)
);
return {
..._.omit(attachment, 'digest', 'key'),
data,
};
},
handleAttachment(attachment) { handleAttachment(attachment) {
// eslint-disable-next-line no-param-reassign const cleaned = this.cleanAttachment(attachment);
attachment.id = attachment.id.toString(); return this.downloadAttachment(cleaned);
// eslint-disable-next-line no-param-reassign
attachment.key = attachment.key.toArrayBuffer();
if (attachment.digest) {
// eslint-disable-next-line no-param-reassign
attachment.digest = attachment.digest.toArrayBuffer();
}
function decryptAttachment(encrypted) {
return textsecure.crypto.decryptAttachment(
encrypted,
attachment.key,
attachment.digest
);
}
function updateAttachment(data) {
// eslint-disable-next-line no-param-reassign
attachment.data = data;
}
return this.server
.getAttachment(attachment.id)
.then(decryptAttachment)
.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)
);
decrypted.preview = (decrypted.preview || []).map(item => {
const { image } = item;
for (let i = 0, max = contacts.length; i < max; i += 1) { if (!image) {
const contact = contacts[i]; return item;
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
);
})
);
}
} }
}
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();
}; };

View file

@ -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;
}
} }
} }

View file

@ -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 {

View file

@ -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 {

View file

@ -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);

View file

@ -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
View 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
View 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>
);
}
}

View file

@ -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
``` ```

View file

@ -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}

View file

@ -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>
``` ```

View file

@ -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,14 +82,29 @@ export class Image extends React.Component<Props> {
softCorners ? 'module-image--soft-corners' : null softCorners ? 'module-image--soft-corners' : null
)} )}
> >
<img {pending ? (
onError={onError} <div
className="module-image__image" className="module-image__loading-placeholder"
alt={alt} style={{
height={height} height: `${height}px`,
width={width} width: `${width}px`,
src={url} lineHeight: `${height}px`,
/> textAlign: 'center',
}}
// alt={i18n('loading')}
>
<Spinner />
</div>
) : (
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
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>

View file

@ -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>) {

View file

@ -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

View file

@ -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,20 +381,26 @@ export class Message extends React.Component<Props, State> {
: null : null
)} )}
> >
<div className="module-message__generic-attachment__icon-container"> {pending ? (
<div className="module-message__generic-attachment__icon"> <div className="module-message__generic-attachment__spinner-container">
{extension ? ( <Spinner small={true} direction={direction} />
<div className="module-message__generic-attachment__icon__extension"> </div>
{extension} ) : (
<div className="module-message__generic-attachment__icon-container">
<div className="module-message__generic-attachment__icon">
{extension ? (
<div className="module-message__generic-attachment__icon__extension">
{extension}
</div>
) : null}
</div>
{isDangerous ? (
<div className="module-message__generic-attachment__icon-dangerous-container">
<div className="module-message__generic-attachment__icon-dangerous" />
</div> </div>
) : null} ) : null}
</div> </div>
{isDangerous ? ( )}
<div className="module-message__generic-attachment__icon-dangerous-container">
<div className="module-message__generic-attachment__icon-dangerous" />
</div>
) : null}
</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);

View file

@ -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',
}, },

View file

@ -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');
} }

View file

@ -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?: {

View file

@ -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);
});
});
}); });

View file

@ -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,14 +87,20 @@ export function contactSelector(
} = options; } = options;
let { avatar } = contact; let { avatar } = contact;
if (avatar && avatar.avatar && avatar.avatar.path) { if (avatar && avatar.avatar) {
avatar = { if (avatar.avatar.error) {
...avatar, avatar = undefined;
avatar: { } else {
...avatar.avatar, avatar = {
path: getAbsoluteAttachmentPath(avatar.avatar.path), ...avatar,
}, avatar: {
}; ...avatar.avatar,
path: avatar.avatar.path
? getAbsoluteAttachmentPath(avatar.avatar.path)
: undefined,
},
};
}
} }
return { return {