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.rb21
-rw-r--r--app/models/bookmark.rb26
-rw-r--r--app/models/concerns/account_interactions.rb4
-rw-r--r--app/models/glitch.rb7
-rw-r--r--app/models/glitch/keyword_mute.rb100
-rw-r--r--app/models/glitch/keyword_mute_helper.rb27
-rw-r--r--app/models/media_attachment.rb30
-rw-r--r--app/models/mute.rb2
-rw-r--r--app/models/status.rb30
-rw-r--r--app/models/stream_entry.rb2
-rw-r--r--app/models/user.rb4
11 files changed, 243 insertions, 10 deletions
diff --git a/app/models/account.rb b/app/models/account.rb
index a166fb474..c1ce1e99e 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -58,6 +58,8 @@ class Account < ApplicationRecord
   include Attachmentable
   include Paginable
 
+  MAX_NOTE_LENGTH = 500
+
   enum protocol: [:ostatus, :activitypub]
 
   # Local users
@@ -73,12 +75,13 @@ class Account < ApplicationRecord
   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: 30 }, if: -> { local? && will_save_change_to_display_name? }
-  validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? }
+  validate :note_length_does_not_exceed_length_limit, if: -> { local? && will_save_change_to_note? }
 
   # Timelines
   has_many :stream_entries, inverse_of: :account, dependent: :destroy
   has_many :statuses, inverse_of: :account, dependent: :destroy
   has_many :favourites, inverse_of: :account, dependent: :destroy
+  has_many :bookmarks, inverse_of: :account, dependent: :destroy
   has_many :mentions, inverse_of: :account, dependent: :destroy
   has_many :notifications, inverse_of: :account, dependent: :destroy
 
@@ -417,6 +420,22 @@ class Account < ApplicationRecord
     self.public_key  = keypair.public_key.to_pem
   end
 
+  YAML_START = "---\r\n"
+  YAML_END = "\r\n...\r\n"
+
+  def note_length_does_not_exceed_length_limit
+    note_without_metadata = note
+    if note.start_with? YAML_START
+      idx = note.index YAML_END
+      unless idx.nil?
+        note_without_metadata = note[(idx + YAML_END.length) .. -1]
+      end
+    end
+    if note_without_metadata.mb_chars.grapheme_length > MAX_NOTE_LENGTH
+      errors.add(:note, "can't be longer than 500 graphemes")
+    end
+  end
+
   def normalize_domain
     return if local?
 
diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb
new file mode 100644
index 000000000..01dc48ee7
--- /dev/null
+++ b/app/models/bookmark.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: bookmarks
+#
+#  id         :integer          not null, primary key
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#  account_id :integer          not null
+#  status_id  :integer          not null
+#
+
+class Bookmark < ApplicationRecord
+  include Paginable
+
+  update_index('statuses#status', :status) if Chewy.enabled?
+
+  belongs_to :account, inverse_of: :bookmarks
+  belongs_to :status,  inverse_of: :bookmarks
+
+  validates :status_id, uniqueness: { scope: :account_id }
+
+  before_validation do
+    self.status = status.reblog if status&.reblog?
+  end
+end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 4a01eed65..20fc74ba6 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -173,6 +173,10 @@ module AccountInteractions
     status.proper.favourites.where(account: self).exists?
   end
 
+  def bookmarked?(status)
+    status.proper.bookmarks.where(account: self).exists?
+  end
+
   def reblogged?(status)
     status.proper.reblogs.where(account: self).exists?
   end
