about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/settings/preferences_controller.rb4
-rw-r--r--app/lib/user_settings_decorator.rb24
-rw-r--r--app/models/concerns/account_associations.rb3
-rw-r--r--app/models/queued_boost.rb17
-rw-r--r--app/models/status.rb2
-rw-r--r--app/models/user.rb26
-rw-r--r--app/services/reblog_service.rb30
-rw-r--r--app/services/remove_status_service.rb6
-rw-r--r--app/views/settings/preferences/show.html.haml12
-rw-r--r--app/workers/reblog_status_worker.rb17
-rw-r--r--app/workers/scheduler/boosts_scheduler.rb49
-rw-r--r--config/locales/simple_form.en.yml21
-rw-r--r--config/sidekiq.yml3
-rw-r--r--db/migrate/20190806195913_create_queued_boosts.rb11
-rw-r--r--db/schema.rb14
-rw-r--r--spec/fabricators/queued_boost_fabricator.rb4
-rw-r--r--spec/models/queued_boost_spec.rb5
17 files changed, 230 insertions, 18 deletions
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 2ae90c7fb..483af2c16 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -56,6 +56,10 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_roar_lifespan,
       :setting_delayed_roars,
       :setting_delayed_for,
+      :setting_boost_interval,
+      :setting_boost_random,
+      :setting_boost_interval_from,
+      :setting_boost_interval_to,
       :setting_show_cursor,
 
       :setting_default_privacy,
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 73831208a..b7e0d577b 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -40,6 +40,10 @@ class UserSettingsDecorator
     user.settings['roar_lifespan']       = roar_lifespan_preference if change?('setting_roar_lifespan')
     user.settings['delayed_roars']       = delayed_roars_preference if change?('setting_delayed_roars')
     user.settings['delayed_for']         = delayed_for_preference if change?('setting_delayed_for')
+    user.settings['boost_interval']      = boost_interval_preference if change?('setting_boost_interval')
+    user.settings['boost_random']        = boost_random_preference if change?('setting_boost_random')
+    user.settings['boost_interval_from'] = boost_interval_from_preference if change?('setting_boost_interval_from')
+    user.settings['boost_interval_to']   = boost_interval_to_preference if change?('setting_boost_interval_to')
     user.settings['show_cursor']         = show_cursor_preference if change?('setting_show_cursor')
 
     user.settings['notification_emails'] = merged_notification_emails if change?('notification_emails')
@@ -150,6 +154,26 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_delayed_roars'
   end
 
+  def boost_interval_preference
+    boolean_cast_setting 'setting_boost_interval'
+  end
+
+  def boost_random_preference
+    boolean_cast_setting 'setting_boost_random'
+  end
+
+  def boost_interval_from_preference
+    settings['setting_boost_interval_from']
+  end
+
+  def boost_interval_to_preference
+    settings['setting_boost_interval_to']
+  end
+
+  def delayed_for_preference
+    settings['setting_delayed_for']
+  end
+
   def merged_notification_emails
     user.settings['notification_emails'].merge coerced_settings('notification_emails').to_h
   end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index 0c3725e54..a90104943 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -58,5 +58,8 @@ module AccountAssociations
     # Hashtags
     has_and_belongs_to_many :tags
     has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account
+
+    # queued boosts
+    has_many :queued_boosts, dependent: :destroy, inverse_of: :account
   end
 end
diff --git a/app/models/queued_boost.rb b/app/models/queued_boost.rb
new file mode 100644
index 000000000..b23282697
--- /dev/null
+++ b/app/models/queued_boost.rb
@@ -0,0 +1,17 @@
+# == Schema Information
+#
+# Table name: queued_boosts
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)
+#  status_id  :bigint(8)
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class QueuedBoost < ApplicationRecord
+  belongs_to :account, inverse_of: :queued_boosts
+  belongs_to :status, inverse_of: :queued_boosts
+
+  validates :account_id, uniqueness: { scope: :status_id }
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index 9f11e6d5d..a2d64ecf0 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -76,6 +76,8 @@ class Status < ApplicationRecord
   has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
   has_many :media_attachments, dependent: :nullify
 
