about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2022-10-05 03:47:56 +0200
committerGitHub <noreply@github.com>2022-10-05 03:47:56 +0200
commitd2528b26b6da34f34b5d7a392e263428d3c09d69 (patch)
tree142ace2915a143d61b852e479603726e8d7e2675
parentcedcece0ccba626d97a910f2e3fecf93c2729ca4 (diff)
Add server banner to web app, add `GET /api/v2/instance` to REST API (#19294)
-rw-r--r--app/controllers/about_controller.rb2
-rw-r--r--app/controllers/api/v1/instances_controller.rb2
-rw-r--r--app/controllers/api/v2/instances_controller.rb8
-rw-r--r--app/javascript/mastodon/actions/rules.js27
-rw-r--r--app/javascript/mastodon/actions/server.js30
-rw-r--r--app/javascript/mastodon/components/account.js14
-rw-r--r--app/javascript/mastodon/components/display_name.js8
-rw-r--r--app/javascript/mastodon/components/server_banner.js91
-rw-r--r--app/javascript/mastodon/features/report/rules.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/compose_panel.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/report_modal.js4
-rw-r--r--app/javascript/mastodon/features/ui/index.js4
-rw-r--r--app/javascript/mastodon/initial_state.js1
-rw-r--r--app/javascript/mastodon/reducers/index.js4
-rw-r--r--app/javascript/mastodon/reducers/rules.js13
-rw-r--r--app/javascript/mastodon/reducers/server.js19
-rw-r--r--app/javascript/styles/mastodon/components.scss82
-rw-r--r--app/presenters/instance_presenter.rb64
-rw-r--r--app/serializers/initial_state_serializer.rb19
-rw-r--r--app/serializers/manifest_serializer.rb4
-rw-r--r--app/serializers/rest/instance_serializer.rb81
-rw-r--r--app/serializers/rest/v1/instance_serializer.rb102
-rw-r--r--app/views/about/more.html.haml8
-rw-r--r--app/views/about/show.html.haml6
-rw-r--r--app/views/application/_sidebar.html.haml4
-rw-r--r--app/views/privacy/show.html.haml2
-rw-r--r--app/views/shared/_og.html.haml4
-rw-r--r--config/routes.rb4
-rw-r--r--spec/presenters/instance_presenter_spec.rb38
-rw-r--r--spec/views/about/show.html.haml_spec.rb20
30 files changed, 473 insertions, 196 deletions
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 4fc2fbe34..8da4a19ab 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -18,7 +18,7 @@ class AboutController < ApplicationController
   def more
     flash.now[:notice] = I18n.t('about.instance_actor_flash') if params[:instance_actor]
 
-    toc_generator = TOCGenerator.new(@instance_presenter.site_extended_description)
+    toc_generator = TOCGenerator.new(@instance_presenter.extended_description)
 
     @rules             = Rule.ordered
     @contents          = toc_generator.html
diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb
index 5b5058a7b..913319a86 100644
--- a/app/controllers/api/v1/instances_controller.rb
+++ b/app/controllers/api/v1/instances_controller.rb
@@ -6,6 +6,6 @@ class Api::V1::InstancesController < Api::BaseController
 
   def show
     expires_in 3.minutes, public: true
-    render_with_cache json: {}, serializer: REST::InstanceSerializer, root: 'instance'
+    render_with_cache json: InstancePresenter.new, serializer: REST::V1::InstanceSerializer, root: 'instance'
   end
 end
diff --git a/app/controllers/api/v2/instances_controller.rb b/app/controllers/api/v2/instances_controller.rb
new file mode 100644
index 000000000..bcd90cff2
--- /dev/null
+++ b/app/controllers/api/v2/instances_controller.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class Api::V2::InstancesController < Api::V1::InstancesController
+  def show
+    expires_in 3.minutes, public: true
+    render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance'
+  end
+end
diff --git a/app/javascript/mastodon/actions/rules.js b/app/javascript/mastodon/actions/rules.js
deleted file mode 100644
index 34e60a121..000000000
--- a/app/javascript/mastodon/actions/rules.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import api from '../api';
-
-export const RULES_FETCH_REQUEST = 'RULES_FETCH_REQUEST';
-export const RULES_FETCH_SUCCESS = 'RULES_FETCH_SUCCESS';
-export const RULES_FETCH_FAIL    = 'RULES_FETCH_FAIL';
-
-export const fetchRules = () => (dispatch, getState) => {
-  dispatch(fetchRulesRequest());
-
-  api(getState)
-    .get('/api/v1/instance').then(({ data }) => dispatch(fetchRulesSuccess(data.rules)))
-    .catch(err => dispatch(fetchRulesFail(err)));
-};
-
-const fetchRulesRequest = () => ({
-  type: RULES_FETCH_REQUEST,
-});
-
-const fetchRulesSuccess = rules => ({
-  type: RULES_FETCH_SUCCESS,
-  rules,
-});
-
-const fetchRulesFail = error => ({
-  type: RULES_FETCH_FAIL,
-  error,
-});
diff --git a/app/javascript/mastodon/actions/server.js b/app/javascript/mastodon/actions/server.js
new file mode 100644
index 000000000..af8fef780
--- /dev/null
+++ b/app/javascript/mastodon/actions/server.js
@@ -0,0 +1,30 @@
+import api from '../api';
+import { importFetchedAccount } from './importer';
+
+export const SERVER_FETCH_REQUEST = 'Server_FETCH_REQUEST';
+export const SERVER_FETCH_SUCCESS = 'Server_FETCH_SUCCESS';
+export const SERVER_FETCH_FAIL    = 'Server_FETCH_FAIL';
+
+export const fetchServer = () => (dispatch, getState) => {
+  dispatch(fetchServerRequest());
+
+  api(getState)
+    .get('/api/v2/instance').then(({ data }) => {
+      if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
+      dispatch(fetchServerSuccess(data));
+    }).catch(err => dispatch(fetchServerFail(err)));
+};
+
+const fetchServerRequest = () => ({
+  type: SERVER_FETCH_REQUEST,
+});
+
+const fetchServerSuccess = server => ({
+  type: SERVER_FETCH_SUCCESS,
+  server,
+});
+
+const fetchServerFail = error => ({
+  type: SERVER_FETCH_FAIL,
+  error,
+});
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index af9f119c8..36429e647 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -9,6 +9,7 @@ import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { me } from '../initial_state';
 import RelativeTimestamp from './relative_timestamp';
+import Skeleton from 'mastodon/components/skeleton';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
@@ -26,7 +27,7 @@ export default @injectIntl
 class Account extends ImmutablePureComponent {
 
   static propTypes = {
-    account: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
@@ -67,7 +68,16 @@ class Account extends ImmutablePureComponent {
     const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction } = this.props;
 
     if (!account) {
-      return <div />;
+      return (
+        <div className='account'>
+          <div className='account__wrapper'>
+            <div className='account__display-name'>
+              <div className='account__avatar-wrapper'><Skeleton width={36} height={36} /></div>
+              <DisplayName />
+            </div>
+          </div>
+        </div>
+      );
     }
 
     if (hidden) {
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index 7ccfbd0cd..e9139ab0f 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -2,11 +2,12 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { autoPlayGif } from 'mastodon/initial_state';
+import Skeleton from 'mastodon/components/skeleton';
 
 export default class DisplayName extends React.PureComponent {
 
   static propTypes = {
-    account: ImmutablePropTypes.map.isRequired,
+    account: ImmutablePropTypes.map,
     others: ImmutablePropTypes.list,
     localDomain: PropTypes.string,
   };
@@ -48,7 +49,7 @@ export default class DisplayName extends React.PureComponent {
       if (others.size - 2 > 0) {
         suffix = `+${others.size - 2}`;
       }
-    } else {
+    } else if ((others && others.size > 0) || this.props.account) {
       if (others && others.size > 0) {
         account = others.first();
       } else {
@@ -63,6 +64,9 @@ export default class DisplayName extends React.PureComponent {
 
       displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
       suffix      = <span className='display-name__account'>@{acct}</span>;
+    } else {
+      displayName = <bdi><strong className='display-name__html'><Skeleton width='10ch' /></strong></bdi>;
+      suffix = <span className='display-name__account'><Skeleton width='7ch' /></span>;
     }
 
     return (
diff --git a/app/javascript/mastodon/components/server_banner.js b/app/javascript/mastodon/components/server_banner.js
new file mode 100644
index 000000000..bdd7f7380
--- /dev/null
+++ b/app/javascript/mastodon/components/server_banner.js
@@ -0,0 +1,91 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { domain } from 'mastodon/initial_state';
+import { fetchServer } from 'mastodon/actions/server';
+import { connect } from 'react-redux';
+import Account from 'mastodon/containers/account_container';
+import ShortNumber from 'mastodon/components/short_number';
+import Skeleton from 'mastodon/components/skeleton';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  aboutActiveUsers: { id: 'server_banner.about_active_users', defaultMessage: 'People using this server during the last 30 days (Monthly Active Users)' },
+});
+
+const mapStateToProps = state => ({
+  server: state.get('server'),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class ServerBanner extends React.PureComponent {
+
+  static propTypes = {
+    server: PropTypes.object,
+    dispatch: PropTypes.func,
+    intl: PropTypes.object,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchServer());
+  }
+
+  render () {
+    const { server, intl } = this.props;
+    const isLoading = server.get('isLoading');
+
+    return (
+      <div className='server-banner'>
+        <div className='server-banner__introduction'>
+          <FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network powered by {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
+        </div>
+
+        <img src={server.get('thumbnail')} alt={server.get('title')} className='server-banner__hero' />
+
+        <div className='server-banner__description'>
+          {isLoading ? (
+            <>
+              <Skeleton width='100%' />
+              <br />
+              <Skeleton width='100%' />
+              <br />
+              <Skeleton width='70%' />
+            </>
+          ) : server.get('description')}
+        </div>
+
+        <div className='server-banner__meta'>
+          <div className='server-banner__meta__column'>
+            <h4><FormattedMessage id='server_banner.administered_by' defaultMessage='Administered by:' /></h4>
+
+            <Account id={server.getIn(['contact', 'account', 'id'])} />
+          </div>
+
+          <div className='server-banner__meta__column'>
+            <h4><FormattedMessage id='server_banner.server_stats' defaultMessage='Server stats:' /></h4>
+
+            {isLoading ? (
+              <>
+                <strong className='server-banner__number'><Skeleton width='10ch' /></strong>
+                <br />
+                <span className='server-banner__number-label'><Skeleton width='5ch' /></span>
+              </>
+            ) : (
+              <>
+                <strong className='server-banner__number'><ShortNumber value={server.getIn(['usage', 'users', 'active_month'])} /></strong>
+                <br />
+                <span className='server-banner__number-label' title={intl.formatMessage(messages.aboutActiveUsers)}><FormattedMessage id='server_banner.active_users' defaultMessage='active users' /></span>
+              </>
+            )}
+          </div>
+        </div>
+
+        <hr className='spacer' />
+
+        <a className='button button--block button-secondary' href='/about/more' target='_blank'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></a>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/report/rules.js b/app/javascript/mastodon/features/report/rules.js
index f2db0d9e4..2cb4a95b5 100644
--- a/app/javascript/mastodon/features/report/rules.js
+++ b/app/javascript/mastodon/features/report/rules.js
@@ -7,7 +7,7 @@ import Button from 'mastodon/components/button';
 import Option from './components/option';
 
 const mapStateToProps = state => ({
-  rules: state.get('rules'),
+  rules: state.getIn(['server', 'rules']),
 });
 
 export default @connect(mapStateToProps)
diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.js b/app/javascript/mastodon/features/ui/components/compose_panel.js
index 655c202fa..1d481eab5 100644
--- a/app/javascript/mastodon/features/ui/components/compose_panel.js
+++ b/app/javascript/mastodon/features/ui/components/compose_panel.js
@@ -5,6 +5,7 @@ import SearchContainer from 'mastodon/features/compose/containers/search_contain
 import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
 import NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
 import LinkFooter from './link_footer';
+import ServerBanner from 'mastodon/components/server_banner';
 import { changeComposing } from 'mastodon/actions/compose';
 
 export default @connect()
@@ -35,6 +36,7 @@ class ComposePanel extends React.PureComponent {
 
         {!signedIn && (
           <React.Fragment>
+            <ServerBanner />
             <div className='flex-spacer' />
           </React.Fragment>
         )}
diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js
index 744dd248b..264da07ce 100644
--- a/app/javascript/mastodon/features/ui/components/report_modal.js
+++ b/app/javascript/mastodon/features/ui/components/report_modal.js
@@ -2,7 +2,7 @@ import React from 'react';
 import { connect } from 'react-redux';
 import { submitReport } from 'mastodon/actions/reports';
 import { expandAccountTimeline } from 'mastodon/actions/timelines';
-import { fetchRules } from 'mastodon/actions/rules';
+import { fetchServer } from 'mastodon/actions/server';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { makeGetAccount } from 'mastodon/selectors';
@@ -117,7 +117,7 @@ class ReportModal extends ImmutablePureComponent {
     const { dispatch, accountId } = this.props;
 
     dispatch(expandAccountTimeline(accountId, { withReplies: true }));
-    dispatch(fetchRules());
+    dispatch(fetchServer());
   }
 
   render () {
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 4e37908c8..bc6ff1866 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -13,7 +13,7 @@ import { debounce } from 'lodash';
 import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
 import { expandHomeTimeline } from '../../actions/timelines';
 import { expandNotifications } from '../../actions/notifications';
-import { fetchRules } from '../../actions/rules';
+import { fetchServer } from '../../actions/server';
 import { clearHeight } from '../../actions/height_cache';
 import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
@@ -392,7 +392,7 @@ class UI extends React.PureComponent {
       this.props.dispatch(expandHomeTimeline());
       this.props.dispatch(expandNotifications());
 
-      setTimeout(() => this.props.dispatch(fetchRules()), 3000);
+      setTimeout(() => this.props.dispatch(fetchServer()), 3000);
     }
 
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 81607a03b..9ecbfe576 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -28,6 +28,5 @@ export const title = getMeta('title');
 export const cropImages = getMeta('crop_images');
 export const disableSwiping = getMeta('disable_swiping');
 export const languages = initialState && initialState.languages;
-export const server = initialState && initialState.server;
 
 export default initialState;
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index d3d0303df..bccdc1865 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -17,7 +17,7 @@ import status_lists from './status_lists';
 import mutes from './mutes';
 import blocks from './blocks';
 import boosts from './boosts';
-import rules from './rules';
+import server from './server';
 import contexts from './contexts';
 import compose from './compose';
 import search from './search';
@@ -62,7 +62,7 @@ const reducers = {
   mutes,
   blocks,
   boosts,
-  rules,
+  server,
   contexts,
   compose,
   search,
diff --git a/app/javascript/mastodon/reducers/rules.js b/app/javascript/mastodon/reducers/rules.js
deleted file mode 100644
index c1180b520..000000000
--- a/app/javascript/mastodon/reducers/rules.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import { RULES_FETCH_SUCCESS } from 'mastodon/actions/rules';
-import { List as ImmutableList, fromJS } from 'immutable';
-
-const initialState = ImmutableList();
-
-export default function rules(state = initialState, action) {
-  switch (action.type) {
-  case RULES_FETCH_SUCCESS:
-    return fromJS(action.rules);
-  default:
-    return state;
-  }
-}
diff --git a/app/javascript/mastodon/reducers/server.js b/app/javascript/mastodon/reducers/server.js
new file mode 100644
index 000000000..68131c6dd
--- /dev/null
+++ b/app/javascript/mastodon/reducers/server.js
@@ -0,0 +1,19 @@
+import { SERVER_FETCH_REQUEST, SERVER_FETCH_SUCCESS, SERVER_FETCH_FAIL } from 'mastodon/actions/server';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  isLoading: true,
+});
+
+export default function server(state = initialState, action) {
+  switch (action.type) {
+  case SERVER_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case SERVER_FETCH_SUCCESS:
+    return fromJS(action.server).set('isLoading', false);
+  case SERVER_FETCH_FAIL:
+    return state.set('isLoading', false);
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index e831fce53..4bdd5accf 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -8021,3 +8021,85 @@ noscript {
     }
   }
 }
+
+.server-banner {
+  padding: 20px 0;
+
+  &__introduction {
+    color: $darker-text-color;
+    margin-bottom: 20px;
+
+    strong {
+      font-weight: 600;
+    }
+
+    a {
+      color: inherit;
+      text-decoration: underline;
+
+      &:hover,
+      &:active,
+      &:focus {
+        text-decoration: none;
+      }
+    }
+  }
+
+  &__hero {
+    display: block;
+    border-radius: 4px;
+    width: 100%;
+    height: auto;
+    margin-bottom: 20px;
+    aspect-ratio: 1.9;
+    border: 0;
+    background: $ui-base-color;
+    object-fit: cover;
+  }
+
+  &__description {
+    margin-bottom: 20px;
+  }
+
+  &__meta {
+    display: flex;
+    gap: 10px;
+    max-width: 100%;
+
+    &__column {
+      flex: 0 0 auto;
+      width: calc(50% - 5px);
+      overflow: hidden;
+    }
+  }
+
+  &__number {
+    font-weight: 600;
+    color: $primary-text-color;
+  }
+
+  &__number-label {
+    color: $darker-text-color;
+    font-weight: 500;
+  }
+
+  h4 {
+    text-transform: uppercase;
+    color: $darker-text-color;
+    margin-bottom: 10px;
+    font-weight: 600;
+  }
+
+  .account {
+    padding: 0;
+    border: 0;
+  }
+
+  .account__avatar-wrapper {
+    margin-left: 0;
+  }
+
+  .spacer {
+    margin: 10px 0;
+  }
+}
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 3e85faa92..c461ac55f 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -1,19 +1,51 @@
 # frozen_string_literal: true
 
-class InstancePresenter
-  delegate(
-    :site_contact_email,
-    :site_title,
-    :site_short_description,
-    :site_description,
-    :site_extended_description,
-    :site_terms,
-    :closed_registrations_message,
-    to: Setting
-  )
-
-  def contact_account
-    Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, ''))
+class InstancePresenter < ActiveModelSerializers::Model
+  attributes :domain, :title, :version, :source_url,
+             :description, :languages, :rules, :contact
+
+  class ContactPresenter < ActiveModelSerializers::Model
+    attributes :email, :account
+
+    def email
+      Setting.site_contact_email
+    end
+
+    def account
+      Account.find_local(Setting.site_contact_username.strip.gsub(/\A@/, ''))
+    end
+  end
+
+  def contact
+    ContactPresenter.new
+  end
+
+  def closed_registrations_message
+    Setting.closed_registrations_message
+  end
+
+  def description
+    Setting.site_short_description
+  end
+
+  def extended_description
+    Setting.site_extended_description
+  end
+
+  def privacy_policy
+    Setting.site_terms
+  end
+
+  def domain
+    Rails.configuration.x.local_domain
+  end
+
+  def title
+    Setting.site_title
+  end
+
+  def languages
+    [I18n.default_locale]
   end
 
   def rules
@@ -40,8 +72,8 @@ class InstancePresenter
     Rails.cache.fetch('sample_accounts', expires_in: 12.hours) { Account.local.discoverable.popular.limit(3) }
   end
 
-  def version_number
-    Mastodon::Version
+  def version
+    Mastodon::Version.to_s
   end
 
   def source_url
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 87f4db83d..1a49182a8 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -5,23 +5,24 @@ class InitialStateSerializer < ActiveModel::Serializer
 
   attributes :meta, :compose, :accounts,
              :media_attachments, :settings,
-             :languages, :server
+             :languages
 
   has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer
   has_one :role, serializer: REST::RoleSerializer
 
+  # rubocop:disable Metrics/AbcSize
   def meta
     store = {
       streaming_api_base_url: Rails.configuration.x.streaming_api_base_url,
       access_token: object.token,
       locale: I18n.locale,
-      domain: Rails.configuration.x.local_domain,
-      title: instance_presenter.site_title,
+      domain: instance_presenter.domain,
+      title: instance_presenter.title,
       admin: object.admin&.id&.to_s,
       search_enabled: Chewy.enabled?,
       repository: Mastodon::Version.repository,
-      source_url: Mastodon::Version.source_url,
-      version: Mastodon::Version.to_s,
+      source_url: instance_presenter.source_url,
+      version: instance_presenter.version,
       limited_federation_mode: Rails.configuration.x.whitelist_mode,
       mascot: instance_presenter.mascot&.file&.url,
       profile_directory: Setting.profile_directory,
@@ -54,6 +55,7 @@ class InitialStateSerializer < ActiveModel::Serializer
 
     store
   end
+  # rubocop:enable Metrics/AbcSize
 
   def compose
     store = {}
@@ -85,13 +87,6 @@ class InitialStateSerializer < ActiveModel::Serializer
     LanguagesHelper::SUPPORTED_LOCALES.map { |(key, value)| [key, value[0], value[1]] }
   end
 
-  def server
-    {
-      hero: instance_presenter.hero&.file&.url || instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'),
-      description: instance_presenter.site_short_description.presence || I18n.t('about.about_mastodon_html'),
-    }
-  end
-
   private
 
   def instance_presenter
diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb
index 9827323a8..6b5296480 100644
--- a/app/serializers/manifest_serializer.rb
+++ b/app/serializers/manifest_serializer.rb
@@ -22,11 +22,11 @@ class ManifestSerializer < ActiveModel::Serializer
              :share_target, :shortcuts
 
   def name
-    object.site_title
+    object.title
   end
 
   def short_name
-    object.site_title
+    object.title
   end
 
   def icons
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index 9cc245422..f4ea49427 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -1,61 +1,39 @@
 # frozen_string_literal: true
 
 class REST::InstanceSerializer < ActiveModel::Serializer
-  include RoutingHelper
-
-  attributes :uri, :title, :short_description, :description, :email,
-             :version, :urls, :stats, :thumbnail,
-             :languages, :registrations, :approval_required, :invites_enabled,
-             :configuration
-
-  has_one :contact_account, serializer: REST::AccountSerializer
-
-  has_many :rules, serializer: REST::RuleSerializer
-
-  delegate :contact_account, :rules, to: :instance_presenter
-
-  def uri
-    Rails.configuration.x.local_domain
-  end
+  class ContactSerializer < ActiveModel::Serializer
+    attributes :email
 
-  def title
-    Setting.site_title
+    has_one :account, serializer: REST::AccountSerializer
   end
 
-  def short_description
-    Setting.site_short_description
-  end
-
-  def description
-    Setting.site_description
-  end
+  include RoutingHelper
 
-  def email
-    Setting.site_contact_email
-  end
+  attributes :domain, :title, :version, :source_url, :description,
+             :usage, :thumbnail, :languages, :configuration,
+             :registrations
 
-  def version
-    Mastodon::Version.to_s
-  end
+  has_one :contact, serializer: ContactSerializer
+  has_many :rules, serializer: REST::RuleSerializer
 
   def thumbnail
-    instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('media/images/preview.png')
+    object.thumbnail ? full_asset_url(object.thumbnail.file.url) : full_pack_url('media/images/preview.png')
   end
 
-  def stats
+  def usage
     {
-      user_count: instance_presenter.user_count,
-      status_count: instance_presenter.status_count,
-      domain_count: instance_presenter.domain_count,
+      users: {
+        active_month: object.active_user_count(4),
+      },
     }
   end
 
-  def urls
-    { streaming_api: Rails.configuration.x.streaming_api_base_url }
-  end
-
   def configuration
     {
+      urls: {
+        streaming: Rails.configuration.x.streaming_api_base_url,
+      },
+
       statuses: {
         max_characters: StatusLengthValidator::MAX_CHARS,
         max_media_attachments: 4,
@@ -80,25 +58,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
     }
   end
 
-  def languages
-    [I18n.default_locale]
-  end
-
   def registrations
-    Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode
-  end
-
-  def approval_required
-    Setting.registrations_mode == 'approved'
-  end
-
-  def invites_enabled
-    UserRole.everyone.can?(:invite_users)
-  end
-
-  private
-
-  def instance_presenter
-    @instance_presenter ||= InstancePresenter.new
+    {
+      enabled: Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode,
+      approval_required: Setting.registrations_mode == 'approved',
+    }
   end
 end
diff --git a/app/serializers/rest/v1/instance_serializer.rb b/app/serializers/rest/v1/instance_serializer.rb
new file mode 100644
index 000000000..47fa086fc
--- /dev/null
+++ b/app/serializers/rest/v1/instance_serializer.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+class REST::V1::InstanceSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :uri, :title, :short_description, :description, :email,
+             :version, :urls, :stats, :thumbnail,
+             :languages, :registrations, :approval_required, :invites_enabled,
+             :configuration
+
+  has_one :contact_account, serializer: REST::AccountSerializer
+
+  has_many :rules, serializer: REST::RuleSerializer
+
+  def uri
+    object.domain
+  end
+
+  def short_description
+    object.description
+  end
+
+  def description
+    Setting.site_description # Legacy
+  end
+
+  def email
+    object.contact.email
+  end
+
+  def contact_account
+    object.contact.account
+  end
+
+  def thumbnail
+    instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('media/images/preview.png')
+  end
+
+  def stats
+    {
+      user_count: instance_presenter.user_count,
+      status_count: instance_presenter.status_count,
+      domain_count: instance_presenter.domain_count,
+    }
+  end
+
+  def urls
+    { streaming_api: Rails.configuration.x.streaming_api_base_url }
+  end
+
+  def usage
+    {
+      users: {
+        active_month: instance_presenter.active_user_count(4),
+      },
+    }
+  end
+
+  def configuration
+    {
+      statuses: {
+        max_characters: StatusLengthValidator::MAX_CHARS,
+        max_media_attachments: 4,
+        characters_reserved_per_url: StatusLengthValidator::URL_PLACEHOLDER_CHARS,
+      },
+
+      media_attachments: {
+        supported_mime_types: MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES + MediaAttachment::AUDIO_MIME_TYPES,
+        image_size_limit: MediaAttachment::IMAGE_LIMIT,
+        image_matrix_limit: Attachmentable::MAX_MATRIX_LIMIT,
+        video_size_limit: MediaAttachment::VIDEO_LIMIT,
+        video_frame_rate_limit: MediaAttachment::MAX_VIDEO_FRAME_RATE,
+        video_matrix_limit: MediaAttachment::MAX_VIDEO_MATRIX_LIMIT,
+      },
+
+      polls: {
+        max_options: PollValidator::MAX_OPTIONS,
+        max_characters_per_option: PollValidator::MAX_OPTION_CHARS,
+        min_expiration: PollValidator::MIN_EXPIRATION,
+        max_expiration: PollValidator::MAX_EXPIRATION,
+      },
+    }
+  end
+
+  def registrations
+    Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode
+  end
+
+  def approval_required
+    Setting.registrations_mode == 'approved'
+  end
+
+  def invites_enabled
+    UserRole.everyone.can?(:invite_users)
+  end
+
+  private
+
+  def instance_presenter
+    @instance_presenter ||= InstancePresenter.new
+  end
+end
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 3b48afc0c..a5a10b620 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -9,7 +9,7 @@
   .column-0
     .public-account-header.public-account-header--no-bar
       .public-account-header__image
-        = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.site_title, class: 'parallax'
+        = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title, class: 'parallax'
 
   .column-1
     .landing-page__call-to-action{ dir: 'ltr' }
@@ -31,14 +31,14 @@
     .contact-widget
       %h4= t 'about.administered_by'
 
-      = account_link_to(@instance_presenter.contact_account)
+      = account_link_to(@instance_presenter.contact.account)
 
-      - if @instance_presenter.site_contact_email.present?
+      - if @instance_presenter.contact.email.present?
         %h4
           = succeed ':' do
             = t 'about.contact'
 
-        = mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email
+        = mail_to @instance_presenter.contact.email, nil, title: @instance_presenter.contact.email
 
   .column-3
     = render 'application/flashes'
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index d61b3cd51..75124d5e2 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -40,11 +40,11 @@
 
       .hero-widget
         .hero-widget__img
-          = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.site_title
+          = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title
 
         .hero-widget__text
           %p
-            = @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html')
+            = @instance_presenter.description.html_safe.presence || t('about.about_mastodon_html')
             = link_to about_more_path do
               = t('about.learn_more')
               = fa_icon 'angle-double-right'
@@ -53,7 +53,7 @@
           .hero-widget__footer__column
             %h4= t 'about.administered_by'
 
-            = account_link_to @instance_presenter.contact_account
+            = account_link_to @instance_presenter.contact.account
 
           .hero-widget__footer__column
             %h4= t 'about.server_stats'
diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml
index cc157bf47..eb2813dd0 100644
--- a/app/views/application/_sidebar.html.haml
+++ b/app/views/application/_sidebar.html.haml
@@ -1,9 +1,9 @@
 .hero-widget
   .hero-widget__img
-    = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.site_title
+    = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.png'), alt: @instance_presenter.title
 
   .hero-widget__text
-    %p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html')
+    %p= @instance_presenter.description.html_safe.presence || t('about.about_mastodon_html')
 
 - if Setting.trends && !(user_signed_in? && !current_user.setting_trends)
   - trends = Trends.tags.query.allowed.limit(3)
diff --git a/app/views/privacy/show.html.haml b/app/views/privacy/show.html.haml
index 9d076a91b..cdd38a595 100644
--- a/app/views/privacy/show.html.haml
+++ b/app/views/privacy/show.html.haml
@@ -4,6 +4,6 @@
 .grid
   .column-0
     .box-widget
-      .rich-formatting= @instance_presenter.site_terms.html_safe.presence || t('terms.body_html')
+      .rich-formatting= @instance_presenter.privacy_policy.html_safe.presence || t('terms.body_html')
   .column-1
     = render 'application/sidebar'
diff --git a/app/views/shared/_og.html.haml b/app/views/shared/_og.html.haml
index 7feae1b8b..b54ab2429 100644
--- a/app/views/shared/_og.html.haml
+++ b/app/views/shared/_og.html.haml
@@ -1,12 +1,12 @@
 - thumbnail     = @instance_presenter.thumbnail
-- description ||= strip_tags(@instance_presenter.site_short_description.presence || t('about.about_mastodon_html'))
+- description ||= strip_tags(@instance_presenter.description.presence || t('about.about_mastodon_html'))
 
 %meta{ name: 'description', content: description }/
 
 = opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname)
 = opengraph 'og:url', url_for(only_path: false)
 = opengraph 'og:type', 'website'
-= opengraph 'og:title', @instance_presenter.site_title
+= opengraph 'og:title', @instance_presenter.title
 = opengraph 'og:description', description
 = opengraph 'og:image', full_asset_url(thumbnail&.file&.url || asset_pack_path('media/images/preview.png', protocol: :request))
 = opengraph 'og:image:width', thumbnail ? thumbnail.meta['width'] : '1200'
diff --git a/config/routes.rb b/config/routes.rb
index d2ede87d3..188898fd0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -616,10 +616,12 @@ Rails.application.routes.draw do
     end
 
     namespace :v2 do
-      resources :media, only: [:create]
       get '/search', to: 'search#index', as: :search
+
+      resources :media,       only: [:create]
       resources :suggestions, only: [:index]
       resources :filters,     only: [:index, :create, :show, :update, :destroy]
+      resource  :instance,    only: [:show]
 
       namespace :admin do
         resources :accounts, only: [:index]
diff --git a/spec/presenters/instance_presenter_spec.rb b/spec/presenters/instance_presenter_spec.rb
index 973b3e23c..a0a8628e8 100644
--- a/spec/presenters/instance_presenter_spec.rb
+++ b/spec/presenters/instance_presenter_spec.rb
@@ -3,21 +3,20 @@ require 'rails_helper'
 describe InstancePresenter do
   let(:instance_presenter) { InstancePresenter.new }
 
-  context do
+  describe '#description' do
     around do |example|
-      site_description = Setting.site_description
+      site_description = Setting.site_short_description
       example.run
-      Setting.site_description = site_description
+      Setting.site_short_description = site_description
     end
 
     it "delegates site_description to Setting" do
-      Setting.site_description = "Site desc"
-
-      expect(instance_presenter.site_description).to eq "Site desc"
+      Setting.site_short_description = "Site desc"
+      expect(instance_presenter.description).to eq "Site desc"
     end
   end
 
-  context do
+  describe '#extended_description' do
     around do |example|
       site_extended_description = Setting.site_extended_description
       example.run
@@ -26,12 +25,11 @@ describe InstancePresenter do
 
     it "delegates site_extended_description to Setting" do
       Setting.site_extended_description = "Extended desc"
-
-      expect(instance_presenter.site_extended_description).to eq "Extended desc"
+      expect(instance_presenter.extended_description).to eq "Extended desc"
     end
   end
 
-  context do
+  describe '#email' do
     around do |example|
       site_contact_email = Setting.site_contact_email
       example.run
@@ -40,12 +38,11 @@ describe InstancePresenter do
 
     it "delegates contact_email to Setting" do
       Setting.site_contact_email = "admin@example.com"
-
-      expect(instance_presenter.site_contact_email).to eq "admin@example.com"
+      expect(instance_presenter.contact.email).to eq "admin@example.com"
     end
   end
 
-  describe "contact_account" do
+  describe '#account' do
     around do |example|
       site_contact_username = Setting.site_contact_username
       example.run
@@ -55,12 +52,11 @@ describe InstancePresenter do
     it "returns the account for the site contact username" do
       Setting.site_contact_username = "aaa"
       account = Fabricate(:account, username: "aaa")
-
-      expect(instance_presenter.contact_account).to eq(account)
+      expect(instance_presenter.contact.account).to eq(account)
     end
   end
 
-  describe "user_count" do
+  describe '#user_count' do
     it "returns the number of site users" do
       Rails.cache.write 'user_count', 123
 
@@ -68,7 +64,7 @@ describe InstancePresenter do
     end
   end
 
-  describe "status_count" do
+  describe '#status_count' do
     it "returns the number of local statuses" do
       Rails.cache.write 'local_status_count', 234
 
@@ -76,7 +72,7 @@ describe InstancePresenter do
     end
   end
 
-  describe "domain_count" do
+  describe '#domain_count' do
     it "returns the number of known domains" do
       Rails.cache.write 'distinct_domain_count', 345
 
@@ -84,9 +80,9 @@ describe InstancePresenter do
     end
   end
 
-  describe '#version_number' do
-    it 'returns Mastodon::Version' do
-      expect(instance_presenter.version_number).to be(Mastodon::Version)
+  describe '#version' do
+    it 'returns string' do
+      expect(instance_presenter.version).to be_a String
     end
   end
 
diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb
index 4eab97da9..bf6e19d2b 100644
--- a/spec/views/about/show.html.haml_spec.rb
+++ b/spec/views/about/show.html.haml_spec.rb
@@ -12,25 +12,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
   end
 
   it 'has valid open graph tags' do
-    instance_presenter = double(
-      :instance_presenter,
-      site_title: 'something',
-      site_short_description: 'something',
-      site_description: 'something',
-      version_number: '1.0',
-      source_url: 'https://github.com/mastodon/mastodon',
-      open_registrations: false,
-      thumbnail: nil,
-      hero: nil,
-      mascot: nil,
-      user_count: 420,
-      status_count: 69,
-      active_user_count: 420,
-      contact_account: nil,
-      sample_accounts: []
-    )
-
-    assign(:instance_presenter, instance_presenter)
+    assign(:instance_presenter, InstancePresenter.new)
     render
 
     header_tags = view.content_for(:header_tags)