diff --git a/app/models/glitch.rb b/app/models/glitch.rb
new file mode 100644
index 000000000..0e497babc
--- /dev/null
+++ b/app/models/glitch.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Glitch
+  def self.table_name_prefix
+    'glitch_'
+  end
+end
diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb
new file mode 100644
index 000000000..f9c380f39
--- /dev/null
+++ b/app/models/glitch/keyword_mute.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: glitch_keyword_mutes
+#
+#  id         :integer          not null, primary key
+#  account_id :integer          not null
+#  keyword    :string           not null
+#  whole_word :boolean          default(TRUE), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class Glitch::KeywordMute < ApplicationRecord
+  belongs_to :account, required: true
+
+  validates_presence_of :keyword
+
+  after_commit :invalidate_cached_matchers
+
+  def self.text_matcher_for(account_id)
+    TextMatcher.new(account_id)
+  end
+
+  def self.tag_matcher_for(account_id)
+    TagMatcher.new(account_id)
+  end
+
+  private
+
+  def invalidate_cached_matchers
+    Rails.cache.delete(TextMatcher.cache_key(account_id))
+    Rails.cache.delete(TagMatcher.cache_key(account_id))
+  end
+
+  class RegexpMatcher
+    attr_reader :account_id
+    attr_reader :regex
+
+    def initialize(account_id)
+      @account_id = account_id
+      regex_text = Rails.cache.fetch(self.class.cache_key(account_id)) { make_regex_text }
+      @regex = /#{regex_text}/
+    end
+
+    protected
+
+    def keywords
+      Glitch::KeywordMute.where(account_id: account_id).pluck(:whole_word, :keyword)
+    end
+
+    def boundary_regex_for_keyword(keyword)
+      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
+      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
+
+      /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
+    end
+  end
+
+  class TextMatcher < RegexpMatcher
+    def self.cache_key(account_id)
+      format('keyword_mutes:regex:text:%s', account_id)
+    end
+
+    def matches?(str)
+      !!(regex =~ str)
+    end
+
+    private
+
+    def make_regex_text
+      kws = keywords.map! do |whole_word, keyword|
+        whole_word ? boundary_regex_for_keyword(keyword) : /(?i:#{keyword})/
+      end
+
+      Regexp.union(kws).source
+    end
+  end
+
+  class TagMatcher < RegexpMatcher
+    def self.cache_key(account_id)
+      format('keyword_mutes:regex:tag:%s', account_id)
+    end
+
+    def matches?(tags)
+      tags.pluck(:name).any? { |n| regex =~ n }
+    end
+
+    private
+
+    def make_regex_text
+      kws = keywords.map! do |whole_word, keyword|
+        term = (Tag::HASHTAG_RE =~ keyword) ? $1 : keyword
+        whole_word ? boundary_regex_for_keyword(term) : term
+      end
+
+      Regexp.union(kws).source
+    end
+  end
+end
diff --git a/app/models/glitch/keyword_mute_helper.rb b/app/models/glitch/keyword_mute_helper.rb
new file mode 100644
index 000000000..6d067947f
--- /dev/null
+++ b/app/models/glitch/keyword_mute_helper.rb
@@ -0,0 +1,27 @@
+require 'html2text'
+
+class Glitch::KeywordMuteHelper
+  attr_reader :text_matcher
+  attr_reader :tag_matcher
+
+  def initialize(receiver_id)
+    @text_matcher   = Glitch::KeywordMute.text_matcher_for(receiver_id)
+    @tag_matcher    = Glitch::KeywordMute.tag_matcher_for(receiver_id)
+  end
+
+  def matches?(status)
+    matchers_match?(status) || (status.reblog? && matchers_match?(status.reblog))
+  end
+
+  private
+
+  def matchers_match?(status)
+    text_matcher.matches?(prepare_text(status.text)) ||
+      text_matcher.matches?(prepare_text(status.spoiler_text)) ||
+      tag_matcher.matches?(status.tags)
+  end
+
+  def prepare_text(text)
+    Html2Text.convert(text)
+  end
+end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index f9a8f322e..c041dce51 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -22,13 +22,15 @@
 class MediaAttachment < ApplicationRecord
   self.inheritance_column = nil
 
-  enum type: [:image, :gifv, :video, :unknown]
+  enum type: [:image, :gifv, :video, :audio, :unknown]
 
   IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'].freeze
   VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v'].freeze
+  AUDIO_FILE_EXTENSIONS = ['.mp3', '.m4a', '.wav', '.ogg'].freeze
 
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
+  AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
 
   IMAGE_STYLES = {
     original: {
@@ -42,6 +44,22 @@ class MediaAttachment < ApplicationRecord
     },
   }.freeze
 
+  AUDIO_STYLES = {
+    original: {
+      format: 'mp4',
+      convert_options: {
+        output: {
+          filter_complex: '"[0:a]compand,showwaves=s=640x360:mode=line,format=yuv420p[v]"',
+          map: '"[v]" -map 0:a', 
+          threads: 2,
+          vcodec: 'libx264',
+          acodec: 'aac',
+          movflags: '+faststart',
+        },
+      },
+    },
+  }.freeze
+
   VIDEO_STYLES = {
     small: {
       convert_options: {
@@ -64,7 +82,9 @@ class MediaAttachment < ApplicationRecord
                     processors: ->(f) { file_processors f },
                     convert_options: { all: '-quality 90 -strip' }
 
-  validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
+  include Remotable
+
+  validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
   validates_attachment_size :file, less_than: LIMIT
   remotable_attachment :file, LIMIT
 
@@ -141,6 +161,8 @@ class MediaAttachment < ApplicationRecord
         }
       elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
         IMAGE_STYLES
+      elsif AUDIO_MIME_TYPES.include? f.instance.file_content_type
+        AUDIO_STYLES
       else
         VIDEO_STYLES
       end
@@ -151,6 +173,8 @@ class MediaAttachment < ApplicationRecord
         [:gif_transcoder]
       elsif VIDEO_MIME_TYPES.include? f.file_content_type
         [:video_transcoder]
+      elsif AUDIO_MIME_TYPES.include? f.file_content_type
+        [:audio_transcoder]
       else
         [:thumbnail]
       end
@@ -175,7 +199,7 @@ class MediaAttachment < ApplicationRecord
   end
 
   def set_type_and_extension
-    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
+    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : AUDIO_MIME_TYPES.include?(file_content_type) ? :audio : :image
   end
 
   def set_meta
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 0e00c2278..639120f7d 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -6,9 +6,9 @@
 #  id                 :bigint(8)        not null, primary key
 #  created_at         :datetime         not null
 #  updated_at         :datetime         not null
+#  hide_notifications :boolean          default(TRUE), not null
 #  account_id         :bigint(8)        not null
 #  target_account_id  :bigint(8)        not null
-#  hide_notifications :boolean          default(TRUE), not null
 #
 
 class Mute < ApplicationRecord
diff --git a/app/models/status.rb b/app/models/status.rb
index 853e75b43..0b3a7c0aa 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -23,6 +23,8 @@
 #  account_id             :bigint(8)        not null
 #  application_id         :bigint(8)
 #  in_reply_to_account_id :bigint(8)
+#  local_only             :boolean
+#  full_status_text       :text             default(""), not null
 #
 
 class Status < ApplicationRecord
@@ -49,6 +51,7 @@ class Status < ApplicationRecord
   belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, counter_cache: :reblogs_count, 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 :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
   has_many :mentions, dependent: :destroy
@@ -81,6 +84,8 @@ class Status < ApplicationRecord
   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 :not_local_only, -> { where(local_only: [false, nil]) }
+
   cache_associated :account, :application, :media_attachments, :conversation, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, :conversation, mentions: :account], thread: :account
 
   delegate :domain, to: :account, prefix: true
@@ -172,6 +177,8 @@ class Status < ApplicationRecord
 
   around_create Mastodon::Snowflake::Callbacks
 
+  before_create :set_locality
+
   before_validation :prepare_contents, if: :local?
   before_validation :set_reblog
   before_validation :set_visibility
@@ -216,6 +223,10 @@ class Status < ApplicationRecord
       Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |f| [f.status_id, true] }.to_h
     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)
       select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).reorder(nil).map { |s| [s.reblog_of_id, true] }.to_h
     end
