FEATURE: Topic slow mode. (#10904)

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.

diff --git a/app/assets/javascripts/discourse/app/components/composer-messages.js b/app/assets/javascripts/discourse/app/components/composer-messages.js
index 3273da6..e3fbca1 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 0000000..bcb4278
--- /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 0000000..5460fd2
--- /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 0000000..fa37871
--- /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 13ab5fa..ed9c263 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 28d6161..8e08d53 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 });
+    },

[... diff too long, it was truncated ...]

GitHub sha: 21c53ed2

This commit appears in #10904 which was approved by eviltrout. It was merged by romanrizzi.