From 21c53ed249cf88686faa6e4f0de689e0771a48cc Mon Sep 17 00:00:00 2001 From: Roman Rizzi Date: Fri, 16 Oct 2020 16:24:38 -0300 Subject: [PATCH] FEATURE: Topic slow mode. (#10904) Adds a new slow mode for topics that are heating up. Users will have to wait for a period of time before being able to post again. We store this interval inside the topics table and track the last time a user posted using the last_posted_at datetime in the TopicUser relation. --- .../app/components/composer-messages.js | 16 +++ .../app/components/slow-mode-info.js | 25 ++++ .../app/controllers/edit-slow-mode.js | 112 ++++++++++++++++++ .../discourse/app/helpers/slow-mode.js | 30 +++++ .../javascripts/discourse/app/models/topic.js | 5 + .../javascripts/discourse/app/routes/topic.js | 6 + .../templates/components/slow-mode-info.hbs | 14 +++ .../components/topic-footer-buttons.hbs | 1 + .../app/templates/modal/edit-slow-mode.hbs | 48 ++++++++ .../discourse/app/templates/topic.hbs | 5 + .../discourse/app/widgets/topic-admin-menu.js | 8 ++ app/assets/stylesheets/common/base/modal.scss | 14 +++ app/assets/stylesheets/desktop/topic.scss | 6 +- app/controllers/topics_controller.rb | 12 +- app/models/topic.rb | 1 + app/models/topic_user.rb | 1 + app/serializers/topic_view_serializer.rb | 14 ++- .../web_hook_topic_view_serializer.rb | 1 + config/locales/client.en.yml | 23 ++++ config/locales/server.en.yml | 1 + config/routes.rb | 1 + ...1005165544_add_topic_slow_mode_interval.rb | 7 ++ ...190955_add_last_posted_at_to_topic_user.rb | 7 ++ lib/post_creator.rb | 16 ++- lib/svg_sprite/svg_sprite.rb | 1 + spec/components/post_creator_spec.rb | 39 ++++++ spec/requests/topics_controller_spec.rb | 52 ++++++++ 27 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/slow-mode-info.js create mode 100644 app/assets/javascripts/discourse/app/controllers/edit-slow-mode.js create mode 100644 app/assets/javascripts/discourse/app/helpers/slow-mode.js create mode 100644 app/assets/javascripts/discourse/app/templates/components/slow-mode-info.hbs create mode 100644 app/assets/javascripts/discourse/app/templates/modal/edit-slow-mode.hbs create mode 100644 db/migrate/20201005165544_add_topic_slow_mode_interval.rb create mode 100644 db/migrate/20201009190955_add_last_posted_at_to_topic_user.rb diff --git a/app/assets/javascripts/discourse/app/components/composer-messages.js b/app/assets/javascripts/discourse/app/components/composer-messages.js index 3273da6b16c..e3fbca18589 100644 --- a/app/assets/javascripts/discourse/app/components/composer-messages.js +++ b/app/assets/javascripts/discourse/app/components/composer-messages.js @@ -4,6 +4,7 @@ import EmberObject from "@ember/object"; import { scheduleOnce } from "@ember/runloop"; import Component from "@ember/component"; import LinkLookup from "discourse/lib/link-lookup"; +import { durationTextFromSeconds } from "discourse/helpers/slow-mode"; let _messagesCache = {}; @@ -116,6 +117,21 @@ export default Component.extend({ } } + const topic = composer.topic; + if (topic && topic.slow_mode_seconds) { + const msg = composer.store.createRecord("composer-message", { + id: "slow-mode-enabled", + extraClass: "custom-body", + templateName: "custom-body", + title: I18n.t("composer.slow_mode.title"), + body: I18n.t("composer.slow_mode.body", { + duration: durationTextFromSeconds(topic.slow_mode_seconds), + }), + }); + + this.send("popup", msg); + } + this.queuedForTyping.forEach((msg) => this.send("popup", msg)); }, diff --git a/app/assets/javascripts/discourse/app/components/slow-mode-info.js b/app/assets/javascripts/discourse/app/components/slow-mode-info.js new file mode 100644 index 00000000000..bcb4278786a --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/slow-mode-info.js @@ -0,0 +1,25 @@ +import { durationTextFromSeconds } from "discourse/helpers/slow-mode"; +import Component from "@ember/component"; +import discourseComputed from "discourse-common/utils/decorators"; +import Topic from "discourse/models/topic"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { action } from "@ember/object"; + +export default Component.extend({ + @discourseComputed("topic.slow_mode_seconds") + durationText(seconds) { + return durationTextFromSeconds(seconds); + }, + + @discourseComputed("topic.slow_mode_seconds", "topic.closed") + showSlowModeNotice(seconds, closed) { + return seconds > 0 && !closed; + }, + + @action + disableSlowMode() { + Topic.setSlowMode(this.topic.id, 0) + .catch(popupAjaxError) + .then(() => this.set("topic.slow_mode_seconds", 0)); + }, +}); diff --git a/app/assets/javascripts/discourse/app/controllers/edit-slow-mode.js b/app/assets/javascripts/discourse/app/controllers/edit-slow-mode.js new file mode 100644 index 00000000000..5460fd27ae1 --- /dev/null +++ b/app/assets/javascripts/discourse/app/controllers/edit-slow-mode.js @@ -0,0 +1,112 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import discourseComputed from "discourse-common/utils/decorators"; +import I18n from "I18n"; +import Topic from "discourse/models/topic"; +import { fromSeconds, toSeconds } from "discourse/helpers/slow-mode"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { equal } from "@ember/object/computed"; +import { action } from "@ember/object"; + +export default Controller.extend(ModalFunctionality, { + selectedSlowMode: null, + hours: null, + minutes: null, + seconds: null, + saveDisabled: false, + showCustomSelect: equal("selectedSlowMode", "custom"), + + init() { + this._super(...arguments); + + this.set("slowModes", [ + { + id: "900", + name: I18n.t("topic.slow_mode_update.durations.15_minutes"), + }, + { + id: "3600", + name: I18n.t("topic.slow_mode_update.durations.1_hour"), + }, + { + id: "14400", + name: I18n.t("topic.slow_mode_update.durations.4_hours"), + }, + { + id: "86400", + name: I18n.t("topic.slow_mode_update.durations.1_day"), + }, + { + id: "604800", + name: I18n.t("topic.slow_mode_update.durations.1_week"), + }, + { + id: "custom", + name: I18n.t("topic.slow_mode_update.durations.custom"), + }, + ]); + }, + + onShow() { + const currentDuration = parseInt(this.model.slow_mode_seconds, 10); + + if (currentDuration) { + const selectedDuration = this.slowModes.find((mode) => { + return mode.id === currentDuration.toString(); + }); + + if (selectedDuration) { + this.set("selectedSlowMode", currentDuration.toString()); + } else { + this.set("selectedSlowMode", "custom"); + } + + this._setFromSeconds(currentDuration); + } + }, + + @discourseComputed("hours", "minutes", "seconds") + submitDisabled(hours, minutes, seconds) { + return this.saveDisabled || !(hours || minutes || seconds); + }, + + _setFromSeconds(seconds) { + this.setProperties(fromSeconds(seconds)); + }, + + @action + setSlowModeDuration(duration) { + if (duration !== "custom") { + let seconds = parseInt(duration, 10); + + this._setFromSeconds(seconds); + } + + this.set("selectedSlowMode", duration); + }, + + @action + enableSlowMode() { + this.set("saveDisabled", true); + const seconds = toSeconds(this.hours, this.minutes, this.seconds); + Topic.setSlowMode(this.model.id, seconds) + .catch(popupAjaxError) + .then(() => { + this.set("model.slow_mode_seconds", seconds); + this.send("closeModal"); + }) + .finally(() => this.set("saveDisabled", false)); + }, + + @action + disableSlowMode() { + this.set("saveDisabled", true); + Topic.setSlowMode(this.model.id, 0) + .catch(popupAjaxError) + .then(() => { + this.set("model.slow_mode_seconds", 0); + this.send("closeModal"); + }) + .finally(() => this.set("saveDisabled", false)); + }, +}); diff --git a/app/assets/javascripts/discourse/app/helpers/slow-mode.js b/app/assets/javascripts/discourse/app/helpers/slow-mode.js new file mode 100644 index 00000000000..fa37871788d --- /dev/null +++ b/app/assets/javascripts/discourse/app/helpers/slow-mode.js @@ -0,0 +1,30 @@ +export function fromSeconds(seconds) { + let initialSeconds = seconds; + + let hours = initialSeconds / 3600; + if (hours >= 1) { + initialSeconds = initialSeconds - 3600 * hours; + } else { + hours = 0; + } + + let minutes = initialSeconds / 60; + if (minutes >= 1) { + initialSeconds = initialSeconds - 60 * minutes; + } else { + minutes = 0; + } + + return { hours, minutes, seconds: initialSeconds }; +} + +export function toSeconds(hours, minutes, seconds) { + const hoursAsSeconds = parseInt(hours, 10) * 60 * 60; + const minutesAsSeconds = parseInt(minutes, 10) * 60; + + return parseInt(seconds, 10) + hoursAsSeconds + minutesAsSeconds; +} + +export function durationTextFromSeconds(seconds) { + return moment.duration(seconds, "seconds").humanize(); +} diff --git a/app/assets/javascripts/discourse/app/models/topic.js b/app/assets/javascripts/discourse/app/models/topic.js index 13ab5fae0b0..ed9c2634e09 100644 --- a/app/assets/javascripts/discourse/app/models/topic.js +++ b/app/assets/javascripts/discourse/app/models/topic.js @@ -845,6 +845,11 @@ Topic.reopenClass({ idForSlug(slug) { return ajax(`/t/id_for/${slug}`); }, + + setSlowMode(topicId, seconds) { + const data = { seconds }; + return ajax(`/t/${topicId}/slow_mode`, { type: "PUT", data }); + }, }); function moveResult(result) { diff --git a/app/assets/javascripts/discourse/app/routes/topic.js b/app/assets/javascripts/discourse/app/routes/topic.js index 28d61612353..8e08d53c499 100644 --- a/app/assets/javascripts/discourse/app/routes/topic.js +++ b/app/assets/javascripts/discourse/app/routes/topic.js @@ -118,6 +118,12 @@ const TopicRoute = DiscourseRoute.extend({ this.controllerFor("modal").set("modalClass", "edit-topic-timer-modal"); }, + showTopicSlowModeUpdate() { + const model = this.modelFor("topic"); + + showModal("edit-slow-mode", { model }); + }, + showChangeTimestamp() { showModal("change-timestamp", { model: this.modelFor("topic"), diff --git a/app/assets/javascripts/discourse/app/templates/components/slow-mode-info.hbs b/app/assets/javascripts/discourse/app/templates/components/slow-mode-info.hbs new file mode 100644 index 00000000000..88103992400 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/components/slow-mode-info.hbs @@ -0,0 +1,14 @@ +{{#if showSlowModeNotice}} +
+

+ + {{d-icon "hourglass-end"}} + {{i18n "topic.slow_mode_notice.duration" duration=durationText}} + + + {{d-button class="slow-mode-remove" + action=(action "disableSlowMode") + icon="trash-alt"}} +

+
+{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/components/topic-footer-buttons.hbs b/app/assets/javascripts/discourse/app/templates/components/topic-footer-buttons.hbs index aa5b44a7163..e92d28c8928 100644 --- a/app/assets/javascripts/discourse/app/templates/components/topic-footer-buttons.hbs +++ b/app/assets/javascripts/discourse/app/templates/components/topic-footer-buttons.hbs @@ -3,6 +3,7 @@ topic=topic openUpwards="true" toggleMultiSelect=toggleMultiSelect + showTopicSlowModeUpdate=showTopicSlowModeUpdate deleteTopic=deleteTopic recoverTopic=recoverTopic toggleFeaturedOnProfile=toggleFeaturedOnProfile diff --git a/app/assets/javascripts/discourse/app/templates/modal/edit-slow-mode.hbs b/app/assets/javascripts/discourse/app/templates/modal/edit-slow-mode.hbs new file mode 100644 index 00000000000..17d751bd030 --- /dev/null +++ b/app/assets/javascripts/discourse/app/templates/modal/edit-slow-mode.hbs @@ -0,0 +1,48 @@ +{{#d-modal-body title="topic.slow_mode_update.title" autoFocus=false}} +
+ +
+ +
+ + {{combo-box + class="slow-mode-type" + content=slowModes + value=selectedSlowMode + onChange=(action "setSlowModeDuration") + }} +
+ + {{#if showCustomSelect}} +
+ {{d-icon "hourglass-end"}} + {{input value=hours type="number" class="input-small" placeholder=(i18n "topic.slow_mode_update.hours")}} + {{input value=minutes type="number" class="input-small" placeholder=(i18n "topic.slow_mode_update.minutes")}} + {{input value=seconds type="number" class="input-small" placeholder=(i18n "topic.slow_mode_update.seconds")}} +
+ {{/if}} + + {{#if model.slow_mode_seconds}} +
+ + {{i18n "topic.slow_mode_update.current" hours=hours minutes=minutes seconds=seconds}} + +
+ {{/if}} +{{/d-modal-body}} + + \ No newline at end of file diff --git a/app/assets/javascripts/discourse/app/templates/topic.hbs b/app/assets/javascripts/discourse/app/templates/topic.hbs index af022006355..0490f39b162 100644 --- a/app/assets/javascripts/discourse/app/templates/topic.hbs +++ b/app/assets/javascripts/discourse/app/templates/topic.hbs @@ -145,6 +145,7 @@ jumpToIndex=(action "jumpToIndex") replyToPost=(action "replyToPost") toggleMultiSelect=(action "toggleMultiSelect") + showTopicSlowModeUpdate=(route-action "showTopicSlowModeUpdate") deleteTopic=(action "deleteTopic") recoverTopic=(action "recoverTopic") toggleClosed=(action "toggleClosed") @@ -168,6 +169,7 @@ openUpwards="true" rightSide="true" toggleMultiSelect=(action "toggleMultiSelect") + showTopicSlowModeUpdate=(route-action "showTopicSlowModeUpdate") deleteTopic=(action "deleteTopic") recoverTopic=(action "recoverTopic") toggleClosed=(action "toggleClosed") @@ -286,6 +288,8 @@ {{/if}} + {{slow-mode-info topic=model user=currentUser}} + {{topic-timer-info topicClosed=model.closed statusType=model.topic_timer.status_type @@ -305,6 +309,7 @@ {{topic-footer-buttons topic=model toggleMultiSelect=(action "toggleMultiSelect") + showTopicSlowModeUpdate=(route-action "showTopicSlowModeUpdate") deleteTopic=(action "deleteTopic") recoverTopic=(action "recoverTopic") toggleClosed=(action "toggleClosed") diff --git a/app/assets/javascripts/discourse/app/widgets/topic-admin-menu.js b/app/assets/javascripts/discourse/app/widgets/topic-admin-menu.js index 143c28cb37a..0c7e3c4fe46 100644 --- a/app/assets/javascripts/discourse/app/widgets/topic-admin-menu.js +++ b/app/assets/javascripts/discourse/app/widgets/topic-admin-menu.js @@ -164,6 +164,14 @@ export default createWidget("topic-admin-menu", { }); } + this.addActionButton({ + className: "topic-admin-slow-mode", + buttonClass: "popup-menu-btn", + action: "showTopicSlowModeUpdate", + icon: "hourglass-end", + label: "actions.slow_mode", + }); + if (topic.get("deleted") && details.get("can_recover")) { this.addActionButton({ className: "topic-admin-recover", diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index fc09f8990d1..f2c51704031 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -811,3 +811,17 @@ } } } + +.modal.edit-slow-mode-modal { + .slow-mode-label { + display: inline-flex; + } + + .alert.alert-info { + margin-bottom: 0; + } + + .input-small { + width: 15%; + } +} diff --git a/app/assets/stylesheets/desktop/topic.scss b/app/assets/stylesheets/desktop/topic.scss index 101bef0910f..3c175e4e2cc 100644 --- a/app/assets/stylesheets/desktop/topic.scss +++ b/app/assets/stylesheets/desktop/topic.scss @@ -63,12 +63,14 @@ border-top: 1px solid var(--primary-low); padding: 10px 0; max-width: 758px; - .topic-timer-heading { + .topic-timer-heading, + .slow-mode-heading { display: flex; align-items: center; margin: 0px; } - .topic-timer-remove { + .topic-timer-remove, + .slow-mode-remove { font-size: $font-down-2; background: transparent; margin-left: auto; diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb index 9b453855b79..628903ed8de 100644 --- a/app/controllers/topics_controller.rb +++ b/app/controllers/topics_controller.rb @@ -28,7 +28,8 @@ class TopicsController < ApplicationController :convert_topic, :bookmark, :publish, - :reset_bump_date + :reset_bump_date, + :set_slow_mode ] before_action :consider_user_for_promotion, only: :show @@ -932,6 +933,15 @@ class TopicsController < ApplicationController render body: nil end + def set_slow_mode + topic = Topic.find(params[:topic_id]) + + guardian.ensure_can_moderate!(topic) + topic.update!(slow_mode_seconds: params[:seconds]) + + head :ok + end + private def topic_params diff --git a/app/models/topic.rb b/app/models/topic.rb index 558c15417e5..976c1996a24 100644 --- a/app/models/topic.rb +++ b/app/models/topic.rb @@ -1770,6 +1770,7 @@ end # archetype :string default("regular"), not null # featured_user4_id :integer # notify_moderators_count :integer default(0), not null +# slow_mode_seconds :integer default(0), not null # spam_count :integer default(0), not null # pinned_at :datetime # score :float diff --git a/app/models/topic_user.rb b/app/models/topic_user.rb index 509db6ed386..ddd5226298f 100644 --- a/app/models/topic_user.rb +++ b/app/models/topic_user.rb @@ -495,6 +495,7 @@ end # posted :boolean default(FALSE), not null # last_read_post_number :integer # highest_seen_post_number :integer +# last_posted_at :datetime # last_visited_at :datetime # first_visited_at :datetime # notification_level :integer default(1), not null diff --git a/app/serializers/topic_view_serializer.rb b/app/serializers/topic_view_serializer.rb index 7c6bd0bbef9..4587164fd24 100644 --- a/app/serializers/topic_view_serializer.rb +++ b/app/serializers/topic_view_serializer.rb @@ -40,7 +40,8 @@ class TopicViewSerializer < ApplicationSerializer :pinned_globally, :pinned_at, :pinned_until, - :image_url + :image_url, + :slow_mode_seconds ) attributes( @@ -72,7 +73,8 @@ class TopicViewSerializer < ApplicationSerializer :queued_posts_count, :show_read_indicator, :requested_group_name, - :thumbnails + :thumbnails, + :user_last_posted_at ) has_one :details, serializer: TopicViewDetailsSerializer, root: false, embed: :objects @@ -280,4 +282,12 @@ class TopicViewSerializer < ApplicationSerializer extra_sizes = ThemeModifierHelper.new(request: scope.request).topic_thumbnail_sizes object.topic.thumbnail_info(enqueue_if_missing: true, extra_sizes: extra_sizes) end + + def user_last_posted_at + object.topic_user.last_posted_at + end + + def include_user_last_posted_at? + object.topic.slow_mode_seconds.to_i > 0 + end end diff --git a/app/serializers/web_hook_topic_view_serializer.rb b/app/serializers/web_hook_topic_view_serializer.rb index 849250bc1ca..cadd50ebaf7 100644 --- a/app/serializers/web_hook_topic_view_serializer.rb +++ b/app/serializers/web_hook_topic_view_serializer.rb @@ -20,6 +20,7 @@ class WebHookTopicViewSerializer < TopicViewSerializer topic_timer details image_url + slow_mode_seconds }.each do |attr| define_method("include_#{attr}?") do false diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d57c8f6ddce..96373937460 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1922,6 +1922,9 @@ en: yourself_confirm: title: "Did you forget to add recipients?" body: "Right now this message is only being sent to yourself!" + slow_mode: + title: "This topic is in slow mode." + body: "After submitting a post, you'll need to wait %{duration} before being able to post again." admin_options_title: "Optional staff settings for this topic" @@ -2313,6 +2316,25 @@ en: jump_reply_down: jump to later reply deleted: "The topic has been deleted" + slow_mode_update: + title: "Slow Mode" + select: "Duration:" + description: "Users will have to wait to be able to post again." + current: "Current duration is %{hours} hours, %{minutes} minutes, and %{seconds} seconds." + save: "Save" + remove: "Disable" + hours: "Hours" + minutes: "Minutes" + seconds: "Seconds" + durations: + 15_minutes: "15 Minutes" + 1_hour: "1 Hour" + 4_hours: "4 Hours" + 1_day: "1 Day" + 1_week: "1 Week" + custom: "Pick Duration" + slow_mode_notice: + duration: "You need to wait %{duration} between posts in this topic" topic_status_update: title: "Topic Timer" save: "Set Timer" @@ -2447,6 +2469,7 @@ en: open: "Open Topic" close: "Close Topic" multi_select: "Select Posts…" + slow_mode: "Set Slow Mode" timed_update: "Set Topic Timer..." pin: "Pin Topic…" unpin: "Un-Pin Topic…" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 736749eb660..a15abb7c68a 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -340,6 +340,7 @@ en: removed_direct_reply_full_quotes: "Automatically removed quote of whole previous post." secure_upload_not_allowed_in_public_topic: "Sorry, the following secure upload(s) cannot be used in a public topic: %{upload_filenames}." create_pm_on_existing_topic: "Sorry, you can't create a PM on an existing topic." + slow_mode_enabled: "You recently posted on this topic, which is in slow mode. Please wait so other users can have their chance to participate." just_posted_that: "is too similar to what you recently posted" invalid_characters: "contains invalid characters" diff --git a/config/routes.rb b/config/routes.rb index 0c5e28b4241..8da5504df0e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -804,6 +804,7 @@ Discourse::Application.routes.draw do put "t/:topic_id/bookmark" => "topics#bookmark", constraints: { topic_id: /\d+/ } put "t/:topic_id/remove_bookmarks" => "topics#remove_bookmarks", constraints: { topic_id: /\d+/ } put "t/:topic_id/tags" => "topics#update_tags", constraints: { topic_id: /\d+/ } + put "t/:topic_id/slow_mode" => "topics#set_slow_mode", constraints: { topic_id: /\d+/ } post "t/:topic_id/notifications" => "topics#set_notifications" , constraints: { topic_id: /\d+/ } diff --git a/db/migrate/20201005165544_add_topic_slow_mode_interval.rb b/db/migrate/20201005165544_add_topic_slow_mode_interval.rb new file mode 100644 index 00000000000..643ba2535bc --- /dev/null +++ b/db/migrate/20201005165544_add_topic_slow_mode_interval.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTopicSlowModeInterval < ActiveRecord::Migration[6.0] + def change + add_column :topics, :slow_mode_seconds, :integer, null: false, default: 0 + end +end diff --git a/db/migrate/20201009190955_add_last_posted_at_to_topic_user.rb b/db/migrate/20201009190955_add_last_posted_at_to_topic_user.rb new file mode 100644 index 00000000000..713473722a2 --- /dev/null +++ b/db/migrate/20201009190955_add_last_posted_at_to_topic_user.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLastPostedAtToTopicUser < ActiveRecord::Migration[6.0] + def change + add_column :topic_users, :last_posted_at, :datetime + end +end diff --git a/lib/post_creator.rb b/lib/post_creator.rb index 7c19f663ce4..3fb1324195e 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -159,6 +159,19 @@ class PostCreator return false end + if @topic&.slow_mode_seconds.to_i > 0 + tu = TopicUser.find_by(user: @user, topic: @topic) + + if tu&.last_posted_at + threshold = tu.last_posted_at + @topic.slow_mode_seconds.seconds + + if DateTime.now < threshold + errors.add(:base, I18n.t(:slow_mode_enabled)) + return false + end + end + end + unless @topic.present? && (@opts[:skip_guardian] || guardian.can_create?(Post, @topic)) errors.add(:base, I18n.t(:topic_not_found)) return false @@ -622,7 +635,8 @@ class PostCreator @topic.id, posted: true, last_read_post_number: @post.post_number, - highest_seen_post_number: @post.post_number) + highest_seen_post_number: @post.post_number, + last_posted_at: Time.zone.now) # assume it took us 5 seconds of reading time to make a post PostTiming.record_timing(topic_id: @post.topic_id, diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index 531ef3e65d3..ffa83a3644f 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -130,6 +130,7 @@ module SvgSprite "heading", "heart", "home", + "hourglass-end", "id-card", "info-circle", "italic", diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index a624437b102..25115937717 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -722,6 +722,32 @@ describe PostCreator do expect(topic.word_count).to eq(6) end end + + context 'when the topic is in slow mode' do + before do + one_day = 86400 + topic.update!(slow_mode_seconds: one_day) + end + + it 'fails if the user recently posted in this topic' do + TopicUser.create!(user: user, topic: topic, last_posted_at: 10.minutes.ago) + + post = creator.create + + expect(post).to be_blank + expect(creator.errors.count).to eq 1 + expect(creator.errors.messages[:base][0]).to match I18n.t(:slow_mode_enabled) + end + + it 'creates the topic if the user last post is older than the slow mode interval' do + TopicUser.create!(user: user, topic: topic, last_posted_at: 5.days.ago) + + post = creator.create + + expect(post).to be_present + expect(creator.errors.count).to be_zero + end + end end context 'closed topic' do @@ -1194,6 +1220,19 @@ describe PostCreator do topic_user = TopicUser.find_by(user_id: user.id, topic_id: pm.id) expect(topic_user.notification_level).to eq(3) end + + it 'sets the last_posted_at timestamp to track the last time the user posted' do + topic = Fabricate(:topic) + + PostCreator.create( + user, + topic_id: topic.id, + raw: "this is a test reply 123 123 ;)" + ) + + topic_user = TopicUser.find_by(user_id: user.id, topic_id: topic.id) + expect(topic_user.last_posted_at).to be_present + end end describe '#create!' do diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb index c9181814057..2ceeba01ad4 100644 --- a/spec/requests/topics_controller_spec.rb +++ b/spec/requests/topics_controller_spec.rb @@ -3047,6 +3047,58 @@ RSpec.describe TopicsController do end end + describe '#set_slow_mode' do + context 'when not logged in' do + it 'returns a forbidden response' do + put "/t/#{topic.id}/slow_mode.json", params: { + seconds: '3600' + } + + expect(response.status).to eq(403) + end + end + + context 'logged in as an admin' do + it 'allows admins to set the slow mode interval' do + sign_in(admin) + + put "/t/#{topic.id}/slow_mode.json", params: { + seconds: '3600' + } + + topic.reload + expect(response.status).to eq(200) + expect(topic.slow_mode_seconds).to eq(3600) + end + end + + context 'logged in as a regular user' do + it 'does nothing if the user is not TL4' do + user.update!(trust_level: TrustLevel[3]) + sign_in(user) + + put "/t/#{topic.id}/slow_mode.json", params: { + seconds: '3600' + } + + expect(response.status).to eq(403) + end + + it 'allows TL4 users to set the slow mode interval' do + user.update!(trust_level: TrustLevel[4]) + sign_in(user) + + put "/t/#{topic.id}/slow_mode.json", params: { + seconds: '3600' + } + + topic.reload + expect(response.status).to eq(200) + expect(topic.slow_mode_seconds).to eq(3600) + end + end + end + describe '#invite' do describe 'when not logged in' do it "should return the right response" do