about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2018-08-10 15:39:06 +0200
committerThibaut Girka <thib@sitedethib.com>2018-08-10 16:22:31 +0200
commite5a603206dfa76e2cd1b8a7fbb940fbd844b436c (patch)
treec3271f86b91ce2d13aea4f111dad6da4d07dcc04 /app
parent90b492143dae5cbaf884b12fbfd823c9f65d392f (diff)
parent5b8603879f06573d51c5c33edda6f410d1af7a02 (diff)
Merge branch 'master' into glitch-soc/merge-upstream
Conflicts:
	app/controllers/accounts_controller.rb
	app/javascript/mastodon/locales/pl.json
	app/views/about/more.html.haml

Conflicts in `accounts_controller.rb` resolved by taking upstream's
version + our `use_pack`.

Conflicts in `pl.json` resolved by taking upstream's changes.

Conflicts in `aboute/more.html.haml` resolved by taking upstream's changes.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/about_controller.rb8
-rw-r--r--app/controllers/accounts_controller.rb5
-rw-r--r--app/controllers/api/v1/accounts/pins_controller.rb32
-rw-r--r--app/helpers/home_helper.rb32
-rw-r--r--app/javascript/mastodon/actions/accounts.js74
-rw-r--r--app/javascript/mastodon/containers/mastodon.js7
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js6
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js6
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js10
-rw-r--r--app/javascript/mastodon/locales/pl.json24
-rw-r--r--app/javascript/mastodon/locales/ru.json44
-rw-r--r--app/javascript/mastodon/reducers/relationships.js4
-rw-r--r--app/javascript/packs/public.js9
-rw-r--r--app/javascript/styles/mastodon/about.scss418
-rw-r--r--app/javascript/styles/mastodon/containers.scss90
-rw-r--r--app/javascript/styles/mastodon/widgets.scss83
-rw-r--r--app/lib/ostatus/atom_serializer.rb2
-rw-r--r--app/models/account.rb4
-rw-r--r--app/models/account_pin.rb26
-rw-r--r--app/models/concerns/account_interactions.rb8
-rw-r--r--app/models/follow.rb5
-rw-r--r--app/presenters/account_relationships_presenter.rb7
-rw-r--r--app/serializers/rest/relationship_serializer.rb7
-rw-r--r--app/views/about/_administration.html.haml19
-rw-r--r--app/views/about/_contact.html.haml22
-rw-r--r--app/views/about/more.html.haml72
-rw-r--r--app/views/about/show.html.haml4
-rw-r--r--app/views/about/terms.html.haml14
-rw-r--r--app/views/accounts/_header.html.haml2
-rw-r--r--app/views/accounts/show.html.haml8
30 files changed, 778 insertions, 274 deletions
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index a620d7c5f..ce1e8293c 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -10,9 +10,13 @@ class AboutController < ApplicationController
     @initial_state_json   = serializable_resource.to_json
   end
 
-  def more; end
+  def more
+    render layout: 'public'
+  end
 
-  def terms; end
+  def terms
+    render layout: 'public'
+  end
 
   private
 
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 35ee5cca9..3d20f0e88 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -11,8 +11,9 @@ class AccountsController < ApplicationController
     respond_to do |format|
       format.html do
         use_pack 'public'
-        @body_classes    = 'with-modals'
-        @pinned_statuses = []
+        @body_classes      = 'with-modals'
+        @pinned_statuses   = []
+        @endorsed_accounts = @account.endorsed_accounts.to_a.sample(4)
 
         if current_account && @account.blocking?(current_account)
           @statuses = []
diff --git a/app/controllers/api/v1/accounts/pins_controller.rb b/app/controllers/api/v1/accounts/pins_controller.rb
new file mode 100644
index 000000000..0a0239c42
--- /dev/null
+++ b/app/controllers/api/v1/accounts/pins_controller.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class Api::V1::Accounts::PinsController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
+  before_action :require_user!
+  before_action :set_account
+
+  respond_to :json
+
+  def create
+    AccountPin.create!(account: current_account, target_account: @account)
+    render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
+  end
+
+  def destroy
+    pin = AccountPin.find_by(account: current_account, target_account: @account)
+    pin&.destroy!
+    render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
+  end
+
+  private
+
+  def set_account
+    @account = Account.find(params[:account_id])
+  end
+
+  def relationships_presenter
+    AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
+  end
+end
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
index d3c6b13a6..8449f6c8a 100644
--- a/app/helpers/home_helper.rb
+++ b/app/helpers/home_helper.rb
@@ -6,4 +6,36 @@ module HomeHelper
       locale: I18n.locale,
     }
   end
+
+  def account_link_to(account, button = '')
+    content_tag(:div, class: 'account') do
+      content_tag(:div, class: 'account__wrapper') do
+        section = if account.nil?
+                    content_tag(:div, class: 'account__display-name') do
+                      content_tag(:div, class: 'account__avatar-wrapper') do
+                        content_tag(:div, '', class: 'account__avatar', style: "background-image: url(#{full_asset_url('avatars/original/missing.png', skip_pipeline: true)})")
+                      end +
+                        content_tag(:span, class: 'display-name') do
+                          content_tag(:strong, t('about.contact_missing')) +
+                            content_tag(:span, t('about.contact_unavailable'), class: 'display-name__account')
+                        end
+                    end
+                  else
+                    link_to(TagManager.instance.url_for(account), class: 'account__display-name') do
+                      content_tag(:div, class: 'account__avatar-wrapper') do
+                        content_tag(:div, '', class: 'account__avatar', style: "background-image: url(#{account.avatar.url})")
+                      end +
+                        content_tag(:span, class: 'display-name') do
+                          content_tag(:bdi) do
+                            content_tag(:strong, display_name(account, custom_emojify: true), class: 'display-name__html emojify')
+                          end +
+                            content_tag(:span, "@#{account.acct}", class: 'display-name__account')
+                        end
+                    end
+                  end
+
+        section + button
+      end
+    end
+  end
 end
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index c9e4afcfc..cbae62a0f 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -30,6 +30,14 @@ export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
 export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
 export const ACCOUNT_UNMUTE_FAIL    = 'ACCOUNT_UNMUTE_FAIL';
 
+export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST';
+export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS';
+export const ACCOUNT_PIN_FAIL    = 'ACCOUNT_PIN_FAIL';
+
+export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST';
+export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS';
+export const ACCOUNT_UNPIN_FAIL    = 'ACCOUNT_UNPIN_FAIL';
+
 export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
 export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
 export const FOLLOWERS_FETCH_FAIL    = 'FOLLOWERS_FETCH_FAIL';
