about summary refs log tree commit diff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/account.rb19
-rw-r--r--app/models/account_filter.rb17
-rw-r--r--app/models/announcement.rb86
-rw-r--r--app/models/announcement_filter.rb39
-rw-r--r--app/models/announcement_mute.rb19
-rw-r--r--app/models/announcement_reaction.rb37
-rw-r--r--app/models/backup.rb2
-rw-r--r--app/models/bookmark.rb6
-rw-r--r--app/models/concerns/account_finder_concern.rb8
-rw-r--r--app/models/concerns/account_interactions.rb1
-rw-r--r--app/models/concerns/attachmentable.rb11
-rw-r--r--app/models/concerns/status_threading_concern.rb12
-rw-r--r--app/models/custom_emoji.rb2
-rw-r--r--app/models/custom_emoji_filter.rb7
-rw-r--r--app/models/custom_filter.rb1
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/instance_filter.rb5
-rw-r--r--app/models/invite_filter.rb5
-rw-r--r--app/models/media_attachment.rb4
-rw-r--r--app/models/relationship_filter.rb120
-rw-r--r--app/models/report_filter.rb7
-rw-r--r--app/models/status.rb7
-rw-r--r--app/models/tag_filter.rb10
-rw-r--r--app/models/user.rb22
24 files changed, 414 insertions, 35 deletions
diff --git a/app/models/account.rb b/app/models/account.rb
index 25cde6d6c..e46888415 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -50,7 +50,7 @@
 
 class Account < ApplicationRecord
   USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
-  MENTION_RE  = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
+  MENTION_RE  = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[[:word:]\.\-]+[a-z0-9]+)?)/i
 
   include AccountAssociations
   include AccountAvatar
@@ -74,14 +74,13 @@ class Account < ApplicationRecord
   enum protocol: [:ostatus, :activitypub]
 
   validates :username, presence: true
+  validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
 
   # Remote user validations
-  validates :username, uniqueness: { scope: :domain, case_sensitive: true }, if: -> { !local? && will_save_change_to_username? }
   validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? }
 
   # Local user validations
   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
-  validates_with UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? }
   validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
   validates :display_name, length: { maximum: MAX_DISPLAY_NAME_LENGTH }, if: -> { local? && will_save_change_to_display_name? }
   validates :note, note_length: { maximum: MAX_NOTE_LENGTH }, if: -> { local? && will_save_change_to_note? }
@@ -168,6 +167,10 @@ class Account < ApplicationRecord
     local? ? username : "#{username}@#{domain}"
   end
 
+  def pretty_acct
+    local? ? username : "#{username}@#{Addressable::IDNA.to_unicode(domain)}"
+  end
+
   def local_username_and_domain
     "#{username}@#{Rails.configuration.x.local_domain}"
   end
@@ -312,10 +315,6 @@ class Account < ApplicationRecord
     self.fields = tmp
   end
 
-  def subscription(webhook_url)
-    @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
-  end
-
   def save_with_optional_media!
     save!
   rescue ActiveRecord::RecordInvalid
@@ -478,6 +477,12 @@ class Account < ApplicationRecord
       records
     end
 
+    def from_text(text)
+      return [] if text.blank?
+
+      text.scan(MENTION_RE).map { |match| match.first.split('@', 2) }.uniq.map { |(username, domain)| EntityCache.instance.mention(username, domain) }
+    end
+
     private
 
     def generate_query_for_search(terms)
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index c3b1fe08d..c7bf07787 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -1,6 +1,21 @@
 # frozen_string_literal: true
 
 class AccountFilter
+  KEYS = %i(
+    local
+    remote
+    by_domain
+    active
+    pending
+    silenced
+    suspended
+    username
+    display_name
+    email
+    ip
+    staff
+  ).freeze
+
   attr_reader :params
 
   def initialize(params)
@@ -50,7 +65,7 @@ class AccountFilter
     when 'email'
       accounts_with_users.merge User.matches_email(value)
     when 'ip'
