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.rb30
-rw-r--r--app/models/concerns/account_avatar.rb2
-rw-r--r--app/models/concerns/account_header.rb2
-rw-r--r--app/models/concerns/account_interactions.rb24
-rw-r--r--app/models/concerns/remotable.rb6
-rw-r--r--app/models/custom_emoji.rb44
-rw-r--r--app/models/form/admin_settings.rb2
-rw-r--r--app/models/import.rb1
-rw-r--r--app/models/instance_filter.rb28
-rw-r--r--app/models/media_attachment.rb16
-rw-r--r--app/models/mute.rb11
-rw-r--r--app/models/preview_card.rb31
-rw-r--r--app/models/remote_follow.rb2
-rw-r--r--app/models/remote_profile.rb12
-rw-r--r--app/models/report.rb2
-rw-r--r--app/models/session_activation.rb1
-rw-r--r--app/models/site_upload.rb44
-rw-r--r--app/models/status.rb53
-rw-r--r--app/models/status_pin.rb18
-rw-r--r--app/models/subscription.rb1
-rw-r--r--app/models/user.rb19
-rw-r--r--app/models/web/push_subscription.rb160
22 files changed, 318 insertions, 191 deletions
diff --git a/app/models/account.rb b/app/models/account.rb
index e217733f5..de7998db4 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -52,7 +52,8 @@ class Account < ApplicationRecord
   include AccountInteractions
   include Attachmentable
   include Remotable
-  include EmojiHelper
+
+  MAX_NOTE_LENGTH = 500
 
   enum protocol: [:ostatus, :activitypub]
 
@@ -77,6 +78,10 @@ class Account < ApplicationRecord
   has_many :mentions, inverse_of: :account, dependent: :destroy
   has_many :notifications, inverse_of: :account, dependent: :destroy
 
+  # Pinned statuses
+  has_many :status_pins, inverse_of: :account, dependent: :destroy
+  has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
+
   # Media
   has_many :media_attachments, dependent: :destroy
 
@@ -91,7 +96,7 @@ class Account < ApplicationRecord
   scope :local, -> { where(domain: nil) }
   scope :without_followers, -> { where(followers_count: 0) }
   scope :with_followers, -> { where('followers_count > 0') }
-  scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
+  scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
   scope :partitioned, -> { order('row_number() over (partition by domain)') }
   scope :silenced, -> { where(silenced: true) }
   scope :suspended, -> { where(suspended: true) }
@@ -100,11 +105,13 @@ class Account < ApplicationRecord
   scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
   scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
   scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
+  scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
 
   delegate :email,
            :current_sign_in_ip,
            :current_sign_in_at,
            :confirmed?,
+           :admin?,
            :locale,
            to: :user,
            prefix: true,
@@ -133,11 +140,11 @@ class Account < ApplicationRecord
   end
 
   def keypair
-    OpenSSL::PKey::RSA.new(private_key || public_key)
+    @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
   end
 
   def subscription(webhook_url)
-    OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url)
+    @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
   end
 
   def save_with_optional_media!
@@ -167,10 +174,18 @@ class Account < ApplicationRecord
   end
 
   class << self
+    def readonly_attributes
+      super - %w(statuses_count following_count followers_count)
+    end
+
     def domains
       reorder(nil).pluck('distinct accounts.domain')
     end
 
+    def inboxes
+      reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")
+    end
+
     def triadic_closures(account, limit: 5, offset: 0)
       sql = <<-SQL.squish
         WITH first_degree AS (
@@ -255,15 +270,12 @@ class Account < ApplicationRecord
   def prepare_contents
     display_name&.strip!
     note&.strip!
-
-    self.display_name = emojify(display_name)
-    self.note         = emojify(note)
   end
 
   def generate_keys
     return unless local?
 
-    keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 1024 : 2048)
+    keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048)
     self.private_key = keypair.to_pem
     self.public_key  = keypair.public_key.to_pem
   end