@@ -694,3 +702,69 @@ export function rejectFollowRequestFail(id, error) {
     error,
   };
 };
+
+export function pinAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(pinAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => {
+      dispatch(pinAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(pinAccountFail(error));
+    });
+  };
+};
+
+export function unpinAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unpinAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => {
+      dispatch(unpinAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unpinAccountFail(error));
+    });
+  };
+};
+
+export function pinAccountRequest(id) {
+  return {
+    type: ACCOUNT_PIN_REQUEST,
+    id,
+  };
+};
+
+export function pinAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_PIN_SUCCESS,
+    relationship,
+  };
+};
+
+export function pinAccountFail(error) {
+  return {
+    type: ACCOUNT_PIN_FAIL,
+    error,
+  };
+};
+
+export function unpinAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNPIN_REQUEST,
+    id,
+  };
+};
+
+export function unpinAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNPIN_SUCCESS,
+    relationship,
+  };
+};
+
+export function unpinAccountFail(error) {
+  return {
+    type: ACCOUNT_UNPIN_FAIL,
+    error,
+  };
+};
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index b29898d3b..b2b0265aa 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -38,13 +38,6 @@ export default class Mastodon extends React.PureComponent {
       window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
     }
 
-    // Protocol handler
-    // Ask after 5 minutes
-    if (typeof navigator.registerProtocolHandler !== 'undefined') {
-      const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
-      window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
-    }
-
     store.dispatch(showOnboardingOnce());
   }
 
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index e3f2d0f55..43b4811e1 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -32,6 +32,8 @@ const messages = defineMessages({
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+  endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
+  unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
 });
 
 @injectIntl
@@ -48,6 +50,7 @@ export default class ActionBar extends React.PureComponent {
     onMute: PropTypes.func.isRequired,
     onBlockDomain: PropTypes.func.isRequired,
     onUnblockDomain: PropTypes.func.isRequired,
+    onEndorseToggle: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
@@ -93,6 +96,9 @@ export default class ActionBar extends React.PureComponent {
         } else {
           menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
         }
+
+        menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
+        menu.push(null);
       }
 
       if (account.getIn(['relationship', 'muting'])) {
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 1ae5126e6..ab29e4bdf 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -22,6 +22,7 @@ export default class Header extends ImmutablePureComponent {
     onMute: PropTypes.func.isRequired,
     onBlockDomain: PropTypes.func.isRequired,
     onUnblockDomain: PropTypes.func.isRequired,
+    onEndorseToggle: PropTypes.func.isRequired,
     hideTabs: PropTypes.bool,
   };
 
@@ -73,6 +74,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onUnblockDomain(domain);
   }
 
