about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-08-25 01:41:18 +0200
committerGitHub <noreply@github.com>2017-08-25 01:41:18 +0200
commit9caa90025fd9f1ef46a74f31cefd19335e291e76 (patch)
tree51d91a2c713bcddc9d21cd63836db3ae4bc3d226
parentc5157ef07bbae5c3a307d6a005aef0f1c0452af3 (diff)
Pinned statuses (#4675)
* Pinned statuses

* yarn manage:translations
-rw-r--r--app/controllers/accounts_controller.rb25
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb5
-rw-r--r--app/controllers/api/v1/statuses/pins_controller.rb28
-rw-r--r--app/javascript/mastodon/actions/interactions.js78
-rw-r--r--app/javascript/mastodon/components/status.js1
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js11
-rw-r--r--app/javascript/mastodon/containers/status_container.js10
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js11
-rw-r--r--app/javascript/mastodon/features/status/index.js11
-rw-r--r--app/javascript/mastodon/locales/ar.json2
-rw-r--r--app/javascript/mastodon/locales/bg.json2
-rw-r--r--app/javascript/mastodon/locales/ca.json2
-rw-r--r--app/javascript/mastodon/locales/de.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json16
-rw-r--r--app/javascript/mastodon/locales/en.json2
-rw-r--r--app/javascript/mastodon/locales/eo.json2
-rw-r--r--app/javascript/mastodon/locales/es.json2
-rw-r--r--app/javascript/mastodon/locales/fa.json2
-rw-r--r--app/javascript/mastodon/locales/fi.json2
-rw-r--r--app/javascript/mastodon/locales/fr.json2
-rw-r--r--app/javascript/mastodon/locales/he.json2
-rw-r--r--app/javascript/mastodon/locales/hr.json2
-rw-r--r--app/javascript/mastodon/locales/hu.json2
-rw-r--r--app/javascript/mastodon/locales/id.json2
-rw-r--r--app/javascript/mastodon/locales/io.json2
-rw-r--r--app/javascript/mastodon/locales/it.json2
-rw-r--r--app/javascript/mastodon/locales/ja.json2
-rw-r--r--app/javascript/mastodon/locales/ko.json2
-rw-r--r--app/javascript/mastodon/locales/nl.json2
-rw-r--r--app/javascript/mastodon/locales/no.json2
-rw-r--r--app/javascript/mastodon/locales/oc.json2
-rw-r--r--app/javascript/mastodon/locales/pl.json2
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json2
-rw-r--r--app/javascript/mastodon/locales/pt.json2
-rw-r--r--app/javascript/mastodon/locales/ru.json2
-rw-r--r--app/javascript/mastodon/locales/th.json2
-rw-r--r--app/javascript/mastodon/locales/tr.json2
-rw-r--r--app/javascript/mastodon/locales/uk.json2
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json2
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json2
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json2
-rw-r--r--app/javascript/mastodon/reducers/statuses.js4
-rw-r--r--app/models/account.rb4
-rw-r--r--app/models/concerns/account_interactions.rb4
-rw-r--r--app/models/status.rb4
-rw-r--r--app/models/status_pin.rb16
-rw-r--r--app/presenters/status_relationships_presenter.rb19
-rw-r--r--app/serializers/rest/status_serializer.rb16
-rw-r--r--app/validators/status_pin_validator.rb9
-rw-r--r--app/views/accounts/show.html.haml3
-rw-r--r--app/views/stream_entries/_status.html.haml7
-rw-r--r--config/locales/en.yml5
-rw-r--r--config/routes.rb13
-rw-r--r--db/migrate/20170823162448_create_status_pins.rb10
-rw-r--r--db/schema.rb12
-rw-r--r--spec/controllers/api/v1/accounts/statuses_controller_spec.rb36
-rw-r--r--spec/controllers/api/v1/statuses/pins_controller_spec.rb57
-rw-r--r--spec/fabricators/status_pin_fabricator.rb4
-rw-r--r--spec/models/status_pin_spec.rb41
59 files changed, 493 insertions, 29 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index c6b98628e..f4ca239ba 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -7,14 +7,17 @@ class AccountsController < ApplicationController
   def show
     respond_to do |format|
       format.html do
+        @pinned_statuses = []
+
         if current_account && @account.blocking?(current_account)
           @statuses = []
           return
         end
 
-        @statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id])
-        @statuses = cache_collection(@statuses, Status)
-        @next_url = next_url unless @statuses.empty?
+        @pinned_statuses = cache_collection(@account.pinned_statuses.limit(1), Status) unless media_requested?
+        @statuses        = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id])
+        @statuses        = cache_collection(@statuses, Status)
+        @next_url        = next_url unless @statuses.empty?
       end
 
       format.atom do
@@ -32,8 +35,8 @@ class AccountsController < ApplicationController
 
   def filtered_statuses
     default_statuses.tap do |statuses|
-      statuses.merge!(only_media_scope) if request.path.ends_with?('/media')
-      statuses.merge!(no_replies_scope) unless request.path.ends_with?('/with_replies')
+      statuses.merge!(only_media_scope) if media_requested?
+      statuses.merge!(no_replies_scope) unless replies_requested?
     end
   end
 
@@ -58,12 +61,20 @@ class AccountsController < ApplicationController
   end
 
   def next_url
-    if request.path.ends_with?('/media')
+    if media_requested?
       short_account_media_url(@account, max_id: @statuses.last.id)
-    elsif request.path.ends_with?('/with_replies')
+    elsif replies_requested?
       short_account_with_replies_url(@account, max_id: @statuses.last.id)
     else
       short_account_url(@account, max_id: @statuses.last.id)
     end
   end
