commit: 9110db41c53a2f3f22affc23b364362133997d3e
parent: 45feb439bd22c0999b8531879461e8d18fabe8a5
Author: Eugen Rochko <eugen@zeonfederated.com>
Date: Sun, 4 Mar 2018 09:19:11 +0100
Federate pinned statuses over ActivityPub (#6610)
* 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.rb
Diffstat:
29 files changed, 383 insertions(+), 37 deletions(-)
diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb
@@ -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
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
@@ -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
diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb
@@ -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
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
@@ -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 {
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
@@ -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 = (
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
@@ -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}
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -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>
);
}
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
@@ -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}
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
@@ -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>
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
@@ -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>
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
@@ -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
diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb
@@ -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
diff --git a/app/lib/activitypub/activity/remove.rb b/app/lib/activitypub/activity/remove.rb
@@ -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
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
@@ -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
diff --git a/app/models/account.rb b/app/models/account.rb
@@ -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
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
@@ -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
diff --git a/app/serializers/activitypub/add_serializer.rb b/app/serializers/activitypub/add_serializer.rb
@@ -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
diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb
@@ -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
diff --git a/app/serializers/activitypub/outbox_serializer.rb b/app/serializers/activitypub/outbox_serializer.rb
@@ -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
diff --git a/app/serializers/activitypub/remove_serializer.rb b/app/serializers/activitypub/remove_serializer.rb
@@ -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
diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb
@@ -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
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
@@ -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])
diff --git a/app/workers/activitypub/synchronize_featured_collection_worker.rb b/app/workers/activitypub/synchronize_featured_collection_worker.rb
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
@@ -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
diff --git a/db/migrate/20180304013859_add_featured_collection_url_to_accounts.rb b/db/migrate/20180304013859_add_featured_collection_url_to_accounts.rb
@@ -0,0 +1,5 @@
+class AddFeaturedCollectionUrlToAccounts < ActiveRecord::Migration[5.1]
+ def change
+ add_column :accounts, :featured_collection_url, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 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"
diff --git a/spec/lib/activitypub/activity/add_spec.rb b/spec/lib/activitypub/activity/add_spec.rb
@@ -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
diff --git a/spec/lib/activitypub/activity/remove_spec.rb b/spec/lib/activitypub/activity/remove_spec.rb
@@ -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
diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb
@@ -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