@@ -279,7 +291,7 @@ class Account < ApplicationRecord
         note_without_metadata = note[(idx + YAML_END.length) .. -1]
       end
     end
-    if note_without_metadata.mb_chars.grapheme_length > 500
+    if note_without_metadata.mb_chars.grapheme_length > MAX_NOTE_LENGTH
       errors.add(:note, "can't be longer than 500 graphemes")
     end
   end
diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb
index a6527a85b..8a5c9a22c 100644
--- a/app/models/concerns/account_avatar.rb
+++ b/app/models/concerns/account_avatar.rb
@@ -8,7 +8,7 @@ module AccountAvatar
   class_methods do
     def avatar_styles(file)
       styles = { original: '120x120#' }
-      styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+      styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
       styles
     end
 
diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb
index 4ba9212a2..aff2aa3f9 100644
--- a/app/models/concerns/account_header.rb
+++ b/app/models/concerns/account_header.rb
@@ -8,7 +8,7 @@ module AccountHeader
   class_methods do
     def header_styles(file)
       styles = { original: '700x335#' }
-      styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+      styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
       styles
     end
 
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 9ffed2910..0afdebf89 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -17,7 +17,11 @@ module AccountInteractions
     end
 
     def muting_map(target_account_ids, account_id)
-      follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+      Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
+        mapping[mute.target_account_id] = {
+          notifications: mute.hide_notifications?
+        }
+      end
     end
 
     def requested_map(target_account_ids, account_id)
@@ -70,8 +74,14 @@ module AccountInteractions
     block_relationships.find_or_create_by!(target_account: other_account)
   end
 
-  def mute!(other_account)
-    mute_relationships.find_or_create_by!(target_account: other_account)
+  def mute!(other_account, notifications: nil)
+    notifications = true if notifications.nil?
+    mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account)
+    # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
+    if mute.hide_notifications? != notifications
+      mute.hide_notifications = notifications
+      mute.save!
+    end
   end
 
   def mute_conversation!(conversation)
@@ -127,6 +137,10 @@ module AccountInteractions
     conversation_mutes.where(conversation: conversation).exists?
   end
 
+  def muting_notifications?(other_account)
+    mute_relationships.where(target_account: other_account, hide_notifications: true).exists?
+  end
+
   def requested?(other_account)
     follow_requests.where(target_account: other_account).exists?
   end
@@ -138,4 +152,8 @@ module AccountInteractions
   def reblogged?(status)
     status.proper.reblogs.where(account: self).exists?
   end
+
+  def pinned?(status)
+    status_pins.where(status: status).exists?
+  end
 end
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index 1bd87a642..990035b34 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -10,6 +10,8 @@ module Remotable
       alt_method_name = "reset_#{attachment_name}!".to_sym
 
       define_method method_name do |url|
+        return if url.blank?
+
         begin
           parsed_url = Addressable::URI.parse(url).normalize
         rescue Addressable::URI::InvalidURIError
@@ -25,9 +27,11 @@ module Remotable
 
           matches  = response.headers['content-disposition']&.match(/filename="([^"]*)"/)
           filename = matches.nil? ? parsed_url.path.split('/').last : matches[1]
+          basename = SecureRandom.hex(8)
+          extname  = File.extname(filename)
 
           send("#{attachment_name}=", StringIO.new(response.to_s))
