diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b4689a941..f8a3a0465 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -551,8 +551,35 @@ "description": "Shown in toast when user attempts to send .exe file, for example" }, + "loadingPreview": { + "message": "Loading Preview...", + "description": + "Shown while Signal Desktop is fetching metadata for a url in composition area" + }, + "stagedPreviewThumbnail": { + "message": "Draft thumbnail link preview for $domain$", + "description": + "Shown while Signal Desktop is fetching metadata for a url in composition area", + "placeholders": { + "path": { + "content": "$1", + "example": "instagram.com" + } + } + }, + "previewThumbnail": { + "message": "Thumbnail link preview for $domain$", + "description": + "Shown while Signal Desktop is fetching metadata for a url in composition area", + "placeholders": { + "path": { + "content": "$1", + "example": "instagram.com" + } + } + }, "stagedImageAttachment": { - "message": "Staged image attachment: $path$", + "message": "Draft image attachment: $path$", "description": "Alt text for staged attachments", "placeholders": { "path": { @@ -1045,9 +1072,20 @@ "message": "Allow access to camera and microphone", "description": "Description of the media permission description" }, - "spellCheck": { - "message": "Spell Check", - "description": "Description of the media permission description" + "general": { + "message": "General", + "description": "Header for general options on the settings screen" + }, + "sendLinkPreviews": { + "message": "Send Link Previews", + "description": + "Option to control creation and send of link previews in setting screen" + }, + "linkPreviewsDescription": { + "message": + "Previews are supported for Imgur, Instagram, Reddit, and YouTube links.", + "description": + "Additional detail provided for Link Previews option in settings screen" }, "spellCheckDescription": { "message": "Enable spell check of text entered in message composition box", diff --git a/app/sql.js b/app/sql.js index d5d3e0267..5fa40c595 100644 --- a/app/sql.js +++ b/app/sql.js @@ -1569,7 +1569,7 @@ async function getMessagesWithFileAttachments(conversationId, { limit }) { } function getExternalFilesForMessage(message) { - const { attachments, contact, quote } = message; + const { attachments, contact, quote, preview } = message; const files = []; forEach(attachments, attachment => { @@ -1607,6 +1607,16 @@ function getExternalFilesForMessage(message) { }); } + if (preview && preview.length) { + forEach(preview, item => { + const { image } = item; + + if (image && image.path) { + files.push(image.path); + } + }); + } + return files; } diff --git a/config/default.json b/config/default.json index 2f379568b..fde1f3425 100644 --- a/config/default.json +++ b/config/default.json @@ -1,6 +1,7 @@ { "serverUrl": "https://textsecure-service-staging.whispersystems.org", "cdnUrl": "https://cdn-staging.signal.org", + "contentProxyUrl": "contentproxy.signal.org", "disableAutoUpdate": false, "openDevTools": false, "buildExpiration": 0, diff --git a/js/background.js b/js/background.js index cadf621db..f3a5c680b 100644 --- a/js/background.js +++ b/js/background.js @@ -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; diff --git a/js/models/conversations.js b/js/models/conversations.js index c46901aad..fbde4bf33 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -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); diff --git a/js/models/messages.js b/js/models/messages.js index 2ada6ca27..049c57486 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -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') { diff --git a/js/modules/backup.js b/js/modules/backup.js index 31b0fd6d3..b486a6e10 100644 --- a/js/modules/backup.js +++ b/js/modules/backup.js @@ -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 diff --git a/js/modules/link_previews.js b/js/modules/link_previews.js new file mode 100644 index 000000000..990b88f91 --- /dev/null +++ b/js/modules/link_previews.js @@ -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 = //im; +const META_IMAGE = //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); +} diff --git a/js/modules/signal.js b/js/modules/signal.js index cbbee785d..4445d8de7 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -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, }; }; diff --git a/js/modules/types/message.js b/js/modules/types/message.js index cc08e4d03..4d558572a 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -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); diff --git a/js/modules/web_api.js b/js/modules/web_api.js index a77cbe7b3..9762f9c15 100644 --- a/js/modules/web_api.js +++ b/js/modules/web_api.js @@ -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 diff --git a/js/settings_start.js b/js/settings_start.js index c81438577..eeb85a66a 100644 --- a/js/settings_start.js +++ b/js/settings_start.js @@ -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(); + } +); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 8aec0734a..e841cb914 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -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() { diff --git a/js/views/settings_view.js b/js/views/settings_view.js index 6d2439071..58818531e 100644 --- a/js/views/settings_view.js +++ b/js/views/settings_view.js @@ -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() { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index c84c817a1..c406437fb 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1371,6 +1371,14 @@ MessageReceiver.prototype.extend({ promises.push(this.handleAttachment(attachment)); } + const previewCount = (decrypted.preview || []).length; + for (let i = 0; i < previewCount; i += 1) { + const preview = decrypted.preview[i]; + if (preview.image) { + promises.push(this.handleAttachment(preview.image)); + } + } + if (decrypted.contact && decrypted.contact.length) { const contacts = decrypted.contact; diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 1bd2918b1..e42fe21e1 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -18,6 +18,7 @@ function Message(options) { this.body = options.body; this.attachments = options.attachments || []; this.quote = options.quote; + this.preview = options.preview; this.group = options.group; this.flags = options.flags; this.recipients = options.recipients; @@ -102,6 +103,15 @@ Message.prototype = { proto.group.id = stringToArrayBuffer(this.group.id); proto.group.type = this.group.type; } + if (Array.isArray(this.preview)) { + proto.preview = this.preview.map(preview => { + const item = new textsecure.protobuf.DataMessage.Preview(); + item.title = preview.title; + item.url = preview.url; + item.image = preview.image; + return item; + }); + } if (this.quote) { const { QuotedAttachment } = textsecure.protobuf.DataMessage.Quote; const { Quote } = textsecure.protobuf.DataMessage; @@ -238,6 +248,25 @@ MessageSender.prototype = { }); }, + async uploadLinkPreviews(message) { + try { + const preview = await Promise.all( + (message.preview || []).map(async item => ({ + ...item, + image: await this.makeAttachmentPointer(item.image), + })) + ); + // eslint-disable-next-line no-param-reassign + message.preview = preview; + } catch (error) { + if (error instanceof Error && error.name === 'HTTPError') { + throw new textsecure.MessageError(message, error); + } else { + throw error; + } + } + }, + uploadThumbnails(message) { const makePointer = this.makeAttachmentPointer.bind(this); const { quote } = message; @@ -274,6 +303,7 @@ MessageSender.prototype = { return Promise.all([ this.uploadAttachments(message), this.uploadThumbnails(message), + this.uploadLinkPreviews(message), ]).then( () => new Promise((resolve, reject) => { @@ -734,6 +764,7 @@ MessageSender.prototype = { messageText, attachments, quote, + preview, timestamp, expireTimer, profileKey, @@ -746,6 +777,7 @@ MessageSender.prototype = { timestamp, attachments, quote, + preview, needsSync: true, expireTimer, profileKey, @@ -822,6 +854,7 @@ MessageSender.prototype = { messageText, attachments, quote, + preview, timestamp, expireTimer, profileKey, @@ -845,6 +878,7 @@ MessageSender.prototype = { timestamp, attachments, quote, + preview, needsSync: true, expireTimer, profileKey, @@ -1023,6 +1057,12 @@ MessageSender.prototype = { options ); }, + makeProxiedRequest(url, options) { + return this.server.makeProxiedRequest(url, options); + }, + getProxiedSize(url) { + return this.server.getProxiedSize(url); + }, }; window.textsecure = window.textsecure || {}; @@ -1068,6 +1108,8 @@ textsecure.MessageSender = function MessageSenderWrapper( this.syncVerification = sender.syncVerification.bind(sender); this.sendDeliveryReceipt = sender.sendDeliveryReceipt.bind(sender); this.sendReadReceipts = sender.sendReadReceipts.bind(sender); + this.makeProxiedRequest = sender.makeProxiedRequest.bind(sender); + this.getProxiedSize = sender.getProxiedSize.bind(sender); }; textsecure.MessageSender.prototype = { diff --git a/main.js b/main.js index c1e2686c1..f8e16c4c4 100644 --- a/main.js +++ b/main.js @@ -148,6 +148,7 @@ function prepareURL(pathSegments, moreKeys) { hostname: os.hostname(), appInstance: process.env.NODE_APP_INSTANCE, proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy, + contentProxyUrl: config.contentProxyUrl, importMode: importMode ? true : undefined, // for stringify() serverTrustRoot: config.get('serverTrustRoot'), ...moreKeys, diff --git a/package.json b/package.json index 41a00203a..7caa9c726 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "glob": "7.1.2", "google-libphonenumber": "3.0.7", "got": "8.2.0", + "he": "1.2.0", "intl-tel-input": "12.1.15", "jquery": "3.3.1", "linkify-it": "2.0.3", diff --git a/preload.js b/preload.js index ece3f61e0..bc13776b4 100644 --- a/preload.js +++ b/preload.js @@ -28,6 +28,7 @@ window.getExpiration = () => config.buildExpiration; window.getNodeVersion = () => config.node_version; window.getHostName = () => config.hostname; window.getServerTrustRoot = () => config.serverTrustRoot; +window.isBehindProxy = () => Boolean(config.proxyUrl); window.isBeforeVersion = (toCheck, baseVersion) => { try { @@ -173,16 +174,20 @@ ipc.on('get-ready-for-shutdown', async () => { function installGetter(name, functionName) { ipc.on(`get-${name}`, async () => { const getFn = window.Events[functionName]; - if (getFn) { - // eslint-disable-next-line no-param-reassign - try { - ipc.send(`get-success-${name}`, null, await getFn()); - } catch (error) { - ipc.send( - `get-success-${name}`, - error && error.stack ? error.stack : error - ); - } + if (!getFn) { + ipc.send( + `get-success-${name}`, + `installGetter: ${functionName} not found for event ${name}` + ); + return; + } + try { + ipc.send(`get-success-${name}`, null, await getFn()); + } catch (error) { + ipc.send( + `get-success-${name}`, + error && error.stack ? error.stack : error + ); } }); } @@ -190,13 +195,21 @@ function installGetter(name, functionName) { function installSetter(name, functionName) { ipc.on(`set-${name}`, async (_event, value) => { const setFn = window.Events[functionName]; - if (setFn) { - try { - await setFn(value); - ipc.send(`set-success-${name}`); - } catch (error) { - ipc.send(`set-success-${name}`, error); - } + if (!setFn) { + ipc.send( + `set-success-${name}`, + `installSetter: ${functionName} not found for event ${name}` + ); + return; + } + try { + await setFn(value); + ipc.send(`set-success-${name}`); + } catch (error) { + ipc.send( + `set-success-${name}`, + error && error.stack ? error.stack : error + ); } }); } @@ -220,6 +233,7 @@ window.WebAPI = initializeWebAPI({ url: config.serverUrl, cdnUrl: config.cdnUrl, certificateAuthority: config.certificateAuthority, + contentProxyUrl: config.contentProxyUrl, proxyUrl: config.proxyUrl, }); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index ad7d4ddca..55c4a2a7d 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -156,6 +156,12 @@ message DataMessage { optional string organization = 7; } + message Preview { + optional string url = 1; + optional string title = 2; + optional AttachmentPointer image = 3; + } + optional string body = 1; repeated AttachmentPointer attachments = 2; optional GroupContext group = 3; @@ -165,6 +171,7 @@ message DataMessage { optional uint64 timestamp = 7; optional Quote quote = 8; repeated Contact contact = 9; + repeated Preview preview = 10; } message NullMessage { @@ -254,6 +261,7 @@ message SyncMessage { optional bool readReceipts = 1; optional bool unidentifiedDeliveryIndicators = 2; optional bool typingIndicators = 3; + optional bool linkPreviews = 4; } optional Sent sent = 1; diff --git a/settings.html b/settings.html index ebc33d209..f74ccb555 100644 --- a/settings.html +++ b/settings.html @@ -87,11 +87,11 @@ {{ /isAudioNotificationSupported }}
-
-

{{ spellCheckHeader }}

- - -
+

{{ generalHeader }}

+
+ + +

{{ permissions }}

diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 128560f57..1ff50bd01 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -190,6 +190,13 @@ margin-bottom: -5px; } +.bottom-bar .preview-wrapper { + margin-top: 3px; + margin-left: 37px; + margin-right: 73px; + margin-bottom: 2px; +} + .bottom-bar { box-sizing: content-box; $button-width: 36px; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index e40fc578a..54352b98b 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -200,8 +200,6 @@ background-color: $color-conversation-blue_grey; } -// START - .module-message__attachment-container { // Entirely to ensure that images are centered if they aren't full width of bubble text-align: center; @@ -357,6 +355,77 @@ color: $color-white; } +.module-message__generic-attachment__file-size--incoming { + color: $color-white; +} + +.module-message__link-preview { + cursor: pointer; + margin-left: -12px; + margin-right: -12px; + margin-top: -10px; + margin-bottom: 5px; + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} + +.module-message__link-preview--with-content-above { + margin-top: 4px; + border-top-left-radius: 0px; + border-top-right-radius: 0px; +} + +.module-message__link-preview__content { + padding: 8px; + border-top-left-radius: 16px; + border-top-right-radius: 16px; + background-color: $color-white; + display: flex; + flex-direction: row; + align-items: flex-start; + border: 1px solid $color-black-015; +} + +.module-message__link-preview__content--with-content-above { + border-top: none; + border-bottom: none; + border-top-left-radius: 0px; + border-top-right-radius: 0px; +} + +.module-message__link-preview__icon_container { + margin: -2px; + margin-right: 8px; + display: inline-block; +} + +.module-message__link-preview__text--with-icon { + margin-top: 5px; +} + +.module-message__link-preview__title { + color: $color-gray-90; + font-size: 16px; + font-weight: 300; + letter-spacing: 0.15px; + line-height: 22px; + + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.module-message__link-preview__location { + margin-top: 4px; + color: $color-gray-60; + font-size: 12px; + height: 16px; + letter-spacing: 0.4px; + line-height: 16px; + text-transform: uppercase; +} + .module-message__author { color: $color-white; font-size: 13px; @@ -2064,6 +2133,9 @@ .module-image--curved-bottom-right { border-bottom-right-radius: 16px; } +.module-image--small-curved-top-left { + border-top-left-radius: 10px; +} .module-image__border-overlay { position: absolute; @@ -2544,6 +2616,65 @@ @include color-svg('../images/plus-36.svg', $color-gray-45); } +// Module: Staged Link Preview + +.module-staged-link-preview { + position: relative; + display: flex; + flex-direction: row; + align-items: flex-start; + + min-height: 65px; +} + +.module-staged-link-preview--is-loading { + align-items: center; +} +.module-staged-link-preview__loading { + color: $color-gray-60; + font-size: 14px; + text-align: center; + flex-grow: 1; + flex-shrink: 1; +} + +.module-staged-link-preview__icon-container { + margin-right: 8px; +} +.module-staged-link-preview__content { + margin-right: 20px; +} +.module-staged-link-preview__title { + color: $color-gray-90; + font-weight: 300; + font-size: 14px; + line-height: 18px; + + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.module-staged-link-preview__location { + margin-top: 4px; + color: $color-gray-60; + font-size: 11px; + height: 16px; + letter-spacing: 0.25px; + text-transform: uppercase; +} +.module-staged-link-preview__close-button { + cursor: pointer; + position: absolute; + top: 0px; + right: 0px; + + height: 16px; + width: 16px; + + @include color-svg('../images/x-16.svg', $color-gray-60); +} + // Third-party module: react-contextmenu .react-contextmenu { @@ -2632,7 +2763,7 @@ } // To limit messages with things forcing them wider, like long attachment names -.module-message { +.module-message__container { max-width: 300px; } @@ -2641,6 +2772,9 @@ .module-message { max-width: 374px; } + .module-message__container { + max-width: 100%; + } // Spec: container < 438px .module-message--incoming { @@ -2674,6 +2808,9 @@ .module-message { max-width: 66%; } + .module-message__container { + max-width: 100%; + } .module-message--incoming { margin-left: 0; diff --git a/stylesheets/_settings.scss b/stylesheets/_settings.scss index 46df495bb..3cf8d6191 100644 --- a/stylesheets/_settings.scss +++ b/stylesheets/_settings.scss @@ -56,4 +56,12 @@ color: white; } } + + .send-link-previews-setting { + margin-top: 0.75em; + } + .description { + margin-top: 0.3em; + margin-left: 1.5em; + } } diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index 2fe02da7d..697852b07 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -680,6 +680,24 @@ body.dark-theme { color: $color-white; } + .module-message__link-preview__content { + background-color: $color-gray-95; + border: 1px solid $color-gray-60; + } + + .module-message__link-preview__content--with-content-above { + border-top: none; + border-bottom: none; + } + + .module-message__link-preview__title { + color: $color-gray-05; + } + + .module-message__link-preview__location { + color: $color-gray-25; + } + .module-message__author { color: $color-white; } @@ -1308,6 +1326,10 @@ body.dark-theme { // Module: Image + .module-image__border-overlay { + box-shadow: inset 0px 0px 0px 1px $color-white-015; + } + // Module: Image Grid // Module: Typing Animation @@ -1364,6 +1386,21 @@ body.dark-theme { @include color-svg('../images/plus-36.svg', $color-gray-60); } + // Module: Staged Link Preview + + .module-staged-link-preview__loading { + color: $color-gray-25; + } + .module-staged-link-preview__title { + color: $color-gray-05; + } + .module-staged-link-preview__location { + color: $color-gray-25; + } + .module-staged-link-preview__close-button { + @include color-svg('../images/x-16.svg', $color-gray-25); + } + // Third-party module: react-contextmenu .react-contextmenu { diff --git a/test/backup_test.js b/test/backup_test.js index d8dbc001d..7d97e835f 100644 --- a/test/backup_test.js +++ b/test/backup_test.js @@ -372,6 +372,15 @@ describe('Backup', () => { return attachment; }) ), + preview: await Promise.all( + (message.preview || []).map(async item => { + if (item.image) { + await wrappedLoadAttachment(item.image); + } + + return item; + }) + ), }); } @@ -380,8 +389,9 @@ describe('Backup', () => { // Seven total: // - Five from image/video attachments // - One from embedded contact avatar - // - Another from embedded quoted attachment thumbnail - const ATTACHMENT_COUNT = 7; + // - One from embedded quoted attachment thumbnail + // - One from a link preview image + const ATTACHMENT_COUNT = 8; const MESSAGE_COUNT = 1; const CONVERSATION_COUNT = 1; @@ -447,6 +457,17 @@ describe('Backup', () => { }, }, ], + preview: [ + { + url: 'https://www.instagram.com/p/BsOGulcndj-/', + title: + 'EGG GANG 🌍 on Instagram: “Let’s set a world record together and get the most liked post on Instagram. Beating the current world record held by Kylie Jenner (18…”', + image: { + contentType: 'image/jpeg', + data: FIXTURES.jpg, + }, + }, + ], }; console.log('Backup test: Clear all data'); diff --git a/test/modules/link_previews_test.js b/test/modules/link_previews_test.js new file mode 100644 index 000000000..327f98b6d --- /dev/null +++ b/test/modules/link_previews_test.js @@ -0,0 +1,231 @@ +const { assert } = require('chai'); + +const { + getTitleMetaTag, + getImageMetaTag, + isLinkInWhitelist, + isMediaLinkInWhitelist, +} = require('../../js/modules/link_previews'); + +describe('Link previews', () => { + describe('#isLinkInWhitelist', () => { + it('returns true for valid links', () => { + assert.strictEqual(isLinkInWhitelist('https://youtube.com/blah'), true); + assert.strictEqual( + isLinkInWhitelist('https://www.youtube.com/blah'), + true + ); + assert.strictEqual(isLinkInWhitelist('https://m.youtube.com/blah'), true); + assert.strictEqual(isLinkInWhitelist('https://youtu.be/blah'), true); + assert.strictEqual(isLinkInWhitelist('https://reddit.com/blah'), true); + assert.strictEqual( + isLinkInWhitelist('https://www.reddit.com/blah'), + true + ); + assert.strictEqual(isLinkInWhitelist('https://m.reddit.com/blah'), true); + assert.strictEqual(isLinkInWhitelist('https://imgur.com/blah'), true); + assert.strictEqual(isLinkInWhitelist('https://www.imgur.com/blah'), true); + assert.strictEqual(isLinkInWhitelist('https://m.imgur.com/blah'), true); + assert.strictEqual(isLinkInWhitelist('https://instagram.com/blah'), true); + assert.strictEqual( + isLinkInWhitelist('https://www.instagram.com/blah'), + true + ); + assert.strictEqual( + isLinkInWhitelist('https://m.instagram.com/blah'), + true + ); + }); + + it('returns false for subdomains', () => { + assert.strictEqual( + isLinkInWhitelist('https://any.subdomain.youtube.com/blah'), + false + ); + assert.strictEqual( + isLinkInWhitelist('https://any.subdomain.instagram.com/blah'), + false + ); + }); + + it('returns false for http links', () => { + assert.strictEqual(isLinkInWhitelist('http://instagram.com/blah'), false); + assert.strictEqual(isLinkInWhitelist('http://youtube.com/blah'), false); + }); + + it('returns false for links with no protocol', () => { + assert.strictEqual(isLinkInWhitelist('instagram.com/blah'), false); + assert.strictEqual(isLinkInWhitelist('youtube.com/blah'), false); + }); + + it('returns false for link to root path', () => { + assert.strictEqual(isLinkInWhitelist('https://instagram.com'), false); + assert.strictEqual(isLinkInWhitelist('https://youtube.com'), false); + + assert.strictEqual(isLinkInWhitelist('https://instagram.com/'), false); + assert.strictEqual(isLinkInWhitelist('https://youtube.com/'), false); + }); + + it('returns false for other well-known sites', () => { + assert.strictEqual(isLinkInWhitelist('https://facebook.com/blah'), false); + assert.strictEqual(isLinkInWhitelist('https://twitter.com/blah'), false); + }); + + it('returns false for links that look like our target links', () => { + assert.strictEqual( + isLinkInWhitelist('https://evil.site.com/.instagram.com/blah'), + false + ); + assert.strictEqual( + isLinkInWhitelist('https://evil.site.com/.instagram.com/blah'), + false + ); + assert.strictEqual( + isLinkInWhitelist('https://sinstagram.com/blah'), + false + ); + }); + }); + + describe('#isMediaLinkInWhitelist', () => { + it('returns true for valid links', () => { + assert.strictEqual( + isMediaLinkInWhitelist( + 'https://i.ytimg.com/vi/bZHShcCEH3I/hqdefault.jpg' + ), + true + ); + assert.strictEqual( + isMediaLinkInWhitelist('https://random.cdninstagram.com/blah'), + true + ); + assert.strictEqual( + isMediaLinkInWhitelist('https://preview.redd.it/something'), + true + ); + assert.strictEqual( + isMediaLinkInWhitelist('https://i.imgur.com/something'), + true + ); + }); + + it('returns false for insecure protocol', () => { + assert.strictEqual( + isMediaLinkInWhitelist( + 'http://i.ytimg.com/vi/bZHShcCEH3I/hqdefault.jpg' + ), + false + ); + assert.strictEqual( + isMediaLinkInWhitelist('http://random.cdninstagram.com/blah'), + false + ); + assert.strictEqual( + isMediaLinkInWhitelist('http://preview.redd.it/something'), + false + ); + assert.strictEqual( + isMediaLinkInWhitelist('http://i.imgur.com/something'), + false + ); + }); + + it('returns false for other domains', () => { + assert.strictEqual( + isMediaLinkInWhitelist('https://www.youtube.com/something'), + false + ); + assert.strictEqual( + isMediaLinkInWhitelist('https://youtu.be/something'), + false + ); + assert.strictEqual( + isMediaLinkInWhitelist('https://www.instagram.com/something'), + false + ); + assert.strictEqual( + isMediaLinkInWhitelist('https://cnn.com/something'), + false + ); + }); + }); + + describe('#_getMetaTag', () => { + it('returns html-decoded tag contents from Youtube', () => { + const youtube = ` + + + + + + `; + + assert.strictEqual( + 'Randomness is Random - Numberphile', + getTitleMetaTag(youtube) + ); + assert.strictEqual( + 'https://i.ytimg.com/vi/tP-Ipsat90c/maxresdefault.jpg', + getImageMetaTag(youtube) + ); + }); + + it('returns html-decoded tag contents from Instagram', () => { + const instagram = ` + + + + + + + `; + + assert.strictEqual( + 'Walter "MFPallytime" on Instagram: “Lol gg”', + getTitleMetaTag(instagram) + ); + assert.strictEqual( + 'https://scontent-lax3-1.cdninstagram.com/vp/1c69aa381c2201720c29a6c28de42ffd/5CD49B5B/t51.2885-15/e35/47690175_2275988962411653_1145978227188801192_n.jpg?_nc_ht=scontent-lax3-1.cdninstagram.com', + getImageMetaTag(instagram) + ); + }); + + it('returns html-decoded tag contents from Instagram', () => { + const imgur = ` + + + + + + + + + `; + + assert.strictEqual('', getTitleMetaTag(imgur)); + assert.strictEqual( + 'https://i.imgur.com/Y3wjlwY.jpg?fb', + getImageMetaTag(imgur) + ); + }); + + it('returns only the first tag', () => { + const html = ` + + `; + + assert.strictEqual('First Second Third', getTitleMetaTag(html)); + }); + + it('handles a newline in attribute value', () => { + const html = ` + + `; + + assert.strictEqual( + 'First thing\r\nSecond thing\nThird thing', + getTitleMetaTag(html) + ); + }); + }); +}); diff --git a/test/modules/types/message_test.js b/test/modules/types/message_test.js index 40f258570..09333f377 100644 --- a/test/modules/types/message_test.js +++ b/test/modules/types/message_test.js @@ -72,6 +72,7 @@ describe('Message', () => { }, ], contact: [], + preview: [], }; const writeExistingAttachmentData = attachment => { @@ -119,6 +120,7 @@ describe('Message', () => { ], }, contact: [], + preview: [], }; const writeExistingAttachmentData = attachment => { @@ -169,6 +171,7 @@ describe('Message', () => { }, }, ], + preview: [], }; const writeExistingAttachmentData = attachment => { diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index f07478963..9c8079745 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -20,6 +20,9 @@ interface Props { curveBottomRight?: boolean; curveTopLeft?: boolean; curveTopRight?: boolean; + + smallCurveTopLeft?: boolean; + darkOverlay?: boolean; playIconOverlay?: boolean; softCorners?: boolean; @@ -50,6 +53,7 @@ export class Image extends React.Component { onError, overlayText, playIconOverlay, + smallCurveTopLeft, softCorners, url, width, @@ -72,6 +76,7 @@ export class Image extends React.Component { curveBottomRight ? 'module-image--curved-bottom-right' : null, curveTopLeft ? 'module-image--curved-top-left' : null, curveTopRight ? 'module-image--curved-top-right' : null, + smallCurveTopLeft ? 'module-image--small-curved-top-left' : null, softCorners ? 'module-image--soft-corners' : null )} > @@ -97,6 +102,7 @@ export class Image extends React.Component { curveTopRight ? 'module-image--curved-top-right' : null, curveBottomLeft ? 'module-image--curved-bottom-left' : null, curveBottomRight ? 'module-image--curved-bottom-right' : null, + smallCurveTopLeft ? 'module-image--small-curved-top-left' : null, softCorners ? 'module-image--soft-corners' : null, darkOverlay ? 'module-image__border-overlay--dark' : null )} diff --git a/ts/components/conversation/ImageGrid.tsx b/ts/components/conversation/ImageGrid.tsx index c63a84efc..f42424dea 100644 --- a/ts/components/conversation/ImageGrid.tsx +++ b/ts/components/conversation/ImageGrid.tsx @@ -11,8 +11,8 @@ import { Localizer } from '../../types/Util'; interface Props { attachments: Array; - withContentAbove: boolean; - withContentBelow: boolean; + withContentAbove?: boolean; + withContentBelow?: boolean; bottomOverlay?: boolean; i18n: Localizer; @@ -370,7 +370,7 @@ type DimensionsType = { width: number; }; -function getImageDimensions(attachment: AttachmentType): DimensionsType { +export function getImageDimensions(attachment: AttachmentType): DimensionsType { const { height, width } = attachment; if (!height || !width) { return { diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md index 1445d2223..79c663f3e 100644 --- a/ts/components/conversation/Message.md +++ b/ts/components/conversation/Message.md @@ -202,12 +202,21 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
  • console.log('onRetrySend')} />
  • @@ -261,6 +270,25 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean onRetrySend={() => console.log('onRetrySend')} />
  • +
  • + console.log('onRetrySend')} + /> +
  • +
  • + +
  • ``` @@ -2533,6 +2579,313 @@ Voice notes are not shown any differently from audio attachments. ``` +#### Link previews, full-size image + +```jsx + +
  • + console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClick'), + }} + text="Pretty sweet link: https://instagram.com/something" + previews={[ + { + title: 'This is a really sweet post', + domain: 'instagram.com', + image: { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + }, + ]} + onClickLinkPreview={url => console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClick'), + }} + text="Pretty sweet link: https://instagram.com/something" + previews={[ + { + title: 'This is a really sweet post', + domain: 'instagram.com', + image: { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 800, + height: 1200, + }, + }, + ]} + onClickLinkPreview={url => console.log('onClickLinkPreview', url)} + /> +
  • +
    +``` + +#### Link previews, small image + +```jsx + +
  • + console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClick'), + }} + text="Pretty sweet link: https://instagram.com/something" + previews={[ + { + title: + 'This is a really sweet post with a really long name. Gotta restrict that to just two lines, you know how that goes...', + domain: 'instagram.com', + image: { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 160, + height: 120, + }, + }, + ]} + onClickLinkPreview={url => console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClick'), + }} + text="Pretty sweet link: https://instagram.com/something" + previews={[ + { + title: + 'This is a really sweet post with a really long name. Gotta restrict that to just two lines, you know how that goes...', + domain: 'instagram.com', + image: { + url: util.pngObjectUrl, + contentType: 'image/png', + width: 160, + height: 120, + }, + }, + ]} + onClickLinkPreview={url => console.log('onClickLinkPreview', url)} + /> +
  • +
    +``` + +#### Link previews, no image + +```jsx + +
  • + console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClick'), + }} + text="Pretty sweet link: https://instagram.com/something" + previews={[ + { + title: + 'This is a really sweet post with a really long name. Gotta restrict that to just two lines, you know how that goes...', + domain: 'instagram.com', + }, + ]} + onClickLinkPreview={url => console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClick'), + }} + text="Pretty sweet link: https://instagram.com/something" + previews={[ + { + title: + 'This is a really sweet post with a really long name. Gotta restrict that to just two lines, you know how that goes...', + domain: 'instagram.com', + }, + ]} + onClickLinkPreview={url => console.log('onClickLinkPreview', url)} + /> +
  • +
    +``` + ### In a group conversation Note that the author avatar goes away if `collapseMetadata` is set. @@ -2713,6 +3066,48 @@ Note that the author avatar goes away if `collapseMetadata` is set. i18n={util.i18n} /> +
  • + console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClickLinkPreview', url)} + /> +
  • ) => void; } +// Same as MIN_WIDTH in ImageGrid.tsx +const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; + +interface LinkPreviewType { + title: string; + domain: string; + url: string; + image?: AttachmentType; +} + export interface Props { disableMenu?: boolean; text?: string; @@ -61,11 +74,13 @@ export interface Props { onClick?: () => void; referencedMessageNotFound: boolean; }; + previews: Array; authorAvatarPath?: string; isExpired: boolean; expirationLength?: number; expirationTimestamp?: number; onClickAttachment?: (attachment: AttachmentType) => void; + onClickLinkPreview?: (url: string) => void; onReply?: () => void; onRetrySend?: () => void; onDownload?: (isDangerous: boolean) => void; @@ -173,7 +188,6 @@ export class Message extends React.Component { public renderMetadata() { const { - attachments, collapseMetadata, direction, expirationLength, @@ -183,20 +197,13 @@ export class Message extends React.Component { text, timestamp, } = this.props; - const { imageBroken } = this.state; if (collapseMetadata) { return null; } - const canDisplayAttachment = canDisplayImage(attachments); - const withImageNoCaption = Boolean( - !text && - canDisplayAttachment && - !imageBroken && - ((isImage(attachments) && hasImage(attachments)) || - (isVideo(attachments) && hasVideoScreenshot(attachments))) - ); + const isShowingImage = this.isShowingImage(); + const withImageNoCaption = Boolean(!text && isShowingImage); const showError = status === 'error' && direction === 'outgoing'; return ( @@ -409,6 +416,107 @@ export class Message extends React.Component { } } + // tslint:disable-next-line cyclomatic-complexity + public renderPreview() { + const { + attachments, + conversationType, + direction, + i18n, + onClickLinkPreview, + previews, + quote, + } = this.props; + + // Attachments take precedence over Link Previews + if (attachments && attachments.length) { + return null; + } + + if (!previews || previews.length < 1) { + return null; + } + + const first = previews[0]; + if (!first) { + return null; + } + + const withContentAbove = + Boolean(quote) || + (conversationType === 'group' && direction === 'incoming'); + + const previewHasImage = first.image && isImageAttachment(first.image); + const width = first.image && first.image.width; + const isFullSizeImage = width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH; + + return ( +
    { + if (onClickLinkPreview) { + onClickLinkPreview(first.url); + } + }} + > + {first.image && previewHasImage && isFullSizeImage ? ( + + ) : null} +
    + {first.image && previewHasImage && !isFullSizeImage ? ( +
    + {i18n('previewThumbnail', +
    + ) : null} +
    +
    + {first.title} +
    +
    + {first.domain} +
    +
    +
    +
    + ); + } + public renderQuote() { const { conversationType, @@ -734,16 +842,80 @@ export class Message extends React.Component { ); } + public getWidth(): Number | undefined { + const { attachments, previews } = this.props; + + if (attachments && attachments.length) { + const dimensions = getGridDimensions(attachments); + if (dimensions) { + return dimensions.width; + } + } + + if (previews && previews.length) { + const first = previews[0]; + + if (!first || !first.image) { + return; + } + const { width } = first.image; + + if ( + isImageAttachment(first.image) && + width && + width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH + ) { + const dimensions = getImageDimensions(first.image); + if (dimensions) { + return dimensions.width; + } + } + } + + return; + } + + public isShowingImage() { + const { attachments, previews } = this.props; + const { imageBroken } = this.state; + + if (imageBroken) { + return false; + } + + if (attachments && attachments.length) { + const displayImage = canDisplayImage(attachments); + + return ( + displayImage && + ((isImage(attachments) && hasImage(attachments)) || + (isVideo(attachments) && hasVideoScreenshot(attachments))) + ); + } + + if (previews && previews.length) { + const first = previews[0]; + const { image } = first; + + if (!image) { + return false; + } + + return isImageAttachment(image); + } + + return false; + } + public render() { const { - attachments, authorPhoneNumber, authorColor, direction, id, timestamp, } = this.props; - const { expired, expiring, imageBroken } = this.state; + const { expired, expiring } = this.state; // This id is what connects our triple-dot click with our associated pop-up menu. // It needs to be unique. @@ -753,15 +925,8 @@ export class Message extends React.Component { return null; } - const displayImage = canDisplayImage(attachments); - - const showingImage = - displayImage && - !imageBroken && - ((isImage(attachments) && hasImage(attachments)) || - (isVideo(attachments) && hasVideoScreenshot(attachments))); - - const { width } = getGridDimensions(attachments) || { width: undefined }; + const width = this.getWidth(); + const isShowingImage = this.isShowingImage(); return (
    { `module-message--${direction}`, expiring ? 'module-message--expired' : null )} - style={{ - width: showingImage ? width : undefined, - }} > {this.renderError(direction === 'incoming')} {this.renderMenu(direction === 'outgoing', triggerId)} @@ -784,10 +946,14 @@ export class Message extends React.Component { ? `module-message__container--incoming-${authorColor}` : null )} + style={{ + width: isShowingImage ? width : undefined, + }} > {this.renderAuthor()} {this.renderQuote()} {this.renderAttachment()} + {this.renderPreview()} {this.renderEmbeddedContact()} {this.renderText()} {this.renderMetadata()} diff --git a/ts/components/conversation/StagedLinkPreview.md b/ts/components/conversation/StagedLinkPreview.md new file mode 100644 index 000000000..e14c77402 --- /dev/null +++ b/ts/components/conversation/StagedLinkPreview.md @@ -0,0 +1,92 @@ +#### Still loading + +```jsx + + console.log('onClose')} + i18n={util.i18n} + /> + +``` + +#### No image + +```jsx + + console.log('onClose')} + i18n={util.i18n} + /> + +``` + +#### Image + +```jsx + + console.log('onClose')} + i18n={util.i18n} + /> + +``` + +#### Image, no title + +```jsx + + console.log('onClose')} + i18n={util.i18n} + /> + +``` + +#### No image, long title + +```jsx + + console.log('onClose')} + i18n={util.i18n} + /> + +``` + +#### Image, long title + +```jsx + + console.log('onClose')} + i18n={util.i18n} + /> + +``` diff --git a/ts/components/conversation/StagedLinkPreview.tsx b/ts/components/conversation/StagedLinkPreview.tsx new file mode 100644 index 000000000..10faf25cf --- /dev/null +++ b/ts/components/conversation/StagedLinkPreview.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { isImageAttachment } from './ImageGrid'; +import { Image } from './Image'; +import { AttachmentType } from './types'; + +import { Localizer } from '../../types/Util'; + +interface Props { + isLoaded: boolean; + title: string; + domain: string; + image?: AttachmentType; + + i18n: Localizer; + onClose?: () => void; +} + +export class StagedLinkPreview extends React.Component { + public render() { + const { isLoaded, onClose, i18n, title, image, domain } = this.props; + + const isImage = image && isImageAttachment(image); + + return ( +
    + {!isLoaded ? ( +
    + {i18n('loadingPreview')} +
    + ) : null} + {isLoaded && image && isImage ? ( +
    + {i18n('stagedPreviewThumbnail', +
    + ) : null} + {isLoaded ? ( +
    +
    {title}
    +
    {domain}
    +
    + ) : null} +
    +
    + ); + } +} diff --git a/ts/types/MIME.ts b/ts/types/MIME.ts index cc4c61b3a..a083e19cd 100644 --- a/ts/types/MIME.ts +++ b/ts/types/MIME.ts @@ -10,6 +10,9 @@ export const VIDEO_MP4 = 'video/mp4' as MIMEType; export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType; export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg'; -export const isImage = (value: MIMEType): boolean => value.startsWith('image/'); -export const isVideo = (value: MIMEType): boolean => value.startsWith('video/'); -export const isAudio = (value: MIMEType): boolean => value.startsWith('audio/'); +export const isImage = (value: MIMEType): boolean => + value && value.startsWith('image/'); +export const isVideo = (value: MIMEType): boolean => + value && value.startsWith('video/'); +export const isAudio = (value: MIMEType): boolean => + value && value.startsWith('audio/'); diff --git a/yarn.lock b/yarn.lock index 54cce7a02..c7af81636 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3980,6 +3980,11 @@ he@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + highlight.js@^9.12.0: version "9.12.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e"