فهرست منبع

Pinned statuses (#4675)

* Pinned statuses

* yarn manage:translations
Eugen Rochko 6 سال پیش
والد
کامیت
9caa90025f
59فایلهای تغییر یافته به همراه493 افزوده شده و 29 حذف شده
  1. 18 7
      app/controllers/accounts_controller.rb
  2. 5 0
      app/controllers/api/v1/accounts/statuses_controller.rb
  3. 28 0
      app/controllers/api/v1/statuses/pins_controller.rb
  4. 78 0
      app/javascript/mastodon/actions/interactions.js
  5. 1 0
      app/javascript/mastodon/components/status.js
  6. 11 0
      app/javascript/mastodon/components/status_action_bar.js
  7. 10 0
      app/javascript/mastodon/containers/status_container.js
  8. 11 0
      app/javascript/mastodon/features/status/components/action_bar.js
  9. 11 0
      app/javascript/mastodon/features/status/index.js
  10. 2 0
      app/javascript/mastodon/locales/ar.json
  11. 2 0
      app/javascript/mastodon/locales/bg.json
  12. 2 0
      app/javascript/mastodon/locales/ca.json
  13. 2 0
      app/javascript/mastodon/locales/de.json
  14. 16 0
      app/javascript/mastodon/locales/defaultMessages.json
  15. 2 0
      app/javascript/mastodon/locales/en.json
  16. 2 0
      app/javascript/mastodon/locales/eo.json
  17. 2 0
      app/javascript/mastodon/locales/es.json
  18. 2 0
      app/javascript/mastodon/locales/fa.json
  19. 2 0
      app/javascript/mastodon/locales/fi.json
  20. 2 0
      app/javascript/mastodon/locales/fr.json
  21. 2 0
      app/javascript/mastodon/locales/he.json
  22. 2 0
      app/javascript/mastodon/locales/hr.json
  23. 2 0
      app/javascript/mastodon/locales/hu.json
  24. 2 0
      app/javascript/mastodon/locales/id.json
  25. 2 0
      app/javascript/mastodon/locales/io.json
  26. 2 0
      app/javascript/mastodon/locales/it.json
  27. 2 0
      app/javascript/mastodon/locales/ja.json
  28. 2 0
      app/javascript/mastodon/locales/ko.json
  29. 2 0
      app/javascript/mastodon/locales/nl.json
  30. 2 0
      app/javascript/mastodon/locales/no.json
  31. 2 0
      app/javascript/mastodon/locales/oc.json
  32. 2 0
      app/javascript/mastodon/locales/pl.json
  33. 2 0
      app/javascript/mastodon/locales/pt-BR.json
  34. 2 0
      app/javascript/mastodon/locales/pt.json
  35. 2 0
      app/javascript/mastodon/locales/ru.json
  36. 2 0
      app/javascript/mastodon/locales/th.json
  37. 2 0
      app/javascript/mastodon/locales/tr.json
  38. 2 0
      app/javascript/mastodon/locales/uk.json
  39. 2 0
      app/javascript/mastodon/locales/zh-CN.json
  40. 2 0
      app/javascript/mastodon/locales/zh-HK.json
  41. 2 0
      app/javascript/mastodon/locales/zh-TW.json
  42. 4 0
      app/javascript/mastodon/reducers/statuses.js
  43. 4 0
      app/models/account.rb
  44. 4 0
      app/models/concerns/account_interactions.rb
  45. 4 0
      app/models/status.rb
  46. 16 0
      app/models/status_pin.rb
  47. 12 7
      app/presenters/status_relationships_presenter.rb
  48. 16 0
      app/serializers/rest/status_serializer.rb
  49. 9 0
      app/validators/status_pin_validator.rb
  50. 3 0
      app/views/accounts/show.html.haml
  51. 7 0
      app/views/stream_entries/_status.html.haml
  52. 5 0
      config/locales/en.yml
  53. 9 4
      config/routes.rb
  54. 10 0
      db/migrate/20170823162448_create_status_pins.rb
  55. 11 1
      db/schema.rb
  56. 26 10
      spec/controllers/api/v1/accounts/statuses_controller_spec.rb
  57. 57 0
      spec/controllers/api/v1/statuses/pins_controller_spec.rb
  58. 4 0
      spec/fabricators/status_pin_fabricator.rb
  59. 41 0
      spec/models/status_pin_spec.rb

+ 18 - 7
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

+ 5 - 0
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

+ 28 - 0
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

+ 78 - 0
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,
+  };
+};

+ 1 - 0
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,

+ 11 - 0
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 });

+ 10 - 0
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')));

+ 11 - 0
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 });

+ 11 - 0
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}

+ 2 - 0
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": "الرئيسية",

+ 2 - 0
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": "Начало",

+ 2 - 0
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",

+ 2 - 0
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",

+ 16 - 0
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"

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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": "خانه",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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": "בבית",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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": "ホーム",

+ 2 - 0
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": "홈",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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": "Главная",

+ 2 - 0
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",

+ 2 - 0
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",

+ 2 - 0
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": "Головна",

+ 2 - 0
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": "主页",

+ 2 - 0
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": "主頁",

+ 2 - 0
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": "家",

+ 4 - 0
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);

+ 4 - 0
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
 

+ 4 - 0
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

+ 4 - 0
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 = []
 

+ 16 - 0
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

+ 12 - 7
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

+ 16 - 0
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

+ 9 - 0
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

+ 3 - 0
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

+ 7 - 0
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
 

+ 5 - 0
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:

+ 9 - 4
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

+ 10 - 0
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

+ 11 - 1
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

+ 26 - 10
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

+ 57 - 0
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

+ 4 - 0
spec/fabricators/status_pin_fabricator.rb

@@ -0,0 +1,4 @@
+Fabricator(:status_pin) do
+  account
+  status
+end

+ 41 - 0
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