about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/flavours/glitch/components/display_name.js11
-rw-r--r--app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js88
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js146
-rw-r--r--app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js173
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js5
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js6
-rw-r--r--app/javascript/flavours/glitch/packs/public.js8
-rw-r--r--app/javascript/flavours/glitch/styles/about.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/containers.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss90
-rw-r--r--app/javascript/mastodon/common.js6
-rw-r--r--app/javascript/mastodon/components/display_name.js12
-rw-r--r--app/javascript/mastodon/components/status.js2
-rw-r--r--app/javascript/mastodon/features/standalone/hashtag_timeline/index.js86
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js132
-rw-r--r--app/javascript/mastodon/features/status/containers/detailed_status_container.js172
-rw-r--r--app/javascript/mastodon/features/status/index.js5
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js2
-rw-r--r--app/javascript/mastodon/features/ui/index.js6
-rw-r--r--app/javascript/mastodon/locales/ca.json2
-rw-r--r--app/javascript/mastodon/locales/cy.json22
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json37
-rw-r--r--app/javascript/mastodon/locales/ro.json2
-rw-r--r--app/javascript/mastodon/locales/sk.json2
-rw-r--r--app/javascript/packs/public.js8
-rw-r--r--app/javascript/styles/mastodon/about.scss2
-rw-r--r--app/javascript/styles/mastodon/containers.scss2
-rw-r--r--app/javascript/styles/mastodon/forms.scss2
-rw-r--r--app/javascript/styles/mastodon/widgets.scss90
31 files changed, 982 insertions, 145 deletions
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
index d6ac4907d..a26cff049 100644
--- a/app/javascript/flavours/glitch/components/display_name.js
+++ b/app/javascript/flavours/glitch/components/display_name.js
@@ -9,15 +9,23 @@ export default function DisplayName ({
   account,
   className,
   inline,
+  localDomain,
 }) {
   const computedClass = classNames('display-name', { inline }, className);
 
+  if (!account) return null;
+
+  let acct = account.get('acct');
+  if (acct.indexOf('@') === -1 && localDomain) {
+    acct = `${acct}@${localDomain}`;
+  }
+
   //  The result.
   return account ? (
     <span className={computedClass}>
       <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
       {inline ? ' ' : null}
-      <span className='display-name__account'>@{account.get('acct')}</span>
+      <span className='display-name__account'>@{acct}</span>
     </span>
   ) : null;
 }
@@ -27,4 +35,5 @@ DisplayName.propTypes = {
   account: ImmutablePropTypes.map,
   className: PropTypes.string,
   inline: PropTypes.bool,
+  localDomain: PropTypes.string,
 };
diff --git a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
index 44ba8db92..17f064713 100644
--- a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
@@ -1,28 +1,32 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines';
-import Column from 'flavours/glitch/components/column';
-import ColumnHeader from 'flavours/glitch/components/column_header';
 import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
+import Masonry from 'react-masonry-infinite';
+import { List as ImmutableList } from 'immutable';
+import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 
-@connect()
-export default class HashtagTimeline extends React.PureComponent {
+const mapStateToProps = (state, { hashtag }) => ({
+  statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
+  isLoading: state.getIn(['timelines', `hashtag:${hashtag}`, 'isLoading'], false),
+  hasMore: state.getIn(['timelines', `hashtag:${hashtag}`, 'hasMore'], false),
+});
+
+export default @connect(mapStateToProps)
+class HashtagTimeline extends React.PureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool.isRequired,
+    hasMore: PropTypes.bool.isRequired,
     hashtag: PropTypes.string.isRequired,
   };
 
-  handleHeaderClick = () => {
-    this.column.scrollTop();
-  }
-
-  setRef = c => {
-    this.column = c;
-  }
-
   componentDidMount () {
     const { dispatch, hashtag } = this.props;
 
@@ -37,28 +41,52 @@ export default class HashtagTimeline extends React.PureComponent {
     }
   }
 
