about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFire Demon <firedemon@creature.cafe>2020-08-15 03:26:55 -0500
committerFire Demon <firedemon@creature.cafe>2020-08-30 05:45:18 -0500
commitdb8e5524db15e19c16b63943ada83b3ac7b75ec6 (patch)
tree6f366cef23a31194f51494a3c2da8287ea7b15dd
parent8efbea953c436af514f6b39eb22316100e522c90 (diff)
[Profiles] Add category filters
-rw-r--r--app/controllers/accounts_controller.rb51
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb91
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js13
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js4
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js22
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/report_modal.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js6
-rw-r--r--app/javascript/mastodon/locales/en-MP.json9
-rw-r--r--app/models/status.rb98
-rw-r--r--app/views/accounts/_header.html.haml2
-rw-r--r--app/views/accounts/show.html.haml4
-rw-r--r--config/locales/en-MP.yml7
-rw-r--r--config/routes.rb3
13 files changed, 219 insertions, 97 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index e26b6395f..8059e0e5d 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -13,7 +13,7 @@ class AccountsController < ApplicationController
   before_action :require_authenticated!, if: -> { @account.require_auth? || @account.private? }
 
   skip_around_action :set_locale, if: -> { [:json, :rss].include?(request.format&.to_sym) }
-  skip_before_action :require_functional! #, unless: :whitelist_mode?
+  skip_before_action :require_functional! # , unless: :whitelist_mode?
 
   def show
     respond_to do |format|
@@ -66,19 +66,26 @@ class AccountsController < ApplicationController
   end
 
   def show_pinned_statuses?
