about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibG <thib@sitedethib.com>2019-01-20 13:56:37 +0100
committerGitHub <noreply@github.com>2019-01-20 13:56:37 +0100
commitaa362ab73dc7121104b3c01800152b9fc56ea396 (patch)
treef0f5879a51325856887a2e32a4c3f9654af61aab /app
parent530d29148ca0c5bf29f6fa516b1ef4f91d95894b (diff)
parent5145c81620efbd5cf8dc911858d17d1fa888c996 (diff)
Merge pull request #888 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
Diffstat (limited to 'app')
-rw-r--r--app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb2
-rw-r--r--app/controllers/tags_controller.rb2
-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
-rw-r--r--app/lib/activitypub/activity/announce.rb14
-rw-r--r--app/lib/activitypub/activity/create.rb9
-rw-r--r--app/lib/activitypub/activity/delete.rb20
-rw-r--r--app/models/status.rb4
-rw-r--r--app/models/tombstone.rb16
-rw-r--r--app/models/user.rb3
-rw-r--r--app/services/activitypub/process_account_service.rb6
-rw-r--r--app/services/precompute_feed_service.rb1
-rw-r--r--app/services/unfollow_service.rb15
-rw-r--r--app/views/directories/index.html.haml18
-rw-r--r--app/views/tags/_features.html.haml25
-rw-r--r--app/views/tags/show.html.haml32
45 files changed, 1084 insertions, 210 deletions
diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
index 4315b0283..6851099f6 100644
--- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
+++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
@@ -25,7 +25,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController
   end
 
   def paginated_statuses
-    Status.where(reblog_of_id: @status.id).paginate_by_max_id(
+    Status.where(reblog_of_id: @status.id).where(visibility: [:public, :unlisted]).paginate_by_max_id(
       limit_param(DEFAULT_ACCOUNTS_LIMIT),
       params[:max_id],
       params[:since_id]
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index d06f4e070..186d276c2 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -3,6 +3,8 @@
 class TagsController < ApplicationController
   PAGE_SIZE = 20
 
+  layout 'public'
+
   before_action :set_body_classes
   before_action :set_instance_presenter
 
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;
+    }
+  }
+}
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 1147a4481..34d1b7cbd 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -17,7 +17,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
       uri: @json['id'],
       created_at: @json['published'],
       override_timestamps: @options[:override_timestamps],
-      visibility: original_status.visibility
+      visibility: visibility_from_audience
     )
 
     distribute(status)
@@ -26,6 +26,18 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
 
   private
 
