about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb1
-rw-r--r--app/controllers/tags_controller.rb1
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/helpers/formatting_helper.rb26
-rw-r--r--app/javascript/mastodon/actions/accounts.js7
-rw-r--r--app/javascript/mastodon/components/account.js9
-rw-r--r--app/javascript/mastodon/components/avatar.js28
-rw-r--r--app/javascript/mastodon/features/account/components/header.js57
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js8
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js35
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js3
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js16
-rw-r--r--app/javascript/mastodon/features/blocks/index.js2
-rw-r--r--app/javascript/mastodon/features/followers/index.js20
-rw-r--r--app/javascript/mastodon/features/following/index.js20
-rw-r--r--app/javascript/mastodon/features/mutes/index.js2
-rw-r--r--app/javascript/mastodon/reducers/accounts.js7
-rw-r--r--app/javascript/mastodon/selectors/index.js8
-rw-r--r--app/javascript/styles/mastodon/components.scss9
-rw-r--r--app/lib/emoji_formatter.rb28
-rw-r--r--app/lib/rss/builder.rb33
-rw-r--r--app/lib/rss/channel.rb49
-rw-r--r--app/lib/rss/element.rb24
-rw-r--r--app/lib/rss/item.rb45
-rw-r--r--app/lib/rss/media_content.rb29
-rw-r--r--app/lib/rss/serializer.rb55
-rw-r--r--app/lib/rss_builder.rb130
-rw-r--r--app/serializers/rest/account_serializer.rb7
-rw-r--r--app/serializers/rss/account_serializer.rb28
-rw-r--r--app/serializers/rss/tag_serializer.rb25
-rw-r--r--app/views/accounts/show.rss.ruby37
-rw-r--r--app/views/tags/show.rss.ruby36
32 files changed, 479 insertions, 310 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 03c07c50b..9949206cb 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -45,7 +45,6 @@ class AccountsController < ApplicationController
         limit     = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
         @statuses = filtered_statuses.without_reblogs.limit(limit)
         @statuses = cache_collection(@statuses, Status)
-        render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
       end
 
       format.json do
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 64736e77f..46821a200 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -27,7 +27,6 @@ class TagsController < ApplicationController
 
       format.rss do
         expires_in 0, public: true
-        render xml: RSS::TagSerializer.render(@tag, @statuses)
       end
 
       format.json do
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 1c670fde0..b26e68c4d 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -244,7 +244,7 @@ module ApplicationHelper
     end.values
   end
 
-  def prerender_custom_emojis(html, custom_emojis)
-    EmojiFormatter.new(html, custom_emojis, animate: prefers_autoplay?).to_s
+  def prerender_custom_emojis(html, custom_emojis, other_options = {})
+    EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s
   end
 end
diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb
index 53e100dd2..526efd766 100644
--- a/app/helpers/formatting_helper.rb
+++ b/app/helpers/formatting_helper.rb
@@ -18,6 +18,32 @@ module FormattingHelper
     html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type)
   end
 
+  def rss_status_content_format(status)
+    html = status_content_format(status)
+
+    before_html = begin
+      if status.spoiler_text?
+        "<p><strong>#{I18n.t('rss.content_warning', locale: valid_locale_or_nil(status.language))}</strong> #{h(status.spoiler_text)}</p><hr />"
+      else
+        ''
+      end
+    end.html_safe # rubocop:disable Rails/OutputSafety
+
+    after_html = begin
+      if status.preloadable_poll
+        "<p>#{status.preloadable_poll.options.map { |o| "<input type=#{status.preloadable_poll.multiple? ? 'checkbox' : 'radio'} disabled /> #{h(o)}" }.join('<br />')}</p>"
+      else
+        ''
+      end
+    end.html_safe # rubocop:disable Rails/OutputSafety
+
+    prerender_custom_emojis(
+      safe_join([before_html, html, after_html]),
+      status.emojis,
+      style: 'width: 1.1em; height: 1.1em; object-fit: contain; vertical-align: middle; margin: -.2ex .15em .2ex'
+    ).to_str
+  end
+
   def account_bio_format(account)
     html_aware_format(account.note, account.local?)
   end
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index ce7bb6d5f..eedf61dc9 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -77,6 +77,8 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
 export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
 export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
 
+export const ACCOUNT_REVEAL = 'ACCOUNT_REVEAL';
+
 export function fetchAccount(id) {
   return (dispatch, getState) => {
     dispatch(fetchRelationships([id]));
@@ -780,3 +782,8 @@ export function unpinAccountFail(error) {
     error,
   };
 };
+
+export const revealAccount = id => ({
+  type: ACCOUNT_REVEAL,
+  id,
+});
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index 62b5843a9..af9f119c8 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -18,6 +18,8 @@ const messages = defineMessages({
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
   mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
   unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
 });
 
 export default @injectIntl
@@ -33,6 +35,7 @@ class Account extends ImmutablePureComponent {
     hidden: PropTypes.bool,
     actionIcon: PropTypes.string,
     actionTitle: PropTypes.string,
+    defaultAction: PropTypes.string,
     onActionClick: PropTypes.func,
   };
 
@@ -61,7 +64,7 @@ class Account extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, intl, hidden, onActionClick, actionIcon, actionTitle } = this.props;
+    const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction } = this.props;
 
     if (!account) {
       return <div />;
@@ -105,6 +108,10 @@ class Account extends ImmutablePureComponent {
             {hidingNotificationsButton}
           </Fragment>
         );
+      } else if (defaultAction === 'mute') {
+        buttons = <IconButton icon='volume-off' title={intl.formatMessage(messages.mute, { name: account.get('username') })} onClick={this.handleMute} />;
+      } else if (defaultAction === 'block') {
+        buttons = <IconButton icon='lock' title={intl.formatMessage(messages.block, { name: account.get('username') })} onClick={this.handleBlock} />;
       } else if (!account.get('moved') || following) {
         buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
       }
diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js
index 570505833..12ab7d2df 100644
--- a/app/javascript/mastodon/components/avatar.js
+++ b/app/javascript/mastodon/components/avatar.js
@@ -2,11 +2,12 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { autoPlayGif } from '../initial_state';
+import classNames from 'classnames';
 
 export default class Avatar extends React.PureComponent {
 
   static propTypes = {
-    account: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map,
     size: PropTypes.number.isRequired,
     style: PropTypes.object,
     inline: PropTypes.bool,
@@ -37,15 +38,6 @@ export default class Avatar extends React.PureComponent {
     const { account, size, animate, inline } = this.props;
     const { hovering } = this.state;
 
-    const src = account.get('avatar');
-    const staticSrc = account.get('avatar_static');
-
-    let className = 'account__avatar';
-
-    if (inline) {
-      className = className + ' account__avatar-inline';
-    }
-
     const style = {
       ...this.props.style,
       width: `${size}px`,
@@ -53,15 +45,21 @@ export default class Avatar extends React.PureComponent {
       backgroundSize: `${size}px ${size}px`,
     };
 
-    if (hovering || animate) {
-      style.backgroundImage = `url(${src})`;
-    } else {
-      style.backgroundImage = `url(${staticSrc})`;
+    if (account) {
+      const src = account.get('avatar');
+      const staticSrc = account.get('avatar_static');
+
+      if (hovering || animate) {
+        style.backgroundImage = `url(${src})`;
+      } else {
+        style.backgroundImage = `url(${staticSrc})`;
+      }
     }
 
+
     return (
       <div
-        className={className}
+        className={classNames('account__avatar', { 'account__avatar-inline': inline })}
         onMouseEnter={this.handleMouseEnter}
         onMouseLeave={this.handleMouseLeave}
         style={style}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 2830bee29..8e6b9f063 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -82,6 +82,7 @@ class Header extends ImmutablePureComponent {
     onEditAccountNote: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     domain: PropTypes.string.isRequired,
+    hidden: PropTypes.bool,
   };
 
   openEditProfile = () => {
@@ -123,7 +124,7 @@ class Header extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, intl, domain } = this.props;
+    const { account, hidden, intl, domain } = this.props;
 
     if (!account) {
       return null;
@@ -267,21 +268,25 @@ class Header extends ImmutablePureComponent {
             {!suspended && info}
           </div>
 
-          <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />
+          {!(suspended || hidden) && <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />}
         </div>
 
         <div className='account__header__bar'>
           <div className='account__header__tabs'>
             <a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'>
-              <Avatar account={account} size={90} />
+              <Avatar account={suspended || hidden ? undefined : account} size={90} />
             </a>
 
             <div className='spacer' />
 
             {!suspended && (
               <div className='account__header__tabs__buttons'>
-                {actionBtn}
-                {bellBtn}
+                {!hidden && (
+                  <React.Fragment>
+                    {actionBtn}
+                    {bellBtn}
+                  </React.Fragment>
+                )}
 
                 <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
               </div>
@@ -295,30 +300,30 @@ class Header extends ImmutablePureComponent {
             </h1>
           </div>
 
-          <div className='account__header__extra'>
-            <div className='account__header__bio'>
-              {fields.size > 0 && (
-                <div className='account__header__fields'>
-                  {fields.map((pair, i) => (
-                    <dl key={i}>
-                      <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
+          {!(suspended || hidden) && (
+            <div className='account__header__extra'>
+              <div className='account__header__bio'>
+                {fields.size > 0 && (
+                  <div className='account__header__fields'>
+                    {fields.map((pair, i) => (
+                      <dl key={i}>
+                        <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
 
-                      <dd className={`${pair.get('verified_at') ? 'verified' : ''} translate`} title={pair.get('value_plain')}>
-                        {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
-                      </dd>
-                    </dl>
-                  ))}
-                </div>
-              )}
+                        <dd className={`${pair.get('verified_at') ? 'verified' : ''} translate`} title={pair.get('value_plain')}>
+                          {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
+                        </dd>
+                      </dl>
+                    ))}
+                  </div>
+                )}
 
-              {account.get('id') !== me && !suspended && <AccountNoteContainer account={account} />}
+                {account.get('id') !== me && <AccountNoteContainer account={account} />}
 
-              {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
+                {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
 
-              <div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div>
-            </div>
+                <div className='account__header__joined'><FormattedMessage id='account.joined' defaultMessage='Joined {date}' values={{ date: intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' }) }} /></div>
+              </div>
 
-            {!suspended && (
               <div className='account__header__extra__links'>
                 <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
                   <ShortNumber
@@ -341,8 +346,8 @@ class Header extends ImmutablePureComponent {
                   />
                 </NavLink>
               </div>
-            )}
-          </div>
+            </div>
+          )}
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 507b6c895..fab0bc597 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -24,6 +24,7 @@ export default class Header extends ImmutablePureComponent {
     onAddToList: PropTypes.func.isRequired,
     hideTabs: PropTypes.bool,
     domain: PropTypes.string.isRequired,
+    hidden: PropTypes.bool,
   };
 
   static contextTypes = {
@@ -91,7 +92,7 @@ export default class Header extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, hideTabs } = this.props;
+    const { account, hidden, hideTabs } = this.props;
 
     if (account === null) {
       return null;
@@ -99,7 +100,7 @@ export default class Header extends ImmutablePureComponent {
 
     return (
       <div className='account-timeline__header'>
-        {account.get('moved') && <MovedNote from={account} to={account.get('moved')} />}
+        {(!hidden && account.get('moved')) && <MovedNote from={account} to={account.get('moved')} />}
 
         <InnerHeader
           account={account}
@@ -117,9 +118,10 @@ export default class Header extends ImmutablePureComponent {
           onAddToList={this.handleAddToList}
           onEditAccountNote={this.handleEditAccountNote}
           domain={this.props.domain}
+          hidden={hidden}
         />
 
-        {!hideTabs && (
+        {!(hideTabs || hidden) && (
           <div className='account__section-headline'>
             <NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Posts' /></NavLink>
             <NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Posts and replies' /></NavLink>
diff --git a/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js
new file mode 100644
index 000000000..6b025596c
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/limited_account_hint.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { revealAccount } from 'mastodon/actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import Button from 'mastodon/components/button';
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+
+  reveal () {
+    dispatch(revealAccount(accountId));
+  },
+
+});
+
+export default @connect(() => {}, mapDispatchToProps)
+class LimitedAccountHint extends React.PureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    reveal: PropTypes.func,
+  }
+
+  render () {
+    const { reveal } = this.props;
+
+    return (
+      <div className='limited-account-hint'>
+        <p><FormattedMessage id='limited_account_hint.title' defaultMessage='This profile has been hidden by the moderators of your server.' /></p>
+        <Button onClick={reveal}><FormattedMessage id='limited_account_hint.action' defaultMessage='Show profile anyway' /></Button>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index b3f8521cb..371794dd7 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import { connect } from 'react-redux';
-import { makeGetAccount } from '../../../selectors';
+import { makeGetAccount, getAccountHidden } from '../../../selectors';
 import Header from '../components/header';
 import {
   followAccount,
@@ -33,6 +33,7 @@ const makeMapStateToProps = () => {
   const mapStateToProps = (state, { accountId }) => ({
     account: getAccount(state, accountId),
     domain: state.getIn(['meta', 'domain']),
+    hidden: getAccountHidden(state, accountId),
   });
 
   return mapStateToProps;
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index 5122aec4e..5b592c5a7 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -16,6 +16,8 @@ import MissingIndicator from 'mastodon/components/missing_indicator';
 import TimelineHint from 'mastodon/components/timeline_hint';
 import { me } from 'mastodon/initial_state';
 import { connectTimeline, disconnectTimeline } from 'mastodon/actions/timelines';
+import LimitedAccountHint from './components/limited_account_hint';
+import { getAccountHidden } from 'mastodon/selectors';
 
 const emptyList = ImmutableList();
 
@@ -40,6 +42,7 @@ const mapStateToProps = (state, { params: { acct, id }, withReplies = false }) =
     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
     hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
     suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+    hidden: getAccountHidden(state, accountId),
     blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
   };
 };
@@ -70,6 +73,7 @@ class AccountTimeline extends ImmutablePureComponent {
     blockedBy: PropTypes.bool,
     isAccount: PropTypes.bool,
     suspended: PropTypes.bool,
+    hidden: PropTypes.bool,
     remote: PropTypes.bool,
     remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
@@ -128,7 +132,7 @@ class AccountTimeline extends ImmutablePureComponent {
   }
 
   render () {
-    const { statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
+    const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -149,8 +153,12 @@ class AccountTimeline extends ImmutablePureComponent {
 
     let emptyMessage;
 
+    const forceEmptyState = suspended || blockedBy || hidden;
+
     if (suspended) {
       emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
+    } else if (hidden) {
+      emptyMessage = <LimitedAccountHint accountId={accountId} />;
     } else if (blockedBy) {
       emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
     } else if (remote && statusIds.isEmpty()) {
@@ -166,14 +174,14 @@ class AccountTimeline extends ImmutablePureComponent {
         <ColumnBackButton multiColumn={multiColumn} />
 
         <StatusList
-          prepend={<HeaderContainer accountId={this.props.accountId} />}
+          prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} />}
           alwaysPrepend
           append={remoteMessage}
           scrollKey='account_timeline'
-          statusIds={(suspended || blockedBy) ? emptyList : statusIds}
+          statusIds={forceEmptyState ? emptyList : statusIds}
           featuredStatusIds={featuredStatusIds}
           isLoading={isLoading}
-          hasMore={hasMore}
+          hasMore={!forceEmptyState && hasMore}
           onLoadMore={this.handleLoadMore}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js
index 7ec177434..e00f2b60e 100644
--- a/app/javascript/mastodon/features/blocks/index.js
+++ b/app/javascript/mastodon/features/blocks/index.js
@@ -69,7 +69,7 @@ class Blocks extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} />,
+            <AccountContainer key={id} id={id} defaultAction='block' />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
index 224e74b3d..5b7f402f8 100644
--- a/app/javascript/mastodon/features/followers/index.js
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -19,6 +19,8 @@ import ColumnBackButton from '../../components/column_back_button';
 import ScrollableList from '../../components/scrollable_list';
 import MissingIndicator from 'mastodon/components/missing_indicator';
 import TimelineHint from 'mastodon/components/timeline_hint';
+import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
+import { getAccountHidden } from 'mastodon/selectors';
 
 const mapStateToProps = (state, { params: { acct, id } }) => {
   const accountId = id || state.getIn(['accounts_map', acct]);
@@ -37,6 +39,8 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
     accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
     hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
     isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+    hidden: getAccountHidden(state, accountId),
     blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
   };
 };
@@ -64,6 +68,8 @@ class Followers extends ImmutablePureComponent {
     isLoading: PropTypes.bool,
     blockedBy: PropTypes.bool,
     isAccount: PropTypes.bool,
+    suspended: PropTypes.bool,
+    hidden: PropTypes.bool,
     remote: PropTypes.bool,
     remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
@@ -101,7 +107,7 @@ class Followers extends ImmutablePureComponent {
   }, 300, { leading: true });
 
   render () {
-    const { accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
+    const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -121,7 +127,13 @@ class Followers extends ImmutablePureComponent {
 
     let emptyMessage;
 
-    if (blockedBy) {
+    const forceEmptyState = blockedBy || suspended || hidden;
+
+    if (suspended) {
+      emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
+    } else if (hidden) {
+      emptyMessage = <LimitedAccountHint accountId={accountId} />;
+    } else if (blockedBy) {
       emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
     } else if (remote && accountIds.isEmpty()) {
       emptyMessage = <RemoteHint url={remoteUrl} />;
@@ -137,7 +149,7 @@ class Followers extends ImmutablePureComponent {
 
         <ScrollableList
           scrollKey='followers'
-          hasMore={hasMore}
+          hasMore={!forceEmptyState && hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
           prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
@@ -146,7 +158,7 @@ class Followers extends ImmutablePureComponent {
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
-          {blockedBy ? [] : accountIds.map(id =>
+          {forceEmptyState ? [] : accountIds.map(id =>
             <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
index aadce1644..143082d76 100644
--- a/app/javascript/mastodon/features/following/index.js
+++ b/app/javascript/mastodon/features/following/index.js
@@ -19,6 +19,8 @@ import ColumnBackButton from '../../components/column_back_button';
 import ScrollableList from '../../components/scrollable_list';
 import MissingIndicator from 'mastodon/components/missing_indicator';
 import TimelineHint from 'mastodon/components/timeline_hint';
+import LimitedAccountHint from '../account_timeline/components/limited_account_hint';
+import { getAccountHidden } from 'mastodon/selectors';
 
 const mapStateToProps = (state, { params: { acct, id } }) => {
   const accountId = id || state.getIn(['accounts_map', acct]);
@@ -37,6 +39,8 @@ const mapStateToProps = (state, { params: { acct, id } }) => {
     accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
     hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
     isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+    hidden: getAccountHidden(state, accountId),
     blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
   };
 };
@@ -64,6 +68,8 @@ class Following extends ImmutablePureComponent {
     isLoading: PropTypes.bool,
     blockedBy: PropTypes.bool,
     isAccount: PropTypes.bool,
+    suspended: PropTypes.bool,
+    hidden: PropTypes.bool,
     remote: PropTypes.bool,
     remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
@@ -101,7 +107,7 @@ class Following extends ImmutablePureComponent {
   }, 300, { leading: true });
 
   render () {
-    const { accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
+    const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -121,7 +127,13 @@ class Following extends ImmutablePureComponent {
 
     let emptyMessage;
 
-    if (blockedBy) {
+    const forceEmptyState = blockedBy || suspended || hidden;
+
+    if (suspended) {
+      emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
+    } else if (hidden) {
+      emptyMessage = <LimitedAccountHint accountId={accountId} />;
+    } else if (blockedBy) {
       emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
     } else if (remote && accountIds.isEmpty()) {
       emptyMessage = <RemoteHint url={remoteUrl} />;
@@ -137,7 +149,7 @@ class Following extends ImmutablePureComponent {
 
         <ScrollableList
           scrollKey='following'
-          hasMore={hasMore}
+          hasMore={!forceEmptyState && hasMore}
           isLoading={isLoading}
           onLoadMore={this.handleLoadMore}
           prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />}
@@ -146,7 +158,7 @@ class Following extends ImmutablePureComponent {
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
-          {blockedBy ? [] : accountIds.map(id =>
+          {forceEmptyState ? [] : accountIds.map(id =>
             <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
index c1d50d194..c21433cc4 100644
--- a/app/javascript/mastodon/features/mutes/index.js
+++ b/app/javascript/mastodon/features/mutes/index.js
@@ -69,7 +69,7 @@ class Mutes extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} />,
+            <AccountContainer key={id} id={id} defaultAction='mute' />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
index 530ed8e60..b5589668c 100644
--- a/app/javascript/mastodon/reducers/accounts.js
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -1,4 +1,5 @@
-import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from '../actions/importer';
+import { ACCOUNT_IMPORT, ACCOUNTS_IMPORT } from 'mastodon/actions/importer';
+import { ACCOUNT_REVEAL } from 'mastodon/actions/accounts';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 
 const initialState = ImmutableMap();
@@ -10,6 +11,8 @@ const normalizeAccount = (state, account) => {
   delete account.following_count;
   delete account.statuses_count;
 
+  account.hidden = state.getIn([account.id, 'hidden']) === false ? false : account.limited;
+
   return state.set(account.id, fromJS(account));
 };
 
@@ -27,6 +30,8 @@ export default function accounts(state = initialState, action) {
     return normalizeAccount(state, action.account);
   case ACCOUNTS_IMPORT:
     return normalizeAccounts(state, action.accounts);
+  case ACCOUNT_REVEAL:
+    return state.setIn([action.id, 'hidden'], false);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 1e19db65d..3121774b3 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -175,3 +175,11 @@ export const getAccountGallery = createSelector([
 
   return medias;
 });
+
+export const getAccountHidden = createSelector([
+  (state, id) => state.getIn(['accounts', id, 'hidden']),
+  (state, id) => state.getIn(['relationships', id, 'following']) || state.getIn(['relationships', id, 'requested']),
+  (state, id) => id === me,
+], (hidden, followingOrRequested, isSelf) => {
+  return hidden && !(isSelf || followingOrRequested);
+});
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 4a805992e..e6133ee32 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -4037,6 +4037,15 @@ a.status-card.compact:hover {
   vertical-align: middle;
 }
 
+.limited-account-hint {
+  p {
+    color: $secondary-text-color;
+    font-size: 15px;
+    font-weight: 500;
+    margin-bottom: 20px;
+  }
+}
+
 .empty-column-indicator,
 .error-column,
 .follow_requests-unlocked_explanation {
diff --git a/app/lib/emoji_formatter.rb b/app/lib/emoji_formatter.rb
index f808f3a22..194849c23 100644
--- a/app/lib/emoji_formatter.rb
+++ b/app/lib/emoji_formatter.rb
@@ -11,6 +11,7 @@ class EmojiFormatter
   # @param [Array<CustomEmoji>] custom_emojis
   # @param [Hash] options
   # @option options [Boolean] :animate
+  # @option options [String] :style
   def initialize(html, custom_emojis, options = {})
     raise ArgumentError unless html.html_safe?
 
@@ -85,14 +86,29 @@ class EmojiFormatter
   def image_for_emoji(shortcode, emoji)
     original_url, static_url = emoji
 
-    if animate?
-      image_tag(original_url, draggable: false, class: 'emojione', alt: ":#{shortcode}:", title: ":#{shortcode}:")
-    else
-      image_tag(original_url, draggable: false, class: 'emojione custom-emoji', alt: ":#{shortcode}:", title: ":#{shortcode}:", data: { original: original_url, static: static_url })
-    end
+    image_tag(
+      animate? ? original_url : static_url,
+      image_attributes.merge(alt: ":#{shortcode}:", title: ":#{shortcode}:", data: image_data_attributes(original_url, static_url))
+    )
+  end
+
+  def image_attributes
+    { rel: 'emoji', draggable: false, width: 16, height: 16, class: image_class_names, style: image_style }
+  end
+
+  def image_data_attributes(original_url, static_url)
+    { original: original_url, static: static_url } unless animate?
+  end
+
+  def image_class_names
+    animate? ? 'emojione' : 'emojione custom-emoji'
+  end
+
+  def image_style
+    @options[:style]
   end
 
   def animate?
-    @options[:animate]
+    @options[:animate] || @options.key?(:style)
   end
 end
diff --git a/app/lib/rss/builder.rb b/app/lib/rss/builder.rb
new file mode 100644
index 000000000..a9b3f08c5
--- /dev/null
+++ b/app/lib/rss/builder.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class RSS::Builder
+  attr_reader :dsl
+
+  def self.build
+    new.tap do |builder|
+      yield builder.dsl
+    end.to_xml
+  end
+
+  def initialize
+    @dsl = RSS::Channel.new
+  end
+
+  def to_xml
+    ('<?xml version="1.0" encoding="UTF-8"?>'.dup << Ox.dump(wrap_in_document, effort: :tolerant)).force_encoding('UTF-8')
+  end
+
+  private
+
+  def wrap_in_document
+    Ox::Document.new(version: '1.0').tap do |document|
+      document << Ox::Element.new('rss').tap do |rss|
+        rss['version']        = '2.0'
+        rss['xmlns:webfeeds'] = 'http://webfeeds.org/rss/1.0'
+        rss['xmlns:media']    = 'http://search.yahoo.com/mrss/'
+
+        rss << @dsl.to_element
+      end
+    end
+  end
+end
diff --git a/app/lib/rss/channel.rb b/app/lib/rss/channel.rb
new file mode 100644
index 000000000..1dba94e47
--- /dev/null
+++ b/app/lib/rss/channel.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class RSS::Channel < RSS::Element
+  def initialize
+    super()
+
+    @root = create_element('channel')
+  end
+
+  def title(str)
+    append_element('title', str)
+  end
+
+  def link(str)
+    append_element('link', str)
+  end
+
+  def last_build_date(date)
+    append_element('lastBuildDate', date.to_formatted_s(:rfc822))
+  end
+
+  def image(url, title, link)
+    append_element('image') do |image|
+      image << create_element('url', url)
+      image << create_element('title', title)
+      image << create_element('link', link)
+    end
+  end
+
+  def description(str)
+    append_element('description', str)
+  end
+
+  def generator(str)
+    append_element('generator', str)
+  end
+
+  def icon(str)
+    append_element('webfeeds:icon', str)
+  end
+
+  def logo(str)
+    append_element('webfeeds:logo', str)
+  end
+
+  def item(&block)
+    @root << RSS::Item.with(&block)
+  end
+end
diff --git a/app/lib/rss/element.rb b/app/lib/rss/element.rb
new file mode 100644
index 000000000..7142fa039
--- /dev/null
+++ b/app/lib/rss/element.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class RSS::Element
+  def self.with(*args, &block)
+    new(*args).tap(&block).to_element
+  end
+
+  def create_element(name, content = nil)
+    Ox::Element.new(name).tap do |element|
+      yield element if block_given?
+      element << content if content.present?
+    end
+  end
+
+  def append_element(name, content = nil)
+    @root << create_element(name, content).tap do |element|
+      yield element if block_given?
+    end
+  end
+
+  def to_element
+    @root
+  end
+end
diff --git a/app/lib/rss/item.rb b/app/lib/rss/item.rb
new file mode 100644
index 000000000..c02991ace
--- /dev/null
+++ b/app/lib/rss/item.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class RSS::Item < RSS::Element
+  def initialize
+    super()
+
+    @root = create_element('item')
+  end
+
+  def title(str)
+    append_element('title', str)
+  end
+
+  def link(str)
+    append_element('guid', str) do |guid|
+      guid['isPermaLink'] = 'true'
+    end
+
+    append_element('link', str)
+  end
+
+  def pub_date(date)
+    append_element('pubDate', date.to_formatted_s(:rfc822))
+  end
+
+  def description(str)
+    append_element('description', str)
+  end
+
+  def category(str)
+    append_element('category', str)
+  end
+
+  def enclosure(url, type, size)
+    append_element('enclosure') do |enclosure|
+      enclosure['url']    = url
+      enclosure['length'] = size
+      enclosure['type']   = type
+    end
+  end
+
+  def media_content(url, type, size, &block)
+    @root << RSS::MediaContent.with(url, type, size, &block)
+  end
+end
diff --git a/app/lib/rss/media_content.rb b/app/lib/rss/media_content.rb
new file mode 100644
index 000000000..7aefd8b40
--- /dev/null
+++ b/app/lib/rss/media_content.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class RSS::MediaContent < RSS::Element
+  def initialize(url, type, size)
+    super()
+
+    @root = create_element('media:content') do |content|
+      content['url']      = url
+      content['type']     = type
+      content['fileSize'] = size
+    end
+  end
+
+  def medium(str)
+    @root['medium'] = str
+  end
+
+  def rating(str)
+    append_element('media:rating', str) do |rating|
+      rating['scheme'] = 'urn:simple'
+    end
+  end
+
+  def description(str)
+    append_element('media:description', str) do |description|
+      description['type'] = 'plain'
+    end
+  end
+end
diff --git a/app/lib/rss/serializer.rb b/app/lib/rss/serializer.rb
deleted file mode 100644
index d44e94221..000000000
--- a/app/lib/rss/serializer.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-class RSS::Serializer
-  include FormattingHelper
-
-  private
-
-  def render_statuses(builder, statuses)
-    statuses.each do |status|
-      builder.item do |item|
-        item.title(status_title(status))
-            .link(ActivityPub::TagManager.instance.url_for(status))
-            .pub_date(status.created_at)
-            .description(status_description(status))
-
-        status.ordered_media_attachments.each do |media|
-          item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size)
-        end
-      end
-    end
-  end
-
-  def status_title(status)
-    preview = status.proper.spoiler_text.presence || status.proper.text
-
-    if preview.length > 30 || preview[0, 30].include?("\n")
-      preview = preview[0, 30]
-      preview = preview[0, preview.index("\n").presence || 30] + '…'
-    end
-
-    preview = "#{status.proper.spoiler_text.present? ? 'CW ' : ''}“#{preview}”#{status.proper.sensitive? ? ' (sensitive)' : ''}"
-
-    if status.reblog?
-      "#{status.account.acct} boosted #{status.reblog.account.acct}: #{preview}"
-    else
-      "#{status.account.acct}: #{preview}"
-    end
-  end
-
-  def status_description(status)
-    if status.proper.spoiler_text?
-      status.proper.spoiler_text
-    else
-      html = status_content_format(status.proper).to_str
-      after_html = ''
-
-      if status.proper.preloadable_poll
-        poll_options_html = status.proper.preloadable_poll.options.map { |o| "[ ] #{o}" }.join('<br />')
-        after_html = "<p>#{poll_options_html}</p>"
-      end
-
-      "#{html}#{after_html}"
-    end
-  end
-end
diff --git a/app/lib/rss_builder.rb b/app/lib/rss_builder.rb
deleted file mode 100644
index 63ddba2e8..000000000
--- a/app/lib/rss_builder.rb
+++ /dev/null
@@ -1,130 +0,0 @@
-# frozen_string_literal: true
-
-class RSSBuilder
-  class ItemBuilder
-    def initialize
-      @item = Ox::Element.new('item')
-    end
-
-    def title(str)
-      @item << (Ox::Element.new('title') << str)
-
-      self
-    end
-
-    def link(str)
-      @item << Ox::Element.new('guid').tap do |guid|
-        guid['isPermalink'] = 'true'
-        guid << str
-      end
-
-      @item << (Ox::Element.new('link') << str)
-
-      self
-    end
-
-    def pub_date(date)
-      @item << (Ox::Element.new('pubDate') << date.to_formatted_s(:rfc822))
-
-      self
-    end
-
-    def description(str)
-      @item << (Ox::Element.new('description') << str)
-
-      self
-    end
-
-    def enclosure(url, type, size)
-      @item << Ox::Element.new('enclosure').tap do |enclosure|
-        enclosure['url']    = url
-        enclosure['length'] = size
-        enclosure['type']   = type
-      end
-
-      self
-    end
-
-    def to_element
-      @item
-    end
-  end
-
-  def initialize
-    @document = Ox::Document.new(version: '1.0')
-    @channel  = Ox::Element.new('channel')
-
-    @document << (rss << @channel)
-  end
-
-  def title(str)
-    @channel << (Ox::Element.new('title') << str)
-
-    self
-  end
-
-  def link(str)
-    @channel << (Ox::Element.new('link') << str)
-
-    self
-  end
-
-  def image(str)
-    @channel << Ox::Element.new('image').tap do |image|
-      image << (Ox::Element.new('url') << str)
-      image << (Ox::Element.new('title') << '')
-      image << (Ox::Element.new('link') << '')
-    end
-
-    @channel << (Ox::Element.new('webfeeds:icon') << str)
-
-    self
-  end
-
-  def cover(str)
-    @channel << Ox::Element.new('webfeeds:cover').tap do |cover|
-      cover['image'] = str
-    end
-
-    self
-  end
-
-  def logo(str)
-    @channel << (Ox::Element.new('webfeeds:logo') << str)
-
-    self
-  end
-
-  def accent_color(str)
-    @channel << (Ox::Element.new('webfeeds:accentColor') << str)
-
-    self
-  end
-
-  def description(str)
-    @channel << (Ox::Element.new('description') << str)
-
-    self
-  end
-
-  def item
-    @channel << ItemBuilder.new.tap do |item|
-      yield item
-    end.to_element
-
-    self
-  end
-
-  def to_xml
-    ('<?xml version="1.0" encoding="UTF-8"?>' + Ox.dump(@document, effort: :tolerant)).force_encoding('UTF-8')
-  end
-
-  private
-
-  def rss
-    Ox::Element.new('rss').tap do |rss|
-      rss['version']        = '2.0'
-      rss['xmlns:webfeeds'] = 'http://webfeeds.org/rss/1.0'
-    end
-  end
-end
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 113e0cca7..e644a3f91 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -13,6 +13,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
   has_many :emojis, serializer: REST::CustomEmojiSerializer
 
   attribute :suspended, if: :suspended?
+  attribute :silenced, key: :limited, if: :silenced?
 
   class FieldSerializer < ActiveModel::Serializer
     include FormattingHelper
@@ -102,7 +103,11 @@ class REST::AccountSerializer < ActiveModel::Serializer
     object.suspended?
   end
 
-  delegate :suspended?, to: :object
+  def silenced
+    object.silenced?
+  end
+
+  delegate :suspended?, :silenced?, to: :object
 
   def moved_and_not_nested?
     object.moved? && object.moved_to_account.moved_to_account_id.nil?
diff --git a/app/serializers/rss/account_serializer.rb b/app/serializers/rss/account_serializer.rb
deleted file mode 100644
index 81e24af0d..000000000
--- a/app/serializers/rss/account_serializer.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-class RSS::AccountSerializer < RSS::Serializer
-  include ActionView::Helpers::NumberHelper
-  include AccountsHelper
-  include RoutingHelper
-
-  def render(account, statuses, tag)
-    builder = RSSBuilder.new
-
-    builder.title("#{display_name(account)} (@#{account.local_username_and_domain})")
-           .description(account_description(account))
-           .link(tag.present? ? short_account_tag_url(account, tag) : short_account_url(account))
-           .logo(full_pack_url('media/images/logo.svg'))
-           .accent_color('2b90d9')
-
-    builder.image(full_asset_url(account.avatar.url(:original))) if account.avatar?
-    builder.cover(full_asset_url(account.header.url(:original))) if account.header?
-
-    render_statuses(builder, statuses)
-
-    builder.to_xml
-  end
-
-  def self.render(account, statuses, tag)
-    new.render(account, statuses, tag)
-  end
-end
diff --git a/app/serializers/rss/tag_serializer.rb b/app/serializers/rss/tag_serializer.rb
deleted file mode 100644
index e549ac367..000000000
--- a/app/serializers/rss/tag_serializer.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-class RSS::TagSerializer < RSS::Serializer
-  include ActionView::Helpers::NumberHelper
-  include ActionView::Helpers::SanitizeHelper
-  include RoutingHelper
-
-  def render(tag, statuses)
-    builder = RSSBuilder.new
-
-    builder.title("##{tag.name}")
-           .description(strip_tags(I18n.t('about.about_hashtag_html', hashtag: tag.name)))
-           .link(tag_url(tag))
-           .logo(full_pack_url('media/images/logo.svg'))
-           .accent_color('2b90d9')
-
-    render_statuses(builder, statuses)
-
-    builder.to_xml
-  end
-
-  def self.render(tag, statuses)
-    new.render(tag, statuses)
-  end
-end
diff --git a/app/views/accounts/show.rss.ruby b/app/views/accounts/show.rss.ruby
new file mode 100644
index 000000000..73c1c51e0
--- /dev/null
+++ b/app/views/accounts/show.rss.ruby
@@ -0,0 +1,37 @@
+RSS::Builder.build do |doc|
+  doc.title(display_name(@account))
+  doc.description(I18n.t('rss.descriptions.account', acct: @account.local_username_and_domain))
+  doc.link(params[:tag].present? ? short_account_tag_url(@account, params[:tag]) : short_account_url(@account))
+  doc.image(full_asset_url(@account.avatar.url(:original)), display_name(@account), params[:tag].present? ? short_account_tag_url(@account, params[:tag]) : short_account_url(@account))
+  doc.last_build_date(@statuses.first.created_at) if @statuses.any?
+  doc.icon(full_asset_url(@account.avatar.url(:original)))
+  doc.logo(full_pack_url('media/images/logo_transparent_white.svg'))
+  doc.generator("Mastodon v#{Mastodon::Version.to_s}")
+
+  @statuses.each do |status|
+    doc.item do |item|
+      item.title(l(status.created_at))
+      item.link(ActivityPub::TagManager.instance.url_for(status))
+      item.pub_date(status.created_at)
+      item.description(rss_status_content_format(status))
+
+      if status.ordered_media_attachments.first&.audio?
+        media = status.ordered_media_attachments.first
+        item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size)
+      end
+
+      status.ordered_media_attachments.each do |media|
+        item.media_content(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) do |media_content|
+          media_content.medium(media.gifv? ? 'image' : media.type.to_s)
+          media_content.rating(status.sensitive? ? 'adult' : 'nonadult')
+          media_content.description(media.description) if media.description.present?
+          media_content.thumbnail(media.thumbnail.url(:original, false)) if media.thumbnail?
+        end
+      end
+
+      status.tags.each do |tag|
+        item.category(tag.name)
+      end
+    end
+  end
+end
diff --git a/app/views/tags/show.rss.ruby b/app/views/tags/show.rss.ruby
new file mode 100644
index 000000000..4152ecd24
--- /dev/null
+++ b/app/views/tags/show.rss.ruby
@@ -0,0 +1,36 @@
+RSS::Builder.build do |doc|
+  doc.title("##{@tag.name}")
+  doc.description(I18n.t('rss.descriptions.tag', hashtag: @tag.name))
+  doc.link(tag_url(@tag))
+  doc.last_build_date(@statuses.first.created_at) if @statuses.any?
+  doc.icon(full_asset_url(@account.avatar.url(:original)))
+  doc.logo(full_pack_url('media/images/logo_transparent_white.svg'))
+  doc.generator("Mastodon v#{Mastodon::Version.to_s}")
+
+  @statuses.each do |status|
+    doc.item do |item|
+      item.title(l(status.created_at))
+      item.link(ActivityPub::TagManager.instance.url_for(status))
+      item.pub_date(status.created_at)
+      item.description(rss_status_content_format(status))
+
+      if status.ordered_media_attachments.first&.audio?
+        media = status.ordered_media_attachments.first
+        item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size)
+      end
+
+      status.ordered_media_attachments.each do |media|
+        item.media_content(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size) do |media_content|
+          media_content.medium(media.gifv? ? 'image' : media.type.to_s)
+          media_content.rating(status.sensitive? ? 'adult' : 'nonadult')
+          media_content.description(media.description) if media.description.present?
+          media_content.thumbnail(media.thumbnail.url(:original, false)) if media.thumbnail?
+        end
+      end
+
+      status.tags.each do |tag|
+        item.category(tag.name)
+      end
+    end
+  end
+end