-    [replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
+    [threads_requested?, replies_requested?, reblogs_requested?, mentions_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
   end
 
   def filtered_statuses
+    return mentions_scope if mentions_requested?
+
     default_statuses.tap do |statuses|
-      statuses.merge!(hashtag_scope)    if tag_requested?
       statuses.merge!(only_media_scope) if media_requested?
-      statuses.merge!(no_replies_scope) unless (current_account&.id == @account.id || @account.show_replies?) && replies_requested?
     end
   end
 
   def default_statuses
-    @account.statuses.permitted_for(@account, current_account, user_signed_in: user_signed_in?).not_local_only
+    @account.statuses.permitted_for(
+      @account,
+      current_account,
+      include_reblogs: !(threads_requested? || replies_requested?),
+      only_reblogs: reblogs_requested?,
+      only_replies: replies_requested?,
+      tag: tag_requested? ? params[:tag] : nil
+    )
   end
 
   def only_media_scope
@@ -89,18 +96,10 @@ class AccountsController < ApplicationController
     @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
   end
 
-  def no_replies_scope
-    Status.without_replies
-  end
-
-  def hashtag_scope
-    tag = Tag.find_normalized(params[:tag])
+  def mentions_scope
+    return Status.none unless current_account?
 
-    if tag
-      Status.tagged_with(tag.id)
-    else
-      Status.none
-    end
+    Status.mentions_between(@account, current_account)
   end
 
   def username_param
@@ -128,8 +127,14 @@ class AccountsController < ApplicationController
       short_account_tag_url(@account, params[:tag], max_id: max_id, min_id: min_id)
     elsif media_requested?
       short_account_media_url(@account, max_id: max_id, min_id: min_id)
+    elsif threads_requested?
+      short_account_threads_url(@account, max_id: max_id, min_id: min_id)
     elsif replies_requested?
       short_account_with_replies_url(@account, max_id: max_id, min_id: min_id)
+    elsif reblogs_requested?
+      short_account_reblogs_url(@account, max_id: max_id, min_id: min_id)
+    elsif mentions_requested?
+      short_account_mentions_url(@account, max_id: max_id, min_id: min_id)
     else
       short_account_url(@account, max_id: max_id, min_id: min_id)
     end
@@ -139,7 +144,13 @@ class AccountsController < ApplicationController
     request.path.split('.').first.ends_with?('/media') && !tag_requested?
   end
 
+  def threads_requested?
+    request.path.split('.').first.ends_with?('/threads') && !tag_requested?
+  end
+
   def replies_requested?
+    return false unless current_account&.id == @account.id || @account.show_replies?
+
     request.path.split('.').first.ends_with?('/with_replies') && !tag_requested?
   end
 
@@ -147,6 +158,14 @@ class AccountsController < ApplicationController
     request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
   end
 
+  def reblogs_requested?
+    request.path.split('.').first.ends_with?('/reblogs') && !tag_requested?
+  end
+
+  def mentions_requested?
+    request.path.split('.').first.ends_with?('/mentions') && !tag_requested?
+  end
+
   def filtered_status_page
     filtered_statuses.paginate_by_id(PAGE_SIZE, params_slice(:max_id, :min_id, :since_id))
   end
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 3e0feed7e..61633ce36 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -17,6 +17,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
     @account = Account.find(params[:account_id])
   end
 
+  def owner?
+    @account.id == current_account&.id
+  end
+
   def load_statuses
     cached_account_statuses
   end
@@ -26,62 +30,75 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def account_statuses
-    return [] if (@account.private && !following?(@account)) || (@account.require_auth && !current_account?)
-
     statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
-
     statuses.merge!(only_media_scope) if truthy_param?(:only_media)
-    statuses.merge!(no_replies_scope) if (current_account&.id != @account.id && !@account.show_replies?) || truthy_param?(:exclude_replies)
-    statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
-    statuses.merge!(hashtag_scope)    if params[:tagged].present?
-
     statuses.paginate_by_id(limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id))
   end
 
   def permitted_account_statuses
-    @account.statuses.permitted_for(@account, current_account, user_signed_in: authenticated_or_following?(@account))
+    return mentions_scope if truthy_param?(:mentions)
+    return Status.none if unauthorized?
+
+    @account.statuses.permitted_for(
+      @account,
+      current_account,
+      include_reblogs: include_reblogs?,
+      include_replies: include_replies?,
+      only_reblogs: only_reblogs?,
+      only_replies: only_replies?,
+      include_unpublished: owner?,
+      tag: params[:tagged]
+    )
   end
 
-  def only_media_scope
-    Status.where(id: account_media_status_ids)
+  def unauthorized?
+    (@account.private && !following?(@account)) || (@account.require_auth && !current_account?)
   end
 
-  def account_media_status_ids
-    # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
-    # Also, Avoid getting slow by not narrowing down by `statuses.account_id`.
-    # When narrowing down by `statuses.account_id`, `index_statuses_20180106` will be used
-    # and the table will be joined by `Merge Semi Join`, so the query will be slow.
-    @account.statuses.joins(:media_attachments).merge(@account.media_attachments).permitted_for(@account, current_account, user_signed_in: authenticated_or_following?(@account))
-            .paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
-            .reorder(id: :desc).distinct(:id).pluck(:id)
+  def include_reblogs?
+    params[:include_reblogs].present? ? truthy_param?(:include_reblogs) : !truthy_param?(:exclude_reblogs)
   end
 
-  def pinned_scope
-    return Status.none if @account.blocking?(current_account)
+  def include_replies?
+    return false unless owner? || @account.show_replies?
 
-    @account.pinned_statuses
+    params[:include_replies].present? ? truthy_param?(:include_replies) : !truthy_param?(:exclude_replies)
+  end
+
+  def only_reblogs?
+    truthy_param?(:only_reblogs).presence || false
   end
 
-  def no_replies_scope
-    Status.without_replies
+  def only_replies?
+    return false unless owner? || @account.show_replies?
+
+    truthy_param?(:only_replies).presence || false
   end
 
-  def no_reblogs_scope
-    Status.without_reblogs
+  def mentions_scope
+    return Status.none unless current_account?
+
+    Status.mentions_between(@account, current_account)
   end
 
-  def hashtag_scope
-    tag = Tag.find_normalized(params[:tagged])
+  def only_media_scope
+    Status.where(id: account_media_status_ids)
+  end
 
-    if tag
-      Status.tagged_with(tag.id)
-    else
-      Status.none
-    end
+  def account_media_status_ids
+    @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
+  end
+
+  def pinned_scope
+    return Status.none if @account.blocking?(current_account)
+
+    @account.pinned_statuses
   end
 
   def pagination_params(core_params)
-    params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params)
+    params.slice(:limit, :only_media, :include_replies, :exclude_replies, :only_replies, :include_reblogs, :exclude_reblogs, :only_relogs, :mentions)
+          .permit(:limit, :only_media, :include_replies, :exclude_replies, :only_replies, :include_reblogs, :exclude_reblogs, :only_relogs, :mentions)
+          .merge(core_params)
   end
 
   def insert_pagination_headers
