* 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 = @account.statuses.permitted_for(@account, signed_request_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) | ||||
@statuses = cache_collection(@statuses, Status) | @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 | end | ||||
private | private | ||||
@@ -11,12 +11,18 @@ class Api::V1::Statuses::PinsController < Api::BaseController | |||||
def create | def create | ||||
StatusPin.create!(account: current_account, status: @status) | StatusPin.create!(account: current_account, status: @status) | ||||
distribute_add_activity! | |||||
render json: @status, serializer: REST::StatusSerializer | render json: @status, serializer: REST::StatusSerializer | ||||
end | end | ||||
def destroy | def destroy | ||||
pin = StatusPin.find_by(account: current_account, status: @status) | 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 | render json: @status, serializer: REST::StatusSerializer | ||||
end | end | ||||
@@ -25,4 +31,24 @@ class Api::V1::Statuses::PinsController < Api::BaseController | |||||
def set_status | def set_status | ||||
@status = Status.find(params[:status_id]) | @status = Status.find(params[:status_id]) | ||||
end | 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 | 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) { | export function refreshTimelineFail(timeline, error, skipLoading) { | ||||
return { | return { | ||||
@@ -138,7 +138,7 @@ export default class Status extends ImmutablePureComponent { | |||||
let media = null; | let media = null; | ||||
let statusAvatar, prepend; | let statusAvatar, prepend; | ||||
const { hidden } = this.props; | |||||
const { hidden, featured } = this.props; | |||||
const { isExpanded } = this.state; | const { isExpanded } = this.state; | ||||
let { status, account, ...other } = this.props; | 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']) }; | const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; | ||||
prepend = ( | prepend = ( | ||||
@@ -11,6 +11,7 @@ export default class StatusList extends ImmutablePureComponent { | |||||
static propTypes = { | static propTypes = { | ||||
scrollKey: PropTypes.string.isRequired, | scrollKey: PropTypes.string.isRequired, | ||||
statusIds: ImmutablePropTypes.list.isRequired, | statusIds: ImmutablePropTypes.list.isRequired, | ||||
featuredStatusIds: ImmutablePropTypes.list, | |||||
onScrollToBottom: PropTypes.func, | onScrollToBottom: PropTypes.func, | ||||
onScrollToTop: PropTypes.func, | onScrollToTop: PropTypes.func, | ||||
onScroll: PropTypes.func, | onScroll: PropTypes.func, | ||||
@@ -50,7 +51,7 @@ export default class StatusList extends ImmutablePureComponent { | |||||
} | } | ||||
render () { | render () { | ||||
const { statusIds, ...other } = this.props; | |||||
const { statusIds, featuredStatusIds, ...other } = this.props; | |||||
const { isLoading, isPartial } = other; | const { isLoading, isPartial } = other; | ||||
if (isPartial) { | 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 | <StatusContainer | ||||
key={statusId} | key={statusId} | ||||
id={statusId} | id={statusId} | ||||
@@ -79,6 +80,18 @@ export default class StatusList extends ImmutablePureComponent { | |||||
)) | )) | ||||
) : null; | ) : null; | ||||
if (scrollableContent && featuredStatusIds) { | |||||
scrollableContent = featuredStatusIds.map(statusId => ( | |||||
<StatusContainer | |||||
key={`f-${statusId}`} | |||||
id={statusId} | |||||
featured | |||||
onMoveUp={this.handleMoveUp} | |||||
onMoveDown={this.handleMoveDown} | |||||
/> | |||||
)).concat(scrollableContent); | |||||
} | |||||
return ( | return ( | ||||
<ScrollableList {...other} ref={this.setRef}> | <ScrollableList {...other} ref={this.setRef}> | ||||
{scrollableContent} | {scrollableContent} | ||||
@@ -21,6 +21,7 @@ export default class Header extends ImmutablePureComponent { | |||||
onMute: PropTypes.func.isRequired, | onMute: PropTypes.func.isRequired, | ||||
onBlockDomain: PropTypes.func.isRequired, | onBlockDomain: PropTypes.func.isRequired, | ||||
onUnblockDomain: PropTypes.func.isRequired, | onUnblockDomain: PropTypes.func.isRequired, | ||||
hideTabs: PropTypes.bool, | |||||
}; | }; | ||||
static contextTypes = { | static contextTypes = { | ||||
@@ -68,7 +69,7 @@ export default class Header extends ImmutablePureComponent { | |||||
} | } | ||||
render () { | render () { | ||||
const { account } = this.props; | |||||
const { account, hideTabs } = this.props; | |||||
if (account === null) { | if (account === null) { | ||||
return <MissingIndicator />; | return <MissingIndicator />; | ||||
@@ -94,11 +95,13 @@ export default class Header extends ImmutablePureComponent { | |||||
onUnblockDomain={this.handleUnblockDomain} | 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> | </div> | ||||
); | ); | ||||
} | } | ||||
@@ -3,7 +3,7 @@ import { connect } from 'react-redux'; | |||||
import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||
import { fetchAccount } from '../../actions/accounts'; | 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 StatusList from '../../components/status_list'; | ||||
import LoadingIndicator from '../../components/loading_indicator'; | import LoadingIndicator from '../../components/loading_indicator'; | ||||
import Column from '../ui/components/column'; | import Column from '../ui/components/column'; | ||||
@@ -17,6 +17,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) | |||||
return { | return { | ||||
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), | statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()), | ||||
featuredStatusIds: state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), | |||||
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), | isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), | ||||
hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']), | hasMore: !!state.getIn(['timelines', `account:${path}`, 'next']), | ||||
}; | }; | ||||
@@ -29,19 +30,24 @@ export default class AccountTimeline extends ImmutablePureComponent { | |||||
params: PropTypes.object.isRequired, | params: PropTypes.object.isRequired, | ||||
dispatch: PropTypes.func.isRequired, | dispatch: PropTypes.func.isRequired, | ||||
statusIds: ImmutablePropTypes.list, | statusIds: ImmutablePropTypes.list, | ||||
featuredStatusIds: ImmutablePropTypes.list, | |||||
isLoading: PropTypes.bool, | isLoading: PropTypes.bool, | ||||
hasMore: PropTypes.bool, | hasMore: PropTypes.bool, | ||||
withReplies: PropTypes.bool, | withReplies: PropTypes.bool, | ||||
}; | }; | ||||
componentWillMount () { | 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) { | componentWillReceiveProps (nextProps) { | ||||
if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) { | 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(fetchAccount(nextProps.params.accountId)); | ||||
this.props.dispatch(refreshAccountFeaturedTimeline(nextProps.params.accountId)); | |||||
this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies)); | this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId, nextProps.params.withReplies)); | ||||
} | } | ||||
} | } | ||||
@@ -53,7 +59,7 @@ export default class AccountTimeline extends ImmutablePureComponent { | |||||
} | } | ||||
render () { | render () { | ||||
const { statusIds, isLoading, hasMore } = this.props; | |||||
const { statusIds, featuredStatusIds, isLoading, hasMore } = this.props; | |||||
if (!statusIds && isLoading) { | if (!statusIds && isLoading) { | ||||
return ( | return ( | ||||
@@ -71,6 +77,7 @@ export default class AccountTimeline extends ImmutablePureComponent { | |||||
prepend={<HeaderContainer accountId={this.props.params.accountId} />} | prepend={<HeaderContainer accountId={this.props.params.accountId} />} | ||||
scrollKey='account_timeline' | scrollKey='account_timeline' | ||||
statusIds={statusIds} | statusIds={statusIds} | ||||
featuredStatusIds={featuredStatusIds} | |||||
isLoading={isLoading} | isLoading={isLoading} | ||||
hasMore={hasMore} | hasMore={hasMore} | ||||
onScrollToBottom={this.handleScrollToBottom} | onScrollToBottom={this.handleScrollToBottom} | ||||
@@ -80,7 +80,7 @@ export default class Followers extends ImmutablePureComponent { | |||||
<ScrollContainer scrollKey='followers'> | <ScrollContainer scrollKey='followers'> | ||||
<div className='scrollable' onScroll={this.handleScroll}> | <div className='scrollable' onScroll={this.handleScroll}> | ||||
<div className='followers'> | <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} />)} | {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} | ||||
{loadMore} | {loadMore} | ||||
</div> | </div> | ||||
@@ -80,7 +80,7 @@ export default class Following extends ImmutablePureComponent { | |||||
<ScrollContainer scrollKey='following'> | <ScrollContainer scrollKey='following'> | ||||
<div className='scrollable' onScroll={this.handleScroll}> | <div className='scrollable' onScroll={this.handleScroll}> | ||||
<div className='following'> | <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} />)} | {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} | ||||
{loadMore} | {loadMore} | ||||
</div> | </div> | ||||
@@ -46,6 +46,10 @@ class ActivityPub::Activity | |||||
ActivityPub::Activity::Reject | ActivityPub::Activity::Reject | ||||
when 'Flag' | when 'Flag' | ||||
ActivityPub::Activity::Flag | ActivityPub::Activity::Flag | ||||
when 'Add' | |||||
ActivityPub::Activity::Add | |||||
when 'Remove' | |||||
ActivityPub::Activity::Remove | |||||
end | end | ||||
end | 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#', | 'toot' => 'http://joinmastodon.org/ns#', | ||||
'Emoji' => 'toot:Emoji', | 'Emoji' => 'toot:Emoji', | ||||
'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' }, | 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' }, | ||||
'featured' => 'toot:featured', | |||||
}, | }, | ||||
], | ], | ||||
}.freeze | }.freeze | ||||
@@ -43,6 +43,7 @@ | |||||
# protocol :integer default("ostatus"), not null | # protocol :integer default("ostatus"), not null | ||||
# memorial :boolean default(FALSE), not null | # memorial :boolean default(FALSE), not null | ||||
# moved_to_account_id :integer | # moved_to_account_id :integer | ||||
# featured_collection_url :string | |||||
# | # | ||||
class Account < ApplicationRecord | class Account < ApplicationRecord | ||||
@@ -4,7 +4,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer | |||||
include RoutingHelper | include RoutingHelper | ||||
attributes :id, :type, :following, :followers, | attributes :id, :type, :following, :followers, | ||||
:inbox, :outbox, | |||||
:inbox, :outbox, :featured, | |||||
:preferred_username, :name, :summary, | :preferred_username, :name, :summary, | ||||
:url, :manually_approves_followers | :url, :manually_approves_followers | ||||
@@ -53,6 +53,10 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer | |||||
account_outbox_url(object) | account_outbox_url(object) | ||||
end | end | ||||
def featured | |||||
account_collection_url(object, :featured) | |||||
end | |||||
def endpoints | def endpoints | ||||
object | object | ||||
end | 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 | class ActivityPub::CollectionSerializer < ActiveModel::Serializer | ||||
def self.serializer_for(model, options) | 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' | return ActivityPub::CollectionSerializer if model.class.name == 'ActivityPub::CollectionPresenter' | ||||
super | super | ||||
end | 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_protocol_change! if protocol_changed? | ||||
after_key_change! if key_changed? | after_key_change! if key_changed? | ||||
check_featured_collection! if @account.featured_collection_url.present? | |||||
@account | @account | ||||
rescue Oj::ParseError | rescue Oj::ParseError | ||||
@@ -57,14 +58,15 @@ class ActivityPub::ProcessAccountService < BaseService | |||||
end | end | ||||
def set_immediate_attributes! | 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 | end | ||||
def set_fetchable_attributes! | def set_fetchable_attributes! | ||||
@@ -85,6 +87,10 @@ class ActivityPub::ProcessAccountService < BaseService | |||||
RefollowWorker.perform_async(@account.id) | RefollowWorker.perform_async(@account.id) | ||||
end | end | ||||
def check_featured_collection! | |||||
ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id) | |||||
end | |||||
def image_url(key) | def image_url(key) | ||||
value = first_of_value(@json[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 | resources :following, only: [:index], controller: :following_accounts | ||||
resource :follow, only: [:create], controller: :account_follow | resource :follow, only: [:create], controller: :account_follow | ||||
resource :unfollow, only: [:create], controller: :account_unfollow | resource :unfollow, only: [:create], controller: :account_unfollow | ||||
resource :outbox, only: [:show], module: :activitypub | resource :outbox, only: [:show], module: :activitypub | ||||
resource :inbox, only: [:create], module: :activitypub | resource :inbox, only: [:create], module: :activitypub | ||||
resources :collections, only: [:show], module: :activitypub | |||||
end | end | ||||
resource :inbox, only: [:create], module: :activitypub | 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. | # 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 | # These are extensions that must be enabled in order to support this database | ||||
enable_extension "plpgsql" | enable_extension "plpgsql" | ||||
@@ -73,6 +73,7 @@ ActiveRecord::Schema.define(version: 20180211015820) do | |||||
t.integer "protocol", default: 0, null: false | t.integer "protocol", default: 0, null: false | ||||
t.boolean "memorial", default: false, null: false | t.boolean "memorial", default: false, null: false | ||||
t.bigint "moved_to_account_id" | 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 "(((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 "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower" | ||||
t.index ["uri"], name: "index_accounts_on_uri" | 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[:outbox]).to_return(status: 404) | ||||
stub_request(:get, actor_json[:followers]).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[:following]).to_return(status: 404) | ||||
stub_request(:get, actor_json[:featured]).to_return(status: 404) | |||||
sender.update!(uri: ActivityPub::TagManager.instance.uri_for(sender)) | sender.update!(uri: ActivityPub::TagManager.instance.uri_for(sender)) | ||||
end | end | ||||