Conflicts: - `app/lib/activitypub/activity/create.rb`: Upstream refactored how `Create` activities are handled and how values are extracted from `Create`d objects. This conflicted with how glitch-soc supported the `directMessage` flag to explicitly distinguish between limited and direct messages. Ported glitch-soc's changes to latest upstream changes. - `app/services/fan_out_on_write_service.rb`: Upstream largely refactored that file and changed some of the logic. This conflicted with glitch-soc's handling of the direct timeline and the options to allow replies and boosts in public feeds. Ported those glitch-soc changes on top of latest upstream changes. - `app/services/process_mentions_service.rb`: Upstream refactored to move mention-related ActivityPub deliveries to `ActivityPub::DeliveryWorker`, while glitch-soc contained an extra check to not send local-only toots to remote mentioned users. Took upstream's version, as the check is not needed anymore, since it is performed at the `ActivityPub::DeliveryWorker` call site already. - `app/workers/feed_insert_worker.rb`: Upstream added support for `update` toot events, while glitch-soc had support for an extra timeline support, `direct`. Ported upstream changes and extended them to the `direct` timeline. Additional changes: - `app/lib/activitypub/parser/status_parser.rb`: Added code to handle the `directMessage` flag and take it into account to compute visibility. - `app/lib/feed_manager.rb`: Extended upstream's support of `update` toot events to glitch-soc's `direct` timeline.main
commit
fe89554a54
@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Statuses::HistoriesController < Api::BaseController
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
|
before_action :set_status
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: @status.edits, each_serializer: REST::StatusEditSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_status
|
||||||
|
@status = Status.find(params[:status_id])
|
||||||
|
authorize @status, :show?
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
not_found
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Statuses::SourcesController < Api::BaseController
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
||||||
|
before_action :set_status
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: @status, serializer: REST::StatusSourceSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_status
|
||||||
|
@status = Status.find(params[:status_id])
|
||||||
|
authorize @status, :show?
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
not_found
|
||||||
|
end
|
||||||
|
end
|
@ -1,32 +1,31 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::Activity::Update < ActivityPub::Activity
|
class ActivityPub::Activity::Update < ActivityPub::Activity
|
||||||
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
|
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
dereference_object!
|
dereference_object!
|
||||||
|
|
||||||
if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
|
if equals_or_includes_any?(@object['type'], %w(Application Group Organization Person Service))
|
||||||
update_account
|
update_account
|
||||||
elsif equals_or_includes_any?(@object['type'], %w(Question))
|
elsif equals_or_includes_any?(@object['type'], %w(Note Question))
|
||||||
update_poll
|
update_status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def update_account
|
def update_account
|
||||||
return if @account.uri != object_uri
|
return reject_payload! if @account.uri != object_uri
|
||||||
|
|
||||||
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
|
ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_poll
|
def update_status
|
||||||
return reject_payload! if invalid_origin?(@object['id'])
|
return reject_payload! if invalid_origin?(@object['id'])
|
||||||
|
|
||||||
status = Status.find_by(uri: object_uri, account_id: @account.id)
|
status = Status.find_by(uri: object_uri, account_id: @account.id)
|
||||||
return if status.nil? || status.preloadable_poll.nil?
|
|
||||||
|
|
||||||
ActivityPub::ProcessPollService.new.call(status.preloadable_poll, @object)
|
return if status.nil?
|
||||||
|
|
||||||
|
ActivityPub::ProcessStatusUpdateService.new.call(status, @object)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Parser::CustomEmojiParser
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
def initialize(json)
|
||||||
|
@json = json
|
||||||
|
end
|
||||||
|
|
||||||
|
def uri
|
||||||
|
@json['id']
|
||||||
|
end
|
||||||
|
|
||||||
|
def shortcode
|
||||||
|
@json['name']&.delete(':')
|
||||||
|
end
|
||||||
|
|
||||||
|
def image_remote_url
|
||||||
|
@json.dig('icon', 'url')
|
||||||
|
end
|
||||||
|
|
||||||
|
def updated_at
|
||||||
|
@json['updated']&.to_datetime
|
||||||
|
rescue ArgumentError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,58 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Parser::MediaAttachmentParser
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
def initialize(json)
|
||||||
|
@json = json
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param [MediaAttachment] previous_record
|
||||||
|
def significantly_changes?(previous_record)
|
||||||
|
remote_url != previous_record.remote_url ||
|
||||||
|
thumbnail_remote_url != previous_record.thumbnail_remote_url ||
|
||||||
|
description != previous_record.description
|
||||||
|
end
|
||||||
|
|
||||||
|
def remote_url
|
||||||
|
Addressable::URI.parse(@json['url'])&.normalize&.to_s
|
||||||
|
rescue Addressable::URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def thumbnail_remote_url
|
||||||
|
Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s
|
||||||
|
rescue Addressable::URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def description
|
||||||
|
@json['summary'].presence || @json['name'].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def focus
|
||||||
|
@json['focalPoint']
|
||||||
|
end
|
||||||
|
|
||||||
|
def blurhash
|
||||||
|
supported_blurhash? ? @json['blurhash'] : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def file_content_type
|
||||||
|
@json['mediaType']
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def supported_blurhash?
|
||||||
|
components = begin
|
||||||
|
blurhash = @json['blurhash']
|
||||||
|
|
||||||
|
if blurhash.present? && /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash)
|
||||||
|
Blurhash.components(blurhash)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
components.present? && components.none? { |comp| comp > 5 }
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,53 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Parser::PollParser
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
def initialize(json)
|
||||||
|
@json = json
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid?
|
||||||
|
equals_or_includes?(@json['type'], 'Question') && items.is_a?(Array)
|
||||||
|
end
|
||||||
|
|
||||||
|
# @param [Poll] previous_record
|
||||||
|
def significantly_changes?(previous_record)
|
||||||
|
options != previous_record.options ||
|
||||||
|
multiple != previous_record.multiple
|
||||||
|
end
|
||||||
|
|
||||||
|
def options
|
||||||
|
items.filter_map { |item| item['name'].presence || item['content'] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def multiple
|
||||||
|
@json['anyOf'].is_a?(Array)
|
||||||
|
end
|
||||||
|
|
||||||
|
def expires_at
|
||||||
|
if @json['closed'].is_a?(String)
|
||||||
|
@json['closed'].to_datetime
|
||||||
|
elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
|
||||||
|
Time.now.utc
|
||||||
|
else
|
||||||
|
@json['endTime']&.to_datetime
|
||||||
|
end
|
||||||
|
rescue ArgumentError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def voters_count
|
||||||
|
@json['votersCount']
|
||||||
|
end
|
||||||
|
|
||||||
|
def cached_tallies
|
||||||
|
items.map { |item| item.dig('replies', 'totalItems') || 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def items
|
||||||
|
@json['anyOf'] || @json['oneOf']
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,124 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::Parser::StatusParser
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
# @param [Hash] json
|
||||||
|
# @param [Hash] magic_values
|
||||||
|
# @option magic_values [String] :followers_collection
|
||||||
|
def initialize(json, magic_values = {})
|
||||||
|
@json = json
|
||||||
|
@object = json['object'] || json
|
||||||
|
@magic_values = magic_values
|
||||||
|
end
|
||||||
|
|
||||||
|
def uri
|
||||||
|
id = @object['id']
|
||||||
|
|
||||||
|
if id&.start_with?('bear:')
|
||||||
|
Addressable::URI.parse(id).query_values['u']
|
||||||
|
else
|
||||||
|
id
|
||||||
|
end
|
||||||
|
rescue Addressable::URI::InvalidURIError
|
||||||
|
id
|
||||||
|
end
|
||||||
|
|
||||||
|
def url
|
||||||
|
url_to_href(@object['url'], 'text/html') if @object['url'].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def text
|
||||||
|
if @object['content'].present?
|
||||||
|
@object['content']
|
||||||
|
elsif content_language_map?
|
||||||
|
@object['contentMap'].values.first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def spoiler_text
|
||||||
|
if @object['summary'].present?
|
||||||
|
@object['summary']
|
||||||
|
elsif summary_language_map?
|
||||||
|
@object['summaryMap'].values.first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def title
|
||||||
|
if @object['name'].present?
|
||||||
|
@object['name']
|
||||||
|
elsif name_language_map?
|
||||||
|
@object['nameMap'].values.first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def created_at
|
||||||
|
@object['published']&.to_datetime
|
||||||
|
rescue ArgumentError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def edited_at
|
||||||
|
@object['updated']&.to_datetime
|
||||||
|
rescue ArgumentError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def reply
|
||||||
|
@object['inReplyTo'].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def sensitive
|
||||||
|
@object['sensitive']
|
||||||
|
end
|
||||||
|
|
||||||
|
def visibility
|
||||||
|
if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
|
||||||
|
:public
|
||||||
|
elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
|
||||||
|
:unlisted
|
||||||
|
elsif audience_to.include?(@magic_values[:followers_collection])
|
||||||
|
:private
|
||||||
|
elsif direct_message == false
|
||||||
|
:limited
|
||||||
|
else
|
||||||
|
:direct
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def language
|
||||||
|
if content_language_map?
|
||||||
|
@object['contentMap'].keys.first
|
||||||
|
elsif name_language_map?
|
||||||
|
@object['nameMap'].keys.first
|
||||||
|
elsif summary_language_map?
|
||||||
|
@object['summaryMap'].keys.first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def direct_message
|
||||||
|
@object['directMessage']
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def audience_to
|
||||||
|
as_array(@object['to'] || @json['to']).map { |x| value_or_id(x) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def audience_cc
|
||||||
|
as_array(@object['cc'] || @json['cc']).map { |x| value_or_id(x) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def summary_language_map?
|
||||||
|
@object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def content_language_map?
|
||||||
|
@object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def name_language_map?
|
||||||
|
@object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,23 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: status_edits
|
||||||
|
#
|
||||||
|
# id :bigint(8) not null, primary key
|
||||||
|
# status_id :bigint(8) not null
|
||||||
|
# account_id :bigint(8)
|
||||||
|
# text :text default(""), not null
|
||||||
|
# spoiler_text :text default(""), not null
|
||||||
|
# media_attachments_changed :boolean default(FALSE), not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
#
|
||||||
|
|
||||||
|
class StatusEdit < ApplicationRecord
|
||||||
|
belongs_to :status
|
||||||
|
belongs_to :account, optional: true
|
||||||
|
|
||||||
|
default_scope { order(id: :asc) }
|
||||||
|
|
||||||
|
delegate :local?, to: :status
|
||||||
|
end
|
@ -0,0 +1,6 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::StatusEditSerializer < ActiveModel::Serializer
|
||||||
|
attributes :text, :spoiler_text, :media_attachments_changed,
|
||||||
|
:created_at
|
||||||
|
end
|
@ -0,0 +1,9 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::StatusSourceSerializer < ActiveModel::Serializer
|
||||||
|
attributes :id, :text, :spoiler_text
|
||||||
|
|
||||||
|
def id
|
||||||
|
object.id.to_s
|
||||||
|
end
|
||||||
|
end
|
@ -1,64 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class ActivityPub::ProcessPollService < BaseService
|
|
||||||
include JsonLdHelper
|
|
||||||
|
|
||||||
def call(poll, json)
|
|
||||||
@json = json
|
|
||||||
|
|
||||||
return unless expected_type?
|
|
||||||
|
|
||||||
previous_expires_at = poll.expires_at
|
|
||||||
|
|
||||||
expires_at = begin
|
|
||||||
if @json['closed'].is_a?(String)
|
|
||||||
@json['closed']
|
|
||||||
elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
|
|
||||||
Time.now.utc
|
|
||||||
else
|
|
||||||
@json['endTime']
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
items = begin
|
|
||||||
if @json['anyOf'].is_a?(Array)
|
|
||||||
@json['anyOf']
|
|
||||||
else
|
|
||||||
@json['oneOf']
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
voters_count = @json['votersCount']
|
|
||||||
|
|
||||||
latest_options = items.filter_map { |item| item['name'].presence || item['content'] }
|
|
||||||
|
|
||||||
# If for some reasons the options were changed, it invalidates all previous
|
|
||||||
# votes, so we need to remove them
|
|
||||||
poll.votes.delete_all if latest_options != poll.options
|
|
||||||
|
|
||||||
begin
|
|
||||||
poll.update!(
|
|
||||||
last_fetched_at: Time.now.utc,
|
|
||||||
expires_at: expires_at,
|
|
||||||
options: latest_options,
|
|
||||||
cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 },
|
|
||||||
voters_count: voters_count
|
|
||||||
)
|
|
||||||
rescue ActiveRecord::StaleObjectError
|
|
||||||
poll.reload
|
|
||||||
retry
|
|
||||||
end
|
|
||||||
|
|
||||||
# If the poll had no expiration date set but now has, and people have voted,
|
|
||||||
# schedule a notification.
|
|
||||||
if previous_expires_at.nil? && poll.expires_at.present? && poll.votes.exists?
|
|
||||||
PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def expected_type?
|
|
||||||
equals_or_includes_any?(@json['type'], %w(Question))
|
|
||||||
end
|
|
||||||
end
|
|
@ -0,0 +1,275 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ActivityPub::ProcessStatusUpdateService < BaseService
|
||||||
|
include JsonLdHelper
|
||||||
|
|
||||||
|
def call(status, json)
|
||||||
|
@json = json
|
||||||
|
@status_parser = ActivityPub::Parser::StatusParser.new(@json)
|
||||||
|
@uri = @status_parser.uri
|
||||||
|
@status = status
|
||||||
|
@account = status.account
|
||||||
|
@media_attachments_changed = false
|
||||||
|
|
||||||
|
# Only native types can be updated at the moment
|
||||||
|
return if !expected_type? || already_updated_more_recently?
|
||||||
|
|
||||||
|
# Only allow processing one create/update per status at a time
|
||||||
|
RedisLock.acquire(lock_options) do |lock|
|
||||||
|
if lock.acquired?
|
||||||
|
Status.transaction do
|
||||||
|
create_previous_edit!
|
||||||
|
update_media_attachments!
|
||||||
|
update_poll!
|
||||||
|
update_immediate_attributes!
|
||||||
|
update_metadata!
|
||||||
|
create_edit!
|
||||||
|
end
|
||||||
|
|
||||||
|
queue_poll_notifications!
|
||||||
|
reset_preview_card!
|
||||||
|
broadcast_updates!
|
||||||
|
else
|
||||||
|
raise Mastodon::RaceConditionError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_media_attachments!
|
||||||
|
previous_media_attachments = @status.media_attachments.to_a
|
||||||
|
next_media_attachments = []
|
||||||
|
|
||||||
|
as_array(@json['attachment']).each do |attachment|
|
||||||
|
media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
|
||||||
|
|
||||||
|
next if media_attachment_parser.remote_url.blank? || next_media_attachments.size > 4
|
||||||
|
|
||||||
|
begin
|
||||||
|
media_attachment = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url }
|
||||||
|
media_attachment ||= MediaAttachment.new(account: @account, remote_url: media_attachment_parser.remote_url)
|
||||||
|
|
||||||
|
# If a previously existing media attachment was significantly updated, mark
|
||||||
|
# media attachments as changed even if none were added or removed
|
||||||
|
if media_attachment_parser.significantly_changes?(media_attachment)
|
||||||
|
@media_attachments_changed = true
|
||||||
|
end
|
||||||
|
|
||||||
|
media_attachment.description = media_attachment_parser.description
|
||||||
|
media_attachment.focus = media_attachment_parser.focus
|
||||||
|
media_attachment.thumbnail_remote_url = media_attachment_parser.thumbnail_remote_url
|
||||||
|
media_attachment.blurhash = media_attachment_parser.blurhash
|
||||||
|
media_attachment.save!
|
||||||
|
|
||||||
|
next_media_attachments << media_attachment
|
||||||
|
|
||||||
|
next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
|
||||||
|
|
||||||
|
RedownloadMediaWorker.perform_async(media_attachment.id) if media_attachment.remote_url_previously_changed? || media_attachment.thumbnail_remote_url_previously_changed?
|
||||||
|
rescue Addressable::URI::InvalidURIError => e
|
||||||
|
Rails.logger.debug "Invalid URL in attachment: #{e}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
removed_media_attachments = previous_media_attachments - next_media_attachments
|
||||||
|
added_media_attachments = next_media_attachments - previous_media_attachments
|
||||||
|
|
||||||
|
MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil)
|
||||||
|
MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
|
||||||
|
|
||||||
|
@media_attachments_changed = true if removed_media_attachments.any? || added_media_attachments.any?
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_poll!
|
||||||
|
previous_poll = @status.preloadable_poll
|
||||||
|
@previous_expires_at = previous_poll&.expires_at
|
||||||
|
poll_parser = ActivityPub::Parser::PollParser.new(@json)
|
||||||
|
|
||||||
|
if poll_parser.valid?
|
||||||
|
poll = previous_poll || @account.polls.new(status: @status)
|
||||||
|
|
||||||
|
# If for some reasons the options were changed, it invalidates all previous
|
||||||
|
# votes, so we need to remove them
|
||||||
|
if poll_parser.significantly_changes?(poll)
|
||||||
|
@media_attachments_changed = true
|
||||||
|
poll.votes.delete_all unless poll.new_record?
|
||||||
|
end
|
||||||
|
|
||||||
|
poll.last_fetched_at = Time.now.utc
|
||||||
|
poll.options = poll_parser.options
|
||||||
|
poll.multiple = poll_parser.multiple
|
||||||
|
poll.expires_at = poll_parser.expires_at
|
||||||
|
poll.voters_count = poll_parser.voters_count
|
||||||
|
poll.cached_tallies = poll_parser.cached_tallies
|
||||||
|
poll.save!
|
||||||
|
|
||||||
|
@status.poll_id = poll.id
|
||||||
|
elsif previous_poll.present?
|
||||||
|
previous_poll.destroy!
|
||||||
|
@media_attachments_changed = true
|
||||||
|
@status.poll_id = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_immediate_attributes!
|
||||||
|
@status.text = @status_parser.text || ''
|
||||||
|
@status.spoiler_text = @status_parser.spoiler_text || ''
|
||||||
|
@status.sensitive = @account.sensitized? || @status_parser.sensitive || false
|
||||||
|
@status.language = @status_parser.language || detected_language
|
||||||
|
@status.edited_at = @status_parser.edited_at || Time.now.utc
|
||||||
|
|
||||||
|
@status.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_metadata!
|
||||||
|
@raw_tags = []
|
||||||
|
@raw_mentions = []
|
||||||
|
@raw_emojis = []
|
||||||
|
|
||||||
|
as_array(@json['tag']).each do |tag|
|
||||||
|
if equals_or_includes?(tag['type'], 'Hashtag')
|
||||||
|
@raw_tags << tag['name']
|
||||||
|
elsif equals_or_includes?(tag['type'], 'Mention')
|
||||||
|
@raw_mentions << tag['href']
|
||||||
|
elsif equals_or_includes?(tag['type'], 'Emoji')
|
||||||
|
@raw_emojis << tag
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
update_tags!
|
||||||
|
update_mentions!
|
||||||
|
update_emojis!
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_tags!
|
||||||
|
@status.tags = Tag.find_or_create_by_names(@raw_tags)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_mentions!
|
||||||
|
previous_mentions = @status.active_mentions.includes(:account).to_a
|
||||||
|
current_mentions = []
|
||||||
|
|
||||||
|
@raw_mentions.each do |href|
|
||||||
|
next if href.blank?
|
||||||
|
|
||||||
|
account = ActivityPub::TagManager.instance.uri_to_resource(href, Account)
|
||||||
|
account ||= ActivityPub::FetchRemoteAccountService.new.call(href)
|
||||||
|
|
||||||
|
next if account.nil?
|
||||||
|
|
||||||
|
mention = previous_mentions.find { |x| x.account_id == account.id }
|
||||||
|
mention ||= account.mentions.new(status: @status)
|
||||||
|
|
||||||
|
current_mentions << mention
|
||||||
|
end
|
||||||
|
|
||||||
|
current_mentions.each do |mention|
|
||||||
|
mention.save if mention.new_record?
|
||||||
|
end
|
||||||
|
|
||||||
|
# If previous mentions are no longer contained in the text, convert them
|
||||||
|
# to silent mentions, since withdrawing access from someone who already
|
||||||
|
# received a notification might be more confusing
|
||||||
|
removed_mentions = previous_mentions - current_mentions
|
||||||
|
|
||||||
|
Mention.where(id: removed_mentions.map(&:id)).update_all(silent: true) unless removed_mentions.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_emojis!
|
||||||
|
return if skip_download?
|
||||||
|
|
||||||
|
@raw_emojis.each do |raw_emoji|
|
||||||
|
custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(raw_emoji)
|
||||||
|
|
||||||
|
next if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
|
||||||
|
|
||||||
|
emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
|
||||||
|
|
||||||
|
next unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at)
|
||||||
|
|
||||||
|
begin
|
||||||
|
emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri)
|
||||||
|
emoji.image_remote_url = custom_emoji_parser.image_remote_url
|
||||||
|
emoji.save
|
||||||
|
rescue Seahorse::Client::NetworkingError => e
|
||||||
|
Rails.logger.warn "Error storing emoji: #{e}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def expected_type?
|
||||||
|
equals_or_includes_any?(@json['type'], %w(Note Question))
|
||||||
|
end
|
||||||
|
|
||||||
|
def lock_options
|
||||||
|
{ redis: Redis.current, key: "create:#{@uri}", autorelease: 15.minutes.seconds }
|
||||||
|
end
|
||||||
|
|
||||||
|
def detected_language
|
||||||
|
LanguageDetector.instance.detect(@status_parser.text, @account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_previous_edit!
|
||||||
|
# We only need to create a previous edit when no previous edits exist, e.g.
|
||||||
|
# when the status has never been edited. For other cases, we always create
|
||||||
|
# an edit, so the step can be skipped
|
||||||
|
|
||||||
|
return if @status.edits.any?
|
||||||
|
|
||||||
|
@status.edits.create(
|
||||||
|
text: @status.text,
|
||||||
|
spoiler_text: @status.spoiler_text,
|
||||||
|
media_attachments_changed: false,
|
||||||
|
account_id: @account.id,
|
||||||
|
created_at: @status.created_at
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_edit!
|
||||||
|
return unless @status.text_previously_changed? || @status.spoiler_text_previously_changed? || @media_attachments_changed
|
||||||
|
|
||||||
|
@status_edit = @status.edits.create(
|
||||||
|
text: @status.text,
|
||||||
|
spoiler_text: @status.spoiler_text,
|
||||||
|
media_attachments_changed: @media_attachments_changed,
|
||||||
|
account_id: @account.id,
|
||||||
|
created_at: @status.edited_at
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def skip_download?
|
||||||
|
return @skip_download if defined?(@skip_download)
|
||||||
|
|
||||||
|
@skip_download ||= DomainBlock.reject_media?(@account.domain)
|
||||||
|
end
|
||||||
|
|
||||||
|
def unsupported_media_type?(mime_type)
|
||||||
|
mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def already_updated_more_recently?
|
||||||
|
@status.edited_at.present? && @status_parser.edited_at.present? && @status.edited_at > @status_parser.edited_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset_preview_card!
|
||||||
|
@status.preview_cards.clear if @status.text_previously_changed? || @status.spoiler_text.present?
|
||||||
|
LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id) if @status.spoiler_text.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_updates!
|
||||||
|
::DistributionWorker.perform_async(@status.id, update: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def queue_poll_notifications!
|
||||||
|
poll = @status.preloadable_poll
|
||||||
|
|
||||||
|
# If the poll had no expiration date set but now has, or now has a sooner
|
||||||
|
# expiration date, and people have voted, schedule a notification
|
||||||
|
|
||||||
|
return unless poll.present? && poll.expires_at.present? && poll.votes.exists?
|
||||||
|
|
||||||
|
PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at
|
||||||
|
PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
|
||||||
|
end
|
||||||
|
end
|
@ -1,54 +1,32 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::DistributionWorker
|
class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker
|
||||||
include Sidekiq::Worker
|
# Distribute a new status or an edit of a status to all the places
|
||||||
include Payloadable
|
# where the status is supposed to go or where it was interacted with
|
||||||
|
|
||||||
sidekiq_options queue: 'push'
|
|
||||||
|
|
||||||
def perform(status_id)
|
def perform(status_id)
|
||||||
@status = Status.find(status_id)
|
@status = Status.find(status_id)
|
||||||
@account = @status.account
|
@account = @status.account
|
||||||
|
|
||||||
return if skip_distribution?
|
distribute!
|
||||||
|
|
||||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
|
|
||||||
[payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }]
|
|
||||||
end
|
|
||||||
|
|
||||||
relay! if relayable?
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
protected
|
||||||
|
|
||||||
def skip_distribution?
|
|
||||||
@status.direct_visibility? || @status.limited_visibility?
|
|
||||||
end
|
|
||||||
|
|
||||||
def relayable?
|
|
||||||
@status.public_visibility?
|
|
||||||
end
|
|
||||||
|
|
||||||
def inboxes
|
def inboxes
|
||||||
# Deliver the status to all followers.
|
@inboxes ||= StatusReachFinder.new(@status).inboxes
|
||||||
# If the status is a reply to another local status, also forward it to that
|
|
||||||
# status' authors' followers.
|
|
||||||
@inboxes ||= if @status.in_reply_to_local_account? && @status.distributable?
|
|
||||||
@account.followers.or(@status.thread.account.followers).inboxes
|
|
||||||
else
|
|
||||||
@account.followers.inboxes
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def payload
|
def payload
|
||||||
@payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account))
|
@payload ||= Oj.dump(serialize_payload(activity, ActivityPub::ActivitySerializer, signer: @account))
|
||||||
end
|
end
|
||||||
|
|
||||||
def relay!
|
def activity
|
||||||
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
|
ActivityPub::ActivityPresenter.from_status(@status)
|
||||||
[payload, @account.id, inbox_url]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def options
|
||||||
|
{ synchronize_followers: @status.private_visibility? }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
# Obsolete but kept around to make sure existing jobs do not fail after upgrade.
|
|
||||||
# Should be removed in a subsequent release.
|
|
||||||
|
|
||||||
class ActivityPub::ReplyDistributionWorker
|
|
||||||
include Sidekiq::Worker
|
|
||||||
include Payloadable
|
|
||||||
|
|
||||||
sidekiq_options queue: 'push'
|
|
||||||
|
|
||||||
def perform(status_id)
|
|
||||||
@status = Status.find(status_id)
|
|
||||||
@account = @status.thread&.account
|
|
||||||
|
|
||||||
return unless @account.present? && @status.distributable?
|
|
||||||
|
|
||||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
|
|
||||||
[payload, @status.account_id, inbox_url]
|
|
||||||
end
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def inboxes
|
|
||||||
@inboxes ||= @account.followers.inboxes
|
|
||||||
end
|
|
||||||
|
|
||||||
def payload
|
|
||||||
@payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,33 +1,24 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ActivityPub::UpdateDistributionWorker
|
class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker
|
||||||
include Sidekiq::Worker
|
# Distribute an profile update to servers that might have a copy
|
||||||
include Payloadable
|
# of the account in question
|
||||||
|
|
||||||
sidekiq_options queue: 'push'
|
|
||||||
|
|
||||||
def perform(account_id, options = {})
|
def perform(account_id, options = {})
|
||||||
@options = options.with_indifferent_access
|
@options = options.with_indifferent_access
|
||||||
@account = Account.find(account_id)
|
@account = Account.find(account_id)
|
||||||
|
|
||||||
ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
|
distribute!
|
||||||
[signed_payload, @account.id, inbox_url]
|
|
||||||
end
|
|
||||||
|
|
||||||
ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
|
|
||||||
[signed_payload, @account.id, inbox_url]
|
|
||||||
end
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
protected
|
||||||
|
|
||||||
def inboxes
|
def inboxes
|
||||||
@inboxes ||= @account.followers.inboxes
|
@inboxes ||= AccountReachFinder.new(@account).inboxes
|
||||||
end
|
end
|
||||||
|
|
||||||
def signed_payload
|
def payload
|
||||||
@signed_payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with]))
|
@payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with]))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
class AddEditedAtToStatuses < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
add_column :statuses, :edited_at, :datetime
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,13 @@
|
|||||||
|
class CreateStatusEdits < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
create_table :status_edits do |t|
|
||||||
|
t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade }
|
||||||
|
t.belongs_to :account, null: true, foreign_key: { on_delete: :nullify }
|
||||||
|
t.text :text, null: false, default: ''
|
||||||
|
t.text :spoiler_text, null: false, default: ''
|
||||||
|
t.boolean :media_attachments_changed, null: false, default: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Api::V1::Statuses::HistoriesController do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
|
||||||
|
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
|
||||||
|
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) }
|
||||||
|
|
||||||
|
context 'with an oauth token' do
|
||||||
|
before do
|
||||||
|
allow(controller).to receive(:doorkeeper_token) { token }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET #show' do
|
||||||
|
let(:status) { Fabricate(:status, account: user.account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
get :show, params: { status_id: status.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Api::V1::Statuses::SourcesController do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
|
||||||
|
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
|
||||||
|
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) }
|
||||||
|
|
||||||
|
context 'with an oauth token' do
|
||||||
|
before do
|
||||||
|
allow(controller).to receive(:doorkeeper_token) { token }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET #show' do
|
||||||
|
let(:status) { Fabricate(:status, account: user.account) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
get :show, params: { status_id: status.id }
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,6 @@
|
|||||||
|
Fabricator(:preview_card) do
|
||||||
|
url { Faker::Internet.url }
|
||||||
|
title { Faker::Lorem.sentence }
|
||||||
|
description { Faker::Lorem.paragraph }
|
||||||
|
type 'link'
|
||||||
|
end
|
@ -0,0 +1,7 @@
|
|||||||
|
Fabricator(:status_edit) do
|
||||||
|
status nil
|
||||||
|
account nil
|
||||||
|
text "MyText"
|
||||||
|
spoiler_text "MyText"
|
||||||
|
media_attachments_changed false
|
||||||
|
end
|
@ -0,0 +1,109 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe StatusReachFinder do
|
||||||
|
describe '#inboxes' do
|
||||||
|
context 'for a local status' do
|
||||||
|
let(:parent_status) { nil }
|
||||||
|
let(:visibility) { :public }
|
||||||
|
let(:alice) { Fabricate(:account, username: 'alice') }
|
||||||
|
let(:status) { Fabricate(:status, account: alice, thread: parent_status, visibility: visibility) }
|
||||||
|
|
||||||
|
subject { described_class.new(status) }
|
||||||
|
|
||||||
|
context 'when it contains mentions of remote accounts' do
|
||||||
|
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
status.mentions.create!(account: bob)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes the inbox of the mentioned account' do
|
||||||
|
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it has been reblogged by a remote account' do
|
||||||
|
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
bob.statuses.create!(reblog: status)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes the inbox of the reblogger' do
|
||||||
|
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when status is not public' do
|
||||||
|
let(:visibility) { :private }
|
||||||
|
|
||||||
|
it 'does not include the inbox of the reblogger' do
|
||||||
|
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it has been favourited by a remote account' do
|
||||||
|
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
bob.favourites.create!(status: status)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes the inbox of the favouriter' do
|
||||||
|
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when status is not public' do
|
||||||
|
let(:visibility) { :private }
|
||||||
|
|
||||||
|
it 'does not include the inbox of the favouriter' do
|
||||||
|
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it has been replied to by a remote account' do
|
||||||
|
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
bob.statuses.create!(thread: status, text: 'Hoge')
|
||||||
|
end
|
||||||
|
|
||||||
|
context do
|
||||||
|
it 'includes the inbox of the replier' do
|
||||||
|
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when status is not public' do
|
||||||
|
let(:visibility) { :private }
|
||||||
|
|
||||||
|
it 'does not include the inbox of the replier' do
|
||||||
|
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is a reply to a remote account' do
|
||||||
|
let(:bob) { Fabricate(:account, username: 'bob', domain: 'foo.bar', protocol: :activitypub, inbox_url: 'https://foo.bar/inbox') }
|
||||||
|
let(:parent_status) { Fabricate(:status, account: bob) }
|
||||||
|
|
||||||
|
context do
|
||||||
|
it 'includes the inbox of the replied-to account' do
|
||||||
|
expect(subject.inboxes).to include 'https://foo.bar/inbox'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when status is not public and replied-to account is not mentioned' do
|
||||||
|
let(:visibility) { :private }
|
||||||
|
|
||||||
|
it 'does not include the inbox of the replied-to account' do
|
||||||
|
expect(subject.inboxes).to_not include 'https://foo.bar/inbox'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,5 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe StatusEdit, type: :model do
|
||||||
|
pending "add some examples to (or delete) #{__FILE__}"
|
||||||
|
end
|
@ -1,37 +1,112 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe FanOutOnWriteService, type: :service do
|
RSpec.describe FanOutOnWriteService, type: :service do
|
||||||
let(:author) { Fabricate(:account, username: 'tom') }
|
let(:last_active_at) { Time.now.utc }
|
||||||
let(:status) { Fabricate(:status, text: 'Hello @alice #test', account: author) }
|
|
||||||
let(:alice) { Fabricate(:user, account: Fabricate(:account, username: 'alice')).account }
|
|
||||||
let(:follower) { Fabricate(:account, username: 'bob') }
|
|
||||||
|
|
||||||
subject { FanOutOnWriteService.new }
|
let!(:alice) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'alice')).account }
|
||||||
|
let!(:bob) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'bob')).account }
|
||||||
|
let!(:tom) { Fabricate(:user, current_sign_in_at: last_active_at, account: Fabricate(:account, username: 'tom')).account }
|
||||||
|
|
||||||
|
subject { described_class.new }
|
||||||
|
|
||||||
|
let(:status) { Fabricate(:status, account: alice, visibility: visibility, text: 'Hello @bob #hoge') }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
alice
|
bob.follow!(alice)
|
||||||
follower.follow!(author)
|
tom.follow!(alice)
|
||||||
|
|
||||||
ProcessMentionsService.new.call(status)
|
ProcessMentionsService.new.call(status)
|
||||||
ProcessHashtagsService.new.call(status)
|
ProcessHashtagsService.new.call(status)
|
||||||
|
|
||||||
|
allow(Redis.current).to receive(:publish)
|
||||||
|
|
||||||
subject.call(status)
|
subject.call(status)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'delivers status to home timeline' do
|
def home_feed_of(account)
|
||||||
expect(HomeFeed.new(author).get(10).map(&:id)).to include status.id
|
HomeFeed.new(account).get(10).map(&:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when status is public' do
|
||||||
|
let(:visibility) { 'public' }
|
||||||
|
|
||||||
|
it 'is added to the home feed of its author' do
|
||||||
|
expect(home_feed_of(alice)).to include status.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is added to the home feed of a follower' do
|
||||||
|
expect(home_feed_of(bob)).to include status.id
|
||||||
|
expect(home_feed_of(tom)).to include status.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is broadcast to the hashtag stream' do
|
||||||
|
expect(Redis.current).to have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||||
|
expect(Redis.current).to have_received(:publish).with('timeline:hashtag:hoge:local', anything)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is broadcast to the public stream' do
|
||||||
|
expect(Redis.current).to have_received(:publish).with('timeline:public', anything)
|
||||||
|
expect(Redis.current).to have_received(:publish).with('timeline:public:local', anything)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when status is limited' do
|
||||||
|
let(:visibility) { 'limited' }
|
||||||
|
|
||||||
it 'delivers status to local followers' do
|
it 'is added to the home feed of its author' do
|
||||||
pending 'some sort of problem in test environment causes this to sometimes fail'
|
expect(home_feed_of(alice)).to include status.id
|
||||||
expect(HomeFeed.new(follower).get(10).map(&:id)).to include status.id
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'delivers status to hashtag' do
|
it 'is added to the home feed of the mentioned follower' do
|
||||||
expect(TagFeed.new(Tag.find_by(name: 'test'), alice).get(20).map(&:id)).to include status.id
|
expect(home_feed_of(bob)).to include status.id
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'delivers status to public timeline' do
|
it 'is not added to the home feed of the other follower' do
|
||||||
expect(PublicFeed.new(alice).get(20).map(&:id)).to include status.id
|
expect(home_feed_of(tom)).to_not include status.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is not broadcast publicly' do
|
||||||
|
expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||||
|
expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when status is private' do
|
||||||
|
let(:visibility) { 'private' }
|
||||||
|
|
||||||
|
it 'is added to the home feed of its author' do
|
||||||
|
expect(home_feed_of(alice)).to include status.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is added to the home feed of a follower' do
|
||||||
|
expect(home_feed_of(bob)).to include status.id
|
||||||
|
expect(home_feed_of(tom)).to include status.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is not broadcast publicly' do
|
||||||
|
expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||||
|
expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when status is direct' do
|
||||||
|
let(:visibility) { 'direct' }
|
||||||
|
|
||||||
|
it 'is added to the home feed of its author' do
|
||||||
|
expect(home_feed_of(alice)).to include status.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is added to the home feed of the mentioned follower' do
|
||||||
|
expect(home_feed_of(bob)).to include status.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is not added to the home feed of the other follower' do
|
||||||
|
expect(home_feed_of(tom)).to_not include status.id
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'is not broadcast publicly' do
|
||||||
|
expect(Redis.current).to_not have_received(:publish).with('timeline:hashtag:hoge', anything)
|
||||||
|
expect(Redis.current).to_not have_received(:publish).with('timeline:public', anything)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
Loading…
Reference in new issue