def show
respond_to do |format|
format.html do
- @body_classes = 'with-modals'
- @pinned_statuses = []
+ @body_classes = 'with-modals'
+ @pinned_statuses = []
+ @endorsed_accounts = @account.endorsed_accounts.to_a.sample(4)
if current_account && @account.blocking?(current_account)
@statuses = []
--- /dev/null
+# frozen_string_literal: true
+
+class Api::V1::Accounts::PinsController < Api::BaseController
+ include Authorization
+
+ before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
+ before_action :require_user!
+ before_action :set_account
+
+ respond_to :json
+
+ def create
+ AccountPin.create!(account: current_account, target_account: @account)
+ render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
+ end
+
+ def destroy
+ pin = AccountPin.find_by(account: current_account, target_account: @account)
+ pin&.destroy!
+ render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
+ end
+
+ private
+
+ def set_account
+ @account = Account.find(params[:account_id])
+ end
+
+ def relationships_presenter
+ AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
+ end
+end
locale: I18n.locale,
}
end
+
+ def account_link_to(account, button = '')
+ content_tag(:div, class: 'account') do
+ content_tag(:div, class: 'account__wrapper') do
+ section = if account.nil?
+ content_tag(:div, class: 'account__display-name') do
+ content_tag(:div, class: 'account__avatar-wrapper') do
+ content_tag(:div, '', class: 'account__avatar', style: "background-image: url(#{full_asset_url('avatars/original/missing.png', skip_pipeline: true)})")
+ end +
+ content_tag(:span, class: 'display-name') do
+ content_tag(:strong, t('about.contact_missing')) +
+ content_tag(:span, t('about.contact_unavailable'), class: 'display-name__account')
+ end
+ end
+ else
+ link_to(TagManager.instance.url_for(account), class: 'account__display-name') do
+ content_tag(:div, class: 'account__avatar-wrapper') do
+ content_tag(:div, '', class: 'account__avatar', style: "background-image: url(#{account.avatar.url})")
+ end +
+ content_tag(:span, class: 'display-name') do
+ content_tag(:bdi) do
+ content_tag(:strong, display_name(account, custom_emojify: true), class: 'display-name__html emojify')
+ end +
+ content_tag(:span, "@#{account.acct}", class: 'display-name__account')
+ end
+ end
+ end
+
+ section + button
+ end
+ end
+ end
end
export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
export const ACCOUNT_UNMUTE_FAIL = 'ACCOUNT_UNMUTE_FAIL';
+export const ACCOUNT_PIN_REQUEST = 'ACCOUNT_PIN_REQUEST';
+export const ACCOUNT_PIN_SUCCESS = 'ACCOUNT_PIN_SUCCESS';
+export const ACCOUNT_PIN_FAIL = 'ACCOUNT_PIN_FAIL';
+
+export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST';
+export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS';
+export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL';
+
export const FOLLOWERS_FETCH_REQUEST = 'FOLLOWERS_FETCH_REQUEST';
export const FOLLOWERS_FETCH_SUCCESS = 'FOLLOWERS_FETCH_SUCCESS';
export const FOLLOWERS_FETCH_FAIL = 'FOLLOWERS_FETCH_FAIL';
error,
};
};
+
+export function pinAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(pinAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/pin`).then(response => {
+ dispatch(pinAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(pinAccountFail(error));
+ });
+ };
+};
+
+export function unpinAccount(id) {
+ return (dispatch, getState) => {
+ dispatch(unpinAccountRequest(id));
+
+ api(getState).post(`/api/v1/accounts/${id}/unpin`).then(response => {
+ dispatch(unpinAccountSuccess(response.data));
+ }).catch(error => {
+ dispatch(unpinAccountFail(error));
+ });
+ };
+};
+
+export function pinAccountRequest(id) {
+ return {
+ type: ACCOUNT_PIN_REQUEST,
+ id,
+ };
+};
+
+export function pinAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_PIN_SUCCESS,
+ relationship,
+ };
+};
+
+export function pinAccountFail(error) {
+ return {
+ type: ACCOUNT_PIN_FAIL,
+ error,
+ };
+};
+
+export function unpinAccountRequest(id) {
+ return {
+ type: ACCOUNT_UNPIN_REQUEST,
+ id,
+ };
+};
+
+export function unpinAccountSuccess(relationship) {
+ return {
+ type: ACCOUNT_UNPIN_SUCCESS,
+ relationship,
+ };
+};
+
+export function unpinAccountFail(error) {
+ return {
+ type: ACCOUNT_UNPIN_FAIL,
+ error,
+ };
+};
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+ endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
+ unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
});
@injectIntl
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired,
+ onEndorseToggle: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
} else {
menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
}
+
+ menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
+ menu.push(null);
}
if (account.getIn(['relationship', 'muting'])) {
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired,
+ onEndorseToggle: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
};
this.props.onUnblockDomain(domain);
}
+ handleEndorseToggle = () => {
+ this.props.onEndorseToggle(this.props.account);
+ }
+
render () {
const { account, hideTabs } = this.props;
onMute={this.handleMute}
onBlockDomain={this.handleBlockDomain}
onUnblockDomain={this.handleUnblockDomain}
+ onEndorseToggle={this.handleEndorseToggle}
/>
{!hideTabs && (
blockAccount,
unblockAccount,
unmuteAccount,
+ pinAccount,
+ unpinAccount,
} from '../../../actions/accounts';
import {
mentionCompose,
}
},
+ onEndorseToggle (account) {
+ if (account.getIn(['relationship', 'endorsed'])) {
+ dispatch(unpinAccount(account.get('id')));
+ } else {
+ dispatch(pinAccount(account.get('id')));
+ }
+ },
+
onReport (account) {
dispatch(initReport(account));
},
ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
ACCOUNT_UNMUTE_SUCCESS,
+ ACCOUNT_PIN_SUCCESS,
+ ACCOUNT_UNPIN_SUCCESS,
RELATIONSHIPS_FETCH_SUCCESS,
} from '../actions/accounts';
import {
case ACCOUNT_UNBLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
case ACCOUNT_UNMUTE_SUCCESS:
+ case ACCOUNT_PIN_SUCCESS:
+ case ACCOUNT_UNPIN_SUCCESS:
return normalizeRelationship(state, action.relationship);
case RELATIONSHIPS_FETCH_SUCCESS:
return normalizeRelationships(state, action.relationships);
}
}
+.endorsements-widget {
+ margin-bottom: 10px;
+ padding-bottom: 10px;
+
+ h4 {
+ padding: 10px;
+ text-transform: uppercase;
+ font-weight: 700;
+ font-size: 13px;
+ color: $darker-text-color;
+ }
+
+ .account {
+ padding: 10px 0;
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ .account__display-name {
+ display: flex;
+ align-items: center;
+ }
+
+ .account__avatar {
+ width: 44px;
+ height: 44px;
+ background-size: 44px 44px;
+ }
+ }
+}
+
.moved-account-widget {
padding: 15px;
padding-bottom: 20px;
has_many :status_pins, inverse_of: :account, dependent: :destroy
has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
+ # Endorsements
+ has_many :account_pins, inverse_of: :account, dependent: :destroy
+ has_many :endorsed_accounts, through: :account_pins, class_name: 'Account', source: :target_account
+
# Media
has_many :media_attachments, dependent: :destroy
--- /dev/null
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_pins
+#
+# id :bigint(8) not null, primary key
+# account_id :bigint(8)
+# target_account_id :bigint(8)
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class AccountPin < ApplicationRecord
+ include RelationshipCacheable
+
+ belongs_to :account
+ belongs_to :target_account, class_name: 'Account'
+
+ validate :validate_follow_relationship
+
+ private
+
+ def validate_follow_relationship
+ errors.add(:base, I18n.t('accounts.pin_errors.following')) unless account.following?(target_account)
+ end
+end
end
end
+ def endorsed_map(target_account_ids, account_id)
+ follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
+ end
+
def domain_blocking_map(target_account_ids, account_id)
accounts_map = Account.where(id: target_account_ids).select('id, domain').map { |a| [a.id, a.domain] }.to_h
blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
status_pins.where(status: status).exists?
end
+ def endorsed?(account)
+ account_pins.where(target_account: account).exists?
+ end
+
def followers_for_local_distribution
followers.local
.joins(:user)
class AccountRelationshipsPresenter
attr_reader :following, :followed_by, :blocking,
- :muting, :requested, :domain_blocking
+ :muting, :requested, :domain_blocking,
+ :endorsed
def initialize(account_ids, current_account_id, **options)
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a }
@muting = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id))
@requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
@domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
+ @endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
cache_uncached!
@muting.merge!(options[:muting_map] || {})
@requested.merge!(options[:requested_map] || {})
@domain_blocking.merge!(options[:domain_blocking_map] || {})
+ @endorsed.merge!(options[:endorsed_map] || {})
end
private
muting: {},
requested: {},
domain_blocking: {},
+ endorsed: {},
}
@uncached_account_ids = []
muting: { account_id => muting[account_id] },
requested: { account_id => requested[account_id] },
domain_blocking: { account_id => domain_blocking[account_id] },
+ endorsed: { account_id => endorsed[account_id] },
}
Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day)
class REST::RelationshipSerializer < ActiveModel::Serializer
attributes :id, :following, :showing_reblogs, :followed_by, :blocking,
- :muting, :muting_notifications, :requested, :domain_blocking
+ :muting, :muting_notifications, :requested, :domain_blocking,
+ :endorsed
def id
object.id.to_s
def domain_blocking
instance_options[:relationships].domain_blocking[object.id] || false
end
+
+ def endorsed
+ instance_options[:relationships].endorsed[object.id] || false
+ end
end
= render 'moved', account: @account
= render 'bio', account: @account
+
+ - unless @endorsed_accounts.empty?
+ .endorsements-widget
+ %h4= t 'accounts.choices_html', name: content_tag(:bdi, display_name(@account, custom_emojify: true))
+
+ - @endorsed_accounts.each do |account|
+ = account_link_to account
+
= render 'application/sidebar'
user_count_before: Home to
what_is_mastodon: What is Mastodon?
accounts:
+ choices_html: "%{name}'s choices:"
follow: Follow
followers: Followers
following: Following
nothing_here: There is nothing here!
people_followed_by: People whom %{name} follows
people_who_follow: People who follow %{name}
+ pin_errors:
+ following: You must be already following the person you want to endorse
posts: Toots
posts_with_replies: Toots and replies
reserved_username: The username is reserved
post :mute
post :unmute
end
+
+ resource :pin, only: :create, controller: 'accounts/pins'
+ post :unpin, to: 'accounts/pins#destroy'
end
resources :lists, only: [:index, :create, :show, :update, :destroy] do
--- /dev/null
+class CreateAccountPins < ActiveRecord::Migration[5.2]
+ def change
+ create_table :account_pins do |t|
+ t.belongs_to :account, foreign_key: { on_delete: :cascade }
+ t.belongs_to :target_account, foreign_key: { on_delete: :cascade, to_table: :accounts }
+
+ t.timestamps
+ end
+
+ add_index :account_pins, [:account_id, :target_account_id], unique: true
+ end
+end
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2018_07_11_152640) do
+ActiveRecord::Schema.define(version: 2018_08_08_175627) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
t.index ["target_account_id"], name: "index_account_moderation_notes_on_target_account_id"
end
+ create_table "account_pins", force: :cascade do |t|
+ t.bigint "account_id"
+ t.bigint "target_account_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "target_account_id"], name: "index_account_pins_on_account_id_and_target_account_id", unique: true
+ t.index ["account_id"], name: "index_account_pins_on_account_id"
+ t.index ["target_account_id"], name: "index_account_pins_on_target_account_id"
+ end
+
create_table "accounts", force: :cascade do |t|
t.string "username", default: "", null: false
t.string "domain"
t.text "phrase", default: "", null: false
t.string "context", default: [], null: false, array: true
t.boolean "irreversible", default: false, null: false
- t.boolean "whole_word", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.boolean "whole_word", default: true, null: false
t.index ["account_id"], name: "index_custom_filters_on_account_id"
end
add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade
add_foreign_key "account_moderation_notes", "accounts"
add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id"
+ add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade
+ add_foreign_key "account_pins", "accounts", on_delete: :cascade
add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify
add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade
add_foreign_key "backups", "users", on_delete: :nullify
--- /dev/null
+Fabricator(:account_pin) do
+ account nil
+ target_account nil
+end
--- /dev/null
+require 'rails_helper'
+
+RSpec.describe AccountPin, type: :model do
+ pending "add some examples to (or delete) #{__FILE__}"
+end