* Federate pinned statuses over ActivityPub * Display pinned toots in web UI Fix #6117 * Fix migration * Fix tests * Update outbox_serializer.rb * Update remove_serializer.rb * Update add_serializer.rb * Update fetch_featured_collection_service.rbmaster
@@ -0,0 +1,57 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::CollectionsController < Api::BaseController | |||
include SignatureVerification | |||
before_action :set_account | |||
before_action :set_size | |||
before_action :set_statuses | |||
def show | |||
render json: collection_presenter, | |||
serializer: ActivityPub::CollectionSerializer, | |||
adapter: ActivityPub::Adapter, | |||
content_type: 'application/activity+json', | |||
skip_activities: true | |||
end | |||
private | |||
def set_account | |||
@account = Account.find_local!(params[:account_username]) | |||
end | |||
def set_statuses | |||
@statuses = scope_for_collection.paginate_by_max_id(20, params[:max_id], params[:since_id]) | |||
@statuses = cache_collection(@statuses, Status) | |||
end | |||
def set_size | |||
case params[:id] | |||
when 'featured' | |||
@account.pinned_statuses.count | |||
else | |||
raise ActiveRecord::NotFound | |||
end | |||
end | |||
def scope_for_collection | |||
case params[:id] | |||
when 'featured' | |||
@account.statuses.permitted_for(@account, signed_request_account).tap do |scope| | |||
scope.merge!(@account.pinned_statuses) | |||
end | |||
else | |||
raise ActiveRecord::NotFound | |||
end | |||
end | |||
def collection_presenter | |||
ActivityPub::CollectionPresenter.new( | |||
id: account_collection_url(@account, params[:id]), | |||
type: :ordered, | |||
size: @size, | |||
items: @statuses | |||
) | |||
end | |||
end |
@@ -9,7 +9,7 @@ class ActivityPub::OutboxesController < Api::BaseController | |||
@statuses = @account.statuses.permitted_for(@account, signed_request_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) | |||
@statuses = cache_collection(@statuses, Status) | |||
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | |||
render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' | |||
end | |||
private | |||
@@ -11,12 +11,18 @@ class Api::V1::Statuses::PinsController < Api::BaseController | |||
def create | |||
StatusPin.create!(account: current_account, status: @status) | |||
distribute_add_activity! | |||
render json: @status, serializer: REST::StatusSerializer | |||
end | |||
def destroy | |||
pin = StatusPin.find_by(account: current_account, status: @status) | |||
pin&.destroy! | |||
if pin | |||
pin.destroy! | |||
distribute_remove_activity! | |||
end | |||
render json: @status, serializer: REST::StatusSerializer | |||
end | |||
@@ -25,4 +31,24 @@ class Api::V1::Statuses::PinsController < Api::BaseController | |||
def set_status | |||
@status = Status.find(params[:status_id]) | |||
end | |||
def distribute_add_activity! | |||
json = ActiveModelSerializers::SerializableResource.new( | |||
@status, | |||
serializer: ActivityPub::AddSerializer, | |||
adapter: ActivityPub::Adapter | |||
).as_json | |||
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account) | |||
end | |||
def distribute_remove_activity! | |||
json = ActiveModelSerializers::SerializableResource.new( | |||
@status, | |||
serializer: ActivityPub::RemoveSerializer, | |||
adapter: ActivityPub::Adapter | |||
).as_json | |||
ActivityPub::RawDistributionWorker.perform_async(Oj.dump(json), current_account) | |||
end | |||
end |
@@ -117,13 +117,14 @@ export function refreshTimeline(timelineId, path, params = {}) { | |||
}; | |||
}; | |||
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); | |||
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); | |||
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); | |||
export const refreshAccountTimeline = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); | |||
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); | |||
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); | |||
export const refreshListTimeline = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); | |||
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); | |||
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); | |||
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); | |||
export const refreshAccountTimeline = (accountId, withReplies) => refreshTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies }); | |||
export const refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); | |||
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); | |||
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); | |||
export const refreshListTimeline = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`); | |||
export function refreshTimelineFail(timeline, error, skipLoading) { | |||
return { | |||
@@ -138,7 +138,7 @@ export default class Status extends ImmutablePureComponent { | |||
let media = null; | |||
let statusAvatar, prepend; | |||
const { hidden } = this.props; | |||
const { hidden, featured } = this.props; | |||
const { isExpanded } = this.state; | |||
let { status, account, ...other } = this.props; | |||
@@ -156,7 +156,14 @@ export default class Status extends ImmutablePureComponent { | |||
); | |||
} | |||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | |||
if (featured) { | |||
prepend = ( | |||
<div className='status__prepend'> | |||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-thumb-tack status__prepend-icon' /></div> | |||
<FormattedMessage id='status.pinned' defaultMessage='Pinned toot' /> | |||
</div> | |||
); | |||
} else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | |||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; | |||
prepend = ( | |||
@@ -11,6 +11,7 @@ export default class StatusList extends ImmutablePureComponent { | |||
static propTypes = { | |||
scrollKey: PropTypes.string.isRequired, | |||
statusIds: ImmutablePropTypes.list.isRequired, | |||
featuredStatusIds: ImmutablePropTypes.list, | |||
onScrollToBottom: PropTypes.func, | |||
onScrollToTop: PropTypes.func, | |||
onScroll: PropTypes.func, | |||
@@ -50,7 +51,7 @@ export default class StatusList extends ImmutablePureComponent { | |||
} | |||
render () { | |||
const { statusIds, ...other } = this.props; | |||
const { statusIds, featuredStatusIds, ...other } = this.props; | |||
const { isLoading, isPartial } = other; | |||
if (isPartial) { | |||
@@ -68,8 +69,8 @@ export default class StatusList extends ImmutablePureComponent { | |||
); | |||
} | |||
const scrollableContent = (isLoading || statusIds.size > 0) ? ( | |||
statusIds.map((statusId) => ( | |||
let scrollableContent = (isLoading || statusIds.size > 0) ? ( | |||
statusIds.map(statusId => ( | |||
<StatusContainer | |||
key={statusId} | |||
id={statusId} | |||
@@ -79,6 +80,18 @@ export default class StatusList extends ImmutablePureComponent { | |||
)) | |||
) : null; | |||
if (scrollableContent && featuredStatusIds) { | |||
scrollableContent = featuredStatusIds.map(statusId => ( | |||
<StatusContainer | |||
key={`f-${statusId}`} | |||
id={statusId} | |||
featured | |||
onMoveUp={this.handleMoveUp} | |||
onMoveDown={this.handleMoveDown} | |||
/> | |||
)).concat(scrollableContent); | |||
} | |||
return ( | |||
<ScrollableList {...other} ref={this.setRef}> | |||
{scrollableContent} | |||
@@ -21,6 +21,7 @@ export default class Header extends ImmutablePureComponent { | |||
onMute: PropTypes.func.isRequired, | |||
onBlockDomain: PropTypes.func.isRequired, | |||
onUnblockDomain: PropTypes.func.isRequired, | |||
hideTabs: PropTypes.bool, | |||
}; | |||
static contextTypes = { | |||
@@ -68,7 +69,7 @@ export default class Header extends ImmutablePureComponent { | |||
} | |||
render () { | |||
const { account } = this.props; | |||
const { account, hideTabs } = this.props; | |||
if (account === null) { | |||
return <MissingIndicator />; | |||
@@ -94,11 +95,13 @@ export default class Header extends ImmutablePureComponent { | |||
onUnblockDomain={this.handleUnblockDomain} | |||
/> | |||
<div className='account__section-headline'> | |||
<NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink> | |||
<NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink> | |||
<NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink> | |||
</div> | |||
{!hideTabs && ( | |||
<div className='account__section-headline'> | |||
<NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink> | |||
<NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink> | |||
<NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink> | |||
</div> | |||
)} | |||
</div> | |||
); | |||
} | |||
@@ -3,7 +3,7 @@ import { connect } from 'react-redux'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import PropTypes from 'prop-types'; | |||
import { fetchAccount } from '../../actions/accounts'; | |||
import { refreshAccountTimeline, expandAccountTimeline } from '../../actions/timelines'; | |||
import { refreshAccountTimeline, refreshAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines'; | |||
import StatusList from '../../components/status_list'; | |||
import LoadingIndicator from '../../components/loading_indicator'; | |||
import Column from '../ui/components/column'; | |||
@@ -17,6 +17,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) | |||
return { | |||
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), | |||
featuredStatusIds: state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), | |||
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), | |||
hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']), | |||
}; | |||
@@ -29,19 +30,24 @@ export default class AccountTimeline extends ImmutablePureComponent { | |||
params: PropTypes.object.isRequired, | |||
dispatch: PropTypes.func.isRequired, | |||
statusIds: ImmutablePropTypes.list, | |||
featuredStatusIds: ImmutablePropTypes.list, | |||
isLoading: PropTypes.bool, | |||
hasMore: PropTypes.bool, | |||
withReplies: PropTypes.bool, | |||
}; | |||
componentWillMount () { | |||
this.props.dispatch(fetchAccount(this.props.params.accountId)); | |||
this.props.dispatch(refreshAccountTimeline(this.props.params.accountId, this.props.withReplies)); | |||
const { params: { accountId }, withReplies } = this.props; | |||
this.props.dispatch(fetchAccount(accountId)); | |||
this.props.dispatch(refreshAccountFeaturedTimeline(accountId)); | |||
this.props.dispatch(refreshAccountTimeline(accountId, withReplies)); | |||
} | |||
componentWillReceiveProps (nextProps) { | |||
if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { | |||
this.props.dispatch(fetchAccount(nextProps.params.accountId)); | |||
this.props.dispatch(refreshAccountFeaturedTimeline(nextProps.params.accountId)); | |||
this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies)); | |||
} | |||
} | |||
@@ -53,7 +59,7 @@ export default class AccountTimeline extends ImmutablePureComponent { | |||
} | |||
render () { | |||
const { statusIds, isLoading, hasMore } = this.props; | |||
const { statusIds, featuredStatusIds, isLoading, hasMore } = this.props; | |||
if (!statusIds && isLoading) { | |||
return ( | |||
@@ -71,6 +77,7 @@ export default class AccountTimeline extends ImmutablePureComponent { | |||
prepend={<HeaderContainer accountId={this.props.params.accountId} />} | |||
scrollKey='account_timeline' | |||
statusIds={statusIds} | |||
featuredStatusIds={featuredStatusIds} | |||
isLoading={isLoading} | |||
hasMore={hasMore} | |||
onScrollToBottom={this.handleScrollToBottom} | |||
@@ -80,7 +80,7 @@ export default class Followers extends ImmutablePureComponent { | |||
<ScrollContainer scrollKey='followers'> | |||
<div className='scrollable' onScroll={this.handleScroll}> | |||
<div className='followers'> | |||
<HeaderContainer accountId={this.props.params.accountId} /> | |||
<HeaderContainer accountId={this.props.params.accountId} hideTabs /> | |||
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} | |||
{loadMore} | |||
</div> | |||
@@ -80,7 +80,7 @@ export default class Following extends ImmutablePureComponent { | |||
<ScrollContainer scrollKey='following'> | |||
<div className='scrollable' onScroll={this.handleScroll}> | |||
<div className='following'> | |||
<HeaderContainer accountId={this.props.params.accountId} /> | |||
<HeaderContainer accountId={this.props.params.accountId} hideTabs /> | |||
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} | |||
{loadMore} | |||
</div> | |||
@@ -46,6 +46,10 @@ class ActivityPub::Activity | |||
ActivityPub::Activity::Reject | |||
when 'Flag' | |||
ActivityPub::Activity::Flag | |||
when 'Add' | |||
ActivityPub::Activity::Add | |||
when 'Remove' | |||
ActivityPub::Activity::Remove | |||
end | |||
end | |||
end | |||
@@ -0,0 +1,13 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::Activity::Add < ActivityPub::Activity | |||
def perform | |||
return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url | |||
status = status_from_uri(object_uri) | |||
return unless status.account_id == @account.id && !@account.pinned?(status) | |||
StatusPin.create!(account: @account, status: status) | |||
end | |||
end |
@@ -0,0 +1,14 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::Activity::Remove < ActivityPub::Activity | |||
def perform | |||
return unless @json['origin'].present? && value_or_id(@json['origin']) == @account.featured_collection_url | |||
status = status_from_uri(object_uri) | |||
return unless status.account_id == @account.id | |||
pin = StatusPin.find_by(account: @account, status: status) | |||
pin&.destroy! | |||
end | |||
end |
@@ -18,6 +18,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base | |||
'toot' => 'http://joinmastodon.org/ns#', | |||
'Emoji' => 'toot:Emoji', | |||
'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' }, | |||
'featured' => 'toot:featured', | |||
}, | |||
], | |||
}.freeze | |||
@@ -43,6 +43,7 @@ | |||
# protocol :integer default("ostatus"), not null | |||
# memorial :boolean default(FALSE), not null | |||
# moved_to_account_id :integer | |||
# featured_collection_url :string | |||
# | |||
class Account < ApplicationRecord | |||
@@ -4,7 +4,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer | |||
include RoutingHelper | |||
attributes :id, :type, :following, :followers, | |||
:inbox, :outbox, | |||
:inbox, :outbox, :featured, | |||
:preferred_username, :name, :summary, | |||
:url, :manually_approves_followers | |||
@@ -53,6 +53,10 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer | |||
account_outbox_url(object) | |||
end | |||
def featured | |||
account_collection_url(object, :featured) | |||
end | |||
def endpoints | |||
object | |||
end | |||
@@ -0,0 +1,24 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::AddSerializer < ActiveModel::Serializer | |||
include RoutingHelper | |||
attributes :type, :actor, :target | |||
attribute :proper_object, key: :object | |||
def type | |||
'Add' | |||
end | |||
def actor | |||
ActivityPub::TagManager.instance.uri_for(object.account) | |||
end | |||
def proper_object | |||
ActivityPub::TagManager.instance.uri_for(object) | |||
end | |||
def target | |||
account_collection_url(object, :featured) | |||
end | |||
end |
@@ -2,7 +2,7 @@ | |||
class ActivityPub::CollectionSerializer < ActiveModel::Serializer | |||
def self.serializer_for(model, options) | |||
return ActivityPub::ActivitySerializer if model.class.name == 'Status' | |||
return ActivityPub::NoteSerializer if model.class.name == 'Status' | |||
return ActivityPub::CollectionSerializer if model.class.name == 'ActivityPub::CollectionPresenter' | |||
super | |||
end | |||
@@ -0,0 +1,8 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::OutboxSerializer < ActivityPub::CollectionSerializer | |||
def self.serializer_for(model, options) | |||
return ActivityPub::ActivitySerializer if model.is_a?(Status) | |||
super | |||
end | |||
end |
@@ -0,0 +1,24 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::RemoveSerializer < ActiveModel::Serializer | |||
include RoutingHelper | |||
attributes :type, :actor, :origin | |||
attribute :proper_object, key: :object | |||
def type | |||
'Remove' | |||
end | |||
def actor | |||
ActivityPub::TagManager.instance.uri_for(object.account) | |||
end | |||
def proper_object | |||
ActivityPub::TagManager.instance.uri_for(object) | |||
end | |||
def origin | |||
account_collection_url(object, :featured) | |||
end | |||
end |
@@ -0,0 +1,52 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::FetchFeaturedCollectionService < BaseService | |||
include JsonLdHelper | |||
def call(account) | |||
@account = account | |||
@json = fetch_resource(@account.featured_collection_url, true) | |||
return unless supported_context? | |||
return if @account.suspended? || @account.local? | |||
case @json['type'] | |||
when 'Collection', 'CollectionPage' | |||
process_items @json['items'] | |||
when 'OrderedCollection', 'OrderedCollectionPage' | |||
process_items @json['orderedItems'] | |||
end | |||
end | |||
private | |||
def process_items(items) | |||
status_ids = items.map { |item| value_or_id(item) } | |||
.reject { |uri| ActivityPub::TagManager.instance.local_uri?(uri) } | |||
.map { |uri| ActivityPub::FetchRemoteStatusService.new.call(uri) } | |||
.compact | |||
.select { |status| status.account_id == @account.id } | |||
.map(&:id) | |||
to_remove = [] | |||
to_add = status_ids | |||
StatusPin.where(account: @account).pluck(:status_id).each do |status_id| | |||
if status_ids.include?(status_id) | |||
to_add.delete(status_id) | |||
else | |||
to_remove << status_id | |||
end | |||
end | |||
StatusPin.where(account: @account, status_id: to_remove).delete_all unless to_remove.empty? | |||
to_add.each do |status_id| | |||
StatusPin.create!(account: @account, status_id: status_id) | |||
end | |||
end | |||
def supported_context? | |||
super(@json) | |||
end | |||
end |
@@ -27,6 +27,7 @@ class ActivityPub::ProcessAccountService < BaseService | |||
after_protocol_change! if protocol_changed? | |||
after_key_change! if key_changed? | |||
check_featured_collection! if @account.featured_collection_url.present? | |||
@account | |||
rescue Oj::ParseError | |||
@@ -57,14 +58,15 @@ class ActivityPub::ProcessAccountService < BaseService | |||
end | |||
def set_immediate_attributes! | |||
@account.inbox_url = @json['inbox'] || '' | |||
@account.outbox_url = @json['outbox'] || '' | |||
@account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || '' | |||
@account.followers_url = @json['followers'] || '' | |||
@account.url = url || @uri | |||
@account.display_name = @json['name'] || '' | |||
@account.note = @json['summary'] || '' | |||
@account.locked = @json['manuallyApprovesFollowers'] || false | |||
@account.inbox_url = @json['inbox'] || '' | |||
@account.outbox_url = @json['outbox'] || '' | |||
@account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || '' | |||
@account.followers_url = @json['followers'] || '' | |||
@account.featured_collection_url = @json['featured'] || '' | |||
@account.url = url || @uri | |||
@account.display_name = @json['name'] || '' | |||
@account.note = @json['summary'] || '' | |||
@account.locked = @json['manuallyApprovesFollowers'] || false | |||
end | |||
def set_fetchable_attributes! | |||
@@ -85,6 +87,10 @@ class ActivityPub::ProcessAccountService < BaseService | |||
RefollowWorker.perform_async(@account.id) | |||
end | |||
def check_featured_collection! | |||
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id) | |||
end | |||
def image_url(key) | |||
value = first_of_value(@json[key]) | |||
@@ -0,0 +1,13 @@ | |||
# frozen_string_literal: true | |||
class ActivityPub::SynchronizeFeaturedCollectionWorker | |||
include Sidekiq::Worker | |||
sidekiq_options queue: 'pull' | |||
def perform(account_id) | |||
ActivityPub::FetchFeaturedCollectionService.new.call(Account.find(account_id)) | |||
rescue ActiveRecord::RecordNotFound | |||
true | |||
end | |||
end |
@@ -58,8 +58,10 @@ Rails.application.routes.draw do | |||
resources :following, only: [:index], controller: :following_accounts | |||
resource :follow, only: [:create], controller: :account_follow | |||
resource :unfollow, only: [:create], controller: :account_unfollow | |||
resource :outbox, only: [:show], module: :activitypub | |||
resource :inbox, only: [:create], module: :activitypub | |||
resources :collections, only: [:show], module: :activitypub | |||
end | |||
resource :inbox, only: [:create], module: :activitypub | |||
@@ -0,0 +1,5 @@ | |||
class AddFeaturedCollectionUrlToAccounts < ActiveRecord::Migration[5.1] | |||
def change | |||
add_column :accounts, :featured_collection_url, :string | |||
end | |||
end |
@@ -10,7 +10,7 @@ | |||
# | |||
# It's strongly recommended that you check this file into your version control system. | |||
ActiveRecord::Schema.define(version: 20180211015820) do | |||
ActiveRecord::Schema.define(version: 20180304013859) do | |||
# These are extensions that must be enabled in order to support this database | |||
enable_extension "plpgsql" | |||
@@ -73,6 +73,7 @@ ActiveRecord::Schema.define(version: 20180211015820) do | |||
t.integer "protocol", default: 0, null: false | |||
t.boolean "memorial", default: false, null: false | |||
t.bigint "moved_to_account_id" | |||
t.string "featured_collection_url" | |||
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin | |||
t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower" | |||
t.index ["uri"], name: "index_accounts_on_uri" | |||
@@ -0,0 +1,29 @@ | |||
require 'rails_helper' | |||
RSpec.describe ActivityPub::Activity::Add do | |||
let(:sender) { Fabricate(:account, featured_collection_url: 'https://example.com/featured') } | |||
let(:status) { Fabricate(:status, account: sender) } | |||
let(:json) do | |||
{ | |||
'@context': 'https://www.w3.org/ns/activitystreams', | |||
id: 'foo', | |||
type: 'Add', | |||
actor: ActivityPub::TagManager.instance.uri_for(sender), | |||
object: ActivityPub::TagManager.instance.uri_for(status), | |||
target: sender.featured_collection_url, | |||
}.with_indifferent_access | |||
end | |||
describe '#perform' do | |||
subject { described_class.new(json, sender) } | |||
before do | |||
subject.perform | |||
end | |||
it 'creates a pin' do | |||
expect(sender.pinned?(status)).to be true | |||
end | |||
end | |||
end |
@@ -0,0 +1,30 @@ | |||
require 'rails_helper' | |||
RSpec.describe ActivityPub::Activity::Remove do | |||
let(:sender) { Fabricate(:account, featured_collection_url: 'https://example.com/featured') } | |||
let(:status) { Fabricate(:status, account: sender) } | |||
let(:json) do | |||
{ | |||
'@context': 'https://www.w3.org/ns/activitystreams', | |||
id: 'foo', | |||
type: 'Add', | |||
actor: ActivityPub::TagManager.instance.uri_for(sender), | |||
object: ActivityPub::TagManager.instance.uri_for(status), | |||
origin: sender.featured_collection_url, | |||
}.with_indifferent_access | |||
end | |||
describe '#perform' do | |||
subject { described_class.new(json, sender) } | |||
before do | |||
StatusPin.create!(account: sender, status: status) | |||
subject.perform | |||
end | |||
it 'removes a pin' do | |||
expect(sender.pinned?(status)).to be false | |||
end | |||
end | |||
end |
@@ -7,6 +7,7 @@ RSpec.describe ActivityPub::Activity::Update do | |||
stub_request(:get, actor_json[:outbox]).to_return(status: 404) | |||
stub_request(:get, actor_json[:followers]).to_return(status: 404) | |||
stub_request(:get, actor_json[:following]).to_return(status: 404) | |||
stub_request(:get, actor_json[:featured]).to_return(status: 404) | |||
sender.update!(uri: ActivityPub::TagManager.instance.uri_for(sender)) | |||
end | |||