# frozen_string_literal: true
# == Schema Information
#
# Table name: statuses
#
# id :bigint(8) not null, primary key
# uri :string
# text :text default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# in_reply_to_id :bigint(8)
# reblog_of_id :bigint(8)
# url :string
# sensitive :boolean default(FALSE), not null
# visibility :integer default("public"), not null
# spoiler_text :text default(""), not null
# reply :boolean default(FALSE), not null
# language :string
# conversation_id :bigint(8)
# local :boolean
# account_id :bigint(8) not null
# application_id :bigint(8)
# in_reply_to_account_id :bigint(8)
# poll_id :bigint(8)
# deleted_at :datetime
# edited_at :datetime
# trendable :boolean
# ordered_media_attachment_ids :bigint(8) is an Array
#
class Status < ApplicationRecord
include Discard :: Model
include Paginable
include Cacheable
include StatusThreadingConcern
include StatusSnapshotConcern
include RateLimitable
include StatusSafeReblogInsert
include StatusSearchConcern
rate_limit by : :account , family : :statuses
self . discard_column = :deleted_at
# If `override_timestamps` is set at creation time, Snowflake ID creation
# will be based on current time instead of `created_at`
attr_accessor :override_timestamps
update_index ( 'statuses' , :proper )
update_index ( 'public_statuses' , :proper )
enum visibility : { public : 0 , unlisted : 1 , private : 2 , direct : 3 , limited : 4 } , _suffix : :visibility
belongs_to :application , class_name : 'Doorkeeper::Application' , optional : true
belongs_to :account , inverse_of : :statuses
belongs_to :in_reply_to_account , class_name : 'Account' , optional : true
belongs_to :conversation , optional : true
belongs_to :preloadable_poll , class_name : 'Poll' , foreign_key : 'poll_id' , optional : true , inverse_of : false
belongs_to :thread , foreign_key : 'in_reply_to_id' , class_name : 'Status' , inverse_of : :replies , optional : true
belongs_to :reblog , foreign_key : 'reblog_of_id' , class_name : 'Status' , inverse_of : :reblogs , optional : true
has_many :favourites , inverse_of : :status , dependent : :destroy
has_many :bookmarks , inverse_of : :status , dependent : :destroy
has_many :reblogs , foreign_key : 'reblog_of_id' , class_name : 'Status' , inverse_of : :reblog , dependent : :destroy
has_many :reblogged_by_accounts , through : :reblogs , class_name : 'Account' , source : :account
has_many :replies , foreign_key : 'in_reply_to_id' , class_name : 'Status' , inverse_of : :thread
has_many :mentions , dependent : :destroy , inverse_of : :status
refactor(vacuum statuses): reduce amount of db queries and load for each query - improve performance (#21487)
* refactor(statuses_vacuum): remove dead code - unused
Method is not called inside class and private.
Clean up dead code.
* refactor(statuses_vacuum): make retention_period present test explicit
This private method only hides functionality.
It is best practice to be as explicit as possible.
* refactor(statuses_vacuum): improve query performance
- fix statuses_scope having sub-select for Account.remote scope by
`joins(:account).merge(Account.remote)`
- fix statuses_scope unnecessary use of `Status.arel_table[:id].lt`
because it is inexplicit, bad practice and even slower than normal
`.where('statuses.id < ?'`
- fix statuses_scope remove select(:id, :visibility) for having reusable
active record query batches (no re queries)
- fix vacuum_statuses! to use in_batches instead of find_in_batches,
because in_batches delivers a full blown active record query result,
in stead of an array - no requeries necessary
- send(:unlink_from_conversations) not to perform another db query, but
reuse the in_batches result instead.
- remove now obsolete remove_from_account_conversations method
- remove_from_search_index uses array of ids, instead of mapping
the ids from an array - this should be more efficient
- use the in_batches scope to call delete_all, instead of running
another db query for this - because it is again more efficient
- add TODO comment for calling models private method with send
* refactor(status): simplify unlink_from_conversations
- add `has_many through:` relation mentioned_accounts
- use model scope local instead of method call `Status#local?`
- more readable add account to inbox_owners when account.local?
* refactor(status): searchable_by way less sub selects
These queries all included a sub-select. Doing the same with a joins
should be more efficient.
Since this method does 5 such queries, this should be significant,
since it technically halves the query count.
This is how it was:
```ruby
[3] pry(main)> Status.first.mentions.where(account: Account.local, silent: false).explain
Status Load (1.6ms) SELECT "statuses".* FROM "statuses" WHERE "statuses"."deleted_at" IS NULL ORDER BY "statuses"."id" DESC LIMIT $1 [["LIMIT", 1]]
Mention Load (1.5ms) SELECT "mentions".* FROM "mentions" WHERE "mentions"."status_id" = $1 AND "mentions"."account_id" IN (SELECT "accounts"."id" FROM "accounts" WHERE "accounts"."domain" IS NULL) AND "mentions"."silent" = $2 [["status_id", 109382923142288414], ["silent", false]]
=> EXPLAIN for: SELECT "mentions".* FROM "mentions" WHERE "mentions"."status_id" = $1 AND "mentions"."account_id" IN (SELECT "accounts"."id" FROM "accounts" WHERE "accounts"."domain" IS NULL) AND "mentions"."silent" = $2 [["status_id", 109382923142288414], ["silent", false]]
QUERY PLAN
------------------------------------------------------------------------------------------------------------------
Nested Loop (cost=0.15..23.08 rows=1 width=41)
-> Seq Scan on accounts (cost=0.00..10.90 rows=1 width=8)
Filter: (domain IS NULL)
-> Index Scan using index_mentions_on_account_id_and_status_id on mentions (cost=0.15..8.17 rows=1 width=41)
Index Cond: ((account_id = accounts.id) AND (status_id = '109382923142288414'::bigint))
Filter: (NOT silent)
(6 rows)
```
This is how it is with this change:
```ruby
[4] pry(main)> Status.first.mentions.joins(:account).merge(Account.local).active.explain
Status Load (1.7ms) SELECT "statuses".* FROM "statuses" WHERE "statuses"."deleted_at" IS NULL ORDER BY "statuses"."id" DESC LIMIT $1 [["LIMIT", 1]]
Mention Load (0.7ms) SELECT "mentions".* FROM "mentions" INNER JOIN "accounts" ON "accounts"."id" = "mentions"."account_id" WHERE "mentions"."status_id" = $1 AND "accounts"."domain" IS NULL AND "mentions"."silent" = $2 [["status_id", 109382923142288414], ["silent", false]]
=> EXPLAIN for: SELECT "mentions".* FROM "mentions" INNER JOIN "accounts" ON "accounts"."id" = "mentions"."account_id" WHERE "mentions"."status_id" = $1 AND "accounts"."domain" IS NULL AND "mentions"."silent" = $2 [["status_id", 109382923142288414], ["silent", false]]
QUERY PLAN
------------------------------------------------------------------------------------------------------------------
Nested Loop (cost=0.15..23.08 rows=1 width=41)
-> Seq Scan on accounts (cost=0.00..10.90 rows=1 width=8)
Filter: (domain IS NULL)
-> Index Scan using index_mentions_on_account_id_and_status_id on mentions (cost=0.15..8.17 rows=1 width=41)
Index Cond: ((account_id = accounts.id) AND (status_id = '109382923142288414'::bigint))
Filter: (NOT silent)
(6 rows)
```
2 years ago
has_many :mentioned_accounts , through : :mentions , source : :account , class_name : 'Account'
has_many :active_mentions , - > { active } , class_name : 'Mention' , inverse_of : :status
has_many :media_attachments , dependent : :nullify
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards
has_one :notification , as : :activity , dependent : :destroy
has_one :status_stat , inverse_of : :status
has_one :poll , inverse_of : :status , dependent : :destroy
has_one :trend , class_name : 'StatusTrend' , inverse_of : :status
validates :uri , uniqueness : true , presence : true , unless : :local?
validates :text , presence : true , unless : - > { with_media? || reblog? }
validates_with StatusLengthValidator
validates_with DisallowedHashtagsValidator
validates :reblog , uniqueness : { scope : :account } , if : :reblog?
validates :visibility , exclusion : { in : %w( direct limited ) } , if : :reblog?
accepts_nested_attributes_for :poll
default_scope { recent . kept }
scope :recent , - > { reorder ( id : :desc ) }
scope :remote , - > { where ( local : false ) . where . not ( uri : nil ) }
scope :local , - > { where ( local : true ) . or ( where ( uri : nil ) ) }
scope :with_accounts , - > ( ids ) { where ( id : ids ) . includes ( :account ) }
scope :without_replies , - > { where ( 'statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id' ) }
scope :without_reblogs , - > { where ( statuses : { reblog_of_id : nil } ) }
scope :with_public_visibility , - > { where ( visibility : :public ) }
scope :tagged_with , - > ( tag_ids ) { joins ( :statuses_tags ) . where ( statuses_tags : { tag_id : tag_ids } ) }
scope :excluding_silenced_accounts , - > { left_outer_joins ( :account ) . where ( accounts : { silenced_at : nil } ) }
scope :including_silenced_accounts , - > { left_outer_joins ( :account ) . where . not ( accounts : { silenced_at : nil } ) }
scope :not_excluded_by_account , - > ( account ) { where . not ( account_id : account . excluded_from_timeline_account_ids ) }
scope :not_domain_blocked_by_account , - > ( account ) { account . excluded_from_timeline_domains . blank? ? left_outer_joins ( :account ) : left_outer_joins ( :account ) . where ( 'accounts.domain IS NULL OR accounts.domain NOT IN (?)' , account . excluded_from_timeline_domains ) }
scope :tagged_with_all , lambda { | tag_ids |
Array ( tag_ids ) . map ( & :to_i ) . reduce ( self ) do | result , id |
result . where ( << ~ SQL . squish , tag_id : id )
EXISTS ( SELECT 1 FROM statuses_tags WHERE statuses_tags . status_id = statuses . id AND statuses_tags . tag_id = :tag_id )
SQL
end
}
scope :tagged_with_none , lambda { | tag_ids |
where ( 'NOT EXISTS (SELECT * FROM statuses_tags forbidden WHERE forbidden.status_id = statuses.id AND forbidden.tag_id IN (?))' , tag_ids )
}
after_create_commit :trigger_create_webhooks
after_update_commit :trigger_update_webhooks
after_create_commit :increment_counter_caches
after_destroy_commit :decrement_counter_caches
after_create_commit :store_uri , if : :local?
after_create_commit :update_statistics , if : :local?
before_validation :prepare_contents , if : :local?
before_validation :set_reblog
before_validation :set_visibility
before_validation :set_conversation
before_validation :set_local
around_create Mastodon :: Snowflake :: Callbacks
after_create :set_poll_id
# The `prepend: true` option below ensures this runs before
# the `dependent: destroy` callbacks remove relevant records
before_destroy :unlink_from_conversations! , prepend : true
cache_associated :application ,
:media_attachments ,
:conversation ,
:status_stat ,
:tags ,
:preview_cards ,
:preloadable_poll ,
account : [ :account_stat , user : :role ] ,
active_mentions : { account : :account_stat } ,
reblog : [
:application ,
:tags ,
:preview_cards ,
:media_attachments ,
:conversation ,
:status_stat ,
:preloadable_poll ,
account : [ :account_stat , user : :role ] ,
active_mentions : { account : :account_stat } ,
] ,
thread : { account : :account_stat }
delegate :domain , to : :account , prefix : true
REAL_TIME_WINDOW = 6 . hours
def cache_key
" v3: #{ super } "
end
def to_log_human_identifier
account . acct
end
def to_log_permalink
ActivityPub :: TagManager . instance . uri_for ( self )
end
def reply?
! in_reply_to_id . nil? || attributes [ 'reply' ]
end
def local?
attributes [ 'local' ] || uri . nil?
end
def in_reply_to_local_account?
reply? && thread & . account & . local?
end
def reblog?
! reblog_of_id . nil?
end
def within_realtime_window?
created_at > = REAL_TIME_WINDOW . ago
end
def verb
if destroyed?
:delete
else
reblog? ? :share : :post
end
end
def object_type
reply? ? :comment : :note
end
def proper
reblog? ? reblog : self
end
def content
proper . text
end
def target
reblog
end
def preview_card
preview_cards . first
end
def hidden?
! distributable?
end
def distributable?
public_visibility? || unlisted_visibility?
end
alias sign? distributable?
def with_media?
ordered_media_attachments . any?
end
def with_preview_card?
preview_cards . any?
end
def with_poll?
preloadable_poll . present?
end
def non_sensitive_with_media?
! sensitive? && with_media?
end
def reported?
@reported || = Report . where ( target_account : account ) . unresolved . where ( '? = ANY(status_ids)' , id ) . exists?
end
def emojis
return @emojis if defined? ( @emojis )
fields = [ spoiler_text , text ]
fields += preloadable_poll . options unless preloadable_poll . nil?
@emojis = CustomEmoji . from_text ( fields . join ( ' ' ) , account . domain )
end
def ordered_media_attachments
if ordered_media_attachment_ids . nil?
media_attachments
else
map = media_attachments . index_by ( & :id )
ordered_media_attachment_ids . filter_map { | media_attachment_id | map [ media_attachment_id ] }
end
end
def replies_count
status_stat & . replies_count || 0
end
def reblogs_count
status_stat & . reblogs_count || 0
end
def favourites_count
status_stat & . favourites_count || 0
end
def increment_count! ( key )
update_status_stat! ( key = > public_send ( key ) + 1 )
end
def decrement_count! ( key )
update_status_stat! ( key = > [ public_send ( key ) - 1 , 0 ] . max )
end
def trendable?
if attributes [ 'trendable' ] . nil?
account . trendable?
else
attributes [ 'trendable' ]
end
end
def requires_review?
attributes [ 'trendable' ] . nil? && account . requires_review?
end
def requires_review_notification?
attributes [ 'trendable' ] . nil? && account . requires_review_notification?
end
class << self
def selectable_visibilities
visibilities . keys - %w( direct limited )
end
def favourites_map ( status_ids , account_id )
Favourite . select ( 'status_id' ) . where ( status_id : status_ids ) . where ( account_id : account_id ) . each_with_object ( { } ) { | f , h | h [ f . status_id ] = true }
end
def bookmarks_map ( status_ids , account_id )
Bookmark . select ( 'status_id' ) . where ( status_id : status_ids ) . where ( account_id : account_id ) . map { | f | [ f . status_id , true ] } . to_h
end
def reblogs_map ( status_ids , account_id )
unscoped . select ( 'reblog_of_id' ) . where ( reblog_of_id : status_ids ) . where ( account_id : account_id ) . each_with_object ( { } ) { | s , h | h [ s . reblog_of_id ] = true }
end
Feature conversations muting (#3017)
* Add <ostatus:conversation /> tag to Atom input/output
Only uses ref attribute (not href) because href would be
the alternate link that's always included also.
Creates new conversation for every non-reply status. Carries
over conversation for every reply. Keeps remote URIs verbatim,
generates local URIs on the fly like the rest of them.
* Conversation muting - prevents notifications that reference a conversation
(including replies, favourites, reblogs) from being created. API endpoints
/api/v1/statuses/:id/mute and /api/v1/statuses/:id/unmute
Currently no way to tell when a status/conversation is muted, so the web UI
only has a "disable notifications" button, doesn't work as a toggle
* Display "Dismiss notifications" on all statuses in notifications column, not just own
* Add "muted" as a boolean attribute on statuses JSON
For now always false on contained reblogs, since it's only relevant for
statuses returned from the notifications endpoint, which are not nested
Remove "Disable notifications" from detailed status view, since it's
only relevant in the notifications column
* Up max class length
* Remove pending test for conversation mute
* Add tests, clean up
* Rename to "mute conversation" and "unmute conversation"
* Raise validation error when trying to mute/unmute status without conversation
8 years ago
def mutes_map ( conversation_ids , account_id )
ConversationMute . select ( 'conversation_id' ) . where ( conversation_id : conversation_ids ) . where ( account_id : account_id ) . each_with_object ( { } ) { | m , h | h [ m . conversation_id ] = true }
Feature conversations muting (#3017)
* Add <ostatus:conversation /> tag to Atom input/output
Only uses ref attribute (not href) because href would be
the alternate link that's always included also.
Creates new conversation for every non-reply status. Carries
over conversation for every reply. Keeps remote URIs verbatim,
generates local URIs on the fly like the rest of them.
* Conversation muting - prevents notifications that reference a conversation
(including replies, favourites, reblogs) from being created. API endpoints
/api/v1/statuses/:id/mute and /api/v1/statuses/:id/unmute
Currently no way to tell when a status/conversation is muted, so the web UI
only has a "disable notifications" button, doesn't work as a toggle
* Display "Dismiss notifications" on all statuses in notifications column, not just own
* Add "muted" as a boolean attribute on statuses JSON
For now always false on contained reblogs, since it's only relevant for
statuses returned from the notifications endpoint, which are not nested
Remove "Disable notifications" from detailed status view, since it's
only relevant in the notifications column
* Up max class length
* Remove pending test for conversation mute
* Add tests, clean up
* Rename to "mute conversation" and "unmute conversation"
* Raise validation error when trying to mute/unmute status without conversation
8 years ago
end
def pins_map ( status_ids , account_id )
StatusPin . select ( 'status_id' ) . where ( status_id : status_ids ) . where ( account_id : account_id ) . each_with_object ( { } ) { | p , h | h [ p . status_id ] = true }
end
def reload_stale_associations! ( cached_items )
account_ids = [ ]
cached_items . each do | item |
account_ids << item . account_id
account_ids << item . reblog . account_id if item . reblog?
end
account_ids . uniq!
status_ids = cached_items . map { | item | item . reblog? ? item . reblog_of_id : item . id } . uniq
return if account_ids . empty?
accounts = Account . where ( id : account_ids ) . includes ( :account_stat , :user ) . index_by ( & :id )
status_stats = StatusStat . where ( status_id : status_ids ) . index_by ( & :status_id )
cached_items . each do | item |
item . account = accounts [ item . account_id ]
item . reblog . account = accounts [ item . reblog . account_id ] if item . reblog?
if item . reblog?
status_stat = status_stats [ item . reblog . id ]
item . reblog . status_stat = status_stat if status_stat . present?
else
status_stat = status_stats [ item . id ]
item . status_stat = status_stat if status_stat . present?
end
end
end
def from_text ( text )
return [ ] if text . blank?
text . scan ( FetchLinkCardService :: URL_PATTERN ) . map ( & :second ) . uniq . filter_map do | url |
status = if TagManager . instance . local_url? ( url )
ActivityPub :: TagManager . instance . uri_to_resource ( url , Status )
else
EntityCache . instance . status ( url )
end
status & . distributable? ? status : nil
end
end
end
def status_stat
super || build_status_stat
end
def discard_with_reblogs
discard_time = Time . current
Status . unscoped . where ( reblog_of_id : id , deleted_at : [ nil , deleted_at ] ) . in_batches . update_all ( deleted_at : discard_time ) unless reblog?
update_attribute ( :deleted_at , discard_time )
end
def unlink_from_conversations!
return unless direct_visibility?
inbox_owners = mentioned_accounts . local
inbox_owners += [ account ] if account . local?
inbox_owners . each do | inbox_owner |
AccountConversation . remove_status ( inbox_owner , self )
end
end
private
def update_status_stat! ( attrs )
return if marked_for_destruction? || destroyed?
status_stat . update ( attrs )
end
def store_uri
update_column ( :uri , ActivityPub :: TagManager . instance . uri_for ( self ) ) if uri . nil?
end
def prepare_contents
text & . strip!
spoiler_text & . strip!
end
def set_reblog
self . reblog = reblog . reblog if reblog? && reblog . reblog?
end
def set_poll_id
update_column ( :poll_id , poll . id ) if association ( :poll ) . loaded? && poll . present?
end
def set_visibility
self . visibility = reblog . visibility if reblog? && visibility . nil?
self . visibility = ( account . locked? ? :private : :public ) if visibility . nil?
self . sensitive = false if sensitive . nil?
end
def set_conversation
self . thread = thread . reblog if thread & . reblog?
self . reply = ! ( in_reply_to_id . nil? && thread . nil? ) unless reply
if reply? && ! thread . nil?
self . in_reply_to_account_id = carried_over_reply_to_account_id
self . conversation_id = thread . conversation_id if conversation_id . nil?
elsif conversation_id . nil?
self . conversation = Conversation . new
end
end
def carried_over_reply_to_account_id
if thread . account_id == account_id && thread . reply?
thread . in_reply_to_account_id
else
thread . account_id
end
end
def set_local
self . local = account . local?
end
def update_statistics
return unless distributable?
ActivityTracker . increment ( 'activity:statuses:local' )
end
def increment_counter_caches
return if direct_visibility?
account & . increment_count! ( :statuses_count )
reblog & . increment_count! ( :reblogs_count ) if reblog?
thread & . increment_count! ( :replies_count ) if in_reply_to_id . present? && distributable?
end
def decrement_counter_caches
return if direct_visibility? || new_record?
account & . decrement_count! ( :statuses_count )
reblog & . decrement_count! ( :reblogs_count ) if reblog?
thread & . decrement_count! ( :replies_count ) if in_reply_to_id . present? && distributable?
end
def trigger_create_webhooks
TriggerWebhookWorker . perform_async ( 'status.created' , 'Status' , id ) if local?
end
def trigger_update_webhooks
TriggerWebhookWorker . perform_async ( 'status.updated' , 'Status' , id ) if local?
end
end