-      valid_ip?(value) ? accounts_with_users.where('users.current_sign_in_ip <<= ?', value) : Account.none
+      valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value)) : Account.none
     when 'staff'
       accounts_with_users.merge User.staff
     else
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
new file mode 100644
index 000000000..d99502f44
--- /dev/null
+++ b/app/models/announcement.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcements
+#
+#  id           :bigint(8)        not null, primary key
+#  text         :text             default(""), not null
+#  published    :boolean          default(FALSE), not null
+#  all_day      :boolean          default(FALSE), not null
+#  scheduled_at :datetime
+#  starts_at    :datetime
+#  ends_at      :datetime
+#  created_at   :datetime         not null
+#  updated_at   :datetime         not null
+#  published_at :datetime
+#
+
+class Announcement < ApplicationRecord
+  scope :unpublished, -> { where(published: false) }
+  scope :published, -> { where(published: true) }
+  scope :without_muted, ->(account) { joins("LEFT OUTER JOIN announcement_mutes ON announcement_mutes.announcement_id = announcements.id AND announcement_mutes.account_id = #{account.id}").where('announcement_mutes.id IS NULL') }
+  scope :chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.published_at, announcements.created_at) ASC')) }
+
+  has_many :announcement_mutes, dependent: :destroy
+  has_many :announcement_reactions, dependent: :destroy
+
+  validates :text, presence: true
+  validates :starts_at, presence: true, if: -> { ends_at.present? }
+  validates :ends_at, presence: true, if: -> { starts_at.present? }
+
+  before_validation :set_all_day
+  before_validation :set_published, on: :create
+
+  def publish!
+    update!(published: true, published_at: Time.now.utc, scheduled_at: nil)
+  end
+
+  def unpublish!
+    update!(published: false, scheduled_at: nil)
+  end
+
+  def time_range?
+    starts_at.present? && ends_at.present?
+  end
+
+  def mentions
+    @mentions ||= Account.from_text(text)
+  end
+
+  def tags
+    @tags ||= Tag.find_or_create_by_names(Extractor.extract_hashtags(text))
+  end
+
+  def emojis
+    @emojis ||= CustomEmoji.from_text(text)
+  end
+
+  def reactions(account = nil)
+    records = begin
+      scope = announcement_reactions.group(:announcement_id, :name, :custom_emoji_id).order(Arel.sql('MIN(created_at) ASC'))
+
+      if account.nil?
+        scope.select('name, custom_emoji_id, count(*) as count, false as me')
+      else
+        scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from announcement_reactions r where r.account_id = #{account.id} and r.announcement_id = announcement_reactions.announcement_id and r.name = announcement_reactions.name) as me")
+      end
+    end
+
+    ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
+    records
+  end
+
+  private
+
+  def set_all_day
+    self.all_day = false if starts_at.blank? || ends_at.blank?
+  end
+
+  def set_published
+    return unless scheduled_at.blank? || scheduled_at.past?
+
+    self.published = true
+    self.published_at = Time.now.utc
+  end
+end
diff --git a/app/models/announcement_filter.rb b/app/models/announcement_filter.rb
new file mode 100644
index 000000000..950852460
--- /dev/null
+++ b/app/models/announcement_filter.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class AnnouncementFilter
+  KEYS = %i(
+    published
+    unpublished
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = Announcement.unscoped
+
+    params.each do |key, value|
+      next if key.to_s == 'page'
+
+      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+    end
+
+    scope.chronological
+  end
+
+  private
+
+  def scope_for(key, _value)
+    case key.to_s
+    when 'published'
+      Announcement.published
+    when 'unpublished'
+      Announcement.unpublished
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+end
diff --git a/app/models/announcement_mute.rb b/app/models/announcement_mute.rb
new file mode 100644
index 000000000..46fda2f5d
--- /dev/null
+++ b/app/models/announcement_mute.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcement_mutes
+#
+#  id              :bigint(8)        not null, primary key
+#  account_id      :bigint(8)
+#  announcement_id :bigint(8)
+#  created_at      :datetime         not null
+#  updated_at      :datetime         not null
+#
+
+class AnnouncementMute < ApplicationRecord
+  belongs_to :account
+  belongs_to :announcement, inverse_of: :announcement_mutes
+
+  validates :account_id, uniqueness: { scope: :announcement_id }
+end
diff --git a/app/models/announcement_reaction.rb b/app/models/announcement_reaction.rb
new file mode 100644
index 000000000..d22771034
--- /dev/null
+++ b/app/models/announcement_reaction.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcement_reactions
+#
+#  id              :bigint(8)        not null, primary key
+#  account_id      :bigint(8)
+#  announcement_id :bigint(8)
+#  name            :string           default(""), not null
+#  custom_emoji_id :bigint(8)
+#  created_at      :datetime         not null
+#  updated_at      :datetime         not null
+#
+
+class AnnouncementReaction < ApplicationRecord
+  after_commit :queue_publish
+
+  belongs_to :account
+  belongs_to :announcement, inverse_of: :announcement_reactions
+  belongs_to :custom_emoji, optional: true
+
+  validates :name, presence: true
+  validates_with ReactionValidator
+
+  before_validation :set_custom_emoji
+
+  private
+
+  def set_custom_emoji
+    self.custom_emoji = CustomEmoji.local.find_by(disabled: false, shortcode: name) if name.present?
+  end
+
+  def queue_publish
+    PublishAnnouncementReactionWorker.perform_async(announcement_id, name) unless announcement.destroyed?
+  end
+end
diff --git a/app/models/backup.rb b/app/models/backup.rb
index 8eeb1748a..d242fd62c 100644
--- a/app/models/backup.rb
+++ b/app/models/backup.rb
@@ -7,11 +7,11 @@
 #  user_id           :bigint(8)
 #  dump_file_name    :string
 #  dump_content_type :string
-#  dump_file_size    :bigint
 #  dump_updated_at   :datetime
 #  processed         :boolean          default(FALSE), not null
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
+#  dump_file_size    :bigint(8)
 #
 
 class Backup < ApplicationRecord
diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb
index 01dc48ee7..916261a17 100644
--- a/app/models/bookmark.rb
+++ b/app/models/bookmark.rb
@@ -3,11 +3,11 @@
 #
 # Table name: bookmarks
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  status_id  :bigint(8)        not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
-#  account_id :integer          not null
-#  status_id  :integer          not null
 #
 
 class Bookmark < ApplicationRecord
diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb
index a54c2174d..04b2c981b 100644
--- a/app/models/concerns/account_finder_concern.rb
+++ b/app/models/concerns/account_finder_concern.rb
@@ -48,7 +48,7 @@ module AccountFinderConcern
     end
 
     def with_usernames
-      Account.where.not(username: '')
+      Account.where.not(Account.arel_table[:username].lower.eq '')
     end
 
     def matching_username
@@ -56,11 +56,7 @@ module AccountFinderConcern
     end
 
     def matching_domain
-      if domain.nil?
-        Account.where(domain: nil)
-      else
-        Account.where(Account.arel_table[:domain].lower.eq domain.to_s.downcase)
-      end
+      Account.where(Account.arel_table[:domain].lower.eq(domain.nil? ? nil : domain.to_s.downcase))
     end
   end
 end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index f27d39483..14bcf7bb1 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -84,6 +84,7 @@ module AccountInteractions
     has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
     has_many :conversation_mutes, dependent: :destroy
     has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
+    has_many :announcement_mutes, dependent: :destroy
   end
 
   def follow!(other_account, reblogs: nil, uri: nil)
diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb
index 3bbc6453c..43ff8ac12 100644
--- a/app/models/concerns/attachmentable.rb
+++ b/app/models/concerns/attachmentable.rb
@@ -9,6 +9,7 @@ module Attachmentable
   GIF_MATRIX_LIMIT = 921_600    # 1280x720px
 
   included do
+    before_post_process :obfuscate_file_name
     before_post_process :set_file_extensions
     before_post_process :check_image_dimensions
     before_post_process :set_file_content_type
@@ -68,4 +69,14 @@ module Attachmentable
   rescue Terrapin::CommandLineError
     ''
   end
+
+  def obfuscate_file_name
+    self.class.attachment_definitions.each_key do |attachment_name|
+      attachment = send(attachment_name)
+
+      next if attachment.blank? || attachment.queued_for_write[:original].blank?
+
+      attachment.instance_write :file_name, SecureRandom.hex(8) + File.extname(attachment.instance_read(:file_name))
+    end
+  end
 end
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index 15eb695cd..a0ead1995 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -81,12 +81,12 @@ module StatusThreadingConcern
   end
 
   def find_statuses_from_tree_path(ids, account, promote: false)
-    statuses    = statuses_with_accounts(ids).to_a
+    statuses    = Status.with_accounts(ids).to_a
     account_ids = statuses.map(&:account_id).uniq
     domains     = statuses.map(&:account_domain).compact.uniq
     relations   = relations_map_for_account(account, account_ids, domains)
 
-    statuses.reject! { |status| filter_from_context?(status, account, relations) }
+    statuses.reject! { |status| StatusFilter.new(status, account, relations).filtered? }
 
     # Order ancestors/descendants by tree path
     statuses.sort_by! { |status| ids.index(status.id) }
@@ -125,12 +125,4 @@ module StatusThreadingConcern
       domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
     }
   end