-          send("#{attachment_name}_file_name=", filename)
+          send("#{attachment_name}_file_name=", basename + extname)
 
           self[attribute_name] = url if has_attribute?(attribute_name)
         rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError => e
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
new file mode 100644
index 000000000..e80c58155
--- /dev/null
+++ b/app/models/custom_emoji.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: custom_emojis
+#
+#  id                 :integer          not null, primary key
+#  shortcode          :string           default(""), not null
+#  domain             :string
+#  image_file_name    :string
+#  image_content_type :string
+#  image_file_size    :integer
+#  image_updated_at   :datetime
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
+#
+
+class CustomEmoji < ApplicationRecord
+  SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}'
+
+  SCAN_RE = /(?<=[^[:alnum:]:]|\n|^)
+    :(#{SHORTCODE_RE_FRAGMENT}):
+    (?=[^[:alnum:]:]|$)/x
+
+  has_attached_file :image
+
+  validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { in: 0..50.kilobytes }
+  validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
+
+  scope :local, -> { where(domain: nil) }
+
+  include Remotable
+
+  class << self
+    def from_text(text, domain)
+      return [] if text.blank?
+
+      shortcodes = text.scan(SCAN_RE).map(&:first).uniq
+
+      return [] if shortcodes.empty?
+
+      where(shortcode: shortcodes, domain: domain)
+    end
+  end
+end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index c3a04ba65..2b148c82b 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -24,6 +24,8 @@ class Form::AdminSettings
     :open_deletion=,
     :timeline_preview,
     :timeline_preview=,
+    :bootstrap_timeline_accounts,
+    :bootstrap_timeline_accounts=,
     to: Setting
   )
 end
diff --git a/app/models/import.rb b/app/models/import.rb
index 815e02589..4656c3af6 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -28,4 +28,5 @@ class Import < ApplicationRecord
 
   has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
   validates_attachment_content_type :data, content_type: FILE_TYPES
+  validates_attachment_presence :data
 end
