about summary refs log tree commit diff
diff options
context:
space:
mode:
authorkibigo! <marrus-sh@users.noreply.github.com>2017-07-12 01:10:17 -0700
committerkibigo! <marrus-sh@users.noreply.github.com>2017-07-12 01:10:17 -0700
commitbcf7ee48e94cd2e4d2de28e8854e7f0e2b5cad1f (patch)
tree07531b033ec7c1d1283135906df23c0fb9d39776
parent297921fce570bfab413bab4e16a4ae694ecc4f28 (diff)
Importing vanillin from upstream ;) ;)
-rw-r--r--app/javascript/mastodon/components/media_gallery.js230
-rw-r--r--app/javascript/mastodon/components/status.js261
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js152
-rw-r--r--app/javascript/mastodon/components/status_content.js184
-rw-r--r--app/javascript/mastodon/components/video_player.js204
-rw-r--r--app/javascript/mastodon/containers/status_container.js129
-rw-r--r--app/javascript/mastodon/features/account/components/header.js144
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js88
-rw-r--r--app/javascript/mastodon/features/notifications/containers/notification_container.js15
9 files changed, 1407 insertions, 0 deletions
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
new file mode 100644
index 000000000..89a358e38
--- /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 = (
+        <a
+          className='media-gallery__item-thumbnail'
+          href={attachment.get('remote_url') || originalUrl}
+          onClick={this.handleClick}
+          target='_blank'
+        >
+          <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' />
+        </a>
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      const autoPlay = !isIOS() && this.props.autoPlayGif;
+
+      thumbnail = (
+        <div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
+          <video
+            className='media-gallery__item-gifv-thumbnail'
+            role='application'
+            src={attachment.get('url')}
+            onClick={this.handleClick}
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
+            autoPlay={autoPlay}
+            loop
+            muted
+          />
+
+          <span className='media-gallery__gifv__label'>GIF</span>
+        </div>
+      );
+    }
+
+    return (
+      <div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        {thumbnail}
+      </div>
+    );
+  }
+
+}
+
+@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 = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
+      } else {
+        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
+      }
+
+      children = (
+        <div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}>
+          <span className='media-spoiler__warning'>{warning}</span>
+          <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </div>
+      );
+    } else {
+      const size = media.take(4).size;
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
+    }
+
+    return (
+      <div className='media-gallery' style={{ height: `${this.props.height}px` }}>
+        <div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}>
+          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
+        </div>
+
+        {children}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
new file mode 100644
index 000000000..6b9fdd2af
--- /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 <div className='media_gallery' style={{ height: '110px' }} />;
+  }
+
+  renderLoadingVideoPlayer () {
+    return <div className='media-spoiler-video' style={{ height: '110px' }} />;
+  }
+
+  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 (
+        <div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}>
+          {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
+          {status.get('content')}
+        </div>
+      );
+    }
+
+    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 (
+        <div className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} >
+          <div className='status__prepend'>
+            <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
+            <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
+          </div>
+
+          <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} />
+        </div>
+      );
+    }
+
+    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 = (
+          <Bundle fetchComponent={VideoPlayer} loading={this.renderLoadingVideoPlayer} >
+            {Component => <Component media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />}
+          </Bundle>
+        );
+      } else {
+        media = (
+          <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
+            {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />}
+          </Bundle>
+        );
+      }
+    }
+
+    if (account === undefined || account === null) {
+      statusAvatar = <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />;
+    }else{
+      statusAvatar = <AvatarOverlay staticSrc={status.getIn(['account', 'avatar_static'])} overlaySrc={account.get('avatar_static')} />;
+    }
+
+    return (
+      <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}>
+        <div className='status__info'>
+          <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+
+          <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'>
+            <div className='status__avatar'>
+              {statusAvatar}
+            </div>
+
+            <DisplayName account={status.get('account')} />
+          </a>
+        </div>
+
+        <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
+
+        {media}
+
+        <StatusActionBar {...this.props} />
+      </div>
+    );
+  }
+
+}
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 000000000..7bb394e71
--- /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 (
+      <div className='status__action-bar'>
+        <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
+        <IconButton className='status__action-bar-button' disabled={anonymousAccess || reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
+        <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+
+        <div className='status__action-bar-dropdown'>
+          <DropdownMenu disabled={anonymousAccess} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
new file mode 100644
index 000000000..1b803a22e
--- /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 => (
+        <Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
+          @<span>{item.get('username')}</span>
+        </Permalink>
+      )).reduce((aggregate, item) => [...aggregate, item, ' '], []);
+
+      const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
+
+      if (hidden) {
+        mentionsPlaceholder = <div>{mentionLinks}</div>;
+      }
+
+      return (
+        <div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+          <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
+            <span dangerouslySetInnerHTML={spoilerContent} />
+            {' '}
+            <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>{toggleText}</button>
+          </p>
+
+          {mentionsPlaceholder}
+
+          <div className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
+        </div>
+      );
+    } else if (this.props.onClick) {
+      return (
+        <div
+          ref={this.setRef}
+          className={classNames}
+          style={directionStyle}
+          onMouseDown={this.handleMouseDown}
+          onMouseUp={this.handleMouseUp}
+          dangerouslySetInnerHTML={content}
+        />
+      );
+    } else {
+      return (
+        <div
+          ref={this.setRef}
+          className='status__content'
+          style={directionStyle}
+          dangerouslySetInnerHTML={content}
+        />
+      );
+    }
+  }
+
+}
diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js
new file mode 100644
index 000000000..999cf42d9
--- /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 = (
+      <div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}>
+        <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
+      </div>
+    );
+
+    let expandButton = '';
+
+    if (this.context.router) {
+      expandButton = (
+        <div className='status__video-player-expand'>
+          <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
+        </div>
+      );
+    }
+
+    let muteButton = '';
+
+    if (this.state.hasAudio) {
+      muteButton = (
+        <div className='status__video-player-mute'>
+          <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
+        </div>
+      );
+    }
+
+    if (!this.state.visible) {
+      if (sensitive) {
+        return (
+          <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
+            {spoilerButton}
+            <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+            <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      } else {
+        return (
+          <div role='button' tabIndex='0' style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
+            {spoilerButton}
+            <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
+            <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+          </div>
+        );
+      }
+    }
+
+    if (this.state.preview && !autoplay) {
+      return (
+        <div role='button' tabIndex='0' className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
+          {spoilerButton}
+          <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
+        </div>
+      );
+    }
+
+    if (this.state.videoError) {
+      return (
+        <div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' >
+          <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
+        </div>
+      );
+    }
+
+    return (
+      <div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}>
+        {spoilerButton}
+        {muteButton}
+        {expandButton}
+
+        <video
+          className='status__video-player-video'
+          role='button'
+          tabIndex='0'
+          ref={this.setRef}
+          src={media.get('url')}
+          autoPlay={!isIOS()}
+          loop
+          muted={this.state.muted}
+          onClick={this.handleVideoClick}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
new file mode 100644
index 000000000..438ecfe43
--- /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: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+      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: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+      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 000000000..3239b1085
--- /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 (
+      <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
+        {({ radius }) =>
+          <a // eslint-disable-line jsx-a11y/anchor-has-content
+            href={account.get('url')}
+            className='account__header__avatar'
+            target='_blank'
+            rel='noopener'
+            style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }}
+            onMouseOver={this.handleMouseOver}
+            onMouseOut={this.handleMouseOut}
+            onFocus={this.handleMouseOver}
+            onBlur={this.handleMouseOut}
+          />
+        }
+      </Motion>
+    );
+  }
+
+}
+
+@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 = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
+    }
+
+    if (me !== account.get('id')) {
+      if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = (
+          <div className='account--action-button'>
+            <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
+          </div>
+        );
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = (
+          <div className='account--action-button'>
+            <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
+          </div>
+        );
+      }
+    }
+
+    if (account.get('locked')) {
+      lockedIcon = <i className='fa fa-lock' />;
+    }
+
+    const content         = { __html: emojify(account.get('note')) };
+    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+
+    return (
+      <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
+        <div>
+          <Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
+
+          <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
+          <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
+          <div className='account__header__content' dangerouslySetInnerHTML={content} />
+
+          {info}
+          {actionBtn}
+        </div>
+      </div>
+    );
+  }
+
+}
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 000000000..9d631644a
--- /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 (
+      <div className='notification notification-follow'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-user-plus' />
+          </div>
+
+          <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
+        </div>
+
+        <AccountContainer id={account.get('id')} withNote={false} />
+      </div>
+    );
+  }
+
+  renderMention (notification) {
+    return <StatusContainer id={notification.get('status')} withDismiss />;
+  }
+
+  renderFavourite (notification, link) {
+    return (
+      <div className='notification notification-favourite'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-star star-icon' />
+          </div>
+          <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
+        </div>
+
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss />
+      </div>
+    );
+  }
+
+  renderReblog (notification, link) {
+    return (
+      <div className='notification notification-reblog'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-retweet' />
+          </div>
+          <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
+        </div>
+
+        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss />
+      </div>
+    );
+  }
+
+  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             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
+
+    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 000000000..786222967
--- /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);