@@ -89,15 +106,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def next_path
-    if records_continue?
-      api_v1_account_statuses_url pagination_params(max_id: pagination_max_id)
-    end
+    api_v1_account_statuses_url pagination_params(max_id: pagination_max_id) if records_continue?
   end
 
   def prev_path
-    unless @statuses.empty?
-      api_v1_account_statuses_url pagination_params(min_id: pagination_since_id)
-    end
+    api_v1_account_statuses_url pagination_params(min_id: pagination_since_id) unless @statuses.empty?
   end
 
   def records_continue?
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index b19666e62..f6cb2d989 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -133,7 +133,18 @@ export const expandHomeTimeline            = ({ maxId } = {}, done = noOp) => ex
 export const expandPublicTimeline          = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done);
 export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
 export const expandDirectTimeline          = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
-export const expandAccountTimeline         = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
+export const expandAccountTimeline         = (accountId, { maxId, filter } = {}) => {
+  const params = {
+    include_replies: filter === ':replies',
+    include_reblogs: filter === ':reblogs',
+    only_replies: filter === ':replies',
+    only_reblogs: filter === ':reblogs',
+    mentions: filter === ':mentions',
+    max_id: maxId,
+  };
+
+  return expandTimeline(`account:${accountId}${filter}`, `/api/v1/accounts/${accountId}/statuses`, params);
+};
 export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
 export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
 export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
index 527352497..938d854eb 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -124,10 +124,12 @@ export default class Header extends ImmutablePureComponent {
 
         {!hideTabs && (
           <div className='account__section-headline'>
-            <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
+            <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.threads' defaultMessage='Threads' /></NavLink>
+            <NavLink exact to={`/accounts/${account.get('id')}/mentions`}><FormattedMessage id='account.mentions' defaultMessage='Mentions' /></NavLink>
             { (account.get('id') === me || account.get('show_replies')) &&
                 (<NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>) }
             <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
+            <NavLink exact to={`/accounts/${account.get('id')}/reblogs`}><FormattedMessage id='account.reblogs' defaultMessage='Boosts' /></NavLink>
           </div>
         )}
       </div>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index 5558ba2a3..66bf55ec4 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -17,15 +17,15 @@ import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
-const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
-  const path = withReplies ? `${accountId}:with_replies` : accountId;
+const mapStateToProps = (state, { params: { accountId }, filter = '' }) => {
+  const path = `${accountId}${filter}`;
 
   return {
     remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
     remoteUrl: state.getIn(['accounts', accountId, 'url']),
     isAccount: !!state.getIn(['accounts', accountId]),
     statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
-    featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
+    featuredStatusIds: !filter ? state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()) : ImmutableList(),
     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
     hasMore:   state.getIn(['timelines', `account:${path}`, 'hasMore']),
   };
@@ -49,7 +49,7 @@ class AccountTimeline extends ImmutablePureComponent {
     featuredStatusIds: ImmutablePropTypes.list,
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
-    withReplies: PropTypes.bool,
+    filter: PropTypes.string,
     isAccount: PropTypes.bool,
     remote: PropTypes.bool,
     remoteUrl: PropTypes.string,
@@ -57,24 +57,24 @@ class AccountTimeline extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    const { params: { accountId }, withReplies } = this.props;
+    const { params: { accountId }, filter } = this.props;
 
     this.props.dispatch(fetchAccount(accountId));
     this.props.dispatch(fetchAccountIdentityProofs(accountId));
-    if (!withReplies) {
+    if (!filter) {
       this.props.dispatch(expandAccountFeaturedTimeline(accountId));
     }
-    this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
+    this.props.dispatch(expandAccountTimeline(accountId, { filter }));
   }
 
   componentWillReceiveProps (nextProps) {
-    if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
+    if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.filter !== this.props.filter) {
       this.props.dispatch(fetchAccount(nextProps.params.accountId));
       this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
-      if (!nextProps.withReplies) {
+      if (!nextProps.filter) {
         this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
       }
-      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
+      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { filter: nextProps.params.filter }));
     }
   }
 