diff --git a/app/models/instance_filter.rb b/app/models/instance_filter.rb
new file mode 100644
index 000000000..5073cf1fa
--- /dev/null
+++ b/app/models/instance_filter.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class InstanceFilter
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = Account.remote.by_domain_accounts
+    params.each do |key, value|
+      scope.merge!(scope_for(key, value)) if value.present?
+    end
+    scope
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'domain_name'
+      Account.matches_domain(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 1e8c6d00a..e4a974f96 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -25,6 +25,9 @@ class MediaAttachment < ApplicationRecord
 
   enum type: [:image, :gifv, :video, :unknown]
 
+  IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'].freeze
+  VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v'].freeze
+
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
 
@@ -56,15 +59,21 @@ class MediaAttachment < ApplicationRecord
 
   validates :account, presence: true
 
-  scope :attached, -> { where.not(status_id: nil) }
+  scope :attached,   -> { where.not(status_id: nil) }
   scope :unattached, -> { where(status_id: nil) }
-  scope :local, -> { where(remote_url: '') }
+  scope :local,      -> { where(remote_url: '') }
+  scope :remote,     -> { where.not(remote_url: '') }
+
   default_scope { order(id: :asc) }
 
   def local?
     remote_url.blank?
   end
 
+  def needs_redownload?
+    file.blank? && remote_url.present?
+  end
+
   def to_param
     shortcode
   end
@@ -142,9 +151,11 @@ class MediaAttachment < ApplicationRecord
 
   def populate_meta
     meta = {}
+
     file.queued_for_write.each do |style, file|
       begin
         geo = Paperclip::Geometry.from_file file
+
         meta[style] = {
           width: geo.width.to_i,
           height: geo.height.to_i,
@@ -155,6 +166,7 @@ class MediaAttachment < ApplicationRecord
         meta[style] = {}
       end
     end
+
     meta
   end
 
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 00e5661a7..0d597a275 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -3,11 +3,12 @@
 #
 # Table name: mutes
 #
-#  id                :integer          not null, primary key
-#  account_id        :integer          not null
-#  target_account_id :integer          not null
-#  created_at        :datetime         not null
-#  updated_at        :datetime         not null
+#  id                 :integer          not null, primary key
+#  account_id         :integer          not null
+#  target_account_id  :integer          not null
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
+#  hide_notifications :boolean          default(TRUE), not null
 #
 
 class Mute < ApplicationRecord
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index c334c48aa..e2bf65d94 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -4,16 +4,13 @@
 # Table name: preview_cards
 #
 #  id                 :integer          not null, primary key
-#  status_id          :integer
 #  url                :string           default(""), not null
-#  title              :string
-#  description        :string
+#  title              :string           default(""), not null
+#  description        :string           default(""), not null
 #  image_file_name    :string
 #  image_content_type :string
 #  image_file_size    :integer
 #  image_updated_at   :datetime
-#  created_at         :datetime         not null
-#  updated_at         :datetime         not null
 #  type               :integer          default("link"), not null
 #  html               :text             default(""), not null
 #  author_name        :string           default(""), not null
@@ -22,6 +19,8 @@
 #  provider_url       :string           default(""), not null
 #  width              :integer          default(0), not null
 #  height             :integer          default(0), not null
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
 #
 
 class PreviewCard < ApplicationRecord
@@ -31,21 +30,37 @@ class PreviewCard < ApplicationRecord
 
   enum type: [:link, :photo, :video, :rich]
 
-  belongs_to :status
+  has_and_belongs_to_many :statuses
 
-  has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :image, styles: { original: '280x280>' }, convert_options: { all: '-quality 80 -strip' }
 
   include Attachmentable
   include Remotable
 
-  validates :url, presence: true
+  validates :url, presence: true, uniqueness: true
   validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :image, less_than: 1.megabytes
 
+  before_save :extract_dimensions, if: :link?
+
   def save_with_optional_image!
     save!
   rescue ActiveRecord::RecordInvalid
     self.image = nil
     save!
   end
+
+  private
+
+  def extract_dimensions
+    file = image.queued_for_write[:original]
+
+    return if file.nil?
+
+    geo         = Paperclip::Geometry.from_file(file)
+    self.width  = geo.width.to_i
+    self.height = geo.height.to_i
+  rescue Paperclip::Errors::NotIdentifiedByImageMagickError
+    nil
+  end
 end
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 8366d43c5..c3f867743 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -42,7 +42,7 @@ class RemoteFollow
 
   def acct_resource
     @_acct_resource ||= Goldfinger.finger("acct:#{acct}")
-  rescue Goldfinger::Error
+  rescue Goldfinger::Error, HTTP::ConnectionError
     nil
   end
 
diff --git a/app/models/remote_profile.rb b/app/models/remote_profile.rb
index 93c759930..613911c57 100644
--- a/app/models/remote_profile.rb
+++ b/app/models/remote_profile.rb
@@ -10,11 +10,11 @@ class RemoteProfile
   end
 
   def root
-    @root ||= document.at_xpath('/atom:feed|/atom:entry', atom: TagManager::XMLNS)
+    @root ||= document.at_xpath('/atom:feed|/atom:entry', atom: OStatus::TagManager::XMLNS)
   end
 
   def author
-    @author ||= root.at_xpath('./atom:author|./dfrn:owner', atom: TagManager::XMLNS, dfrn: TagManager::DFRN_XMLNS)
+    @author ||= root.at_xpath('./atom:author|./dfrn:owner', atom: OStatus::TagManager::XMLNS, dfrn: OStatus::TagManager::DFRN_XMLNS)
   end
 
   def hub_link
@@ -22,15 +22,15 @@ class RemoteProfile
   end
 
   def display_name
-    @display_name ||= author.at_xpath('./poco:displayName', poco: TagManager::POCO_XMLNS)&.content
+    @display_name ||= author.at_xpath('./poco:displayName', poco: OStatus::TagManager::POCO_XMLNS)&.content
   end
 
   def note
-    @note ||= author.at_xpath('./atom:summary|./poco:note', atom: TagManager::XMLNS, poco: TagManager::POCO_XMLNS)&.content
+    @note ||= author.at_xpath('./atom:summary|./poco:note', atom: OStatus::TagManager::XMLNS, poco: OStatus::TagManager::POCO_XMLNS)&.content
   end
 
   def scope
-    @scope ||= author.at_xpath('./mastodon:scope', mastodon: TagManager::MTDN_XMLNS)&.content
+    @scope ||= author.at_xpath('./mastodon:scope', mastodon: OStatus::TagManager::MTDN_XMLNS)&.content
   end
 
   def avatar
@@ -48,6 +48,6 @@ class RemoteProfile
   private
 
   def link_href_from_xml(xml, type)
-    xml.at_xpath(%(./atom:link[@rel="#{type}"]/@href), atom: TagManager::XMLNS)&.content
+    xml.at_xpath(%(./atom:link[@rel="#{type}"]/@href), atom: OStatus::TagManager::XMLNS)&.content
   end
 end
diff --git a/app/models/report.rb b/app/models/report.rb
index 4d2552d30..479aa17bb 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -22,6 +22,8 @@ class Report < ApplicationRecord
   scope :unresolved, -> { where(action_taken: false) }
   scope :resolved,   -> { where(action_taken: true) }
 
+  validates :comment, length: { maximum: 1000 }
+
   def statuses
     Status.where(id: status_ids).includes(:account, :media_attachments, :mentions)
   end
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 7eb16af8f..c1645223b 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -25,6 +25,7 @@
 #
 
 class SessionActivation < ApplicationRecord
+  belongs_to :user, inverse_of: :session_activations, required: true
   belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
   belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy
 
diff --git a/app/models/site_upload.rb b/app/models/site_upload.rb
new file mode 100644
index 000000000..8ffdc8313
--- /dev/null
+++ b/app/models/site_upload.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: site_uploads
+#
+#  id                :integer          not null, primary key
+#  var               :string           default(""), not null
+#  file_file_name    :string
+#  file_content_type :string
+#  file_file_size    :integer
+#  file_updated_at   :datetime
+#  meta              :json
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class SiteUpload < ApplicationRecord
+  has_attached_file :file
+
+  validates_attachment_content_type :file, content_type: /\Aimage\/.*\z/
+  validates :var, presence: true, uniqueness: true
+
+  before_save :set_meta
+  after_commit :clear_cache
+
+  def cache_key
+    "site_uploads/#{var}"
+  end
+
+  private
+
+  def set_meta
+    tempfile = file.queued_for_write[:original]
+
+    return if tempfile.nil?
+
+    geometry  = Paperclip::Geometry.from_file(tempfile)
+    self.meta = { width: geometry.width.to_i, height: geometry.height.to_i }
+  end
+
+  def clear_cache
+    Rails.cache.delete(cache_key)
+  end
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index 24eaf7071..ea4c097bf 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -22,6 +22,7 @@
 #  reblogs_count          :integer          default(0), not null
 #  language               :string
 #  conversation_id        :integer
+#  local                  :boolean
 #
 
 class Status < ApplicationRecord
@@ -29,7 +30,6 @@ class Status < ApplicationRecord
   include Streamable
   include Cacheable
   include StatusThreadingConcern
-  include EmojiHelper
 
   enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility
 
@@ -47,12 +47,14 @@ class Status < ApplicationRecord
   has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
   has_many :mentions, dependent: :destroy
   has_many :media_attachments, dependent: :destroy
+
   has_and_belongs_to_many :tags
+  has_and_belongs_to_many :preview_cards
 
   has_one :notification, as: :activity, dependent: :destroy
-  has_one :preview_card, dependent: :destroy
+  has_one :stream_entry, as: :activity, inverse_of: :status
 
-  validates :uri, uniqueness: true, unless: :local?
+  validates :uri, uniqueness: true, presence: true, unless: :local?
   validates :text, presence: true, unless: :reblog?
   validates_with StatusLengthValidator
   validates :reblog, uniqueness: { scope: :account }, if: :reblog?
@@ -60,14 +62,13 @@ class Status < ApplicationRecord
   default_scope { recent }
 
   scope :recent, -> { reorder(id: :desc) }
-  scope :remote, -> { where.not(uri: nil) }
-  scope :local, -> { where(uri: nil) }
+  scope :remote, -> { where(local: false).or(where.not(uri: nil)) }
+  scope :local,  -> { where(local: true).or(where(uri: nil)) }
 
   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) }
   scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