+  handleEndorseToggle = () => {
+    this.props.onEndorseToggle(this.props.account);
+  }
+
   render () {
     const { account, hideTabs } = this.props;
 
@@ -100,6 +105,7 @@ export default class Header extends ImmutablePureComponent {
           onMute={this.handleMute}
           onBlockDomain={this.handleBlockDomain}
           onUnblockDomain={this.handleUnblockDomain}
+          onEndorseToggle={this.handleEndorseToggle}
         />
 
         {!hideTabs && (
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 7681430b7..02803893d 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -8,6 +8,8 @@ import {
   blockAccount,
   unblockAccount,
   unmuteAccount,
+  pinAccount,
+  unpinAccount,
 } from '../../../actions/accounts';
 import {
   mentionCompose,
@@ -82,6 +84,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onEndorseToggle (account) {
+    if (account.getIn(['relationship', 'endorsed'])) {
+      dispatch(unpinAccount(account.get('id')));
+    } else {
+      dispatch(pinAccount(account.get('id')));
+    }
+  },
+
   onReport (account) {
     dispatch(initReport(account));
   },
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 67711d1bd..3f3a7dd41 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -2,7 +2,7 @@
   "account.badges.bot": "Bot",
   "account.block": "Blokuj @{name}",
   "account.block_domain": "Blokuj wszystko z {domain}",
-  "account.blocked": "Zablokowany",
+  "account.blocked": "Zablokowany(-a)",
   "account.direct": "Wyślij wiadomość bezpośrednią do @{name}",
   "account.disclaimer_full": "Poniższe informacje mogą nie odwzorowywać bezbłędnie profilu użytkownika.",
   "account.domain_blocked": "Ukryto domenę",
@@ -14,7 +14,7 @@
   "account.hide_reblogs": "Ukryj podbicia od @{name}",
   "account.media": "Zawartość multimedialna",
   "account.mention": "Wspomnij o @{name}",
-  "account.moved_to": "{name} przeniósł się do:",
+  "account.moved_to": "{name} przeniósł(-osła) się do:",
   "account.mute": "Wycisz @{name}",
   "account.mute_notifications": "Wycisz powiadomienia o @{name}",
   "account.muted": "Wyciszony",
@@ -109,8 +109,8 @@
   "emoji_button.symbols": "Symbole",
   "emoji_button.travel": "Podróże i miejsca",
   "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!",
-  "empty_column.direct": "Nie masz żadnych wiadomości bezpośrednich. Jeżeli wyślesz lub otrzymasz jakąś, będzie tu widoczna.",
-  "empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
+  "empty_column.direct": "Nie masz żadnych wiadomości bezpośrednich. Kiedy dostaniesz lub wyślesz jakąś, pojawi się ona tutaj.",
+  "empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy(-a)!",
   "empty_column.home": "Nie śledzisz nikogo. Odwiedź globalną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
   "empty_column.home.public_timeline": "globalna oś czasu",
   "empty_column.list": "Nie ma nic na tej liście. Kiedy członkowie listy dodadzą nowe wpisy, pojawia się one tutaj.",
@@ -139,7 +139,7 @@
   "keyboard_shortcuts.favourite": "aby dodać do ulubionych",
   "keyboard_shortcuts.heading": "Skróty klawiszowe",
   "keyboard_shortcuts.hotkey": "Klawisz",
-  "keyboard_shortcuts.legend": "aby wyświetlić tą legendę",
+  "keyboard_shortcuts.legend": "aby wyświetlić tę legendę",
   "keyboard_shortcuts.mention": "aby wspomnieć o autorze",
   "keyboard_shortcuts.profile": "aby przejść do profilu autora wpisu",
   "keyboard_shortcuts.reply": "aby odpowiedzieć",
@@ -184,10 +184,10 @@
   "navigation_bar.preferences": "Preferencje",
   "navigation_bar.public_timeline": "Globalna oś czasu",
   "navigation_bar.security": "Bezpieczeństwo",
-  "notification.favourite": "{name} dodał Twój wpis do ulubionych",
-  "notification.follow": "{name} zaczął Cię śledzić",
-  "notification.mention": "{name} wspomniał o tobie",
-  "notification.reblog": "{name} podbił Twój wpis",
+  "notification.favourite": "{name} dodał(a) Twój wpis do ulubionych",
+  "notification.follow": "{name} zaczął(-ęła) Cię śledzić",
+  "notification.mention": "{name} wspomniał(a) o tobie",
+  "notification.reblog": "{name} podbił(a) Twój wpis",
   "notifications.clear": "Wyczyść powiadomienia",
   "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
   "notifications.column_settings.alert": "Powiadomienia na pulpicie",
@@ -246,7 +246,7 @@
   "report.target": "Zgłaszanie {target}",
   "search.placeholder": "Szukaj",
   "search_popout.search_format": "Zaawansowane wyszukiwanie",
-  "search_popout.tips.full_text": "Pozwala na wyszukiwanie wpisów które napisałeś, dodałeś do ulubionych, podbiłeś w których o Tobie wspomniano, oraz pasujące nazwy użytkowników, pełne nazwy i hashtagi.",
+  "search_popout.tips.full_text": "Pozwala na wyszukiwanie wpisów które napisałeś(-aś), dodałeś(-aś) do ulubionych lub podbiłeś(-aś), w których o Tobie wspomniano, oraz pasujące nazwy użytkowników, pełne nazwy i hashtagi.",
   "search_popout.tips.hashtag": "hashtag",
   "search_popout.tips.status": "wpis",
   "search_popout.tips.text": "Proste wyszukiwanie pasujących pseudonimów, nazw użytkowników i hashtagów",
@@ -263,7 +263,7 @@
   "status.direct": "Wyślij wiadomość bezpośrednią do @{name}",
   "status.embed": "Osadź",
   "status.favourite": "Dodaj do ulubionych",
-  "status.filtered": "Filtrowany",
+  "status.filtered": "Filtrowany(-a)",
   "status.load_more": "Załaduj więcej",
   "status.media_hidden": "Zawartość multimedialna ukryta",
   "status.mention": "Wspomnij o @{name}",
@@ -275,7 +275,7 @@
   "status.pinned": "Przypięty wpis",
   "status.reblog": "Podbij",
   "status.reblog_private": "Podbij dla odbiorców oryginalnego wpisu",
-  "status.reblogged_by": "{name} podbił",
+  "status.reblogged_by": "{name} podbił(a)",
   "status.redraft": "Usuń i przeredaguj",
   "status.reply": "Odpowiedz",
   "status.replyAll": "Odpowiedz na wątek",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index ad2dcda12..2706d5b71 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -12,7 +12,7 @@
   "account.follows": "Подписки",
   "account.follows_you": "Подписан(а) на Вас",
   "account.hide_reblogs": "Скрыть продвижения от @{name}",
-  "account.media": "Медиаконтент",
+  "account.media": "Медиа",
   "account.mention": "Упомянуть",
   "account.moved_to": "Ищите {name} здесь:",
   "account.mute": "Заглушить",
@@ -59,9 +59,9 @@
   "column_header.show_settings": "Показать настройки",
   "column_header.unpin": "Открепить",
   "column_subheading.settings": "Настройки",
-  "community.column_settings.media_only": "Media Only",
+  "community.column_settings.media_only": "Только медиа",
   "compose_form.direct_message_warning": "Этот статус будет виден только упомянутым пользователям.",
-  "compose_form.direct_message_warning_learn_more": "Learn more",
+  "compose_form.direct_message_warning_learn_more": "Узнать больше",
   "compose_form.hashtag_warning": "Этот пост не будет показывается в поиске по хэштегу, т.к. он непубличный. Только публичные посты можно найти в поиске по хэштегу.",
   "compose_form.lock_disclaimer": "Ваш аккаунт не {locked}. Любой человек может подписаться на Вас и просматривать посты для подписчиков.",
   "compose_form.lock_disclaimer.lock": "закрыт",
@@ -84,8 +84,8 @@
   "confirmations.domain_block.message": "Вы на самом деле уверены, что хотите блокировать весь {domain}? В большинстве случаев нескольких отдельных блокировок или глушений достаточно.",
   "confirmations.mute.confirm": "Заглушить",
   "confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?",
-  "confirmations.redraft.confirm": "Delete & redraft",
-  "confirmations.redraft.message": "Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.",
+  "confirmations.redraft.confirm": "Удалить и исправить",
+  "confirmations.redraft.message": "Вы уверены, что хотите удалить этот статус и превратить в черновик? Вы потеряете все ответы, продвижения и отметки 'нравится' к нему.",
   "confirmations.unfollow.confirm": "Отписаться",
   "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?",
   "embed.instructions": "Встройте этот статус на Вашем сайте, скопировав код внизу.",
@@ -114,14 +114,14 @@
   "empty_column.public": "Здесь ничего нет! Опубликуйте что-нибудь или подпишитесь на пользователей с других узлов, чтобы заполнить ленту.",
   "follow_request.authorize": "Авторизовать",
   "follow_request.reject": "Отказать",
-  "getting_started.developers": "Developers",
-  "getting_started.documentation": "Documentation",
-  "getting_started.find_friends": "Find friends from Twitter",
+  "getting_started.developers": "Для разработчиков",
+  "getting_started.documentation": "Документация",
+  "getting_started.find_friends": "Найти друзей из Twitter",
   "getting_started.heading": "Добро пожаловать",
-  "getting_started.invite": "Invite people",
-  "getting_started.open_source_notice": "Mastodon - программа с открытым исходным кодом. Вы можете помочь проекту или сообщить о проблемах на GitHub по адресу {github}.",
-  "getting_started.security": "Security",
-  "getting_started.terms": "Terms of service",
+  "getting_started.invite": "Пригласить людей",
+  "getting_started.open_source_notice": "Mastodon - сервис с открытым исходным кодом. Вы можете помочь проекту или сообщить о проблемах на GitHub по адресу {github}.",
+  "getting_started.security": "Безопасность",
+  "getting_started.terms": "Условия использования",
   "home.column_settings.basic": "Основные",
   "home.column_settings.show_reblogs": "Показывать продвижения",
   "home.column_settings.show_replies": "Показывать ответы",
@@ -137,7 +137,7 @@
   "keyboard_shortcuts.hotkey": "Гор. клавиша",
   "keyboard_shortcuts.legend": "показать это окно",
   "keyboard_shortcuts.mention": "упомянуть автора поста",
-  "keyboard_shortcuts.profile": "to open author's profile",
+  "keyboard_shortcuts.profile": "перейти к профилю автора",
   "keyboard_shortcuts.reply": "ответить",
   "keyboard_shortcuts.search": "перейти к поиску",
   "keyboard_shortcuts.toggle_hidden": "показать/скрыть текст за предупреждением",
@@ -163,22 +163,22 @@
   "navigation_bar.blocks": "Список блокировки",
   "navigation_bar.community_timeline": "Локальная лента",
   "navigation_bar.direct": "Личные сообщения",
-  "navigation_bar.discover": "Discover",
+  "navigation_bar.discover": "Изучайте",
   "navigation_bar.domain_blocks": "Скрытые домены",
   "navigation_bar.edit_profile": "Изменить профиль",
   "navigation_bar.favourites": "Понравившееся",
-  "navigation_bar.filters": "Muted words",
+  "navigation_bar.filters": "Заглушенные слова",
   "navigation_bar.follow_requests": "Запросы на подписку",
   "navigation_bar.info": "Об узле",
   "navigation_bar.keyboard_shortcuts": "Сочетания клавиш",
   "navigation_bar.lists": "Списки",
   "navigation_bar.logout": "Выйти",
   "navigation_bar.mutes": "Список глушения",
-  "navigation_bar.personal": "Personal",
+  "navigation_bar.personal": "Личное",
   "navigation_bar.pins": "Закреплённые посты",
   "navigation_bar.preferences": "Опции",
   "navigation_bar.public_timeline": "Глобальная лента",
-  "navigation_bar.security": "Security",
+  "navigation_bar.security": "Безопасность",
   "notification.favourite": "{name} понравился Ваш статус",
   "notification.follow": "{name} подписался(-лась) на Вас",
   "notification.mention": "{name} упомянул(а) Вас",
@@ -194,7 +194,7 @@
   "notifications.column_settings.reblog": "Продвижения:",
   "notifications.column_settings.show": "Показывать в колонке",
   "notifications.column_settings.sound": "Проигрывать звук",
-  "notifications.group": "{count} notifications",
+  "notifications.group": "{count} уведомл.",
   "onboarding.done": "Готово",
   "onboarding.next": "Далее",
   "onboarding.page_five.public_timelines": "Локальная лента показывает публичные посты всех пользователей {domain}. Глобальная лента показывает публичные посты всех людей, на которых подписаны пользователи {domain}. Это - публичные ленты, отличный способ найти новые знакомства.",
@@ -258,9 +258,9 @@
   "status.direct": "Написать @{name}",
   "status.embed": "Встроить",
   "status.favourite": "Нравится",
-  "status.filtered": "Filtered",
+  "status.filtered": "Отфильтровано",
   "status.load_more": "Показать еще",
-  "status.media_hidden": "Медиаконтент скрыт",
+  "status.media_hidden": "Медиа скрыто",
   "status.mention": "Упомянуть @{name}",
   "status.more": "Больше",
   "status.mute": "Заглушить @{name}",
@@ -271,7 +271,7 @@
   "status.reblog": "Продвинуть",
   "status.reblog_private": "Продвинуть для своей аудитории",
   "status.reblogged_by": "{name} продвинул(а)",
-  "status.redraft": "Delete & re-draft",
+  "status.redraft": "Удалить и повторить",
   "status.reply": "Ответить",
   "status.replyAll": "Ответить на тред",
   "status.report": "Пожаловаться",
@@ -289,7 +289,7 @@
   "tabs_bar.local_timeline": "Локальная",
   "tabs_bar.notifications": "Уведомления",
   "tabs_bar.search": "Поиск",
-  "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
+  "trends.count_by_accounts": "Популярно у {count} {rawCount, plural, one {человека} few {человек} many {человек} other {человек}}",
   "ui.beforeunload": "Ваш черновик будет утерян, если вы покинете Mastodon.",
   "upload_area.title": "Перетащите сюда, чтобы загрузить",
   "upload_button.label": "Добавить медиаконтент",
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index d1caabc1c..f46049297 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -5,6 +5,8 @@ import {
   ACCOUNT_UNBLOCK_SUCCESS,
   ACCOUNT_MUTE_SUCCESS,
   ACCOUNT_UNMUTE_SUCCESS,
+  ACCOUNT_PIN_SUCCESS,
+  ACCOUNT_UNPIN_SUCCESS,
   RELATIONSHIPS_FETCH_SUCCESS,
 } from '../actions/accounts';
 import {
@@ -41,6 +43,8 @@ export default function relationships(state = initialState, action) {
   case ACCOUNT_UNBLOCK_SUCCESS:
   case ACCOUNT_MUTE_SUCCESS:
   case ACCOUNT_UNMUTE_SUCCESS:
+  case ACCOUNT_PIN_SUCCESS:
+  case ACCOUNT_UNPIN_SUCCESS:
     return normalizeRelationship(state, action.relationship);
   case RELATIONSHIPS_FETCH_SUCCESS:
     return normalizeRelationships(state, action.relationships);
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 3a1ca1a7b..7d632776e 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -15,6 +15,7 @@ function main() {
   const React = require('react');
   const ReactDOM = require('react-dom');
   const Rellax = require('rellax');
+  const createHistory = require('history').createBrowserHistory;
 
   ready(() => {
     const locale = document.documentElement.lang;
@@ -70,6 +71,14 @@ function main() {
     }
 
     new Rellax('.parallax', { speed: -1 });
+
+    const history = createHistory();
+    const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status');
+    const location = history.location;
+    if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) {
+      detailedStatuses[0].scrollIntoView();
+      history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true });
+    }
   });
 }
 
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index b9544bb33..228dd96f0 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -15,6 +15,276 @@ $small-breakpoint: 960px;
   }
 }
 
+.rich-formatting {
+  font-family: 'mastodon-font-sans-serif', sans-serif;
+  font-size: 16px;
+  font-weight: 400;
+  font-size: 16px;
+  line-height: 30px;
+  color: $darker-text-color;
+  padding-right: 10px;
+
+  a {
+    color: $highlight-text-color;
+    text-decoration: underline;
+  }
+
+  p,
+  li {
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    font-weight: 400;
+    font-size: 16px;
+    line-height: 30px;
+    margin-bottom: 12px;
+    color: $darker-text-color;
+
+    a {
+      color: $highlight-text-color;
+      text-decoration: underline;
+    }
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  em {
+    display: inline;
+    margin: 0;
+    padding: 0;
+    font-weight: 700;
+    background: transparent;
+    font-family: inherit;
+    font-size: inherit;
+    line-height: inherit;
+    color: lighten($darker-text-color, 10%);
+  }
+
+  h1 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 26px;
+    line-height: 30px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $secondary-text-color;
+
+    small {
+      font-family: 'mastodon-font-sans-serif', sans-serif;
+      display: block;
+      font-size: 18px;
+      font-weight: 400;
+      color: lighten($darker-text-color, 10%);
+    }
+  }
+
+  h2 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 22px;
+    line-height: 26px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $secondary-text-color;
+  }
+
+  h3 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 18px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $secondary-text-color;
+  }
+
+  h4 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 16px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $secondary-text-color;
+  }
+
+  h5 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 14px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $secondary-text-color;
+  }
+
+  h6 {
+    font-family: 'mastodon-font-display', sans-serif;
+    font-size: 12px;
+    line-height: 24px;
+    font-weight: 500;
+    margin-bottom: 20px;
+    color: $secondary-text-color;
+  }
+
+  ul,
+  ol {
+    margin-left: 20px;
+
+    &[type='a'] {
+      list-style-type: lower-alpha;
+    }
+
+    &[type='i'] {
+      list-style-type: lower-roman;
+    }
+  }
+
+  ul {
+    list-style: disc;
+  }
+
+  ol {
+    list-style: decimal;
+  }
+
+  li > ol,
+  li > ul {
+    margin-top: 6px;
+  }
+
+  hr {
+    width: 100%;
+    height: 0;
+    border: 0;
+    border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
+    margin: 20px 0;
+
+    &.spacer {
+      height: 1px;
+      border: 0;
+    }
+  }
+}
+
+.information-board {
+  background: darken($ui-base-color, 4%);
+  padding: 20px 0;
+
+  .container-alt {
+    position: relative;
+    padding-right: 280px + 15px;
+  }
+
+  &__sections {
+    display: flex;
+    justify-content: space-between;
+    flex-wrap: wrap;
+  }
+
+  &__section {
+    flex: 1 0 0;
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 16px;
+    line-height: 28px;
+    color: $primary-text-color;
+    text-align: right;
+    padding: 10px 15px;
+
+    span,
+    strong {
+      display: block;
+    }
+
+    span {
+      &:last-child {
+        color: $secondary-text-color;
+      }
+    }
+
+    strong {
+      font-weight: 500;
+      font-size: 32px;
+      line-height: 48px;
+    }
+
+    @media screen and (max-width: $column-breakpoint) {
+      text-align: center;
+    }
+  }
+
+  .panel {
+    position: absolute;
+    width: 280px;
+    box-sizing: border-box;
+    background: darken($ui-base-color, 8%);
+    padding: 20px;
+    padding-top: 10px;
+    border-radius: 4px 4px 0 0;
+    right: 0;
+    bottom: -40px;
+
+    .panel-header {
+      font-family: 'mastodon-font-display', sans-serif;
+      font-size: 14px;
+      line-height: 24px;
+      font-weight: 500;
+      color: $darker-text-color;
+      padding-bottom: 5px;
+      margin-bottom: 15px;
+      border-bottom: 1px solid lighten($ui-base-color, 4%);
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      overflow: hidden;
+
+      a,
+      span {
+        font-weight: 400;
+        color: darken($darker-text-color, 10%);
+      }
+
+      a {
+        text-decoration: none;
+      }
+    }
+  }
+
+  .owner {
+    text-align: center;
+
+    .avatar {
+      width: 80px;
+      height: 80px;
+      margin: 0 auto;
+      margin-bottom: 15px;
+
+      img {
+        display: block;
+        width: 80px;
+        height: 80px;
+        border-radius: 48px;
+      }
+    }
+
+    .name {
+      font-size: 14px;
+
+      a {
+        display: block;
+        color: $primary-text-color;
+        text-decoration: none;
+
+        &:hover {
+          .display_name {
+            text-decoration: underline;
+          }
+        }
+      }
+
+      .username {
+        display: block;
+        color: $darker-text-color;
+      }
+    }
+  }
+}
+
 .landing-page {
   .grid {
     display: grid;
@@ -486,128 +756,6 @@ $small-breakpoint: 960px;
     }
   }
 