+
+  def media_requested?
+    request.path.ends_with?('/media')
+  end
+
+  def replies_requested?
+    request.path.ends_with?('/with_replies')
+  end
 end
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index d9ae5c089..095f6937b 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -29,6 +29,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   def account_statuses
     default_statuses.tap do |statuses|
       statuses.merge!(only_media_scope) if params[:only_media]
+      statuses.merge!(pinned_scope) if params[:pinned]
       statuses.merge!(no_replies_scope) if params[:exclude_replies]
     end
   end
@@ -53,6 +54,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
     @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
   end
 
+  def pinned_scope
+    @account.pinned_statuses
+  end
+
   def no_replies_scope
     Status.without_replies
   end
diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb
new file mode 100644
index 000000000..3de1009b8
--- /dev/null
+++ b/app/controllers/api/v1/statuses/pins_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class Api::V1::Statuses::PinsController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :write }
+  before_action :require_user!
+  before_action :set_status
+
+  respond_to :json
+
+  def create
+    StatusPin.create!(account: current_account, status: @status)
+    render json: @status, serializer: REST::StatusSerializer
+  end
+
+  def destroy
+    pin = StatusPin.find_by(account: current_account, status: @status)
+    pin&.destroy!
+    render json: @status, serializer: REST::StatusSerializer
+  end
+
+  private
+
+  def set_status
+    @status = Status.find(params[:status_id])
+  end
+end
diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js
index 36eec4934..7b5f4bd9c 100644
--- a/app/javascript/mastodon/actions/interactions.js
+++ b/app/javascript/mastodon/actions/interactions.js
@@ -24,6 +24,14 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
 export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
 export const FAVOURITES_FETCH_FAIL    = 'FAVOURITES_FETCH_FAIL';
 