-  scope :local_only, -> { left_outer_joins(:account).where(accounts: { domain: nil }) }
   scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: false }) }
   scope :including_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced: true }) }
   scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
@@ -82,7 +83,7 @@ class Status < ApplicationRecord
   end
 
   def local?
-    uri.nil?
+    attributes['local'] || uri.nil?
   end
 
   def reblog?
@@ -90,7 +91,11 @@ class Status < ApplicationRecord
   end
 
   def verb
-    reblog? ? :share : :post
+    if destroyed?
+      :delete
+    else
+      reblog? ? :share : :post
+    end
   end
 
   def object_type
@@ -110,7 +115,11 @@ class Status < ApplicationRecord
   end
 
   def title
-    reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
+    if destroyed?
+      "#{account.acct} deleted status"
+    else
+      reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
+    end
   end
 
   def hidden?
@@ -121,15 +130,22 @@ class Status < ApplicationRecord
     !sensitive? && media_attachments.any?
   end
 
+  def emojis
+    CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
+  end
+
+  after_create :store_uri, if: :local?
+
   before_validation :prepare_contents, if: :local?
   before_validation :set_reblog
   before_validation :set_visibility
   before_validation :set_conversation
   before_validation :set_sensitivity
+  before_validation :set_local
 
   class << self
     def not_in_filtered_languages(account)