+  has_many :queued_boosts, dependent: :destroy, inverse_of: :status
+
   has_and_belongs_to_many :tags
   has_and_belongs_to_many :preview_cards
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 479392642..a0786aa69 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -131,6 +131,10 @@ class User < ApplicationRecord
     :roar_lifespan,
     :delayed_roars,
     :delayed_for,
+    :boost_interval,
+    :boost_random,
+    :boost_interval_from,
+    :boost_interval_to,
     :show_cursor,
 
     :auto_play_gif,
@@ -303,11 +307,11 @@ class User < ApplicationRecord
   end
 
   def max_public_history
-    @_max_public_history ||= (settings.max_public_history || 6)
+    @_max_public_history ||= [1, (settings.max_public_history || 6).to_i].max
   end
 
   def roar_lifespan
-    @_roar_lifespan ||= (settings.roar_lifespan || 0)
+    @_roar_lifespan ||= [0, (settings.roar_lifespan || 0).to_i].max
   end
 
   def delayed_roars?
@@ -315,7 +319,23 @@ class User < ApplicationRecord
   end
 
   def delayed_for
-    @_delayed_for ||= (settings.delayed_for || 60)
+    @_delayed_for ||= [5, (settings.delayed_for || 60).to_i].max
+  end
+
+  def boost_interval?
+    @boost_interval ||= (settings.boost_interval || false)
+  end
+
+  def boost_random?
+    @boost_random ||= (settings.boost_random || false)
+  end
+
+  def boost_interval_from
+    @boost_interval_from ||= [1, (settings.boost_interval_from || 1).to_i].max
+  end
+
+  def boost_interval_to
+    @boost_interval_to ||= [2, (settings.boost_interval_to || 15).to_i].max
   end
 
   def shows_cursor?
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 9ac38ac9d..7d72357f9 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -14,22 +14,30 @@ class ReblogService < BaseService
     authorize_with account, reblogged_status, :reblog?
 
     reblog = account.statuses.find_by(reblog: reblogged_status)
+    new_reblog = reblog.nil?
 
-    return reblog unless reblog.nil?
+    if new_reblog
+      visibility = options[:visibility] || account.user&.setting_default_privacy
+      visibility = reblogged_status.visibility if reblogged_status.hidden?
+      reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility)
+    end
 
-    visibility = options[:visibility] || account.user&.setting_default_privacy
-    visibility = reblogged_status.visibility if reblogged_status.hidden?
-    reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility)
+    if !options[:distribute] && account&.user&.boost_interval?
+      QueuedBoost.find_or_create_by!(account_id: account.id, status_id: reblogged_status.id) if account&.user&.boost_interval?
+    elsif !options[:nodistribute]
+      return reblog unless options[:distribute] || new_reblog
 
-    DistributionWorker.perform_async(reblog.id)
+      DistributionWorker.perform_async(reblog.id)
 
-    unless reblogged_status.local_only?
-      ActivityPub::DistributionWorker.perform_async(reblog.id)
-    end
+      unless reblogged_status.local_only?
+        ActivityPub::DistributionWorker.perform_async(reblog.id)
+      end
 
-    curate_status(reblogged_status)
-    create_notification(reblog) unless options[:skip_notify]
-    bump_potential_friendship(account, reblog)
+      curate_status(reblogged_status)
+
+      create_notification(reblog) unless options[:skip_notify]
+      bump_potential_friendship(account, reblog)
+    end
 
     reblog
   end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index d55ecadef..f9f20aae5 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -17,6 +17,7 @@ class RemoveStatusService < BaseService
 
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
+        remove_from_queued
         remove_from_self if status.account.local?
         remove_from_followers
         remove_from_lists
@@ -46,6 +47,11 @@ class RemoveStatusService < BaseService
 
   private
 
+  def remove_from_queued
+    QueuedBoost.where(account_id: @account.id, status_id: @status.proper.id).destroy_all
+    QueuedBoost.where(status_id: @status.id).destroy_all
+  end
+
   def remove_from_self
     FeedManager.instance.unpush_from_home(@account, @status)
   end
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 07c9fd86f..e6198de6a 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -35,7 +35,13 @@
 
   .fields-group
     = f.input :setting_delayed_roars, as: :boolean, wrapper: :with_label