-  .information-board {
-    background: darken($ui-base-color, 4%);
-    padding: 20px 0;
-
-    .container-alt {
-      position: relative;
-      padding-right: 280px + 15px;
-    }
-
-    &__sections {
-      display: flex;
-      justify-content: space-between;
-      flex-wrap: wrap;
-    }
-
-    &__section {
-      flex: 1 0 0;
-      font-family: 'mastodon-font-sans-serif', sans-serif;
-      font-size: 16px;
-      line-height: 28px;
-      color: $primary-text-color;
-      text-align: right;
-      padding: 10px 15px;
-
-      span,
-      strong {
-        display: block;
-      }
-
-      span {
-        &:last-child {
-          color: $secondary-text-color;
-        }
-      }
-
-      strong {
-        font-weight: 500;
-        font-size: 32px;
-        line-height: 48px;
-      }
-
-      @media screen and (max-width: $column-breakpoint) {
-        text-align: center;
-      }
-    }
-
-    .panel {
-      position: absolute;
-      width: 280px;
-      box-sizing: border-box;
-      background: darken($ui-base-color, 8%);
-      padding: 20px;
-      padding-top: 10px;
-      border-radius: 4px 4px 0 0;
-      right: 0;
-      bottom: -40px;
-
-      .panel-header {
-        font-family: 'mastodon-font-display', sans-serif;
-        font-size: 14px;
-        line-height: 24px;
-        font-weight: 500;
-        color: $darker-text-color;
-        padding-bottom: 5px;
-        margin-bottom: 15px;
-        border-bottom: 1px solid lighten($ui-base-color, 4%);
-        text-overflow: ellipsis;
-        white-space: nowrap;
-        overflow: hidden;
-
-        a,
-        span {
-          font-weight: 400;
-          color: darken($darker-text-color, 10%);
-        }
-
-        a {
-          text-decoration: none;
-        }
-      }
-    }
-
-    .owner {
-      text-align: center;
-
-      .avatar {
-        width: 80px;
-        height: 80px;
-        margin: 0 auto;
-        margin-bottom: 15px;
-
-        img {
-          display: block;
-          width: 80px;
-          height: 80px;
-          border-radius: 48px;
-        }
-      }
-
-      .name {
-        font-size: 14px;
-
-        a {
-          display: block;
-          color: $primary-text-color;
-          text-decoration: none;
-
-          &:hover {
-            .display_name {
-              text-decoration: underline;
-            }
-          }
-        }
-
-        .username {
-          display: block;
-          color: $darker-text-color;
-        }
-      }
-    }
-  }
-
   &.alternative {
     padding: 10px 0;
 
@@ -642,8 +790,10 @@ $small-breakpoint: 960px;
     border-radius: 4px;
     padding: 25px 40px;
     overflow: hidden;
+    box-sizing: border-box;
 
     .row {
+      width: 100%;
       display: flex;
       flex-direction: row-reverse;
       flex-wrap: wrap;
@@ -660,11 +810,20 @@ $small-breakpoint: 960px;
         flex: 1 0 auto;
         padding: 0 10px;
       }
+
+      @media screen and (max-width: $no-gap-breakpoint) {
+        width: 100%;
+        justify-content: space-between;
+      }
     }
 
     .row__mascot {
       flex: 1;
       margin: 10px -50px 0 0;
+
+      @media screen and (max-width: $no-gap-breakpoint) {
+        display: none;
+      }
     }
   }
 
