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
This commit is contained in:
parent
45feb439bd
commit
9110db41c5
57
app/controllers/activitypub/collections_controller.rb
Normal file
57
app/controllers/activitypub/collections_controller.rb
Normal 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
|
@ -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
|
||||
|
13
app/lib/activitypub/activity/add.rb
Normal file
13
app/lib/activitypub/activity/add.rb
Normal 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
app/lib/activitypub/activity/remove.rb
Normal file
14
app/lib/activitypub/activity/remove.rb
Normal 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
|
@ -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
|
||||
|
24
app/serializers/activitypub/add_serializer.rb
Normal file
24
app/serializers/activitypub/add_serializer.rb
Normal 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
|
@ -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
app/serializers/activitypub/outbox_serializer.rb
Normal file
8
app/serializers/activitypub/outbox_serializer.rb
Normal 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
app/serializers/activitypub/remove_serializer.rb
Normal file
24
app/serializers/activitypub/remove_serializer.rb
Normal 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
|
@ -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"
|
||||
|
29
spec/lib/activitypub/activity/add_spec.rb
Normal file
29
spec/lib/activitypub/activity/add_spec.rb
Normal 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
spec/lib/activitypub/activity/remove_spec.rb
Normal file
30
spec/lib/activitypub/activity/remove_spec.rb
Normal 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
|
@ -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…
Reference in New Issue
Block a user