-      where.not(language: account.filtered_languages)
+      where(language: nil).or where.not(language: account.filtered_languages)
     end
 
     def as_home_timeline(account)
@@ -164,6 +180,10 @@ class Status < ApplicationRecord
       ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h
     end
 
+    def pins_map(status_ids, account_id)
+      StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |p| [p.status_id, true] }.to_h
+    end
+
     def reload_stale_associations!(cached_items)
       account_ids = []
 
@@ -203,7 +223,7 @@ class Status < ApplicationRecord
     private
 
     def timeline_scope(local_only = false)
-      starting_scope = local_only ? Status.local_only : Status
+      starting_scope = local_only ? Status.local : Status
       starting_scope
         .with_public_visibility
         .without_reblogs
@@ -239,12 +259,13 @@ class Status < ApplicationRecord
 
   private
 
+  def store_uri
+    update_attribute(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil?
+  end
+
   def prepare_contents
     text&.strip!
     spoiler_text&.strip!
-
-    self.text         = emojify(text)
-    self.spoiler_text = emojify(spoiler_text)
   end
 
   def set_reblog
@@ -278,4 +299,8 @@ class Status < ApplicationRecord
       thread.account_id
     end
   end
+
+  def set_local
+    self.local = account.local?
+  end
 end
diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb
new file mode 100644
index 000000000..a72c19750
--- /dev/null
+++ b/app/models/status_pin.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_pins
+#
+#  id         :integer          not null, primary key
+#  account_id :integer          not null
+#  status_id  :integer          not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class StatusPin < ApplicationRecord
+  belongs_to :account, required: true
+  belongs_to :status, required: true
+
+  validates_with StatusPinValidator
+end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index bf643c1f9..14f1a140c 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -26,6 +26,7 @@ class Subscription < ApplicationRecord
 
   scope :confirmed, -> { where(confirmed: true) }
   scope :future_expiration, -> { where(arel_table[:expires_at].gt(Time.now.utc)) }
+  scope :expired, -> { where(arel_table[:expires_at].lt(Time.now.utc)) }
   scope :active, -> { confirmed.future_expiration }
 
   def lease_seconds=(value)
diff --git a/app/models/user.rb b/app/models/user.rb
index 96a2d09b7..3bf069a31 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -46,6 +46,8 @@ class User < ApplicationRecord
   belongs_to :account, inverse_of: :user, required: true
   accepts_nested_attributes_for :account
 
+  has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
+
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
   validates_with BlacklistedEmailValidator, if: :email_changed?
 
@@ -108,10 +110,25 @@ class User < ApplicationRecord
     settings.noindex
   end
 
+  def setting_theme
+    settings.theme
+  end
+
+  def token_for_app(a)
+    return nil if a.nil? || a.owner != self
+    Doorkeeper::AccessToken
+      .find_or_create_by(application_id: a.id, resource_owner_id: id) do |t|
+
+      t.scopes = a.scopes
+      t.expires_in = Doorkeeper.configuration.access_token_expires_in
+      t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled?
+    end
+  end
+
   def activate_session(request)
     session_activations.activate(session_id: SecureRandom.hex,
                                  user_agent: request.user_agent,
-                                 ip: request.ip).session_id
+                                 ip: request.remote_ip).session_id
   end
 
   def exclusive_session(id)
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index e76f61278..cb15dfa37 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -13,59 +13,14 @@
 #
 
 require 'webpush'