+  def visibility_from_audience
+    if equals_or_includes?(@json['to'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :public
+    elsif equals_or_includes?(@json['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
+      :unlisted
+    elsif equals_or_includes?(@json['to'], @account.followers_url)
+      :private
+    else
+      :direct
+    end
+  end
+
   def announceable?(status)
     status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility?
   end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 2b238bc88..b49657d4b 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -5,10 +5,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   CONVERTED_TYPES = %w(Image Video Article Page).freeze
 
   def perform
-    return if delete_arrived_first?(object_uri) || unsupported_object_type? || invalid_origin?(@object['id'])
+    return if unsupported_object_type? || invalid_origin?(@object['id'])
+    return if Tombstone.exists?(uri: @object['id'])
 
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
+        return if delete_arrived_first?(object_uri)
+
         @status = find_existing_status
 
         if @status.nil?
@@ -59,7 +62,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
         account: @account,
         text: text_from_content || '',
         language: detected_language,
-        spoiler_text: text_from_summary || '',
+        spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
         created_at: @object['published'],
         override_timestamps: @options[:override_timestamps],
         reply: @object['inReplyTo'].present?,
@@ -254,7 +257,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def text_from_content
-    return Formatter.instance.linkify([text_from_name, object_url || @object['id']].join(' ')) if converted_object_type?
+    return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || @object['id']].join(' ')) if converted_object_type?
 
     if @object['content'].present?
       @object['content']
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index 8270fed1b..dc76dd3e2 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -21,11 +21,14 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
   def delete_note
     return if object_uri.nil?
 
+    unless invalid_origin?(object_uri)
+      RedisLock.acquire(lock_options) { |_lock| delete_later!(object_uri) }
+      Tombstone.find_or_create_by(uri: object_uri, account: @account)
+    end
+
     @status   = Status.find_by(uri: object_uri, account: @account)
     @status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
 
-    delete_later!(object_uri)
-
     return if @status.nil?
 
     if @status.public_visibility? || @status.unlisted_visibility?
@@ -68,4 +71,17 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
   def payload
     @payload ||= Oj.dump(@json)
   end
+
+  def lock_options
+    { redis: Redis.current, key: "create:#{object_uri}" }
+  end
+
+  def invalid_origin?(url)
+    return true if unsupported_uri_scheme?(url)
+
+    needle   = Addressable::URI.parse(url).host
+    haystack = Addressable::URI.parse(@account.uri).host
+
+    !haystack.casecmp(needle).zero?
+  end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index e709b16c8..4566c0d20 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -504,7 +504,7 @@ class Status < ApplicationRecord
     return if direct_visibility?
 
     account&.increment_count!(:statuses_count)
-    reblog&.increment_count!(:reblogs_count) if reblog?
+    reblog&.increment_count!(:reblogs_count) if reblog? && (public_visibility? || unlisted_visibility?)
     thread&.increment_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
   end
 
@@ -512,7 +512,7 @@ class Status < ApplicationRecord
     return if direct_visibility? || marked_for_mass_destruction?
 
     account&.decrement_count!(:statuses_count)
-    reblog&.decrement_count!(:reblogs_count) if reblog?
+    reblog&.decrement_count!(:reblogs_count) if reblog? && (public_visibility? || unlisted_visibility?)
     thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
   end
 
diff --git a/app/models/tombstone.rb b/app/models/tombstone.rb
new file mode 100644
index 000000000..997bb65fd
--- /dev/null
+++ b/app/models/tombstone.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: tombstones
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)
+#  uri        :string           not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class Tombstone < ApplicationRecord
+  belongs_to :account
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 83ccce948..0425c1772 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -362,7 +362,8 @@ class User < ApplicationRecord
   end
 
   def regenerate_feed!
-    Redis.current.setnx("account:#{account_id}:regeneration", true) && Redis.current.expire("account:#{account_id}:regeneration", 1.day.seconds)
+    return unless Redis.current.setnx("account:#{account_id}:regeneration", true)
+    Redis.current.expire("account:#{account_id}:regeneration", 1.day.seconds)
     RegenerationWorker.perform_async(account_id)
   end
 
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index d6c791b44..487456f3a 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -33,6 +33,8 @@ class ActivityPub::ProcessAccountService < BaseService
 
     after_protocol_change! if protocol_changed?
     after_key_change! if key_changed? && !@options[:signed_with_known_key]
+    clear_tombstones! if key_changed?
+
     unless @options[:only_key]
       check_featured_collection! if @account.featured_collection_url.present?
       check_links! unless @account.fields.empty?
@@ -209,6 +211,10 @@ class ActivityPub::ProcessAccountService < BaseService
     !@old_public_key.nil? && @old_public_key != @account.public_key
   end
 
+  def clear_tombstones!
+    Tombstone.delete_all(account_id: @account.id)
+  end
+
   def protocol_changed?
     !@old_protocol.nil? && @old_protocol != @account.protocol
   end
diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb
index 4f771ff72..076dedaca 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -3,6 +3,7 @@
 class PrecomputeFeedService < BaseService
   def call(account)
     FeedManager.instance.populate_feed(account)
+  ensure
     Redis.current.del("account:#{account.id}:regeneration")
   end
 end
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index 03e45912d..95da2a667 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -20,6 +20,7 @@ class UnfollowService < BaseService
 
     follow.destroy!
     create_notification(follow) unless @target_account.local?
+    create_reject_notification(follow) if @target_account.local? && !@source_account.local?
     UnmergeWorker.perform_async(@target_account.id, @source_account.id)
     follow
   end
@@ -42,6 +43,12 @@ class UnfollowService < BaseService
     end
   end
 
+  def create_reject_notification(follow)
+    # Rejecting an already-existing follow request
+    return unless follow.account.activitypub?
+    ActivityPub::DeliveryWorker.perform_async(build_reject_json(follow), follow.target_account_id, follow.account.inbox_url)
+  end
+
   def build_json(follow)
     ActiveModelSerializers::SerializableResource.new(
       follow,
@@ -50,6 +57,14 @@ class UnfollowService < BaseService
     ).to_json
   end
 
+  def build_reject_json(follow)
+    ActiveModelSerializers::SerializableResource.new(
+      follow,
+      serializer: ActivityPub::RejectFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).to_json
+  end
+
   def build_xml(follow)
     OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow))
   end
diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml
index 9e1e65732..6608a5dcb 100644
--- a/app/views/directories/index.html.haml
+++ b/app/views/directories/index.html.haml
@@ -41,8 +41,22 @@
       = paginate @accounts
 
   .column-1
-    - if @tags.empty?
-      .nothing-here.nothing-here--flexible
+    - if user_signed_in?
+      .box-widget.notice-widget
+        - if current_account.discoverable?
+          - if current_account.followers_count < Account::MIN_FOLLOWERS_DISCOVERY
+            %p= t('directories.enabled_but_waiting', min_followers: Account::MIN_FOLLOWERS_DISCOVERY)
+          - else
+            %p= t('directories.enabled')
+        - else
+          %p= t('directories.how_to_enable')
+
+          = link_to settings_profile_path do
+            = t('settings.edit_profile')
+            = fa_icon 'chevron-right fw'
+
+    - if @tags.empty? && !user_signed_in?
+      .nothing-here
     - else
       - @tags.each do |tag|
         .directory__tag{ class: tag.id == @tag&.id ? 'active' : nil }
diff --git a/app/views/tags/_features.html.haml b/app/views/tags/_features.html.haml
deleted file mode 100644
index 8fbc6b760..000000000
--- a/app/views/tags/_features.html.haml
+++ /dev/null
@@ -1,25 +0,0 @@
-.features-list
-  .features-list__row
-    .text
-      %h6= t 'about.features.real_conversation_title'
-      = t 'about.features.real_conversation_body'
-    .visual
-      = fa_icon 'fw comments'
-  .features-list__row
-    .text
-      %h6= t 'about.features.not_a_product_title'
-      = t 'about.features.not_a_product_body'
-    .visual
-      = fa_icon 'fw users'
-  .features-list__row
-    .text
-      %h6= t 'about.features.within_reach_title'
-      = t 'about.features.within_reach_body'
-    .visual
-      = fa_icon 'fw mobile'
-  .features-list__row
-    .text
-      %h6= t 'about.features.humane_approach_title'
-      = t 'about.features.humane_approach_body'
-    .visual
-      = fa_icon 'fw leaf'
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index 2b46e58c7..850160ac1 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -7,33 +7,9 @@
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
   = render 'og'
 
-.landing-page.tag-page.alternative
-  .features
-    .container
-      .grid
-        .column-1
-          #mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) } }
-
-        .column-2
-          .about-mastodon
-            .about-hashtag.landing-page__information
-              .brand
-                = link_to root_url do
-                  = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
-
-              %p= t 'about.about_hashtag_html', hashtag: @tag.name
-
-              .cta
-                - if user_signed_in?
-                  = link_to t('settings.back'), root_path, class: 'button button-secondary'
-                - else
-                  = link_to t('auth.login'), new_user_session_path, class: 'button button-secondary'
-                = link_to t('about.learn_more'), about_path, class: 'button button-alternative'
-
-            .landing-page__features.landing-page__information
-              %h3= t 'about.what_is_mastodon'
-              %p= t 'about.about_mastodon_html'
-
-              = render 'features'
+.page-header
+  %h1= "##{@tag.name}"
+  %p= t('about.about_hashtag_html', hashtag: @tag.name)
 
+#mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) }}
 #modal-container