Pinned statuses (#4675)

* Pinned statuses

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

@ -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

@ -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

@ -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,
};
};

@ -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,

@ -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 });

@ -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')));

@ -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 });

@ -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}

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

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

@ -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",

@ -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",

@ -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"

@ -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",

@ -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",

@ -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",

@ -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": "خانه",

@ -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",

@ -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",

@ -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": "בבית",

@ -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",

@ -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",

@ -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",

@ -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",

@ -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",

@ -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": "ホーム",

@ -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": "홈",

@ -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",

@ -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",

@ -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",

@ -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",

@ -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",

@ -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",

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

@ -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",

@ -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",

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

@ -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": "主页",

@ -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": "主頁",

@ -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": "家",

@ -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);

@ -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

@ -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

@ -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 = []

@ -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

@ -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

@ -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

@ -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

@ -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