lbry-desktop/ui/component/common/markdown-preview.jsx
ktprograms fc32339d2d
Fix lbry:// URLs in iframes being broken by Safari (#1812)
When Safari parses lbry:// URLs, it breaks them as they are not
compliant with the RFC 3986 URI syntax. This causes iframes in text
posts which link to a lbry:// URL to not display on Safari.

Instead, just use the lbry:// URL matched from the iframe regex instead
of parsing the iframe on Safari.
2022-07-07 10:39:10 -04:00

283 lines
8.2 KiB
JavaScript

// @flow
import { CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS, MISSING_THUMB_DEFAULT } from 'config';
import { platform } from 'util/platform';
import { formattedEmote, inlineEmote } from 'util/remark-emote';
import { formattedLinks, inlineLinks } from 'util/remark-lbry';
import { formattedTimestamp, inlineTimestamp } from 'util/remark-timestamp';
import { getThumbnailCdnUrl, getImageProxyUrl } from 'util/thumbnail';
import * as ICONS from 'constants/icons';
import * as React from 'react';
import Button from 'component/button';
import classnames from 'classnames';
import defaultSchema from 'hast-util-sanitize/lib/github.json';
import MarkdownLink from 'component/markdownLink';
import OptimizedImage from 'component/optimizedImage';
import reactRenderer from 'remark-react';
import remark from 'remark';
import remarkAttr from 'remark-attr';
import remarkBreaks from 'remark-breaks';
import remarkEmoji from 'remark-emoji';
import remarkFrontMatter from 'remark-frontmatter';
import remarkStrip from 'strip-markdown';
import ZoomableImage from 'component/zoomableImage';
const RE_EMOTE = /:\+1:|:-1:|:[\w-]+:/;
function isEmote(title, src) {
return title && RE_EMOTE.test(title) && src.includes('static.odycdn.com/emoticons');
}
function isStakeEnoughForPreview(stakedLevel) {
return !stakedLevel || stakedLevel >= CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS;
}
type SimpleTextProps = {
children?: React.Node,
};
type SimpleLinkProps = {
href?: string,
title?: string,
embed?: boolean,
children?: React.Node,
};
type ImageLinkProps = {
src: string,
title?: string,
alt?: string,
helpText?: string,
};
type MarkdownProps = {
strip?: boolean,
content: ?string,
simpleLinks?: boolean,
noDataStore?: boolean,
className?: string,
parentCommentId?: string,
isMarkdownPost?: boolean,
disableTimestamps?: boolean,
stakedLevel?: number,
setUserMention?: (boolean) => void,
hasMembership?: boolean,
};
// ****************************************************************************
// ****************************************************************************
const SimpleText = (props: SimpleTextProps) => {
return <span>{props.children}</span>;
};
// ****************************************************************************
// ****************************************************************************
const SimpleLink = (props: SimpleLinkProps) => {
const { title, children, href, embed } = props;
if (!href) {
return children || null;
}
if (!href.startsWith('lbry:/')) {
return (
<a href={href} title={title} target={'_blank'} rel={'noreferrer noopener'}>
{children}
</a>
);
}
const [uri, search] = href.split('?');
const urlParams = new URLSearchParams(search);
const embedParam = urlParams.get('embed');
if (embed || embedParam) {
// Decode this since users might just copy it from the url bar
const decodedUri = decodeURI(uri);
return (
<div className="embed__inline-button embed__inline-button--preview">
<pre>{decodedUri}</pre>
</div>
);
}
// Dummy link (no 'href')
return <a title={title}>{children}</a>;
};
// ****************************************************************************
// ****************************************************************************
const SimpleImageLink = (props: ImageLinkProps) => {
const { src, title, alt, helpText } = props;
if (!src) {
return null;
}
if (isEmote(title, src)) {
return <OptimizedImage src={src} title={title} className="emote" waitLoad loading="lazy" />;
}
return (
<Button
button="link"
iconRight={ICONS.EXTERNAL}
label={title || alt || src}
title={helpText || title || alt || src}
className="button--external-link"
href={src}
/>
);
};
// ****************************************************************************
// ****************************************************************************
// Use github sanitation schema
const schema = { ...defaultSchema };
// Extend sanitation schema to support lbry protocol
schema.protocols.href.push('lbry');
schema.attributes.a.push('embed');
const REPLACE_REGEX = /(?:<iframe\s+src=["'])(.*?(?=))(?:["']\s*><\/iframe>)/g;
// ****************************************************************************
// ****************************************************************************
export default React.memo<MarkdownProps>(function MarkdownPreview(props: MarkdownProps) {
const {
content,
strip,
simpleLinks,
noDataStore,
className,
parentCommentId,
isMarkdownPost,
disableTimestamps,
stakedLevel,
setUserMention,
hasMembership,
} = props;
const strippedContent = content
? content.replace(REPLACE_REGEX, (iframeHtml, iframeUrl) => {
if (platform.isSafari()) {
return iframeUrl;
}
// Let the browser try to create an iframe to see if the markup is valid
const outer = document.createElement('div');
outer.innerHTML = iframeHtml;
const iframe = ((outer.querySelector('iframe'): any): ?HTMLIFrameElement);
if (iframe) {
const src = iframe.src;
if (src && src.startsWith('lbry://')) {
return src;
}
}
return iframeHtml;
})
: '';
const remarkOptions: Object = {
sanitize: schema,
fragment: React.Fragment,
remarkReactComponents: {
a: noDataStore
? SimpleLink
: (linkProps) => (
<MarkdownLink
{...linkProps}
parentCommentId={parentCommentId}
isMarkdownPost={isMarkdownPost}
simpleLinks={simpleLinks}
allowPreview={isStakeEnoughForPreview(stakedLevel) || hasMembership}
setUserMention={setUserMention}
/>
),
// Workaraund of remarkOptions.Fragment
div: React.Fragment,
img: (imgProps) => {
const isGif = imgProps.src && imgProps.src.endsWith('gif');
const imageCdnUrl =
(isGif
? getImageProxyUrl(imgProps.src)
: getThumbnailCdnUrl({ thumbnail: imgProps.src, width: 0, height: 0, quality: 85 })) ||
MISSING_THUMB_DEFAULT;
if (noDataStore) {
return (
<div className="file-viewer file-viewer--document">
<img {...imgProps} src={imageCdnUrl} />
</div>
);
} else if ((isStakeEnoughForPreview(stakedLevel) || hasMembership) && !isEmote(imgProps.title, imgProps.src)) {
return <ZoomableImage {...imgProps} src={imageCdnUrl} />;
} else {
return (
<SimpleImageLink
src={imageCdnUrl}
alt={imgProps.alt}
title={imgProps.title}
helpText={__('Odysee Premium required to enable image previews')}
/>
);
}
},
},
};
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 dir="auto" className="markdown-preview">
{
remark()
.use(remarkStrip)
.use(remarkFrontMatter, ['yaml'])
.use(reactRenderer, remarkOptions)
.processSync(content).contents
}
</span>
);
}
return (
<div dir="auto" className={classnames('notranslate markdown-preview', className)}>
{
remark()
.use(remarkAttr, remarkAttrOpts)
// Remark plugins for lbry urls
// Note: The order is important
.use(formattedLinks)
.use(inlineLinks)
.use(disableTimestamps || isMarkdownPost ? null : inlineTimestamp)
.use(disableTimestamps || isMarkdownPost ? null : formattedTimestamp)
// Emojis
.use(inlineEmote)
.use(formattedEmote)
.use(remarkEmoji)
// Render new lines without needing spaces.
.use(remarkBreaks)
.use(remarkFrontMatter, ['yaml'])
.use(reactRenderer, remarkOptions)
.processSync(strippedContent).contents
}
</div>
);
});