Link Previews

This commit is contained in:
Scott Nonnenberg 2019-01-15 19:03:56 -08:00
parent 91ef39e482
commit 813924685e
36 changed files with 2298 additions and 134 deletions

View file

@ -551,8 +551,35 @@
"description": "description":
"Shown in toast when user attempts to send .exe file, for example" "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": { "stagedImageAttachment": {
"message": "Staged image attachment: $path$", "message": "Draft image attachment: $path$",
"description": "Alt text for staged attachments", "description": "Alt text for staged attachments",
"placeholders": { "placeholders": {
"path": { "path": {
@ -1045,9 +1072,20 @@
"message": "Allow access to camera and microphone", "message": "Allow access to camera and microphone",
"description": "Description of the media permission description" "description": "Description of the media permission description"
}, },
"spellCheck": { "general": {
"message": "Spell Check", "message": "General",
"description": "Description of the media permission description" "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": { "spellCheckDescription": {
"message": "Enable spell check of text entered in message composition box", "message": "Enable spell check of text entered in message composition box",

View file

@ -1569,7 +1569,7 @@ async function getMessagesWithFileAttachments(conversationId, { limit }) {
} }
function getExternalFilesForMessage(message) { function getExternalFilesForMessage(message) {
const { attachments, contact, quote } = message; const { attachments, contact, quote, preview } = message;
const files = []; const files = [];
forEach(attachments, attachment => { 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; return files;
} }

View file

@ -1,6 +1,7 @@
{ {
"serverUrl": "https://textsecure-service-staging.whispersystems.org", "serverUrl": "https://textsecure-service-staging.whispersystems.org",
"cdnUrl": "https://cdn-staging.signal.org", "cdnUrl": "https://cdn-staging.signal.org",
"contentProxyUrl": "contentproxy.signal.org",
"disableAutoUpdate": false, "disableAutoUpdate": false,
"openDevTools": false, "openDevTools": false,
"buildExpiration": 0, "buildExpiration": 0,

View file

@ -795,6 +795,7 @@
readReceipts, readReceipts,
typingIndicators, typingIndicators,
unidentifiedDeliveryIndicators, unidentifiedDeliveryIndicators,
linkPreviews,
} = configuration; } = configuration;
storage.put('read-receipt-setting', readReceipts); storage.put('read-receipt-setting', readReceipts);
@ -813,6 +814,10 @@
storage.put('typingIndicators', typingIndicators); storage.put('typingIndicators', typingIndicators);
} }
if (linkPreviews === true || linkPreviews === false) {
storage.put('linkPreviews', linkPreviews);
}
ev.confirm(); ev.confirm();
} }
@ -1107,7 +1112,9 @@
} }
try { try {
if (queryMessage.get('schemaVersion') < Message.CURRENT_SCHEMA_VERSION) { if (
queryMessage.get('schemaVersion') < Message.VERSION_NEEDED_FOR_DISPLAY
) {
const upgradedMessage = await upgradeMessageSchema( const upgradedMessage = await upgradeMessageSchema(
queryMessage.attributes queryMessage.attributes
); );
@ -1126,15 +1133,23 @@
const queryAttachments = queryMessage.get('attachments') || []; const queryAttachments = queryMessage.get('attachments') || [];
if (queryAttachments.length === 0) { if (queryAttachments.length > 0) {
return message; const queryFirst = queryAttachments[0];
const { thumbnail } = queryFirst;
if (thumbnail && thumbnail.path) {
firstAttachment.thumbnail = thumbnail;
}
} }
const queryFirst = queryAttachments[0]; const queryPreview = queryMessage.get('preview') || [];
const { thumbnail } = queryFirst; if (queryPreview.length > 0) {
const queryFirst = queryPreview[0];
const { image } = queryFirst;
if (thumbnail && thumbnail.path) { if (image && image.path) {
firstAttachment.thumbnail = thumbnail; firstAttachment.thumbnail = image;
}
} }
return message; return message;

View file

@ -759,6 +759,7 @@
const { getName } = Contact; const { getName } = Contact;
const contact = quotedMessage.getContact(); const contact = quotedMessage.getContact();
const attachments = quotedMessage.get('attachments'); const attachments = quotedMessage.get('attachments');
const preview = quotedMessage.get('preview');
const body = quotedMessage.get('body'); const body = quotedMessage.get('body');
const embeddedContact = quotedMessage.get('contact'); const embeddedContact = quotedMessage.get('contact');
@ -767,32 +768,45 @@
? getName(embeddedContact[0]) ? getName(embeddedContact[0])
: ''; : '';
const media =
attachments && attachments.length ? attachments : preview || [];
return { return {
author: contact.id, author: contact.id,
id: quotedMessage.get('sent_at'), id: quotedMessage.get('sent_at'),
text: body || embeddedContactName, text: body || embeddedContactName,
attachments: await Promise.all( attachments: await Promise.all(
(attachments || []).map(async attachment => { media
const { contentType, fileName, thumbnail } = attachment; .filter(
attachment =>
(attachment && attachment.thumbnail) || attachment.message
)
.map(async attachment => {
const { fileName } = attachment;
return { const thumbnail = attachment.thumbnail || attachment.image;
contentType, const contentType =
// Our protos library complains about this field being undefined, so we attachment.contentType ||
// force it to null (attachment.image && attachment.image.contentType);
fileName: fileName || null,
thumbnail: thumbnail return {
? { contentType,
...(await loadAttachmentData(thumbnail)), // Our protos library complains about this field being undefined, so we
objectUrl: getAbsoluteAttachmentPath(thumbnail.path), // force it to null
} fileName: fileName || null,
: null, thumbnail: thumbnail
}; ? {
}) ...(await loadAttachmentData(thumbnail)),
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
}
: null,
};
})
), ),
}; };
}, },
sendMessage(body, attachments, quote) { sendMessage(body, attachments, quote, preview) {
this.clearTypingTimers(); this.clearTypingTimers();
const destination = this.id; const destination = this.id;
@ -819,6 +833,7 @@
body, body,
conversationId: destination, conversationId: destination,
quote, quote,
preview,
attachments, attachments,
sent_at: now, sent_at: now,
received_at: now, received_at: now,
@ -885,6 +900,7 @@
body, body,
attachmentsWithData, attachmentsWithData,
quote, quote,
preview,
now, now,
expireTimer, expireTimer,
profileKey, profileKey,
@ -1621,7 +1637,7 @@
const { attributes } = message; const { attributes } = message;
const { schemaVersion } = attributes; 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 // Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const upgradedMessage = await upgradeMessageSchema(attributes); const upgradedMessage = await upgradeMessageSchema(attributes);

View file

@ -24,6 +24,7 @@
getAbsoluteAttachmentPath, getAbsoluteAttachmentPath,
loadAttachmentData, loadAttachmentData,
loadQuoteData, loadQuoteData,
loadPreviewData,
writeNewAttachmentData, writeNewAttachmentData,
} = window.Signal.Migrations; } = window.Signal.Migrations;
@ -425,6 +426,7 @@
attachments: attachments.map(attachment => attachments: attachments.map(attachment =>
this.getPropsForAttachment(attachment) this.getPropsForAttachment(attachment)
), ),
previews: this.getPropsForPreview(),
quote: this.getPropsForQuote(), quote: this.getPropsForQuote(),
authorAvatarPath, authorAvatarPath,
isExpired: this.hasExpired, isExpired: this.hasExpired,
@ -434,6 +436,7 @@
onRetrySend: () => this.retrySend(), onRetrySend: () => this.retrySend(),
onShowDetail: () => this.trigger('show-message-detail', this), onShowDetail: () => this.trigger('show-message-detail', this),
onDelete: () => this.trigger('delete', this), onDelete: () => this.trigger('delete', this),
onClickLinkPreview: url => this.trigger('navigate-to', url),
onClickAttachment: attachment => onClickAttachment: attachment =>
this.trigger('show-lightbox', { this.trigger('show-lightbox', {
attachment, attachment,
@ -526,6 +529,15 @@
thumbnail: thumbnailWithObjectUrl, 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() { getPropsForQuote() {
const quote = this.get('quote'); const quote = this.get('quote');
if (!quote) { if (!quote) {
@ -712,6 +724,7 @@
(this.get('attachments') || []).map(loadAttachmentData) (this.get('attachments') || []).map(loadAttachmentData)
); );
const quoteWithData = await loadQuoteData(this.get('quote')); const quoteWithData = await loadQuoteData(this.get('quote'));
const previewWithData = await loadPreviewData(this.get('preview'));
const conversation = this.getConversation(); const conversation = this.getConversation();
const options = conversation.getSendOptions(); const options = conversation.getSendOptions();
@ -725,6 +738,7 @@
this.get('body'), this.get('body'),
attachmentsWithData, attachmentsWithData,
quoteWithData, quoteWithData,
previewWithData,
this.get('sent_at'), this.get('sent_at'),
this.get('expireTimer'), this.get('expireTimer'),
profileKey, profileKey,
@ -741,6 +755,7 @@
timestamp: this.get('sent_at'), timestamp: this.get('sent_at'),
attachments: attachmentsWithData, attachments: attachmentsWithData,
quote: quoteWithData, quote: quoteWithData,
preview: previewWithData,
needsSync: !this.get('synced'), needsSync: !this.get('synced'),
expireTimer: this.get('expireTimer'), expireTimer: this.get('expireTimer'),
profileKey, profileKey,
@ -775,6 +790,7 @@
(this.get('attachments') || []).map(loadAttachmentData) (this.get('attachments') || []).map(loadAttachmentData)
); );
const quoteWithData = await loadQuoteData(this.get('quote')); const quoteWithData = await loadQuoteData(this.get('quote'));
const previewWithData = await loadPreviewData(this.get('preview'));
const { wrap, sendOptions } = ConversationController.prepareForSend( const { wrap, sendOptions } = ConversationController.prepareForSend(
number number
@ -784,6 +800,7 @@
this.get('body'), this.get('body'),
attachmentsWithData, attachmentsWithData,
quoteWithData, quoteWithData,
previewWithData,
this.get('sent_at'), this.get('sent_at'),
this.get('expireTimer'), this.get('expireTimer'),
profileKey, profileKey,
@ -1146,6 +1163,22 @@
message.set({ group_update: groupUpdate }); 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({ message.set({
attachments: dataMessage.attachments, attachments: dataMessage.attachments,
body: dataMessage.body, body: dataMessage.body,
@ -1158,6 +1191,7 @@
hasFileAttachments: dataMessage.hasFileAttachments, hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
quote: dataMessage.quote, quote: dataMessage.quote,
preview,
schemaVersion: dataMessage.schemaVersion, schemaVersion: dataMessage.schemaVersion,
}); });
if (type === 'outgoing') { if (type === 'outgoing') {

View file

@ -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 = {}) { async function writeEncryptedAttachment(target, source, options = {}) {
const { key, newKey, filename, dir } = options; const { key, newKey, filename, dir } = options;
@ -752,6 +796,18 @@ async function exportConversation(conversation, options = {}) {
newKey, 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; 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) { function saveMessage(message) {
@ -1013,8 +1080,9 @@ async function importConversation(dir, options) {
message.quote.attachments && message.quote.attachments &&
message.quote.attachments.length > 0; message.quote.attachments.length > 0;
const hasContacts = message.contact && message.contact.length; 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 importMessage = async () => {
const getName = attachmentsDir const getName = attachmentsDir
? _getAnonymousAttachmentFileName ? _getAnonymousAttachmentFileName

176
js/modules/link_previews.js Normal file
View file

@ -0,0 +1,176 @@
/* global URL */
const he = require('he');
const LinkifyIt = require('linkify-it');
const linkify = LinkifyIt();
const { concatenateBytes, getViewOfArrayBuffer } = require('./crypto');
module.exports = {
assembleChunks,
findLinks,
getChunkPattern,
getDomain,
getTitleMetaTag,
getImageMetaTag,
isLinkInWhitelist,
isMediaLinkInWhitelist,
};
const SUPPORTED_DOMAINS = [
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'youtu.be',
'reddit.com',
'www.reddit.com',
'm.reddit.com',
'imgur.com',
'www.imgur.com',
'm.imgur.com',
'instagram.com',
'www.instagram.com',
'm.instagram.com',
];
function isLinkInWhitelist(link) {
try {
const url = new URL(link);
if (url.protocol !== 'https:') {
return false;
}
if (!url.pathname || url.pathname.length < 2) {
return false;
}
const lowercase = url.host.toLowerCase();
if (!SUPPORTED_DOMAINS.includes(lowercase)) {
return false;
}
return true;
} catch (error) {
return false;
}
}
const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg.com|cdninstagram.com|redd.it|imgur.com)$/i;
function isMediaLinkInWhitelist(link) {
try {
const url = new URL(link);
if (url.protocol !== 'https:') {
return false;
}
if (!url.pathname || url.pathname.length < 2) {
return false;
}
if (!SUPPORTED_MEDIA_DOMAINS.test(url.host)) {
return false;
}
return true;
} catch (error) {
return false;
}
}
const META_TITLE = /<meta\s+property="og:title"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
const META_IMAGE = /<meta\s+property="og:image"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
function _getMetaTag(html, regularExpression) {
const match = regularExpression.exec(html);
if (match && match[1]) {
return he.decode(match[1]).trim();
}
return null;
}
function getTitleMetaTag(html) {
return _getMetaTag(html, META_TITLE);
}
function getImageMetaTag(html) {
return _getMetaTag(html, META_IMAGE);
}
function findLinks(text) {
const matches = linkify.match(text || '') || [];
return matches.map(match => match.text);
}
function getDomain(url) {
try {
const urlObject = new URL(url);
return urlObject.hostname;
} catch (error) {
return null;
}
}
const MB = 1024 * 1024;
const KB = 1024;
function getChunkPattern(size) {
if (size > MB) {
return _getRequestPattern(size, MB);
} else if (size > 500 * KB) {
return _getRequestPattern(size, 500 * KB);
} else if (size > 100 * KB) {
return _getRequestPattern(size, 100 * KB);
} else if (size > 50 * KB) {
return _getRequestPattern(size, 50 * KB);
} else if (size > 10 * KB) {
return _getRequestPattern(size, 10 * KB);
} else if (size > KB) {
return _getRequestPattern(size, KB);
}
throw new Error(`getChunkPattern: Unsupported size: ${size}`);
}
function _getRequestPattern(size, increment) {
const results = [];
let offset = 0;
while (size - offset > increment) {
results.push({
start: offset,
end: offset + increment - 1,
overlap: 0,
});
offset += increment;
}
if (size - offset > 0) {
results.push({
start: size - increment,
end: size - 1,
overlap: increment - (size - offset),
});
}
return results;
}
function assembleChunks(chunkDescriptors) {
const chunks = chunkDescriptors.map((chunk, index) => {
if (index !== chunkDescriptors.length - 1) {
return chunk.data;
}
if (!chunk.overlap) {
return chunk.data;
}
return getViewOfArrayBuffer(
chunk.data,
chunk.overlap,
chunk.data.byteLength
);
});
return concatenateBytes(...chunks);
}

View file

@ -13,6 +13,7 @@ const Util = require('../../ts/util');
const { migrateToSQL } = require('./migrate_to_sql'); const { migrateToSQL } = require('./migrate_to_sql');
const Metadata = require('./metadata/SecretSessionCipher'); const Metadata = require('./metadata/SecretSessionCipher');
const RefreshSenderCertificate = require('./refresh_sender_certificate'); const RefreshSenderCertificate = require('./refresh_sender_certificate');
const LinkPreviews = require('./link_previews');
// Components // Components
const { const {
@ -55,6 +56,9 @@ const {
const { const {
SafetyNumberNotification, SafetyNumberNotification,
} = require('../../ts/components/conversation/SafetyNumberNotification'); } = require('../../ts/components/conversation/SafetyNumberNotification');
const {
StagedLinkPreview,
} = require('../../ts/components/conversation/StagedLinkPreview');
const { const {
TimerNotification, TimerNotification,
} = require('../../ts/components/conversation/TimerNotification'); } = require('../../ts/components/conversation/TimerNotification');
@ -120,6 +124,7 @@ function initializeMigrations({
const attachmentsPath = getPath(userDataPath); const attachmentsPath = getPath(userDataPath);
const readAttachmentData = createReader(attachmentsPath); const readAttachmentData = createReader(attachmentsPath);
const loadAttachmentData = Type.loadData(readAttachmentData); const loadAttachmentData = Type.loadData(readAttachmentData);
const loadPreviewData = MessageType.loadPreviewData(readAttachmentData);
const loadQuoteData = MessageType.loadQuoteData(readAttachmentData); const loadQuoteData = MessageType.loadQuoteData(readAttachmentData);
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath); const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
const deleteOnDisk = Attachments.createDeleter(attachmentsPath); const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
@ -135,8 +140,9 @@ function initializeMigrations({
getPlaceholderMigrations, getPlaceholderMigrations,
getCurrentVersion, getCurrentVersion,
loadAttachmentData, loadAttachmentData,
loadQuoteData,
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData), loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
loadPreviewData,
loadQuoteData,
readAttachmentData, readAttachmentData,
run, run,
upgradeMessageSchema: (message, options = {}) => { upgradeMessageSchema: (message, options = {}) => {
@ -196,6 +202,7 @@ exports.setup = (options = {}) => {
Quote, Quote,
ResetSessionNotification, ResetSessionNotification,
SafetyNumberNotification, SafetyNumberNotification,
StagedLinkPreview,
TimerNotification, TimerNotification,
Types: { Types: {
Message: MediaGalleryMessage, Message: MediaGalleryMessage,
@ -226,7 +233,6 @@ exports.setup = (options = {}) => {
}; };
return { return {
Metadata,
Backbone, Backbone,
Components, Components,
Crypto, Crypto,
@ -234,6 +240,9 @@ exports.setup = (options = {}) => {
Database, Database,
Emoji, Emoji,
IndexedDB, IndexedDB,
LinkPreviews,
Metadata,
migrateToSQL,
Migrations, Migrations,
Notifications, Notifications,
OS, OS,
@ -243,6 +252,5 @@ exports.setup = (options = {}) => {
Util, Util,
Views, Views,
Workflow, Workflow,
migrateToSQL,
}; };
}; };

View file

@ -47,6 +47,8 @@ const PRIVATE = 'private';
// Version 9 // Version 9
// - Attachments: Expand the set of unicode characters we filter out of // - Attachments: Expand the set of unicode characters we filter out of
// attachment filenames // attachment filenames
// Version 10
// - Preview: A new type of attachment can be included in a message.
const INITIAL_SCHEMA_VERSION = 0; 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) => const toVersion0 = async (message, context) =>
exports.initializeSchemaVersion({ message, logger: context.logger }); exports.initializeSchemaVersion({ message, logger: context.logger });
const toVersion1 = exports._withSchemaVersion({ const toVersion1 = exports._withSchemaVersion({
@ -277,6 +319,10 @@ const toVersion9 = exports._withSchemaVersion({
schemaVersion: 9, schemaVersion: 9,
upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2), upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2),
}); });
const toVersion10 = exports._withSchemaVersion({
schemaVersion: 10,
upgrade: exports._mapPreviewAttachments(Attachment.migrateDataToFileSystem),
});
const VERSIONS = [ const VERSIONS = [
toVersion0, toVersion0,
@ -289,9 +335,13 @@ const VERSIONS = [
toVersion7, toVersion7,
toVersion8, toVersion8,
toVersion9, toVersion9,
toVersion10,
]; ];
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1; exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
// We need dimensions and screenshots for images for proper display
exports.VERSION_NEEDED_FOR_DISPLAY = 9;
// UpgradeStep // UpgradeStep
exports.upgradeSchema = async ( exports.upgradeSchema = async (
rawMessage, 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 }) => { exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
if (!isFunction(deleteAttachmentData)) { if (!isFunction(deleteAttachmentData)) {
throw new TypeError( throw new TypeError(
@ -422,7 +497,7 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
} }
return async message => { return async message => {
const { attachments, quote, contact } = message; const { attachments, quote, contact, preview } = message;
if (attachments && attachments.length) { if (attachments && attachments.length) {
await Promise.all(attachments.map(deleteAttachmentData)); 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, logger,
}); });
const { attachments, quote, contact } = message; const { attachments, quote, contact, preview } = message;
const hasFilesToWrite = const hasFilesToWrite =
(quote && quote.attachments && quote.attachments.length > 0) || (quote && quote.attachments && quote.attachments.length > 0) ||
(attachments && attachments.length > 0) || (attachments && attachments.length > 0) ||
(contact && contact.length > 0); (contact && contact.length > 0) ||
(preview && preview.length > 0);
if (!hasFilesToWrite) { if (!hasFilesToWrite) {
return message; 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( const messageWithoutAttachmentData = Object.assign(
{}, {},
await writeThumbnails(message, { logger }), await writeThumbnails(message, { logger }),
{ {
contact: await Promise.all((contact || []).map(writeContactAvatar)), contact: await Promise.all((contact || []).map(writeContactAvatar)),
preview: await Promise.all((preview || []).map(writePreviewImage)),
attachments: await Promise.all( attachments: await Promise.all(
(attachments || []).map(async attachment => { (attachments || []).map(async attachment => {
await writeExistingAttachmentData(attachment); await writeExistingAttachmentData(attachment);

View file

@ -5,9 +5,7 @@ const { Agent } = require('https');
const is = require('@sindresorhus/is'); const is = require('@sindresorhus/is');
/* global Buffer: false */ /* global Buffer, setTimeout, log, _ */
/* global setTimeout: false */
/* global log: false */
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */ /* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
@ -166,12 +164,28 @@ const agents = {
auth: null, auth: null,
}; };
function getContentType(response) {
if (response.headers && response.headers.get) {
return response.headers.get('content-type');
}
return null;
}
function _promiseAjax(providedUrl, options) { function _promiseAjax(providedUrl, options) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = providedUrl || `${options.host}/${options.path}`; const url = providedUrl || `${options.host}/${options.path}`;
log.info( if (options.disableLogs) {
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}` log.info(
); `${options.type} [REDACTED_URL]${
options.unauthenticated ? ' (unauth)' : ''
}`
);
} else {
log.info(
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
);
}
const timeout = const timeout =
typeof options.timeout !== 'undefined' ? options.timeout : 10000; typeof options.timeout !== 'undefined' ? options.timeout : 10000;
@ -195,7 +209,12 @@ function _promiseAjax(providedUrl, options) {
const fetchOptions = { const fetchOptions = {
method: options.type, method: options.type,
body: options.data || null, 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, agent,
ca: options.certificateAuthority, ca: options.certificateAuthority,
timeout, timeout,
@ -238,13 +257,20 @@ function _promiseAjax(providedUrl, options) {
response.headers.get('Content-Type') === 'application/json' response.headers.get('Content-Type') === 'application/json'
) { ) {
resultPromise = response.json(); resultPromise = response.json();
} else if (options.responseType === 'arraybuffer') { } else if (
options.responseType === 'arraybuffer' ||
options.responseType === 'arraybufferwithdetails'
) {
resultPromise = response.buffer(); resultPromise = response.buffer();
} else { } else {
resultPromise = response.text(); resultPromise = response.text();
} }
return resultPromise.then(result => { return resultPromise.then(result => {
if (options.responseType === 'arraybuffer') { if (
options.responseType === 'arraybuffer' ||
options.responseType === 'arraybufferwithdetails'
) {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
result = result.buffer.slice( result = result.buffer.slice(
result.byteOffset, result.byteOffset,
@ -254,8 +280,17 @@ function _promiseAjax(providedUrl, options) {
if (options.responseType === 'json') { if (options.responseType === 'json') {
if (options.validateResponse) { if (options.validateResponse) {
if (!_validateResponse(result, options.validateResponse)) { if (!_validateResponse(result, options.validateResponse)) {
log.error(options.type, url, response.status, 'Error'); if (options.disableLogs) {
reject( log.info(
options.type,
'[REDACTED_URL]',
response.status,
'Error'
);
} else {
log.error(options.type, url, response.status, 'Error');
}
return reject(
HTTPError( HTTPError(
'promiseAjax: invalid response', 'promiseAjax: invalid response',
response.status, response.status,
@ -267,23 +302,47 @@ function _promiseAjax(providedUrl, options) {
} }
} }
if (response.status >= 0 && response.status < 400) { if (response.status >= 0 && response.status < 400) {
log.info(options.type, url, response.status, 'Success'); if (options.disableLogs) {
resolve(result, response.status); 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 { } else {
log.error(options.type, url, response.status, 'Error'); 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 => { .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}`; const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
reject(HTTPError('promiseAjax catch', 0, e.toString(), 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 // 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)) { if (!is.string(url)) {
throw new Error('WebAPI.initialize: Invalid server url'); throw new Error('WebAPI.initialize: Invalid server url');
} }
@ -352,6 +417,9 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
if (!is.string(certificateAuthority)) { if (!is.string(certificateAuthority)) {
throw new Error('WebAPI.initialize: Invalid 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 // Thanks to function-hoisting, we can put this return statement before all of the
// below function definitions. // below function definitions.
@ -372,8 +440,6 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
getAttachment, getAttachment,
getAvatar, getAvatar,
getDevices, getDevices,
getSenderCertificate,
registerSupportForUnauthenticatedDelivery,
getKeysForNumber, getKeysForNumber,
getKeysForNumberUnauth, getKeysForNumberUnauth,
getMessageSocket, getMessageSocket,
@ -381,15 +447,19 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
getProfile, getProfile,
getProfileUnauth, getProfileUnauth,
getProvisioningSocket, getProvisioningSocket,
getProxiedSize,
getSenderCertificate,
makeProxiedRequest,
putAttachment, putAttachment,
registerKeys, registerKeys,
registerSupportForUnauthenticatedDelivery,
removeSignalingKey,
requestVerificationSMS, requestVerificationSMS,
requestVerificationVoice, requestVerificationVoice,
sendMessages, sendMessages,
sendMessagesUnauth, sendMessagesUnauth,
setSignedPreKey, setSignedPreKey,
updateDeviceName, updateDeviceName,
removeSignalingKey,
}; };
function _ajax(param) { 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() { function getMessageSocket() {
log.info('opening message socket', url); log.info('opening message socket', url);
const fixedScheme = url const fixedScheme = url

View file

@ -32,10 +32,21 @@ const getInitialData = async () => ({
window.initialRequest = getInitialData(); window.initialRequest = getInitialData();
// eslint-disable-next-line more/no-then // eslint-disable-next-line more/no-then
window.initialRequest.then(data => { window.initialRequest.then(
'use strict'; data => {
'use strict';
window.initialData = data; window.initialData = data;
window.view = new Whisper.SettingsView(); window.view = new Whisper.SettingsView();
window.view.$el.appendTo($body); window.view.$el.appendTo($body);
}); },
error => {
'use strict';
window.log.error(
'settings.initialRequest error:',
error && error.stack ? error.stack : error
);
window.closeSettings();
}
);

View file

@ -1,14 +1,15 @@
/* global /* global
$, $,
_, _,
ConversationController
emojiData, emojiData,
EmojiPanel, EmojiPanel,
extension, extension,
i18n, i18n,
Signal, Signal,
storage, storage,
textsecure,
Whisper, Whisper,
ConversationController
*/ */
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
@ -131,6 +132,9 @@
'show-message-detail', 'show-message-detail',
this.showMessageDetail this.showMessageDetail
); );
this.listenTo(this.model.messageCollection, 'navigate-to', url => {
window.location = url;
});
this.lazyUpdateVerified = _.debounce( this.lazyUpdateVerified = _.debounce(
this.model.updateVerified.bind(this.model), this.model.updateVerified.bind(this.model),
@ -140,6 +144,10 @@
this.model.getProfiles.bind(this.model), this.model.getProfiles.bind(this.model),
1000 * 60 * 5 // five minutes 1000 * 60 * 5 // five minutes
); );
this.debouncedMaybeGrabLinkPreview = _.debounce(
this.maybeGrabLinkPreview.bind(this),
200
);
this.render(); this.render();
@ -157,8 +165,11 @@
this.onChooseAttachment this.onChooseAttachment
); );
this.listenTo(this.fileInput, 'staged-attachments-changed', () => { this.listenTo(this.fileInput, 'staged-attachments-changed', () => {
this.view.resetScrollPosition(); this.view.restoreBottomOffset();
this.toggleMicrophone(); this.toggleMicrophone();
if (this.fileInput.hasFiles()) {
this.removeLinkPreview();
}
}); });
const getHeaderProps = () => { const getHeaderProps = () => {
@ -253,7 +264,7 @@
'submit .send': 'checkUnverifiedSendMessage', 'submit .send': 'checkUnverifiedSendMessage',
'input .send-message': 'updateMessageFieldSize', 'input .send-message': 'updateMessageFieldSize',
'keydown .send-message': 'updateMessageFieldSize', 'keydown .send-message': 'updateMessageFieldSize',
'keyup .send-message': 'maybeBumpTyping', 'keyup .send-message': 'onKeyUp',
click: 'onClick', click: 'onClick',
'click .bottom-bar': 'focusMessageField', 'click .bottom-bar': 'focusMessageField',
'click .capture-audio .microphone': 'captureAudio', 'click .capture-audio .microphone': 'captureAudio',
@ -776,7 +787,7 @@
const message = rawMedia[i]; const message = rawMedia[i];
const { schemaVersion } = message; 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 // Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
rawMedia[i] = await upgradeMessageSchema(message); rawMedia[i] = await upgradeMessageSchema(message);
@ -1634,10 +1645,16 @@
const sendDelta = Date.now() - this.sendStart; const sendDelta = Date.now() - this.sendStart;
window.log.info('Send pre-checks took', sendDelta, 'milliseconds'); 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(''); input.val('');
this.setQuoteMessage(null); this.setQuoteMessage(null);
this.resetLinkPreview();
this.focusMessageFieldAndClearDisabled(); this.focusMessageFieldAndClearDisabled();
this.forceUpdateMessageFieldSize(e); this.forceUpdateMessageFieldSize(e);
this.fileInput.clearAttachments(); 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 // Called whenever the user changes the message composition field. But only
// fires if there's content in the message field after the change. // fires if there's content in the message field after the change.
maybeBumpTyping() { maybeBumpTyping() {

View file

@ -148,8 +148,10 @@
clearDataExplanation: i18n('clearDataExplanation'), clearDataExplanation: i18n('clearDataExplanation'),
permissions: i18n('permissions'), permissions: i18n('permissions'),
mediaPermissionsDescription: i18n('mediaPermissionsDescription'), mediaPermissionsDescription: i18n('mediaPermissionsDescription'),
spellCheckHeader: i18n('spellCheck'), generalHeader: i18n('general'),
spellCheckDescription: i18n('spellCheckDescription'), spellCheckDescription: i18n('spellCheckDescription'),
sendLinkPreviews: i18n('sendLinkPreviews'),
linkPreviewsDescription: i18n('linkPreviewsDescription'),
}; };
}, },
onClose() { onClose() {

View file

@ -1371,6 +1371,14 @@ MessageReceiver.prototype.extend({
promises.push(this.handleAttachment(attachment)); 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) { if (decrypted.contact && decrypted.contact.length) {
const contacts = decrypted.contact; const contacts = decrypted.contact;

View file

@ -18,6 +18,7 @@ function Message(options) {
this.body = options.body; this.body = options.body;
this.attachments = options.attachments || []; this.attachments = options.attachments || [];
this.quote = options.quote; this.quote = options.quote;
this.preview = options.preview;
this.group = options.group; this.group = options.group;
this.flags = options.flags; this.flags = options.flags;
this.recipients = options.recipients; this.recipients = options.recipients;
@ -102,6 +103,15 @@ Message.prototype = {
proto.group.id = stringToArrayBuffer(this.group.id); proto.group.id = stringToArrayBuffer(this.group.id);
proto.group.type = this.group.type; 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) { if (this.quote) {
const { QuotedAttachment } = textsecure.protobuf.DataMessage.Quote; const { QuotedAttachment } = textsecure.protobuf.DataMessage.Quote;
const { Quote } = textsecure.protobuf.DataMessage; 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) { uploadThumbnails(message) {
const makePointer = this.makeAttachmentPointer.bind(this); const makePointer = this.makeAttachmentPointer.bind(this);
const { quote } = message; const { quote } = message;
@ -274,6 +303,7 @@ MessageSender.prototype = {
return Promise.all([ return Promise.all([
this.uploadAttachments(message), this.uploadAttachments(message),
this.uploadThumbnails(message), this.uploadThumbnails(message),
this.uploadLinkPreviews(message),
]).then( ]).then(
() => () =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@ -734,6 +764,7 @@ MessageSender.prototype = {
messageText, messageText,
attachments, attachments,
quote, quote,
preview,
timestamp, timestamp,
expireTimer, expireTimer,
profileKey, profileKey,
@ -746,6 +777,7 @@ MessageSender.prototype = {
timestamp, timestamp,
attachments, attachments,
quote, quote,
preview,
needsSync: true, needsSync: true,
expireTimer, expireTimer,
profileKey, profileKey,
@ -822,6 +854,7 @@ MessageSender.prototype = {
messageText, messageText,
attachments, attachments,
quote, quote,
preview,
timestamp, timestamp,
expireTimer, expireTimer,
profileKey, profileKey,
@ -845,6 +878,7 @@ MessageSender.prototype = {
timestamp, timestamp,
attachments, attachments,
quote, quote,
preview,
needsSync: true, needsSync: true,
expireTimer, expireTimer,
profileKey, profileKey,
@ -1023,6 +1057,12 @@ MessageSender.prototype = {
options options
); );
}, },
makeProxiedRequest(url, options) {
return this.server.makeProxiedRequest(url, options);
},
getProxiedSize(url) {
return this.server.getProxiedSize(url);
},
}; };
window.textsecure = window.textsecure || {}; window.textsecure = window.textsecure || {};
@ -1068,6 +1108,8 @@ textsecure.MessageSender = function MessageSenderWrapper(
this.syncVerification = sender.syncVerification.bind(sender); this.syncVerification = sender.syncVerification.bind(sender);
this.sendDeliveryReceipt = sender.sendDeliveryReceipt.bind(sender); this.sendDeliveryReceipt = sender.sendDeliveryReceipt.bind(sender);
this.sendReadReceipts = sender.sendReadReceipts.bind(sender); this.sendReadReceipts = sender.sendReadReceipts.bind(sender);
this.makeProxiedRequest = sender.makeProxiedRequest.bind(sender);
this.getProxiedSize = sender.getProxiedSize.bind(sender);
}; };
textsecure.MessageSender.prototype = { textsecure.MessageSender.prototype = {

View file

@ -148,6 +148,7 @@ function prepareURL(pathSegments, moreKeys) {
hostname: os.hostname(), hostname: os.hostname(),
appInstance: process.env.NODE_APP_INSTANCE, appInstance: process.env.NODE_APP_INSTANCE,
proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy, proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy,
contentProxyUrl: config.contentProxyUrl,
importMode: importMode ? true : undefined, // for stringify() importMode: importMode ? true : undefined, // for stringify()
serverTrustRoot: config.get('serverTrustRoot'), serverTrustRoot: config.get('serverTrustRoot'),
...moreKeys, ...moreKeys,

View file

@ -64,6 +64,7 @@
"glob": "7.1.2", "glob": "7.1.2",
"google-libphonenumber": "3.0.7", "google-libphonenumber": "3.0.7",
"got": "8.2.0", "got": "8.2.0",
"he": "1.2.0",
"intl-tel-input": "12.1.15", "intl-tel-input": "12.1.15",
"jquery": "3.3.1", "jquery": "3.3.1",
"linkify-it": "2.0.3", "linkify-it": "2.0.3",

View file

@ -28,6 +28,7 @@ window.getExpiration = () => config.buildExpiration;
window.getNodeVersion = () => config.node_version; window.getNodeVersion = () => config.node_version;
window.getHostName = () => config.hostname; window.getHostName = () => config.hostname;
window.getServerTrustRoot = () => config.serverTrustRoot; window.getServerTrustRoot = () => config.serverTrustRoot;
window.isBehindProxy = () => Boolean(config.proxyUrl);
window.isBeforeVersion = (toCheck, baseVersion) => { window.isBeforeVersion = (toCheck, baseVersion) => {
try { try {
@ -173,16 +174,20 @@ ipc.on('get-ready-for-shutdown', async () => {
function installGetter(name, functionName) { function installGetter(name, functionName) {
ipc.on(`get-${name}`, async () => { ipc.on(`get-${name}`, async () => {
const getFn = window.Events[functionName]; const getFn = window.Events[functionName];
if (getFn) { if (!getFn) {
// eslint-disable-next-line no-param-reassign ipc.send(
try { `get-success-${name}`,
ipc.send(`get-success-${name}`, null, await getFn()); `installGetter: ${functionName} not found for event ${name}`
} catch (error) { );
ipc.send( return;
`get-success-${name}`, }
error && error.stack ? error.stack : error 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) { function installSetter(name, functionName) {
ipc.on(`set-${name}`, async (_event, value) => { ipc.on(`set-${name}`, async (_event, value) => {
const setFn = window.Events[functionName]; const setFn = window.Events[functionName];
if (setFn) { if (!setFn) {
try { ipc.send(
await setFn(value); `set-success-${name}`,
ipc.send(`set-success-${name}`); `installSetter: ${functionName} not found for event ${name}`
} catch (error) { );
ipc.send(`set-success-${name}`, error); 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, url: config.serverUrl,
cdnUrl: config.cdnUrl, cdnUrl: config.cdnUrl,
certificateAuthority: config.certificateAuthority, certificateAuthority: config.certificateAuthority,
contentProxyUrl: config.contentProxyUrl,
proxyUrl: config.proxyUrl, proxyUrl: config.proxyUrl,
}); });

View file

@ -156,6 +156,12 @@ message DataMessage {
optional string organization = 7; optional string organization = 7;
} }
message Preview {
optional string url = 1;
optional string title = 2;
optional AttachmentPointer image = 3;
}
optional string body = 1; optional string body = 1;
repeated AttachmentPointer attachments = 2; repeated AttachmentPointer attachments = 2;
optional GroupContext group = 3; optional GroupContext group = 3;
@ -165,6 +171,7 @@ message DataMessage {
optional uint64 timestamp = 7; optional uint64 timestamp = 7;
optional Quote quote = 8; optional Quote quote = 8;
repeated Contact contact = 9; repeated Contact contact = 9;
repeated Preview preview = 10;
} }
message NullMessage { message NullMessage {
@ -254,6 +261,7 @@ message SyncMessage {
optional bool readReceipts = 1; optional bool readReceipts = 1;
optional bool unidentifiedDeliveryIndicators = 2; optional bool unidentifiedDeliveryIndicators = 2;
optional bool typingIndicators = 3; optional bool typingIndicators = 3;
optional bool linkPreviews = 4;
} }
optional Sent sent = 1; optional Sent sent = 1;

View file

@ -87,11 +87,11 @@
</div> </div>
{{ /isAudioNotificationSupported }} {{ /isAudioNotificationSupported }}
<hr> <hr>
<div class='spell-check-setting'> <h3>{{ generalHeader }}</h3>
<h3>{{ spellCheckHeader }}</h3> <div class='spell-check-setting'>
<input type='checkbox' name='spell-check-setting' id='spell-check-setting' /> <input type='checkbox' name='spell-check-setting' id='spell-check-setting' />
<label for='spell-check-setting'>{{ spellCheckDescription }}</label> <label for='spell-check-setting'>{{ spellCheckDescription }}</label>
</div> </div>
<hr> <hr>
<div class='permissions-setting'> <div class='permissions-setting'>
<h3>{{ permissions }}</h3> <h3>{{ permissions }}</h3>

View file

@ -190,6 +190,13 @@
margin-bottom: -5px; margin-bottom: -5px;
} }
.bottom-bar .preview-wrapper {
margin-top: 3px;
margin-left: 37px;
margin-right: 73px;
margin-bottom: 2px;
}
.bottom-bar { .bottom-bar {
box-sizing: content-box; box-sizing: content-box;
$button-width: 36px; $button-width: 36px;

View file

@ -200,8 +200,6 @@
background-color: $color-conversation-blue_grey; background-color: $color-conversation-blue_grey;
} }
// START
.module-message__attachment-container { .module-message__attachment-container {
// Entirely to ensure that images are centered if they aren't full width of bubble // Entirely to ensure that images are centered if they aren't full width of bubble
text-align: center; text-align: center;
@ -357,6 +355,77 @@
color: $color-white; 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 { .module-message__author {
color: $color-white; color: $color-white;
font-size: 13px; font-size: 13px;
@ -2064,6 +2133,9 @@
.module-image--curved-bottom-right { .module-image--curved-bottom-right {
border-bottom-right-radius: 16px; border-bottom-right-radius: 16px;
} }
.module-image--small-curved-top-left {
border-top-left-radius: 10px;
}
.module-image__border-overlay { .module-image__border-overlay {
position: absolute; position: absolute;
@ -2544,6 +2616,65 @@
@include color-svg('../images/plus-36.svg', $color-gray-45); @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 // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {
@ -2632,7 +2763,7 @@
} }
// To limit messages with things forcing them wider, like long attachment names // To limit messages with things forcing them wider, like long attachment names
.module-message { .module-message__container {
max-width: 300px; max-width: 300px;
} }
@ -2641,6 +2772,9 @@
.module-message { .module-message {
max-width: 374px; max-width: 374px;
} }
.module-message__container {
max-width: 100%;
}
// Spec: container < 438px // Spec: container < 438px
.module-message--incoming { .module-message--incoming {
@ -2674,6 +2808,9 @@
.module-message { .module-message {
max-width: 66%; max-width: 66%;
} }
.module-message__container {
max-width: 100%;
}
.module-message--incoming { .module-message--incoming {
margin-left: 0; margin-left: 0;

View file

@ -56,4 +56,12 @@
color: white; color: white;
} }
} }
.send-link-previews-setting {
margin-top: 0.75em;
}
.description {
margin-top: 0.3em;
margin-left: 1.5em;
}
} }

View file

@ -680,6 +680,24 @@ body.dark-theme {
color: $color-white; 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 { .module-message__author {
color: $color-white; color: $color-white;
} }
@ -1308,6 +1326,10 @@ body.dark-theme {
// Module: Image // Module: Image
.module-image__border-overlay {
box-shadow: inset 0px 0px 0px 1px $color-white-015;
}
// Module: Image Grid // Module: Image Grid
// Module: Typing Animation // Module: Typing Animation
@ -1364,6 +1386,21 @@ body.dark-theme {
@include color-svg('../images/plus-36.svg', $color-gray-60); @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 // Third-party module: react-contextmenu
.react-contextmenu { .react-contextmenu {

View file

@ -372,6 +372,15 @@ describe('Backup', () => {
return attachment; 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: // Seven total:
// - Five from image/video attachments // - Five from image/video attachments
// - One from embedded contact avatar // - One from embedded contact avatar
// - Another from embedded quoted attachment thumbnail // - One from embedded quoted attachment thumbnail
const ATTACHMENT_COUNT = 7; // - One from a link preview image
const ATTACHMENT_COUNT = 8;
const MESSAGE_COUNT = 1; const MESSAGE_COUNT = 1;
const CONVERSATION_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: “Lets 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'); console.log('Backup test: Clear all data');

View file

@ -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 = `
<meta property="og:site_name" content="YouTube">
<meta property="og:url" content="https://www.youtube.com/watch?v=tP-Ipsat90c">
<meta property="og:type" content="video.other">
<meta property="og:title" content="Randomness is Random - Numberphile">
<meta property="og:image" content="https://i.ytimg.com/vi/tP-Ipsat90c/maxresdefault.jpg">
`;
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 = `
<meta property="og:site_name" content="Instagram" />
<meta property="og:url" content="https://www.instagram.com/p/BrgpsUjF9Jo/" />
<meta property="og:type" content="instapp:photo" />
<meta property="og:title" content="Walter &#34;MFPallytime&#34; on Instagram: “Lol gg”" />
<meta property="og:description" content="632 Likes, 56 Comments - Walter &#34;MFPallytime&#34; (@mfpallytime) on Instagram: “Lol gg ”" />
<meta property="og:image" content="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" />
`;
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 = `
<meta property="og:site_name" content="Imgur">
<meta property="og:url" content="https://imgur.com/gallery/KFCL8fm">
<meta property="og:type" content="article">
<meta property="og:title" content="&nbsp;">
<meta property="og:description" content="13246 views and 482 votes on Imgur">
<meta property="og:image" content="https://i.imgur.com/Y3wjlwY.jpg?fb">
<meta property="og:image:width" content="600">
<meta property="og:image:height" content="315">
`;
assert.strictEqual('', getTitleMetaTag(imgur));
assert.strictEqual(
'https://i.imgur.com/Y3wjlwY.jpg?fb',
getImageMetaTag(imgur)
);
});
it('returns only the first tag', () => {
const html = `
<meta property="og:title" content="First&nbsp;Second&nbsp;Third"><meta property="og:title" content="Fourth&nbsp;Fifth&nbsp;Sixth">
`;
assert.strictEqual('First Second Third', getTitleMetaTag(html));
});
it('handles a newline in attribute value', () => {
const html = `
<meta property="og:title" content="First thing\r\nSecond thing\nThird thing">
`;
assert.strictEqual(
'First thing\r\nSecond thing\nThird thing',
getTitleMetaTag(html)
);
});
});
});

View file

@ -72,6 +72,7 @@ describe('Message', () => {
}, },
], ],
contact: [], contact: [],
preview: [],
}; };
const writeExistingAttachmentData = attachment => { const writeExistingAttachmentData = attachment => {
@ -119,6 +120,7 @@ describe('Message', () => {
], ],
}, },
contact: [], contact: [],
preview: [],
}; };
const writeExistingAttachmentData = attachment => { const writeExistingAttachmentData = attachment => {
@ -169,6 +171,7 @@ describe('Message', () => {
}, },
}, },
], ],
preview: [],
}; };
const writeExistingAttachmentData = attachment => { const writeExistingAttachmentData = attachment => {

View file

@ -20,6 +20,9 @@ interface Props {
curveBottomRight?: boolean; curveBottomRight?: boolean;
curveTopLeft?: boolean; curveTopLeft?: boolean;
curveTopRight?: boolean; curveTopRight?: boolean;
smallCurveTopLeft?: boolean;
darkOverlay?: boolean; darkOverlay?: boolean;
playIconOverlay?: boolean; playIconOverlay?: boolean;
softCorners?: boolean; softCorners?: boolean;
@ -50,6 +53,7 @@ export class Image extends React.Component<Props> {
onError, onError,
overlayText, overlayText,
playIconOverlay, playIconOverlay,
smallCurveTopLeft,
softCorners, softCorners,
url, url,
width, width,
@ -72,6 +76,7 @@ export class Image extends React.Component<Props> {
curveBottomRight ? 'module-image--curved-bottom-right' : null, curveBottomRight ? 'module-image--curved-bottom-right' : null,
curveTopLeft ? 'module-image--curved-top-left' : null, curveTopLeft ? 'module-image--curved-top-left' : null,
curveTopRight ? 'module-image--curved-top-right' : null, curveTopRight ? 'module-image--curved-top-right' : null,
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
softCorners ? 'module-image--soft-corners' : null softCorners ? 'module-image--soft-corners' : null
)} )}
> >
@ -97,6 +102,7 @@ export class Image extends React.Component<Props> {
curveTopRight ? 'module-image--curved-top-right' : null, curveTopRight ? 'module-image--curved-top-right' : null,
curveBottomLeft ? 'module-image--curved-bottom-left' : null, curveBottomLeft ? 'module-image--curved-bottom-left' : null,
curveBottomRight ? 'module-image--curved-bottom-right' : null, curveBottomRight ? 'module-image--curved-bottom-right' : null,
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
softCorners ? 'module-image--soft-corners' : null, softCorners ? 'module-image--soft-corners' : null,
darkOverlay ? 'module-image__border-overlay--dark' : null darkOverlay ? 'module-image__border-overlay--dark' : null
)} )}

View file

@ -11,8 +11,8 @@ import { Localizer } from '../../types/Util';
interface Props { interface Props {
attachments: Array<AttachmentType>; attachments: Array<AttachmentType>;
withContentAbove: boolean; withContentAbove?: boolean;
withContentBelow: boolean; withContentBelow?: boolean;
bottomOverlay?: boolean; bottomOverlay?: boolean;
i18n: Localizer; i18n: Localizer;
@ -370,7 +370,7 @@ type DimensionsType = {
width: number; width: number;
}; };
function getImageDimensions(attachment: AttachmentType): DimensionsType { export function getImageDimensions(attachment: AttachmentType): DimensionsType {
const { height, width } = attachment; const { height, width } = attachment;
if (!height || !width) { if (!height || !width) {
return { return {

View file

@ -202,12 +202,21 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
</li> </li>
<li> <li>
<Message <Message
direction="incoming" direction="outgoing"
status="error" status="error"
authorColor="purple" authorColor="purple"
timestamp={Date.now()} timestamp={Date.now() - 56}
text="Error!" text="Error!"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n} i18n={util.i18n}
onRetrySend={() => console.log('onRetrySend')}
/> />
</li> </li>
<li> <li>
@ -261,6 +270,25 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
onRetrySend={() => console.log('onRetrySend')} onRetrySend={() => console.log('onRetrySend')}
/> />
</li> </li>
<li>
<Message
direction="outgoing"
status="error"
authorColor="purple"
timestamp={Date.now() - 57}
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
text="🔥"
i18n={util.i18n}
onRetrySend={() => console.log('onRetrySend')}
/>
</li>
<li> <li>
<Message <Message
direction="incoming" direction="incoming"
@ -271,6 +299,24 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
i18n={util.i18n} i18n={util.i18n}
/> />
</li> </li>
<li>
<Message
direction="incoming"
status="error"
authorColor="purple"
timestamp={Date.now()}
text="🔥"
attachments={[
{
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
]}
i18n={util.i18n}
/>
</li>
</util.ConversationContext> </util.ConversationContext>
``` ```
@ -2533,6 +2579,313 @@ Voice notes are not shown any differently from audio attachments.
</util.ConversationContext> </util.ConversationContext>
``` ```
#### Link previews, full-size image
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
timestamp={Date.now()}
text="Pretty sweet link: https://instagram.com/something"
previews={[
{
title: 'This is a really sweet post',
domain: 'instagram.com',
image: {
url: util.pngObjectUrl,
contentType: 'image/png',
width: 800,
height: 1200,
},
},
]}
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
/>
</li>
<li>
<Message
authorColor="green"
direction="outgoing"
i18n={util.i18n}
timestamp={Date.now()}
status="sent"
text="Pretty sweet link: https://instagram.com/something"
previews={[
{
title: 'This is a really sweet post',
domain: 'instagram.com',
image: {
url: util.pngObjectUrl,
contentType: 'image/png',
width: 800,
height: 1200,
},
},
]}
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
timestamp={Date.now()}
quote={{
authorColor: 'purple',
text: 'How many ferrets do you have?',
authorPhoneNumber: '(202) 555-0011',
onClick: () => 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)}
/>
</li>
<li>
<Message
authorColor="green"
direction="outgoing"
i18n={util.i18n}
timestamp={Date.now()}
status="sent"
quote={{
authorColor: 'purple',
text: 'How many ferrets do you have?',
authorPhoneNumber: '(202) 555-0011',
onClick: () => 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)}
/>
</li>
</util.ConversationContext>
```
#### Link previews, small image
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
timestamp={Date.now()}
text="Pretty sweet link: https://instagram.com/something"
previews={[
{
title: 'This is a really sweet post',
domain: 'instagram.com',
image: {
url: util.pngObjectUrl,
contentType: 'image/png',
width: 160,
height: 120,
},
},
]}
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
/>
</li>
<li>
<Message
authorColor="green"
direction="outgoing"
i18n={util.i18n}
timestamp={Date.now()}
status="sent"
text="Pretty sweet link: https://instagram.com/something"
previews={[
{
title: 'This is a really sweet post',
domain: 'instagram.com',
image: {
url: util.pngObjectUrl,
contentType: 'image/png',
width: 160,
height: 120,
},
},
]}
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
timestamp={Date.now()}
quote={{
authorColor: 'purple',
text: 'How many ferrets do you have?',
authorPhoneNumber: '(202) 555-0011',
onClick: () => 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)}
/>
</li>
<li>
<Message
authorColor="green"
direction="outgoing"
i18n={util.i18n}
timestamp={Date.now()}
status="sent"
quote={{
authorColor: 'purple',
text: 'How many ferrets do you have?',
authorPhoneNumber: '(202) 555-0011',
onClick: () => 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)}
/>
</li>
</util.ConversationContext>
```
#### Link previews, no image
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios}>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
timestamp={Date.now()}
text="Pretty sweet link: https://instagram.com/something"
previews={[
{
title: 'This is a really sweet post',
domain: 'instagram.com',
},
]}
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
/>
</li>
<li>
<Message
authorColor="green"
direction="outgoing"
i18n={util.i18n}
timestamp={Date.now()}
status="sent"
text="Pretty sweet link: https://instagram.com/something"
previews={[
{
title: 'This is a really sweet post',
domain: 'instagram.com',
},
]}
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
/>
</li>
<li>
<Message
authorColor="green"
direction="incoming"
i18n={util.i18n}
timestamp={Date.now()}
quote={{
authorColor: 'purple',
text: 'How many ferrets do you have?',
authorPhoneNumber: '(202) 555-0011',
onClick: () => 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)}
/>
</li>
<li>
<Message
authorColor="green"
direction="outgoing"
i18n={util.i18n}
timestamp={Date.now()}
status="sent"
quote={{
authorColor: 'purple',
text: 'How many ferrets do you have?',
authorPhoneNumber: '(202) 555-0011',
onClick: () => 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)}
/>
</li>
</util.ConversationContext>
```
### In a group conversation ### In a group conversation
Note that the author avatar goes away if `collapseMetadata` is set. 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} i18n={util.i18n}
/> />
</li> </li>
<li>
<Message
authorColor="green"
authorName="Mr. Fire"
conversationType="group"
direction="incoming"
i18n={util.i18n}
timestamp={Date.now()}
text="Pretty sweet link: https://instagram.com/something"
previews={[
{
title: 'This is a really sweet post',
domain: 'instagram.com',
image: {
url: util.gifObjectUrl,
contentType: 'image/gif',
width: 320,
height: 240,
},
},
]}
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
/>
</li>
<li>
<Message
authorColor="green"
authorName="Mr. Fire"
conversationType="group"
direction="incoming"
i18n={util.i18n}
timestamp={Date.now()}
text="Pretty sweet link: https://instagram.com/something"
previews={[
{
title: 'This is a really sweet post',
domain: 'instagram.com',
},
]}
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
/>
</li>
<li> <li>
<Message <Message
direction="outgoing" direction="outgoing"

View file

@ -6,12 +6,15 @@ import { MessageBody } from './MessageBody';
import { ExpireTimer, getIncrement } from './ExpireTimer'; import { ExpireTimer, getIncrement } from './ExpireTimer';
import { import {
getGridDimensions, getGridDimensions,
getImageDimensions,
hasImage, hasImage,
hasVideoScreenshot, hasVideoScreenshot,
ImageGrid, ImageGrid,
isImage, isImage,
isImageAttachment,
isVideo, isVideo,
} from './ImageGrid'; } from './ImageGrid';
import { Image } from './Image';
import { Timestamp } from './Timestamp'; import { Timestamp } from './Timestamp';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Quote, QuotedAttachmentType } from './Quote'; import { Quote, QuotedAttachmentType } from './Quote';
@ -28,6 +31,16 @@ interface Trigger {
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void; handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => 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 { export interface Props {
disableMenu?: boolean; disableMenu?: boolean;
text?: string; text?: string;
@ -61,11 +74,13 @@ export interface Props {
onClick?: () => void; onClick?: () => void;
referencedMessageNotFound: boolean; referencedMessageNotFound: boolean;
}; };
previews: Array<LinkPreviewType>;
authorAvatarPath?: string; authorAvatarPath?: string;
isExpired: boolean; isExpired: boolean;
expirationLength?: number; expirationLength?: number;
expirationTimestamp?: number; expirationTimestamp?: number;
onClickAttachment?: (attachment: AttachmentType) => void; onClickAttachment?: (attachment: AttachmentType) => void;
onClickLinkPreview?: (url: string) => void;
onReply?: () => void; onReply?: () => void;
onRetrySend?: () => void; onRetrySend?: () => void;
onDownload?: (isDangerous: boolean) => void; onDownload?: (isDangerous: boolean) => void;
@ -173,7 +188,6 @@ export class Message extends React.Component<Props, State> {
public renderMetadata() { public renderMetadata() {
const { const {
attachments,
collapseMetadata, collapseMetadata,
direction, direction,
expirationLength, expirationLength,
@ -183,20 +197,13 @@ export class Message extends React.Component<Props, State> {
text, text,
timestamp, timestamp,
} = this.props; } = this.props;
const { imageBroken } = this.state;
if (collapseMetadata) { if (collapseMetadata) {
return null; return null;
} }
const canDisplayAttachment = canDisplayImage(attachments); const isShowingImage = this.isShowingImage();
const withImageNoCaption = Boolean( const withImageNoCaption = Boolean(!text && isShowingImage);
!text &&
canDisplayAttachment &&
!imageBroken &&
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
);
const showError = status === 'error' && direction === 'outgoing'; const showError = status === 'error' && direction === 'outgoing';
return ( return (
@ -409,6 +416,107 @@ export class Message extends React.Component<Props, State> {
} }
} }
// 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 (
<div
role="button"
className={classNames(
'module-message__link-preview',
withContentAbove
? 'module-message__link-preview--with-content-above'
: null
)}
onClick={() => {
if (onClickLinkPreview) {
onClickLinkPreview(first.url);
}
}}
>
{first.image && previewHasImage && isFullSizeImage ? (
<ImageGrid
attachments={[first.image]}
withContentAbove={withContentAbove}
withContentBelow={true}
onError={this.handleImageErrorBound}
i18n={i18n}
/>
) : null}
<div
className={classNames(
'module-message__link-preview__content',
withContentAbove || isFullSizeImage
? 'module-message__link-preview__content--with-content-above'
: null
)}
>
{first.image && previewHasImage && !isFullSizeImage ? (
<div className="module-message__link-preview__icon_container">
<Image
smallCurveTopLeft={!withContentAbove}
softCorners={true}
alt={i18n('previewThumbnail', [first.domain])}
height={72}
width={72}
url={first.image.url}
attachment={first.image}
onError={this.handleImageErrorBound}
i18n={i18n}
/>
</div>
) : null}
<div
className={classNames(
'module-message__link-preview__text',
previewHasImage && !isFullSizeImage
? 'module-message__link-preview__text--with-icon'
: null
)}
>
<div className="module-message__link-preview__title">
{first.title}
</div>
<div className="module-message__link-preview__location">
{first.domain}
</div>
</div>
</div>
</div>
);
}
public renderQuote() { public renderQuote() {
const { const {
conversationType, conversationType,
@ -734,16 +842,80 @@ export class Message extends React.Component<Props, State> {
); );
} }
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() { public render() {
const { const {
attachments,
authorPhoneNumber, authorPhoneNumber,
authorColor, authorColor,
direction, direction,
id, id,
timestamp, timestamp,
} = this.props; } = 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. // This id is what connects our triple-dot click with our associated pop-up menu.
// It needs to be unique. // It needs to be unique.
@ -753,15 +925,8 @@ export class Message extends React.Component<Props, State> {
return null; return null;
} }
const displayImage = canDisplayImage(attachments); const width = this.getWidth();
const isShowingImage = this.isShowingImage();
const showingImage =
displayImage &&
!imageBroken &&
((isImage(attachments) && hasImage(attachments)) ||
(isVideo(attachments) && hasVideoScreenshot(attachments)));
const { width } = getGridDimensions(attachments) || { width: undefined };
return ( return (
<div <div
@ -770,9 +935,6 @@ export class Message extends React.Component<Props, State> {
`module-message--${direction}`, `module-message--${direction}`,
expiring ? 'module-message--expired' : null expiring ? 'module-message--expired' : null
)} )}
style={{
width: showingImage ? width : undefined,
}}
> >
{this.renderError(direction === 'incoming')} {this.renderError(direction === 'incoming')}
{this.renderMenu(direction === 'outgoing', triggerId)} {this.renderMenu(direction === 'outgoing', triggerId)}
@ -784,10 +946,14 @@ export class Message extends React.Component<Props, State> {
? `module-message__container--incoming-${authorColor}` ? `module-message__container--incoming-${authorColor}`
: null : null
)} )}
style={{
width: isShowingImage ? width : undefined,
}}
> >
{this.renderAuthor()} {this.renderAuthor()}
{this.renderQuote()} {this.renderQuote()}
{this.renderAttachment()} {this.renderAttachment()}
{this.renderPreview()}
{this.renderEmbeddedContact()} {this.renderEmbeddedContact()}
{this.renderText()} {this.renderText()}
{this.renderMetadata()} {this.renderMetadata()}

View file

@ -0,0 +1,92 @@
#### Still loading
```jsx
<util.ConversationContext theme={util.theme}>
<StagedLinkPreview
isLoaded={false}
onClose={() => console.log('onClose')}
i18n={util.i18n}
/>
</util.ConversationContext>
```
#### No image
```jsx
<util.ConversationContext theme={util.theme}>
<StagedLinkPreview
isLoaded={true}
title="This is a super-sweet site"
domain="instagram.com"
onClose={() => console.log('onClose')}
i18n={util.i18n}
/>
</util.ConversationContext>
```
#### Image
```jsx
<util.ConversationContext theme={util.theme}>
<StagedLinkPreview
isLoaded={true}
title="This is a super-sweet site"
domain="instagram.com"
image={{
url: util.gifObjectUrl,
contentType: 'image/gif',
}}
onClose={() => console.log('onClose')}
i18n={util.i18n}
/>
</util.ConversationContext>
```
#### Image, no title
```jsx
<util.ConversationContext theme={util.theme}>
<StagedLinkPreview
isLoaded={true}
domain="instagram.com"
image={{
url: util.gifObjectUrl,
contentType: 'image/gif',
}}
onClose={() => console.log('onClose')}
i18n={util.i18n}
/>
</util.ConversationContext>
```
#### No image, long title
```jsx
<util.ConversationContext theme={util.theme}>
<StagedLinkPreview
isLoaded={true}
title="This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?"
domain="instagram.com"
onClose={() => console.log('onClose')}
i18n={util.i18n}
/>
</util.ConversationContext>
```
#### Image, long title
```jsx
<util.ConversationContext theme={util.theme}>
<StagedLinkPreview
isLoaded={true}
title="This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?"
domain="instagram.com"
image={{
url: util.gifObjectUrl,
contentType: 'image/gif',
}}
onClose={() => console.log('onClose')}
i18n={util.i18n}
/>
</util.ConversationContext>
```

View file

@ -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<Props> {
public render() {
const { isLoaded, onClose, i18n, title, image, domain } = this.props;
const isImage = image && isImageAttachment(image);
return (
<div
className={classNames(
'module-staged-link-preview',
!isLoaded ? 'module-staged-link-preview--is-loading' : null
)}
>
{!isLoaded ? (
<div className="module-staged-link-preview__loading">
{i18n('loadingPreview')}
</div>
) : null}
{isLoaded && image && isImage ? (
<div className="module-staged-link-preview__icon-container">
<Image
alt={i18n('stagedPreviewThumbnail', [domain])}
softCorners={true}
height={72}
width={72}
url={image.url}
attachment={image}
i18n={i18n}
/>
</div>
) : null}
{isLoaded ? (
<div className="module-staged-link-preview__content">
<div className="module-staged-link-preview__title">{title}</div>
<div className="module-staged-link-preview__location">{domain}</div>
</div>
) : null}
<div
role="button"
className="module-staged-link-preview__close-button"
onClick={onClose}
/>
</div>
);
}
}

View file

@ -10,6 +10,9 @@ export const VIDEO_MP4 = 'video/mp4' as MIMEType;
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType; export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg'; export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
export const isImage = (value: MIMEType): boolean => value.startsWith('image/'); export const isImage = (value: MIMEType): boolean =>
export const isVideo = (value: MIMEType): boolean => value.startsWith('video/'); value && value.startsWith('image/');
export const isAudio = (value: MIMEType): boolean => value.startsWith('audio/'); export const isVideo = (value: MIMEType): boolean =>
value && value.startsWith('video/');
export const isAudio = (value: MIMEType): boolean =>
value && value.startsWith('audio/');

View file

@ -3980,6 +3980,11 @@ he@1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" 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: highlight.js@^9.12.0:
version "9.12.0" version "9.12.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e"