-require_relative '../../models/setting'
 
 class Web::PushSubscription < ApplicationRecord
-  include RoutingHelper
-  include StreamEntriesHelper
-  include ActionView::Helpers::TranslationHelper
-  include ActionView::Helpers::SanitizeHelper
-
   has_one :session_activation
 
-  before_create :send_welcome_notification
-
   def push(notification)
-    name = display_name notification.from_account
-    title = title_str(name, notification)
-    body = body_str notification
-    dir = dir_str body
-    url = url_str notification
-    image = image_str notification
-    actions = actions_arr notification
-
-    access_token = actions.empty? ? nil : find_or_create_access_token(notification).token
-    nsfw = notification.target_status.nil? || notification.target_status.spoiler_text.empty? ? nil : notification.target_status.spoiler_text
-
-    # TODO: Make sure that the payload does not exceed 4KB - Webpush::PayloadTooLarge
-    Webpush.payload_send(
-      message: JSON.generate(
-        title: title,
-        dir: dir,
-        image: image,
-        badge: full_asset_url('badge.png', skip_pipeline: true),
-        tag: notification.id,
-        timestamp: notification.created_at,
-        icon: notification.from_account.avatar_static_url,
-        data: {
-          content: decoder.decode(strip_tags(body)),
-          nsfw: nsfw.nil? ? nil : decoder.decode(strip_tags(nsfw)),
-          url: url,
-          actions: actions,
-          access_token: access_token,
-          message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
-        }
-      ),
-      endpoint: endpoint,
-      p256dh: key_p256dh,
-      auth: key_auth,
-      vapid: {
-        subject: "mailto:#{Setting.site_contact_email}",
-        private_key: Rails.configuration.x.vapid_private_key,
-        public_key: Rails.configuration.x.vapid_public_key,
-      },
-      ttl: 40 * 60 * 60 # 48 hours
-    )
+    I18n.with_locale(session_activation.user.locale || I18n.default_locale) do
+      push_payload(message_from(notification), 48.hours.seconds)
+    end
   end
 
   def pushable?(notification)
@@ -73,120 +28,47 @@ class Web::PushSubscription < ApplicationRecord
   end
 
   def as_payload
-    payload = {
-      id: id,
-      endpoint: endpoint,
-    }
-
+    payload = { id: id, endpoint: endpoint }
     payload[:alerts] = data['alerts'] if data && data.key?('alerts')
-
     payload
   end
 
-  private
-
-  def title_str(name, notification)
-    case notification.type
-    when :mention then translate('push_notifications.mention.title', name: name)
-    when :follow then translate('push_notifications.follow.title', name: name)
-    when :favourite then translate('push_notifications.favourite.title', name: name)
-    when :reblog then translate('push_notifications.reblog.title', name: name)
-    end
+  def access_token
+    find_or_create_access_token.token
   end
 
