--- /dev/null
+# 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
@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
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
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
};
};
-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 {
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;
);
}
- 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 = (
static propTypes = {
scrollKey: PropTypes.string.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
+ featuredStatusIds: ImmutablePropTypes.list,
onScrollToBottom: PropTypes.func,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
}
render () {
- const { statusIds, ...other } = this.props;
+ const { statusIds, featuredStatusIds, ...other } = this.props;
const { isLoading, isPartial } = other;
if (isPartial) {
);
}
- const scrollableContent = (isLoading || statusIds.size > 0) ? (
- statusIds.map((statusId) => (
+ let scrollableContent = (isLoading || statusIds.size > 0) ? (
+ statusIds.map(statusId => (
<StatusContainer
key={statusId}
id={statusId}
))
) : 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}
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired,
+ hideTabs: PropTypes.bool,
};
static contextTypes = {
}
render () {
- const { account } = this.props;
+ const { account, hideTabs } = this.props;
if (account === null) {
return <MissingIndicator />;
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>
);
}
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';
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']),
};
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));
}
}
}
render () {
- const { statusIds, isLoading, hasMore } = this.props;
+ const { statusIds, featuredStatusIds, isLoading, hasMore } = this.props;
if (!statusIds && isLoading) {
return (
prepend={<HeaderContainer accountId={this.props.params.accountId} />}
scrollKey='account_timeline'
statusIds={statusIds}
+ featuredStatusIds={featuredStatusIds}
isLoading={isLoading}
hasMore={hasMore}
onScrollToBottom={this.handleScrollToBottom}
<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>
<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>
ActivityPub::Activity::Reject
when 'Flag'
ActivityPub::Activity::Flag
+ when 'Add'
+ ActivityPub::Activity::Add
+ when 'Remove'
+ ActivityPub::Activity::Remove
end
end
end
--- /dev/null
+# 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
--- /dev/null
+# 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
'toot' => 'http://joinmastodon.org/ns#',
'Emoji' => 'toot:Emoji',
'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' },
+ 'featured' => 'toot:featured',
},
],
}.freeze
# protocol :integer default("ostatus"), not null
# memorial :boolean default(FALSE), not null
# moved_to_account_id :integer
+# featured_collection_url :string
#
class Account < ApplicationRecord
include RoutingHelper
attributes :id, :type, :following, :followers,
- :inbox, :outbox,
+ :inbox, :outbox, :featured,
:preferred_username, :name, :summary,
:url, :manually_approves_followers
account_outbox_url(object)
end
+ def featured
+ account_collection_url(object, :featured)
+ end
+
def endpoints
object
end
--- /dev/null
+# 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
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
--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+# 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
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
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!
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])
--- /dev/null
+# 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
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
--- /dev/null
+class AddFeaturedCollectionUrlToAccounts < ActiveRecord::Migration[5.1]
+ def change
+ add_column :accounts, :featured_collection_url, :string
+ end
+end
#
# 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"
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"
--- /dev/null
+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
--- /dev/null
+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
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