about summary refs log tree commit diff
path: root/app/javascript/mastodon/components
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-05-03 02:04:16 +0200
committerGitHub <noreply@github.com>2017-05-03 02:04:16 +0200
commitf5bf5ebb82e3af420dcd23d602b1be6cc86838e1 (patch)
tree92eef08642a038cf44ccbc6d16a884293e7a0814 /app/javascript/mastodon/components
parent26bc5915727e0a0173c03cb49f5193dd612fb888 (diff)
Replace sprockets/browserify with Webpack (#2617)
* Replace browserify with webpack

* Add react-intl-translations-manager

* Do not minify in development, add offline-plugin for ServiceWorker background cache updates

* Adjust tests and dependencies

* Fix production deployments

* Fix tests

* More optimizations

* Improve travis cache for npm stuff

* Re-run travis

* Add back support for custom.scss as before

* Remove offline-plugin and babili

* Fix issue with Immutable.List().unshift(...values) not working as expected

* Make travis load schema instead of running all migrations in sequence

* Fix missing React import in WarningContainer. Optimize rendering performance by using ImmutablePureComponent instead of
React.PureComponent. ImmutablePureComponent uses Immutable.is() to compare props. Replace dynamic callback bindings in
<UI />

* Add react definitions to places that use JSX

* Add Procfile.dev for running rails, webpack and streaming API at the same time
Diffstat (limited to 'app/javascript/mastodon/components')
-rw-r--r--app/javascript/mastodon/components/account.js93
-rw-r--r--app/javascript/mastodon/components/attachment_list.js33
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js213
-rw-r--r--app/javascript/mastodon/components/avatar.js68
-rw-r--r--app/javascript/mastodon/components/button.js50
-rw-r--r--app/javascript/mastodon/components/collapsable.js21
-rw-r--r--app/javascript/mastodon/components/column_back_button.js32
-rw-r--r--app/javascript/mastodon/components/column_back_button_slim.js32
-rw-r--r--app/javascript/mastodon/components/column_collapsable.js57
-rw-r--r--app/javascript/mastodon/components/display_name.js25
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js79
-rw-r--r--app/javascript/mastodon/components/extended_video_player.js54
-rw-r--r--app/javascript/mastodon/components/icon_button.js96
-rw-r--r--app/javascript/mastodon/components/load_more.js15
-rw-r--r--app/javascript/mastodon/components/loading_indicator.js10
-rw-r--r--app/javascript/mastodon/components/media_gallery.js196
-rw-r--r--app/javascript/mastodon/components/missing_indicator.js12
-rw-r--r--app/javascript/mastodon/components/permalink.js41
-rw-r--r--app/javascript/mastodon/components/relative_timestamp.js20
-rw-r--r--app/javascript/mastodon/components/status.js123
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js138
-rw-r--r--app/javascript/mastodon/components/status_content.js165
-rw-r--r--app/javascript/mastodon/components/status_list.js130
-rw-r--r--app/javascript/mastodon/components/video_player.js210
24 files changed, 1913 insertions, 0 deletions
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
new file mode 100644
index 000000000..9016bedb6
--- /dev/null
+++ b/app/javascript/mastodon/components/account.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import DisplayName from './display_name';
+import Permalink from './permalink';
+import IconButton from './icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }
+});
+
+class Account extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleFollow = this.handleFollow.bind(this);
+    this.handleBlock = this.handleBlock.bind(this);
+    this.handleMute = this.handleMute.bind(this);
+  }
+
+  handleFollow () {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock () {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMute () {
+    this.props.onMute(this.props.account);
+  }
+
+  render () {
+    const { account, me, intl } = this.props;
+
+    if (!account) {
+      return <div />;
+    }
+
+    let buttons;
+
+    if (account.get('id') !== me && account.get('relationship', null) !== null) {
+      const following = account.getIn(['relationship', 'following']);
+      const requested = account.getIn(['relationship', 'requested']);
+      const blocking  = account.getIn(['relationship', 'blocking']);
+      const muting  = account.getIn(['relationship', 'muting']);
+
+      if (requested) {
+        buttons = <IconButton disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
+      } else if (blocking) {
+        buttons = <IconButton active={true} icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
+      } else if (muting) {
+        buttons = <IconButton active={true} icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
+      } else {
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      }
+    }
+
+    return (
+      <div className='account'>
+        <div className='account__wrapper'>
+          <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+            <div className='account__avatar-wrapper'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={36} /></div>
+            <DisplayName account={account} />
+          </Permalink>
+
+          <div className='account__relationship'>
+            {buttons}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Account.propTypes = {
+  account: ImmutablePropTypes.map.isRequired,
+  me: PropTypes.number.isRequired,
+  onFollow: PropTypes.func.isRequired,
+  onBlock: PropTypes.func.isRequired,
+  onMute: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+}
+
+export default injectIntl(Account);
diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js
new file mode 100644
index 000000000..6df578b77
--- /dev/null
+++ b/app/javascript/mastodon/components/attachment_list.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
+
+class AttachmentList extends React.PureComponent {
+
+  render () {
+    const { media } = this.props;
+
+    return (
+      <div className='attachment-list'>
+        <div className='attachment-list__icon'>
+          <i className='fa fa-link' />
+        </div>
+
+        <ul className='attachment-list__list'>
+          {media.map(attachment =>
+            <li key={attachment.get('id')}>
+              <a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a>
+            </li>
+          )}
+        </ul>
+      </div>
+    );
+  }
+}
+
+AttachmentList.propTypes = {
+  media: ImmutablePropTypes.list.isRequired
+};
+
+export default AttachmentList;
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
new file mode 100644
index 000000000..6d8d3b2a3
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -0,0 +1,213 @@
+import React from 'react';
+import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from '../rtl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const textAtCursorMatchesToken = (str, caretPosition) => {
+  let word;
+
+  let left  = str.slice(0, caretPosition).search(/\S+$/);
+  let right = str.slice(caretPosition).search(/\s/);
+
+  if (right < 0) {
+    word = str.slice(left);
+  } else {
+    word = str.slice(left, right + caretPosition);
+  }
+
+  if (!word || word.trim().length < 2 || word[0] !== '@') {
+    return [null, null];
+  }
+
+  word = word.trim().toLowerCase().slice(1);
+
+  if (word.length > 0) {
+    return [left + 1, word];
+  } else {
+    return [null, null];
+  }
+};
+
+class AutosuggestTextarea extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      suggestionsHidden: false,
+      selectedSuggestion: 0,
+      lastToken: null,
+      tokenStart: 0
+    };
+    this.onChange = this.onChange.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
+    this.onBlur = this.onBlur.bind(this);
+    this.onSuggestionClick = this.onSuggestionClick.bind(this);
+    this.setTextarea = this.setTextarea.bind(this);
+    this.onPaste = this.onPaste.bind(this);
+  }
+
+  onChange (e) {
+    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
+
+    if (token !== null && this.state.lastToken !== token) {
+      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+      this.props.onSuggestionsFetchRequested(token);
+    } else if (token === null) {
+      this.setState({ lastToken: null });
+      this.props.onSuggestionsClearRequested();
+    }
+
+    // auto-resize textarea
+    e.target.style.height = `${e.target.scrollHeight}px`;
+
+    this.props.onChange(e);
+  }
+
+  onKeyDown (e) {
+    const { suggestions, disabled } = this.props;
+    const { selectedSuggestion, suggestionsHidden } = this.state;
+
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    switch(e.key) {
+    case 'Escape':
+      if (!suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ suggestionsHidden: true });
+      }
+
+      break;
+    case 'ArrowDown':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+
+      break;
+    case 'ArrowUp':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+
+      break;
+    case 'Enter':
+    case 'Tab':
+      // Select suggestion
+      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+      }
+
+      break;
+    }
+
+    if (e.defaultPrevented || !this.props.onKeyDown) {
+      return;
+    }
+
+    this.props.onKeyDown(e);
+  }
+
+  onBlur () {
+    // If we hide the suggestions immediately, then this will prevent the
+    // onClick for the suggestions themselves from firing.
+    // Setting a short window for that to take place before hiding the
+    // suggestions ensures that can't happen.
+    setTimeout(() => {
+      this.setState({ suggestionsHidden: true });
+    }, 100);
+  }
+
+  onSuggestionClick (suggestion, e) {
+    e.preventDefault();
+    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+    this.textarea.focus();
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  setTextarea (c) {
+    this.textarea = c;
+  }
+
+  onPaste (e) {
+    if (e.clipboardData && e.clipboardData.files.length === 1) {
+      this.props.onPaste(e.clipboardData.files)
+      e.preventDefault();
+    }
+  }
+
+  reset () {
+    this.textarea.style.height = 'auto';
+  }
+
+  render () {
+    const { value, suggestions, disabled, placeholder, onKeyUp } = this.props;
+    const { suggestionsHidden, selectedSuggestion } = this.state;
+    const style = { direction: 'ltr' };
+
+    if (isRtl(value)) {
+      style.direction = 'rtl';
+    }
+
+    return (
+      <div className='autosuggest-textarea'>
+        <textarea
+          ref={this.setTextarea}
+          className='autosuggest-textarea__textarea'
+          disabled={disabled}
+          placeholder={placeholder}
+          autoFocus={true}
+          value={value}
+          onChange={this.onChange}
+          onKeyDown={this.onKeyDown}
+          onKeyUp={onKeyUp}
+          onBlur={this.onBlur}
+          onPaste={this.onPaste}
+          style={style}
+        />
+
+        <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
+          {suggestions.map((suggestion, i) => (
+            <div
+              role='button'
+              tabIndex='0'
+              key={suggestion}
+              className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`}
+              onClick={this.onSuggestionClick.bind(this, suggestion)}>
+              <AutosuggestAccountContainer id={suggestion} />
+            </div>
+          ))}
+        </div>
+      </div>
+    );
+  }
+
+};
+
+AutosuggestTextarea.propTypes = {
+  value: PropTypes.string,
+  suggestions: ImmutablePropTypes.list,
+  disabled: PropTypes.bool,
+  placeholder: PropTypes.string,
+  onSuggestionSelected: PropTypes.func.isRequired,
+  onSuggestionsClearRequested: PropTypes.func.isRequired,
+  onSuggestionsFetchRequested: PropTypes.func.isRequired,
+  onChange: PropTypes.func.isRequired,
+  onKeyUp: PropTypes.func,
+  onKeyDown: PropTypes.func,
+  onPaste: PropTypes.func.isRequired,
+};
+
+export default AutosuggestTextarea;
diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js
new file mode 100644
index 000000000..47f2715c7
--- /dev/null
+++ b/app/javascript/mastodon/components/avatar.js
@@ -0,0 +1,68 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class Avatar extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+
+    this.state = {
+      hovering: false
+    };
+
+    this.handleMouseEnter = this.handleMouseEnter.bind(this);
+    this.handleMouseLeave = this.handleMouseLeave.bind(this);
+  }
+
+  handleMouseEnter () {
+    if (this.props.animate) return;
+    this.setState({ hovering: true });
+  }
+
+  handleMouseLeave () {
+    if (this.props.animate) return;
+    this.setState({ hovering: false });
+  }
+
+  render () {
+    const { src, size, staticSrc, animate } = this.props;
+    const { hovering } = this.state;
+
+    const style = {
+      ...this.props.style,
+      width: `${size}px`,
+      height: `${size}px`,
+      backgroundSize: `${size}px ${size}px`
+    };
+
+    if (hovering || animate) {
+      style.backgroundImage = `url(${src})`;
+    } else {
+      style.backgroundImage = `url(${staticSrc})`;
+    }
+
+    return (
+      <div
+        className='account__avatar'
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        style={style}
+      />
+    );
+  }
+
+}
+
+Avatar.propTypes = {
+  src: PropTypes.string.isRequired,
+  staticSrc: PropTypes.string,
+  size: PropTypes.number.isRequired,
+  style: PropTypes.object,
+  animate: PropTypes.bool
+};
+
+Avatar.defaultProps = {
+  animate: false
+};
+
+export default Avatar;
diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js
new file mode 100644
index 000000000..1063e0289
--- /dev/null
+++ b/app/javascript/mastodon/components/button.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class Button extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    if (!this.props.disabled) {
+      this.props.onClick();
+    }
+  }
+
+  render () {
+    const style = {
+      display: this.props.block ? 'block' : 'inline-block',
+      width: this.props.block ? '100%' : 'auto',
+      padding: `0 ${this.props.size / 2.25}px`,
+      height: `${this.props.size}px`,
+      lineHeight: `${this.props.size}px`
+    };
+
+    return (
+      <button className={`button ${this.props.secondary ? 'button-secondary' : ''}`} disabled={this.props.disabled} onClick={this.handleClick} style={{ ...style, ...this.props.style }}>
+        {this.props.text || this.props.children}
+      </button>
+    );
+  }
+
+}
+
+Button.propTypes = {
+  text: PropTypes.node,
+  onClick: PropTypes.func,
+  disabled: PropTypes.bool,
+  block: PropTypes.bool,
+  secondary: PropTypes.bool,
+  size: PropTypes.number,
+  style: PropTypes.object,
+  children: PropTypes.node
+};
+
+Button.defaultProps = {
+  size: 36
+};
+
+export default Button;
diff --git a/app/javascript/mastodon/components/collapsable.js b/app/javascript/mastodon/components/collapsable.js
new file mode 100644
index 000000000..a61f67d8e
--- /dev/null
+++ b/app/javascript/mastodon/components/collapsable.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import { Motion, spring } from 'react-motion';
+import PropTypes from 'prop-types';
+
+const Collapsable = ({ fullHeight, isVisible, children }) => (
+  <Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}>
+    {({ opacity, height }) =>
+      <div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}>
+        {children}
+      </div>
+    }
+  </Motion>
+);
+
+Collapsable.propTypes = {
+  fullHeight: PropTypes.number.isRequired,
+  isVisible: PropTypes.bool.isRequired,
+  children: PropTypes.node.isRequired
+};
+
+export default Collapsable;
diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js
new file mode 100644
index 000000000..bedc417fd
--- /dev/null
+++ b/app/javascript/mastodon/components/column_back_button.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+class ColumnBackButton extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick () {
+    if (window.history && window.history.length === 1) this.context.router.push("/");
+    else this.context.router.goBack();
+  }
+
+  render () {
+    return (
+      <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button'>
+        <i className='fa fa-fw fa-chevron-left column-back-button__icon'/>
+        <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+      </div>
+    );
+  }
+
+};
+
+ColumnBackButton.contextTypes = {
+  router: PropTypes.object
+};
+
+export default ColumnBackButton;
diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js
new file mode 100644
index 000000000..9aa7e92c2
--- /dev/null
+++ b/app/javascript/mastodon/components/column_back_button_slim.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+class ColumnBackButtonSlim extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick () {
+    this.context.router.push('/');
+  }
+
+  render () {
+    return (
+      <div className='column-back-button--slim'>
+        <div role='button' tabIndex='0' onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
+          <i className='fa fa-fw fa-chevron-left column-back-button__icon' />
+          <FormattedMessage id='column_back_button.label' defaultMessage='Back' />
+        </div>
+      </div>
+    );
+  }
+}
+
+ColumnBackButtonSlim.contextTypes = {
+  router: PropTypes.object
+};
+
+export default ColumnBackButtonSlim;
diff --git a/app/javascript/mastodon/components/column_collapsable.js b/app/javascript/mastodon/components/column_collapsable.js
new file mode 100644
index 000000000..797946859
--- /dev/null
+++ b/app/javascript/mastodon/components/column_collapsable.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import { Motion, spring } from 'react-motion';
+import PropTypes from 'prop-types';
+
+class ColumnCollapsable extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      collapsed: true
+    };
+
+    this.handleToggleCollapsed = this.handleToggleCollapsed.bind(this);
+  }
+
+  handleToggleCollapsed () {
+    const currentState = this.state.collapsed;
+
+    this.setState({ collapsed: !currentState });
+
+    if (!currentState && this.props.onCollapse) {
+      this.props.onCollapse();
+    }
+  }
+
+  render () {
+    const { icon, title, fullHeight, children } = this.props;
+    const { collapsed } = this.state;
+    const collapsedClassName = collapsed ? 'collapsable-collapsed' : 'collapsable';
+
+    return (
+      <div className='column-collapsable'>
+        <div role='button' tabIndex='0' title={`${title}`} className={`column-icon ${collapsedClassName}`} onClick={this.handleToggleCollapsed}>
+          <i className={`fa fa-${icon}`} />
+        </div>
+
+        <Motion defaultStyle={{ opacity: 0, height: 0 }} style={{ opacity: spring(collapsed ? 0 : 100), height: spring(collapsed ? 0 : fullHeight, collapsed ? undefined : { stiffness: 150, damping: 9 }) }}>
+          {({ opacity, height }) =>
+            <div style={{ overflow: height === fullHeight ? 'auto' : 'hidden', height: `${height}px`, opacity: opacity / 100, maxHeight: '70vh' }}>
+              {children}
+            </div>
+          }
+        </Motion>
+      </div>
+    );
+  }
+}
+
+ColumnCollapsable.propTypes = {
+  icon: PropTypes.string.isRequired,
+  title: PropTypes.string,
+  fullHeight: PropTypes.number.isRequired,
+  children: PropTypes.node,
+  onCollapse: PropTypes.func
+};
+
+export default ColumnCollapsable;
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
new file mode 100644
index 000000000..6bdd06db7
--- /dev/null
+++ b/app/javascript/mastodon/components/display_name.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import escapeTextContentForBrowser from 'escape-html';
+import emojify from '../emoji';
+
+class DisplayName extends React.PureComponent {
+
+  render () {
+    const displayName     = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name');
+    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+
+    return (
+      <span className='display-name'>
+        <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
+      </span>
+    );
+  }
+
+};
+
+DisplayName.propTypes = {
+  account: ImmutablePropTypes.map.isRequired
+}
+
+export default DisplayName;
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
new file mode 100644
index 000000000..aed0757b1
--- /dev/null
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
+import PropTypes from 'prop-types';
+
+class DropdownMenu extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      direction: 'left'
+    };
+    this.setRef = this.setRef.bind(this);
+    this.renderItem = this.renderItem.bind(this);
+  }
+
+  setRef (c) {
+    this.dropdown = c;
+  }
+
+  handleClick (i, e) {
+    const { action } = this.props.items[i];
+
+    if (typeof action === 'function') {
+      e.preventDefault();
+      action();
+      this.dropdown.hide();
+    }
+  }
+
+  renderItem (item, i) {
+    if (item === null) {
+      return <li key={ 'sep' + i } className='dropdown__sep' />;
+    }
+
+    const { text, action, href = '#' } = item;
+
+    return (
+      <li className='dropdown__content-list-item' key={ text + i }>
+        <a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)} className='dropdown__content-list-link'>
+          {text}
+        </a>
+      </li>
+    );
+  }
+
+  render () {
+    const { icon, items, size, direction, ariaLabel } = this.props;
+    const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
+
+    return (
+      <Dropdown ref={this.setRef}>
+        <DropdownTrigger className='icon-button' style={{ fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }} aria-label={ariaLabel}>
+          <i className={ `fa fa-fw fa-${icon} dropdown__icon` }  aria-hidden={true} />
+        </DropdownTrigger>
+
+        <DropdownContent className={directionClass}>
+          <ul className='dropdown__content-list'>
+            {items.map(this.renderItem)}
+          </ul>
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+
+}
+
+DropdownMenu.propTypes = {
+  icon: PropTypes.string.isRequired,
+  items: PropTypes.array.isRequired,
+  size: PropTypes.number.isRequired,
+  direction: PropTypes.string,
+  ariaLabel: PropTypes.string
+};
+
+DropdownMenu.defaultProps = {
+  ariaLabel: "Menu"
+};
+
+export default DropdownMenu;
diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js
new file mode 100644
index 000000000..34ede66fd
--- /dev/null
+++ b/app/javascript/mastodon/components/extended_video_player.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class ExtendedVideoPlayer extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleLoadedData = this.handleLoadedData.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleLoadedData () {
+    if (this.props.time) {
+      this.video.currentTime = this.props.time;
+    }
+  }
+
+  componentDidMount () {
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+  }
+
+  componentWillUnmount () {
+    this.video.removeEventListener('loadeddata', this.handleLoadedData);
+  }
+
+  setRef (c) {
+    this.video = c;
+  }
+
+  render () {
+    return (
+      <div className='extended-video-player'>
+        <video
+          ref={this.setRef}
+          src={this.props.src}
+          autoPlay
+          muted={this.props.muted}
+          controls={this.props.controls}
+          loop={!this.props.controls}
+        />
+      </div>
+    );
+  }
+
+}
+
+ExtendedVideoPlayer.propTypes = {
+  src: PropTypes.string.isRequired,
+  time: PropTypes.number,
+  controls: PropTypes.bool.isRequired,
+  muted: PropTypes.bool.isRequired
+};
+
+export default ExtendedVideoPlayer;
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
new file mode 100644
index 000000000..87324b6c8
--- /dev/null
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import { Motion, spring } from 'react-motion';
+import PropTypes from 'prop-types';
+
+class IconButton extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    e.preventDefault();
+
+    if (!this.props.disabled) {
+      this.props.onClick(e);
+    }
+  }
+
+  render () {
+    let style = {
+      fontSize: `${this.props.size}px`,
+      width: `${this.props.size * 1.28571429}px`,
+      height: `${this.props.size * 1.28571429}px`,
+      lineHeight: `${this.props.size}px`,
+      ...this.props.style
+    };
+
+    if (this.props.active) {
+      style = { ...style, ...this.props.activeStyle };
+    }
+
+    const classes = ['icon-button'];
+
+    if (this.props.active) {
+      classes.push('active');
+    }
+
+    if (this.props.disabled) {
+      classes.push('disabled');
+    }
+
+    if (this.props.inverted) {
+      classes.push('inverted');
+    }
+
+    if (this.props.overlay) {
+      classes.push('overlayed');
+    }
+
+    if (this.props.className) {
+      classes.push(this.props.className)
+    }
+
+    return (
+      <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
+        {({ rotate }) =>
+          <button
+            aria-label={this.props.title}
+            title={this.props.title}
+            className={classes.join(' ')}
+            onClick={this.handleClick}
+            style={style}>
+            <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
+          </button>
+        }
+      </Motion>
+    );
+  }
+
+}
+
+IconButton.propTypes = {
+  className: PropTypes.string,
+  title: PropTypes.string.isRequired,
+  icon: PropTypes.string.isRequired,
+  onClick: PropTypes.func,
+  size: PropTypes.number,
+  active: PropTypes.bool,
+  style: PropTypes.object,
+  activeStyle: PropTypes.object,
+  disabled: PropTypes.bool,
+  inverted: PropTypes.bool,
+  animate: PropTypes.bool,
+  overlay: PropTypes.bool
+};
+
+IconButton.defaultProps = {
+  size: 18,
+  active: false,
+  disabled: false,
+  animate: false,
+  overlay: false
+};
+
+export default IconButton;
diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js
new file mode 100644
index 000000000..36dae79af
--- /dev/null
+++ b/app/javascript/mastodon/components/load_more.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const LoadMore = ({ onClick }) => (
+  <a href="#" className='load-more' role='button' onClick={onClick}>
+    <FormattedMessage id='status.load_more' defaultMessage='Load more' />
+  </a>
+);
+
+LoadMore.propTypes = {
+  onClick: PropTypes.func
+};
+
+export default LoadMore;
diff --git a/app/javascript/mastodon/components/loading_indicator.js b/app/javascript/mastodon/components/loading_indicator.js
new file mode 100644
index 000000000..c09244834
--- /dev/null
+++ b/app/javascript/mastodon/components/loading_indicator.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const LoadingIndicator = () => (
+  <div className='loading-indicator'>
+    <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />
+  </div>
+);
+
+export default LoadingIndicator;
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
new file mode 100644
index 000000000..dc08c457d
--- /dev/null
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -0,0 +1,196 @@
+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 {
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    const { index, onClick } = this.props;
+
+    if (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') {
+      thumbnail = (
+        <a
+          className='media-gallery__item-thumbnail'
+          href={attachment.get('remote_url') || attachment.get('url')}
+          onClick={this.handleClick}
+          target='_blank'
+          style={{ backgroundImage: `url(${attachment.get('preview_url')})` }}
+        />
+      );
+    } 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}
+            autoPlay={autoPlay}
+            loop={true}
+            muted={true}
+          />
+
+          <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>
+    );
+  }
+
+}
+
+Item.propTypes = {
+  attachment: ImmutablePropTypes.map.isRequired,
+  index: PropTypes.number.isRequired,
+  size: PropTypes.number.isRequired,
+  onClick: PropTypes.func.isRequired,
+  autoPlayGif: PropTypes.bool.isRequired
+};
+
+class MediaGallery extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      visible: !props.sensitive
+    };
+    this.handleOpen = this.handleOpen.bind(this);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleOpen (e) {
+    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' style={{ display: !this.state.visible ? 'none' : 'block' }}>
+          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
+        </div>
+
+        {children}
+      </div>
+    );
+  }
+
+}
+
+MediaGallery.propTypes = {
+  sensitive: PropTypes.bool,
+  media: ImmutablePropTypes.list.isRequired,
+  height: PropTypes.number.isRequired,
+  onOpenMedia: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  autoPlayGif: PropTypes.bool.isRequired
+};
+
+export default injectIntl(MediaGallery);
diff --git a/app/javascript/mastodon/components/missing_indicator.js b/app/javascript/mastodon/components/missing_indicator.js
new file mode 100644
index 000000000..87df7f61c
--- /dev/null
+++ b/app/javascript/mastodon/components/missing_indicator.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
+
+const MissingIndicator = () => (
+  <div className='missing-indicator'>
+    <div>
+      <FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
+    </div>
+  </div>
+);
+
+export default MissingIndicator;
diff --git a/app/javascript/mastodon/components/permalink.js b/app/javascript/mastodon/components/permalink.js
new file mode 100644
index 000000000..26444f27c
--- /dev/null
+++ b/app/javascript/mastodon/components/permalink.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class Permalink extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(this.props.to);
+    }
+  }
+
+  render () {
+    const { href, children, className, ...other } = this.props;
+
+    return (
+      <a href={href} onClick={this.handleClick} {...other} className={'permalink ' + className}>
+        {children}
+      </a>
+    );
+  }
+
+}
+
+Permalink.contextTypes = {
+  router: PropTypes.object
+};
+
+Permalink.propTypes = {
+  className: PropTypes.string,
+  href: PropTypes.string.isRequired,
+  to: PropTypes.string.isRequired,
+  children: PropTypes.node
+};
+
+export default Permalink;
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
new file mode 100644
index 000000000..9c7a8121e
--- /dev/null
+++ b/app/javascript/mastodon/components/relative_timestamp.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import { injectIntl, FormattedRelative } from 'react-intl';
+import PropTypes from 'prop-types';
+
+const RelativeTimestamp = ({ intl, timestamp }) => {
+  const date = new Date(timestamp);
+
+  return (
+    <time dateTime={timestamp} title={intl.formatDate(date, { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}>
+      <FormattedRelative value={date} />
+    </time>
+  );
+};
+
+RelativeTimestamp.propTypes = {
+  intl: PropTypes.object.isRequired,
+  timestamp: PropTypes.string.isRequired
+};
+
+export default injectIntl(RelativeTimestamp);
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
new file mode 100644
index 000000000..39ed6dd4f
--- /dev/null
+++ b/app/javascript/mastodon/components/status.js
@@ -0,0 +1,123 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from './avatar';
+import RelativeTimestamp from './relative_timestamp';
+import DisplayName from './display_name';
+import MediaGallery from './media_gallery';
+import VideoPlayer from './video_player';
+import AttachmentList from './attachment_list';
+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';
+
+class Status extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+    this.handleAccountClick = this.handleAccountClick.bind(this);
+  }
+
+  handleClick () {
+    const { status } = this.props;
+    this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
+  }
+
+  handleAccountClick (id, e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/accounts/${id}`);
+    }
+  }
+
+  render () {
+    let media = '';
+    const { status, ...other } = this.props;
+
+    if (status === null) {
+      return <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'>
+          <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.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} />
+          </div>
+
+          <Status {...other} wrapped={true} status={status.get('reblog')} />
+        </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 = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />;
+      } else {
+        media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
+      }
+    }
+
+    return (
+      <div className={this.props.muted ? 'status muted' : 'status'}>
+        <div className='status__info'>
+          <div className='status__info-time'>
+            <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+          </div>
+
+          <a onClick={this.handleAccountClick.bind(this, status.getIn(['account', 'id']))} href={status.getIn(['account', 'url'])} className='status__display-name'>
+            <div className='status__avatar'>
+              <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
+            </div>
+
+            <DisplayName account={status.get('account')} />
+          </a>
+        </div>
+
+        <StatusContent status={status} onClick={this.handleClick} />
+
+        {media}
+
+        <StatusActionBar {...this.props} />
+      </div>
+    );
+  }
+
+}
+
+Status.contextTypes = {
+  router: PropTypes.object
+};
+
+Status.propTypes = {
+  status: 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
+};
+
+export default Status;
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..dc4466d6c
--- /dev/null
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -0,0 +1,138 @@
+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';
+
+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}' }
+});
+
+class StatusActionBar extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleReplyClick = this.handleReplyClick.bind(this);
+    this.handleFavouriteClick = this.handleFavouriteClick.bind(this);
+    this.handleReblogClick = this.handleReblogClick.bind(this);
+    this.handleDeleteClick = this.handleDeleteClick.bind(this);
+    this.handleMentionClick = this.handleMentionClick.bind(this);
+    this.handleMuteClick = this.handleMuteClick.bind(this);
+    this.handleBlockClick = this.handleBlockClick.bind(this);
+    this.handleOpen = this.handleOpen.bind(this);
+    this.handleReport = this.handleReport.bind(this);
+  }
+
+  handleReplyClick () {
+    this.props.onReply(this.props.status, this.context.router);
+  }
+
+  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);
+  }
+
+  handleMuteClick () {
+    this.props.onMute(this.props.status.get('account'));
+  }
+
+  handleBlockClick () {
+    this.props.onBlock(this.props.status.get('account'));
+  }
+
+  handleOpen () {
+    this.context.router.push(`/statuses/${this.props.status.get('id')}`);
+  }
+
+  handleReport () {
+    this.props.onReport(this.props.status);
+    this.context.router.push('/report');
+  }
+
+  render () {
+    const { status, me, intl } = this.props;
+    const reblog_disabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
+    let menu = [];
+
+    menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+    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 });
+    }
+
+    let reblogIcon = 'retweet';
+    if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
+    else if (status.get('visibility') === 'private') reblogIcon = 'lock';
+    let reply_icon;
+    let reply_title;
+    if (status.get('in_reply_to_id', null) === null) {
+      reply_icon = "reply";
+      reply_title = intl.formatMessage(messages.reply);
+    } else {
+      reply_icon = "reply-all";
+      reply_title = intl.formatMessage(messages.replyAll);
+    }
+
+    return (
+      <div className='status__action-bar'>
+        <div className='status__action-bar-button-wrapper'><IconButton title={reply_title} icon={reply_icon} onClick={this.handleReplyClick} /></div>
+        <div className='status__action-bar-button-wrapper'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
+        <div className='status__action-bar-button-wrapper'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} className='star-icon' /></div>
+
+        <div className='status__action-bar-dropdown'>
+          <DropdownMenu items={menu} icon='ellipsis-h' size={18} direction="right" ariaLabel="More"/>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+StatusActionBar.contextTypes = {
+  router: PropTypes.object
+};
+
+StatusActionBar.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,
+  me: PropTypes.number.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(StatusActionBar);
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
new file mode 100644
index 000000000..1d462103b
--- /dev/null
+++ b/app/javascript/mastodon/components/status_content.js
@@ -0,0 +1,165 @@
+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';
+
+class StatusContent extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      hidden: true
+    };
+    this.onMentionClick = this.onMentionClick.bind(this);
+    this.onHashtagClick = this.onHashtagClick.bind(this);
+    this.handleMouseDown = this.handleMouseDown.bind(this)
+    this.handleMouseUp = this.handleMouseUp.bind(this);
+    this.handleSpoilerClick = this.handleSpoilerClick.bind(this);
+    this.setRef = this.setRef.bind(this);
+  };
+
+  componentDidMount () {
+    const node  = this.node;
+    const links = node.querySelectorAll('a');
+
+    for (var i = 0; i < links.length; ++i) {
+      let link    = links[i];
+      let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
+      let media   = this.props.status.get('media_attachments').find(item => link.href === item.get('text_url') || (item.get('remote_url').length > 0 && link.href === item.get('remote_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 if (media) {
+        link.innerHTML = '<i class="fa fa-fw fa-photo"></i>';
+      } else {
+        link.setAttribute('target', '_blank');
+        link.setAttribute('rel', 'noopener');
+        link.setAttribute('title', link.href);
+      }
+    }
+  }
+
+  onMentionClick (mention, e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/accounts/${mention.get('id')}`);
+    }
+  }
+
+  onHashtagClick (hashtag, e) {
+    hashtag = hashtag.replace(/^#/, '').toLowerCase();
+
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/timelines/tag/${hashtag}`);
+    }
+  }
+
+  handleMouseDown (e) {
+    this.startXY = [e.clientX, e.clientY];
+  }
+
+  handleMouseUp (e) {
+    const [ startX, startY ] = this.startXY;
+    const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
+
+    if (e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) {
+      return;
+    }
+
+    if (deltaX + deltaY < 5 && e.button === 0) {
+      this.props.onClick();
+    }
+
+    this.startXY = null;
+  }
+
+  handleSpoilerClick (e) {
+    e.preventDefault();
+    this.setState({ hidden: !this.state.hidden });
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { status } = this.props;
+    const { hidden } = this.state;
+
+    const content = { __html: emojify(status.get('content')) };
+    const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+    const directionStyle = { direction: 'ltr' };
+
+    if (isRtl(status.get('content'))) {
+      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='status__content' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+          <p style={{ marginBottom: hidden && status.get('mentions').size === 0 ? '0px' : '' }} >
+            <span dangerouslySetInnerHTML={spoilerContent} />  <a tabIndex='0' className='status__content__spoiler-link' role='button' onClick={this.handleSpoilerClick}>{toggleText}</a>
+          </p>
+
+          {mentionsPlaceholder}
+
+          <div ref={this.setRef} style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
+        </div>
+      );
+    } else if (this.props.onClick) {
+      return (
+        <div
+          ref={this.setRef}
+          className='status__content'
+          style={{ ...directionStyle }}
+          onMouseDown={this.handleMouseDown}
+          onMouseUp={this.handleMouseUp}
+          dangerouslySetInnerHTML={content}
+        />
+      );
+    } else {
+      return (
+        <div
+          ref={this.setRef}
+          className='status__content status__content--no-action'
+          style={{ ...directionStyle }}
+          dangerouslySetInnerHTML={content}
+        />
+      );
+    }
+  }
+
+}
+
+StatusContent.contextTypes = {
+  router: PropTypes.object
+};
+
+StatusContent.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onClick: PropTypes.func
+};
+
+export default StatusContent;
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
new file mode 100644
index 000000000..9abf1fbfe
--- /dev/null
+++ b/app/javascript/mastodon/components/status_list.js
@@ -0,0 +1,130 @@
+import React from 'react';
+import Status from './status';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { ScrollContainer } from 'react-router-scroll';
+import PropTypes from 'prop-types';
+import StatusContainer from '../containers/status_container';
+import LoadMore from './load_more';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class StatusList extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+    this.setRef = this.setRef.bind(this);
+    this.handleLoadMore = this.handleLoadMore.bind(this);
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
+    this._oldScrollPosition = scrollHeight - scrollTop;
+
+    if (250 > offset && this.props.onScrollToBottom && !this.props.isLoading) {
+      this.props.onScrollToBottom();
+    } else if (scrollTop < 100 && this.props.onScrollToTop) {
+      this.props.onScrollToTop();
+    } else if (this.props.onScroll) {
+      this.props.onScroll();
+    }
+  }
+
+  componentDidMount () {
+    this.attachScrollListener();
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.node.scrollTop > 0 && (prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition)) {
+      this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
+    }
+  }
+
+  componentWillUnmount () {
+    this.detachScrollListener();
+  }
+
+  attachScrollListener () {
+    this.node.addEventListener('scroll', this.handleScroll);
+  }
+
+  detachScrollListener () {
+    this.node.removeEventListener('scroll', this.handleScroll);
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  handleLoadMore (e) {
+    e.preventDefault();
+    this.props.onScrollToBottom();
+  }
+
+  render () {
+    const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
+
+    let loadMore       = '';
+    let scrollableArea = '';
+    let unread         = '';
+
+    if (!isLoading && statusIds.size > 0 && hasMore) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    if (isUnread) {
+      unread = <div className='status-list__unread-indicator' />;
+    }
+
+    if (isLoading || statusIds.size > 0 || !emptyMessage) {
+      scrollableArea = (
+        <div className='scrollable' ref={this.setRef}>
+          {unread}
+
+          <div className='status-list'>
+            {prepend}
+
+            {statusIds.map((statusId) => {
+              return <StatusContainer key={statusId} id={statusId} />;
+            })}
+
+            {loadMore}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          {emptyMessage}
+        </div>
+      );
+    }
+
+    return (
+      <ScrollContainer scrollKey={scrollKey} shouldUpdateScroll={shouldUpdateScroll}>
+        {scrollableArea}
+      </ScrollContainer>
+    );
+  }
+
+}
+
+StatusList.propTypes = {
+  scrollKey: PropTypes.string.isRequired,
+  statusIds: ImmutablePropTypes.list.isRequired,
+  onScrollToBottom: PropTypes.func,
+  onScrollToTop: PropTypes.func,
+  onScroll: PropTypes.func,
+  shouldUpdateScroll: PropTypes.func,
+  isLoading: PropTypes.bool,
+  isUnread: PropTypes.bool,
+  hasMore: PropTypes.bool,
+  prepend: PropTypes.node,
+  emptyMessage: PropTypes.node
+};
+
+StatusList.defaultProps = {
+  trackScroll: true
+};
+
+export default StatusList;
diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js
new file mode 100644
index 000000000..0c8aea3a9
--- /dev/null
+++ b/app/javascript/mastodon/components/video_player.js
@@ -0,0 +1,210 @@
+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' },
+  expand_video: { id: 'video_player.video_error', defaultMessage: 'Video could not be played' }
+});
+
+class VideoPlayer extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      visible: !this.props.sensitive,
+      preview: true,
+      muted: true,
+      hasAudio: true,
+      videoError: false
+    };
+
+    this.handleClick = this.handleClick.bind(this);
+    this.handleVideoClick = this.handleVideoClick.bind(this);
+    this.handleOpen = this.handleOpen.bind(this);
+    this.handleVisibility = this.handleVisibility.bind(this);
+    this.handleExpand = this.handleExpand.bind(this);
+    this.setRef = this.setRef.bind(this);
+    this.handleLoadedData = this.handleLoadedData.bind(this);
+    this.handleVideoError = this.handleVideoError.bind(this);
+  }
+
+  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' style={{ display: !this.state.visible ? 'none' : 'block' }} >
+        <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
+      </div>
+    );
+
+    let 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` }} 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` }} 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`, background: `url(${media.get('preview_url')}) no-repeat center` }} 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={true}
+          muted={this.state.muted}
+          onClick={this.handleVideoClick}
+        />
+      </div>
+    );
+  }
+
+}
+
+VideoPlayer.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
+};
+
+VideoPlayer.defaultProps = {
+  width: 239,
+  height: 110
+};
+
+export default injectIntl(VideoPlayer);