diff --git a/js/models/messages.js b/js/models/messages.js index feb92099a5..74a9b80cc7 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -778,6 +778,7 @@ isStickerPack: window.Signal.LinkPreviews.isStickerPack(preview.url), domain: window.Signal.LinkPreviews.getDomain(preview.url), image: preview.image ? this.getPropsForAttachment(preview.image) : null, + date: preview.date ? preview.date.toNumber() : null, })); }, getPropsForQuote() { @@ -2327,7 +2328,7 @@ item => (item.image || item.title) && urls.includes(item.url) && - window.Signal.LinkPreviews.isLinkInWhitelist(item.url) + window.Signal.LinkPreviews.isLinkSafeToPreview(item.url) ); if (preview.length < incomingPreview.length) { window.log.info( diff --git a/js/modules/link_previews.d.ts b/js/modules/link_previews.d.ts index ea3e3e3d18..183bdb1cfd 100644 --- a/js/modules/link_previews.d.ts +++ b/js/modules/link_previews.d.ts @@ -1 +1,3 @@ +export function isLinkSafeToPreview(link: string): boolean; + export function isLinkSneaky(link: string): boolean; diff --git a/js/modules/link_previews.js b/js/modules/link_previews.js index e5e6a64b1e..5ee8d53e9c 100644 --- a/js/modules/link_previews.js +++ b/js/modules/link_previews.js @@ -15,12 +15,23 @@ module.exports = { getDomain, getTitleMetaTag, getImageMetaTag, + isLinkSafeToPreview, isLinkInWhitelist, isMediaLinkInWhitelist, isLinkSneaky, isStickerPack, }; +function isLinkSafeToPreview(link) { + let url; + try { + url = new URL(link); + } catch (err) { + return false; + } + return url.protocol === 'https:' && !isLinkSneaky(link); +} + const SUPPORTED_DOMAINS = [ 'youtube.com', 'www.youtube.com', @@ -41,6 +52,10 @@ const SUPPORTED_DOMAINS = [ 'signal.art', ]; +// This function will soon be removed in favor of `isLinkSafeToPreview`. It is +// currently used because outbound-from-Desktop link previews only support a +// few domains (see the list above). We will soon remove this restriction to +// allow link previews from all domains, making this function obsolete. function isLinkInWhitelist(link) { try { const url = new URL(link); @@ -69,6 +84,9 @@ function isStickerPack(link) { } const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg\.com|cdninstagram\.com|redd\.it|imgur\.com|fbcdn\.net|pinimg\.com)$/i; + +// This function will soon be removed. See the comment in `isLinkInWhitelist` +// for more info. function isMediaLinkInWhitelist(link) { try { const url = new URL(link); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 44a46ca71c..037a09af6e 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -189,6 +189,8 @@ message DataMessage { optional string url = 1; optional string title = 2; optional AttachmentPointer image = 3; + optional string description = 4; + optional uint64 date = 5; } message Sticker { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index fde59c2ad4..8244fabdd9 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -870,6 +870,8 @@ .module-message__link-preview__content { padding: 8px; + border: 1px solid transparent; /* Color overwritten below. */ + border-bottom: 0; border-top-left-radius: 16px; border-top-right-radius: 16px; background-color: $color-white; @@ -878,11 +880,11 @@ align-items: flex-start; @include light-theme { - border: 1px solid $color-black-alpha-20; + border-color: $color-black-alpha-20; } @include dark-theme { background-color: $color-gray-95; - border: 1px solid $color-gray-60; + border-color: $color-gray-60; } } @@ -919,11 +921,29 @@ } } -.module-message__link-preview__location { +.module-message__link-preview__description { @include font-body-2; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 5; + -webkit-box-orient: vertical; + + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } +} + +.module-message__link-preview__footer { + @include font-body-2; + + display: flex; + flex-flow: row wrap; + align-items: center; margin-top: 4px; - text-transform: uppercase; @include light-theme { color: $color-gray-60; @@ -931,6 +951,21 @@ @include dark-theme { color: $color-gray-25; } + + > *:not(:first-child) { + display: flex; + + &:before { + content: '•'; + font-size: 50%; + margin-left: 0.2rem; + margin-right: 0.2rem; + } + } +} + +.module-message__link-preview__location { + text-transform: lowercase; } .module-message__author { diff --git a/test/modules/link_previews_test.js b/test/modules/link_previews_test.js index 5cba9a21a1..fb3faa6faf 100644 --- a/test/modules/link_previews_test.js +++ b/test/modules/link_previews_test.js @@ -4,12 +4,43 @@ const { findLinks, getTitleMetaTag, getImageMetaTag, + isLinkSafeToPreview, isLinkInWhitelist, isLinkSneaky, isMediaLinkInWhitelist, } = require('../../js/modules/link_previews'); describe('Link previews', () => { + describe('#isLinkSafeToPreview', () => { + it('returns false for invalid URLs', () => { + assert.isFalse(isLinkSafeToPreview('')); + assert.isFalse(isLinkSafeToPreview('https')); + assert.isFalse(isLinkSafeToPreview('https://')); + assert.isFalse(isLinkSafeToPreview('https://bad url')); + assert.isFalse(isLinkSafeToPreview('example.com')); + }); + + it('returns false for non-HTTPS URLs', () => { + assert.isFalse(isLinkSafeToPreview('http://example.com')); + assert.isFalse(isLinkSafeToPreview('ftp://example.com')); + assert.isFalse(isLinkSafeToPreview('file://example')); + }); + + it('returns false if the link is "sneaky"', () => { + // See `isLinkSneaky` tests below for more thorough checking. + assert.isFalse(isLinkSafeToPreview('https://user:pass@example.com')); + assert.isFalse(isLinkSafeToPreview('https://aquí.example')); + assert.isFalse(isLinkSafeToPreview('https://aqu%C3%AD.example')); + }); + + it('returns true for "safe" urls', () => { + assert.isTrue(isLinkSafeToPreview('https://example.com')); + assert.isTrue( + isLinkSafeToPreview('https://example.com/foo/bar?query=string#hash') + ); + }); + }); + describe('#isLinkInWhitelist', () => { it('returns true for valid links', () => { assert.strictEqual(isLinkInWhitelist('https://youtube.com/blah'), true); diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index e425dfb841..4a6660193f 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -358,7 +358,10 @@ story.add('Link Preview', () => { }, isStickerPack: false, title: 'Signal', + description: + 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', url: 'https://www.signal.org', + date: new Date(2020, 2, 10).valueOf(), }, ], status: 'sent', @@ -382,7 +385,10 @@ story.add('Link Preview with Small Image', () => { }, isStickerPack: false, title: 'Signal', + description: + 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', url: 'https://www.signal.org', + date: new Date(2020, 2, 10).valueOf(), }, ], status: 'sent', @@ -399,7 +405,161 @@ story.add('Link Preview without Image', () => { domain: 'signal.org', isStickerPack: false, title: 'Signal', + description: + 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', url: 'https://www.signal.org', + date: new Date(2020, 2, 10).valueOf(), + }, + ], + status: 'sent', + text: 'Be sure to look at https://www.signal.org', + }); + + return renderBothDirections(props); +}); + +story.add('Link Preview with no description', () => { + const props = createProps({ + previews: [ + { + domain: 'signal.org', + isStickerPack: false, + title: 'Signal', + url: 'https://www.signal.org', + date: Date.now(), + }, + ], + status: 'sent', + text: 'Be sure to look at https://www.signal.org', + }); + + return renderBothDirections(props); +}); + +story.add('Link Preview with long description', () => { + const props = createProps({ + previews: [ + { + domain: 'signal.org', + isStickerPack: false, + title: 'Signal', + description: Array(10) + .fill( + 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.' + ) + .join(' '), + url: 'https://www.signal.org', + date: Date.now(), + }, + ], + status: 'sent', + text: 'Be sure to look at https://www.signal.org', + }); + + return renderBothDirections(props); +}); + +story.add('Link Preview with small image, long description', () => { + const props = createProps({ + previews: [ + { + domain: 'signal.org', + image: { + contentType: IMAGE_PNG, + fileName: 'the-sax.png', + height: 50, + url: pngUrl, + width: 50, + }, + isStickerPack: false, + title: 'Signal', + description: Array(10) + .fill( + 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.' + ) + .join(' '), + url: 'https://www.signal.org', + date: Date.now(), + }, + ], + status: 'sent', + text: 'Be sure to look at https://www.signal.org', + }); + + return renderBothDirections(props); +}); + +story.add('Link Preview with no date', () => { + const props = createProps({ + previews: [ + { + domain: 'signal.org', + image: { + contentType: IMAGE_PNG, + fileName: 'the-sax.png', + height: 240, + url: pngUrl, + width: 320, + }, + isStickerPack: false, + title: 'Signal', + description: + 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', + url: 'https://www.signal.org', + }, + ], + status: 'sent', + text: 'Be sure to look at https://www.signal.org', + }); + + return renderBothDirections(props); +}); + +story.add('Link Preview with too old a date', () => { + const props = createProps({ + previews: [ + { + domain: 'signal.org', + image: { + contentType: IMAGE_PNG, + fileName: 'the-sax.png', + height: 240, + url: pngUrl, + width: 320, + }, + isStickerPack: false, + title: 'Signal', + description: + 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', + url: 'https://www.signal.org', + date: 123, + }, + ], + status: 'sent', + text: 'Be sure to look at https://www.signal.org', + }); + + return renderBothDirections(props); +}); + +story.add('Link Preview with too new a date', () => { + const props = createProps({ + previews: [ + { + domain: 'signal.org', + image: { + contentType: IMAGE_PNG, + fileName: 'the-sax.png', + height: 240, + url: pngUrl, + width: 320, + }, + isStickerPack: false, + title: 'Signal', + description: + 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', + url: 'https://www.signal.org', + date: Date.now() + 3000000000, }, ], status: 'sent', diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 05ea743e91..ae7dfcd75a 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import Measure from 'react-measure'; import { drop, groupBy, orderBy, take } from 'lodash'; import { Manager, Popper, Reference } from 'react-popper'; +import moment, { Moment } from 'moment'; import { Avatar } from '../Avatar'; import { Spinner } from '../Spinner'; @@ -50,15 +51,19 @@ interface Trigger { // Same as MIN_WIDTH in ImageGrid.tsx const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; +const MINIMUM_LINK_PREVIEW_DATE = new Date(1990, 0, 1).valueOf(); const STICKER_SIZE = 200; const SELECTED_TIMEOUT = 1000; +const ONE_DAY = 24 * 60 * 60 * 1000; interface LinkPreviewType { title: string; + description?: string; domain: string; url: string; isStickerPack: boolean; image?: AttachmentType; + date?: number; } export const MessageStatuses = [ @@ -791,6 +796,15 @@ export class Message extends React.PureComponent { width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH; + // Don't show old dates or dates too far in the future. This is predicated on the + // idea that showing an invalid dates is worse than hiding valid ones. + const maximumLinkPreviewDate = Date.now() + ONE_DAY; + const isDateValid: boolean = + typeof first.date === 'number' && + first.date > MINIMUM_LINK_PREVIEW_DATE && + first.date < maximumLinkPreviewDate; + const dateMoment: Moment | null = isDateValid ? moment(first.date) : null; + return (