}
@@ -260,6 +278,8 @@ export default class StatusContent extends React.PureComponent {
} else {
return (
+ {edited}
+
{!!status.get('poll') &&
}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 025ae6e7d..7702c8be1 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -380,6 +380,7 @@
"status.delete": "Delete",
"status.detailed_status": "Detailed conversation view",
"status.direct": "Direct message @{name}",
+ "status.edited": "{count, plural, one {# edit} other {# edits}} ยท last update: {updated_at}",
"status.embed": "Embed",
"status.favourite": "Favourite",
"status.filtered": "Filtered",
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index f09caaae4..d2bbd26d5 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -51,7 +51,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@status = find_existing_status
- if @status.nil?
+ if @status.nil? || @options[:update]
process_status
elsif @options[:delivered_to_account_id].present?
postprocess_audience_and_deliver
@@ -77,6 +77,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@mentions = []
@params = {}
+ unless @status.nil?
+ process_status_update_params
+ process_tags
+ process_audience
+
+ @status = UpdateStatusService.new.call(@status, @params, @mentions, @tags)
+ resolve_thread(@status)
+ fetch_replies(@status)
+ return @status
+ end
+
process_status_params
process_tags
process_audience
@@ -121,6 +132,19 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
+ def process_status_update_params
+ @params = begin
+ {
+ text: text_from_content || '',
+ language: detected_language,
+ spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
+ sensitive: @object['sensitive'] || false,
+ visibility: visibility_from_audience,
+ media_attachment_ids: process_attachments.take(4).map(&:id),
+ }
+ end
+ end
+
def process_audience
(as_array(audience_to) + as_array(audience_cc)).uniq.each do |audience|
next if audience == ActivityPub::TagManager::COLLECTIONS[:public]
@@ -240,7 +264,21 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
begin
href = Addressable::URI.parse(attachment['url']).normalize.to_s
- media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
+ media_attachment = MediaAttachment.find_by(account: @account, remote_url: href)
+
+ if media_attachment.nil?
+ media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
+ else
+ updated_description = attachment['summary'].presence || media_attachment[:description].presence || attachment['name'].presence || media_attachment[:name].presence
+ updated_focus = attachment['focalPoint'].presence || media_attachment['focalPoint'].presence
+ updated_blurhash = supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : media_attachment[:blurhash]
+
+ media_attachment.update(description: updated_description, focus: updated_focus, blurhash: updated_blurhash)
+
+ media_attachments << media_attachment
+ next
+ end
+
media_attachments << media_attachment
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
index 018e2df54..d1dba5196 100644
--- a/app/lib/activitypub/activity/update.rb
+++ b/app/lib/activitypub/activity/update.rb
@@ -2,6 +2,7 @@
class ActivityPub::Activity::Update < ActivityPub::Activity
SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
+ SUPPORTED_OBJECT_TYPES = (ActivityPub::Activity::SUPPORTED_TYPES + ActivityPub::Activity::CONVERTED_TYPES).freeze
def perform
dereference_object!
@@ -10,6 +11,9 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
update_account
elsif equals_or_includes_any?(@object['type'], %w(Question))
update_poll
+ elsif equals_or_includes_any?(@object['type'], SUPPORTED_OBJECT_TYPES)
+ @options[:update] = true
+ ActivityPub::Activity::Create.new(@json, @account, @options).perform
end
end
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 712c48823..309b84c37 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -8,6 +8,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
CONTEXT_EXTENSION_MAP = {
direct_message: { 'litepub': 'http://litepub.social/ns#', 'directMessage': 'litepub:directMessage' },
+ edited: { 'mp' => 'http://the.monsterpit.net/ns#', 'edited' => 'mp:edited' },
manually_approves_followers: { 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers' },
sensitive: { 'sensitive' => 'as:sensitive' },
hashtag: { 'Hashtag' => 'as:Hashtag' },
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 051f27408..d5408a30b 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -122,8 +122,8 @@ class Formatter
html.html_safe # rubocop:disable Rails/OutputSafety
end
- def linkify(text)
- html = encode_and_link_urls(text)
+ def linkify(text, accounts = nil, options = {})
+ html = encode_and_link_urls(text, accounts, options)
html = simple_format(html, {}, sanitize: false)
html = html.delete("\n")
diff --git a/app/models/status.rb b/app/models/status.rb
index e4d94186e..02f48621a 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -25,6 +25,7 @@
# poll_id :bigint(8)
# content_type :string
# deleted_at :datetime
+# edited :integer default(0)
#
class Status < ApplicationRecord
diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb
index 5d174767f..41ce01474 100644
--- a/app/presenters/activitypub/activity_presenter.rb
+++ b/app/presenters/activitypub/activity_presenter.rb
@@ -4,10 +4,11 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
attributes :id, :type, :actor, :published, :to, :cc, :virtual_object
class << self
- def from_status(status)
+ def from_status(status, update: false)
new.tap do |presenter|
+ default_activity = update && status.edited.positive? ? 'Update' : 'Create'
presenter.id = ActivityPub::TagManager.instance.activity_uri_for(status)
- presenter.type = status.reblog? ? 'Announce' : 'Create'
+ presenter.type = status.reblog? ? 'Announce' : default_activity
presenter.actor = ActivityPub::TagManager.instance.uri_for(status.account)
presenter.published = status.created_at
presenter.to = ActivityPub::TagManager.instance.to(status)
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index a0965790e..431a0faa4 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,12 +3,16 @@
class ActivityPub::NoteSerializer < ActivityPub::Serializer
context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message
+ context_extensions :edited
+
attributes :id, :type, :summary,
:in_reply_to, :published, :url,
:attributed_to, :to, :cc, :sensitive,
:atom_uri, :in_reply_to_atom_uri,
:conversation
+ attribute :updated
+
attribute :content
attribute :content_map, if: :language?
@@ -29,6 +33,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
def id
raise Mastodon::NotPermittedError, 'Local-only statuses should not be serialized' if object.local_only? && !instance_options[:allow_local_only]
+
ActivityPub::TagManager.instance.uri_for(object)
end
@@ -94,6 +99,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
object.created_at.iso8601
end
+ def updated
+ object.updated_at.iso8601
+ end
+
def url
ActivityPub::TagManager.instance.url_for(object)
end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 58e7bd4e4..26748f683 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -6,6 +6,9 @@ class REST::StatusSerializer < ActiveModel::Serializer
:uri, :url, :replies_count, :reblogs_count,
:favourites_count
+ # Monsterfork additions
+ attributes :updated_at, :edited
+
attribute :favourited, if: :current_user?
attribute :reblogged, if: :current_user?
attribute :muted, if: :current_user?
@@ -13,7 +16,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :pinned, if: :pinnable?
attribute :local_only if :local?
- attribute :content, unless: :source_requested?
+ attribute :content
attribute :text, if: :source_requested?
attribute :content_type, if: :source_requested?
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 250d0e8ed..c52ca4a9b 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -20,6 +20,9 @@ class PostStatusService < BaseService
# @option [Doorkeeper::Application] :application
# @option [String] :idempotency Optional idempotency key
# @option [Boolean] :with_rate_limit
+ # @option [Status] :status Edit an existing status
+ # @option [Enumerable] :mentions Optional array of Mentions to include
+ # @option [Enumerable] :tags Option array of tag names to include
# @return [Status]
def call(account, options = {})
@account = account
@@ -27,6 +30,11 @@ class PostStatusService < BaseService
@text = @options[:text] || ''
@in_reply_to = @options[:thread]
+ raise Mastodon::NotPermittedError if different_author?
+
+ @tag_names = (@options[:tags] || []).select { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i }
+ @mentions = @options[:mentions] || []
+
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
validate_media!
@@ -34,6 +42,8 @@ class PostStatusService < BaseService
if scheduled?
schedule_status!
+ elsif @options[:status].present? && status_exists?
+ update_status!
else
process_status!
postprocess_status!
@@ -49,14 +59,14 @@ class PostStatusService < BaseService
def preprocess_attributes!
if @text.blank? && @options[:spoiler_text].present?
- @text = '.'
- if @media&.find(&:video?) || @media&.find(&:gifv?)
- @text = '๐น'
- elsif @media&.find(&:audio?)
- @text = '๐ต'
- elsif @media&.find(&:image?)
- @text = '๐ผ'
- end
+ @text = '.'
+ if @media&.find(&:video?) || @media&.find(&:gifv?)
+ @text = '๐น'
+ elsif @media&.find(&:audio?)
+ @text = '๐ต'
+ elsif @media&.find(&:image?)
+ @text = '๐ผ'
+ end
end
@sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
@visibility = @options[:visibility] || @account.user&.setting_default_privacy
@@ -75,8 +85,8 @@ class PostStatusService < BaseService
@status = @account.statuses.create!(status_attributes)
end
- process_hashtags_service.call(@status)
- process_mentions_service.call(@status)
+ process_hashtags_service.call(@status, nil, @tag_names)
+ process_mentions_service.call(@status, mentions: @mentions)
end
def schedule_status!
@@ -103,12 +113,18 @@ class PostStatusService < BaseService
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
end
+ def update_status!
+ tags = Tag.find_or_create_by_names(@tag_names)
+ @status = UpdateStatusService.new.call(@options[:status], status_attributes, @mentions, tags)
+ end
+
def validate_media!
return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present?
- @media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i))
+ @media = @options[:status].present? ? @account.media_attachments.where(status_id: [nil, @options[:status].id]) : @account.media_attachments.where(status_id: nil)
+ @media = @media.where(id: @options[:media_ids].take(4).map(&:to_i))
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if @media.size > 1 && @media.find(&:audio_or_video?)
raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if @media.any?(&:not_processed?)
@@ -198,6 +214,16 @@ class PostStatusService < BaseService
options_hash[:scheduled_at] = nil
options_hash[:idempotency] = nil
options_hash[:with_rate_limit] = false
+ options_hash[:mention_ids] = options_hash.delete(:mentions)&.pluck(:id)
+ options_hash[:status_id] = options_hash.delete(:status)&.id
end
end
+
+ def different_author?
+ @options[:status].present? && @options[:status].account_id != @account.id
+ end
+
+ def status_exists?
+ !(@options[:status].discarded? || @options[:status].destroyed?)
+ end
end
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index e8e139b05..1f0d64323 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -1,11 +1,15 @@
# frozen_string_literal: true
class ProcessHashtagsService < BaseService
- def call(status, tags = [])
- tags = Extractor.extract_hashtags(status.text) if status.local?
+ def call(status, tags = nil, extra_tags = [])
+ tags ||= extra_tags | (status.local? ? Extractor.extract_hashtags(status.text) : [])
records = []
+ tag_ids = status.tag_ids.to_set
+
Tag.find_or_create_by_names(tags) do |tag|
+ next if tag_ids.include?(tag.id)
+
status.tags << tag
records << tag
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index f45422970..f3ce81ef1 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -7,42 +7,15 @@ class ProcessMentionsService < BaseService
# local mention pointers, send Salmon notifications to mentioned
# remote users
# @param [Status] status
- def call(status)
+ # @option [Enumerable] :mentions Mentions to include
+ # @option [Boolean] :reveal_implicit_mentions Append implicit mentions to text
+ def call(status, mentions: [], reveal_implicit_mentions: true)
return unless status.local?
- @status = status
- mentions = []
+ @status = status
+ @status.text, mentions = ResolveMentionsService.new.call(@status, mentions: mentions, reveal_implicit_mentions: reveal_implicit_mentions)
+ @status.save!
- status.text = status.text.gsub(Account::MENTION_RE) do |match|
- username, domain = Regexp.last_match(1).split('@')
-
- domain = begin
- if TagManager.instance.local_domain?(domain)
- nil
- else
- TagManager.instance.normalize_domain(domain)
- end
- end
-
- mentioned_account = Account.find_remote(username, domain)
-
- if mention_undeliverable?(mentioned_account)
- begin
- mentioned_account = resolve_account_service.call(Regexp.last_match(1))
- rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
- mentioned_account = nil
- end
- end
-
- next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
-
- mention = mentioned_account.mentions.new(status: status)
- mentions << mention if mention.save
-
- "@#{mentioned_account.acct}"
- end
-
- status.save!
check_for_spam(status)
mentions.each { |mention| create_notification(mention) }
@@ -50,10 +23,6 @@ class ProcessMentionsService < BaseService
private
- def mention_undeliverable?(mentioned_account)
- mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?)
- end
-
def create_notification(mention)
mentioned_account = mention.account
@@ -69,10 +38,6 @@ class ProcessMentionsService < BaseService
@activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
end
- def resolve_account_service
- ResolveAccountService.new
- end
-
def check_for_spam(status)
SpamCheck.perform(status)
end
diff --git a/app/services/remove_media_attachments_service.rb b/app/services/remove_media_attachments_service.rb
new file mode 100644
index 000000000..de3cd9afb
--- /dev/null
+++ b/app/services/remove_media_attachments_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class RemoveMediaAttachmentsService < BaseService
+ # Remove a list of media attachments by their IDs
+ # @param [Enumerable] attachment_ids
+ def call(attachment_ids)
+ media_attachments = MediaAttachment.where(id: attachment_ids)
+ media_attachments.map(&:id).each { |id| Rails.cache.delete_matched("statuses/#{id}-*") }
+ media_attachments.destroy_all
+ end
+end
diff --git a/app/services/resolve_mentions_service.rb b/app/services/resolve_mentions_service.rb
new file mode 100644
index 000000000..cb00b5c19
--- /dev/null
+++ b/app/services/resolve_mentions_service.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+class ResolveMentionsService < BaseService
+ # Scan text for mentions and create local mention pointers
+ # @param [Status] status Status to attach to mention pointers
+ # @option [String] :text Text containing mentions to resolve (default: use status text)
+ # @option [Enumerable] :mentions Additional mentions to include
+ # @option [Boolean] :reveal_implicit_mentions Append implicit mentions to text
+ # @return [Array] Array containing text with mentions resolved (String) and mention pointers (Set)
+ def call(status, text: nil, mentions: [], reveal_implicit_mentions: true)
+ mentions = Mention.includes(:account).where(id: mentions.pluck(:id), accounts: { suspended_at: nil }).to_set
+ implicit_mention_acct_ids = mentions.pluck(:account_id).to_set
+ text = status.text if text.nil?
+
+ text.gsub(Account::MENTION_RE) do |match|
+ username, domain = Regexp.last_match(1).split('@')
+
+ domain = begin
+ if TagManager.instance.local_domain?(domain)
+ nil
+ else
+ TagManager.instance.normalize_domain(domain)
+ end
+ end
+
+ mentioned_account = Account.find_remote(username, domain)
+
+ if mention_undeliverable?(mentioned_account)
+ begin
+ mentioned_account = resolve_account_service.call(Regexp.last_match(1))
+ rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
+ mentioned_account = nil
+ end
+ end
+
+ next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
+
+ mentions << mentioned_account.mentions.where(status: status).first_or_create(status: status)
+ implicit_mention_acct_ids.delete(mentioned_account.id)
+
+ "@#{mentioned_account.acct}"
+ end
+
+ if reveal_implicit_mentions && implicit_mention_acct_ids.present?
+ implicit_mention_accts = Account.where(id: implicit_mention_acct_ids, suspended_at: nil)
+ formatted_accts = format_mentions(implicit_mention_accts)
+ formatted_accts = Formatter.instance.linkify(formatted_accts, implicit_mention_accts) unless status.local?
+ text << formatted_accts
+ end
+
+ [text, mentions]
+ end
+
+ private
+
+ def mention_undeliverable?(mentioned_account)
+ mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?)
+ end
+
+ def resolve_account_service
+ ResolveAccountService.new
+ end
+
+ def format_mentions(accounts)
+ "\n\n#{accounts_to_mentions(accounts).join(' ')}"
+ end
+
+ def accounts_to_mentions(accounts)
+ accounts.reorder(:username, :domain).pluck(:username, :domain).map do |username, domain|
+ domain.blank? ? "@#{username}" : "@#{username}@#{domain}"
+ end
+ end
+end
diff --git a/app/services/revoke_status_service.rb b/app/services/revoke_status_service.rb
new file mode 100644
index 000000000..b7d7a6e18
--- /dev/null
+++ b/app/services/revoke_status_service.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+class RevokeStatusService < BaseService
+ include Redisable
+ include Payloadable
+
+ # Unpublish a status from a given set of local accounts' timelines and public, if visibility changed.
+ # @param [Status] status
+ # @param [Enumerable] account_ids
+ def call(status, account_ids)
+ @payload = Oj.dump(event: :delete, payload: status.id.to_s)
+ @status = status
+ @account = status.account
+ @account_ids = account_ids
+ @mentions = status.active_mentions.where(account_id: account_ids)
+ @reblogs = status.reblogs.where(account_id: account_ids)
+
+ RedisLock.acquire(lock_options) do |lock|
+ if lock.acquired?
+ remove_from_followers
+ remove_from_lists
+ remove_from_affected
+ remove_reblogs
+ remove_from_hashtags unless @status.distributable?
+ remove_from_public
+ remove_from_media
+ remove_from_direct if status.direct_visibility?
+ else
+ raise Mastodon::RaceConditionError
+ end
+ end
+ end
+
+ private
+
+ def remove_from_followers
+ @account.followers_for_local_distribution.where(id: @account_ids).reorder(nil).find_each do |follower|
+ FeedManager.instance.unpush_from_home(follower, @status)
+ end
+ end
+
+ def remove_from_lists
+ @account.lists_for_local_distribution.where(account_id: @account_ids).select(:id, :account_id).reorder(nil).find_each do |list|
+ FeedManager.instance.unpush_from_list(list, @status)
+ end
+ end
+
+ def remove_from_affected
+ @mentions.map(&:account).select(&:local?).each do |account|
+ redis.publish("timeline:#{account.id}", @payload)
+ end
+ end
+
+ def remove_reblogs
+ @reblogs.each do |reblog|
+ RemoveStatusService.new.call(reblog)
+ end
+ end
+
+ def remove_from_hashtags
+ @account.featured_tags.where(tag_id: @status.tags.pluck(:id)).each do |featured_tag|
+ featured_tag.decrement(@status.id)
+ end
+
+ return unless @status.public_visibility?
+
+ @tags.each do |hashtag|
+ redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
+ redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
+ end
+ end
+
+ def remove_from_public
+ return if @status.public_visibility?
+
+ redis.publish('timeline:public', @payload)
+ if @status.local?
+ redis.publish('timeline:public:local', @payload)
+ else
+ redis.publish('timeline:public:remote', @payload)
+ end
+ end
+
+ def remove_from_media
+ return if @status.public_visibility?
+
+ redis.publish('timeline:public:media', @payload)
+ if @status.local?
+ redis.publish('timeline:public:local:media', @payload)
+ else
+ redis.publish('timeline:public:remote:media', @payload)
+ end
+ end
+
+ def remove_from_direct
+ @mentions.each do |mention|
+ FeedManager.instance.unpush_from_direct(mention.account, @status) if mention.account.local?
+ end
+ end
+
+ def lock_options
+ { redis: Redis.current, key: "distribute:#{@status.id}" }
+ end
+end
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
new file mode 100644
index 000000000..b393f13bb
--- /dev/null
+++ b/app/services/update_status_service.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+class UpdateStatusService < BaseService
+ include Redisable
+
+ ALLOWED_ATTRIBUTES = %i(
+ spoiler_text
+ text
+ content_type
+ language
+ sensitive
+ visibility
+ media_attachments
+ media_attachment_ids
+ application
+ rate_limit
+ ).freeze
+
+ # Updates the content of an existing status.
+ # @param [Status] status The status to update.
+ # @param [Hash] params The attributes of the new status.
+ # @param [Enumerable] mentions Additional mentions added to the status.
+ # @param [Enumerable] tags New tags for the status to belong to (implicit tags are preserved).
+ def call(status, params, mentions, tags)
+ raise ActiveRecord::RecordNotFound if status.blank? || status.discarded? || status.destroyed?
+ return status if params.blank?
+
+ @status = status
+ @account = @status.account
+ @params = params.with_indifferent_access.slice(*ALLOWED_ATTRIBUTES).compact
+ @mentions = (@status.mentions | (mentions || [])).to_set
+ @tags = (tags.nil? ? @status.tags : (tags || [])).to_set
+
+ @params[:text] ||= ''
+ @params[:edited] ||= 1 + @status.edited
+
+ update_tags if @status.local?
+ filter_tags
+ update_mentions
+
+ @delete_payload = Oj.dump(event: :delete, payload: @status.id.to_s)
+ @deleted_tag_ids = @status.tag_ids - @tags.pluck(:id)
+ @deleted_tag_names = @status.tags.pluck(:name) - @tags.pluck(:name)
+ @deleted_attachment_ids = @status.media_attachment_ids - (@params[:media_attachment_ids] || @params[:media_attachments]&.pluck(:id) || [])
+ @new_mention_ids = @mentions.pluck(:id) - @status.mention_ids
+
+ ApplicationRecord.transaction do
+ @status.update!(@params)
+ detach_deleted_tags
+ attach_updated_tags
+ end
+
+ prune_tags
+ prune_attachments
+ reset_status_caches
+
+ SpamCheck.perform(@status)
+ distribute
+
+ @status
+ end
+
+ private
+
+ def prune_attachments
+ RemoveMediaAttachmentsWorker.perform_async(@deleted_attachment_ids) if @deleted_attachment_ids.present?
+ end
+
+ def detach_deleted_tags
+ @status.tags.where(id: @deleted_tag_ids).destroy_all if @deleted_tag_ids.present?
+ end
+
+ def prune_tags
+ @account.featured_tags.where(tag_id: @deleted_tag_ids).each do |featured_tag|
+ featured_tag.decrement(@status.id)
+ end
+
+ if @status.public_visibility?
+ return if @deleted_tag_names.blank?
+
+ @deleted_tag_names.each do |hashtag|
+ redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @delete_payload)
+ redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @delete_payload) if @status.local?
+ end
+ end
+ end
+
+ def update_tags
+ old_explicit_tags = Tag.matching_name(Extractor.extract_hashtags(@status.text))
+ @tags |= Tag.find_or_create_by_names(Extractor.extract_hashtags(@params[:text]))
+
+ # Preserve implicit tags attached to the original status.
+ # TODO: Let locals remove them from edits.
+ @tags |= @status.tags.where.not(id: old_explicit_tags.select(:id))
+ end
+
+ def filter_tags
+ @tags.select! { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i }
+ end
+
+ def update_mentions
+ @params[:text], @mentions = ResolveMentionsService.new.call(@status, text: @params[:text], mentions: @mentions)
+ end
+
+ def attach_updated_tags
+ tag_ids = @status.tag_ids.to_set
+ new_tag_ids = []
+ now = Time.now.utc
+
+ @tags.each do |tag|
+ next if tag_ids.include?(tag.id) || /\A(#{Tag::HASHTAG_NAME_RE})\z/i =~ $LAST_READ_LINE
+
+ @status.tags << tag
+ new_tag_ids << tag.id
+ TrendingTags.record_use!(tag, @account, now) if @status.public_visibility?
+ end
+
+ return unless @status.local? && @status.distributable?
+
+ @account.featured_tags.where(tag_id: new_tag_ids).each do |featured_tag|
+ featured_tag.increment(now)
+ end
+ end
+
+ def reset_status_caches
+ Rails.cache.delete_matched("statuses/#{@status.id}-*")
+ Rails.cache.delete("statuses/#{@status.id}")
+ Rails.cache.delete(@status)
+ redis.zremrangebyscore("spam_check:#{@account.id}", @status.id, @status.id)
+ end
+
+ def distribute
+ LinkCrawlWorker.perform_in(rand(1..30).seconds, @status.id) unless @status.spoiler_text?
+ DistributionWorker.perform_async(@status.id)
+ ActivityPub::DistributionWorker.perform_async(@status.id) if @status.local? && !@status.local_only?
+
+ mentions = @status.active_mentions.includes(:account).where(id: @new_mention_ids, accounts: { domain: nil })
+ mentions.each { |mention| LocalNotificationWorker.perform_async(mention.account.id, mention.id, mention.class.name) }
+ end
+end
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index e4997ba0e..7e28bc9eb 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -43,7 +43,7 @@ class ActivityPub::DistributionWorker
end
def payload
- @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account))
+ @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, update: true), ActivityPub::ActivitySerializer, signer: @account))
end
def relay!
diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb
index d4d0148ac..eaeb8a8b8 100644
--- a/app/workers/activitypub/reply_distribution_worker.rb
+++ b/app/workers/activitypub/reply_distribution_worker.rb
@@ -29,6 +29,6 @@ class ActivityPub::ReplyDistributionWorker
end
def payload
- @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
+ @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status, update: true), ActivityPub::ActivitySerializer, signer: @status.account))
end
end
diff --git a/app/workers/publish_scheduled_status_worker.rb b/app/workers/publish_scheduled_status_worker.rb
index ce42f7be7..a5166f6a8 100644
--- a/app/workers/publish_scheduled_status_worker.rb
+++ b/app/workers/publish_scheduled_status_worker.rb
@@ -21,6 +21,8 @@ class PublishScheduledStatusWorker
options.tap do |options_hash|
options_hash[:application] = Doorkeeper::Application.find(options_hash.delete(:application_id)) if options[:application_id]
options_hash[:thread] = Status.find(options_hash.delete(:in_reply_to_id)) if options_hash[:in_reply_to_id]
+ options_hash[:mentions] = Mention.where(id: options_hash.delete(:mention_ids)) if options_hash[:mention_ids]
+ options_hash[:status] = Status.find_by(id: options_hash.delete(:status_id)) if options_hash[:status_id]
end
end
end
diff --git a/app/workers/remove_media_attachments_worker.rb b/app/workers/remove_media_attachments_worker.rb
new file mode 100644
index 000000000..d5bac6ab8
--- /dev/null
+++ b/app/workers/remove_media_attachments_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class RemoveMediaAttachmentsWorker
+ include Sidekiq::Worker
+
+ def perform(attachment_ids)
+ RemoveMediaAttachmentsService.new.call(attachment_ids)
+ rescue ActiveRecord::RecordNotFound
+ true
+ end
+end
diff --git a/app/workers/revoke_status_worker.rb b/app/workers/revoke_status_worker.rb
new file mode 100644
index 000000000..8cc2b1623
--- /dev/null
+++ b/app/workers/revoke_status_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class RevokeStatusWorker
+ include Sidekiq::Worker
+
+ def perform(status_id, account_ids)
+ RevokeStatusService.new.call(Status.find(status_id), account_ids)
+ rescue ActiveRecord::RecordNotFound
+ true
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index 801d4c541..b0d064c35 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -290,7 +290,7 @@ Rails.application.routes.draw do
# JSON / REST API
namespace :v1 do
- resources :statuses, only: [:create, :show, :destroy] do
+ resources :statuses, only: [:create, :update, :show, :destroy] do
scope module: :statuses do
resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
resources :favourited_by, controller: :favourited_by_accounts, only: :index
diff --git a/db/migrate/20200630222227_add_edited_to_statuses.rb b/db/migrate/20200630222227_add_edited_to_statuses.rb
new file mode 100644
index 000000000..c0a5abb97
--- /dev/null
+++ b/db/migrate/20200630222227_add_edited_to_statuses.rb
@@ -0,0 +1,10 @@
+class AddEditedToStatuses < ActiveRecord::Migration[5.2]
+ def up
+ add_column :statuses, :edited, :int
+ change_column_default :statuses, :edited, 0
+ end
+
+ def down
+ remove_column :statuses, :edited
+ end
+end
diff --git a/db/migrate/20200630222517_backfill_default_statuses_edited.rb b/db/migrate/20200630222517_backfill_default_statuses_edited.rb
new file mode 100644
index 000000000..cbcbd600b
--- /dev/null
+++ b/db/migrate/20200630222517_backfill_default_statuses_edited.rb
@@ -0,0 +1,14 @@
+class BackfillDefaultStatusesEdited < ActiveRecord::Migration[5.2]
+ disable_ddl_transaction!
+
+ def up
+ Rails.logger.info('Backfilling "edited" column of table "statuses" to default value 0...')
+ Status.unscoped.in_batches do |statuses|
+ statuses.update_all(edited: 0)
+ end
+ end
+
+ def down
+ true
+ end
+end
diff --git a/db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb b/db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb
new file mode 100644
index 000000000..f35a2fc99
--- /dev/null
+++ b/db/migrate/20200702032702_add_conversation_id_index_to_statuses.rb
@@ -0,0 +1,7 @@
+class AddConversationIdIndexToStatuses < ActiveRecord::Migration[5.2]
+ disable_ddl_transaction!
+
+ def change
+ safety_assured { add_index :statuses, :conversation_id, where: 'deleted_at IS NULL', algorithm: :concurrently, name: :index_statuses_on_conversation_id }
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d2767752a..7b17b2128 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2020_06_28_133322) do
+ActiveRecord::Schema.define(version: 2020_07_02_032702) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -785,7 +785,9 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
t.bigint "poll_id"
t.string "content_type"
t.datetime "deleted_at"
+ t.integer "edited", default: 0
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
+ t.index ["conversation_id"], name: "index_statuses_on_conversation_id", where: "(deleted_at IS NULL)"
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
diff --git a/monsterfork.code-workspace b/monsterfork.code-workspace
index 8f4183e8f..e67eae18c 100644
--- a/monsterfork.code-workspace
+++ b/monsterfork.code-workspace
@@ -3,5 +3,9 @@
{
"path": "."
}
- ]
+ ],
+ "settings": {
+ "typescript.surveys.enabled": false,
+ "javascript.format.enable": false
+ }
}
--
cgit