@@ -983,21 +1142,6 @@ $small-breakpoint: 960px;
     }
   }
 
-  .extended-description {
-    padding: 50px 0;
-    font-family: 'mastodon-font-sans-serif', sans-serif;
-    font-size: 16px;
-    font-weight: 400;
-    font-size: 16px;
-    line-height: 30px;
-    color: $darker-text-color;
-
-    a {
-      color: $highlight-text-color;
-      text-decoration: underline;
-    }
-  }
-
   .footer-links {
     padding-bottom: 50px;
     text-align: right;
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 7b339277f..8ecedd2cb 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -115,6 +115,83 @@
   }
 }
 
+.grid-3 {
+  display: grid;
+  grid-gap: 10px;
+  grid-template-columns: 3fr 1fr;
+  grid-auto-columns: 25%;
+  grid-auto-rows: max-content;
+
+  .column-0 {
+    grid-column: 1/3;
+    grid-row: 1;
+  }
+
+  .column-1 {
+    grid-column: 1;
+    grid-row: 2;
+  }
+
+  .column-2 {
+    grid-column: 2;
+    grid-row: 2;
+  }
+
+  .column-3 {
+    grid-column: 1/3;
+    grid-row: 3;
+  }
+
+  .landing-page__call-to-action {
+    min-height: 100%;
+  }
+
+  @media screen and (max-width: 738px) {
+    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
+
+    .landing-page__call-to-action {
+      padding: 20px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .row__information-board {
+      width: 100%;
+      justify-content: center;
+      align-items: center;
+    }
+
+    .row__mascot {
+      display: none;
+    }
+  }
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    grid-gap: 0;
+    grid-template-columns: minmax(0, 100%);
+
+    .column-0 {
+      grid-column: 1;
+    }
+
+    .column-1 {
+      grid-column: 1;
+      grid-row: 3;
+    }
+
+    .column-2 {
+      grid-column: 1;
+      grid-row: 2;
+    }
+
+    .column-3 {
+      grid-column: 1;
+      grid-row: 4;
+    }
+  }
+}
+
 .public-layout {
   @media screen and (max-width: $no-gap-breakpoint) {
     padding-top: 48px;
@@ -300,6 +377,19 @@
       }
     }
 