@@ -83,7 +83,7 @@ class AccountTimeline extends ImmutablePureComponent {
   }
 
   handleLoadMore = maxId => {
-    this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies }));
+    this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, filter: this.props.filter }));
   }
 
   setRef = c => {
diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
index 9016b08d7..7473cfbe0 100644
--- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
@@ -30,7 +30,7 @@ const makeMapStateToProps = () => {
       account: getAccount(state, accountId),
       comment: state.getIn(['reports', 'new', 'comment']),
       forward: state.getIn(['reports', 'new', 'forward']),
-      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
+      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
     };
   };
 
@@ -70,12 +70,12 @@ class ReportModal extends ImmutablePureComponent {
   }
 
   componentDidMount () {
-    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
+    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { filter: ':replies' }));
   }
 
   componentWillReceiveProps (nextProps) {
     if (this.props.account !== nextProps.account && nextProps.account) {
-      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
+      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { filter: ':replies' }));
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index bf76c0e57..ee1d898bb 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -214,8 +214,10 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
           <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
 
-          <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
-          <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
+          <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} componentParams={{ filter: '' }} />
+          <WrappedRoute path='/accounts/:accountId/mentions' component={AccountTimeline} content={children} componentParams={{ filter: ':mentions' }} />
+          <WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ filter: ':replies' }} />
+          <WrappedRoute path='/accounts/:accountId/reblogs' component={AccountTimeline} content={children} componentParams={{ filter: ':reblogs' }} />
           <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
           <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
           <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
diff --git a/app/javascript/mastodon/locales/en-MP.json b/app/javascript/mastodon/locales/en-MP.json
index 7490fca60..2e1fe2393 100644
--- a/app/javascript/mastodon/locales/en-MP.json
+++ b/app/javascript/mastodon/locales/en-MP.json
@@ -4,8 +4,12 @@
   "account.follows.empty": "This creature doesn't follow anyone yet.",
   "account.follows": "Follows",
   "account.locked_info": "This creature manually reviews who can follow them.",
-  "account.posts_with_replies": "Roars & Growls",
-  "account.posts": "Roars",
+  "account.media": "Media",
+  "account.mentions": "Mentions",
+  "account.posts_with_replies": "Replies",
+  "account.posts": "Blog",
+  "account.reblogs": "Boosts",
+  "account.threads": "Threads",
   "account.statuses_counter": "{count, plural, one {{counter} Roar} other {{counter} Roars}}",
   "advanced_options.local-only.long": "Do not post to other servers",
   "column_header.profile": "Creature",
@@ -151,7 +155,6 @@
   "upload_form.edit": "Add description text",
   "upload_modal.edit_media": "Add description text",
   "video.expand": "Open video",
-
   "column.community": "Monsterpit",
   "community.column_settings.local_only": "Monsterpit only",
   "directory.local": "From Monsterpit",
