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
29 changed files with 383 additions and 37 deletions
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 = @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 refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
|
||||||
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
|
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
|
||||||
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
|
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 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 refreshAccountFeaturedTimeline = accountId => refreshTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
|
||||||
export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
|
export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
|
||||||
export const refreshListTimeline = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
|
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) ? (
|
let scrollableContent = (isLoading || statusIds.size > 0) ? (
|
||||||
statusIds.map((statusId) => (
|
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'>
|
{!hideTabs && (
|
||||||
<NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
|
<div className='account__section-headline'>
|
||||||
<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')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
|
||||||
<NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
|
<NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>
|
||||||
</div>
|
<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));
|
const { params: { accountId }, withReplies } = this.props;
|
||||||
this.props.dispatch(refreshAccountTimeline(this.props.params.accountId, this.props.withReplies));
|
|
||||||
|
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
|
||||||
|
|
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#',
|
'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
|
||||||
|
|
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
|
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
|
||||||
|
|
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_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.inbox_url = @json['inbox'] || ''
|
||||||
@account.outbox_url = @json['outbox'] || ''
|
@account.outbox_url = @json['outbox'] || ''
|
||||||
@account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || ''
|
@account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || ''
|
||||||
@account.followers_url = @json['followers'] || ''
|
@account.followers_url = @json['followers'] || ''
|
||||||
@account.url = url || @uri
|
@account.featured_collection_url = @json['featured'] || ''
|
||||||
@account.display_name = @json['name'] || ''
|
@account.url = url || @uri
|
||||||
@account.note = @json['summary'] || ''
|
@account.display_name = @json['name'] || ''
|
||||||
@account.locked = @json['manuallyApprovesFollowers'] || false
|
@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"
|
||||||
|
|
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[: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
|
||||||
|
|
Loading…
Reference in a new issue