+    &--no-bar {
+      margin-bottom: 0;
+
+      .public-account-header__image,
+      .public-account-header__image img {
+        border-radius: 4px;
+
+        @media screen and (max-width: $no-gap-breakpoint) {
+          border-radius: 0;
+        }
+      }
+    }
+
     @media screen and (max-width: $no-gap-breakpoint) {
       margin-bottom: 0;
       box-shadow: none;
diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss
index d37a6f458..f843f0b42 100644
--- a/app/javascript/styles/mastodon/widgets.scss
+++ b/app/javascript/styles/mastodon/widgets.scss
@@ -71,6 +71,84 @@
   }
 }
 
+.endorsements-widget {
+  margin-bottom: 10px;
+  padding-bottom: 10px;
+
+  h4 {
+    padding: 10px;
+    text-transform: uppercase;
+    font-weight: 700;
+    font-size: 13px;
+    color: $darker-text-color;
+  }
+
+  .account {
+    padding: 10px 0;
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    .account__display-name {
+      display: flex;
+      align-items: center;
+    }
+
+    .account__avatar {
+      width: 44px;
+      height: 44px;
+      background-size: 44px 44px;
+    }
+  }
+}
+
+.box-widget {
+  padding: 20px;
+  border-radius: 4px;
+  background: $ui-base-color;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+}
+
+.contact-widget,
+.landing-page__information.contact-widget {
+  box-sizing: border-box;
+  padding: 20px;
+  min-height: 100%;
+  border-radius: 4px;
+  background: $ui-base-color;
+  box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+}
+
+.contact-widget {
+  font-size: 15px;
+  color: $darker-text-color;
+  line-height: 20px;
+  word-wrap: break-word;
+  font-weight: 400;
+
+  strong {
+    font-weight: 500;
+  }
+
+  p {
+    margin-bottom: 10px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  &__mail {
+    margin-top: 10px;
+
+    a {
+      color: $primary-text-color;
+      text-decoration: none;
+    }
+  }
+}
+
 .moved-account-widget {
   padding: 15px;
   padding-bottom: 20px;
@@ -152,7 +230,10 @@
 }
 
 .moved-account-widget,
-.memoriam-widget {
+.memoriam-widget,
+.box-widget,
+.contact-widget,
+.landing-page__information.contact-widget {
   @media screen and (max-width: $no-gap-breakpoint) {
     margin-bottom: 0;
     box-shadow: none;
diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 5c6ff4f9b..1a0a635b3 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -352,7 +352,7 @@ class OStatus::AtomSerializer
     append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(status)) if status.account.local?
 
     append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
-    append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
+    append_element(entry, 'content', Formatter.instance.format(status).to_str || '.', type: 'html', 'xml:lang': status.language)
 
     status.mentions.sort_by(&:id).each do |mentioned|
       append_element(entry, 'link', nil, rel: :mentioned, 'ostatus:object-type': OStatus::TagManager::TYPES[:person], href: OStatus::TagManager.instance.uri_for(mentioned.account))
diff --git a/app/models/account.rb b/app/models/account.rb
index 041eda6f4..9daf063a5 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -92,6 +92,10 @@ class Account < ApplicationRecord
   has_many :status_pins, inverse_of: :account, dependent: :destroy
   has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
 
+  # Endorsements
+  has_many :account_pins, inverse_of: :account, dependent: :destroy
+  has_many :endorsed_accounts, through: :account_pins, class_name: 'Account', source: :target_account
+
   # Media
   has_many :media_attachments, dependent: :destroy
 
diff --git a/app/models/account_pin.rb b/app/models/account_pin.rb
new file mode 100644
index 000000000..9a21c3405
--- /dev/null
+++ b/app/models/account_pin.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_pins
+#
+#  id                :bigint(8)        not null, primary key
+#  account_id        :bigint(8)
+#  target_account_id :bigint(8)
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class AccountPin < ApplicationRecord
+  include RelationshipCacheable
+
+  belongs_to :account
+  belongs_to :target_account, class_name: 'Account'
+
+  validate :validate_follow_relationship
+
+  private
+
+  def validate_follow_relationship
+    errors.add(:base, I18n.t('accounts.pin_errors.following')) unless account.following?(target_account)
+  end
+end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index cacee54e0..ff57a884b 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -40,6 +40,10 @@ module AccountInteractions
       end
     end
 
+    def endorsed_map(target_account_ids, account_id)
+      follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
+    end
+
     def domain_blocking_map(target_account_ids, account_id)
       accounts_map    = Account.where(id: target_account_ids).select('id, domain').map { |a| [a.id, a.domain] }.to_h
       blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
@@ -194,6 +198,10 @@ module AccountInteractions
     status_pins.where(status: status).exists?
   end
 
+  def endorsed?(account)
+    account_pins.where(target_account: account).exists?
+  end
+
   def followers_for_local_distribution
     followers.local
              .joins(:user)
diff --git a/app/models/follow.rb b/app/models/follow.rb
index eaf8445f3..3fce14b9a 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -33,10 +33,15 @@ class Follow < ApplicationRecord
   end
 
   before_validation :set_uri, only: :create
+  after_destroy :remove_endorsements
 
   private
 
   def set_uri
     self.uri = ActivityPub::TagManager.instance.generate_uri_for(self) if uri.nil?
   end
+
+  def remove_endorsements
+    AccountPin.where(target_account_id: target_account_id, account_id: account_id).delete_all
+  end
 end
diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb
index b1e99b31b..e4aaa65f6 100644
--- a/app/presenters/account_relationships_presenter.rb
+++ b/app/presenters/account_relationships_presenter.rb
@@ -2,7 +2,8 @@
 
 class AccountRelationshipsPresenter
   attr_reader :following, :followed_by, :blocking,
-              :muting, :requested, :domain_blocking
+              :muting, :requested, :domain_blocking,
+              :endorsed
 
   def initialize(account_ids, current_account_id, **options)
     @account_ids        = account_ids.map { |a| a.is_a?(Account) ? a.id : a }
@@ -14,6 +15,7 @@ class AccountRelationshipsPresenter
     @muting          = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id))
     @requested       = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
     @domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
+    @endorsed        = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
 
     cache_uncached!
 
@@ -23,6 +25,7 @@ class AccountRelationshipsPresenter
     @muting.merge!(options[:muting_map] || {})
     @requested.merge!(options[:requested_map] || {})
     @domain_blocking.merge!(options[:domain_blocking_map] || {})
+    @endorsed.merge!(options[:endorsed_map] || {})
   end
 
   private
@@ -37,6 +40,7 @@ class AccountRelationshipsPresenter
       muting: {},
       requested: {},
       domain_blocking: {},
+      endorsed: {},
     }
 
     @uncached_account_ids = []