-  def body_str(notification)
-    case notification.type
-    when :mention then notification.target_status.text
-    when :follow then notification.from_account.note
-    when :favourite then notification.target_status.text
-    when :reblog then notification.target_status.text
-    end
-  end
-
-  def url_str(notification)
-    case notification.type
-    when :mention then web_url("statuses/#{notification.target_status.id}")
-    when :follow then web_url("accounts/#{notification.from_account.id}")
-    when :favourite then web_url("statuses/#{notification.target_status.id}")
-    when :reblog then web_url("statuses/#{notification.target_status.id}")
-    end
-  end
-
-  def actions_arr(notification)
-    actions =
-      case notification.type
-      when :mention then [
-        {
-          title: translate('push_notifications.mention.action_favourite'),
-          icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true),
-          todo: 'request',
-          method: 'POST',
-          action: "/api/v1/statuses/#{notification.target_status.id}/favourite",
-        },
-      ]
-      else []
-      end
-
-    should_hide = notification.type.equal?(:mention) && !notification.target_status.nil? && (notification.target_status.sensitive || !notification.target_status.spoiler_text.empty?)
-    can_boost = notification.type.equal?(:mention) && !notification.target_status.nil? && !notification.target_status.hidden?
-
-    if should_hide
-      actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true), todo: 'expand', action: 'expand')
-    end
-
-    if can_boost
-      actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true), todo: 'request', method: 'POST', action: "/api/v1/statuses/#{notification.target_status.id}/reblog" }
-    end
-
-    actions
-  end
-
-  def image_str(notification)
-    return nil if notification.target_status.nil? || notification.target_status.media_attachments.empty?
-
-    full_asset_url(notification.target_status.media_attachments.first.file.url(:small))
-  end
+  private
 
-  def dir_str(body)
-    rtl?(body) ? 'rtl' : 'ltr'
-  end
+  def push_payload(message, ttl = 5.minutes.seconds)
+    # TODO: Make sure that the payload does not
+    # exceed 4KB - Webpush::PayloadTooLarge
 
-  def send_welcome_notification
     Webpush.payload_send(
-      message: JSON.generate(
-        title: translate('push_notifications.subscribed.title'),
-        icon: full_asset_url('android-chrome-192x192.png', skip_pipeline: true),
-        badge: full_asset_url('badge.png', skip_pipeline: true),
-        data: {
-          content: translate('push_notifications.subscribed.body'),
-          actions: [],
-          url: web_url('notifications'),
-          message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
-        }
-      ),
+      message: Oj.dump(message),
       endpoint: endpoint,
       p256dh: key_p256dh,
       auth: key_auth,
+      ttl: ttl,
       vapid: {
-        subject: "mailto:#{Setting.site_contact_email}",
+        subject: "mailto:#{::Setting.site_contact_email}",
         private_key: Rails.configuration.x.vapid_private_key,
         public_key: Rails.configuration.x.vapid_public_key,
-      },
-      ttl: 5 * 60 # 5 minutes
+      }
     )
   end
 
-  def find_or_create_access_token(notification)
+  def message_from(notification)
+    serializable_resource = ActiveModelSerializers::SerializableResource.new(notification, serializer: Web::NotificationSerializer, scope: self, scope_name: :current_push_subscription)
+    serializable_resource.as_json
+  end
+
+  def find_or_create_access_token
     Doorkeeper::AccessToken.find_or_create_for(
       Doorkeeper::Application.find_by(superapp: true),
-      notification.account.user.id,
+      session_activation.user_id,
       Doorkeeper::OAuth::Scopes.from_string('read write follow'),
       Doorkeeper.configuration.access_token_expires_in,
       Doorkeeper.configuration.refresh_token_enabled?
     )
   end
-
-  def decoder
-    @decoder ||= HTMLEntities.new
-  end
 end