-
-  def statuses_with_accounts(ids)
-    Status.where(id: ids).includes(:account)
-  end
-
-  def filter_from_context?(status, account, relations)
-    StatusFilter.new(status, account, relations).filtered?
-  end
 end
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 0dacaf654..d177cf281 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -67,7 +67,7 @@ class CustomEmoji < ApplicationRecord
   end
 
   class << self
-    def from_text(text, domain)
+    def from_text(text, domain = nil)
       return [] if text.blank?
 
       shortcodes = text.scan(SCAN_RE).map(&:first).uniq
diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb
index 15b8da1d1..414e1fcdd 100644
--- a/app/models/custom_emoji_filter.rb
+++ b/app/models/custom_emoji_filter.rb
@@ -1,6 +1,13 @@
 # frozen_string_literal: true
 
 class CustomEmojiFilter
+  KEYS = %i(
+    local
+    remote
+    by_domain
+    shortcode
+  ).freeze
+
   attr_reader :params
 
   def initialize(params)
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 382562fb8..8df8a4fbf 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -20,6 +20,7 @@ class CustomFilter < ApplicationRecord
     notifications
     public
     thread
+    account
   ).freeze
 
   include Expireable
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 4e865b850..f0a5bd296 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -54,7 +54,7 @@ class DomainBlock < ApplicationRecord
       segments = uri.normalized_host.split('.')
       variants = segments.map.with_index { |_, i| segments[i..-1].join('.') }
 
