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.
This commit is contained in:
Roman Rizzi 2020-10-16 16:24:38 -03:00 committed by GitHub
parent 4669e60ce5
commit 21c53ed249
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 460 additions and 6 deletions

View File

@ -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));
},

View File

@ -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));
},
});

View File

@ -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));
},
});

View File

@ -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();
}

View File

@ -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) {

View File

@ -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"),

View File

@ -0,0 +1,14 @@
{{#if showSlowModeNotice}}
<div class="topic-status-info">
<h3 class="slow-mode-heading">
<span>
{{d-icon "hourglass-end"}}
{{i18n "topic.slow_mode_notice.duration" duration=durationText}}
</span>
{{d-button class="slow-mode-remove"
action=(action "disableSlowMode")
icon="trash-alt"}}
</h3>
</div>
{{/if}}

View File

@ -3,6 +3,7 @@
topic=topic
openUpwards="true"
toggleMultiSelect=toggleMultiSelect
showTopicSlowModeUpdate=showTopicSlowModeUpdate
deleteTopic=deleteTopic
recoverTopic=recoverTopic
toggleFeaturedOnProfile=toggleFeaturedOnProfile

View File

@ -0,0 +1,48 @@
{{#d-modal-body title="topic.slow_mode_update.title" autoFocus=false}}
<div class="control-group">
<label class="slow-mode-label">{{i18n "topic.slow_mode_update.description"}}</label>
</div>
<div class="control-group">
<label class="slow-mode-label">{{i18n "topic.slow_mode_update.select"}}</label>
{{combo-box
class="slow-mode-type"
content=slowModes
value=selectedSlowMode
onChange=(action "setSlowModeDuration")
}}
</div>
{{#if showCustomSelect}}
<div class="control-group">
{{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")}}
</div>
{{/if}}
{{#if model.slow_mode_seconds}}
<div class="alert alert-info">
<b>
{{i18n "topic.slow_mode_update.current" hours=hours minutes=minutes seconds=seconds}}
</b>
</div>
{{/if}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button class="btn-primary"
disabled=submitDisabled
label="topic.slow_mode_update.save"
action=(action "enableSlowMode")}}
{{conditional-loading-spinner size="small" condition=loading}}
{{#if model.slow_mode_seconds}}
{{d-button class="btn-danger"
action=(action "disableSlowMode")
disabled=submitDisabled
label="topic.slow_mode_update.remove"}}
{{/if}}
</div>

View File

@ -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 @@
</div>
{{/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")

View File

@ -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",

View File

@ -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%;
}
}

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -20,6 +20,7 @@ class WebHookTopicViewSerializer < TopicViewSerializer
topic_timer
details
image_url
slow_mode_seconds
}.each do |attr|
define_method("include_#{attr}?") do
false

View File

@ -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…"

View File

@ -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"

View File

@ -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+/ }

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -130,6 +130,7 @@ module SvgSprite
"heading",
"heart",
"home",
"hourglass-end",
"id-card",
"info-circle",
"italic",

View File

@ -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

View File

@ -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