@@ -63,6 +67,7 @@ class AccountRelationshipsPresenter
         muting:          { account_id => muting[account_id] },
         requested:       { account_id => requested[account_id] },
         domain_blocking: { account_id => domain_blocking[account_id] },
+        endorsed:        { account_id => endorsed[account_id] },
       }
 
       Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day)
diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb
index 45bfd4d6e..c6c722a54 100644
--- a/app/serializers/rest/relationship_serializer.rb
+++ b/app/serializers/rest/relationship_serializer.rb
@@ -2,7 +2,8 @@
 
 class REST::RelationshipSerializer < ActiveModel::Serializer
   attributes :id, :following, :showing_reblogs, :followed_by, :blocking,
-             :muting, :muting_notifications, :requested, :domain_blocking
+             :muting, :muting_notifications, :requested, :domain_blocking,
+             :endorsed
 
   def id
     object.id.to_s
@@ -41,4 +42,8 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
   def domain_blocking
     instance_options[:relationships].domain_blocking[object.id] || false
   end
+
+  def endorsed
+    instance_options[:relationships].endorsed[object.id] || false
+  end
 end
diff --git a/app/views/about/_administration.html.haml b/app/views/about/_administration.html.haml
deleted file mode 100644
index 02286d68b..000000000
--- a/app/views/about/_administration.html.haml
+++ /dev/null
@@ -1,19 +0,0 @@
-.account
-  .account__wrapper
-    - if @instance_presenter.contact_account
-      = link_to TagManager.instance.url_for(@instance_presenter.contact_account), class: 'account__display-name' do
-        .account__avatar-wrapper
-          .account__avatar{ style: "background-image: url(#{@instance_presenter.contact_account.avatar.url})" }
-        %span.display-name
-          %bdi
-            %strong.display-name__html.emojify= display_name(@instance_presenter.contact_account, custom_emojify: true)
-          %span.display-name__account @#{@instance_presenter.contact_account.acct}
-    - else
-      .account__display-name
-        .account__avatar-wrapper
-          .account__avatar{ style: "background-image: url(#{full_asset_url('avatars/original/missing.png', skip_pipeline: true)})" }
-        %span.display-name
-          %strong= t 'about.contact_missing'
-          %span.display-name__account= t 'about.contact_unavailable'
-
-    = link_to t('about.learn_more'), about_more_path, class: 'button button-alternative'
diff --git a/app/views/about/_contact.html.haml b/app/views/about/_contact.html.haml
deleted file mode 100644
index 3215d50b5..000000000
--- a/app/views/about/_contact.html.haml
+++ /dev/null
@@ -1,22 +0,0 @@
-.panel
-  .panel-header
-    = succeed ':' do
-      = t 'about.contact'
-    - if contact.site_contact_email.present?
-      = mail_to contact.site_contact_email, nil, title: contact.site_contact_email
-    - else
-      %span= t 'about.contact_unavailable'
-  .panel-body
-    - if contact.contact_account
-      .owner
-        .avatar= image_tag contact.contact_account.avatar.url
-        .name
-          = link_to TagManager.instance.url_for(contact.contact_account) do
-            %span.display_name.emojify= display_name(contact.contact_account, custom_emojify: true)
-            %span.username @#{contact.contact_account.acct}
-    - else
-      .owner
-        .avatar= image_tag full_asset_url('avatars/original/missing.png', skip_pipeline: true)
-        .name
-          %span.display_name= t 'about.contact_missing'
-          %span.username= t 'about.contact_unavailable'
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index f86051fbf..bafe618cc 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -4,43 +4,43 @@
 - content_for :header_tags do
   = render partial: 'shared/og'
 