-      where(domain: variants[0..-2]).order(Arel.sql('char_length(domain) desc')).first
+      where(domain: variants).order(Arel.sql('char_length(domain) desc')).first
     end
   end
 
diff --git a/app/models/instance_filter.rb b/app/models/instance_filter.rb
index 8bfab826d..9c467bc27 100644
--- a/app/models/instance_filter.rb
+++ b/app/models/instance_filter.rb
@@ -1,6 +1,11 @@
 # frozen_string_literal: true
 
 class InstanceFilter
+  KEYS = %i(
+    limited
+    by_domain
+  ).freeze
+
   attr_reader :params
 
   def initialize(params)
diff --git a/app/models/invite_filter.rb b/app/models/invite_filter.rb
index 7d89bad4a..9685d4abb 100644
--- a/app/models/invite_filter.rb
+++ b/app/models/invite_filter.rb
@@ -1,6 +1,11 @@
 # frozen_string_literal: true
 
 class InviteFilter
+  KEYS = %i(
+    available
+    expired
+  ).freeze
+
   attr_reader :params
 
   def initialize(params)
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 880599028..6a0b892f6 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -142,6 +142,7 @@ class MediaAttachment < ApplicationRecord
 
   validates :account, presence: true
   validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local?
+  validates :file, presence: true, if: :local?
 
   scope :attached,   -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
   scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
@@ -202,9 +203,12 @@ class MediaAttachment < ApplicationRecord
   end
 
   after_commit :reset_parent_cache, on: :update
+
   before_create :prepare_description, unless: :local?
   before_create :set_shortcode
+
   before_post_process :set_type_and_extension
+
   before_save :set_meta
 
   class << self