diff --git a/app/models/status.rb b/app/models/status.rb
index e568bfed7..d31e5668e 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -135,6 +135,10 @@ class Status < ApplicationRecord
   scope :without_semiprivate, -> { where(semiprivate: false) }
   scope :reblogs, -> { where('statuses.reblog_of_id IS NOT NULL') }
   scope :locally_reblogged, -> { where(id: Status.unscoped.local.reblogs.select(:reblog_of_id)) }
+  scope :public_conversations, -> { joins(:conversation).where(conversations: { public: true }) }
+  scope :conversations_by, ->(account) { joins(:conversation).where(conversations: { account: account }) }
+  scope :mentioning_account, ->(account) { joins(:mentions).where(mentions: { account: account }) }
+  scope :replies, -> { where(reply: true).where('statuses.in_reply_to_account_id != statuses.account_id') }
 
   scope :not_hidden_by_account, ->(account) do
     left_outer_joins(:mutes, :conversation_mute).where('(status_mutes.account_id IS NULL OR status_mutes.account_id != ?) AND (conversation_mutes.account_id IS NULL OR (conversation_mutes.account_id != ? AND conversation_mutes.hidden = TRUE))', account.id, account.id)
@@ -504,27 +508,27 @@ class Status < ApplicationRecord
       end
     end
 
-    def permitted_for(target_account, account, user_signed_in: false)
-      visibility = user_signed_in || target_account.show_unlisted? ? [:public, :unlisted] : :public
+    def permitted_for(target_account, account, **options)
+      visibility = [:public, :unlisted]
 
-      if account.nil?
-        where(visibility: visibility).not_local_only.published
-      elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps
-        none
-      elsif account.id == target_account.id # author can see own stuff
-        all
-      else
-        # followers can see followers-only stuff, but also things they are mentioned in.
-        # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in.
-        visibility.push(:private) if account.following?(target_account) && user_signed_in
-        scope = left_outer_joins(:reblog)
-
-        scope = scope.where(visibility: visibility)
-                     .or(scope.where(id: account.mentions.select(:status_id)))
-                     .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids })))
+      if account.present?
+        return none if target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain))
+        return apply_category_filters(all, target_account, account, **options) if account.id == target_account.id
 
-        apply_timeline_filters(scope.published, account, false)
+        visibility.push(:private) if account.following?(target_account)
+      elsif !target_account.show_unlisted?
+        visibility = :public
       end
+
+      scope = where(visibility: visibility)
+      scope = apply_account_filters(scope, account, **options)
+      apply_category_filters(scope, target_account, account, **options)
+    end
+
+    def mentions_between(account, target_account)
+      return none if account.blank? || target_account.blank?
+
+      account.statuses.mentioning_account(target_account).or(target_account.statuses.mentioning_account(account))
     end
 
     def from_text(text)
@@ -544,6 +548,64 @@ class Status < ApplicationRecord
 
     private
 
