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? - - 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) - - DistributionWorker.perform_async(reblog.id) - - unless reblogged_status.local_only? - ActivityPub::DistributionWorker.perform_async(reblog.id) + 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 - curate_status(reblogged_status) - create_notification(reblog) unless options[:skip_notify] - bump_potential_friendship(account, reblog) + 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) + + 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) + 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