2017-06-08_509bba0/509bba0_unpacked_with_node_.../discord_app/components/Embed.js

677 lines
19 KiB
JavaScript
Executable File

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Image from './common/Image';
import url from 'url';
import MarkupUtils from '../utils/MarkupUtils';
import moment from 'moment';
import classnames from 'classnames';
import {int2hex} from '../../discord_common/js/utils/ColorUtils';
import MaskedLink from '../components/common/MaskedLink';
import SnowflakeUtils from '../utils/SnowflakeUtils';
import './Embed.styl';
const VIDEO_PROVIDERS = /youtube|steam|imgur|vimeo|sketchfab|vine|soundcloud|streamable|twitch|vid\.me|twitter/i;
const VIDEO_PROVIDER_CHECK_UNIX_TIMESTAMP = 1492472454139;
const EmbedGIFV = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
url: React.PropTypes.string.isRequired,
thumbnail: React.PropTypes.shape({
url: React.PropTypes.string,
// eslint-disable-next-line camelcase
proxy_url: React.PropTypes.string,
width: React.PropTypes.width,
height: React.PropTypes.height,
}),
video: React.PropTypes.shape({
url: React.PropTypes.string,
width: React.PropTypes.width,
height: React.PropTypes.height,
}),
scale: React.PropTypes.bool,
},
render() {
const {url, scale, thumbnail, video, trusted} = this.props;
let {width, height} = thumbnail;
const maxWidth = scale ? 512 : width;
const maxHeight = scale ? 384 : height;
let widthRatio = 1;
if (width > maxWidth) {
widthRatio = maxWidth / width;
}
width = Math.round(width * widthRatio);
height = Math.round(height * widthRatio);
let heightRatio = 1;
if (height > maxHeight) {
heightRatio = maxHeight / height;
}
width = Math.round(width * heightRatio);
height = Math.round(height * heightRatio);
return (
<MaskedLink className="embed-thumbnail embed-thumbnail-gifv" href={url} trusted={trusted}>
<video
width={width}
height={height}
poster={thumbnail['proxy_url']}
src={video.url}
muted
loop
preload="none"
onMouseEnter={e => e.currentTarget.play()}
onMouseLeave={e => e.currentTarget.pause()}
/>
</MaskedLink>
);
},
});
const EmbedVideo = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
url: React.PropTypes.string.isRequired,
playable: React.PropTypes.bool,
thumbnail: React.PropTypes.shape({
url: React.PropTypes.string,
// eslint-disable-next-line camelcase
proxy_url: React.PropTypes.string,
width: React.PropTypes.width,
height: React.PropTypes.height,
}),
video: React.PropTypes.shape({
url: React.PropTypes.string,
width: React.PropTypes.width,
height: React.PropTypes.height,
}),
scale: React.PropTypes.bool,
useVideoSizeWhenPlaying: React.PropTypes.bool,
},
getInitialState() {
return {
play: false,
};
},
getDefaultProps() {
return {
scale: true,
useVideoSizeWhenPlaying: false,
playable: false,
};
},
handlePlay() {
this.setState({play: true});
},
componentWillMount() {
this.messageListener = null;
},
componentWillUnmount() {
this.removeMessageListener();
},
removeMessageListener() {
if (this.messageListener != null) {
window.removeEventListener('message', this.messageListener);
this.messageListener = null;
}
},
installMessageListener(listener) {
this.removeMessageListener();
const messageListener = (this.messageListener = e => {
if (listener(e) === false && messageListener === this.messageListener) {
this.removeMessageListener();
}
});
window.addEventListener('message', messageListener);
},
handleRef(src, el) {
if (this.props.provider === 'Vine') {
// This hack tricks vine into thinking that the video is in view and ready to be played, by emulating
// functionality in their embed code that does scroll watching. In our case, we can just treat loading
// the iframe as being in view.
const expectedMessage = `ping::${src}`;
this.installMessageListener(e => {
if (el && el.contentWindow === e.source && e.origin === 'https://vine.co' && e.data === expectedMessage) {
e.source.postMessage('pong', '*');
e.source.postMessage('fullyInView', '*');
e.source.postMessage('play', '*');
return false;
}
});
}
},
render() {
let {width, height} = this.props.thumbnail;
if (this.state.play && this.props.useVideoSizeWhenPlaying) {
if (this.props.video.width && this.props.video.height) {
width = this.props.video.width;
height = this.props.video.height;
}
}
const maxWidth = this.props.scale ? 400 : width;
const maxHeight = this.props.scale ? 300 : height;
let widthRatio = 1;
if (width > maxWidth) {
widthRatio = maxWidth / width;
}
width = Math.round(width * widthRatio);
height = Math.round(height * widthRatio);
let heightRatio = 1;
if (height > maxHeight) {
heightRatio = maxHeight / height;
}
width = Math.round(width * heightRatio);
height = Math.round(height * heightRatio);
if (this.state.play) {
const urlObj = url.parse(this.props.video.url, true);
urlObj.query.autoplay = 1;
urlObj.query['auto_play'] = 1;
delete urlObj.search;
const src = url.format(urlObj);
return (
<div className="embed-thumbnail embed-thumbnail-video" style={{width, height}}>
<iframe
className="image"
ref={this.handleRef.bind(null, src)}
src={src}
width={width}
height={height}
frameBorder={0}
allowFullScreen
/>
</div>
);
} else {
let playButton;
if (this.props.playable) {
playButton = <button className="embed-video-play" type="button" onClick={this.handlePlay} />;
}
return (
<div key="embed-thumbnail" className="embed-thumbnail embed-thumbnail-video" style={{width, height}}>
<Image
src={this.props.thumbnail['proxy_url']}
href={this.props.thumbnail.url}
width={this.props.thumbnail.width}
height={this.props.thumbnail.height}
maxWidth={maxWidth}
maxHeight={maxHeight}
resize={true}
lightbox={false}
/>
<div className="embed-video-actions">
<div className="embed-video-actions-inner">
{playButton}
<a className="embed-video-popout" href={this.props.url} target="_blank" rel="noreferrer" />
</div>
</div>
</div>
);
}
},
});
const EmbedWrapper = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
className: React.PropTypes.string,
children: React.PropTypes.node.isRequired,
color: React.PropTypes.number,
maxWidth: React.PropTypes.number,
},
render() {
const {maxWidth} = this.props;
const style = this.props.color ? {backgroundColor: int2hex(this.props.color)} : null;
return (
<div className="embed-wrapper" style={{maxWidth}}>
<div className="embed-color-pill" style={style} />
<div className={classnames('embed', this.props.className)}>
{this.props.children}
</div>
</div>
);
},
});
const Embed = React.createClass({
mixins: [PureRenderMixin],
propTypes: {
/* eslint-disable camelcase */
message: React.PropTypes.object,
channelId: React.PropTypes.string,
inlineMedia: React.PropTypes.bool,
type: React.PropTypes.string.isRequired,
url: React.PropTypes.string,
title: React.PropTypes.string,
description: React.PropTypes.string,
color: React.PropTypes.number,
timestamp: React.PropTypes.string,
provider: React.PropTypes.shape({
name: React.PropTypes.string,
url: React.PropTypes.string,
}),
author: React.PropTypes.shape({
name: React.PropTypes.string,
url: React.PropTypes.string,
icon_url: React.PropTypes.string,
proxy_icon_url: React.PropTypes.string,
}),
thumbnail: React.PropTypes.shape({
url: React.PropTypes.string,
proxy_url: React.PropTypes.string,
width: React.PropTypes.width,
height: React.PropTypes.height,
}),
image: React.PropTypes.shape({
url: React.PropTypes.string,
proxy_url: React.PropTypes.string,
width: React.PropTypes.width,
height: React.PropTypes.height,
}),
footer: React.PropTypes.shape({
text: React.PropTypes.string,
icon_url: React.PropTypes.string,
proxy_icon_url: React.PropTypes.string,
}),
fields: React.PropTypes.arrayOf(
React.PropTypes.shape({
name: React.PropTypes.string,
value: React.PropTypes.string,
inline: React.PropTypes.bool,
})
),
scale: React.PropTypes.bool,
/* eslint-enable camelcase */
},
getDefaultProps() {
return {
inlineMedia: true,
scale: true,
};
},
isMaskedLinkTrusted() {
return this.props.type !== 'rich';
},
renderProvider() {
let {provider, url} = this.props;
if (provider) {
url = provider.url || url;
provider = url
? <MaskedLink className="embed-provider" href={url} trusted={this.isMaskedLinkTrusted()}>
{provider.name}
</MaskedLink>
: <span className="embed-provider">
{provider.name}
</span>;
return (
<div>
{provider}
</div>
);
}
},
renderAuthor() {
let {author, url} = this.props;
if (author) {
let icon = null;
if (author.proxy_icon_url) {
icon = <Image className="embed-author-icon" src={author.proxy_icon_url} width={20} height={20} />;
}
url = author.url || url;
author = url
? <MaskedLink className="embed-author-name" href={url} trusted={this.isMaskedLinkTrusted()}>
{author.name}
</MaskedLink>
: <span className="embed-author-name">
{author.name}
</span>;
return (
<div className="embed-author">
{icon}
{author}
</div>
);
}
},
renderFooter() {
let timestamp = null;
if (this.props.timestamp) {
timestamp = moment(this.props.timestamp).format('ddd MMM Do, YYYY [at] h:mm A');
}
if (this.props.footer) {
let icon = null;
if (this.props.footer.proxy_icon_url) {
icon = <Image className="embed-footer-icon" src={this.props.footer.proxy_icon_url} width={20} height={20} />;
}
return (
<div>
{icon}
<span className="embed-footer">
{this.props.footer.text}
{timestamp ? ' | ' : null}
{timestamp}
</span>
</div>
);
} else if (timestamp) {
return <span className="embed-footer">{timestamp}</span>;
}
},
renderTitle(markdown = false) {
let {title, url, channelId} = this.props;
if (title == null) {
return;
}
title = markdown ? MarkupUtils.parseEmbedTitle(title, true, {channelId}) : title;
if (url) {
return (
<div>
<MaskedLink className="embed-title" href={url} trusted={this.isMaskedLinkTrusted()}>
{title}
</MaskedLink>
</div>
);
}
return (
<div className="embed-title">
{title}
</div>
);
},
renderDescription(markdown = false) {
const {description, channelId} = this.props;
if (description) {
return (
<div className={classnames({'embed-description': true, markup: markdown})}>
{markdown ? MarkupUtils.parseAllowLinks(description, true, {channelId}) : description}
</div>
);
}
},
renderFields() {
const {fields, channelId} = this.props;
if (fields) {
return (
<div className="embed-fields">
{fields.map(({name, value, inline}, i) =>
<div key={i} className={classnames({'embed-field': true, 'embed-field-inline': inline})}>
<div className="embed-field-name">{MarkupUtils.parseEmbedTitle(name, true, {channelId})}</div>
<div className="embed-field-value markup">{MarkupUtils.parseAllowLinks(value, true, {channelId})}</div>
</div>
)}
</div>
);
}
},
renderImage(lightbox = false, maxWidth = 400, maxHeight = 250, thumbnail = this.props.thumbnail) {
if (thumbnail && this.props.inlineMedia) {
const {width, height} = thumbnail;
if (width > 0 && height > 0) {
const image = (
<Image
src={thumbnail['proxy_url']}
href={thumbnail.url}
width={width}
height={height}
maxWidth={maxWidth}
maxHeight={maxHeight}
resize={true}
lightbox={lightbox}
/>
);
return lightbox
? <a className={'embed-thumbnail embed-thumbnail-' + this.props.type} href={this.props.url}>
{image}
</a>
: <MaskedLink
className={'embed-thumbnail embed-thumbnail-' + this.props.type}
href={this.props.url}
trusted={this.isMaskedLinkTrusted()}>
{image}
</MaskedLink>;
}
}
},
renderVideo() {
const {provider, message, thumbnail, video, inlineMedia, scale, url} = this.props;
if (thumbnail && inlineMedia) {
const {width, height} = thumbnail;
if (width > 0 && height > 0) {
let playable = video && /^https:/i.test(video.url);
// Messages sent after this point contain server-side validated video urls
if (message != null && SnowflakeUtils.extractTimestamp(message.id) < VIDEO_PROVIDER_CHECK_UNIX_TIMESTAMP) {
playable = playable && provider && VIDEO_PROVIDERS.test(provider.name);
}
const useVideoSizeWhenPlaying = provider && /soundcloud|twitch/i.test(provider.name);
return (
<EmbedVideo
url={url}
useVideoSizeWhenPlaying={useVideoSizeWhenPlaying}
provider={provider && provider.name}
thumbnail={thumbnail}
video={video}
playable={playable}
scale={scale}
/>
);
}
}
},
renderGIFV() {
const {thumbnail, video, inlineMedia, scale, url} = this.props;
if (thumbnail && inlineMedia && video) {
const {width, height} = thumbnail;
if (width > 0 && height > 0) {
return (
<EmbedGIFV url={url} thumbnail={thumbnail} video={video} scale={scale} trusted={this.isMaskedLinkTrusted()} />
);
}
}
},
hasProvider() {
return this.provider && this.provider.name;
},
hasAuthor() {
return this.author && this.author.name;
},
isInline() {
const {provider, title} = this.props;
return (provider != null && /giphy|tenor|gfycat/i.test(provider.name)) || (!this.hasAuthor() && !title);
},
/**
* Determine a maxWidth based on whether the embed has a fairly large image.
* This way the image is framed by the borders to have equal padding and results a nicer looking UI.
*/
getMaxWidth(): ?number {
const {image, thumbnail, video, type} = this.props;
let target = thumbnail;
const maxWidth = video != null ? 512 : 400;
switch (type) {
case 'changelog':
return;
case 'rich':
if (thumbnail != null) {
return;
}
target = image;
break;
}
return target && target.width >= maxWidth ? maxWidth + 20 /* PADDING */ : undefined;
},
render() {
const {thumbnail, type, color, image, video, description} = this.props;
switch (type) {
case 'rich':
return (
<EmbedWrapper color={color} className="embed-rich" maxWidth={this.getMaxWidth()}>
<div className="embed-content">
<div className="embed-content-inner">
{this.renderProvider()}
{this.renderAuthor()}
{this.renderTitle(true)}
{this.renderDescription(true)}
{this.renderFields()}
</div>
{!video && thumbnail && thumbnail.width
? <Image
className="embed-rich-thumb"
src={thumbnail['proxy_url']}
width={thumbnail.width}
height={thumbnail.height}
maxWidth={80}
maxHeight={80}
/>
: null}
</div>
{video ? this.renderVideo() : null}
{image ? this.renderImage(true, undefined, undefined, image) : null}
{this.renderFooter()}
</EmbedWrapper>
);
case 'article':
return (
<EmbedWrapper maxWidth={this.getMaxWidth()}>
{this.renderProvider()}
{this.renderAuthor()}
{this.renderTitle()}
{this.renderDescription()}
{this.renderImage(true)}
</EmbedWrapper>
);
case 'image':
if (this.isInline()) {
return (
<div className="embed embed-inline">
{this.renderImage(true)}
</div>
);
} else {
return (
<EmbedWrapper maxWidth={this.getMaxWidth()}>
{this.renderProvider()}
{this.renderAuthor()}
{this.renderTitle()}
{this.renderImage(true)}
</EmbedWrapper>
);
}
case 'gifv':
if (this.isInline()) {
return (
<div className="embed embed-inline">
{this.renderGIFV()}
</div>
);
} else {
return (
<EmbedWrapper maxWidth={this.getMaxWidth()}>
{this.renderProvider()}
{this.renderAuthor()}
{this.renderTitle()}
{this.renderGIFV()}
</EmbedWrapper>
);
}
case 'changelog':
case 'video':
return (
<EmbedWrapper maxWidth={this.getMaxWidth()}>
{this.renderProvider()}
{this.renderAuthor()}
{this.renderTitle()}
{this.renderVideo()}
</EmbedWrapper>
);
case 'tweet':
return (
<EmbedWrapper className="embed-tweet" maxWidth={this.getMaxWidth()}>
<div className="embed-inner">
{this.renderTitle()}
{this.renderAuthor()}
</div>
{this.renderDescription()}
{video != null ? this.renderVideo() : this.renderImage(true)}
</EmbedWrapper>
);
case 'link':
default:
if (!description && !thumbnail) {
return null;
} else {
return (
<EmbedWrapper className="embed-link">
<div className="embed-inner">
{this.renderProvider()}
{this.renderAuthor()}
{this.renderTitle()}
{this.renderDescription()}
</div>
{this.renderImage(false, 75, 75)}
</EmbedWrapper>
);
}
}
},
});
export default Embed;
// WEBPACK FOOTER //
// ./discord_app/components/Embed.js