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