-.landing-page
-  .header-wrapper.compact
-    .header
-      = render 'links'
+.grid-3
+  .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('preview.jpg'), alt: @instance_presenter.site_title, class: 'parallax'
 
-      .container-alt.hero
-        .heading
-          %h3= t('about.description_headline', domain: site_hostname)
-          %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
+  .column-1
+    .landing-page__call-to-action{ dir: 'ltr' }
+      .row
+        .row__information-board
+          .information-board__section
+            %span= t 'about.user_count_before'
+            %strong= number_with_delimiter @instance_presenter.user_count
+            %span= t 'about.user_count_after'
+          .information-board__section
+            %span= t 'about.status_count_before'
+            %strong= number_with_delimiter @instance_presenter.status_count
+            %span= t 'about.status_count_after'
+        .row__mascot
+          .landing-page__mascot
+            = image_tag asset_pack_path('elephant_ui_plane.svg')
 
-  .information-board
-    .container-alt
-      .information-board__sections
-        .information-board__section
-          %span= t 'about.user_count_before'
-          %strong= number_with_delimiter @instance_presenter.user_count
-          %span= t 'about.user_count_after'
-        .information-board__section
-          %span= t 'about.status_count_before'
-          %strong= number_with_delimiter @instance_presenter.status_count
-          %span= t 'about.status_count_after'
-        .information-board__section
-          %span= t 'about.domain_count_before'
-          %strong= number_with_delimiter @instance_presenter.domain_count
-          %span= t 'about.domain_count_after'
-      = render 'contact', contact: @instance_presenter
+  .column-2
+    .landing-page__information.contact-widget
+      %p
+        %strong= t 'about.administered_by'
 
-  .extended-description
-    .container-alt
-      = @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html')
+      = account_link_to(@instance_presenter.contact_account)
 
-  .footer-links
-    .container-alt
-      %p
-        = link_to t('about.source_code'), @instance_presenter.source_url
-        - if @instance_presenter.commit_hash == ""
-          %strong= " (#{@instance_presenter.version_number})"
-        - else
-          %strong= "#{@instance_presenter.version_number}, "
-          %strong= "#{@instance_presenter.commit_hash}"
+      - if @instance_presenter.site_contact_email.present?
+        %p.contact-widget__mail
+          %strong
+            = succeed ':' do
+              = t 'about.contact'
+          %br/
+          = mail_to @instance_presenter.site_contact_email, nil, title: @instance_presenter.site_contact_email
+
+  .column-3
+    .box-widget
+      .rich-formatting= @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html')
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index e6d4cd10e..9d40eceeb 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -109,7 +109,7 @@
                 %p= t 'about.about_mastodon_html'
               %div.contact
                 %h3= t 'about.administered_by'
-                = render 'administration'
+                = account_link_to(@instance_presenter.contact_account, link_to(t('about.learn_more'), about_more_path, class: 'button button-alternative'))
 
             = render 'features'
 
@@ -130,7 +130,7 @@
                 %p= t 'about.about_mastodon_html'
               %div.contact
                 %h3= t 'about.administered_by'
-                = render 'administration'
+                = account_link_to(@instance_presenter.contact_account, link_to(t('about.learn_more'), about_more_path, class: 'button button-alternative'))
 
             = render 'features'
 
diff --git a/app/views/about/terms.html.haml b/app/views/about/terms.html.haml
index c7d36ed47..9d076a91b 100644
--- a/app/views/about/terms.html.haml
+++ b/app/views/about/terms.html.haml
@@ -1,11 +1,9 @@
 - content_for :page_title do
   = t('terms.title', instance: site_hostname)
 
-.landing-page
-  .header-wrapper.compact
-    .header
-      = render 'links'
-
-  .extended-description
-    .container-alt
-      = @instance_presenter.site_terms.html_safe.presence || t('terms.body_html')
+.grid
+  .column-0
+    .box-widget
+      .rich-formatting= @instance_presenter.site_terms.html_safe.presence || t('terms.body_html')
+  .column-1
+    = render 'application/sidebar'
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index e343be820..d3b9893c4 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -7,7 +7,7 @@
     .public-account-header__tabs
       .public-account-header__tabs__name
         %h1
-          = display_name(account)
+          = display_name(account, custom_emojify: true)
           %small
             = acct(account)
             = fa_icon('lock') if account.locked?
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index b30755d94..e398fc29b 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -55,4 +55,12 @@
       = render 'moved', account: @account
 
     = render 'bio', account: @account
+
+    - unless @endorsed_accounts.empty?
+      .endorsements-widget
+        %h4= t 'accounts.choices_html', name: content_tag(:bdi, display_name(@account, custom_emojify: true))
+
+        - @endorsed_accounts.each do |account|
+          = account_link_to account
+
     = render 'application/sidebar'