-  handleLoadMore = maxId => {
-    this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
+  handleLoadMore = () => {
+    const maxId = this.props.statusIds.last();
+
+    if (maxId) {
+      this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
+    }
+  }
+
+  setRef = c => {
+    this.masonry = c;
   }
 
+  handleHeightChange = debounce(() => {
+    if (!this.masonry) {
+      return;
+    }
+
+    this.masonry.forcePack();
+  }, 50)
+
   render () {
-    const { hashtag } = this.props;
+    const { statusIds, hasMore, isLoading } = this.props;
+
+    const sizes = [
+      { columns: 1, gutter: 0 },
+      { mq: '415px', columns: 1, gutter: 10 },
+      { mq: '640px', columns: 2, gutter: 10 },
+      { mq: '960px', columns: 3, gutter: 10 },
+      { mq: '1255px', columns: 3, gutter: 10 },
+    ];
+
+    const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
 
     return (
-      <Column ref={this.setRef}>
-        <ColumnHeader
-          icon='hashtag'
-          title={hashtag}
-          onClick={this.handleHeaderClick}
-        />
-
-        <StatusListContainer
-          trackScroll={false}
-          scrollKey='standalone_hashtag_timeline'
-          timelineId={`hashtag:${hashtag}`}
-          onLoadMore={this.handleLoadMore}
-        />
-      </Column>
+      <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
+        {statusIds.map(statusId => (
+          <div className='statuses-grid__item' key={statusId}>
+            <DetailedStatusContainer
+              id={statusId}
+              compact
+              measureHeight
+              onHeightChange={this.handleHeightChange}
+            />
+          </div>
+        )).toArray()}
+      </Masonry>
     );
   }
 
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 6302f11af..02f02efea 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -12,6 +12,8 @@ import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from 'flavours/glitch/features/video';
 import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
+import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
+import classNames from 'classnames';
 
 export default class DetailedStatus extends ImmutablePureComponent {
 
@@ -26,10 +28,18 @@ export default class DetailedStatus extends ImmutablePureComponent {
     onOpenVideo: PropTypes.func.isRequired,
     onToggleHidden: PropTypes.func.isRequired,
     expanded: PropTypes.bool,
+    measureHeight: PropTypes.bool,
+    onHeightChange: PropTypes.func,
+    domain: PropTypes.string.isRequired,
+    compact: PropTypes.bool,
+  };
+
+  state = {
+    height: null,
   };
 
   handleAccountClick = (e) => {
-    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
       e.preventDefault();
       this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
     }
@@ -38,7 +48,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
   }
 
   parseClick = (e, destination) => {
-    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
       e.preventDefault();
       this.context.router.history.push(destination);
     }
@@ -50,15 +60,59 @@ export default class DetailedStatus extends ImmutablePureComponent {
     this.props.onOpenVideo(media, startTime);
   }
 
+  _measureHeight (heightJustChanged) {
+    if (this.props.measureHeight && this.node) {
+      scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
+
+      if (this.props.onHeightChange && heightJustChanged) {
+        this.props.onHeightChange();
+      }
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+    this._measureHeight();
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    this._measureHeight(prevState.height !== this.state.height);
+  }
+
+  handleModalLink = e => {
+    e.preventDefault();
+
+    let href;
+
+    if (e.target.nodeName !== 'A') {
+      href = e.target.parentNode.href;
+    } else {
+      href = e.target.href;
+    }
+
+    window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
+  }
+
   render () {
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
     const { expanded, onToggleHidden, settings } = this.props;
+    const outerStyle = { boxSizing: 'border-box' };
+    const { compact } = this.props;
+
+    if (!status) {
+      return null;
+    }
 
     let media           = '';
     let mediaIcon       = null;
     let applicationLink = '';
     let reblogLink = '';
     let reblogIcon = 'retweet';
+    let favouriteLink = '';
+
+    if (this.props.measureHeight) {
+      outerStyle.height = `${this.state.height}px`;
+    }
 
     if (status.get('media_attachments').size > 0) {
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
@@ -108,41 +162,69 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
     if (status.get('visibility') === 'private') {
       reblogLink = <i className={`fa fa-${reblogIcon}`} />;
+    } else if (this.context.router) {
+      reblogLink = (
+        <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+          <i className={`fa fa-${reblogIcon}`} />
+          <span className='detailed-status__reblogs'>
+            <FormattedNumber value={status.get('reblogs_count')} />
+          </span>
+        </Link>
+      );
     } else {
-      reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
-        <i className={`fa fa-${reblogIcon}`} />
-        <span className='detailed-status__reblogs'>
-          <FormattedNumber value={status.get('reblogs_count')} />
-        </span>
-      </Link>);
+      reblogLink = (
+        <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <i className={`fa fa-${reblogIcon}`} />
+          <span className='detailed-status__reblogs'>
+            <FormattedNumber value={status.get('reblogs_count')} />
+          </span>
+        </a>
+      );
     }
 
-    return (
-      <div className='detailed-status' data-status-by={status.getIn(['account', 'acct'])}>
-        <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
-          <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
-          <DisplayName account={status.get('account')} />
+    if (this.context.router) {
+      favouriteLink = (
+        <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+          <i className='fa fa-star' />
+          <span className='detailed-status__favorites'>
+            <FormattedNumber value={status.get('favourites_count')} />
+          </span>
+        </Link>
+      );
+    } else {
+      favouriteLink = (
+        <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <i className='fa fa-star' />
+          <span className='detailed-status__favorites'>
+            <FormattedNumber value={status.get('favourites_count')} />
+          </span>
         </a>
+      );
+    }
+
+    return (
+      <div style={outerStyle}>
+        <div ref={this.setRef} className={classNames('detailed-status', { compact })} data-status-by={status.getIn(['account', 'acct'])}>
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+            <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
+            <DisplayName account={status.get('account')} localDomain={this.props.domain} />
+          </a>
+
+          <StatusContent
+            status={status}
+            media={media}
+            mediaIcon={mediaIcon}
+            expanded={expanded}
+            collapsed={false}
+            onExpandedToggle={onToggleHidden}
+            parseClick={this.parseClick}
+          />
 
-        <StatusContent
-          status={status}
-          media={media}
-          mediaIcon={mediaIcon}
-          expanded={expanded}
-          collapsed={false}
-          onExpandedToggle={onToggleHidden}
-          parseClick={this.parseClick}
-        />
-
-        <div className='detailed-status__meta'>
-          <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
-            <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
-          </a>{applicationLink} · {reblogLink} · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
-            <i className='fa fa-star' />
-            <span className='detailed-status__favorites'>
-              <FormattedNumber value={status.get('favourites_count')} />
-            </span>
-          </Link> · <VisibilityIcon visibility={status.get('visibility')} />
+          <div className='detailed-status__meta'>
+            <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
+              <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
+            </a>{applicationLink} · {reblogLink} · {favouriteLink} · <VisibilityIcon visibility={status.get('visibility')} />
+          </div>
         </div>
       </div>
     );
diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
new file mode 100644
index 000000000..e41b1dc88
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
@@ -0,0 +1,173 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import DetailedStatus from '../components/detailed_status';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import {
+  replyCompose,
+  mentionCompose,
+  directCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  reblog,
+  favourite,
+  unreblog,
+  unfavourite,
+  pin,
+  unpin,
+} from 'flavours/glitch/actions/interactions';
+import { blockAccount } from 'flavours/glitch/actions/accounts';
+import {
+  muteStatus,
+  unmuteStatus,
+  deleteStatus,
+  hideStatus,
+  revealStatus,
+} from 'flavours/glitch/actions/statuses';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, deleteModal } from 'flavours/glitch/util/initial_state';
+import { showAlertForError } from 'flavours/glitch/actions/alerts';
+
+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?' },
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props),
+    domain: state.getIn(['meta', 'domain']),
+    settings: state.get('local_settings'),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onReply (status, router) {
+    dispatch((_, getState) => {
+      let state = getState();
+      if (state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onConfirm: () => dispatch(replyCompose(status, router)),
+        }));
+      } else {
+        dispatch(replyCompose(status, router));
+      }
+    });
+  },
+
+  onModalReblog (status) {
+    dispatch(reblog(status));
+  },
+
+  onReblog (status, e) {
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !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));
+    }
+  },
+
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', {
+      url: status.get('url'),
+      onError: error => dispatch(showAlertForError(error)),
+    }));
+  },
+
+  onDelete (status, history, withRedraft = false) {
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      }));
+    }
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  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(initMuteModal(account));
+  },
+
+  onMuteConversation (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
+  onToggleHidden (status) {
+    if (status.get('hidden')) {
+      dispatch(revealStatus(status.get('id')));
+    } else {
+      dispatch(hideStatus(status.get('id')));
+    }
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index aa508c483..86c4db283 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -100,6 +100,7 @@ const makeMapStateToProps = () => {
       descendantsIds,
       settings: state.get('local_settings'),
       askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0,
+      domain: state.getIn(['meta', 'domain']),
     };
   };
 
@@ -123,6 +124,7 @@ export default class Status extends ImmutablePureComponent {
     descendantsIds: ImmutablePropTypes.list,
     intl: PropTypes.object.isRequired,
     askReplyConfirmation: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
   };
 
   state = {
@@ -417,7 +419,7 @@ export default class Status extends ImmutablePureComponent {
   render () {
     let ancestors, descendants;
     const { setExpansion } = this;
-    const { status, settings, ancestorsIds, descendantsIds, intl } = this.props;
+    const { status, settings, ancestorsIds, descendantsIds, intl, domain } = this.props;
     const { fullscreen, isExpanded } = this.state;
 
     if (status === null) {
@@ -470,6 +472,7 @@ export default class Status extends ImmutablePureComponent {
                   onOpenMedia={this.handleOpenMedia}
                   expanded={isExpanded}
                   onToggleHidden={this.handleExpandedToggle}
+                  domain={domain}
                 />
 
                 <ActionBar
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 61f6c0fed..83b797305 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -30,7 +30,7 @@ const componentMap = {
   'LIST': ListTimeline,
 };
 
-const shouldHideFAB = path => path.match(/^\/statuses\//);
+const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/);
 
 const messages = defineMessages({
   publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 7928dfe6c..602d93832 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -166,6 +166,7 @@ export default class UI extends React.Component {
   }
 
   handleDragOver = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return false;
     e.preventDefault();
     e.stopPropagation();
 
@@ -179,6 +180,7 @@ export default class UI extends React.Component {
   }
 
   handleDrop = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return;
     e.preventDefault();
 
     this.setState({ draggingOver: false });
@@ -202,6 +204,10 @@ export default class UI extends React.Component {
     this.setState({ draggingOver: false });
   }
 
+  dataTransferIsText = (dataTransfer) => {
+    return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1);
+  }
+
   closeUploadModal = () => {
     this.setState({ draggingOver: false });
   }
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index 56012ba78..da0b4c8e0 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -86,6 +86,14 @@ function main() {
     if (parallaxComponents.length > 0 ) {
       new Rellax('.parallax', { speed: -1 });
     }
+
+    if (document.body.classList.contains('with-modals')) {
+      const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
+      const scrollbarWidthStyle = document.createElement('style');
+      scrollbarWidthStyle.id = 'scrollbar-width';
+      document.head.appendChild(scrollbarWidthStyle);
+      scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0);
+    }
   });
 }
 
diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss
index c8d144e5b..e8f46766a 100644
--- a/app/javascript/flavours/glitch/styles/about.scss
+++ b/app/javascript/flavours/glitch/styles/about.scss
@@ -366,7 +366,7 @@ $small-breakpoint: 960px;
 
   @media screen and (max-width: $column-breakpoint) {
     .grid {
-      grid-template-columns: auto;
+      grid-template-columns: 100%;
 
       .column-0 {
         display: block;
diff --git a/app/javascript/flavours/glitch/styles/containers.scss b/app/javascript/flavours/glitch/styles/containers.scss
index 82d4050d7..fd334f869 100644
--- a/app/javascript/flavours/glitch/styles/containers.scss
+++ b/app/javascript/flavours/glitch/styles/containers.scss
@@ -297,7 +297,7 @@
         color: $primary-text-color;
       }
 
-      @media screen and (max-width: $no-gap-breakpoint) {
+      @media screen and (max-width: 550px) {
         &.optional {
           display: none;
         }
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 6132dd1ae..bab982706 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -419,7 +419,7 @@ code {
     background: darken($ui-base-color, 10%) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") no-repeat right 8px center / auto 16px;
     border: 1px solid darken($ui-base-color, 14%);
     border-radius: 4px;
-    padding: 10px;
+    padding-left: 10px;
     padding-right: 30px;
     height: 41px;
   }
diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss
index 87e633c70..c97337e4e 100644
--- a/app/javascript/flavours/glitch/styles/widgets.scss
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -425,3 +425,93 @@
     border-radius: 0;
   }
 }
+
+$maximum-width: 1235px;
+$fluid-breakpoint: $maximum-width + 20px;
+
+.statuses-grid {
+  min-height: 600px;
+
+  @media screen and (max-width: 640px) {
+    width: 100% !important; // Masonry layout is unnecessary at this width
+  }
+
+  &__item {
+    width: (960px - 20px) / 3;
+
+    @media screen and (max-width: $fluid-breakpoint) {
+      width: (940px - 20px) / 3;
+    }
+
+    @media screen and (max-width: 640px) {
+      width: 100%;
+    }
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      width: 100vw;
+    }
+  }
+
+  .detailed-status {
+    border-radius: 4px;
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      border-top: 1px solid lighten($ui-base-color, 16%);
+    }
+
+    &.compact {
+      .detailed-status__meta {
+        margin-top: 15px;
+      }
+
+      .status__content {
+        font-size: 15px;
+        line-height: 20px;
+
+        .emojione {
+          width: 20px;
+          height: 20px;
+          margin: -3px 0 0;
+        }
+
+        .status__content__spoiler-link {
+          line-height: 20px;
+          margin: 0;
+        }
+      }
+
+      .media-gallery,
+      .status-card,
+      .video-player {
+        margin-top: 15px;
+      }
+    }
+  }
+}
+
+.notice-widget {
+  margin-bottom: 10px;
+  color: $darker-text-color;
+
+  p {
+    margin-bottom: 10px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    font-size: 14px;
+    line-height: 20px;
+    text-decoration: none;
+    font-weight: 500;
+    color: $ui-highlight-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+}
diff --git a/app/javascript/mastodon/common.js b/app/javascript/mastodon/common.js
index 2b10b8c30..fba21316a 100644
--- a/app/javascript/mastodon/common.js
+++ b/app/javascript/mastodon/common.js
@@ -4,5 +4,9 @@ export function start() {
   require('font-awesome/css/font-awesome.css');
   require.context('../images/', true);
 
-  Rails.start();
+  try {
+    Rails.start();
+  } catch (e) {
+    // If called twice
+  }
 };
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index c2c40cb3f..acddf77c5 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -1,15 +1,17 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
 
 export default class DisplayName extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
     others: ImmutablePropTypes.list,
+    localDomain: PropTypes.string,
   };
 
   render () {
-    const { account, others } = this.props;
+    const { account, others, localDomain } = this.props;
     const displayNameHtml = { __html: account.get('display_name_html') };
 
     let suffix;
@@ -17,7 +19,13 @@ export default class DisplayName extends React.PureComponent {
     if (others && others.size > 1) {
       suffix = `+${others.size}`;
     } else {
-      suffix = <span className='display-name__account'>@{account.get('acct')}</span>;
+      let acct = account.get('acct');
+
+      if (acct.indexOf('@') === -1 && localDomain) {
+        acct = `${acct}@${localDomain}`;
+      }
+
+      suffix = <span className='display-name__account'>@{acct}</span>;
     }
 
     return (
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index fd0780025..20d838500 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -77,7 +77,7 @@ class Status extends ImmutablePureComponent {
     'account',
     'muted',
     'hidden',
-  ]
+  ];
 
   handleClick = () => {
     if (this.props.onClick) {
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
index 759922638..333726f94 100644
--- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
@@ -1,28 +1,32 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import StatusListContainer from '../../ui/containers/status_list_container';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 import { expandHashtagTimeline } from '../../../actions/timelines';
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
 import { connectHashtagStream } from '../../../actions/streaming';
+import Masonry from 'react-masonry-infinite';
+import { List as ImmutableList } from 'immutable';
+import DetailedStatusContainer from '../../status/containers/detailed_status_container';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../../components/loading_indicator';
 
-export default @connect()
+const mapStateToProps = (state, { hashtag }) => ({
+  statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
+  isLoading: state.getIn(['timelines', `hashtag:${hashtag}`, 'isLoading'], false),
+  hasMore: state.getIn(['timelines', `hashtag:${hashtag}`, 'hasMore'], false),
+});
+
+export default @connect(mapStateToProps)
 class HashtagTimeline extends React.PureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool.isRequired,
+    hasMore: PropTypes.bool.isRequired,
     hashtag: PropTypes.string.isRequired,
   };
 
-  handleHeaderClick = () => {
-    this.column.scrollTop();
-  }
-
-  setRef = c => {
-    this.column = c;
-  }
-
   componentDidMount () {
     const { dispatch, hashtag } = this.props;
 
@@ -37,28 +41,52 @@ class HashtagTimeline extends React.PureComponent {
     }
   }
 
-  handleLoadMore = maxId => {
-    this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
+  handleLoadMore = () => {
+    const maxId = this.props.statusIds.last();
+
+    if (maxId) {
+      this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
+    }
+  }
+
+  setRef = c => {
+    this.masonry = c;
   }
 
+  handleHeightChange = debounce(() => {
+    if (!this.masonry) {
+      return;
+    }
+
+    this.masonry.forcePack();
+  }, 50)
+
   render () {
-    const { hashtag } = this.props;
+    const { statusIds, hasMore, isLoading } = this.props;
+
+    const sizes = [
+      { columns: 1, gutter: 0 },
+      { mq: '415px', columns: 1, gutter: 10 },
+      { mq: '640px', columns: 2, gutter: 10 },
+      { mq: '960px', columns: 3, gutter: 10 },
+      { mq: '1255px', columns: 3, gutter: 10 },
+    ];
+
+    const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
 
     return (
-      <Column ref={this.setRef}>
-        <ColumnHeader
-          icon='hashtag'
-          title={hashtag}
-          onClick={this.handleHeaderClick}
-        />
-
-        <StatusListContainer
-          trackScroll={false}
-          scrollKey='standalone_hashtag_timeline'
-          timelineId={`hashtag:${hashtag}`}
-          onLoadMore={this.handleLoadMore}
-        />
-      </Column>
+      <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
+        {statusIds.map(statusId => (
+          <div className='statuses-grid__item' key={statusId}>
+            <DetailedStatusContainer
+              id={statusId}
+              compact
+              measureHeight
+              onHeightChange={this.handleHeightChange}
+            />
+          </div>
+        )).toArray()}
+      </Masonry>
     );
   }
 
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index b0dea8817..0630387d2 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -11,6 +11,8 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
 import Card from './card';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Video from '../../video';
+import scheduleIdleTask from '../../ui/util/schedule_idle_task';
+import classNames from 'classnames';
 
 export default class DetailedStatus extends ImmutablePureComponent {
 
@@ -23,10 +25,18 @@ export default class DetailedStatus extends ImmutablePureComponent {
     onOpenMedia: PropTypes.func.isRequired,
     onOpenVideo: PropTypes.func.isRequired,
     onToggleHidden: PropTypes.func.isRequired,
+    measureHeight: PropTypes.bool,
+    onHeightChange: PropTypes.func,
+    domain: PropTypes.string.isRequired,
+    compact: PropTypes.bool,
+  };
+
+  state = {
+    height: null,
   };
 
   handleAccountClick = (e) => {
-    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
       e.preventDefault();
       this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
     }
@@ -42,13 +52,57 @@ export default class DetailedStatus extends ImmutablePureComponent {
     this.props.onToggleHidden(this.props.status);
   }
 
+  _measureHeight (heightJustChanged) {
+    if (this.props.measureHeight && this.node) {
+      scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
+
+      if (this.props.onHeightChange && heightJustChanged) {
+        this.props.onHeightChange();
+      }
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+    this._measureHeight();
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    this._measureHeight(prevState.height !== this.state.height);
+  }
+
+  handleModalLink = e => {
+    e.preventDefault();
+
+    let href;
+
+    if (e.target.nodeName !== 'A') {
+      href = e.target.parentNode.href;
+    } else {
+      href = e.target.href;
+    }
+
+    window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
+  }
+
   render () {
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
+    const outerStyle = { boxSizing: 'border-box' };
+    const { compact } = this.props;
+
+    if (!status) {
+      return null;
+    }
 
     let media           = '';
     let applicationLink = '';
     let reblogLink = '';
     let reblogIcon = 'retweet';
+    let favouriteLink = '';
+
+    if (this.props.measureHeight) {
+      outerStyle.height = `${this.state.height}px`;
+    }
 
     if (status.get('media_attachments').size > 0) {
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
@@ -95,35 +149,63 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
     if (status.get('visibility') === 'private') {
       reblogLink = <i className={`fa fa-${reblogIcon}`} />;
+    } else if (this.context.router) {
+      reblogLink = (
+        <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+          <i className={`fa fa-${reblogIcon}`} />
+          <span className='detailed-status__reblogs'>
+            <FormattedNumber value={status.get('reblogs_count')} />
+          </span>
+        </Link>
+      );
     } else {
-      reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
-        <i className={`fa fa-${reblogIcon}`} />
-        <span className='detailed-status__reblogs'>
-          <FormattedNumber value={status.get('reblogs_count')} />
-        </span>
-      </Link>);
+      reblogLink = (
+        <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <i className={`fa fa-${reblogIcon}`} />
+          <span className='detailed-status__reblogs'>
+            <FormattedNumber value={status.get('reblogs_count')} />
+          </span>
+        </a>
+      );
     }
 
-    return (
-      <div className='detailed-status'>
-        <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
-          <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
-          <DisplayName account={status.get('account')} />
+    if (this.context.router) {
+      favouriteLink = (
+        <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+          <i className='fa fa-star' />
+          <span className='detailed-status__favorites'>
+            <FormattedNumber value={status.get('favourites_count')} />
+          </span>
+        </Link>
+      );
+    } else {
+      favouriteLink = (
+        <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <i className='fa fa-star' />
+          <span className='detailed-status__favorites'>
+            <FormattedNumber value={status.get('favourites_count')} />
+          </span>
         </a>
+      );
+    }
 
-        <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
-
-        {media}
-
-        <div className='detailed-status__meta'>
-          <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
-            <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
-          </a>{applicationLink} · {reblogLink} · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
-            <i className='fa fa-star' />
-            <span className='detailed-status__favorites'>
-              <FormattedNumber value={status.get('favourites_count')} />
-            </span>
-          </Link>
+    return (
+      <div style={outerStyle}>
+        <div ref={this.setRef} className={classNames('detailed-status', { compact })}>
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+            <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
+            <DisplayName account={status.get('account')} localDomain={this.props.domain} />
+          </a>
+
+          <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
+
+          {media}
+
+          <div className='detailed-status__meta'>
+            <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
+              <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
+            </a>{applicationLink} · {reblogLink} · {favouriteLink}
+          </div>
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
new file mode 100644
index 000000000..2c0db0a6b
--- /dev/null
+++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
@@ -0,0 +1,172 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import DetailedStatus from '../components/detailed_status';
+import { makeGetStatus } from '../../../selectors';
+import {
+  replyCompose,
+  mentionCompose,
+  directCompose,
+} from '../../../actions/compose';
+import {
+  reblog,
+  favourite,
+  unreblog,
+  unfavourite,
+  pin,
+  unpin,
+} from '../../../actions/interactions';
+import { blockAccount } from '../../../actions/accounts';
+import {
+  muteStatus,
+  unmuteStatus,
+  deleteStatus,
+  hideStatus,
+  revealStatus,
+} from '../../../actions/statuses';
+import { initMuteModal } from '../../../actions/mutes';
+import { initReport } from '../../../actions/reports';
+import { openModal } from '../../../actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, deleteModal } from '../../../initial_state';
+import { showAlertForError } from '../../../actions/alerts';
+
+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?' },
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props),
+    domain: state.getIn(['meta', 'domain']),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onReply (status, router) {
+    dispatch((_, getState) => {
+      let state = getState();
+      if (state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onConfirm: () => dispatch(replyCompose(status, router)),
+        }));
+      } else {
+        dispatch(replyCompose(status, router));
+      }
+    });
+  },
+
+  onModalReblog (status) {
+    dispatch(reblog(status));
+  },
+
+  onReblog (status, e) {
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !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));
+    }
+  },
+
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', {
+      url: status.get('url'),
+      onError: error => dispatch(showAlertForError(error)),
+    }));
+  },
+
+  onDelete (status, history, withRedraft = false) {
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      }));
+    }
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  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(initMuteModal(account));
+  },
+
+  onMuteConversation (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
+  onToggleHidden (status) {
+    if (status.get('hidden')) {
+      dispatch(revealStatus(status.get('id')));
+    } else {
+      dispatch(hideStatus(status.get('id')));
+    }
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index a092f7bb1..d48b682eb 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -101,6 +101,7 @@ const makeMapStateToProps = () => {
       ancestorsIds,
       descendantsIds,
       askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+      domain: state.getIn(['meta', 'domain']),
     };
   };
 
@@ -123,6 +124,7 @@ class Status extends ImmutablePureComponent {
     descendantsIds: ImmutablePropTypes.list,
     intl: PropTypes.object.isRequired,
     askReplyConfirmation: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
   };
 
   state = {
@@ -387,7 +389,7 @@ class Status extends ImmutablePureComponent {
 
   render () {
     let ancestors, descendants;
-    const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl } = this.props;
+    const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain } = this.props;
     const { fullscreen } = this.state;
 
     if (status === null) {
@@ -438,6 +440,7 @@ class Status extends ImmutablePureComponent {
                   onOpenVideo={this.handleOpenVideo}
                   onOpenMedia={this.handleOpenMedia}
                   onToggleHidden={this.handleToggleHidden}
+                  domain={domain}
                 />
 
                 <ActionBar
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index ed338c2eb..b7e350cbc 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -33,7 +33,7 @@ const messages = defineMessages({
   publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
 });
 
-const shouldHideFAB = path => path.match(/^\/statuses\//);
+const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/);
 
 export default @(component => injectIntl(component, { withRef: true }))
 class ColumnsArea extends ImmutablePureComponent {
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index a59c0a257..f01c2bf24 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -243,6 +243,7 @@ class UI extends React.PureComponent {
   }
 
   handleDragOver = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return false;
     e.preventDefault();
     e.stopPropagation();
 
@@ -256,6 +257,7 @@ class UI extends React.PureComponent {
   }
 
   handleDrop = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return;
     e.preventDefault();
 
     this.setState({ draggingOver: false });
@@ -279,6 +281,10 @@ class UI extends React.PureComponent {
     this.setState({ draggingOver: false });
   }
 
+  dataTransferIsText = (dataTransfer) => {
+    return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1);
+  }
+
   closeUploadModal = () => {
     this.setState({ draggingOver: false });
   }
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 302ff2573..11c31877c 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -8,7 +8,7 @@
   "account.disclaimer_full": "La informació següent pot reflectir incompleta el perfil de l'usuari.",
   "account.domain_blocked": "Domini ocult",
   "account.edit_profile": "Editar el perfil",
-  "account.endorse": "Característica del perfil",
+  "account.endorse": "Recomanar en el teu perfil",
   "account.follow": "Segueix",
   "account.followers": "Seguidors",
   "account.followers.empty": "Encara ningú no segueix aquest usuari.",
diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json
index 471b70439..f35b96244 100644
--- a/app/javascript/mastodon/locales/cy.json
+++ b/app/javascript/mastodon/locales/cy.json
@@ -84,7 +84,7 @@
   "confirmations.block.confirm": "Blocio",
   "confirmations.block.message": "Ydych chi'n sicr eich bod eisiau blocio {name}?",
   "confirmations.delete.confirm": "Dileu",
-  "confirmations.delete.message": "Ydych chi'n sicr eich bod eisiau dileu y statws hwn?",
+  "confirmations.delete.message": "Ydych chi'n sicr eich bod eisiau dileu y tŵt hwn?",
   "confirmations.delete_list.confirm": "Dileu",
   "confirmations.delete_list.message": "Ydych chi'n sicr eich bod eisiau dileu y rhestr hwn am byth?",
   "confirmations.domain_block.confirm": "Cuddio parth cyfan",
@@ -92,12 +92,12 @@
   "confirmations.mute.confirm": "Tawelu",
   "confirmations.mute.message": "Ydych chi'n sicr eich bod am ddistewi {name}?",
   "confirmations.redraft.confirm": "Dileu & ailddrafftio",
-  "confirmations.redraft.message": "Ydych chi'n siwr eich bod eisiau dileu y statws hwn a'i ailddrafftio? Bydd ffefrynnau a bwstiau'n cael ei colli, a bydd ymatebion i'r statws gwreiddiol yn cael eu hamddifadu.",
+  "confirmations.redraft.message": "Ydych chi'n siwr eich bod eisiau dileu y tŵt hwn a'i ailddrafftio? Bydd ffefrynnau a bwstiau'n cael ei colli, a bydd ymatebion i'r tŵt gwreiddiol yn cael eu hamddifadu.",
   "confirmations.reply.confirm": "Ateb",
   "confirmations.reply.message": "Bydd ateb nawr yn cymryd lle y neges yr ydych yn cyfansoddi ar hyn o bryd. Ydych chi'n sicr yr ydych am barhau?",
   "confirmations.unfollow.confirm": "Dad-ddilynwch",
   "confirmations.unfollow.message": "Ydych chi'n sicr eich bod am ddad-ddilyn {name}?",
-  "embed.instructions": "Mewnblannwch y statws hwn ar eich gwefan drwy gopïo'r côd isod.",
+  "embed.instructions": "Mewnblannwch y tŵt hwn ar eich gwefan drwy gopïo'r côd isod.",
   "embed.preview": "Dyma sut olwg fydd arno:",
   "emoji_button.activity": "Gweithgarwch",
   "emoji_button.custom": "Unigryw",
@@ -169,12 +169,12 @@
   "keyboard_shortcuts.back": "i lywio nôl",
   "keyboard_shortcuts.blocked": "i agor rhestr defnyddwyr a flociwyd",
   "keyboard_shortcuts.boost": "i fŵstio",
-  "keyboard_shortcuts.column": "i ffocysu statws yn un o'r colofnau",
+  "keyboard_shortcuts.column": "i ffocysu tŵt yn un o'r colofnau",
   "keyboard_shortcuts.compose": "i ffocysu yr ardal cyfansoddi testun",
   "keyboard_shortcuts.description": "Disgrifiad",
   "keyboard_shortcuts.direct": "i agor colofn negeseuon preifat",
   "keyboard_shortcuts.down": "i symud lawr yn y rhestr",
-  "keyboard_shortcuts.enter": "i agor statws",
+  "keyboard_shortcuts.enter": "i agor tŵt",
   "keyboard_shortcuts.favourite": "i hoffi",
   "keyboard_shortcuts.favourites": "i agor rhestr hoffi",
   "keyboard_shortcuts.federated": "i agor ffrwd y ffederasiwn",
@@ -234,10 +234,10 @@
   "navigation_bar.preferences": "Dewisiadau",
   "navigation_bar.public_timeline": "Ffrwd y ffederasiwn",
   "navigation_bar.security": "Diogelwch",
-  "notification.favourite": "hoffodd {name} eich statws",
+  "notification.favourite": "hoffodd {name} eich tŵt",
   "notification.follow": "dilynodd {name} chi",
   "notification.mention": "Soniodd {name} amdanoch chi",
-  "notification.reblog": "{name} boosted your status",
+  "notification.reblog": "Hysbysebodd {name} eich tŵt",
   "notifications.clear": "Clirio hysbysiadau",
   "notifications.clear_confirmation": "Ydych chi'n sicr eich bod am glirio'ch holl hysbysiadau am byth?",
   "notifications.column_settings.alert": "Hysbysiadau bwrdd gwaith",
@@ -257,7 +257,7 @@
   "notifications.filter.follows": "Yn dilyn",
   "notifications.filter.mentions": "Mentions",
   "notifications.group": "{count} o hysbysiadau",
-  "privacy.change": "Addasu preifatrwdd y statws",
+  "privacy.change": "Addasu preifatrwdd y tŵt",
   "privacy.direct.long": "Cyhoeddi i'r defnyddwyr sy'n cael eu crybwyll yn unig",
   "privacy.direct.short": "Uniongyrchol",
   "privacy.private.long": "Cyhoeddi i ddilynwyr yn unig",
@@ -284,7 +284,7 @@
   "search_popout.search_format": "Fformat chwilio uwch",
   "search_popout.tips.full_text": "Mae testun syml yn dychwelyd tŵtiau yr ydych wedi ysgrifennu, hoffi, wedi'u bŵstio, neu wedi'ch crybwyll ynddynt, ynghyd a chyfateb a enwau defnyddwyr, enwau arddangos ac hashnodau.",
   "search_popout.tips.hashtag": "hashnod",
-  "search_popout.tips.status": "statws",
+  "search_popout.tips.status": "tŵt",
   "search_popout.tips.text": "Mae testun syml yn dychwelyd enwau arddangos, enwau defnyddwyr a hashnodau sy'n cyfateb",
   "search_popout.tips.user": "defnyddiwr",
   "search_results.accounts": "Pobl",
@@ -293,7 +293,7 @@
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "Golwg tu fewn...",
   "status.admin_account": "Open moderation interface for @{name}",
-  "status.admin_status": "Open this status in the moderation interface",
+  "status.admin_status": "Open this tŵt in the moderation interface",
   "status.block": "Blocio @{name}",
   "status.cancel_reblog_private": "Dadfŵstio",
   "status.cannot_reblog": "Ni ellir sbarduno'r tŵt hwn",
@@ -309,7 +309,7 @@
   "status.more": "Mwy",
   "status.mute": "Tawelu @{name}",
   "status.mute_conversation": "Tawelu sgwrs",
-  "status.open": "Ehangu'r statws hwn",
+  "status.open": "Ehangu'r tŵt hwn",
   "status.pin": "Pinio ar y proffil",
   "status.pinned": "Pinio tŵt",
   "status.read_more": "Darllen mwy",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index b4181ea05..60f481076 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1973,6 +1973,43 @@
         "id": "confirmations.block.confirm"
       },
       {
+        "defaultMessage": "Reply",
+        "id": "confirmations.reply.confirm"
+      },
+      {
+        "defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
+        "id": "confirmations.reply.message"
+      },
+      {
+        "defaultMessage": "Are you sure you want to block {name}?",
+        "id": "confirmations.block.message"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/status/containers/detailed_status_container.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Delete",
+        "id": "confirmations.delete.confirm"
+      },
+      {
+        "defaultMessage": "Are you sure you want to delete this status?",
+        "id": "confirmations.delete.message"
+      },
+      {
+        "defaultMessage": "Delete & redraft",
+        "id": "confirmations.redraft.confirm"
+      },
+      {
+        "defaultMessage": "Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
+        "id": "confirmations.redraft.message"
+      },
+      {
+        "defaultMessage": "Block",
+        "id": "confirmations.block.confirm"
+      },
+      {
         "defaultMessage": "Show more for all",
         "id": "status.show_more_all"
       },
diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json
index f213f8ea3..a1a514f49 100644
--- a/app/javascript/mastodon/locales/ro.json
+++ b/app/javascript/mastodon/locales/ro.json
@@ -132,7 +132,7 @@
   "follow_request.authorize": "Autorizează",
   "follow_request.reject": "Respinge",
   "getting_started.developers": "Dezvoltatori",
-  "getting_started.directory": "Directorul profilului",
+  "getting_started.directory": "Explorează",
   "getting_started.documentation": "Documentație",
   "getting_started.heading": "Începe",
   "getting_started.invite": "Invită prieteni",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index 91ecbbce7..a3467785a 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -70,7 +70,7 @@
   "compose_form.direct_message_warning": "Tento príspevok bude videný výhradne iba spomenutými užívateľmi. Ber ale na vedomie že správci tvojej a všetkých iných zahrnutých instancií majú možnosť skontrolovať túto správu.",
   "compose_form.direct_message_warning_learn_more": "Zistiť viac",
   "compose_form.hashtag_warning": "Tento toot nebude zobrazený pod žiadným haštagom lebo nieje listovaný. Iba verejné tooty môžu byť nájdené podľa haštagu.",
-  "compose_form.lock_disclaimer": "Váš účet nie je zamknutý. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.",
+  "compose_form.lock_disclaimer": "Váš účet nie je {locked}. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.",
   "compose_form.lock_disclaimer.lock": "zamknutý",
   "compose_form.placeholder": "Čo máš na mysli?",
   "compose_form.publish": "Pošli",
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 69441d315..69e6ba0ec 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -91,6 +91,14 @@ function main() {
     if (parallaxComponents.length > 0 ) {
       new Rellax('.parallax', { speed: -1 });
     }
+
+    if (document.body.classList.contains('with-modals')) {
+      const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
+      const scrollbarWidthStyle = document.createElement('style');
+      scrollbarWidthStyle.id = 'scrollbar-width';
+      document.head.appendChild(scrollbarWidthStyle);
+      scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0);
+    }
   });
 }
 
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index 47ac9265b..c6f249fab 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -364,7 +364,7 @@ $small-breakpoint: 960px;
 
   @media screen and (max-width: $column-breakpoint) {
     .grid {
-      grid-template-columns: auto;
+      grid-template-columns: 100%;
 
       .column-0 {
         display: block;
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 8de53ca98..a98fa52c4 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -295,7 +295,7 @@
         color: $primary-text-color;
       }
 
-      @media screen and (max-width: $no-gap-breakpoint) {
+      @media screen and (max-width: 550px) {
         &.optional {
           display: none;
         }
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 6132dd1ae..bab982706 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -419,7 +419,7 @@ code {
     background: darken($ui-base-color, 10%) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") no-repeat right 8px center / auto 16px;
     border: 1px solid darken($ui-base-color, 14%);
     border-radius: 4px;
-    padding: 10px;
+    padding-left: 10px;
     padding-right: 30px;
     height: 41px;
   }
diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss
index 87e633c70..c97337e4e 100644
--- a/app/javascript/styles/mastodon/widgets.scss
+++ b/app/javascript/styles/mastodon/widgets.scss
@@ -425,3 +425,93 @@
     border-radius: 0;
   }
 }
+
+$maximum-width: 1235px;
+$fluid-breakpoint: $maximum-width + 20px;
+
+.statuses-grid {
+  min-height: 600px;
+
+  @media screen and (max-width: 640px) {
+    width: 100% !important; // Masonry layout is unnecessary at this width
+  }
+
+  &__item {
+    width: (960px - 20px) / 3;
+
+    @media screen and (max-width: $fluid-breakpoint) {
+      width: (940px - 20px) / 3;
+    }
+
+    @media screen and (max-width: 640px) {
+      width: 100%;
+    }
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      width: 100vw;
+    }
+  }
+
+  .detailed-status {
+    border-radius: 4px;
+
+    @media screen and (max-width: $no-gap-breakpoint) {
+      border-top: 1px solid lighten($ui-base-color, 16%);
+    }
+
+    &.compact {
+      .detailed-status__meta {
+        margin-top: 15px;
+      }
+
+      .status__content {
+        font-size: 15px;
+        line-height: 20px;
+
+        .emojione {
+          width: 20px;
+          height: 20px;
+          margin: -3px 0 0;
+        }
+
+        .status__content__spoiler-link {
+          line-height: 20px;
+          margin: 0;
+        }
+      }
+
+      .media-gallery,
+      .status-card,
+      .video-player {
+        margin-top: 15px;
+      }
+    }
+  }
+}
+
+.notice-widget {
+  margin-bottom: 10px;
+  color: $darker-text-color;
+
+  p {
+    margin-bottom: 10px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    font-size: 14px;
+    line-height: 20px;
+    text-decoration: none;
+    font-weight: 500;
+    color: $ui-highlight-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+}