@@ -252,7 +263,7 @@ class Status < ApplicationRecord
       visibility = [:public, :unlisted]
 
       if account.nil?
-        where(visibility: visibility)
+        where(visibility: visibility).not_local_only
       elsif target_account.blocking?(account) # get rid of blocked peeps
         none
       elsif account.id == target_account.id # author can see own stuff
@@ -291,7 +302,7 @@ class Status < ApplicationRecord
     end
 
     def filter_timeline_default(query)
-      query.excluding_silenced_accounts
+      query.not_local_only.excluding_silenced_accounts
     end
 
     def account_silencing_filter(account)
@@ -303,6 +314,15 @@ class Status < ApplicationRecord
     end
   end
 
+  def marked_local_only?
+    # match both with and without U+FE0F (the emoji variation selector)
+    /#{local_only_emoji}\ufe0f?\z/.match?(content)
+  end
+
+  def local_only_emoji
+    '👁'
+  end
+
   private
 
   def store_uri
@@ -328,6 +348,12 @@ class Status < ApplicationRecord
     self.sensitive = sensitive || spoiler_text.present?
   end
 
+  def set_locality
+    if account.domain.nil? && !attribute_changed?(:local_only)
+      self.local_only = marked_local_only?
+    end
+  end
+
   def set_conversation
     self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply
 
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index a2f273281..dd383eb81 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -27,7 +27,7 @@ class StreamEntry < ApplicationRecord
   scope :recent, -> { reorder(id: :desc) }
   scope :with_includes, -> { includes(:account, status: STATUS_INCLUDES) }
 
-  delegate :target, :title, :content, :thread,
+  delegate :target, :title, :content, :thread, :local_only?,
            to: :status,
            allow_nil: true
 
diff --git a/app/models/user.rb b/app/models/user.rb
index a9f3e1da2..24beb77b2 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -85,8 +85,8 @@ class User < ApplicationRecord
 
   has_many :session_activations, dependent: :destroy
 
-  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal,
-           :reduce_motion, :system_font_ui, :noindex, :theme, :display_sensitive_media,
+  delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal,
+           :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_sensitive_media,
            to: :settings, prefix: :setting, allow_nil: false
 
   attr_accessor :invite_code