diff --git a/package.json b/package.json index fcc31a9a5..8a2182f79 100644 --- a/package.json +++ b/package.json @@ -162,11 +162,13 @@ "remark": "^9.0.0", "remark-emoji": "^2.0.1", "remark-react": "^4.0.3", + "remark-squeeze-paragraphs": "^3.0.3", "render-media": "^3.1.0", "reselect": "^3.0.0", "sass-loader": "^7.1.0", "semver": "^5.3.0", "stream-to-blob-url": "^2.1.1", + "strip-markdown": "^3.0.3", "style-loader": "^0.23.1", "terser-webpack-plugin": "^1.2.3", "three": "^0.93.0", diff --git a/src/ui/component/channelLink/view.jsx b/src/ui/component/channelLink/view.jsx index 553e92855..3fe4421a3 100644 --- a/src/ui/component/channelLink/view.jsx +++ b/src/ui/component/channelLink/view.jsx @@ -1,8 +1,8 @@ // @flow import * as React from 'react'; import { parseURI } from 'lbry-redux'; -import ToolTip from 'react-portal-tooltip'; import Button from 'component/button'; +import ChannelTooltip from 'component/common/channel-tooltip'; type Props = { uri: string, @@ -20,48 +20,10 @@ type Props = { }>, }; -type TooltipProps = { - uri: string, - style: Object, - title: ?string, - active: ?boolean, - parent: ?HTMLElement, - claimId: ?string, - thumbnail: ?string, - claimName: ?string, - channelName: ?string, - description: ?string, -}; - type State = { isTooltipActive: boolean, }; -const ChannelTooltip = (props: TooltipProps) => { - const { style, title, active, parent, claimId, thumbnail, claimName, channelName, description } = props; - - return ( - - - - - - {title || channelName} - - {claimName} - {claimId && `#${claimId}`} - - - - - {description} - - - - - ); -}; - class ChannelLink extends React.Component { buttonRef: { current: ?any }; @@ -102,15 +64,15 @@ class ChannelLink extends React.Component { } componentDidMount() { - const { isResolvingUri, resolveUri, uri } = this.props; - if (!isResolvingUri) { + const { isResolvingUri, resolveUri, claim, uri } = this.props; + if (!isResolvingUri && uri && !claim) { resolveUri(uri); } } componentDidUpdate() { const { isResolvingUri, resolveUri, claim, uri } = this.props; - if (!isResolvingUri && uri && claim === undefined) { + if (!isResolvingUri && uri && !claim) { resolveUri(uri); } } @@ -121,12 +83,6 @@ class ChannelLink extends React.Component { const blackListed = this.isClaimBlackListed(); const isReady = !blackListed && !isResolvingUri && claim !== null; const tooltipReady = this.buttonRef.current !== null; - const bgColor = '#32373b'; - - const tooltipStyle = { - style: { background: bgColor }, - arrowStyle: { color: bgColor }, - }; return ( @@ -141,7 +97,6 @@ class ChannelLink extends React.Component { {tooltipReady && ( { + return { + uri: props.uri, + claim: makeSelectClaimForUri(props.uri)(state), + title: makeSelectTitleForUri(props.uri)(state), + cover: makeSelectCoverForUri(props.uri)(state), + thumbnail: makeSelectThumbnailForUri(props.uri)(state), + description: makeSelectMetadataItemForUri(props.uri, 'description')(state), + channelIsMine: makeSelectClaimIsMine(props.uri)(state), + isResolvingUri: makeSelectIsUriResolving(props.uri)(state), + blackListedOutpoints: selectBlackListedOutpoints(state), + }; +}; + +const perform = dispatch => ({ + resolveUri: uri => dispatch(doResolveUri(uri)), +}); + +export default connect( + select, + perform +)(ClaimLink); diff --git a/src/ui/component/claimLink/view.jsx b/src/ui/component/claimLink/view.jsx new file mode 100644 index 000000000..42041392d --- /dev/null +++ b/src/ui/component/claimLink/view.jsx @@ -0,0 +1,78 @@ +// @flow +import * as React from 'react'; +import { parseURI } from 'lbry-redux'; +import Button from 'component/button'; +import PreviewLink from 'component/common/preview-link'; + +type Props = { + uri: string, + title: ?string, + cover: ?string, + claim: StreamClaim, + children: React.Node, + thumbnail: ?string, + autoEmbed: ?boolean, + description: ?string, + isResolvingUri: boolean, + resolveUri: string => void, + blackListedOutpoints: Array<{ + txid: string, + nout: number, + }>, +}; + +class ClaimLink extends React.Component { + static defaultProps = { + href: null, + title: null, + }; + + isClaimBlackListed() { + const { claim, blackListedOutpoints } = this.props; + + if (claim && blackListedOutpoints) { + let blackListed = false; + + for (let i = 0; i < blackListedOutpoints.length; i += 1) { + const outpoint = blackListedOutpoints[i]; + if (outpoint.txid === claim.txid && outpoint.nout === claim.nout) { + blackListed = true; + break; + } + } + return blackListed; + } + } + + componentDidMount() { + const { isResolvingUri, resolveUri, uri, claim } = this.props; + if (!isResolvingUri && !claim) { + resolveUri(uri); + } + } + + componentDidUpdate() { + const { isResolvingUri, resolveUri, claim, uri } = this.props; + if (!isResolvingUri && uri && !claim) { + resolveUri(uri); + } + } + + render() { + const { uri, claim, title, description, autoEmbed, thumbnail, children, isResolvingUri } = this.props; + const { claimName } = parseURI(uri); + const blackListed = this.isClaimBlackListed(); + const showPreview = autoEmbed && !blackListed && !isResolvingUri && claim !== null; + + return ( + + + {showPreview && ( + + )} + + ); + } +} + +export default ClaimLink; diff --git a/src/ui/component/common/channel-tooltip.jsx b/src/ui/component/common/channel-tooltip.jsx new file mode 100644 index 000000000..b025f8833 --- /dev/null +++ b/src/ui/component/common/channel-tooltip.jsx @@ -0,0 +1,49 @@ +// @flow +import * as React from 'react'; +import ToolTip from 'react-portal-tooltip'; + +type TooltipProps = { + uri: string, + title: ?string, + active: ?boolean, + parent: ?HTMLElement, + claimId: ?string, + thumbnail: ?string, + claimName: ?string, + channelName: ?string, + description: ?string, +}; + +const ChannelTooltip = (props: TooltipProps) => { + const { title, active, parent, claimId, thumbnail, claimName, channelName, description } = props; + + const bgColor = '#32373b'; + + const style = { + style: { background: bgColor }, + arrowStyle: { color: bgColor }, + }; + + return ( + + + + + + {title || channelName} + + {claimName} + {claimId && `#${claimId}`} + + + + + {description} + + + + + ); +}; + +export default ChannelTooltip; diff --git a/src/ui/component/common/markdown-preview-internal.jsx b/src/ui/component/common/markdown-preview-internal.jsx index 04701cc6d..f04a73e63 100644 --- a/src/ui/component/common/markdown-preview-internal.jsx +++ b/src/ui/component/common/markdown-preview-internal.jsx @@ -2,14 +2,14 @@ import * as React from 'react'; import remark from 'remark'; import remarkLBRY from 'util/remark-lbry'; +import remarkStrip from 'strip-markdown'; import remarkEmoji from 'remark-emoji'; import reactRenderer from 'remark-react'; import ExternalLink from 'component/externalLink'; import defaultSchema from 'hast-util-sanitize/lib/github.json'; -type MarkdownProps = { - content: ?string, - promptLinks?: boolean, +type SimpleTextProps = { + children?: React.Node, }; type SimpleLinkProps = { @@ -18,6 +18,16 @@ type SimpleLinkProps = { children?: React.Node, }; +type MarkdownProps = { + strip?: boolean, + content: ?string, + promptLinks?: boolean, +}; + +const SimpleText = (props: SimpleTextProps) => { + return {props.children}; +}; + const SimpleLink = (props: SimpleLinkProps) => { const { href, title, children } = props; return ( @@ -32,15 +42,37 @@ const schema = { ...defaultSchema }; // Extend sanitation schema to support lbry protocol schema.protocols.href.push('lbry'); +schema.attributes.a.push('data-preview'); const MarkdownPreview = (props: MarkdownProps) => { - const { content, promptLinks } = props; - const remarkOptions = { + const { content, strip, promptLinks } = props; + + const remarkOptions: Object = { sanitize: schema, + fragment: React.Fragment, remarkReactComponents: { a: promptLinks ? ExternalLink : SimpleLink, + // Workaraund of remarkOptions.Fragment + div: React.Fragment, }, }; + + // Strip all content and just render text + if (strip) { + // Remove new lines and extra space + remarkOptions.remarkReactComponents.p = SimpleText; + return ( + + { + remark() + .use(remarkStrip) + .use(reactRenderer, remarkOptions) + .processSync(content).contents + } + + ); + } + return ( { diff --git a/src/ui/component/common/preview-link.jsx b/src/ui/component/common/preview-link.jsx new file mode 100644 index 000000000..e80e9b329 --- /dev/null +++ b/src/ui/component/common/preview-link.jsx @@ -0,0 +1,47 @@ +// @flow +import * as React from 'react'; +import DateTime from 'component/dateTime'; +import UriIndicator from 'component/uriIndicator'; +import TruncatedText from 'component/common/truncated-text'; +import MarkdownPreview from 'component/common/markdown-preview'; + +type Props = { + uri: string, + title: ?string, + thumbnail: ?string, + description: ?string, +}; + +const PreviewLink = (props: Props) => { + const { uri, title, description, thumbnail } = props; + const placeholder = 'static/img/placeholder.png'; + + const thumbnailStyle = { + backgroundImage: `url(${thumbnail || placeholder})`, + }; + + return ( + + + + + + + + + + {__('Published to')} + + + + + + + + + + + ); +}; + +export default PreviewLink; diff --git a/src/ui/component/common/truncated-text.jsx b/src/ui/component/common/truncated-text.jsx index f90e387aa..71f59f7c6 100644 --- a/src/ui/component/common/truncated-text.jsx +++ b/src/ui/component/common/truncated-text.jsx @@ -2,14 +2,24 @@ import * as React from 'react'; type Props = { - text: ?string, + text?: ?string, lines: number, + showTooltip?: boolean, + children?: React.Node, }; -const TruncatedText = (props: Props) => ( - - {props.text} - -); +const TruncatedText = (props: Props) => { + const { text, children, lines, showTooltip } = props; + const tooltip = showTooltip ? children || text : ''; + return ( + + {children || text} + + ); +}; + +TruncatedText.defaultProps = { + showTooltip: true, +}; export default TruncatedText; diff --git a/src/ui/component/externalLink/view.jsx b/src/ui/component/externalLink/view.jsx index 24da65671..1a2642a25 100644 --- a/src/ui/component/externalLink/view.jsx +++ b/src/ui/component/externalLink/view.jsx @@ -2,8 +2,9 @@ import * as MODALS from 'constants/modal_types'; import * as ICONS from 'constants/icons'; import * as React from 'react'; -import { isURIValid } from 'lbry-redux'; +import { isURIValid, parseURI } from 'lbry-redux'; import Button from 'component/button'; +import ClaimLink from 'component/claimLink'; import ChannelLink from 'component/channelLink'; type Props = { @@ -11,6 +12,7 @@ type Props = { title?: string, children: React.Node, openModal: (id: string, { uri: string }) => void, + 'data-preview'?: boolean, }; class ExternalLink extends React.PureComponent { @@ -41,7 +43,20 @@ class ExternalLink extends React.PureComponent { } // Return local link if protocol is lbry uri if (protocol && protocol[0] === 'lbry:' && isURIValid(href)) { - element = {children}; + try { + const uri = parseURI(href); + if (uri.isChannel && !uri.path) { + element = {children}; + } else if (uri) { + element = ( + + {children} + + ); + } + } catch (err) { + // Silent error: console.error(err); + } } return element; diff --git a/src/ui/scss/component/_markdown-preview.scss b/src/ui/scss/component/_markdown-preview.scss index f61671487..9d1462294 100644 --- a/src/ui/scss/component/_markdown-preview.scss +++ b/src/ui/scss/component/_markdown-preview.scss @@ -116,4 +116,19 @@ white-space: normal; text-align: left; } + + .preview-link { + margin: var(--spacing-vertical-medium) 0; + padding: 1.2rem 0.8rem; + background-color: rgba($lbry-teal-5, 0.1); + border-left: 0.5rem solid $lbry-teal-5; + display: block; + align-items: center; + width: 40rem; + } + + .preview-link--description { + display: block; + margin: var(--spacing-vertical-medium) 0; + } } diff --git a/src/ui/util/remark-lbry.js b/src/ui/util/remark-lbry.js index de1600fe8..8411b14a8 100644 --- a/src/ui/util/remark-lbry.js +++ b/src/ui/util/remark-lbry.js @@ -5,9 +5,10 @@ const locateURI = (value, fromIndex) => value.indexOf(protocol, fromIndex); const locateMention = (value, fromIndex) => value.indexOf('@', fromIndex); // Generate a valid markdown link -const createURI = (text, uri) => ({ +const createURI = (text, uri, autoEmbed = false) => ({ type: 'link', url: (uri.startsWith(protocol) ? '' : protocol) + uri, + data: { hProperties: { dataPreview: autoEmbed } }, children: [{ type: 'text', value: text }], }); @@ -18,10 +19,10 @@ const validateURI = (match, eat) => { const uri = parseURI(text); // Create channel link if (uri.isChannel && !uri.path) { - return eat(text)(createURI(uri.claimName, text)); + return eat(text)(createURI(uri.claimName, text, false)); } // Create uri link - return eat(text)(createURI(text, text)); + return eat(text)(createURI(text, text, true)); } catch (err) { // Silent errors: console.error(err) } diff --git a/yarn.lock b/yarn.lock index ab8b5a61d..4b76b50e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7063,6 +7063,13 @@ md5@^2.2.1: crypt "~0.0.1" is-buffer "~1.1.1" +mdast-squeeze-paragraphs@^3.0.0: + version "3.0.5" + resolved "https://registry.yarnpkg.com/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-3.0.5.tgz#f428b6b944f8faef454db9b58f170c4183cb2e61" + integrity sha512-xX6Vbe348Y/rukQlG4W3xH+7v4ZlzUbSY4HUIQCuYrF2DrkcHx584mCaFxkWoDZKNUfyLZItHC9VAqX3kIP7XA== + dependencies: + unist-util-remove "^1.0.0" + mdast-util-compact@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.2.tgz#c12ebe16fffc84573d3e19767726de226e95f649" @@ -9808,6 +9815,13 @@ remark-react@^4.0.3: hast-util-sanitize "^1.0.0" mdast-util-to-hast "^3.0.0" +remark-squeeze-paragraphs@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-3.0.3.tgz#299d8db7d44008c9ae240dbf6d1f55b8b0f924ce" + integrity sha512-eDvjtwFa9eClqb7XgdF/1H9Pfs2LPnf/P3eRs9ucYAWUuv4WO8ZOVAUeT/1h66rQvghnfctz9au+HEmoKcdoqA== + dependencies: + mdast-squeeze-paragraphs "^3.0.0" + remark-stringify@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-5.0.0.tgz#336d3a4d4a6a3390d933eeba62e8de4bd280afba" @@ -10840,6 +10854,11 @@ strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +strip-markdown@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/strip-markdown/-/strip-markdown-3.0.3.tgz#93b4526abe32a1d69e5ca943f4d9ba25c01a4d48" + integrity sha512-G2DSM9wy3PWxY3miAibWpsTqZgXLXgRoq0yVyaVs9O7FDGEwPO6pmSE8CyzHhU88Z2w1dkFqFmWUklFNsQKwqg== + style-loader@^0.23.1: version "0.23.1" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.23.1.tgz#cb9154606f3e771ab6c4ab637026a1049174d925" @@ -11468,6 +11487,11 @@ unist-util-is@^2.0.0, unist-util-is@^2.1.2: resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-2.1.2.tgz#1193fa8f2bfbbb82150633f3a8d2eb9a1c1d55db" integrity sha512-YkXBK/H9raAmG7KXck+UUpnKiNmUdB+aBGrknfQ4EreE1banuzrKABx3jP6Z5Z3fMSPMQQmeXBlKpCbMwBkxVw== +unist-util-is@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-3.0.0.tgz#d9e84381c2468e82629e4a5be9d7d05a2dd324cd" + integrity sha512-sVZZX3+kspVNmLWBPAB6r+7D9ZgAFPNWm66f7YNb420RlQSbn+n8rG8dGZSkrER7ZIXGQYNm5pqC3v3HopH24A== + unist-util-position@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-3.0.2.tgz#80ad4a05efc4ab01a66886cc70493893ba73c5eb" @@ -11480,6 +11504,13 @@ unist-util-remove-position@^1.0.0: dependencies: unist-util-visit "^1.1.0" +unist-util-remove@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/unist-util-remove/-/unist-util-remove-1.0.3.tgz#58ec193dfa84b52d5a055ffbc58e5444eb8031a3" + integrity sha512-mB6nCHCQK0pQffUAcCVmKgIWzG/AXs/V8qpS8K72tMPtOSCMSjDeMc5yN+Ye8rB0FhcE+JvW++o1xRNc0R+++g== + dependencies: + unist-util-is "^3.0.0" + unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6"
{description}