677 lines
19 KiB
JavaScript
Executable File
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
|