+    # TODO: Cast cleanup spell.
+    # rubocop:disable Metrics/PerceivedComplexity
+    def apply_category_filters(query, target_account, account, **options)
+      return query if options[:without_category_filters]
+
+      query = query.published unless options[:include_unpublished]
+
+      if options[:only_reblogs]
+        query = query.joins(:reblog)
+        if account.present? && account.excluded_from_timeline_account_ids.present?
+          query = query.where.not(
+            reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids }
+          )
+        end
+      elsif target_account.id == account&.id
+        query = query.without_replies unless options[:include_replies] || options[:only_replies]
+        query = query.without_reblogs unless options[:include_reblogs] || options[:only_reblogs]
+        query = query.reblogs if options[:only_reblogs]
+        query = query.replies if options[:only_replies]
+      else
+        if options[:include_reblogs] && account.present? && account.excluded_from_timeline_account_ids.present?
+          query = query.left_outer_joins(:reblog).where(
+            '(statuses.reblog_of_id IS NULL OR reblogs_statuses.account_id NOT IN (?))',
+            account.excluded_from_timeline_account_ids
+          )
+        elsif !options[:include_reblogs]
+          query = query.without_reblogs
+        end
+
+        query = if options[:only_replies]
+                  query.replies
+                elsif options[:include_replies]
+                  if target_account.present?
+                    query.public_conversations.or(query.conversations_by(target_account))
+                  else
+                    query.public_conversations
+                  end
+                else
+                  query.without_replies
+                end
+      end
+
+      return query if options[:tag].blank?
+
+      (tag = Tag.find_normalized(options[:tag])) ? query.merge(Status.tagged_with(tag.id)) : none
+    end
+    # rubocop:enable Metrics/PerceivedComplexity
+
+    def apply_account_filters(query, account, **options)
+      return query.not_local_only if account.blank?
+      return (account.local? ? query : query.not_local_only) if options[:without_account_filters]
+
+      query = query.not_local_only unless account.local?
+      query = query.not_hidden_by_account(account)
+      query = query.in_chosen_languages(account) if account.chosen_languages.present?
+      query
+    end
+
     def timeline_scope(scope = false, include_unlisted: false)
       starting_scope = case scope
                        when :local, true
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index 33a144ed2..27a29c061 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -13,7 +13,7 @@
             = fa_icon('lock') if account.locked?
       .public-account-header__tabs__tabs
         .details-counters
-          .counter{ class: active_nav_class(short_account_url(account), short_account_with_replies_url(account), short_account_media_url(account)) }
+          .counter{ class: active_nav_class(short_account_url(account), short_account_threads_url(account), short_account_with_replies_url(account), short_account_reblogs_url(account), short_account_mentions_url(account), short_account_media_url(account)) }
             = link_to short_account_url(account), class: 'u-url u-uid', title: number_with_delimiter(account.statuses_count) do
               %span.counter-label= t('accounts.posts', count: account.statuses_count)
 
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index 9eda36f1e..461262f60 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -29,9 +29,13 @@
 
       .account__section-headline
         = active_link_to t('accounts.posts_tab_heading'), short_account_url(@account)
+        = active_link_to t('accounts.threads'), short_account_threads_url(@account)
+        - if @account.present?
+          = active_link_to t('accounts.mentions'), short_account_mentions_url(@account)
         - if @account.show_replies?
           = active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account)
         = active_link_to t('accounts.media'), short_account_media_url(@account)
+        = active_link_to t('accounts.reblogs'), short_account_reblogs_url(@account)
 
       - if user_signed_in? && @account.blocking?(current_account)
         .nothing-here.nothing-here--under-tabs= t('accounts.unavailable')
diff --git a/config/locales/en-MP.yml b/config/locales/en-MP.yml
index be4be53e2..481c299e5 100644
--- a/config/locales/en-MP.yml
+++ b/config/locales/en-MP.yml
@@ -18,8 +18,11 @@ en-MP:
     posts:
       one: Roar
       other: Roars
-    posts_tab_heading: Roars
-    posts_with_replies: Roars & Growls
+    posts_tab_heading: Blog
+    posts_with_replies: Replies
+    reblogs: Boosts
+    threads: Threads
+    mentions: Mentions
   admin:
     accounts:
       search_same_email_domain: Other creatures with the same e-mail domain
diff --git a/config/routes.rb b/config/routes.rb
index 4702e766b..cdfb4a77a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -89,8 +89,11 @@ Rails.application.routes.draw do
   resource :inbox, only: [:create], module: :activitypub
 
   get '/@:username', to: 'accounts#show', as: :short_account
+  get '/@:username/threads', to: 'accounts#show', as: :short_account_threads
   get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies
   get '/@:username/media', to: 'accounts#show', as: :short_account_media
+  get '/@:username/reblogs', to: 'accounts#show', as: :short_account_reblogs
+  get '/@:username/mentions', to: 'accounts#show', as: :short_account_mentions
   get '/@:username/tagged/:tag', to: 'accounts#show', as: :short_account_tag
   get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status
   get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status