about summary refs log tree commit diff
path: root/app/services/update_status_service.rb
blob: 1f587635351f788fb8e3375f45764c823050a6a9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# frozen_string_literal: true

class UpdateStatusService < BaseService
  include Redisable
  include ImgProxyHelper

  ALLOWED_ATTRIBUTES = %i(
    spoiler_text
    title
    text
    original_text
    footer
    content_type
    language
    sensitive
    visibility
    local_only
    media_attachments
    media_attachment_ids
    application
    expires_at
  ).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 = nil, tags = nil)
    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[:original_text] = @params[:text]
    @params[:published]     = true if @status.published?
    @params[:edited]      ||= 1 + @status.edited if @params[:published].presence || @status.published?
    @params[:expires_at]  ||= Time.now.utc + (@status.expires_at - @status.created_at) if @status.expires_at.present?
    @params[:sensitive]     = true if @account.sensitized?

    @params[:originally_local_only] = @params[:local_only] unless @status.published?

    RemoveStatusService.new.call(@status, unpublish: true) if @status.published? && !@status.local_only? && @params[:local_only]
    update_tags if @status.local?

    @delete_payload         = Oj.dump(event: :delete, payload: @status.id.to_s)
    @deleted_tag_ids        = @status.tags.pluck(:id) - @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) || [])

    ApplicationRecord.transaction do
      @status.update!(@params)

      if @account.local?
        ProcessCommandTagsService.new.call(@account, @status)
      else
        process_inline_images!
      end

      update_mentions
      @status.save!

      detach_deleted_tags
      attach_updated_tags
    end

    prune_tags
    prune_attachments
    reset_status_caches

    SpamCheck.perform(@status) if @status.published?
    distribute

    @status
  end

  private

  def prune_attachments
    @new_inline_ids = @status.inlined_attachments.pluck(:media_attachment_id)
    RemoveMediaAttachmentsWorker.perform_async(@deleted_attachment_ids) if @deleted_attachment_ids.present?
  end

  def detach_deleted_tags
    @status.tags -= Tag.where(id: @deleted_tag_ids) 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

    return unless @status.distributable? && @deleted_tag_names.present?

    @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

  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 update_mentions
    @new_mention_ids = @mentions.pluck(:id) - @status.mention_ids
    @status.text, @mentions = ResolveMentionsService.new.call(@status, mentions: @mentions)
    @new_mention_ids |= (@mentions.pluck(:id) - @new_mention_ids)
  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.distributable?
    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("statuses/*:#{@status.id}")
    Rails.cache.delete(@status)
    Rails.cache.delete_matched("format:#{@status.id}:*")
    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)

    return unless @status.published?

    ActivityPub::DistributionWorker.perform_async(@status.id) if @status.local? && !@status.local_only?

    return unless @status.notify?

    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