From 8f8e25bb3e789f905cf082ad45dba950c80b0435 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Fri, 8 Feb 2019 11:51:34 +1100 Subject: [PATCH] Added local link preview --- background.html | 1 + js/link_previews_helper.js | 151 +++++++++++++++++++++++++++++++++++++ js/models/messages.js | 70 +++++++++++++++-- 3 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 js/link_previews_helper.js diff --git a/background.html b/background.html index 7f85a30d1..6dfaf898b 100644 --- a/background.html +++ b/background.html @@ -730,6 +730,7 @@ + diff --git a/js/link_previews_helper.js b/js/link_previews_helper.js new file mode 100644 index 000000000..5a7e18268 --- /dev/null +++ b/js/link_previews_helper.js @@ -0,0 +1,151 @@ +/* global + Signal, + textsecure, + dcodeIO, +*/ + +// eslint-disable-next-line func-names +(function() { + 'use strict'; + + window.Signal = window.Signal || {}; + window.Signal.LinkPreviews = window.Signal.LinkPreviews || {}; + + const base64ImageCache = {}; + + function getBase64Image(preview) { + const { url, image } = preview; + if (!url || !image || !image.data) return null; + + // Return the cached value + if (base64ImageCache[url]) return base64ImageCache[url]; + + // Set the cache and return the value + const contentType = image.contentType || 'image/jpeg'; + const base64 = dcodeIO.ByteBuffer.wrap(image.data).toString('base64'); + + const data = `data:${contentType};base64, ${base64}`; + base64ImageCache[url] = data; + + return data; + } + + async function 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 function 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 makeChunkedRequest(imageUrl); + + // Calculate dimensions + const file = new Blob([data.data], { + type: data.contentType, + }); + objectUrl = URL.createObjectURL(file); + + const dimensions = await Signal.Types.VisualAttachment.getImageDimensions( + { + objectUrl, + logger: window.log, + } + ); + + image = { + ...data, + ...dimensions, + contentType: 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, + }; + } + + window.Signal.LinkPreviews.helper = { + getPreview, + getBase64Image, + } +})(); diff --git a/js/models/messages.js b/js/models/messages.js index 796070e00..ee8d7e9d6 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -8,6 +8,7 @@ /* global Signal: false */ /* global textsecure: false */ /* global Whisper: false */ +/* global dcodeIO: false */ /* eslint-disable more/no-then */ @@ -84,6 +85,8 @@ this.on('unload', this.unload); this.on('expired', this.onExpired); this.setToExpire(); + + this.updatePreviews(); }, idForLogging() { return `${this.get('source')}.${this.get('sourceDevice')} ${this.get( @@ -109,6 +112,40 @@ // eslint-disable-next-line no-bitwise return !!(this.get('flags') & flag); }, + async updatePreviews() { + if (this.updatingPreview) return; + + // Only update the preview if we don't have any set + const preview = this.get('preview'); + if (!_.isEmpty(preview)) return; + + // Make sure we have links we can preview + const links = Signal.LinkPreviews.findLinks(this.get('body')); + const firstLink = links.find(link => Signal.LinkPreviews.isLinkInWhitelist(link)); + if (!firstLink) return; + + this.updatingPreview = true; + + try { + const result = await Signal.LinkPreviews.helper.getPreview(firstLink); + if (!result) { + this.updatingPreview = false; + return; + } + + if (!result.image && !result.title) { + // A link preview isn't worth showing unless we have either a title or an image + this.updatingPreview = false; + return; + } + + this.set({ preview: [result] }); + } catch (e) { + window.log.warn(`Failed to load previews for message: ${this.id}`); + } finally { + this.updatingPreview = false; + } + }, getEndSessionTranslationKey() { const sessionType = this.get('endSessionType'); if (sessionType === 'ongoing') { @@ -616,11 +653,34 @@ 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, - })); + return previews.map(preview => { + let image = {}; + + // Try set the image from the attachment otherwise just pass in the object + if (preview.image) { + try { + const attachmentProps = this.getPropsForAttachment(preview.image); + if (attachmentProps.url) { + image = attachmentProps; + } + } catch (e) { + // Only set the image if we have a url to display + const url = Signal.LinkPreviews.helper.getBase64Image(preview); + if (preview.image.url || url) { + image = { + ...preview.image, + url: preview.image.url || url, + } + } + } + } + + return { + ...preview, + domain: window.Signal.LinkPreviews.getDomain(preview.url), + image, + }; + }); }, getPropsForQuote() { const quote = this.get('quote');