Merge pull request #2521 from lbryio/smart-links

Extend markdown support for LBRY urls
This commit is contained in:
Sean Yesmunt 2019-06-26 01:23:38 -04:00 committed by GitHub
commit 081d86097b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 465 additions and 25 deletions

View file

@ -65,6 +65,7 @@
"@lbry/components": "^2.7.2",
"@reach/rect": "^0.2.1",
"@reach/tabs": "^0.1.5",
"@reach/tooltip": "^0.2.1",
"@types/three": "^0.93.1",
"async-exit-hook": "^2.0.1",
"babel-eslint": "^10.0.1",
@ -163,6 +164,7 @@
"redux-persist-transform-filter": "0.0.16",
"redux-thunk": "^2.2.0",
"remark": "^9.0.0",
"remark-attr": "^0.8.3",
"remark-emoji": "^2.0.1",
"remark-react": "^4.0.3",
"render-media": "^3.1.0",
@ -170,11 +172,13 @@
"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",
"three-full": "^17.1.0",
"tree-kill": "^1.1.0",
"unist-util-visit": "^1.4.1",
"video.js": "^7.2.2",
"villain": "btzr-io/Villain",
"wavesurfer.js": "^2.2.1",

View file

@ -7,7 +7,7 @@ import { formatLbryUriForWeb } from 'util/uri';
import { OutboundLink } from 'react-ga';
type Props = {
onClick: ?(any) => any,
id: ?string,
href: ?string,
title: ?string,
label: ?string,
@ -24,6 +24,11 @@ type Props = {
iconSize?: number,
constrict: ?boolean, // to shorten the button and ellipsis, only use for links
activeClass?: string,
innerRef: ?any,
// Events
onClick: ?(any) => any,
onMouseEnter: ?(any) => any,
onMouseLeave: ?(any) => any,
};
class Button extends React.PureComponent<Props> {
@ -33,7 +38,11 @@ class Button extends React.PureComponent<Props> {
render() {
const {
id,
onClick,
onMouseEnter,
onMouseLeave,
innerRef,
href,
title,
label,
@ -102,6 +111,7 @@ class Button extends React.PureComponent<Props> {
return path ? (
<NavLink
id={id}
exact
to={path}
title={title}
@ -112,13 +122,17 @@ class Button extends React.PureComponent<Props> {
onClick();
}
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className={combinedClassName}
activeClassName={activeClass}
innerRef={innerRef}
>
{content}
</NavLink>
) : (
<button
id={id}
title={title}
aria-label={description || label || title}
className={combinedClassName}

View file

@ -0,0 +1,26 @@
import { connect } from 'react-redux';
import { doResolveUri, makeSelectTitleForUri, makeSelectClaimForUri, makeSelectIsUriResolving } from 'lbry-redux';
import { selectBlackListedOutpoints } from 'lbryinc';
import ClaimLink from './view';
const select = (state, props) => {
return {
uri: props.uri,
claim: makeSelectClaimForUri(props.uri)(state),
title: makeSelectTitleForUri(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);

View file

@ -0,0 +1,87 @@
// @flow
import * as React from 'react';
import Button from 'component/button';
import PreviewLink from 'component/previewLink';
type Props = {
uri: string,
title: ?string,
claim: StreamClaim,
children: React.Node,
className: ?string,
autoEmbed: ?boolean,
description: ?string,
isResolvingUri: boolean,
resolveUri: string => void,
blackListedOutpoints: Array<{
txid: string,
nout: number,
}>,
};
class ClaimLink extends React.Component<Props> {
static defaultProps = {
href: null,
link: false,
title: null,
thumbnail: null,
autoEmbed: false,
description: null,
isResolvingUri: false,
};
componentDidMount() {
this.resolve(this.props);
}
componentDidUpdate() {
this.resolve(this.props);
}
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;
}
}
resolve = (props: Props) => {
const { isResolvingUri, resolveUri, claim, uri } = props;
if (!isResolvingUri && claim === undefined && uri) {
resolveUri(uri);
}
};
render() {
const { uri, claim, title, className, autoEmbed, children, isResolvingUri } = this.props;
const isUnresolved = (!isResolvingUri && !claim) || !claim;
const isBlacklisted = this.isClaimBlackListed();
if (isBlacklisted || isUnresolved) {
return <span>{children}</span>;
}
const { name: claimName } = claim;
const showPreview = autoEmbed === true && !isUnresolved;
return (
<React.Fragment>
<Button label={children} title={title || claimName} button={'link'} navigate={uri} className={className} />
{showPreview && <PreviewLink uri={uri} />}
</React.Fragment>
);
}
}
export default ClaimLink;

View file

@ -2,11 +2,12 @@
import type { ElementRef } from 'react';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import 'easymde/dist/easymde.min.css';
import Toggle from 'react-toggle';
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
import SimpleMDE from 'react-simplemde-editor';
import MarkdownPreview from 'component/common/markdown-preview-internal';
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
import 'easymde/dist/easymde.min.css';
type Props = {
name: string,

View file

@ -1,14 +1,16 @@
// @flow
import * as React from 'react';
import remark from 'remark';
import reactRenderer from 'remark-react';
import remarkAttr from 'remark-attr';
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';
import { formatedLinks, inlineLinks } from 'util/remark-lbry';
type MarkdownProps = {
content: ?string,
promptLinks?: boolean,
type SimpleTextProps = {
children?: React.Node,
};
type SimpleLinkProps = {
@ -17,6 +19,16 @@ type SimpleLinkProps = {
children?: React.Node,
};
type MarkdownProps = {
strip?: boolean,
content: ?string,
promptLinks?: boolean,
};
const SimpleText = (props: SimpleTextProps) => {
return <span>{props.children}</span>;
};
const SimpleLink = (props: SimpleLinkProps) => {
const { href, title, children } = props;
return (
@ -31,19 +43,54 @@ const schema = { ...defaultSchema };
// Extend sanitation schema to support lbry protocol
schema.protocols.href.push('lbry');
schema.attributes.a.push('embed');
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,
},
};
const remarkAttrOpts = {
scope: 'extended',
elements: ['link'],
extend: { link: ['embed'] },
defaultValue: true,
};
// Strip all content and just render text
if (strip) {
// Remove new lines and extra space
remarkOptions.remarkReactComponents.p = SimpleText;
return (
<span className="markdown-preview">
{
remark()
.use(remarkStrip)
.use(reactRenderer, remarkOptions)
.processSync(content).contents
}
</span>
);
}
return (
<div className="markdown-preview">
{
remark()
.use(remarkAttr, remarkAttrOpts)
// Remark plugins for lbry urls
// Note: The order is important
.use(formatedLinks)
.use(inlineLinks)
// Emojis
.use(remarkEmoji)
.use(reactRenderer, remarkOptions)
.processSync(content).contents

View file

@ -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) => (
<span title={props.text} className="truncated-text" style={{ WebkitLineClamp: props.lines }}>
{props.text}
</span>
);
const TruncatedText = (props: Props) => {
const { text, children, lines, showTooltip } = props;
const tooltip = showTooltip ? children || text : '';
return (
<span title={tooltip} className="truncated-text" style={{ WebkitLineClamp: lines }}>
{children || text}
</span>
);
};
TruncatedText.defaultProps = {
showTooltip: true,
};
export default TruncatedText;

View file

@ -4,10 +4,12 @@ import * as ICONS from 'constants/icons';
import * as React from 'react';
import { isURIValid } from 'lbry-redux';
import Button from 'component/button';
import ClaimLink from 'component/claimLink';
type Props = {
href: string,
title?: string,
embed?: boolean,
children: React.Node,
openModal: (id: string, { uri: string }) => void,
};
@ -16,18 +18,16 @@ class ExternalLink extends React.PureComponent<Props> {
static defaultProps = {
href: null,
title: null,
embed: false,
};
createLink() {
const { href, title, children, openModal } = this.props;
const { href, title, embed, children, openModal } = this.props;
// Regex for url protocol
const protocolRegex = new RegExp('^(https?|lbry|mailto)+:', 'i');
const protocol = href ? protocolRegex.exec(href) : null;
// Return plain text if no valid url
let element = <span>{children}</span>;
// Return external link if protocol is http or https
if (protocol && (protocol[0] === 'http:' || protocol[0] === 'https:' || protocol[0] === 'mailto:')) {
element = (
@ -41,10 +41,13 @@ class ExternalLink extends React.PureComponent<Props> {
/>
);
}
// Return local link if protocol is lbry uri
if (protocol && protocol[0] === 'lbry:' && isURIValid(href)) {
element = <Button button="link" title={title || href} label={children} navigate={href} />;
element = (
<ClaimLink uri={href} autoEmbed={embed}>
{children}
</ClaimLink>
);
}
return element;

View file

@ -0,0 +1,37 @@
import { connect } from 'react-redux';
import {
doResolveUri,
makeSelectClaimIsMine,
makeSelectTitleForUri,
makeSelectThumbnailForUri,
makeSelectClaimForUri,
makeSelectIsUriResolving,
makeSelectMetadataItemForUri,
} from 'lbry-redux';
import { selectBlackListedOutpoints } from 'lbryinc';
import PreviewLink from './view';
const select = (state, props) => {
return {
uri: props.uri,
claim: makeSelectClaimForUri(props.uri)(state),
title: makeSelectTitleForUri(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
)(PreviewLink);

View file

@ -0,0 +1,58 @@
// @flow
import * as React from 'react';
import UriIndicator from 'component/uriIndicator';
import TruncatedText from 'component/common/truncated-text';
import MarkdownPreview from 'component/common/markdown-preview';
import { withRouter } from 'react-router-dom';
import { formatLbryUriForWeb } from 'util/uri';
type Props = {
uri: string,
title: ?string,
thumbnail: ?string,
description: ?string,
history: { push: string => void },
};
class PreviewLink extends React.PureComponent<Props> {
handleClick = () => {
const { uri, history } = this.props;
history.push(formatLbryUriForWeb(uri));
};
render() {
const { uri, title, description, thumbnail } = this.props;
const placeholder = 'static/img/placeholder.png';
const thumbnailStyle = {
backgroundImage: `url(${thumbnail || placeholder})`,
};
return (
<span className={'preview-link'} role="button" onClick={this.handleClick}>
<span className={'file-list__item'}>
<span style={thumbnailStyle} className={'preview-link__thumbnail media__thumb'} />
<span className={'file-list__item-metadata'}>
<span className={'file-list__item-info'}>
<span className={'file-list__item-title'}>
<TruncatedText text={title} lines={1} />
</span>
</span>
<span className={'preview-link__description media__subtext'}>
<UriIndicator uri={uri} link />
</span>
<span className={'file-list__item-properties'}>
<span className={'preview-link__description media__subtext'}>
<TruncatedText lines={2} showTooltip={false}>
<MarkdownPreview content={description} promptLinks strip />
</TruncatedText>
</span>
</span>
</span>
</span>
</span>
);
}
}
export default withRouter(PreviewLink);

View file

@ -34,8 +34,8 @@
}
.editor-preview.editor-preview-active {
background-color: $lbry-gray-5;
color: $lbry-black;
background-color: $lbry-black;
color: $lbry-white;
}
}
}

View file

@ -120,4 +120,23 @@
white-space: normal;
text-align: left;
}
.preview-link {
padding: 0;
margin: 1rem 0;
background-color: rgba($lbry-teal-5, 0.1);
border-left: 0.5rem solid $lbry-teal-5;
display: block;
align-items: center;
width: 40rem;
word-break: break-all;
}
.preview-link__thumbnail {
width: 12rem;
}
.preview-link__description {
display: block;
}
}

111
src/ui/util/remark-lbry.js Normal file
View file

@ -0,0 +1,111 @@
import { parseURI } from 'lbry-redux';
import visit from 'unist-util-visit';
const protocol = 'lbry://';
const locateURI = (value, fromIndex) => value.indexOf(protocol, fromIndex);
const locateMention = (value, fromIndex) => value.indexOf('@', fromIndex);
// Generate a valid markdown link
const createURI = (text, uri, embed = false) => ({
type: 'link',
url: (uri.startsWith(protocol) ? '' : protocol) + uri,
data: {
// Custom attribute
hProperties: { embed },
},
children: [{ type: 'text', value: text }],
});
const validateURI = (match, eat) => {
if (match) {
try {
const text = match[0];
const uri = parseURI(text);
const isValid = uri && uri.claimName;
const isChannel = uri.isChannel && !uri.path;
if (isValid) {
// Create channel link
if (isChannel) {
return eat(text)(createURI(uri.claimName, text, false));
}
// Create claim link
return eat(text)(createURI(text, text, true));
}
} catch (err) {
// Silent errors: console.error(err)
}
}
};
// Generate a markdown link from channel name
function tokenizeMention(eat, value, silent) {
const match = /^@+[a-zA-Z0-9-#:/]+/.exec(value);
return validateURI(match, eat);
}
// Generate a markdown link from lbry url
function tokenizeURI(eat, value, silent) {
const match = /^(lbry:\/\/)+[a-zA-Z0-9-@#:/]+/.exec(value);
return validateURI(match, eat);
}
// Configure tokenizer for lbry urls
tokenizeURI.locator = locateURI;
tokenizeURI.notInList = true;
tokenizeURI.notInLink = true;
tokenizeURI.notInBlock = true;
// Configure tokenizer for lbry channels
tokenizeMention.locator = locateMention;
tokenizeMention.notInList = true;
tokenizeMention.notInLink = true;
tokenizeMention.notInBlock = true;
const visitor = (node, index, parent) => {
if (node.type === 'link' && parent && parent.type === 'paragraph') {
try {
const uri = parseURI(node.url);
const isValid = uri && uri.claimName;
const isChannel = uri.isChannel && !uri.path;
if (isValid && !isChannel) {
if (!node.data || !node.data.hProperties) {
// Create new node data
node.data = {
hProperties: { embed: true },
};
} else if (node.data.hProperties) {
// Don't overwrite current attributes
node.data.hProperties = {
embed: true,
...node.data.hProperties,
};
}
}
} catch (err) {
// Silent errors: console.error(err)
}
}
};
// transform
const transform = tree => {
visit(tree, ['link'], visitor);
};
export const formatedLinks = () => transform;
// Main module
export function inlineLinks() {
const Parser = this.Parser;
const tokenizers = Parser.prototype.inlineTokenizers;
const methods = Parser.prototype.inlineMethods;
// Add an inline tokenizer (defined in the following example).
tokenizers.uri = tokenizeURI;
tokenizers.mention = tokenizeMention;
// Run it just before `text`.
methods.splice(methods.indexOf('text'), 0, 'uri');
methods.splice(methods.indexOf('text'), 0, 'mention');
}

View file

@ -993,7 +993,7 @@
integrity sha512-JZshEuGsLvi6fUIJ7Unx12yNeM5SmqWjber2MLr9tfwf1hpNv73EiPBOIJyV0DjW7GXzjcOEvwnqysm59s2s/A==
dependencies:
"@reach/component-component" "^0.1.3"
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"
@ -5498,6 +5498,11 @@ html-comment-regex@^1.1.0:
resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7"
integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==
html-element-attributes@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/html-element-attributes/-/html-element-attributes-2.1.0.tgz#ff674b8716526b8a9ee1d8e454466eda56d6d0cc"
integrity sha512-uWNlZuzM3MfRvZLAHWUzwwU6He2NvX9szpXwjG7+hoVqZzY3e0vc6mlujiSnJ/i9zVFZDhBDN33Pm9HNEzcyPg==
html-entities@^1.2.0, html-entities@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f"
@ -7114,6 +7119,11 @@ math-expression-evaluator@^1.2.14:
resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac"
integrity sha1-3oGf282E3M2PrlnGrreWFbnSZqw=
md-attr-parser@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/md-attr-parser/-/md-attr-parser-1.2.1.tgz#1043f6451c33ed3e392b40504df08010f4d03e3d"
integrity sha512-dZqt2L4Q7FUcx6ZcuownAxa74Y7d5jcsHRB2MIgQ0vT10Pa+/0Som6hhJ+jgAjP3vnFtrd4aO+ZMc5K7QVfbiQ==
md5.js@^1.3.4:
version "1.3.5"
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@ -9841,6 +9851,14 @@ relateurl@0.2.x:
resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
remark-attr@^0.8.3:
version "0.8.3"
resolved "https://registry.yarnpkg.com/remark-attr/-/remark-attr-0.8.3.tgz#0263045acb958e1556b0c6955b5d66a4b36d4d92"
integrity sha512-JMP6rmLwhj5VmDRG99kGr+HNXQ6MmAs+UojeAHGMQFiIitEmJZBkmgOBsWadxu/RTACD4XnMuJXY1QWEFdw31A==
dependencies:
html-element-attributes "^2.0.0"
md-attr-parser "^1.2.1"
remark-emoji@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/remark-emoji/-/remark-emoji-2.0.2.tgz#49c134021132c192ee4cceed1988ec9b8ced7eb8"
@ -10912,6 +10930,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"
@ -11564,7 +11587,7 @@ unist-util-visit-parents@^2.0.0:
dependencies:
unist-util-is "^2.1.2"
unist-util-visit@^1.0.0, unist-util-visit@^1.1.0, unist-util-visit@^1.3.0, unist-util-visit@^1.4.0:
unist-util-visit@^1.0.0, unist-util-visit@^1.1.0, unist-util-visit@^1.3.0, unist-util-visit@^1.4.0, unist-util-visit@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.1.tgz#4724aaa8486e6ee6e26d7ff3c8685960d560b1e3"
integrity sha512-AvGNk7Bb//EmJZyhtRUnNMEpId/AZ5Ph/KUpTI09WHQuDZHKovQ1oEv3mfmKpWKtoMzyMC4GLBm1Zy5k12fjIw==