+export const PIN_REQUEST = 'PIN_REQUEST';
+export const PIN_SUCCESS = 'PIN_SUCCESS';
+export const PIN_FAIL    = 'PIN_FAIL';
+
+export const UNPIN_REQUEST = 'UNPIN_REQUEST';
+export const UNPIN_SUCCESS = 'UNPIN_SUCCESS';
+export const UNPIN_FAIL    = 'UNPIN_FAIL';
+
 export function reblog(status) {
   return function (dispatch, getState) {
     dispatch(reblogRequest(status));
@@ -233,3 +241,73 @@ export function fetchFavouritesFail(id, error) {
     error,
   };
 };
+
+export function pin(status) {
+  return (dispatch, getState) => {
+    dispatch(pinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => {
+      dispatch(pinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(pinFail(status, error));
+    });
+  };
+};
+
+export function pinRequest(status) {
+  return {
+    type: PIN_REQUEST,
+    status,
+  };
+};
+
+export function pinSuccess(status, response) {
+  return {
+    type: PIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function pinFail(status, error) {
+  return {
+    type: PIN_FAIL,
+    status,
+    error,
+  };
+};
+
+export function unpin (status) {
+  return (dispatch, getState) => {
+    dispatch(unpinRequest(status));
+
+    api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => {
+      dispatch(unpinSuccess(status, response.data));
+    }).catch(error => {
+      dispatch(unpinFail(status, error));
+    });
+  };
+};
+
+export function unpinRequest(status) {
+  return {
+    type: UNPIN_REQUEST,
+    status,
+  };
+};
+
+export function unpinSuccess(status, response) {
+  return {
+    type: UNPIN_SUCCESS,
+    status,
+    response,
+  };
+};
+
+export function unpinFail(status, error) {
+  return {
+    type: UNPIN_FAIL,
+    status,
+    error,
+  };
+};
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 38a4aafc1..b4f523f72 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -31,6 +31,7 @@ export default class Status extends ImmutablePureComponent {
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onPin: PropTypes.func,
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
     onBlock: PropTypes.func,
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 0d8c9add4..6436d6ebe 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -21,6 +21,8 @@ const messages = defineMessages({
   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
   muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
   unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
 });
 
 @injectIntl
@@ -41,6 +43,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onBlock: PropTypes.func,
     onReport: PropTypes.func,
     onMuteConversation: PropTypes.func,
+    onPin: PropTypes.func,
     me: PropTypes.number,
     withDismiss: PropTypes.bool,
     intl: PropTypes.object.isRequired,
@@ -77,6 +80,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.props.onDelete(this.props.status);
   }
 
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
   handleMentionClick = () => {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
@@ -121,6 +128,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     }
 
     if (status.getIn(['account', 'id']) === me) {
+      if (['public', 'unlisted'].indexOf(status.get('visibility')) !== -1) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index b150165aa..c488b6ce7 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -11,6 +11,8 @@ import {
   favourite,
   unreblog,
   unfavourite,
+  pin,
+  unpin,
 } from '../actions/interactions';
 import {
   blockAccount,
@@ -72,6 +74,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
   onDelete (status) {
     if (!this.deleteModal) {
       dispatch(deleteStatus(status.get('id')));
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 91ac64de2..c4a614677 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -14,6 +14,8 @@ const messages = defineMessages({
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
   report: { id: 'status.report', defaultMessage: 'Report @{name}' },
   share: { id: 'status.share', defaultMessage: 'Share' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
 });
 
 @injectIntl
@@ -31,6 +33,7 @@ export default class ActionBar extends React.PureComponent {
     onDelete: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onReport: PropTypes.func,
+    onPin: PropTypes.func,
     me: PropTypes.number.isRequired,
     intl: PropTypes.object.isRequired,
   };
@@ -59,6 +62,10 @@ export default class ActionBar extends React.PureComponent {
     this.props.onReport(this.props.status);
   }
 
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
   handleShare = () => {
     navigator.share({
       text: this.props.status.get('search_index'),
@@ -72,6 +79,10 @@ export default class ActionBar extends React.PureComponent {
     let menu = [];
 
     if (me === status.getIn(['account', 'id'])) {
+      if (['public', 'unlisted'].indexOf(status.get('visibility')) !== -1) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index cbabdd5bc..84e717a12 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -12,6 +12,8 @@ import {
   unfavourite,
   reblog,
   unreblog,
+  pin,
+  unpin,
 } from '../../actions/interactions';
 import {
   replyCompose,
@@ -87,6 +89,14 @@ export default class Status extends ImmutablePureComponent {
     }
   }
 
+  handlePin = (status) => {
+    if (status.get('pinned')) {
+      this.props.dispatch(unpin(status));
+    } else {
+      this.props.dispatch(pin(status));
+    }
+  }
+
   handleReplyClick = (status) => {
     this.props.dispatch(replyCompose(status, this.context.router.history));
   }
@@ -187,6 +197,7 @@ export default class Status extends ImmutablePureComponent {
               onDelete={this.handleDeleteClick}
               onMention={this.handleMentionClick}
               onReport={this.handleReport}
+              onPin={this.handlePin}
             />
 
             {descendants}
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index f5cf77f92..fa8cda97d 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -168,6 +168,7 @@
   "status.mention": "أذكُر @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "وسع هذه المشاركة",
+  "status.pin": "Pin on profile",
   "status.reblog": "رَقِّي",
   "status.reblogged_by": "{name} رقى",
   "status.reply": "ردّ",
@@ -179,6 +180,7 @@
   "status.show_less": "إعرض أقلّ",
   "status.show_more": "أظهر المزيد",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "تحرير",
   "tabs_bar.federated_timeline": "الموحَّد",
   "tabs_bar.home": "الرئيسية",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index e6788f9eb..4aa097d31 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -168,6 +168,7 @@
   "status.mention": "Споменаване",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Споделяне",
   "status.reblogged_by": "{name} сподели",
   "status.reply": "Отговор",
@@ -179,6 +180,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Съставяне",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Начало",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 95b3c60bf..d9cb7c7a3 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -168,6 +168,7 @@
   "status.mention": "Esmentar @{name}",
   "status.mute_conversation": "Silenciar conversació",
   "status.open": "Ampliar aquest estat",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} ha retootejat",
   "status.reply": "Respondre",
@@ -179,6 +180,7 @@
   "status.show_less": "Mostra menys",
   "status.show_more": "Mostra més",
   "status.unmute_conversation": "Activar conversació",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compondre",
   "tabs_bar.federated_timeline": "Federada",
   "tabs_bar.home": "Inici",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 67a99b765..a5232552f 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -168,6 +168,7 @@
   "status.mention": "Erwähnen",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Öffnen",
+  "status.pin": "Pin on profile",
   "status.reblog": "Teilen",
   "status.reblogged_by": "{name} teilte",
   "status.reply": "Antworten",
@@ -179,6 +180,7 @@
   "status.show_less": "Weniger anzeigen",
   "status.show_more": "Mehr anzeigen",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Schreiben",
   "tabs_bar.federated_timeline": "Föderation",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index ef76f6e5b..fdb8aefe1 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -189,6 +189,14 @@
       {
         "defaultMessage": "Unmute conversation",
         "id": "status.unmute_conversation"
+      },
+      {
+        "defaultMessage": "Pin on profile",
+        "id": "status.pin"
+      },
+      {
+        "defaultMessage": "Unpin from profile",
+        "id": "status.unpin"
       }
     ],
     "path": "app/javascript/mastodon/components/status_action_bar.json"
@@ -1035,6 +1043,14 @@
       {
         "defaultMessage": "Share",
         "id": "status.share"
+      },
+      {
+        "defaultMessage": "Pin on profile",
+        "id": "status.pin"
+      },
+      {
+        "defaultMessage": "Unpin from profile",
+        "id": "status.unpin"
       }
     ],
     "path": "app/javascript/mastodon/features/status/components/action_bar.json"
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 2ea2062d3..595063888 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -168,6 +168,7 @@
   "status.mention": "Mention @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boosted",
   "status.reply": "Reply",
@@ -179,6 +180,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compose",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 960d747ec..ed323f406 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -168,6 +168,7 @@
   "status.mention": "Mencii @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Diskonigi",
   "status.reblogged_by": "{name} diskonigita",
   "status.reply": "Respondi",
@@ -179,6 +180,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Ekskribi",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Hejmo",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 212d16639..2fee29148 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -168,6 +168,7 @@
   "status.mention": "Mencionar",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir estado",
+  "status.pin": "Pin on profile",
   "status.reblog": "Retoot",
   "status.reblogged_by": "Retooteado por {name}",
   "status.reply": "Responder",
@@ -179,6 +180,7 @@
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar más",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Redactar",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Inicio",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 5ada62f93..89fa014e4 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -168,6 +168,7 @@
   "status.mention": "نام‌بردن از @{name}",
   "status.mute_conversation": "بی‌صداکردن گفتگو",
   "status.open": "این نوشته را باز کن",
+  "status.pin": "Pin on profile",
   "status.reblog": "بازبوقیدن",
   "status.reblogged_by": "‫{name}‬ بازبوقید",
   "status.reply": "پاسخ",
@@ -179,6 +180,7 @@
   "status.show_less": "نهفتن",
   "status.show_more": "نمایش",
   "status.unmute_conversation": "باصداکردن گفتگو",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "بنویسید",
   "tabs_bar.federated_timeline": "همگانی",
   "tabs_bar.home": "خانه",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index cb9e9c2a6..1c1334899 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -168,6 +168,7 @@
   "status.mention": "Mainitse @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Buustaa",
   "status.reblogged_by": "{name} buustasi",
   "status.reply": "Vastaa",
@@ -179,6 +180,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Luo",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Koti",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 34a89a69f..479b8de7d 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -168,6 +168,7 @@
   "status.mention": "Mentionner",
   "status.mute_conversation": "Masquer la conversation",
   "status.open": "Déplier ce statut",
+  "status.pin": "Pin on profile",
   "status.reblog": "Partager",
   "status.reblogged_by": "{name} a partagé :",
   "status.reply": "Répondre",
@@ -179,6 +180,7 @@
   "status.show_less": "Replier",
   "status.show_more": "Déplier",
   "status.unmute_conversation": "Ne plus masquer la conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Composer",
   "tabs_bar.federated_timeline": "Fil public global",
   "tabs_bar.home": "Accueil",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 34266d8e1..1e221af9c 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -168,6 +168,7 @@
   "status.mention": "פניה אל @{name}",
   "status.mute_conversation": "השתקת שיחה",
   "status.open": "הרחבת הודעה",
+  "status.pin": "Pin on profile",
   "status.reblog": "הדהוד",
   "status.reblogged_by": "הודהד על ידי {name}",
   "status.reply": "תגובה",
@@ -179,6 +180,7 @@
   "status.show_less": "הראה פחות",
   "status.show_more": "הראה יותר",
   "status.unmute_conversation": "הסרת השתקת שיחה",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "חיבור",
   "tabs_bar.federated_timeline": "ציר זמן בין-קהילתי",
   "tabs_bar.home": "בבית",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index f69b096d4..2effecb1e 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -168,6 +168,7 @@
   "status.mention": "Spomeni @{name}",
   "status.mute_conversation": "Utišaj razgovor",
   "status.open": "Proširi ovaj status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Podigni",
   "status.reblogged_by": "{name} je podigao",
   "status.reply": "Odgovori",
@@ -179,6 +180,7 @@
   "status.show_less": "Pokaži manje",
   "status.show_more": "Pokaži više",
   "status.unmute_conversation": "Poništi utišavanje razgovora",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Sastavi",
   "tabs_bar.federated_timeline": "Federalni",
   "tabs_bar.home": "Dom",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 4d2a50963..59a7b8deb 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -168,6 +168,7 @@
   "status.mention": "Említés",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Reblog",
   "status.reblogged_by": "{name} reblogolta",
   "status.reply": "Válasz",
@@ -179,6 +180,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Összeállítás",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Kezdőlap",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 532739e3c..9dd66b6cd 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -168,6 +168,7 @@
   "status.mention": "Balasan @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Tampilkan status ini",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "di-boost {name}",
   "status.reply": "Balas",
@@ -179,6 +180,7 @@
   "status.show_less": "Tampilkan lebih sedikit",
   "status.show_more": "Tampilkan semua",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Tulis",
   "tabs_bar.federated_timeline": "Gabungan",
   "tabs_bar.home": "Beranda",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index a5e363e40..07184ae81 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -168,6 +168,7 @@
   "status.mention": "Mencionar @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Detaligar ca mesajo",
+  "status.pin": "Pin on profile",
   "status.reblog": "Repetar",
   "status.reblogged_by": "{name} repetita",
   "status.reply": "Respondar",
@@ -179,6 +180,7 @@
   "status.show_less": "Montrar mine",
   "status.show_more": "Montrar plue",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Kompozar",
   "tabs_bar.federated_timeline": "Federata",
   "tabs_bar.home": "Hemo",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 329eb82ca..369ae7f32 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -168,6 +168,7 @@
   "status.mention": "Nomina @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Espandi questo post",
+  "status.pin": "Pin on profile",
   "status.reblog": "Condividi",
   "status.reblogged_by": "{name} ha condiviso",
   "status.reply": "Rispondi",
@@ -179,6 +180,7 @@
   "status.show_less": "Mostra meno",
   "status.show_more": "Mostra di più",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Scrivi",
   "tabs_bar.federated_timeline": "Federazione",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 757190c90..c35b0def3 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -168,6 +168,7 @@
   "status.mention": "返信",
   "status.mute_conversation": "会話をミュート",
   "status.open": "詳細を表示",
+  "status.pin": "Pin on profile",
   "status.reblog": "ブースト",
   "status.reblogged_by": "{name}さんにブーストされました",
   "status.reply": "返信",
@@ -179,6 +180,7 @@
   "status.show_less": "隠す",
   "status.show_more": "もっと見る",
   "status.unmute_conversation": "会話のミュートを解除",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "投稿",
   "tabs_bar.federated_timeline": "連合",
   "tabs_bar.home": "ホーム",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 47d0d4087..52ba1e70f 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -168,6 +168,7 @@
   "status.mention": "답장",
   "status.mute_conversation": "이 대화를 뮤트",
   "status.open": "상세 정보 표시",
+  "status.pin": "Pin on profile",
   "status.reblog": "부스트",
   "status.reblogged_by": "{name}님이 부스트 했습니다",
   "status.reply": "답장",
@@ -179,6 +180,7 @@
   "status.show_less": "숨기기",
   "status.show_more": "더 보기",
   "status.unmute_conversation": "이 대화의 뮤트 해제하기",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "포스트",
   "tabs_bar.federated_timeline": "연합",
   "tabs_bar.home": "홈",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 4d68c7992..fb4127831 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -168,6 +168,7 @@
   "status.mention": "Vermeld @{name}",
   "status.mute_conversation": "Negeer conversatie",
   "status.open": "Toot volledig tonen",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boostte",
   "status.reply": "Reageren",
@@ -179,6 +180,7 @@
   "status.show_less": "Minder tonen",
   "status.show_more": "Meer tonen",
   "status.unmute_conversation": "Conversatie niet meer negeren",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Schrijven",
   "tabs_bar.federated_timeline": "Globaal",
   "tabs_bar.home": "Start",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 9453e65ff..2d6224c48 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -168,6 +168,7 @@
   "status.mention": "Nevn @{name}",
   "status.mute_conversation": "Demp samtale",
   "status.open": "Utvid denne statusen",
+  "status.pin": "Pin on profile",
   "status.reblog": "Fremhev",
   "status.reblogged_by": "Fremhevd av {name}",
   "status.reply": "Svar",
@@ -179,6 +180,7 @@
   "status.show_less": "Vis mindre",
   "status.show_more": "Vis mer",
   "status.unmute_conversation": "Ikke demp samtale",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Komponer",
   "tabs_bar.federated_timeline": "Felles",
   "tabs_bar.home": "Hjem",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 5e5e28af0..34e1a8c47 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -168,6 +168,7 @@
   "status.mention": "Mencionar",
   "status.mute_conversation": "Rescondre la conversacion",
   "status.open": "Desplegar aqueste estatut",
+  "status.pin": "Pin on profile",
   "status.reblog": "Partejar",
   "status.reblogged_by": "{name} a partejat :",
   "status.reply": "Respondre",
@@ -179,6 +180,7 @@
   "status.show_less": "Tornar plegar",
   "status.show_more": "Desplegar",
   "status.unmute_conversation": "Conversacions amb silenci levat",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compausar",
   "tabs_bar.federated_timeline": "Flux public global",
   "tabs_bar.home": "Acuèlh",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index af38bbb6c..8a8d0f38a 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -168,6 +168,7 @@
   "status.mention": "Wspomnij o @{name}",
   "status.mute_conversation": "Wycisz konwersację",
   "status.open": "Rozszerz ten status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Podbij",
   "status.reblogged_by": "{name} podbił",
   "status.reply": "Odpowiedz",
@@ -179,6 +180,7 @@
   "status.show_less": "Pokaż mniej",
   "status.show_more": "Pokaż więcej",
   "status.unmute_conversation": "Cofnij wyciszenie konwersacji",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Napisz",
   "tabs_bar.federated_timeline": "Globalne",
   "tabs_bar.home": "Strona główna",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 55d2f05de..8a299e272 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -168,6 +168,7 @@
   "status.mention": "Mencionar @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir",
+  "status.pin": "Pin on profile",
   "status.reblog": "Partilhar",
   "status.reblogged_by": "{name} partilhou",
   "status.reply": "Responder",
@@ -179,6 +180,7 @@
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Criar",
   "tabs_bar.federated_timeline": "Global",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 55d2f05de..8a299e272 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -168,6 +168,7 @@
   "status.mention": "Mencionar @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expandir",
+  "status.pin": "Pin on profile",
   "status.reblog": "Partilhar",
   "status.reblogged_by": "{name} partilhou",
   "status.reply": "Responder",
@@ -179,6 +180,7 @@
   "status.show_less": "Mostrar menos",
   "status.show_more": "Mostrar mais",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Criar",
   "tabs_bar.federated_timeline": "Global",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index af38fc723..822f116c7 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -168,6 +168,7 @@
   "status.mention": "Упомянуть @{name}",
   "status.mute_conversation": "Заглушить тред",
   "status.open": "Развернуть статус",
+  "status.pin": "Pin on profile",
   "status.reblog": "Продвинуть",
   "status.reblogged_by": "{name} продвинул(а)",
   "status.reply": "Ответить",
@@ -179,6 +180,7 @@
   "status.show_less": "Свернуть",
   "status.show_more": "Развернуть",
   "status.unmute_conversation": "Снять глушение с треда",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Написать",
   "tabs_bar.federated_timeline": "Глобальная",
   "tabs_bar.home": "Главная",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index aa0929f82..9c985eec9 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -168,6 +168,7 @@
   "status.mention": "Mention @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost",
   "status.reblogged_by": "{name} boosted",
   "status.reply": "Reply",
@@ -179,6 +180,7 @@
   "status.show_less": "Show less",
   "status.show_more": "Show more",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Compose",
   "tabs_bar.federated_timeline": "Federated",
   "tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index 37ce8597e..41c9d44a7 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -168,6 +168,7 @@
   "status.mention": "Bahset @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Bu gönderiyi genişlet",
+  "status.pin": "Pin on profile",
   "status.reblog": "Boost'la",
   "status.reblogged_by": "{name} boost etti",
   "status.reply": "Cevapla",
@@ -179,6 +180,7 @@
   "status.show_less": "Daha azı",
   "status.show_more": "Daha fazlası",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Oluştur",
   "tabs_bar.federated_timeline": "Federe",
   "tabs_bar.home": "Ana sayfa",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index fea7bd94e..6087e3a1e 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -168,6 +168,7 @@
   "status.mention": "Згадати",
   "status.mute_conversation": "Заглушити діалог",
   "status.open": "Розгорнути допис",
+  "status.pin": "Pin on profile",
   "status.reblog": "Передмухнути",
   "status.reblogged_by": "{name} передмухнув(-ла)",
   "status.reply": "Відповісти",
@@ -179,6 +180,7 @@
   "status.show_less": "Згорнути",
   "status.show_more": "Розгорнути",
   "status.unmute_conversation": "Зняти глушення з діалогу",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "Написати",
   "tabs_bar.federated_timeline": "Глобальна",
   "tabs_bar.home": "Головна",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index e7c431454..2e3b4b0b8 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -168,6 +168,7 @@
   "status.mention": "提及 @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "展开嘟文",
+  "status.pin": "Pin on profile",
   "status.reblog": "转嘟",
   "status.reblogged_by": "{name} 转嘟",
   "status.reply": "回应",
@@ -179,6 +180,7 @@
   "status.show_less": "减少显示",
   "status.show_more": "显示更多",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "撰写",
   "tabs_bar.federated_timeline": "跨站",
   "tabs_bar.home": "主页",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 7312aae82..1ab3b3f9d 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -168,6 +168,7 @@
   "status.mention": "提及 @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "展開文章",
+  "status.pin": "Pin on profile",
   "status.reblog": "轉推",
   "status.reblogged_by": "{name} 轉推",
   "status.reply": "回應",
@@ -179,6 +180,7 @@
   "status.show_less": "減少顯示",
   "status.show_more": "顯示更多",
   "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "撰寫",
   "tabs_bar.federated_timeline": "跨站",
   "tabs_bar.home": "主頁",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 1c2e35272..571a2383d 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -168,6 +168,7 @@
   "status.mention": "提到 @{name}",
   "status.mute_conversation": "消音對話",
   "status.open": "展開這個狀態",
+  "status.pin": "Pin on profile",
   "status.reblog": "轉推",
   "status.reblogged_by": "{name} 轉推了",
   "status.reply": "回應",
@@ -179,6 +180,7 @@
   "status.show_less": "看少點",
   "status.show_more": "看更多",
   "status.unmute_conversation": "不消音對話",
+  "status.unpin": "Unpin from profile",
   "tabs_bar.compose": "編輯",
   "tabs_bar.federated_timeline": "聯盟",
   "tabs_bar.home": "家",
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 3e40b0b42..38691dc43 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -7,6 +7,8 @@ import {
   FAVOURITE_SUCCESS,
   FAVOURITE_FAIL,
   UNFAVOURITE_SUCCESS,
+  PIN_SUCCESS,
+  UNPIN_SUCCESS,
 } from '../actions/interactions';
 import {
   STATUS_FETCH_SUCCESS,
@@ -114,6 +116,8 @@ export default function statuses(state = initialState, action) {
   case UNREBLOG_SUCCESS:
   case FAVOURITE_SUCCESS:
   case UNFAVOURITE_SUCCESS:
+  case PIN_SUCCESS:
+  case UNPIN_SUCCESS:
     return normalizeStatus(state, action.response);
   case FAVOURITE_REQUEST:
     return state.setIn([action.status.get('id'), 'favourited'], true);
diff --git a/app/models/account.rb b/app/models/account.rb
index 0c9c6aed4..b83aa1159 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -77,6 +77,10 @@ class Account < ApplicationRecord
   has_many :mentions, inverse_of: :account, dependent: :destroy
   has_many :notifications, inverse_of: :account, dependent: :destroy
 
+  # Pinned statuses
+  has_many :status_pins, inverse_of: :account, dependent: :destroy
+  has_many :pinned_statuses, through: :status_pins, class_name: 'Status', source: :status
+
   # Media
   has_many :media_attachments, dependent: :destroy
 
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 9ffed2910..b26520f5b 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -138,4 +138,8 @@ module AccountInteractions
   def reblogged?(status)
     status.proper.reblogs.where(account: self).exists?
   end
+
+  def pinned?(status)
+    status_pins.where(status: status).exists?
+  end
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index 24eaf7071..3dc83ad1f 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -164,6 +164,10 @@ class Status < ApplicationRecord
       ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h
     end
 
+    def pins_map(status_ids, account_id)
+      StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |p| [p.status_id, true] }.to_h
+    end
+
     def reload_stale_associations!(cached_items)
       account_ids = []
 
diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb
new file mode 100644
index 000000000..c9a669344
--- /dev/null
+++ b/app/models/status_pin.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_pins
+#
+#  id         :integer          not null, primary key
+#  account_id :integer          not null
+#  status_id  :integer          not null
+#
+
+class StatusPin < ApplicationRecord
+  belongs_to :account, required: true
+  belongs_to :status, required: true
+
+  validates_with StatusPinValidator
+end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 03294015f..10b449504 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -1,19 +1,24 @@
 # frozen_string_literal: true
 
 class StatusRelationshipsPresenter
-  attr_reader :reblogs_map, :favourites_map, :mutes_map
+  attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map
 
-  def initialize(statuses, current_account_id = nil, reblogs_map: {}, favourites_map: {}, mutes_map: {})
+  def initialize(statuses, current_account_id = nil, options = {})
     if current_account_id.nil?
       @reblogs_map    = {}
       @favourites_map = {}
       @mutes_map      = {}
+      @pins_map       = {}
     else
-      status_ids       = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
-      conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq
-      @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(reblogs_map)
-      @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(favourites_map)
-      @mutes_map       = Status.mutes_map(conversation_ids, current_account_id).merge(mutes_map)
+      statuses            = statuses.compact
+      status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
+      conversation_ids    = statuses.map(&:conversation_id).compact.uniq
+      pinnable_status_ids = statuses.map(&:proper).select { |s| s.account_id == current_account_id && %w(public unlisted).include?(s.visibility) }.map(&:id)
+
+      @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
+      @favourites_map  = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
+      @mutes_map       = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
+      @pins_map        = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
     end
   end
 end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 246b12a90..298a3bb40 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -8,6 +8,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :favourited, if: :current_user?
   attribute :reblogged, if: :current_user?
   attribute :muted, if: :current_user?
+  attribute :pinned, if: :pinnable?
 
   belongs_to :reblog, serializer: REST::StatusSerializer
   belongs_to :application
@@ -57,6 +58,21 @@ class REST::StatusSerializer < ActiveModel::Serializer
     end
   end
 
+  def pinned
+    if instance_options && instance_options[:relationships]
+      instance_options[:relationships].pins_map[object.id] || false
+    else
+      current_user.account.pinned?(object)
+    end
+  end
+
+  def pinnable?
+    current_user? &&
+      current_user.account_id == object.account_id &&
+      !object.reblog? &&
+      %w(public unlisted).include?(object.visibility)
+  end
+
   class ApplicationSerializer < ActiveModel::Serializer
     attributes :name, :website
   end
diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb
new file mode 100644
index 000000000..f557df6af
--- /dev/null
+++ b/app/validators/status_pin_validator.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class StatusPinValidator < ActiveModel::Validator
+  def validate(pin)
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog?
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id
+    pin.errors.add(:status, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted).include?(pin.status.visibility)
+  end
+end
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index ec44f4c74..e0f9f869a 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -30,6 +30,9 @@
       = render 'nothing_here'
   - else
     .activity-stream.with-header
+      - if params[:page].to_i.zero?
+        = render partial: 'stream_entries/status', collection: @pinned_statuses, as: :status, locals: { pinned: true }
+
       = render partial: 'stream_entries/status', collection: @statuses, as: :status
 
   - if @statuses.size == 20
diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml
index 50a373743..e2e1fdd12 100644
--- a/app/views/stream_entries/_status.html.haml
+++ b/app/views/stream_entries/_status.html.haml
@@ -1,4 +1,5 @@
 :ruby
+  pinned          ||= false
   include_threads ||= false
   is_predecessor  ||= false
   is_successor    ||= false
@@ -25,6 +26,12 @@
         = link_to TagManager.instance.url_for(status.account), class: 'status__display-name muted' do
           %strong.emojify= display_name(status.account)
         = t('stream_entries.reblogged')
+  - elsif pinned
+    .pre-header
+      .pre-header__icon
+        = fa_icon('thumb-tack fw')
+      %span
+        = t('stream_entries.pinned')
 
   = render (centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status'), status: status.proper
 
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 97bb14186..96d08e6b2 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -434,6 +434,10 @@ en:
   statuses:
     open_in_web: Open in web
     over_character_limit: character limit of %{max} exceeded
+    pin_errors:
+      ownership: Someone else's toot cannot be pinned
+      private: Non-public toot cannot be pinned
+      reblog: A boost cannot be pinned
     show_more: Show more
     visibilities:
       private: Followers-only
@@ -444,6 +448,7 @@ en:
       unlisted_long: Everyone can see, but not listed on public timelines
   stream_entries:
     click_to_show: Click to show
+    pinned: Pinned toot
     reblogged: boosted
     sensitive_content: Sensitive content
   terms:
diff --git a/config/routes.rb b/config/routes.rb
index 94a4ac88e..7588805c0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -162,6 +162,9 @@ Rails.application.routes.draw do
 
           resource :mute, only: :create
           post :unmute, to: 'mutes#destroy'
+
+          resource :pin, only: :create
+          post :unpin, to: 'pins#destroy'
         end
 
         member do
@@ -175,7 +178,8 @@ Rails.application.routes.draw do
         resource :public, only: :show, controller: :public
         resources :tag, only: :show
       end
-      resources :streaming,  only: [:index]
+
+      resources :streaming, only: [:index]
 
       get '/search', to: 'search#index', as: :search
 
@@ -210,6 +214,7 @@ Rails.application.routes.draw do
         resource :search, only: :show, controller: :search
         resources :relationships, only: :index
       end
+
       resources :accounts, only: [:show] do
         resources :statuses, only: :index, controller: 'accounts/statuses'
         resources :followers, only: :index, controller: 'accounts/follower_accounts'
@@ -245,7 +250,7 @@ Rails.application.routes.draw do
   root 'home#index'
 
   match '*unmatched_route',
-    via: :all,
-    to: 'application#raise_not_found',
-    format: false
+        via: :all,
+        to: 'application#raise_not_found',
+        format: false
 end
diff --git a/db/migrate/20170823162448_create_status_pins.rb b/db/migrate/20170823162448_create_status_pins.rb
new file mode 100644
index 000000000..9a6d4a7b9
--- /dev/null
+++ b/db/migrate/20170823162448_create_status_pins.rb
@@ -0,0 +1,10 @@
+class CreateStatusPins < ActiveRecord::Migration[5.1]
+  def change
+    create_table :status_pins do |t|
+      t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false
+      t.belongs_to :status, foreign_key: { on_delete: :cascade }, null: false
+    end
+
+    add_index :status_pins, [:account_id, :status_id], unique: true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 98b07e282..d0e72be0f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20170720000000) do
+ActiveRecord::Schema.define(version: 20170823162448) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -282,6 +282,14 @@ ActiveRecord::Schema.define(version: 20170720000000) do
     t.index ["thing_type", "thing_id", "var"], name: "index_settings_on_thing_type_and_thing_id_and_var", unique: true
   end
 
+  create_table "status_pins", force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.bigint "status_id", null: false
+    t.index ["account_id", "status_id"], name: "index_status_pins_on_account_id_and_status_id", unique: true
+    t.index ["account_id"], name: "index_status_pins_on_account_id"
+    t.index ["status_id"], name: "index_status_pins_on_status_id"
+  end
+
   create_table "statuses", force: :cascade do |t|
     t.string "uri"
     t.integer "account_id", null: false
@@ -430,6 +438,8 @@ ActiveRecord::Schema.define(version: 20170720000000) do
   add_foreign_key "reports", "accounts", on_delete: :cascade
   add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade
   add_foreign_key "session_activations", "users", on_delete: :cascade
+  add_foreign_key "status_pins", "accounts", on_delete: :cascade
+  add_foreign_key "status_pins", "statuses", on_delete: :cascade
   add_foreign_key "statuses", "accounts", column: "in_reply_to_account_id", on_delete: :nullify
   add_foreign_key "statuses", "accounts", on_delete: :cascade
   add_foreign_key "statuses", "statuses", column: "in_reply_to_id", on_delete: :nullify
diff --git a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb
index 8b4fd6a5b..c49a77ac3 100644
--- a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb
@@ -18,21 +18,37 @@ describe Api::V1::Accounts::StatusesController do
       expect(response).to have_http_status(:success)
       expect(response.headers['Link'].links.size).to eq(2)
     end
-  end
 
-  describe 'GET #index with only media' do
-    it 'returns http success' do
-      get :index, params: { account_id: user.account.id, only_media: true }
+    context 'with only media' do
+      it 'returns http success' do
+        get :index, params: { account_id: user.account.id, only_media: true }
 
-      expect(response).to have_http_status(:success)
+        expect(response).to have_http_status(:success)
+      end
     end
-  end
 
-  describe 'GET #index with exclude replies' do
-    it 'returns http success' do
-      get :index, params: { account_id: user.account.id, exclude_replies: true }
+    context 'with exclude replies' do
+      before do
+        Fabricate(:status, account: user.account, thread: Fabricate(:status))
+      end
 
-      expect(response).to have_http_status(:success)
+      it 'returns http success' do
+        get :index, params: { account_id: user.account.id, exclude_replies: true }
+
+        expect(response).to have_http_status(:success)
+      end
+    end
+
+    context 'with only pinned' do
+      before do
+        Fabricate(:status_pin, account: user.account, status: Fabricate(:status, account: user.account))
+      end
+
+      it 'returns http success' do
+        get :index, params: { account_id: user.account.id, pinned: true }
+
+        expect(response).to have_http_status(:success)
+      end
     end
   end
 end
diff --git a/spec/controllers/api/v1/statuses/pins_controller_spec.rb b/spec/controllers/api/v1/statuses/pins_controller_spec.rb
new file mode 100644
index 000000000..2e170da24
--- /dev/null
+++ b/spec/controllers/api/v1/statuses/pins_controller_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Api::V1::Statuses::PinsController do
+  render_views
+
+  let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
+  let(:app)   { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
+  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write', application: app) }
+
+  context 'with an oauth token' do
+    before do
+      allow(controller).to receive(:doorkeeper_token) { token }
+    end
+
+    describe 'POST #create' do
+      let(:status) { Fabricate(:status, account: user.account) }
+
+      before do
+        post :create, params: { status_id: status.id }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(:success)
+      end
+
+      it 'updates the pinned attribute' do
+        expect(user.account.pinned?(status)).to be true
+      end
+
+      it 'return json with updated attributes' do
+        hash_body = body_as_json
+
+        expect(hash_body[:id]).to eq status.id
+        expect(hash_body[:pinned]).to be true
+      end
+    end
+
+    describe 'POST #destroy' do
+      let(:status) { Fabricate(:status, account: user.account) }
+
+      before do
+        Fabricate(:status_pin, status: status, account: user.account)
+        post :destroy, params: { status_id: status.id }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(:success)
+      end
+
+      it 'updates the pinned attribute' do
+        expect(user.account.pinned?(status)).to be false
+      end
+    end
+  end
+end
diff --git a/spec/fabricators/status_pin_fabricator.rb b/spec/fabricators/status_pin_fabricator.rb
new file mode 100644
index 000000000..6a9006c9f
--- /dev/null
+++ b/spec/fabricators/status_pin_fabricator.rb
@@ -0,0 +1,4 @@
+Fabricator(:status_pin) do
+  account
+  status
+end
diff --git a/spec/models/status_pin_spec.rb b/spec/models/status_pin_spec.rb
new file mode 100644
index 000000000..6f54f80f9
--- /dev/null
+++ b/spec/models/status_pin_spec.rb
@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+RSpec.describe StatusPin, type: :model do
+  describe 'validations' do
+    it 'allows pins of own statuses' do
+      account = Fabricate(:account)
+      status  = Fabricate(:status, account: account)
+
+      expect(StatusPin.new(account: account, status: status).save).to be true
+    end
+
+    it 'does not allow pins of statuses by someone else' do
+      account = Fabricate(:account)
+      status  = Fabricate(:status)
+
+      expect(StatusPin.new(account: account, status: status).save).to be false
+    end
+
+    it 'does not allow pins of reblogs' do
+      account = Fabricate(:account)
+      status  = Fabricate(:status, account: account)
+      reblog  = Fabricate(:status, reblog: status)
+
+      expect(StatusPin.new(account: account, status: reblog).save).to be false
+    end
+
+    it 'does not allow pins of private statuses' do
+      account = Fabricate(:account)
+      status  = Fabricate(:status, account: account, visibility: :private)
+
+      expect(StatusPin.new(account: account, status: status).save).to be false
+    end
+
+    it 'does not allow pins of direct statuses' do
+      account = Fabricate(:account)
+      status  = Fabricate(:status, account: account, visibility: :direct)
+
+      expect(StatusPin.new(account: account, status: status).save).to be false
+    end
+  end
+end