diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
new file mode 100644
index 0000000000..89a358e384
--- /dev/null
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -0,0 +1,230 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
+
+const messages = defineMessages({
+ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
+});
+
+class Item extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ attachment: ImmutablePropTypes.map.isRequired,
+ index: PropTypes.number.isRequired,
+ size: PropTypes.number.isRequired,
+ onClick: PropTypes.func.isRequired,
+ autoPlayGif: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ autoPlayGif: false,
+ };
+
+ handleMouseEnter = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.play();
+ }
+ }
+
+ handleMouseLeave = (e) => {
+ if (this.hoverToPlay()) {
+ e.target.pause();
+ e.target.currentTime = 0;
+ }
+ }
+
+ hoverToPlay () {
+ const { attachment, autoPlayGif } = this.props;
+ return !autoPlayGif && attachment.get('type') === 'gifv';
+ }
+
+ handleClick = (e) => {
+ const { index, onClick } = this.props;
+
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ onClick(index);
+ }
+
+ e.stopPropagation();
+ }
+
+ render () {
+ const { attachment, index, size } = this.props;
+
+ let width = 50;
+ let height = 100;
+ let top = 'auto';
+ let left = 'auto';
+ let bottom = 'auto';
+ let right = 'auto';
+
+ if (size === 1) {
+ width = 100;
+ }
+
+ if (size === 4 || (size === 3 && index > 0)) {
+ height = 50;
+ }
+
+ if (size === 2) {
+ if (index === 0) {
+ right = '2px';
+ } else {
+ left = '2px';
+ }
+ } else if (size === 3) {
+ if (index === 0) {
+ right = '2px';
+ } else if (index > 0) {
+ left = '2px';
+ }
+
+ if (index === 1) {
+ bottom = '2px';
+ } else if (index > 1) {
+ top = '2px';
+ }
+ } else if (size === 4) {
+ if (index === 0 || index === 2) {
+ right = '2px';
+ }
+
+ if (index === 1 || index === 3) {
+ left = '2px';
+ }
+
+ if (index < 2) {
+ bottom = '2px';
+ } else {
+ top = '2px';
+ }
+ }
+
+ let thumbnail = '';
+
+ if (attachment.get('type') === 'image') {
+ const previewUrl = attachment.get('preview_url');
+ const previewWidth = attachment.getIn(['meta', 'small', 'width']);
+
+ const originalUrl = attachment.get('url');
+ const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+
+ const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
+
+ const srcSet = hasSize && `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
+ const sizes = hasSize && `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`;
+
+ thumbnail = (
+
+
+
+ );
+ } else if (attachment.get('type') === 'gifv') {
+ const autoPlay = !isIOS() && this.props.autoPlayGif;
+
+ thumbnail = (
+
+
+
+ GIF
+
+ );
+ }
+
+ return (
+
+ {thumbnail}
+
+ );
+ }
+
+}
+
+@injectIntl
+export default class MediaGallery extends React.PureComponent {
+
+ static propTypes = {
+ sensitive: PropTypes.bool,
+ media: ImmutablePropTypes.list.isRequired,
+ height: PropTypes.number.isRequired,
+ onOpenMedia: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ autoPlayGif: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ autoPlayGif: false,
+ };
+
+ state = {
+ visible: !this.props.sensitive,
+ };
+
+ handleOpen = () => {
+ this.setState({ visible: !this.state.visible });
+ }
+
+ handleClick = (index) => {
+ this.props.onOpenMedia(this.props.media, index);
+ }
+
+ render () {
+ const { media, intl, sensitive } = this.props;
+
+ let children;
+
+ if (!this.state.visible) {
+ let warning;
+
+ if (sensitive) {
+ warning = ;
+ } else {
+ warning = ;
+ }
+
+ children = (
+
+ {warning}
+
+
+ );
+ } else {
+ const size = media.take(4).size;
+ children = media.take(4).map((attachment, i) => );
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
new file mode 100644
index 0000000000..6b9fdd2afe
--- /dev/null
+++ b/app/javascript/mastodon/components/status.js
@@ -0,0 +1,261 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import AvatarOverlay from './avatar_overlay';
+import RelativeTimestamp from './relative_timestamp';
+import DisplayName from './display_name';
+import StatusContent from './status_content';
+import StatusActionBar from './status_action_bar';
+import { FormattedMessage } from 'react-intl';
+import emojify from '../emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
+import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components';
+
+// We use the component (and not the container) since we do not want
+// to use the progress bar to show download progress
+import Bundle from '../features/ui/components/bundle';
+import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
+
+export default class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map,
+ account: ImmutablePropTypes.map,
+ wrapped: PropTypes.bool,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onOpenMedia: PropTypes.func,
+ onOpenVideo: PropTypes.func,
+ onBlock: PropTypes.func,
+ me: PropTypes.number,
+ boostModal: PropTypes.bool,
+ autoPlayGif: PropTypes.bool,
+ muted: PropTypes.bool,
+ intersectionObserverWrapper: PropTypes.object,
+ };
+
+ state = {
+ isExpanded: false,
+ isIntersecting: true, // assume intersecting until told otherwise
+ isHidden: false, // set to true in requestIdleCallback to trigger un-render
+ }
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'account',
+ 'wrapped',
+ 'me',
+ 'boostModal',
+ 'autoPlayGif',
+ 'muted',
+ ]
+
+ updateOnStates = ['isExpanded']
+
+ shouldComponentUpdate (nextProps, nextState) {
+ if (!nextState.isIntersecting && nextState.isHidden) {
+ // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
+ // that either "isIntersecting" or "isHidden" matter, and then they're
+ // the only things that matter.
+ return this.state.isIntersecting || !this.state.isHidden;
+ } else if (nextState.isIntersecting && !this.state.isIntersecting) {
+ // If we're going from a non-intersecting state to an intersecting state,
+ // (i.e. offscreen to onscreen), then we definitely need to re-render
+ return true;
+ }
+ // Otherwise, diff based on "updateOnProps" and "updateOnStates"
+ return super.shouldComponentUpdate(nextProps, nextState);
+ }
+
+ componentDidMount () {
+ if (!this.props.intersectionObserverWrapper) {
+ // TODO: enable IntersectionObserver optimization for notification statuses.
+ // These are managed in notifications/index.js rather than status_list.js
+ return;
+ }
+ this.props.intersectionObserverWrapper.observe(
+ this.props.id,
+ this.node,
+ this.handleIntersection
+ );
+
+ this.componentMounted = true;
+ }
+
+ componentWillUnmount () {
+ if (this.props.intersectionObserverWrapper) {
+ this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node);
+ }
+
+ this.componentMounted = false;
+ }
+
+ handleIntersection = (entry) => {
+ if (this.node && this.node.children.length !== 0) {
+ // save the height of the fully-rendered element
+ this.height = getRectFromEntry(entry).height;
+ }
+
+ // Edge 15 doesn't support isIntersecting, but we can infer it
+ // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/
+ // https://github.com/WICG/IntersectionObserver/issues/211
+ const isIntersecting = (typeof entry.isIntersecting === 'boolean') ?
+ entry.isIntersecting : entry.intersectionRect.height > 0;
+ this.setState((prevState) => {
+ if (prevState.isIntersecting && !isIntersecting) {
+ scheduleIdleTask(this.hideIfNotIntersecting);
+ }
+ return {
+ isIntersecting: isIntersecting,
+ isHidden: false,
+ };
+ });
+ }
+
+ hideIfNotIntersecting = () => {
+ if (!this.componentMounted) {
+ return;
+ }
+
+ // When the browser gets a chance, test if we're still not intersecting,
+ // and if so, set our isHidden to true to trigger an unrender. The point of
+ // this is to save DOM nodes and avoid using up too much memory.
+ // See: https://github.com/tootsuite/mastodon/issues/2900
+ this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
+ }
+
+ handleRef = (node) => {
+ this.node = node;
+ }
+
+ handleClick = () => {
+ if (!this.context.router) {
+ return;
+ }
+
+ const { status } = this.props;
+ this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
+ }
+
+ handleAccountClick = (e) => {
+ if (this.context.router && e.button === 0) {
+ const id = Number(e.currentTarget.getAttribute('data-id'));
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${id}`);
+ }
+ }
+
+ handleExpandedToggle = () => {
+ this.setState({ isExpanded: !this.state.isExpanded });
+ };
+
+ renderLoadingMediaGallery () {
+ return ;
+ }
+
+ renderLoadingVideoPlayer () {
+ return ;
+ }
+
+ render () {
+ let media = null;
+ let statusAvatar;
+
+ // Exclude intersectionObserverWrapper from `other` variable
+ // because intersection is managed in here.
+ const { status, account, intersectionObserverWrapper, ...other } = this.props;
+ const { isExpanded, isIntersecting, isHidden } = this.state;
+
+ if (status === null) {
+ return null;
+ }
+
+ if (!isIntersecting && isHidden) {
+ return (
+
+ {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
+ {status.get('content')}
+
+ );
+ }
+
+ if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+ let displayName = status.getIn(['account', 'display_name']);
+
+ if (displayName.length === 0) {
+ displayName = status.getIn(['account', 'username']);
+ }
+
+ const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+
+ return (
+
+ );
+ }
+
+ if (status.get('media_attachments').size > 0 && !this.props.muted) {
+ if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+
+ } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+ media = (
+
+ {Component => }
+
+ );
+ } else {
+ media = (
+
+ {Component => }
+
+ );
+ }
+ }
+
+ if (account === undefined || account === null) {
+ statusAvatar = ;
+ }else{
+ statusAvatar = ;
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
new file mode 100644
index 0000000000..7bb394e71b
--- /dev/null
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -0,0 +1,152 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import DropdownMenu from './dropdown_menu';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ delete: { id: 'status.delete', defaultMessage: 'Delete' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ open: { id: 'status.open', defaultMessage: 'Expand this status' },
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+});
+
+@injectIntl
+export default class StatusActionBar extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onMention: PropTypes.func,
+ onMute: PropTypes.func,
+ onBlock: PropTypes.func,
+ onReport: PropTypes.func,
+ onMuteConversation: PropTypes.func,
+ me: PropTypes.number,
+ withDismiss: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
+ 'me',
+ 'withDismiss',
+ ]
+
+ handleReplyClick = () => {
+ this.props.onReply(this.props.status, this.context.router.history);
+ }
+
+ handleFavouriteClick = () => {
+ this.props.onFavourite(this.props.status);
+ }
+
+ handleReblogClick = (e) => {
+ this.props.onReblog(this.props.status, e);
+ }
+
+ handleDeleteClick = () => {
+ this.props.onDelete(this.props.status);
+ }
+
+ handleMentionClick = () => {
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ }
+
+ handleMuteClick = () => {
+ this.props.onMute(this.props.status.get('account'));
+ }
+
+ handleBlockClick = () => {
+ this.props.onBlock(this.props.status.get('account'));
+ }
+
+ handleOpen = () => {
+ this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+ }
+
+ handleReport = () => {
+ this.props.onReport(this.props.status);
+ }
+
+ handleConversationMuteClick = () => {
+ this.props.onMuteConversation(this.props.status);
+ }
+
+ render () {
+ const { status, me, intl, withDismiss } = this.props;
+ const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
+ const mutingConversation = status.get('muted');
+ const anonymousAccess = !me;
+
+ let menu = [];
+ let reblogIcon = 'retweet';
+ let replyIcon;
+ let replyTitle;
+
+ menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+ menu.push(null);
+
+ if (withDismiss) {
+ menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+ menu.push(null);
+ }
+
+ if (status.getIn(['account', 'id']) === me) {
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
+ menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+ }
+
+ if (status.get('visibility') === 'direct') {
+ reblogIcon = 'envelope';
+ } else if (status.get('visibility') === 'private') {
+ reblogIcon = 'lock';
+ }
+
+ if (status.get('in_reply_to_id', null) === null) {
+ replyIcon = 'reply';
+ replyTitle = intl.formatMessage(messages.reply);
+ } else {
+ replyIcon = 'reply-all';
+ replyTitle = intl.formatMessage(messages.replyAll);
+ }
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
new file mode 100644
index 0000000000..1b803a22ec
--- /dev/null
+++ b/app/javascript/mastodon/components/status_content.js
@@ -0,0 +1,184 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import escapeTextContentForBrowser from 'escape-html';
+import PropTypes from 'prop-types';
+import emojify from '../emoji';
+import { isRtl } from '../rtl';
+import { FormattedMessage } from 'react-intl';
+import Permalink from './permalink';
+import classnames from 'classnames';
+
+export default class StatusContent extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ expanded: PropTypes.bool,
+ onExpandedToggle: PropTypes.func,
+ onClick: PropTypes.func,
+ };
+
+ state = {
+ hidden: true,
+ };
+
+ _updateStatusLinks () {
+ const node = this.node;
+ const links = node.querySelectorAll('a');
+
+ for (var i = 0; i < links.length; ++i) {
+ let link = links[i];
+ if (link.classList.contains('status-link')) {
+ continue;
+ }
+ link.classList.add('status-link');
+
+ let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
+
+ if (mention) {
+ link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+ link.setAttribute('title', mention.get('acct'));
+ } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+ link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+ } else {
+ link.setAttribute('title', link.href);
+ }
+
+ link.setAttribute('target', '_blank');
+ link.setAttribute('rel', 'noopener');
+ }
+ }
+
+ componentDidMount () {
+ this._updateStatusLinks();
+ }
+
+ componentDidUpdate () {
+ this._updateStatusLinks();
+ }
+
+ onMentionClick = (mention, e) => {
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(`/accounts/${mention.get('id')}`);
+ }
+ }
+
+ onHashtagClick = (hashtag, e) => {
+ hashtag = hashtag.replace(/^#/, '').toLowerCase();
+
+ if (this.context.router && e.button === 0) {
+ e.preventDefault();
+ this.context.router.history.push(`/timelines/tag/${hashtag}`);
+ }
+ }
+
+ handleMouseDown = (e) => {
+ this.startXY = [e.clientX, e.clientY];
+ }
+
+ handleMouseUp = (e) => {
+ if (!this.startXY) {
+ return;
+ }
+
+ const [ startX, startY ] = this.startXY;
+ const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+ if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
+ return;
+ }
+
+ if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
+ this.props.onClick();
+ }
+
+ this.startXY = null;
+ }
+
+ handleSpoilerClick = (e) => {
+ e.preventDefault();
+
+ if (this.props.onExpandedToggle) {
+ // The parent manages the state
+ this.props.onExpandedToggle();
+ } else {
+ this.setState({ hidden: !this.state.hidden });
+ }
+ }
+
+ setRef = (c) => {
+ this.node = c;
+ }
+
+ render () {
+ const { status } = this.props;
+
+ const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
+
+ const content = { __html: emojify(status.get('content')) };
+ const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+ const directionStyle = { direction: 'ltr' };
+ const classNames = classnames('status__content', {
+ 'status__content--with-action': this.props.onClick && this.context.router,
+ });
+
+ if (isRtl(status.get('search_index'))) {
+ directionStyle.direction = 'rtl';
+ }
+
+ if (status.get('spoiler_text').length > 0) {
+ let mentionsPlaceholder = '';
+
+ const mentionLinks = status.get('mentions').map(item => (
+
+ @{item.get('username')}
+
+ )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
+
+ const toggleText = hidden ? : ;
+
+ if (hidden) {
+ mentionsPlaceholder = {mentionLinks}
;
+ }
+
+ return (
+
+
+
+ {' '}
+
+
+
+ {mentionsPlaceholder}
+
+
+
+ );
+ } else if (this.props.onClick) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+
+}
diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js
new file mode 100644
index 0000000000..999cf42d9e
--- /dev/null
+++ b/app/javascript/mastodon/components/video_player.js
@@ -0,0 +1,204 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
+
+const messages = defineMessages({
+ toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
+ toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
+ expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
+});
+
+@injectIntl
+export default class VideoPlayer extends React.PureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ media: ImmutablePropTypes.map.isRequired,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ sensitive: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ autoplay: PropTypes.bool,
+ onOpenVideo: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ width: 239,
+ height: 110,
+ };
+
+ state = {
+ visible: !this.props.sensitive,
+ preview: true,
+ muted: true,
+ hasAudio: true,
+ videoError: false,
+ };
+
+ handleClick = () => {
+ this.setState({ muted: !this.state.muted });
+ }
+
+ handleVideoClick = (e) => {
+ e.stopPropagation();
+
+ const node = this.video;
+
+ if (node.paused) {
+ node.play();
+ } else {
+ node.pause();
+ }
+ }
+
+ handleOpen = () => {
+ this.setState({ preview: !this.state.preview });
+ }
+
+ handleVisibility = () => {
+ this.setState({
+ visible: !this.state.visible,
+ preview: true,
+ });
+ }
+
+ handleExpand = () => {
+ this.video.pause();
+ this.props.onOpenVideo(this.props.media, this.video.currentTime);
+ }
+
+ setRef = (c) => {
+ this.video = c;
+ }
+
+ handleLoadedData = () => {
+ if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
+ this.setState({ hasAudio: false });
+ }
+ }
+
+ handleVideoError = () => {
+ this.setState({ videoError: true });
+ }
+
+ componentDidMount () {
+ if (!this.video) {
+ return;
+ }
+
+ this.video.addEventListener('loadeddata', this.handleLoadedData);
+ this.video.addEventListener('error', this.handleVideoError);
+ }
+
+ componentDidUpdate () {
+ if (!this.video) {
+ return;
+ }
+
+ this.video.addEventListener('loadeddata', this.handleLoadedData);
+ this.video.addEventListener('error', this.handleVideoError);
+ }
+
+ componentWillUnmount () {
+ if (!this.video) {
+ return;
+ }
+
+ this.video.removeEventListener('loadeddata', this.handleLoadedData);
+ this.video.removeEventListener('error', this.handleVideoError);
+ }
+
+ render () {
+ const { media, intl, width, height, sensitive, autoplay } = this.props;
+
+ let spoilerButton = (
+
+
+
+ );
+
+ let expandButton = '';
+
+ if (this.context.router) {
+ expandButton = (
+
+
+
+ );
+ }
+
+ let muteButton = '';
+
+ if (this.state.hasAudio) {
+ muteButton = (
+
+
+
+ );
+ }
+
+ if (!this.state.visible) {
+ if (sensitive) {
+ return (
+
+ {spoilerButton}
+
+
+
+ );
+ } else {
+ return (
+
+ {spoilerButton}
+
+
+
+ );
+ }
+ }
+
+ if (this.state.preview && !autoplay) {
+ return (
+
+ );
+ }
+
+ if (this.state.videoError) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {spoilerButton}
+ {muteButton}
+ {expandButton}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
new file mode 100644
index 0000000000..438ecfe431
--- /dev/null
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Status from '../components/status';
+import { makeGetStatus } from '../selectors';
+import {
+ replyCompose,
+ mentionCompose,
+} from '../actions/compose';
+import {
+ reblog,
+ favourite,
+ unreblog,
+ unfavourite,
+} from '../actions/interactions';
+import {
+ blockAccount,
+ muteAccount,
+} from '../actions/accounts';
+import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
+import { initReport } from '../actions/reports';
+import { openModal } from '../actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+ blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+ muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, props) => ({
+ status: getStatus(state, props.id),
+ me: state.getIn(['meta', 'me']),
+ boostModal: state.getIn(['meta', 'boost_modal']),
+ deleteModal: state.getIn(['meta', 'delete_modal']),
+ autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onReply (status, router) {
+ dispatch(replyCompose(status, router));
+ },
+
+ onModalReblog (status) {
+ dispatch(reblog(status));
+ },
+
+ onReblog (status, e) {
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else {
+ if (e.shiftKey || !this.boostModal) {
+ this.onModalReblog(status);
+ } else {
+ dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+ }
+ }
+ },
+
+ onFavourite (status) {
+ if (status.get('favourited')) {
+ dispatch(unfavourite(status));
+ } else {
+ dispatch(favourite(status));
+ }
+ },
+
+ onDelete (status) {
+ if (!this.deleteModal) {
+ dispatch(deleteStatus(status.get('id')));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.deleteMessage),
+ confirm: intl.formatMessage(messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'))),
+ }));
+ }
+ },
+
+ onMention (account, router) {
+ dispatch(mentionCompose(account, router));
+ },
+
+ onOpenMedia (media, index) {
+ dispatch(openModal('MEDIA', { media, index }));
+ },
+
+ onOpenVideo (media, time) {
+ dispatch(openModal('VIDEO', { media, time }));
+ },
+
+ onBlock (account) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.blockConfirm),
+ onConfirm: () => dispatch(blockAccount(account.get('id'))),
+ }));
+ },
+
+ onReport (status) {
+ dispatch(initReport(status.get('account'), status));
+ },
+
+ onMute (account) {
+ dispatch(openModal('CONFIRM', {
+ message: @{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.muteConfirm),
+ onConfirm: () => dispatch(muteAccount(account.get('id'))),
+ }));
+ },
+
+ onMuteConversation (status) {
+ if (status.get('muted')) {
+ dispatch(unmuteStatus(status.get('id')));
+ } else {
+ dispatch(muteStatus(status.get('id')));
+ }
+ },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
new file mode 100644
index 0000000000..3239b1085c
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -0,0 +1,144 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import emojify from '../../../emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+});
+
+const makeMapStateToProps = () => {
+ const mapStateToProps = state => ({
+ autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
+ });
+
+ return mapStateToProps;
+};
+
+class Avatar extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map.isRequired,
+ autoPlayGif: PropTypes.bool.isRequired,
+ };
+
+ state = {
+ isHovered: false,
+ };
+
+ handleMouseOver = () => {
+ if (this.state.isHovered) return;
+ this.setState({ isHovered: true });
+ }
+
+ handleMouseOut = () => {
+ if (!this.state.isHovered) return;
+ this.setState({ isHovered: false });
+ }
+
+ render () {
+ const { account, autoPlayGif } = this.props;
+ const { isHovered } = this.state;
+
+ return (
+
+ {({ radius }) =>
+
+ }
+
+ );
+ }
+
+}
+
+@connect(makeMapStateToProps)
+@injectIntl
+export default class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ account: ImmutablePropTypes.map,
+ me: PropTypes.number.isRequired,
+ onFollow: PropTypes.func.isRequired,
+ intl: PropTypes.object.isRequired,
+ autoPlayGif: PropTypes.bool.isRequired,
+ };
+
+ render () {
+ const { account, me, intl } = this.props;
+
+ if (!account) {
+ return null;
+ }
+
+ let displayName = account.get('display_name');
+ let info = '';
+ let actionBtn = '';
+ let lockedIcon = '';
+
+ if (displayName.length === 0) {
+ displayName = account.get('username');
+ }
+
+ if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+ info = ;
+ }
+
+ if (me !== account.get('id')) {
+ if (account.getIn(['relationship', 'requested'])) {
+ actionBtn = (
+
+
+
+ );
+ } else if (!account.getIn(['relationship', 'blocking'])) {
+ actionBtn = (
+
+
+
+ );
+ }
+ }
+
+ if (account.get('locked')) {
+ lockedIcon = ;
+ }
+
+ const content = { __html: emojify(account.get('note')) };
+ const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+
+ return (
+
+
+
+
+
+
@{account.get('acct')} {lockedIcon}
+
+
+ {info}
+ {actionBtn}
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
new file mode 100644
index 0000000000..9d631644a0
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -0,0 +1,88 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusContainer from '../../../containers/status_container';
+import AccountContainer from '../../../containers/account_container';
+import { FormattedMessage } from 'react-intl';
+import Permalink from '../../../components/permalink';
+import emojify from '../../../emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class Notification extends ImmutablePureComponent {
+
+ static propTypes = {
+ notification: ImmutablePropTypes.map.isRequired,
+ };
+
+ renderFollow (account, link) {
+ return (
+
+ );
+ }
+
+ renderMention (notification) {
+ return ;
+ }
+
+ renderFavourite (notification, link) {
+ return (
+
+ );
+ }
+
+ renderReblog (notification, link) {
+ return (
+
+ );
+ }
+
+ render () {
+ const { notification } = this.props;
+ const account = notification.get('account');
+ const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
+ const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+ const link = ;
+
+ switch(notification.get('type')) {
+ case 'follow':
+ return this.renderFollow(account, link);
+ case 'mention':
+ return this.renderMention(notification);
+ case 'favourite':
+ return this.renderFavourite(notification, link);
+ case 'reblog':
+ return this.renderReblog(notification, link);
+ }
+
+ return null;
+ }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
new file mode 100644
index 0000000000..7862229676
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { makeGetNotification } from '../../../selectors';
+import Notification from '../components/notification';
+
+const makeMapStateToProps = () => {
+ const getNotification = makeGetNotification();
+
+ const mapStateToProps = (state, props) => ({
+ notification: getNotification(state, props.notification, props.accountId),
+ });
+
+ return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(Notification);