diff --git a/app/models/relationship_filter.rb b/app/models/relationship_filter.rb
new file mode 100644
index 000000000..e6859bf3d
--- /dev/null
+++ b/app/models/relationship_filter.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+class RelationshipFilter
+  KEYS = %i(
+    relationship
+    status
+    by_domain
+    activity
+    order
+    location
+  ).freeze
+
+  attr_reader :params, :account
+
+  def initialize(account, params)
+    @account = account
+    @params  = params
+
+    set_defaults!
+  end
+
+  def results
+    scope = scope_for('relationship', params['relationship'].to_s.strip)
+
+    params.each do |key, value|
+      next if key.to_s == 'page'
+
+      scope.merge!(scope_for(key.to_s, value.to_s.strip)) if value.present?
+    end
+
+    scope
+  end
+
+  private
+
+  def set_defaults!
+    params['relationship'] = 'following' if params['relationship'].blank?
+    params['order']        = 'recent' if params['order'].blank?
+  end
+
+  def scope_for(key, value)
+    case key
+    when 'relationship'
+      relationship_scope(value)
+    when 'by_domain'
+      by_domain_scope(value)
+    when 'location'
+      location_scope(value)
+    when 'status'
+      status_scope(value)
+    when 'order'
+      order_scope(value)
+    when 'activity'
+      activity_scope(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+
+  def relationship_scope(value)
+    case value
+    when 'following'
+      account.following.eager_load(:account_stat).reorder(nil)
+    when 'followed_by'
+      account.followers.eager_load(:account_stat).reorder(nil)
+    when 'mutual'
+      account.followers.eager_load(:account_stat).reorder(nil).merge(Account.where(id: account.following))
+    when 'invited'
+      Account.joins(user: :invite).merge(Invite.where(user: account.user)).eager_load(:account_stat).reorder(nil)
+    else
+      raise "Unknown relationship: #{value}"
+    end
+  end
+
+  def by_domain_scope(value)
+    Account.where(domain: value)
+  end
+
+  def location_scope(value)
+    case value
+    when 'local'
+      Account.local
+    when 'remote'
+      Account.remote
+    else
+      raise "Unknown location: #{value}"
+    end
+  end
+
+  def status_scope(value)
+    case value
+    when 'moved'
+      Account.where.not(moved_to_account_id: nil)
+    when 'primary'
+      Account.where(moved_to_account_id: nil)
+    else
+      raise "Unknown status: #{value}"
+    end
+  end
+
+  def order_scope(value)
+    case value
+    when 'active'
+      Account.by_recent_status
+    when 'recent'
+      params[:relationship] == 'invited' ? Account.recent : Follow.recent
+    else
+      raise "Unknown order: #{value}"
+    end
+  end
+
+  def activity_scope(value)
+    case value
+    when 'dormant'
+      AccountStat.where(last_status_at: nil).or(AccountStat.where(AccountStat.arel_table[:last_status_at].lt(1.month.ago)))
+    else
+      raise "Unknown activity: #{value}"
+    end
+  end
+end
diff --git a/app/models/report_filter.rb b/app/models/report_filter.rb
index abf53cbab..c32d4359e 100644
--- a/app/models/report_filter.rb
+++ b/app/models/report_filter.rb
@@ -1,6 +1,13 @@
 # frozen_string_literal: true
 
 class ReportFilter
+  KEYS = %i(
+    resolved
+    account_id
+    target_account_id
+    by_target_domain
+  ).freeze
+
   attr_reader :params
 
   def initialize(params)
diff --git a/app/models/status.rb b/app/models/status.rb
index c189d19bf..f4284f771 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -87,6 +87,7 @@ class Status < ApplicationRecord
   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 IS NULL') }
   scope :with_public_visibility, -> { where(visibility: :public) }
@@ -200,8 +201,12 @@ class Status < ApplicationRecord
   def title
     if destroyed?
       "#{account.acct} deleted status"
+    elsif reblog?
+      preview = sensitive ? '<sensitive>' : text.slice(0, 10).split("\n")[0]
+      "#{account.acct} shared #{reblog.account.acct}'s: #{preview}"
     else
-      reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
+      preview = sensitive ? '<sensitive>' : text.slice(0, 20).split("\n")[0]
+      "#{account.acct}: #{preview}"
     end
   end
 
diff --git a/app/models/tag_filter.rb b/app/models/tag_filter.rb
index 8921e186b..a9ff5b703 100644
--- a/app/models/tag_filter.rb
+++ b/app/models/tag_filter.rb
@@ -1,6 +1,16 @@
 # frozen_string_literal: true
 
 class TagFilter
+  KEYS = %i(
+    directory
+    reviewed
+    unreviewed
+    pending_review
+    popular
+    active
+    name
+  ).freeze
+
   attr_reader :params
 
   def initialize(params)
diff --git a/app/models/user.rb b/app/models/user.rb
index 49cfc25ca..cd8a6f273 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -93,6 +93,7 @@ class User < ApplicationRecord
   scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
   scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) }
   scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
+  scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
 
   before_validation :sanitize_languages
@@ -128,9 +129,7 @@ class User < ApplicationRecord
   end
 
   def disable!
-    update!(disabled: true,
-            last_sign_in_at: current_sign_in_at,
-            current_sign_in_at: nil)
+    update!(disabled: true)
   end
 
   def enable!
@@ -247,7 +246,7 @@ class User < ApplicationRecord
                                  ip: request.remote_ip).session_id
   end
 
-  def exclusive_session(id)
+  def clear_other_sessions(id)
     session_activations.exclusive(id)
   end
 
@@ -290,6 +289,21 @@ class User < ApplicationRecord
     setting_display_media == 'hide_all'
   end
 
+  def recent_ips
+    @recent_ips ||= begin
+      arr = []
+
+      session_activations.each do |session_activation|
+        arr << [session_activation.updated_at, session_activation.ip]
+      end
+
+      arr << [current_sign_in_at, current_sign_in_ip] if current_sign_in_ip.present?
+      arr << [last_sign_in_at, last_sign_in_ip] if last_sign_in_ip.present?
+
+      arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse!
+    end
+  end
+
   protected
 
   def send_devise_notification(notification, *args)