Browse Source

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
master
Eugen Rochko 6 years ago
committed by GitHub
parent
commit
9110db41c5
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 383 additions and 37 deletions
  1. +57
    -0
      app/controllers/activitypub/collections_controller.rb
  2. +1
    -1
      app/controllers/activitypub/outboxes_controller.rb
  3. +27
    -1
      app/controllers/api/v1/statuses/pins_controller.rb
  4. +8
    -7
      app/javascript/mastodon/actions/timelines.js
  5. +9
    -2
      app/javascript/mastodon/components/status.js
  6. +16
    -3
      app/javascript/mastodon/components/status_list.js
  7. +9
    -6
      app/javascript/mastodon/features/account_timeline/components/header.js
  8. +11
    -4
      app/javascript/mastodon/features/account_timeline/index.js
  9. +1
    -1
      app/javascript/mastodon/features/followers/index.js
  10. +1
    -1
      app/javascript/mastodon/features/following/index.js
  11. +4
    -0
      app/lib/activitypub/activity.rb
  12. +13
    -0
      app/lib/activitypub/activity/add.rb
  13. +14
    -0
      app/lib/activitypub/activity/remove.rb
  14. +1
    -0
      app/lib/activitypub/adapter.rb
  15. +1
    -0
      app/models/account.rb
  16. +5
    -1
      app/serializers/activitypub/actor_serializer.rb
  17. +24
    -0
      app/serializers/activitypub/add_serializer.rb
  18. +1
    -1
      app/serializers/activitypub/collection_serializer.rb
  19. +8
    -0
      app/serializers/activitypub/outbox_serializer.rb
  20. +24
    -0
      app/serializers/activitypub/remove_serializer.rb
  21. +52
    -0
      app/services/activitypub/fetch_featured_collection_service.rb
  22. +14
    -8
      app/services/activitypub/process_account_service.rb
  23. +13
    -0
      app/workers/activitypub/synchronize_featured_collection_worker.rb
  24. +2
    -0
      config/routes.rb
  25. +5
    -0
      db/migrate/20180304013859_add_featured_collection_url_to_accounts.rb
  26. +2
    -1
      db/schema.rb
  27. +29
    -0
      spec/lib/activitypub/activity/add_spec.rb
  28. +30
    -0
      spec/lib/activitypub/activity/remove_spec.rb
  29. +1
    -0
      spec/lib/activitypub/activity/update_spec.rb

+ 57
- 0
app/controllers/activitypub/collections_controller.rb View File

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

+ 1
- 1
app/controllers/activitypub/outboxes_controller.rb View File

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


+ 27
- 1
app/controllers/api/v1/statuses/pins_controller.rb View File

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

+ 8
- 7
app/javascript/mastodon/actions/timelines.js View File

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


+ 9
- 2
app/javascript/mastodon/components/status.js View File

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


+ 16
- 3
app/javascript/mastodon/components/status_list.js View File

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


+ 9
- 6
app/javascript/mastodon/features/account_timeline/components/header.js View File

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


+ 11
- 4
app/javascript/mastodon/features/account_timeline/index.js View File

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


+ 1
- 1
app/javascript/mastodon/features/followers/index.js View File

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


+ 1
- 1
app/javascript/mastodon/features/following/index.js View File

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


+ 4
- 0
app/lib/activitypub/activity.rb View File

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


+ 13
- 0
app/lib/activitypub/activity/add.rb View File

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

+ 14
- 0
app/lib/activitypub/activity/remove.rb View File

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

+ 1
- 0
app/lib/activitypub/adapter.rb View File

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


+ 1
- 0
app/models/account.rb View File

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


+ 5
- 1
app/serializers/activitypub/actor_serializer.rb View File

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


+ 24
- 0
app/serializers/activitypub/add_serializer.rb View File

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

+ 1
- 1
app/serializers/activitypub/collection_serializer.rb View File

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


+ 8
- 0
app/serializers/activitypub/outbox_serializer.rb View File

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

+ 24
- 0
app/serializers/activitypub/remove_serializer.rb View File

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

+ 52
- 0
app/services/activitypub/fetch_featured_collection_service.rb View File

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

+ 14
- 8
app/services/activitypub/process_account_service.rb View File

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



+ 13
- 0
app/workers/activitypub/synchronize_featured_collection_worker.rb View File

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

+ 2
- 0
config/routes.rb View File

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


+ 5
- 0
db/migrate/20180304013859_add_featured_collection_url_to_accounts.rb View File

@@ -0,0 +1,5 @@
class AddFeaturedCollectionUrlToAccounts < ActiveRecord::Migration[5.1]
def change
add_column :accounts, :featured_collection_url, :string
end
end

+ 2
- 1
db/schema.rb View File

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


+ 29
- 0
spec/lib/activitypub/activity/add_spec.rb View File

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

+ 30
- 0
spec/lib/activitypub/activity/remove_spec.rb View File

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

+ 1
- 0
spec/lib/activitypub/activity/update_spec.rb View File

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


Loading…
Cancel
Save