FEATURE: Let staff add custom post notices. (#7377)

This commit is contained in:
Dan Ungureanu 2019-04-19 17:53:58 +03:00 committed by GitHub
parent ba6369edc5
commit 57d1dea8a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 282 additions and 102 deletions

View File

@ -0,0 +1,59 @@
import ModalFunctionality from "discourse/mixins/modal-functionality";
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Controller.extend(ModalFunctionality, {
post: null,
resolve: null,
reject: null,
notice: null,
saving: false,
@computed("saving", "notice")
disabled(saving, notice) {
return saving || Ember.isEmpty(notice);
},
onShow() {
this.setProperties({
notice: "",
saving: false
});
},
onClose() {
const reject = this.get("reject");
if (reject) {
reject();
}
},
actions: {
setNotice() {
this.set("saving", true);
const post = this.get("post");
const resolve = this.get("resolve");
const reject = this.get("reject");
const notice = this.get("notice");
// Let `updatePostField` handle state.
this.setProperties({ resolve: null, reject: null });
post
.updatePostField("notice", notice)
.then(() => {
post.setProperties({
notice_type: "custom",
notice_args: notice
});
resolve();
this.send("closeModal");
})
.catch(() => {
reject();
this.send("closeModal");
});
}
}
});

View File

@ -750,6 +750,22 @@ export default Ember.Controller.extend(bufferedProperty("model"), {
this.send("showGrantBadgeModal");
},
addNotice(post) {
return new Ember.RSVP.Promise(function(resolve, reject) {
const controller = showModal("add-post-notice");
controller.setProperties({ post, resolve, reject });
});
},
removeNotice(post) {
return post.updatePostField("notice", null).then(() =>
post.setProperties({
notice_type: null,
notice_args: null
})
);
},
toggleParticipant(user) {
this.get("model.postStream")
.toggleParticipant(user.get("username"))

View File

@ -133,10 +133,12 @@ export default function transformPost(
postAtts.topicUrl = topic.get("url");
postAtts.isSaving = post.isSaving;
if (post.post_notice_type) {
postAtts.postNoticeType = post.post_notice_type;
if (postAtts.postNoticeType === "returning") {
postAtts.postNoticeTime = new Date(post.post_notice_time);
if (post.notice_type) {
postAtts.noticeType = post.notice_type;
if (postAtts.noticeType === "custom") {
postAtts.noticeMessage = post.notice_args;
} else if (postAtts.noticeType === "returning_user") {
postAtts.noticeTime = new Date(post.notice_args);
}
}

View File

@ -0,0 +1,12 @@
{{#d-modal-body title="post.controls.add_post_notice"}}
<form>{{textarea value=notice}}</form>
{{/d-modal-body}}
<div class="modal-footer">
{{d-button
class="btn-primary"
action=(action "setNotice")
disabled=disabled
label=(if saving "saving" "save")}}
{{d-modal-cancel close=(route-action "closeModal")}}
</div>

View File

@ -184,6 +184,8 @@
rebakePost=(action "rebakePost")
changePostOwner=(action "changePostOwner")
grantBadge=(action "grantBadge")
addNotice=(action "addNotice")
removeNotice=(action "removeNotice")
lockPost=(action "lockPost")
unlockPost=(action "unlockPost")
unhidePost=(action "unhidePost")

View File

@ -74,14 +74,23 @@ export function buildManageButtons(attrs, currentUser, siteSettings) {
});
}
const action = attrs.locked ? "unlock" : "lock";
contents.push({
icon: action,
label: `post.controls.${action}_post`,
action: `${action}Post`,
title: `post.controls.${action}_post_description`,
className: `btn-default ${action}-post`
});
if (attrs.locked) {
contents.push({
icon: "unlock",
label: "post.controls.unlock_post",
action: "unlockPost",
title: "post.controls.unlock_post_description",
className: "btn-default unlock-post"
});
} else {
contents.push({
icon: "lock",
label: "post.controls.lock_post",
action: "lockPost",
title: "post.controls.lock_post_description",
className: "btn-default lock-post"
});
}
}
if (attrs.canManage || attrs.canWiki) {
@ -102,6 +111,24 @@ export function buildManageButtons(attrs, currentUser, siteSettings) {
}
}
if (currentUser.staff) {
if (attrs.noticeType) {
contents.push({
icon: "asterisk",
label: "post.controls.remove_post_notice",
action: "removeNotice",
className: "btn-default remove-notice"
});
} else {
contents.push({
icon: "asterisk",
label: "post.controls.add_post_notice",
action: "addNotice",
className: "btn-default add-notice"
});
}
}
return contents;
}

View File

@ -434,13 +434,7 @@ createWidget("post-notice", {
tagName: "div.post-notice",
buildClasses(attrs) {
const classes = [];
if (attrs.postNoticeType === "first") {
classes.push("new-user");
} else if (attrs.postNoticeType === "returning") {
classes.push("returning-user");
}
const classes = [attrs.noticeType.replace(/_/g, "-")];
if (
new Date() - new Date(attrs.created_at) >
@ -458,13 +452,16 @@ createWidget("post-notice", {
? attrs.username
: attrs.name;
let text, icon;
if (attrs.postNoticeType === "first") {
if (attrs.noticeType === "custom") {
icon = "asterisk";
text = attrs.noticeMessage;
} else if (attrs.noticeType === "new_user") {
icon = "hands-helping";
text = I18n.t("post.notice.first", { user });
} else if (attrs.postNoticeType === "returning") {
text = I18n.t("post.notice.new_user", { user });
} else if (attrs.noticeType === "returning_user") {
icon = "far-smile";
const distance = (new Date() - new Date(attrs.postNoticeTime)) / 1000;
text = I18n.t("post.notice.return", {
const distance = (new Date() - new Date(attrs.noticeTime)) / 1000;
text = I18n.t("post.notice.returning_user", {
user,
time: durationTiny(distance, { addAgo: true })
});
@ -552,7 +549,7 @@ createWidget("post-article", {
);
}
if (attrs.postNoticeType) {
if (attrs.noticeType) {
rows.push(h("div.row", [this.attach("post-notice", attrs)]));
}

View File

@ -476,6 +476,22 @@ class PostsController < ApplicationController
render_json_dump(locked: post.locked?)
end
def notice
raise Discourse::NotFound unless guardian.is_staff?
post = find_post_from_params
if params[:notice].present?
post.custom_fields["notice_type"] = Post.notices[:custom]
post.custom_fields["notice_args"] = params[:notice]
post.save_custom_fields
else
post.delete_post_notices
end
render body: nil
end
def bookmark
if params[:bookmarked] == "true"
post = find_post_from_params

View File

@ -142,6 +142,12 @@ class Post < ActiveRecord::Base
email: 3)
end
def self.notices
@notices ||= Enum.new(custom: "custom",
new_user: "new_user",
returning_user: "returning_user")
end
def self.find_by_detail(key, value)
includes(:post_details).find_by(post_details: { key: key, value: value })
end
@ -389,8 +395,8 @@ class Post < ActiveRecord::Base
end
def delete_post_notices
self.custom_fields.delete("post_notice_type")
self.custom_fields.delete("post_notice_time")
self.custom_fields.delete("notice_type")
self.custom_fields.delete("notice_args")
self.save_custom_fields
end

View File

@ -70,8 +70,8 @@ class PostSerializer < BasicPostSerializer
:is_auto_generated,
:action_code,
:action_code_who,
:post_notice_type,
:post_notice_time,
:notice_type,
:notice_args,
:last_wiki_edit,
:locked,
:excerpt
@ -365,24 +365,33 @@ class PostSerializer < BasicPostSerializer
include_action_code? && action_code_who.present?
end
def post_notice_type
post_custom_fields["post_notice_type"]
def notice_type
post_custom_fields["notice_type"]
end
def include_post_notice_type?
return false if !scope.user || !scope.user.id || scope.user.id == object.user_id ||
!object.user || object.user.anonymous? || object.user.bot? || object.user.staged ||
!scope.user.has_trust_level?(SiteSetting.min_post_notice_tl)
def include_notice_type?
case notice_type
when Post.notices[:custom]
return true
when Post.notices[:new_user]
min_trust_level = SiteSetting.new_user_notice_tl
when Post.notices[:returning_user]
min_trust_level = SiteSetting.returning_user_notice_tl
else
return false
end
post_notice_type.present?
scope.user && scope.user.id && object.user &&
scope.user.id != object.user_id &&
scope.user.has_trust_level?(min_trust_level)
end
def post_notice_time
post_custom_fields["post_notice_time"]
def notice_args
post_custom_fields["notice_args"]
end
def include_post_notice_time?
include_post_notice_type? && post_notice_time.present?
def include_notice_args?
notice_args.present? && include_notice_type?
end
def locked

View File

@ -18,8 +18,8 @@ class WebHookPostSerializer < PostSerializer
primary_group_flair_url
primary_group_flair_bg_color
primary_group_flair_color
post_notice_time
post_notice_type
notice_args
notice_type
}.each do |attr|
define_method("include_#{attr}?") do
false

View File

@ -2274,8 +2274,8 @@ en:
other: "view {{count}} hidden replies"
notice:
first: "This is the first time {{user}} has posted — lets welcome them to our community!"
return: "Its been a while since weve seen {{user}} — their last post was {{time}}."
new_user: "This is the first time {{user}} has posted — lets welcome them to our community!"
returning_user: "Its been a while since weve seen {{user}} — their last post was {{time}}."
unread: "Post is unread"
has_replies:
@ -2358,6 +2358,8 @@ en:
delete_topic_disallowed_modal: "You don't have permission to delete this topic. If you really want it to be deleted, submit a flag for moderator attention together with reasoning."
delete_topic_disallowed: "you don't have permission to delete this topic"
delete_topic: "delete topic"
add_post_notice: "Add post notice"
remove_post_notice: "Remove post notice"
actions:
flag: "Flag"

View File

@ -1948,8 +1948,9 @@ en:
max_allowed_message_recipients: "Maximum recipients allowed in a message."
watched_words_regular_expressions: "Watched words are regular expressions."
min_post_notice_tl: "Minimum trust level required to see post notices."
old_post_notice_days: "Days before post notice becomes old"
new_user_notice_tl: "Minimum trust level required to see new user post notices."
returning_user_notice_tl: "Minimum trust level required to see returning user post notices."
returning_users_days: "How many days should pass before a user is considered to be returning."
default_email_digest_frequency: "How often users receive summary emails by default."

View File

@ -553,6 +553,7 @@ Discourse::Application.routes.draw do
put "rebake"
put "unhide"
put "locked"
put "notice"
get "replies"
get "revisions/latest" => "posts#latest_revision"
get "revisions/:revision" => "posts#revisions", constraints: { revision: /\d+/ }

View File

@ -825,12 +825,15 @@ posting:
default: false
client: true
shadowed_by_global: true
min_post_notice_tl:
default: 2
enum: "TrustLevelSetting"
old_post_notice_days:
default: 14
client: true
new_user_notice_tl:
default: 2
enum: "TrustLevelSetting"
returning_user_notice_tl:
default: 2
enum: "TrustLevelSetting"
returning_users_days:
default: 120

View File

@ -0,0 +1,35 @@
class RenamePostNotices < ActiveRecord::Migration[5.2]
def up
add_index :post_custom_fields, :post_id, unique: true, name: "index_post_custom_fields_on_notice_type", where: "name = 'notice_type'"
add_index :post_custom_fields, :post_id, unique: true, name: "index_post_custom_fields_on_notice_args", where: "name = 'notice_args'"
# Split site setting `min_post_notice_tl` into `new_user_notice_tl` and `returning_user_notice_tl`.
execute <<~SQL
INSERT INTO site_settings(name, data_type, value, created_at, updated_at)
SELECT 'new_user_notice_tl', data_type, value, created_at, updated_at
FROM site_settings WHERE name = 'min_post_notice_tl'
UNION
SELECT 'returning_user_notice_tl', data_type, value, created_at, updated_at
FROM site_settings WHERE name = 'min_post_notice_tl'
SQL
execute "DELETE FROM site_settings WHERE name = 'min_post_notice_tl'"
# Rename custom fields to match new naming scheme.
execute "UPDATE post_custom_fields SET name = 'notice_type', value = 'new_user' WHERE name = 'post_notice_type' AND value = 'first'"
execute "UPDATE post_custom_fields SET name = 'notice_type', value = 'returning_user' WHERE name = 'post_notice_type' AND value = 'returning'"
execute "UPDATE post_custom_fields SET name = 'notice_args' WHERE name = 'post_notice_time'"
# Delete all notices for bots, staged and anonymous users.
execute <<~SQL
DELETE FROM user_custom_fields
WHERE (name = 'notice_type' OR name = 'notice_args')
AND user_id IN (SELECT id FROM users WHERE id <= 0 OR staged = true
UNION
SELECT user_id FROM user_custom_fields ucf WHERE name = 'master_id')
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -519,7 +519,7 @@ class PostCreator
end
def create_post_notice
return if @opts[:import_mode] || @user.bot? || @user.staged
return if @opts[:import_mode] || @user.anonymous? || @user.bot? || @user.staged
last_post_time = Post.where(user_id: @user.id)
.order(created_at: :desc)
@ -528,10 +528,10 @@ class PostCreator
.first
if !last_post_time
@post.custom_fields["post_notice_type"] = "first"
@post.custom_fields["notice_type"] = Post.notices[:new_user]
elsif SiteSetting.returning_users_days > 0 && last_post_time < SiteSetting.returning_users_days.days.ago
@post.custom_fields["post_notice_type"] = "returning"
@post.custom_fields["post_notice_time"] = last_post_time.iso8601
@post.custom_fields["notice_type"] = Post.notices[:returning_user]
@post.custom_fields["notice_args"] = last_post_time.iso8601
end
end

View File

@ -20,6 +20,7 @@ module SvgSprite
"arrow-up",
"arrows-alt-h",
"arrows-alt-v",
"asterisk",
"at",
"backward",
"ban",

View File

@ -36,7 +36,7 @@ class TopicView
end
def self.default_post_custom_fields
@default_post_custom_fields ||= ["action_code_who", "post_notice_type", "post_notice_time"]
@default_post_custom_fields ||= ["action_code_who", "notice_type", "notice_args"]
end
def self.post_custom_fields_whitelisters

View File

@ -1331,33 +1331,37 @@ describe PostCreator do
context "#create_post_notice" do
let(:user) { Fabricate(:user) }
let(:staged) { Fabricate(:staged) }
let(:anonymous) { Fabricate(:anonymous) }
it "generates post notices for new users" do
post = PostCreator.create(user, title: "one of my first topics", raw: "one of my first posts")
expect(post.custom_fields["post_notice_type"]).to eq("first")
post = PostCreator.create(user, title: "another one of my first topics", raw: "another one of my first posts")
expect(post.custom_fields["post_notice_type"]).to eq(nil)
post = PostCreator.create!(user, title: "one of my first topics", raw: "one of my first posts")
expect(post.custom_fields["notice_type"]).to eq("new_user")
post = PostCreator.create!(user, title: "another one of my first topics", raw: "another one of my first posts")
expect(post.custom_fields["notice_type"]).to eq(nil)
end
it "generates post notices for returning users" do
SiteSetting.returning_users_days = 30
old_post = Fabricate(:post, user: user, created_at: 31.days.ago)
post = PostCreator.create(user, title: "this is a returning topic", raw: "this is a post")
expect(post.custom_fields["post_notice_type"]).to eq("returning")
expect(post.custom_fields["post_notice_time"]).to eq(old_post.created_at.iso8601)
post = PostCreator.create!(user, title: "this is a returning topic", raw: "this is a post")
expect(post.custom_fields["notice_type"]).to eq(Post.notices[:returning_user])
expect(post.custom_fields["notice_args"]).to eq(old_post.created_at.iso8601)
post = PostCreator.create(user, title: "this is another topic", raw: "this is my another post")
expect(post.custom_fields["post_notice_type"]).to eq(nil)
expect(post.custom_fields["post_notice_time"]).to eq(nil)
post = PostCreator.create!(user, title: "this is another topic", raw: "this is my another post")
expect(post.custom_fields["notice_type"]).to eq(nil)
expect(post.custom_fields["notice_args"]).to eq(nil)
end
it "does not generate for non-human or staged users" do
[Discourse.system_user, staged].each do |user|
it "does not generate for non-human, staged or anonymous users" do
SiteSetting.allow_anonymous_posting = true
[anonymous, Discourse.system_user, staged].each do |user|
expect(user.posts.size).to eq(0)
post = PostCreator.create(user, title: "#{user.name}'s first topic", raw: "#{user.name}'s first post")
expect(post.custom_fields["post_notice_type"]).to eq(nil)
expect(post.custom_fields["post_notice_time"]).to eq(nil)
post = PostCreator.create!(user, title: "#{user.username}'s first topic", raw: "#{user.name}'s first post")
expect(post.custom_fields["notice_type"]).to eq(nil)
expect(post.custom_fields["notice_args"]).to eq(nil)
end
end
end

View File

@ -136,8 +136,8 @@ describe Post do
context 'a post with notices' do
let(:post) {
post = Fabricate(:post, post_args)
post.custom_fields["post_notice_type"] = "returning"
post.custom_fields["post_notice_time"] = 1.day.ago
post.custom_fields["notice_type"] = Post.notices[:returning_user]
post.custom_fields["notice_args"] = 1.day.ago
post.save_custom_fields
post
}

View File

@ -181,8 +181,8 @@ describe PostSerializer do
let(:post) {
post = Fabricate(:post, user: user)
post.custom_fields["post_notice_type"] = "returning"
post.custom_fields["post_notice_time"] = 1.day.ago
post.custom_fields["notice_type"] = Post.notices[:returning_user]
post.custom_fields["notice_args"] = 1.day.ago
post.save_custom_fields
post
}
@ -192,32 +192,16 @@ describe PostSerializer do
end
it "is visible for TL2+ users (except poster)" do
expect(json_for_user(nil)[:post_notice_type]).to eq(nil)
expect(json_for_user(user)[:post_notice_type]).to eq(nil)
expect(json_for_user(nil)[:notice_type]).to eq(nil)
expect(json_for_user(user)[:notice_type]).to eq(nil)
SiteSetting.min_post_notice_tl = 2
expect(json_for_user(user_tl1)[:post_notice_type]).to eq(nil)
expect(json_for_user(user_tl2)[:post_notice_type]).to eq("returning")
SiteSetting.returning_user_notice_tl = 2
expect(json_for_user(user_tl1)[:notice_type]).to eq(nil)
expect(json_for_user(user_tl2)[:notice_type]).to eq(Post.notices[:returning_user])
SiteSetting.min_post_notice_tl = 1
expect(json_for_user(user_tl1)[:post_notice_type]).to eq("returning")
expect(json_for_user(user_tl2)[:post_notice_type]).to eq("returning")
end
it "is visible when created by anonymous, bots and staged users" do
expect(json_for_user(user_tl2)[:post_notice_type]).to eq("returning")
post.user = nil
expect(json_for_user(user_tl2)[:post_notice_type]).to eq(nil)
post.user = AnonymousShadowCreator.get(user)
expect(json_for_user(user_tl2)[:post_notice_type]).to eq(nil)
post.user = Discourse.system_user
expect(json_for_user(user_tl2)[:post_notice_type]).to eq(nil)
post.user = Fabricate(:staged)
expect(json_for_user(user_tl2)[:post_notice_type]).to eq(nil)
SiteSetting.returning_user_notice_tl = 1
expect(json_for_user(user_tl1)[:notice_type]).to eq(Post.notices[:returning_user])
expect(json_for_user(user_tl2)[:notice_type]).to eq(Post.notices[:returning_user])
end
end

View File

@ -4999,7 +4999,7 @@ export default {
edit_reason: null,
can_view_edit_history: true,
wiki: false,
post_notice_type: "first"
notice_type: "new-user"
}
],
stream: [25, 26, 27]

View File

@ -879,8 +879,8 @@ widgetTest("post notice - with username", {
this.siteSettings.prioritize_username_in_ux = true;
this.siteSettings.old_post_notice_days = 14;
this.set("args", {
postNoticeType: "returning",
postNoticeTime: twoDaysAgo,
noticeType: "returning_user",
noticeTime: twoDaysAgo,
username: "codinghorror",
name: "Jeff",
created_at: new Date()
@ -891,7 +891,10 @@ widgetTest("post notice - with username", {
find(".post-notice.returning-user:not(.old)")
.text()
.trim(),
I18n.t("post.notice.return", { user: "codinghorror", time: "2d ago" })
I18n.t("post.notice.returning_user", {
user: "codinghorror",
time: "2d ago"
})
);
}
});
@ -902,7 +905,7 @@ widgetTest("post notice - with name", {
this.siteSettings.prioritize_username_in_ux = false;
this.siteSettings.old_post_notice_days = 14;
this.set("args", {
postNoticeType: "first",
noticeType: "new_user",
username: "codinghorror",
name: "Jeff",
created_at: new Date(2019, 0, 1)
@ -913,7 +916,7 @@ widgetTest("post notice - with name", {
find(".post-notice.old.new-user")
.text()
.trim(),
I18n.t("post.notice.first", { user: "Jeff", time: "Jan '10" })
I18n.t("post.notice.new_user", { user: "Jeff", time: "Jan '10" })
);
}
});