-    = f.input :setting_delayed_for, collection: [5, 10, 15, 30, 60, 120, 180, 300, 360, 600, 1800, 3600], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.delayed_for.#{item}")]) }, selected: [5, current_user.delayed_for.to_i].max
+    = f.input :setting_delayed_for, collection: [5, 10, 15, 30, 60, 120, 180, 300, 360, 600, 1800, 3600], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.delayed_for.#{item}")]) }, selected: current_user.delayed_for
+
+  .fields-group
+    = f.input :setting_boost_interval, as: :boolean, wrapper: :with_label
+    = f.input :setting_boost_random, as: :boolean, wrapper: :with_label
+    = f.input :setting_boost_interval_from, collection: [1, 2, 3, 4, 5, 6, 10, 15, 30, 60, 120, 180, 300, 360, 720, 1440], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.boost_interval.#{item}")]) }, selected: current_user.boost_interval_from
+    = f.input :setting_boost_interval_to, collection: [1, 2, 3, 4, 5, 6, 10, 15, 30, 60, 120, 180, 300, 360, 720, 1440], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.boost_interval.#{item}")]) }, selected: current_user.boost_interval_to
 
   %hr#settings_other/
 
@@ -54,8 +60,8 @@
   %hr/
 
   .fields-group
-    = f.input :setting_max_public_history, collection: [1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.max_public_history.to_i
-    = f.input :setting_roar_lifespan, collection: [0, 1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.roar_lifespan.to_i
+    = f.input :setting_max_public_history, collection: [1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.max_public_history
+    = f.input :setting_roar_lifespan, collection: [0, 1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.roar_lifespan
     = f.input :setting_hide_public_profile, as: :boolean, wrapper: :with_label
     = f.input :setting_hide_public_outbox, as: :boolean, wrapper: :with_label
 
diff --git a/app/workers/reblog_status_worker.rb b/app/workers/reblog_status_worker.rb
new file mode 100644
index 000000000..c0b2153b2
--- /dev/null
+++ b/app/workers/reblog_status_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ReblogStatusWorker
+  include Sidekiq::Worker
+
+  sidekiq_options unique: :until_executed
+
+  def perform(account_id, status_id, reblog_params = {})
+    account = Account.find(account_id)
+    status = Status.find(status_id)
+    return false if status.destroyed?
+    ReblogService.new.call(account, status, reblog_params.symbolize_keys)
+    true
+  rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid
+    true
+  end
+end
diff --git a/app/workers/scheduler/boosts_scheduler.rb b/app/workers/scheduler/boosts_scheduler.rb
new file mode 100644
index 000000000..de0d89992
--- /dev/null
+++ b/app/workers/scheduler/boosts_scheduler.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class Scheduler::BoostsScheduler
+  include Sidekiq::Worker
+  include Redisable
+
+  sidekiq_options unique: :until_executed, retry: 0
+
+  def perform
+    process_queued_boosts!
+  end
+
+  private
+
+  def process_queued_boosts!
+    queued_accounts.find_each do |account|
+      next if redis.exists("queued_boost:#{account.id}") || account&.user.nil?
+
+      q = next_boost(account.id, account.user.boost_random?)
+      next if q.empty?
+
+      from_interval = account.user.boost_interval_from
+      to_interval = account.user.boost_interval_to
+
+      if from_interval > to_interval
+        from_interval, to_interval = [to_interval, from_interval]
+      end
+
+      interval = rand(from_interval .. to_interval).minutes
+
+      redis.setex("queued_boost:#{account.id}", interval, 1)
+      ReblogStatusWorker.perform_async(account.id, q.first.status_id, distribute: true)
+      q.destroy_all
+    end
+  end
+
+  def queued_accounts
+    Account.where(id: queued_account_ids)
+  end
+
+  def queued_account_ids
+    QueuedBoost.distinct.pluck(:account_id)
+  end
+
+  def next_boost(account_id, boost_random = false)
+    q = QueuedBoost.where(account_id: account_id)
+    (boost_random ? q.order(Arel.sql('RANDOM()')) : q.order(:id)).limit(1)
+  end
+end
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 75ff9d69d..28750bcda 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -127,6 +127,10 @@ en:
         setting_roar_lifespan: Auto-delete new roars after
         setting_delayed_roars: Delayed publishing of roars for proofreading
         setting_delayed_for: Delay for
+        setting_boost_interval: Automatically space out consecutive boosts
+        setting_boost_random: Boost in random order
+        setting_boost_interval_from: Minimum boost interval
+        setting_boost_interval_to: Maximum boost interval
         setting_default_local: Default to Monsterpit-only roars (in Glitch flavour)
         setting_always_local: Don't send your roars outside Monsterpit
         setting_rawr_federated: Show raw world timeline (may contain offensive content!)
@@ -176,6 +180,23 @@ en:
         username: Username
         username_or_email: Username or Email
         whole_word: Whole word
+      boost_interval:
+        1: 1 minute
+        2: 2 minutes
+        3: 3 minutes
+        4: 4 minutes
+        5: 5 minutes
+        6: 6 minutes
+        10: 10 minutes
+        15: 15 minutes
+        30: 30 minutes
+        60: 1 hour
+        120: 2 hours
+        180: 3 hours
+        300: 5 hours
+        360: 6 hours
+        720: 12 hours
+        1440: 1 day
       delayed_for:
         5: 5 seconds
         10: 10 seconds
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 4390b5a0e..03b057257 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -12,6 +12,9 @@
   destructing_statuses_scheduler:
     every: '1m'
     class: Scheduler::DestructingStatusesScheduler
+  boosts_scheduler:
+    every: '1m'
+    class: Scheduler::BoostsScheduler
   janitor_scheduler:
     every: '1h'
     class: Scheduler::JanitorScheduler
diff --git a/db/migrate/20190806195913_create_queued_boosts.rb b/db/migrate/20190806195913_create_queued_boosts.rb
new file mode 100644
index 000000000..1b20c9ffa
--- /dev/null
+++ b/db/migrate/20190806195913_create_queued_boosts.rb
@@ -0,0 +1,11 @@
+class CreateQueuedBoosts < ActiveRecord::Migration[5.2]
+  def change
+    create_table :queued_boosts do |t|
+      t.references :account, foreign_key: { on_delete: :cascade }
+      t.references :status, foreign_key: { on_delete: :cascade }
+      t.timestamps
+    end
+
+    add_index :queued_boosts, [:account_id, :status_id], unique: true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3c2664db5..a57ac2fd3 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2019_08_05_203816) do
+ActiveRecord::Schema.define(version: 2019_08_06_195913) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -554,6 +554,16 @@ ActiveRecord::Schema.define(version: 2019_08_05_203816) do
     t.index ["status_id", "preview_card_id"], name: "index_preview_cards_statuses_on_status_id_and_preview_card_id"
   end
 
+  create_table "queued_boosts", force: :cascade do |t|
+    t.bigint "account_id"
+    t.bigint "status_id"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["account_id", "status_id"], name: "index_queued_boosts_on_account_id_and_status_id", unique: true
+    t.index ["account_id"], name: "index_queued_boosts_on_account_id"
+    t.index ["status_id"], name: "index_queued_boosts_on_status_id"
+  end
+
   create_table "relays", force: :cascade do |t|
     t.string "inbox_url", default: "", null: false
     t.string "follow_activity_id"
@@ -861,6 +871,8 @@ ActiveRecord::Schema.define(version: 2019_08_05_203816) do
   add_foreign_key "poll_votes", "polls", on_delete: :cascade
   add_foreign_key "polls", "accounts", on_delete: :cascade
   add_foreign_key "polls", "statuses", on_delete: :cascade
+  add_foreign_key "queued_boosts", "accounts", on_delete: :cascade
+  add_foreign_key "queued_boosts", "statuses", on_delete: :cascade
   add_foreign_key "report_notes", "accounts", on_delete: :cascade
   add_foreign_key "report_notes", "reports", on_delete: :cascade
   add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify
diff --git a/spec/fabricators/queued_boost_fabricator.rb b/spec/fabricators/queued_boost_fabricator.rb
new file mode 100644
index 000000000..35c744028
--- /dev/null
+++ b/spec/fabricators/queued_boost_fabricator.rb
@@ -0,0 +1,4 @@
+Fabricator(:queued_boost) do
+  account nil
+  status  nil
+end
diff --git a/spec/models/queued_boost_spec.rb b/spec/models/queued_boost_spec.rb
new file mode 100644
index 000000000..066be3dcd
--- /dev/null
+++ b/spec/models/queued_boost_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe QueuedBoost, type: :model do
+  pending "add some examples to (or delete) #{__FILE__}"
+end