Link Previews
This commit is contained in:
parent
91ef39e482
commit
813924685e
36 changed files with 2298 additions and 134 deletions
|
@ -795,6 +795,7 @@
|
|||
readReceipts,
|
||||
typingIndicators,
|
||||
unidentifiedDeliveryIndicators,
|
||||
linkPreviews,
|
||||
} = configuration;
|
||||
|
||||
storage.put('read-receipt-setting', readReceipts);
|
||||
|
@ -813,6 +814,10 @@
|
|||
storage.put('typingIndicators', typingIndicators);
|
||||
}
|
||||
|
||||
if (linkPreviews === true || linkPreviews === false) {
|
||||
storage.put('linkPreviews', linkPreviews);
|
||||
}
|
||||
|
||||
ev.confirm();
|
||||
}
|
||||
|
||||
|
@ -1107,7 +1112,9 @@
|
|||
}
|
||||
|
||||
try {
|
||||
if (queryMessage.get('schemaVersion') < Message.CURRENT_SCHEMA_VERSION) {
|
||||
if (
|
||||
queryMessage.get('schemaVersion') < Message.VERSION_NEEDED_FOR_DISPLAY
|
||||
) {
|
||||
const upgradedMessage = await upgradeMessageSchema(
|
||||
queryMessage.attributes
|
||||
);
|
||||
|
@ -1126,15 +1133,23 @@
|
|||
|
||||
const queryAttachments = queryMessage.get('attachments') || [];
|
||||
|
||||
if (queryAttachments.length === 0) {
|
||||
return message;
|
||||
if (queryAttachments.length > 0) {
|
||||
const queryFirst = queryAttachments[0];
|
||||
const { thumbnail } = queryFirst;
|
||||
|
||||
if (thumbnail && thumbnail.path) {
|
||||
firstAttachment.thumbnail = thumbnail;
|
||||
}
|
||||
}
|
||||
|
||||
const queryFirst = queryAttachments[0];
|
||||
const { thumbnail } = queryFirst;
|
||||
const queryPreview = queryMessage.get('preview') || [];
|
||||
if (queryPreview.length > 0) {
|
||||
const queryFirst = queryPreview[0];
|
||||
const { image } = queryFirst;
|
||||
|
||||
if (thumbnail && thumbnail.path) {
|
||||
firstAttachment.thumbnail = thumbnail;
|
||||
if (image && image.path) {
|
||||
firstAttachment.thumbnail = image;
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
|
|
|
@ -759,6 +759,7 @@
|
|||
const { getName } = Contact;
|
||||
const contact = quotedMessage.getContact();
|
||||
const attachments = quotedMessage.get('attachments');
|
||||
const preview = quotedMessage.get('preview');
|
||||
|
||||
const body = quotedMessage.get('body');
|
||||
const embeddedContact = quotedMessage.get('contact');
|
||||
|
@ -767,32 +768,45 @@
|
|||
? getName(embeddedContact[0])
|
||||
: '';
|
||||
|
||||
const media =
|
||||
attachments && attachments.length ? attachments : preview || [];
|
||||
|
||||
return {
|
||||
author: contact.id,
|
||||
id: quotedMessage.get('sent_at'),
|
||||
text: body || embeddedContactName,
|
||||
attachments: await Promise.all(
|
||||
(attachments || []).map(async attachment => {
|
||||
const { contentType, fileName, thumbnail } = attachment;
|
||||
media
|
||||
.filter(
|
||||
attachment =>
|
||||
(attachment && attachment.thumbnail) || attachment.message
|
||||
)
|
||||
.map(async attachment => {
|
||||
const { fileName } = attachment;
|
||||
|
||||
return {
|
||||
contentType,
|
||||
// Our protos library complains about this field being undefined, so we
|
||||
// force it to null
|
||||
fileName: fileName || null,
|
||||
thumbnail: thumbnail
|
||||
? {
|
||||
...(await loadAttachmentData(thumbnail)),
|
||||
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
const thumbnail = attachment.thumbnail || attachment.image;
|
||||
const contentType =
|
||||
attachment.contentType ||
|
||||
(attachment.image && attachment.image.contentType);
|
||||
|
||||
return {
|
||||
contentType,
|
||||
// Our protos library complains about this field being undefined, so we
|
||||
// force it to null
|
||||
fileName: fileName || null,
|
||||
thumbnail: thumbnail
|
||||
? {
|
||||
...(await loadAttachmentData(thumbnail)),
|
||||
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
sendMessage(body, attachments, quote) {
|
||||
sendMessage(body, attachments, quote, preview) {
|
||||
this.clearTypingTimers();
|
||||
|
||||
const destination = this.id;
|
||||
|
@ -819,6 +833,7 @@
|
|||
body,
|
||||
conversationId: destination,
|
||||
quote,
|
||||
preview,
|
||||
attachments,
|
||||
sent_at: now,
|
||||
received_at: now,
|
||||
|
@ -885,6 +900,7 @@
|
|||
body,
|
||||
attachmentsWithData,
|
||||
quote,
|
||||
preview,
|
||||
now,
|
||||
expireTimer,
|
||||
profileKey,
|
||||
|
@ -1621,7 +1637,7 @@
|
|||
const { attributes } = message;
|
||||
const { schemaVersion } = attributes;
|
||||
|
||||
if (schemaVersion < Message.CURRENT_SCHEMA_VERSION) {
|
||||
if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) {
|
||||
// Yep, we really do want to wait for each of these
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const upgradedMessage = await upgradeMessageSchema(attributes);
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
getAbsoluteAttachmentPath,
|
||||
loadAttachmentData,
|
||||
loadQuoteData,
|
||||
loadPreviewData,
|
||||
writeNewAttachmentData,
|
||||
} = window.Signal.Migrations;
|
||||
|
||||
|
@ -425,6 +426,7 @@
|
|||
attachments: attachments.map(attachment =>
|
||||
this.getPropsForAttachment(attachment)
|
||||
),
|
||||
previews: this.getPropsForPreview(),
|
||||
quote: this.getPropsForQuote(),
|
||||
authorAvatarPath,
|
||||
isExpired: this.hasExpired,
|
||||
|
@ -434,6 +436,7 @@
|
|||
onRetrySend: () => this.retrySend(),
|
||||
onShowDetail: () => this.trigger('show-message-detail', this),
|
||||
onDelete: () => this.trigger('delete', this),
|
||||
onClickLinkPreview: url => this.trigger('navigate-to', url),
|
||||
onClickAttachment: attachment =>
|
||||
this.trigger('show-lightbox', {
|
||||
attachment,
|
||||
|
@ -526,6 +529,15 @@
|
|||
thumbnail: thumbnailWithObjectUrl,
|
||||
});
|
||||
},
|
||||
getPropsForPreview() {
|
||||
const previews = this.get('preview') || [];
|
||||
|
||||
return previews.map(preview => ({
|
||||
...preview,
|
||||
domain: window.Signal.LinkPreviews.getDomain(preview.url),
|
||||
image: preview.image ? this.getPropsForAttachment(preview.image) : null,
|
||||
}));
|
||||
},
|
||||
getPropsForQuote() {
|
||||
const quote = this.get('quote');
|
||||
if (!quote) {
|
||||
|
@ -712,6 +724,7 @@
|
|||
(this.get('attachments') || []).map(loadAttachmentData)
|
||||
);
|
||||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||
const previewWithData = await loadPreviewData(this.get('preview'));
|
||||
|
||||
const conversation = this.getConversation();
|
||||
const options = conversation.getSendOptions();
|
||||
|
@ -725,6 +738,7 @@
|
|||
this.get('body'),
|
||||
attachmentsWithData,
|
||||
quoteWithData,
|
||||
previewWithData,
|
||||
this.get('sent_at'),
|
||||
this.get('expireTimer'),
|
||||
profileKey,
|
||||
|
@ -741,6 +755,7 @@
|
|||
timestamp: this.get('sent_at'),
|
||||
attachments: attachmentsWithData,
|
||||
quote: quoteWithData,
|
||||
preview: previewWithData,
|
||||
needsSync: !this.get('synced'),
|
||||
expireTimer: this.get('expireTimer'),
|
||||
profileKey,
|
||||
|
@ -775,6 +790,7 @@
|
|||
(this.get('attachments') || []).map(loadAttachmentData)
|
||||
);
|
||||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||
const previewWithData = await loadPreviewData(this.get('preview'));
|
||||
|
||||
const { wrap, sendOptions } = ConversationController.prepareForSend(
|
||||
number
|
||||
|
@ -784,6 +800,7 @@
|
|||
this.get('body'),
|
||||
attachmentsWithData,
|
||||
quoteWithData,
|
||||
previewWithData,
|
||||
this.get('sent_at'),
|
||||
this.get('expireTimer'),
|
||||
profileKey,
|
||||
|
@ -1146,6 +1163,22 @@
|
|||
message.set({ group_update: groupUpdate });
|
||||
}
|
||||
}
|
||||
|
||||
const urls = window.Signal.LinkPreviews.findLinks(dataMessage.body);
|
||||
const incomingPreview = dataMessage.preview || [];
|
||||
const preview = incomingPreview.filter(
|
||||
item =>
|
||||
(item.image || item.title) &&
|
||||
urls.includes(item.url) &&
|
||||
window.Signal.LinkPreviews.isLinkInWhitelist(item.url)
|
||||
);
|
||||
if (preview.length > incomingPreview.length) {
|
||||
window.log.info(
|
||||
`${message.idForLogging()}: Eliminated ${preview.length -
|
||||
incomingPreview.length} previews with invalid urls'`
|
||||
);
|
||||
}
|
||||
|
||||
message.set({
|
||||
attachments: dataMessage.attachments,
|
||||
body: dataMessage.body,
|
||||
|
@ -1158,6 +1191,7 @@
|
|||
hasFileAttachments: dataMessage.hasFileAttachments,
|
||||
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
||||
quote: dataMessage.quote,
|
||||
preview,
|
||||
schemaVersion: dataMessage.schemaVersion,
|
||||
});
|
||||
if (type === 'outgoing') {
|
||||
|
|
|
@ -616,6 +616,50 @@ async function writeContactAvatars(contact, options) {
|
|||
}
|
||||
}
|
||||
|
||||
async function writePreviewImage(preview, options) {
|
||||
const { image } = preview || {};
|
||||
if (!image || !image.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dir, message, index, key, newKey } = options;
|
||||
const name = _getAnonymousAttachmentFileName(message, index);
|
||||
const filename = `${name}-preview`;
|
||||
const target = path.join(dir, filename);
|
||||
|
||||
await writeEncryptedAttachment(target, image.path, {
|
||||
key,
|
||||
newKey,
|
||||
filename,
|
||||
dir,
|
||||
});
|
||||
}
|
||||
|
||||
async function writePreviews(preview, options) {
|
||||
const { name } = options;
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
_.map(preview, (item, index) =>
|
||||
writePreviewImage(
|
||||
item,
|
||||
Object.assign({}, options, {
|
||||
index,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'writePreviews: error exporting conversation',
|
||||
name,
|
||||
':',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEncryptedAttachment(target, source, options = {}) {
|
||||
const { key, newKey, filename, dir } = options;
|
||||
|
||||
|
@ -752,6 +796,18 @@ async function exportConversation(conversation, options = {}) {
|
|||
newKey,
|
||||
});
|
||||
}
|
||||
|
||||
const { preview } = message;
|
||||
if (preview && preview.length > 0) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await writePreviews(preview, {
|
||||
dir: attachmentsDir,
|
||||
name,
|
||||
message,
|
||||
key,
|
||||
newKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const last = messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
|
@ -925,7 +981,18 @@ async function loadAttachments(dir, getName, options) {
|
|||
})
|
||||
);
|
||||
|
||||
// TODO: Handle video screenshots, and image/video thumbnails
|
||||
const { preview } = message;
|
||||
await Promise.all(
|
||||
_.map(preview, (item, index) => {
|
||||
const image = item && item.image;
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = `${getName(message, index)}-preview`;
|
||||
return readEncryptedAttachment(dir, image, name, options);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function saveMessage(message) {
|
||||
|
@ -1013,8 +1080,9 @@ async function importConversation(dir, options) {
|
|||
message.quote.attachments &&
|
||||
message.quote.attachments.length > 0;
|
||||
const hasContacts = message.contact && message.contact.length;
|
||||
const hasPreviews = message.preview && message.preview.length;
|
||||
|
||||
if (hasAttachments || hasQuotedAttachments || hasContacts) {
|
||||
if (hasAttachments || hasQuotedAttachments || hasContacts || hasPreviews) {
|
||||
const importMessage = async () => {
|
||||
const getName = attachmentsDir
|
||||
? _getAnonymousAttachmentFileName
|
||||
|
|
176
js/modules/link_previews.js
Normal file
176
js/modules/link_previews.js
Normal file
|
@ -0,0 +1,176 @@
|
|||
/* global URL */
|
||||
|
||||
const he = require('he');
|
||||
const LinkifyIt = require('linkify-it');
|
||||
|
||||
const linkify = LinkifyIt();
|
||||
const { concatenateBytes, getViewOfArrayBuffer } = require('./crypto');
|
||||
|
||||
module.exports = {
|
||||
assembleChunks,
|
||||
findLinks,
|
||||
getChunkPattern,
|
||||
getDomain,
|
||||
getTitleMetaTag,
|
||||
getImageMetaTag,
|
||||
isLinkInWhitelist,
|
||||
isMediaLinkInWhitelist,
|
||||
};
|
||||
|
||||
const SUPPORTED_DOMAINS = [
|
||||
'youtube.com',
|
||||
'www.youtube.com',
|
||||
'm.youtube.com',
|
||||
'youtu.be',
|
||||
'reddit.com',
|
||||
'www.reddit.com',
|
||||
'm.reddit.com',
|
||||
'imgur.com',
|
||||
'www.imgur.com',
|
||||
'm.imgur.com',
|
||||
'instagram.com',
|
||||
'www.instagram.com',
|
||||
'm.instagram.com',
|
||||
];
|
||||
function isLinkInWhitelist(link) {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
|
||||
if (url.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!url.pathname || url.pathname.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowercase = url.host.toLowerCase();
|
||||
if (!SUPPORTED_DOMAINS.includes(lowercase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg.com|cdninstagram.com|redd.it|imgur.com)$/i;
|
||||
function isMediaLinkInWhitelist(link) {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
|
||||
if (url.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!url.pathname || url.pathname.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SUPPORTED_MEDIA_DOMAINS.test(url.host)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const META_TITLE = /<meta\s+property="og:title"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
|
||||
const META_IMAGE = /<meta\s+property="og:image"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
|
||||
function _getMetaTag(html, regularExpression) {
|
||||
const match = regularExpression.exec(html);
|
||||
if (match && match[1]) {
|
||||
return he.decode(match[1]).trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTitleMetaTag(html) {
|
||||
return _getMetaTag(html, META_TITLE);
|
||||
}
|
||||
function getImageMetaTag(html) {
|
||||
return _getMetaTag(html, META_IMAGE);
|
||||
}
|
||||
|
||||
function findLinks(text) {
|
||||
const matches = linkify.match(text || '') || [];
|
||||
return matches.map(match => match.text);
|
||||
}
|
||||
|
||||
function getDomain(url) {
|
||||
try {
|
||||
const urlObject = new URL(url);
|
||||
return urlObject.hostname;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const KB = 1024;
|
||||
|
||||
function getChunkPattern(size) {
|
||||
if (size > MB) {
|
||||
return _getRequestPattern(size, MB);
|
||||
} else if (size > 500 * KB) {
|
||||
return _getRequestPattern(size, 500 * KB);
|
||||
} else if (size > 100 * KB) {
|
||||
return _getRequestPattern(size, 100 * KB);
|
||||
} else if (size > 50 * KB) {
|
||||
return _getRequestPattern(size, 50 * KB);
|
||||
} else if (size > 10 * KB) {
|
||||
return _getRequestPattern(size, 10 * KB);
|
||||
} else if (size > KB) {
|
||||
return _getRequestPattern(size, KB);
|
||||
}
|
||||
|
||||
throw new Error(`getChunkPattern: Unsupported size: ${size}`);
|
||||
}
|
||||
|
||||
function _getRequestPattern(size, increment) {
|
||||
const results = [];
|
||||
|
||||
let offset = 0;
|
||||
while (size - offset > increment) {
|
||||
results.push({
|
||||
start: offset,
|
||||
end: offset + increment - 1,
|
||||
overlap: 0,
|
||||
});
|
||||
offset += increment;
|
||||
}
|
||||
|
||||
if (size - offset > 0) {
|
||||
results.push({
|
||||
start: size - increment,
|
||||
end: size - 1,
|
||||
overlap: increment - (size - offset),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function assembleChunks(chunkDescriptors) {
|
||||
const chunks = chunkDescriptors.map((chunk, index) => {
|
||||
if (index !== chunkDescriptors.length - 1) {
|
||||
return chunk.data;
|
||||
}
|
||||
|
||||
if (!chunk.overlap) {
|
||||
return chunk.data;
|
||||
}
|
||||
|
||||
return getViewOfArrayBuffer(
|
||||
chunk.data,
|
||||
chunk.overlap,
|
||||
chunk.data.byteLength
|
||||
);
|
||||
});
|
||||
|
||||
return concatenateBytes(...chunks);
|
||||
}
|
|
@ -13,6 +13,7 @@ const Util = require('../../ts/util');
|
|||
const { migrateToSQL } = require('./migrate_to_sql');
|
||||
const Metadata = require('./metadata/SecretSessionCipher');
|
||||
const RefreshSenderCertificate = require('./refresh_sender_certificate');
|
||||
const LinkPreviews = require('./link_previews');
|
||||
|
||||
// Components
|
||||
const {
|
||||
|
@ -55,6 +56,9 @@ const {
|
|||
const {
|
||||
SafetyNumberNotification,
|
||||
} = require('../../ts/components/conversation/SafetyNumberNotification');
|
||||
const {
|
||||
StagedLinkPreview,
|
||||
} = require('../../ts/components/conversation/StagedLinkPreview');
|
||||
const {
|
||||
TimerNotification,
|
||||
} = require('../../ts/components/conversation/TimerNotification');
|
||||
|
@ -120,6 +124,7 @@ function initializeMigrations({
|
|||
const attachmentsPath = getPath(userDataPath);
|
||||
const readAttachmentData = createReader(attachmentsPath);
|
||||
const loadAttachmentData = Type.loadData(readAttachmentData);
|
||||
const loadPreviewData = MessageType.loadPreviewData(readAttachmentData);
|
||||
const loadQuoteData = MessageType.loadQuoteData(readAttachmentData);
|
||||
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
||||
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
|
||||
|
@ -135,8 +140,9 @@ function initializeMigrations({
|
|||
getPlaceholderMigrations,
|
||||
getCurrentVersion,
|
||||
loadAttachmentData,
|
||||
loadQuoteData,
|
||||
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
|
||||
loadPreviewData,
|
||||
loadQuoteData,
|
||||
readAttachmentData,
|
||||
run,
|
||||
upgradeMessageSchema: (message, options = {}) => {
|
||||
|
@ -196,6 +202,7 @@ exports.setup = (options = {}) => {
|
|||
Quote,
|
||||
ResetSessionNotification,
|
||||
SafetyNumberNotification,
|
||||
StagedLinkPreview,
|
||||
TimerNotification,
|
||||
Types: {
|
||||
Message: MediaGalleryMessage,
|
||||
|
@ -226,7 +233,6 @@ exports.setup = (options = {}) => {
|
|||
};
|
||||
|
||||
return {
|
||||
Metadata,
|
||||
Backbone,
|
||||
Components,
|
||||
Crypto,
|
||||
|
@ -234,6 +240,9 @@ exports.setup = (options = {}) => {
|
|||
Database,
|
||||
Emoji,
|
||||
IndexedDB,
|
||||
LinkPreviews,
|
||||
Metadata,
|
||||
migrateToSQL,
|
||||
Migrations,
|
||||
Notifications,
|
||||
OS,
|
||||
|
@ -243,6 +252,5 @@ exports.setup = (options = {}) => {
|
|||
Util,
|
||||
Views,
|
||||
Workflow,
|
||||
migrateToSQL,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -47,6 +47,8 @@ const PRIVATE = 'private';
|
|||
// Version 9
|
||||
// - Attachments: Expand the set of unicode characters we filter out of
|
||||
// attachment filenames
|
||||
// Version 10
|
||||
// - Preview: A new type of attachment can be included in a message.
|
||||
|
||||
const INITIAL_SCHEMA_VERSION = 0;
|
||||
|
||||
|
@ -232,6 +234,46 @@ exports._mapQuotedAttachments = upgradeAttachment => async (
|
|||
});
|
||||
};
|
||||
|
||||
// _mapPreviewAttachments :: (PreviewAttachment -> Promise PreviewAttachment) ->
|
||||
// (Message, Context) ->
|
||||
// Promise Message
|
||||
exports._mapPreviewAttachments = upgradeAttachment => async (
|
||||
message,
|
||||
context
|
||||
) => {
|
||||
if (!message.preview) {
|
||||
return message;
|
||||
}
|
||||
if (!context || !isObject(context.logger)) {
|
||||
throw new Error('_mapPreviewAttachments: context must have logger object');
|
||||
}
|
||||
const { logger } = context;
|
||||
|
||||
const upgradeWithContext = async preview => {
|
||||
const { image } = preview;
|
||||
if (!image) {
|
||||
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);
|
||||
return Object.assign({}, preview, {
|
||||
image: upgradedImage,
|
||||
});
|
||||
};
|
||||
|
||||
const preview = await Promise.all(
|
||||
(message.preview || []).map(upgradeWithContext)
|
||||
);
|
||||
return Object.assign({}, message, {
|
||||
preview,
|
||||
});
|
||||
};
|
||||
|
||||
const toVersion0 = async (message, context) =>
|
||||
exports.initializeSchemaVersion({ message, logger: context.logger });
|
||||
const toVersion1 = exports._withSchemaVersion({
|
||||
|
@ -277,6 +319,10 @@ const toVersion9 = exports._withSchemaVersion({
|
|||
schemaVersion: 9,
|
||||
upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2),
|
||||
});
|
||||
const toVersion10 = exports._withSchemaVersion({
|
||||
schemaVersion: 10,
|
||||
upgrade: exports._mapPreviewAttachments(Attachment.migrateDataToFileSystem),
|
||||
});
|
||||
|
||||
const VERSIONS = [
|
||||
toVersion0,
|
||||
|
@ -289,9 +335,13 @@ const VERSIONS = [
|
|||
toVersion7,
|
||||
toVersion8,
|
||||
toVersion9,
|
||||
toVersion10,
|
||||
];
|
||||
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
|
||||
|
||||
// We need dimensions and screenshots for images for proper display
|
||||
exports.VERSION_NEEDED_FOR_DISPLAY = 9;
|
||||
|
||||
// UpgradeStep
|
||||
exports.upgradeSchema = async (
|
||||
rawMessage,
|
||||
|
@ -408,6 +458,31 @@ exports.loadQuoteData = loadAttachmentData => {
|
|||
};
|
||||
};
|
||||
|
||||
exports.loadPreviewData = loadAttachmentData => {
|
||||
if (!isFunction(loadAttachmentData)) {
|
||||
throw new TypeError('loadPreviewData: loadAttachmentData is required');
|
||||
}
|
||||
|
||||
return async preview => {
|
||||
if (!preview || !preview.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
preview.map(async () => {
|
||||
if (!preview.image) {
|
||||
return preview;
|
||||
}
|
||||
|
||||
return {
|
||||
...preview,
|
||||
image: await loadAttachmentData(preview.image),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
||||
if (!isFunction(deleteAttachmentData)) {
|
||||
throw new TypeError(
|
||||
|
@ -422,7 +497,7 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
|||
}
|
||||
|
||||
return async message => {
|
||||
const { attachments, quote, contact } = message;
|
||||
const { attachments, quote, contact, preview } = message;
|
||||
|
||||
if (attachments && attachments.length) {
|
||||
await Promise.all(attachments.map(deleteAttachmentData));
|
||||
|
@ -451,6 +526,18 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
|||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (preview && preview.length) {
|
||||
await Promise.all(
|
||||
preview.map(async item => {
|
||||
const { image } = item;
|
||||
|
||||
if (image && image.path) {
|
||||
await deleteOnDisk(image.path);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -480,11 +567,12 @@ exports.createAttachmentDataWriter = ({
|
|||
logger,
|
||||
});
|
||||
|
||||
const { attachments, quote, contact } = message;
|
||||
const { attachments, quote, contact, preview } = message;
|
||||
const hasFilesToWrite =
|
||||
(quote && quote.attachments && quote.attachments.length > 0) ||
|
||||
(attachments && attachments.length > 0) ||
|
||||
(contact && contact.length > 0);
|
||||
(contact && contact.length > 0) ||
|
||||
(preview && preview.length > 0);
|
||||
|
||||
if (!hasFilesToWrite) {
|
||||
return message;
|
||||
|
@ -545,11 +633,25 @@ exports.createAttachmentDataWriter = ({
|
|||
});
|
||||
};
|
||||
|
||||
const writePreviewImage = async item => {
|
||||
const { image } = item;
|
||||
if (!image) {
|
||||
return omit(item, ['image']);
|
||||
}
|
||||
|
||||
await writeExistingAttachmentData(image);
|
||||
|
||||
return Object.assign({}, item, {
|
||||
image: omit(image, ['data']),
|
||||
});
|
||||
};
|
||||
|
||||
const messageWithoutAttachmentData = Object.assign(
|
||||
{},
|
||||
await writeThumbnails(message, { logger }),
|
||||
{
|
||||
contact: await Promise.all((contact || []).map(writeContactAvatar)),
|
||||
preview: await Promise.all((preview || []).map(writePreviewImage)),
|
||||
attachments: await Promise.all(
|
||||
(attachments || []).map(async attachment => {
|
||||
await writeExistingAttachmentData(attachment);
|
||||
|
|
|
@ -5,9 +5,7 @@ const { Agent } = require('https');
|
|||
|
||||
const is = require('@sindresorhus/is');
|
||||
|
||||
/* global Buffer: false */
|
||||
/* global setTimeout: false */
|
||||
/* global log: false */
|
||||
/* global Buffer, setTimeout, log, _ */
|
||||
|
||||
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
|
||||
|
||||
|
@ -166,12 +164,28 @@ const agents = {
|
|||
auth: null,
|
||||
};
|
||||
|
||||
function getContentType(response) {
|
||||
if (response.headers && response.headers.get) {
|
||||
return response.headers.get('content-type');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function _promiseAjax(providedUrl, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = providedUrl || `${options.host}/${options.path}`;
|
||||
log.info(
|
||||
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
|
||||
);
|
||||
if (options.disableLogs) {
|
||||
log.info(
|
||||
`${options.type} [REDACTED_URL]${
|
||||
options.unauthenticated ? ' (unauth)' : ''
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
log.info(
|
||||
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
|
||||
);
|
||||
}
|
||||
const timeout =
|
||||
typeof options.timeout !== 'undefined' ? options.timeout : 10000;
|
||||
|
||||
|
@ -195,7 +209,12 @@ function _promiseAjax(providedUrl, options) {
|
|||
const fetchOptions = {
|
||||
method: options.type,
|
||||
body: options.data || null,
|
||||
headers: { 'X-Signal-Agent': 'OWD' },
|
||||
headers: {
|
||||
'User-Agent': 'Signal Desktop (+https://signal.org/download)',
|
||||
'X-Signal-Agent': 'OWD',
|
||||
...options.headers,
|
||||
},
|
||||
redirect: options.redirect,
|
||||
agent,
|
||||
ca: options.certificateAuthority,
|
||||
timeout,
|
||||
|
@ -238,13 +257,20 @@ function _promiseAjax(providedUrl, options) {
|
|||
response.headers.get('Content-Type') === 'application/json'
|
||||
) {
|
||||
resultPromise = response.json();
|
||||
} else if (options.responseType === 'arraybuffer') {
|
||||
} else if (
|
||||
options.responseType === 'arraybuffer' ||
|
||||
options.responseType === 'arraybufferwithdetails'
|
||||
) {
|
||||
resultPromise = response.buffer();
|
||||
} else {
|
||||
resultPromise = response.text();
|
||||
}
|
||||
|
||||
return resultPromise.then(result => {
|
||||
if (options.responseType === 'arraybuffer') {
|
||||
if (
|
||||
options.responseType === 'arraybuffer' ||
|
||||
options.responseType === 'arraybufferwithdetails'
|
||||
) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
result = result.buffer.slice(
|
||||
result.byteOffset,
|
||||
|
@ -254,8 +280,17 @@ function _promiseAjax(providedUrl, options) {
|
|||
if (options.responseType === 'json') {
|
||||
if (options.validateResponse) {
|
||||
if (!_validateResponse(result, options.validateResponse)) {
|
||||
log.error(options.type, url, response.status, 'Error');
|
||||
reject(
|
||||
if (options.disableLogs) {
|
||||
log.info(
|
||||
options.type,
|
||||
'[REDACTED_URL]',
|
||||
response.status,
|
||||
'Error'
|
||||
);
|
||||
} else {
|
||||
log.error(options.type, url, response.status, 'Error');
|
||||
}
|
||||
return reject(
|
||||
HTTPError(
|
||||
'promiseAjax: invalid response',
|
||||
response.status,
|
||||
|
@ -267,23 +302,47 @@ function _promiseAjax(providedUrl, options) {
|
|||
}
|
||||
}
|
||||
if (response.status >= 0 && response.status < 400) {
|
||||
log.info(options.type, url, response.status, 'Success');
|
||||
resolve(result, response.status);
|
||||
if (options.disableLogs) {
|
||||
log.info(
|
||||
options.type,
|
||||
'[REDACTED_URL]',
|
||||
response.status,
|
||||
'Success'
|
||||
);
|
||||
} else {
|
||||
log.info(options.type, url, response.status, 'Success');
|
||||
}
|
||||
if (options.responseType === 'arraybufferwithdetails') {
|
||||
return resolve({
|
||||
data: result,
|
||||
contentType: getContentType(response),
|
||||
response,
|
||||
});
|
||||
}
|
||||
return resolve(result, response.status);
|
||||
}
|
||||
|
||||
if (options.disableLogs) {
|
||||
log.info(options.type, '[REDACTED_URL]', response.status, 'Error');
|
||||
} else {
|
||||
log.error(options.type, url, response.status, 'Error');
|
||||
reject(
|
||||
HTTPError(
|
||||
'promiseAjax: error response',
|
||||
response.status,
|
||||
result,
|
||||
options.stack
|
||||
)
|
||||
);
|
||||
}
|
||||
return reject(
|
||||
HTTPError(
|
||||
'promiseAjax: error response',
|
||||
response.status,
|
||||
result,
|
||||
options.stack
|
||||
)
|
||||
);
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
log.error(options.type, url, 0, 'Error');
|
||||
if (options.disableLogs) {
|
||||
log.error(options.type, '[REDACTED_URL]', 0, 'Error');
|
||||
} else {
|
||||
log.error(options.type, url, 0, 'Error');
|
||||
}
|
||||
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
|
||||
reject(HTTPError('promiseAjax catch', 0, e.toString(), stack));
|
||||
});
|
||||
|
@ -342,7 +401,13 @@ module.exports = {
|
|||
};
|
||||
|
||||
// We first set up the data that won't change during this session of the app
|
||||
function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
||||
function initialize({
|
||||
url,
|
||||
cdnUrl,
|
||||
certificateAuthority,
|
||||
contentProxyUrl,
|
||||
proxyUrl,
|
||||
}) {
|
||||
if (!is.string(url)) {
|
||||
throw new Error('WebAPI.initialize: Invalid server url');
|
||||
}
|
||||
|
@ -352,6 +417,9 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
|||
if (!is.string(certificateAuthority)) {
|
||||
throw new Error('WebAPI.initialize: Invalid certificateAuthority');
|
||||
}
|
||||
if (!is.string(contentProxyUrl)) {
|
||||
throw new Error('WebAPI.initialize: Invalid contentProxyUrl');
|
||||
}
|
||||
|
||||
// Thanks to function-hoisting, we can put this return statement before all of the
|
||||
// below function definitions.
|
||||
|
@ -372,8 +440,6 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
|||
getAttachment,
|
||||
getAvatar,
|
||||
getDevices,
|
||||
getSenderCertificate,
|
||||
registerSupportForUnauthenticatedDelivery,
|
||||
getKeysForNumber,
|
||||
getKeysForNumberUnauth,
|
||||
getMessageSocket,
|
||||
|
@ -381,15 +447,19 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
|||
getProfile,
|
||||
getProfileUnauth,
|
||||
getProvisioningSocket,
|
||||
getProxiedSize,
|
||||
getSenderCertificate,
|
||||
makeProxiedRequest,
|
||||
putAttachment,
|
||||
registerKeys,
|
||||
registerSupportForUnauthenticatedDelivery,
|
||||
removeSignalingKey,
|
||||
requestVerificationSMS,
|
||||
requestVerificationVoice,
|
||||
sendMessages,
|
||||
sendMessagesUnauth,
|
||||
setSignedPreKey,
|
||||
updateDeviceName,
|
||||
removeSignalingKey,
|
||||
};
|
||||
|
||||
function _ajax(param) {
|
||||
|
@ -799,6 +869,47 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
|||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
async function getProxiedSize(url) {
|
||||
const result = await _outerAjax(url, {
|
||||
processData: false,
|
||||
responseType: 'arraybufferwithdetails',
|
||||
proxyUrl: contentProxyUrl,
|
||||
type: 'HEAD',
|
||||
disableLogs: true,
|
||||
});
|
||||
|
||||
const { response } = result;
|
||||
if (!response.headers || !response.headers.get) {
|
||||
throw new Error('getProxiedSize: Problem retrieving header value');
|
||||
}
|
||||
|
||||
const size = response.headers.get('content-length');
|
||||
return parseInt(size, 10);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
function makeProxiedRequest(url, options = {}) {
|
||||
const { returnArrayBuffer, start, end } = options;
|
||||
let headers;
|
||||
|
||||
if (_.isNumber(start) && _.isNumber(end)) {
|
||||
headers = {
|
||||
Range: `bytes=${start}-${end}`,
|
||||
};
|
||||
}
|
||||
|
||||
return _outerAjax(url, {
|
||||
processData: false,
|
||||
responseType: returnArrayBuffer ? 'arraybufferwithdetails' : null,
|
||||
proxyUrl: contentProxyUrl,
|
||||
type: 'GET',
|
||||
redirect: 'follow',
|
||||
disableLogs: true,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function getMessageSocket() {
|
||||
log.info('opening message socket', url);
|
||||
const fixedScheme = url
|
||||
|
|
|
@ -32,10 +32,21 @@ const getInitialData = async () => ({
|
|||
window.initialRequest = getInitialData();
|
||||
|
||||
// eslint-disable-next-line more/no-then
|
||||
window.initialRequest.then(data => {
|
||||
'use strict';
|
||||
window.initialRequest.then(
|
||||
data => {
|
||||
'use strict';
|
||||
|
||||
window.initialData = data;
|
||||
window.view = new Whisper.SettingsView();
|
||||
window.view.$el.appendTo($body);
|
||||
});
|
||||
window.initialData = data;
|
||||
window.view = new Whisper.SettingsView();
|
||||
window.view.$el.appendTo($body);
|
||||
},
|
||||
error => {
|
||||
'use strict';
|
||||
|
||||
window.log.error(
|
||||
'settings.initialRequest error:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
window.closeSettings();
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
/* global
|
||||
$,
|
||||
_,
|
||||
ConversationController
|
||||
emojiData,
|
||||
EmojiPanel,
|
||||
extension,
|
||||
i18n,
|
||||
Signal,
|
||||
storage,
|
||||
textsecure,
|
||||
Whisper,
|
||||
ConversationController
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
|
@ -131,6 +132,9 @@
|
|||
'show-message-detail',
|
||||
this.showMessageDetail
|
||||
);
|
||||
this.listenTo(this.model.messageCollection, 'navigate-to', url => {
|
||||
window.location = url;
|
||||
});
|
||||
|
||||
this.lazyUpdateVerified = _.debounce(
|
||||
this.model.updateVerified.bind(this.model),
|
||||
|
@ -140,6 +144,10 @@
|
|||
this.model.getProfiles.bind(this.model),
|
||||
1000 * 60 * 5 // five minutes
|
||||
);
|
||||
this.debouncedMaybeGrabLinkPreview = _.debounce(
|
||||
this.maybeGrabLinkPreview.bind(this),
|
||||
200
|
||||
);
|
||||
|
||||
this.render();
|
||||
|
||||
|
@ -157,8 +165,11 @@
|
|||
this.onChooseAttachment
|
||||
);
|
||||
this.listenTo(this.fileInput, 'staged-attachments-changed', () => {
|
||||
this.view.resetScrollPosition();
|
||||
this.view.restoreBottomOffset();
|
||||
this.toggleMicrophone();
|
||||
if (this.fileInput.hasFiles()) {
|
||||
this.removeLinkPreview();
|
||||
}
|
||||
});
|
||||
|
||||
const getHeaderProps = () => {
|
||||
|
@ -253,7 +264,7 @@
|
|||
'submit .send': 'checkUnverifiedSendMessage',
|
||||
'input .send-message': 'updateMessageFieldSize',
|
||||
'keydown .send-message': 'updateMessageFieldSize',
|
||||
'keyup .send-message': 'maybeBumpTyping',
|
||||
'keyup .send-message': 'onKeyUp',
|
||||
click: 'onClick',
|
||||
'click .bottom-bar': 'focusMessageField',
|
||||
'click .capture-audio .microphone': 'captureAudio',
|
||||
|
@ -776,7 +787,7 @@
|
|||
const message = rawMedia[i];
|
||||
const { schemaVersion } = message;
|
||||
|
||||
if (schemaVersion < Message.CURRENT_SCHEMA_VERSION) {
|
||||
if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) {
|
||||
// Yep, we really do want to wait for each of these
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
rawMedia[i] = await upgradeMessageSchema(message);
|
||||
|
@ -1634,10 +1645,16 @@
|
|||
const sendDelta = Date.now() - this.sendStart;
|
||||
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
|
||||
|
||||
this.model.sendMessage(message, attachments, this.quote);
|
||||
this.model.sendMessage(
|
||||
message,
|
||||
attachments,
|
||||
this.quote,
|
||||
this.getLinkPreview()
|
||||
);
|
||||
|
||||
input.val('');
|
||||
this.setQuoteMessage(null);
|
||||
this.resetLinkPreview();
|
||||
this.focusMessageFieldAndClearDisabled();
|
||||
this.forceUpdateMessageFieldSize(e);
|
||||
this.fileInput.clearAttachments();
|
||||
|
@ -1651,6 +1668,311 @@
|
|||
}
|
||||
},
|
||||
|
||||
onKeyUp() {
|
||||
this.maybeBumpTyping();
|
||||
this.debouncedMaybeGrabLinkPreview();
|
||||
},
|
||||
|
||||
maybeGrabLinkPreview() {
|
||||
// Don't generate link previews if user has turned them off
|
||||
if (!storage.get('linkPreviews', false)) {
|
||||
return;
|
||||
}
|
||||
// Do nothing if we're offline
|
||||
if (!textsecure.messaging) {
|
||||
return;
|
||||
}
|
||||
// If we have attachments, don't add link preview
|
||||
if (this.fileInput.hasFiles()) {
|
||||
return;
|
||||
}
|
||||
// If we're behind a user-configured proxy, we don't support link previews
|
||||
if (window.isBehindProxy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageText = this.$messageField.val().trim();
|
||||
|
||||
if (!messageText) {
|
||||
this.resetLinkPreview();
|
||||
return;
|
||||
}
|
||||
if (this.disableLinkPreviews) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = window.Signal.LinkPreviews.findLinks(messageText);
|
||||
const { currentlyMatchedLink } = this;
|
||||
if (links.includes(currentlyMatchedLink)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentlyMatchedLink = null;
|
||||
this.excludedPreviewUrls = this.excludedPreviewUrls || [];
|
||||
|
||||
const link = links.find(
|
||||
item =>
|
||||
window.Signal.LinkPreviews.isLinkInWhitelist(item) &&
|
||||
!this.excludedPreviewUrls.includes(item)
|
||||
);
|
||||
if (!link) {
|
||||
this.removeLinkPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentlyMatchedLink = link;
|
||||
this.addLinkPreview(link);
|
||||
},
|
||||
|
||||
resetLinkPreview() {
|
||||
this.disableLinkPreviews = false;
|
||||
this.excludedPreviewUrls = [];
|
||||
this.removeLinkPreview();
|
||||
},
|
||||
|
||||
removeLinkPreview() {
|
||||
(this.preview || []).forEach(item => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
});
|
||||
this.preview = null;
|
||||
this.previewLoading = null;
|
||||
this.currentlyMatchedLink = false;
|
||||
this.renderLinkPreview();
|
||||
},
|
||||
|
||||
async makeChunkedRequest(url) {
|
||||
const PARALLELISM = 3;
|
||||
const size = await textsecure.messaging.getProxiedSize(url);
|
||||
const chunks = await Signal.LinkPreviews.getChunkPattern(size);
|
||||
|
||||
let results = [];
|
||||
const jobs = chunks.map(chunk => async () => {
|
||||
const { start, end } = chunk;
|
||||
|
||||
const result = await textsecure.messaging.makeProxiedRequest(url, {
|
||||
start,
|
||||
end,
|
||||
returnArrayBuffer: true,
|
||||
});
|
||||
|
||||
return {
|
||||
...chunk,
|
||||
...result,
|
||||
};
|
||||
});
|
||||
|
||||
while (jobs.length > 0) {
|
||||
const activeJobs = [];
|
||||
for (let i = 0, max = PARALLELISM; i < max; i += 1) {
|
||||
if (!jobs.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const job = jobs.shift();
|
||||
activeJobs.push(job());
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
results = results.concat(await Promise.all(activeJobs));
|
||||
}
|
||||
|
||||
if (!results.length) {
|
||||
throw new Error('No responses received');
|
||||
}
|
||||
|
||||
const { contentType } = results[0];
|
||||
const data = Signal.LinkPreviews.assembleChunks(results);
|
||||
|
||||
return {
|
||||
contentType,
|
||||
data,
|
||||
};
|
||||
},
|
||||
|
||||
async getPreview(url) {
|
||||
let html;
|
||||
try {
|
||||
html = await textsecure.messaging.makeProxiedRequest(url);
|
||||
} catch (error) {
|
||||
if (error.code >= 300) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const title = window.Signal.LinkPreviews.getTitleMetaTag(html);
|
||||
const imageUrl = window.Signal.LinkPreviews.getImageMetaTag(html);
|
||||
|
||||
let image;
|
||||
let objectUrl;
|
||||
try {
|
||||
if (imageUrl) {
|
||||
if (!Signal.LinkPreviews.isMediaLinkInWhitelist(imageUrl)) {
|
||||
const primaryDomain = Signal.LinkPreviews.getDomain(url);
|
||||
const imageDomain = Signal.LinkPreviews.getDomain(imageUrl);
|
||||
throw new Error(
|
||||
`imageUrl for domain ${primaryDomain} did not match media whitelist. Domain: ${imageDomain}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await this.makeChunkedRequest(imageUrl);
|
||||
|
||||
// Ensure that this file is either small enough or is resized to meet our
|
||||
// requirements for attachments
|
||||
const withBlob = await this.fileInput.autoScale({
|
||||
contentType: data.contentType,
|
||||
file: new Blob([data.data], {
|
||||
type: data.contentType,
|
||||
}),
|
||||
});
|
||||
|
||||
const attachment = await this.fileInput.readFile(withBlob);
|
||||
objectUrl = URL.createObjectURL(withBlob.file);
|
||||
|
||||
const dimensions = await Signal.Types.VisualAttachment.getImageDimensions(
|
||||
{
|
||||
objectUrl,
|
||||
logger: window.log,
|
||||
}
|
||||
);
|
||||
|
||||
image = {
|
||||
...attachment,
|
||||
...dimensions,
|
||||
contentType: withBlob.file.type,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// We still want to show the preview if we failed to get an image
|
||||
window.log.error(
|
||||
'getPreview failed to get image for link preview:',
|
||||
error.message
|
||||
);
|
||||
} finally {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
url,
|
||||
image,
|
||||
};
|
||||
},
|
||||
|
||||
async addLinkPreview(url) {
|
||||
(this.preview || []).forEach(item => {
|
||||
if (item.url) {
|
||||
URL.revokeObjectURL(item.url);
|
||||
}
|
||||
});
|
||||
this.preview = null;
|
||||
|
||||
this.currentlyMatchedLink = url;
|
||||
this.previewLoading = this.getPreview(url);
|
||||
const promise = this.previewLoading;
|
||||
this.renderLinkPreview();
|
||||
|
||||
try {
|
||||
const result = await promise;
|
||||
|
||||
if (
|
||||
url !== this.currentlyMatchedLink ||
|
||||
promise !== this.previewLoading
|
||||
) {
|
||||
// another request was started, or this was canceled
|
||||
return;
|
||||
}
|
||||
|
||||
// If we couldn't pull down the initial URL
|
||||
if (!result) {
|
||||
this.excludedPreviewUrls.push(url);
|
||||
this.removeLinkPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.image) {
|
||||
const blob = new Blob([result.image.data], {
|
||||
type: result.image.contentType,
|
||||
});
|
||||
result.image.url = URL.createObjectURL(blob);
|
||||
} else if (!result.title) {
|
||||
// A link preview isn't worth showing unless we have either a title or an image
|
||||
this.removeLinkPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
this.preview = [result];
|
||||
this.renderLinkPreview();
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'Problem loading link preview, disabling.',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
this.disableLinkPreviews = true;
|
||||
this.removeLinkPreview();
|
||||
}
|
||||
},
|
||||
|
||||
renderLinkPreview() {
|
||||
if (this.previewView) {
|
||||
this.previewView.remove();
|
||||
this.previewView = null;
|
||||
}
|
||||
if (!this.currentlyMatchedLink) {
|
||||
this.view.restoreBottomOffset();
|
||||
this.updateMessageFieldSize({});
|
||||
return;
|
||||
}
|
||||
|
||||
const first = (this.preview && this.preview[0]) || null;
|
||||
const props = {
|
||||
...first,
|
||||
domain: first && window.Signal.LinkPreviews.getDomain(first.url),
|
||||
isLoaded: Boolean(first),
|
||||
onClose: () => {
|
||||
this.disableLinkPreviews = true;
|
||||
this.removeLinkPreview();
|
||||
},
|
||||
};
|
||||
|
||||
this.previewView = new Whisper.ReactWrapperView({
|
||||
className: 'preview-wrapper',
|
||||
Component: window.Signal.Components.StagedLinkPreview,
|
||||
elCallback: el => this.$('.send').prepend(el),
|
||||
props,
|
||||
onInitialRender: () => {
|
||||
this.view.restoreBottomOffset();
|
||||
this.updateMessageFieldSize({});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
getLinkPreview() {
|
||||
// Don't generate link previews if user has turned them off
|
||||
if (!storage.get('linkPreviews', false)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.preview) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.preview.map(item => {
|
||||
if (item.image) {
|
||||
// We eliminate the ObjectURL here, unneeded for send or save
|
||||
return {
|
||||
...item,
|
||||
image: _.omit(item.image, 'url'),
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
},
|
||||
|
||||
// Called whenever the user changes the message composition field. But only
|
||||
// fires if there's content in the message field after the change.
|
||||
maybeBumpTyping() {
|
||||
|
|
|
@ -148,8 +148,10 @@
|
|||
clearDataExplanation: i18n('clearDataExplanation'),
|
||||
permissions: i18n('permissions'),
|
||||
mediaPermissionsDescription: i18n('mediaPermissionsDescription'),
|
||||
spellCheckHeader: i18n('spellCheck'),
|
||||
generalHeader: i18n('general'),
|
||||
spellCheckDescription: i18n('spellCheckDescription'),
|
||||
sendLinkPreviews: i18n('sendLinkPreviews'),
|
||||
linkPreviewsDescription: i18n('linkPreviewsDescription'),
|
||||
};
|
||||
},
|
||||
onClose() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue