FEATURE: Allow selective dismissal of new and unread topics (#12976)

This PR improves the UI of bulk select so that its context is applied to the Dismiss Unread and Dismiss New buttons. Regular users (not just staff) are now able to use topic bulk selection on the /new and /unread routes to perform these dismiss actions more selectively.

For Dismiss Unread, there is a new count in the text of the button and in the modal when one or more topic is selected with the bulk select checkboxes.

For Dismiss New, there is a count in the button text, and we have added functionality to the server side to accept an array of topic ids to dismiss new for, instead of always having to dismiss all new, the same as the bulk dismiss unread functionality. To clean things up, the `DismissTopics` service has been rolled into the `TopicsBulkAction` service.

We now also show the top Dismiss/Dismiss New button based on whether the bottom one is in the viewport, not just based on the topic count.
This commit is contained in:
Martin Brennan 2021-05-26 09:38:46 +10:00 committed by GitHub
parent de0f2b9546
commit 7a79bd7da3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 399 additions and 292 deletions

View File

@ -1,5 +1,6 @@
import Component from "@ember/component"; import Component from "@ember/component";
import { schedule } from "@ember/runloop"; import { schedule } from "@ember/runloop";
import { reads } from "@ember/object/computed";
import showModal from "discourse/lib/show-modal"; import showModal from "discourse/lib/show-modal";
export default Component.extend({ export default Component.extend({
@ -17,6 +18,8 @@ export default Component.extend({
}); });
}, },
canDoBulkActions: reads("currentUser.staff"),
actions: { actions: {
showBulkActions() { showBulkActions() {
const controller = showModal("topic-bulk-actions", { const controller = showModal("topic-bulk-actions", {

View File

@ -0,0 +1,105 @@
import { action } from "@ember/object";
import showModal from "discourse/lib/show-modal";
import { later } from "@ember/runloop";
import isElementInViewport from "discourse/lib/is-element-in-viewport";
import discourseComputed, { on } from "discourse-common/utils/decorators";
import I18n from "I18n";
import Component from "@ember/component";
export default Component.extend({
tagName: "",
classNames: ["topic-dismiss-buttons"],
position: null,
selectedTopics: null,
model: null,
@discourseComputed("position")
containerClass(position) {
return `dismiss-container-${position}`;
},
@discourseComputed("position")
dismissReadId(position) {
return `dismiss-topics-${position}`;
},
@discourseComputed("position")
dismissNewId(position) {
return `dismiss-new-${position}`;
},
@discourseComputed(
"position",
"isOtherDismissUnreadButtonVisible",
"isOtherDismissNewButtonVisible"
)
showBasedOnPosition(
position,
isOtherDismissUnreadButtonVisible,
isOtherDismissNewButtonVisible
) {
if (position !== "top") {
return true;
}
return !(
isOtherDismissUnreadButtonVisible || isOtherDismissNewButtonVisible
);
},
@discourseComputed("selectedTopics.length")
dismissLabel(selectedTopicCount) {
if (selectedTopicCount === 0) {
return I18n.t("topics.bulk.dismiss_button");
}
return I18n.t("topics.bulk.dismiss_button_with_selected", {
count: selectedTopicCount,
});
},
@discourseComputed("selectedTopics.length")
dismissNewLabel(selectedTopicCount) {
if (selectedTopicCount === 0) {
return I18n.t("topics.bulk.dismiss_new");
}
return I18n.t("topics.bulk.dismiss_new_with_selected", {
count: selectedTopicCount,
});
},
// we want to only render the Dismiss... button at the top of the
// page if the user cannot see the bottom Dismiss... button based on their
// viewport, or if too many topics fill the page
@on("didInsertElement")
_determineOtherDismissVisibility() {
later(() => {
if (this.position === "top") {
this.set(
"isOtherDismissUnreadButtonVisible",
isElementInViewport(document.getElementById("dismiss-topics-bottom"))
);
this.set(
"isOtherDismissNewButtonVisible",
isElementInViewport(document.getElementById("dismiss-new-bottom"))
);
} else {
this.set("isOtherDismissUnreadButtonVisible", true);
this.set("isOtherDismissNewButtonVisible", true);
}
});
},
@action
dismissReadPosts() {
let dismissTitle = "topics.bulk.dismiss_read";
if (this.selectedTopics.length > 0) {
dismissTitle = "topics.bulk.dismiss_read_with_selected";
}
showModal("dismiss-read", {
titleTranslated: I18n.t(dismissTitle, {
count: this.selectedTopics.length,
}),
});
},
});

View File

@ -13,7 +13,7 @@ export default Component.extend({
if (path === "faq" || path === "guidelines") { if (path === "faq" || path === "guidelines") {
$(window).on("load.faq resize.faq scroll.faq", () => { $(window).on("load.faq resize.faq scroll.faq", () => {
const faqUnread = !currentUser.get("read_faq"); const faqUnread = !currentUser.get("read_faq");
if (faqUnread && isElementInViewport($(".contents p").last())) { if (faqUnread && isElementInViewport($(".contents p").last()[0])) {
this.action(); this.action();
} }
}); });

View File

@ -18,7 +18,6 @@ import discourseComputed from "discourse-common/utils/decorators";
import { endWith } from "discourse/lib/computed"; import { endWith } from "discourse/lib/computed";
import { routeAction } from "discourse/helpers/route-action"; import { routeAction } from "discourse/helpers/route-action";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import showModal from "discourse/lib/show-modal";
import { userPath } from "discourse/lib/url"; import { userPath } from "discourse/lib/url";
const controllerOpts = { const controllerOpts = {
@ -39,6 +38,18 @@ const controllerOpts = {
order: readOnly("model.params.order"), order: readOnly("model.params.order"),
ascending: readOnly("model.params.ascending"), ascending: readOnly("model.params.ascending"),
selected: null,
@discourseComputed("model.filter", "model.topics.length")
showDismissRead(filter, topicsLength) {
return this._isFilterPage(filter, "unread") && topicsLength > 0;
},
@discourseComputed("model.filter", "model.topics.length")
showResetNew(filter, topicsLength) {
return this._isFilterPage(filter, "new") && topicsLength > 0;
},
actions: { actions: {
changeSort() { changeSort() {
deprecated( deprecated(
@ -98,17 +109,20 @@ const controllerOpts = {
(this.router.currentRoute.queryParams["f"] || (this.router.currentRoute.queryParams["f"] ||
this.router.currentRoute.queryParams["filter"]) === "tracked"; this.router.currentRoute.queryParams["filter"]) === "tracked";
Topic.resetNew(this.category, !this.noSubcategories, tracked).then(() => let topicIds = this.selected
? this.selected.map((topic) => topic.id)
: null;
Topic.resetNew(this.category, !this.noSubcategories, {
tracked,
topicIds,
}).then(() =>
this.send( this.send(
"refresh", "refresh",
tracked ? { skipResettingParams: ["filter", "f"] } : {} tracked ? { skipResettingParams: ["filter", "f"] } : {}
) )
); );
}, },
dismissReadPosts() {
showModal("dismiss-read", { title: "topics.bulk.dismiss_read" });
},
}, },
afterRefresh(filter, list, listModel = list) { afterRefresh(filter, list, listModel = list) {
@ -122,32 +136,6 @@ const controllerOpts = {
this.send("loadingComplete"); this.send("loadingComplete");
}, },
isFilterPage: function (filter, filterType) {
if (!filter) {
return false;
}
return filter.match(new RegExp(filterType + "$", "gi")) ? true : false;
},
@discourseComputed("model.filter", "model.topics.length")
showDismissRead(filter, topicsLength) {
return this.isFilterPage(filter, "unread") && topicsLength > 0;
},
@discourseComputed("model.filter", "model.topics.length")
showResetNew(filter, topicsLength) {
return this.isFilterPage(filter, "new") && topicsLength > 0;
},
@discourseComputed("model.filter", "model.topics.length")
showDismissAtTop(filter, topicsLength) {
return (
(this.isFilterPage(filter, "new") ||
this.isFilterPage(filter, "unread")) &&
topicsLength >= 15
);
},
hasTopics: gt("model.topics.length", 0), hasTopics: gt("model.topics.length", 0),
allLoaded: empty("model.more_topics_url"), allLoaded: empty("model.more_topics_url"),
latest: endWith("model.filter", "latest"), latest: endWith("model.filter", "latest"),

View File

@ -8,7 +8,6 @@ import Topic from "discourse/models/topic";
import { alias } from "@ember/object/computed"; import { alias } from "@ember/object/computed";
import bootbox from "bootbox"; import bootbox from "bootbox";
import { queryParams } from "discourse/controllers/discovery-sortable"; import { queryParams } from "discourse/controllers/discovery-sortable";
import showModal from "discourse/lib/show-modal";
export default Controller.extend(BulkTopicSelection, FilterModeMixin, { export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
application: controller(), application: controller(),
@ -93,48 +92,31 @@ export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
} }
}, },
isFilterPage: function (filter, filterType) {
if (!filter) {
return false;
}
return filter.match(new RegExp(filterType + "$", "gi")) ? true : false;
},
@discourseComputed("list.filter", "list.topics.length") @discourseComputed("list.filter", "list.topics.length")
showDismissRead(filter, topicsLength) { showDismissRead(filter, topicsLength) {
return this.isFilterPage(filter, "unread") && topicsLength > 0; return this._isFilterPage(filter, "unread") && topicsLength > 0;
}, },
@discourseComputed("list.filter", "list.topics.length") @discourseComputed("list.filter", "list.topics.length")
showResetNew(filter, topicsLength) { showResetNew(filter, topicsLength) {
return this.isFilterPage(filter, "new") && topicsLength > 0; return this._isFilterPage(filter, "new") && topicsLength > 0;
},
@discourseComputed("list.filter", "list.topics.length")
showDismissAtTop(filter, topicsLength) {
return (
(this.isFilterPage(filter, "new") ||
this.isFilterPage(filter, "unread")) &&
topicsLength >= 15
);
}, },
actions: { actions: {
dismissReadPosts() {
showModal("dismiss-read", { title: "topics.bulk.dismiss_read" });
},
resetNew() { resetNew() {
const tracked = const tracked =
(this.router.currentRoute.queryParams["f"] || (this.router.currentRoute.queryParams["f"] ||
this.router.currentRoute.queryParams["filter"]) === "tracked"; this.router.currentRoute.queryParams["filter"]) === "tracked";
Topic.resetNew( let topicIds = this.selected
this.category, ? this.selected.map((topic) => topic.id)
!this.noSubcategories, : null;
Topic.resetNew(this.category, !this.noSubcategories, {
tracked, tracked,
this.tag tag: this.tag,
).then(() => topicIds,
}).then(() =>
this.send( this.send(
"refresh", "refresh",
tracked ? { skipResettingParams: ["filter", "f"] } : {} tracked ? { skipResettingParams: ["filter", "f"] } : {}

View File

@ -1639,7 +1639,7 @@ export default Controller.extend(bufferedProperty("model"), {
function () { function () {
const $post = $(`.topic-post article#post_${postNumber}`); const $post = $(`.topic-post article#post_${postNumber}`);
if ($post.length === 0 || isElementInViewport($post)) { if ($post.length === 0 || isElementInViewport($post[0])) {
return; return;
} }

View File

@ -1,6 +1,6 @@
export default function (element) { export default function (element) {
if (element instanceof jQuery) { if (!element) {
element = element[0]; return;
} }
const $window = $(window), const $window = $(window),

View File

@ -41,6 +41,8 @@ export default function (name, opts) {
route.render(fullName, renderArgs); route.render(fullName, renderArgs);
if (opts.title) { if (opts.title) {
modalController.set("title", I18n.t(opts.title)); modalController.set("title", I18n.t(opts.title));
} else if (opts.titleTranslated) {
modalController.set("title", opts.titleTranslated);
} else { } else {
modalController.set("title", null); modalController.set("title", null);
} }

View File

@ -1,8 +1,8 @@
import Mixin from "@ember/object/mixin"; import Mixin from "@ember/object/mixin";
import { or } from "@ember/object/computed";
import { on } from "discourse-common/utils/decorators";
import { NotificationLevels } from "discourse/lib/notification-levels"; import { NotificationLevels } from "discourse/lib/notification-levels";
import Topic from "discourse/models/topic"; import Topic from "discourse/models/topic";
import { alias } from "@ember/object/computed";
import { on } from "discourse-common/utils/decorators";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
export default Mixin.create({ export default Mixin.create({
@ -12,13 +12,20 @@ export default Mixin.create({
autoAddTopicsToBulkSelect: false, autoAddTopicsToBulkSelect: false,
selected: null, selected: null,
canBulkSelect: alias("currentUser.staff"), canBulkSelect: or("currentUser.staff", "showDismissRead", "showResetNew"),
@on("init") @on("init")
resetSelected() { resetSelected() {
this.set("selected", []); this.set("selected", []);
}, },
_isFilterPage(filter, filterType) {
if (!filter) {
return false;
}
return new RegExp(filterType + "$", "gi").test(filter);
},
actions: { actions: {
toggleBulkSelect() { toggleBulkSelect() {
this.toggleProperty("bulkSelectEnabled"); this.toggleProperty("bulkSelectEnabled");

View File

@ -756,7 +756,14 @@ Topic.reopenClass({
}); });
}, },
resetNew(category, include_subcategories, tracked = false, tag = false) { resetNew(category, include_subcategories, opts = {}) {
let { tracked, tag, topicIds } = {
tracked: false,
tag: null,
topicIds: null,
...opts,
};
const data = { tracked }; const data = { tracked };
if (category) { if (category) {
data.category_id = category.id; data.category_id = category.id;
@ -765,6 +772,9 @@ Topic.reopenClass({
if (tag) { if (tag) {
data.tag_id = tag.id; data.tag_id = tag.id;
} }
if (topicIds) {
data.topic_ids = topicIds;
}
return ajax("/topics/reset-new", { type: "PUT", data }); return ajax("/topics/reset-new", { type: "PUT", data });
}, },

View File

@ -1,5 +1,7 @@
{{#if canDoBulkActions}}
{{#if selected}} {{#if selected}}
<div id="bulk-select"> <div id="bulk-select">
{{d-button class="btn-default bulk-select-btn" action=(action "showBulkActions") icon="wrench"}} {{d-button class="btn-default bulk-select-btn" action=(action "showBulkActions") icon="wrench"}}
</div> </div>
{{/if}} {{/if}}
{{/if}}

View File

@ -0,0 +1,21 @@
{{#if showBasedOnPosition}}
<div class="row {{containerClass}}">
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id=dismissReadId
action=(action "dismissReadPosts")
translatedLabel=dismissLabel
title="topics.bulk.dismiss_tooltip"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
id=dismissNewId
action=resetNew
icon="check"
translatedLabel=dismissNewLabel}}
{{/if}}
</div>
{{/if}}

View File

@ -2,26 +2,8 @@
<div class="alert alert-info">{{redirectedReason}}</div> <div class="alert alert-info">{{redirectedReason}}</div>
{{/if}} {{/if}}
{{#if showDismissAtTop}} {{topic-dismiss-buttons position="top" selectedTopics=selected
<div class="row dismiss-container-top"> model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-topics-top"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-new-top"
action=(action "resetNew")
icon="check"
label="topics.bulk.dismiss_new"}}
{{/if}}
</div>
{{/if}}
{{#if model.sharedDrafts}} {{#if model.sharedDrafts}}
{{topic-list {{topic-list
@ -89,22 +71,8 @@
<footer class="topic-list-bottom"> <footer class="topic-list-bottom">
{{conditional-loading-spinner condition=model.loadingMore}} {{conditional-loading-spinner condition=model.loadingMore}}
{{#if allLoaded}} {{#if allLoaded}}
{{#if showDismissRead}} {{topic-dismiss-buttons position="bottom" selectedTopics=selected
{{d-button model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
class="btn-default dismiss-read"
id="dismiss-topics"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
action=(action "resetNew")
id="dismiss-new"
icon="check"
label="topics.bulk.dismiss_new"}}
{{/if}}
{{#footer-message education=footerEducation message=footerMessage}} {{#footer-message education=footerEducation message=footerMessage}}
{{#if latest}} {{#if latest}}

View File

@ -1,23 +1,5 @@
{{#if showDismissAtTop}} {{topic-dismiss-buttons position="top" selectedTopics=selected
<div class="row dismiss-container-top"> model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-topics-top"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-new-top"
action=(action "resetNew")
icon="check"
label="topics.bulk.dismiss_new"}}
{{/if}}
</div>
{{/if}}
{{#discovery-topics-list model=model refresh=(action "refresh") incomingCount=topicTrackingState.incomingCount as |discoveryTopicList|}} {{#discovery-topics-list model=model refresh=(action "refresh") incomingCount=topicTrackingState.incomingCount as |discoveryTopicList|}}
{{#if top}} {{#if top}}
@ -51,22 +33,8 @@
<footer class="topic-list-bottom"> <footer class="topic-list-bottom">
{{conditional-loading-spinner condition=model.loadingMore}} {{conditional-loading-spinner condition=model.loadingMore}}
{{#if allLoaded}} {{#if allLoaded}}
{{#if showDismissRead}} {{topic-dismiss-buttons position="bottom" selectedTopics=selected
{{d-button model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
class="btn-default dismiss-read"
id="dismiss-topics"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-new"
action=(action "resetNew")
icon="check"
label="topics.bulk.dismiss_new"}}
{{/if}}
{{#footer-message education=footerEducation message=footerMessage}} {{#footer-message education=footerEducation message=footerMessage}}
{{#if latest}} {{#if latest}}

View File

@ -37,29 +37,8 @@
{{plugin-outlet name="discovery-list-container-top" args=(hash category=category)}} {{plugin-outlet name="discovery-list-container-top" args=(hash category=category)}}
{{#if showDismissAtTop}} {{topic-dismiss-buttons position="top" selectedTopics=selected
<div class="row dismiss-container-top"> model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
{{#if showDismissRead}}
{{d-button
class="btn-default dismiss-read"
id="dismiss-topics"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"
}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
action=(action "resetNew")
id="dismiss-new"
icon="check"
label="topics.bulk.dismiss_new"
}}
{{/if}}
</div>
{{/if}}
<div class="container list-container"> <div class="container list-container">
<div class="row"> <div class="row">
@ -99,25 +78,8 @@
{{/if}} {{/if}}
<footer class="topic-list-bottom"> <footer class="topic-list-bottom">
{{#if showDismissRead}} {{topic-dismiss-buttons position="bottom" selectedTopics=selected
{{d-button model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
class="btn-default dismiss-read"
id="dismiss-topics"
action=(action "dismissReadPosts")
title="topics.bulk.dismiss_tooltip"
label="topics.bulk.dismiss_button"
}}
{{/if}}
{{#if showResetNew}}
{{d-button
class="btn-default dismiss-read"
action=(action "resetNew")
id="dismiss-new"
icon="check"
label="topics.bulk.dismiss_new"
}}
{{/if}}
{{#footer-message education=footerEducation message=footerMessage}} {{#footer-message education=footerEducation message=footerMessage}}
{{#link-to "tags"}} {{i18n "topic.browse_all_tags"}}{{/link-to}} {{i18n "or"}} {{#link-to "discovery.latest"}}{{i18n "topic.view_latest_topics"}}{{/link-to}}. {{#link-to "tags"}} {{i18n "topic.browse_all_tags"}}{{/link-to}} {{i18n "or"}} {{#link-to "discovery.latest"}}{{i18n "topic.view_latest_topics"}}{{/link-to}}.

View File

@ -245,11 +245,11 @@ class TopicsController < ApplicationController
params.require(:topic_id) params.require(:topic_id)
params.require(:post_ids) params.require(:post_ids)
post_ids = params[:post_ids].map(&:to_i) unless Array === params[:post_ids]
unless Array === post_ids
render_json_error("Expecting post_ids to contain a list of posts ids") render_json_error("Expecting post_ids to contain a list of posts ids")
return return
end end
post_ids = params[:post_ids].map(&:to_i)
if post_ids.length > 100 if post_ids.length > 100
render_json_error("Requested a chunk that is too big") render_json_error("Requested a chunk that is too big")
@ -911,6 +911,11 @@ class TopicsController < ApplicationController
def bulk def bulk
if params[:topic_ids].present? if params[:topic_ids].present?
unless Array === params[:topic_ids]
raise Discourse::InvalidParameters.new(
"Expecting topic_ids to contain a list of topic ids"
)
end
topic_ids = params[:topic_ids].map { |t| t.to_i } topic_ids = params[:topic_ids].map { |t| t.to_i }
elsif params[:filter] == 'unread' elsif params[:filter] == 'unread'
tq = TopicQuery.new(current_user) tq = TopicQuery.new(current_user)
@ -970,7 +975,18 @@ class TopicsController < ApplicationController
end end
end end
dismissed_topic_ids = DismissTopics.new(current_user, topic_scope).perform! if params[:topic_ids].present?
unless Array === params[:topic_ids]
raise Discourse::InvalidParameters.new(
"Expecting topic_ids to contain a list of topic ids"
)
end
topic_ids = params[:topic_ids].map { |t| t.to_i }
topic_scope = topic_scope.where(id: topic_ids)
end
dismissed_topic_ids = TopicsBulkAction.new(current_user, [topic_scope.pluck(:id)], type: "dismiss_topics").perform!
TopicTrackingState.publish_dismiss_new(current_user.id, topic_ids: dismissed_topic_ids) TopicTrackingState.publish_dismiss_new(current_user.id, topic_ids: dismissed_topic_ids)
render body: nil render body: nil

View File

@ -1,45 +0,0 @@
# frozen_string_literal: true
class DismissTopics
def initialize(user, topics_scope)
@user = user
@topics_scope = topics_scope
end
def perform!
DismissedTopicUser.insert_all(rows) if rows.present?
@rows.map { |row| row[:topic_id] }
end
private
def rows
@rows ||= @topics_scope
.joins("LEFT JOIN topic_users ON topic_users.topic_id = topics.id AND topic_users.user_id = #{@user.id}")
.where("topics.created_at >= ?", since_date)
.where("topic_users.last_read_post_number IS NULL")
.where("topics.archetype <> ?", Archetype.private_message)
.order("topics.created_at DESC")
.limit(SiteSetting.max_new_topics).map do |topic|
{
topic_id: topic.id,
user_id: @user.id,
created_at: Time.zone.now
}
end
end
def since_date
new_topic_duration_minutes = @user.user_option&.new_topic_duration_minutes || SiteSetting.default_other_new_topic_duration_minutes
setting_date =
case new_topic_duration_minutes
when User::NewTopicDuration::LAST_VISIT
@user.previous_visit_at || @user.created_at
when User::NewTopicDuration::ALWAYS
@user.created_at
else
new_topic_duration_minutes.minutes.ago
end
[setting_date, @user.created_at, Time.at(SiteSetting.min_new_topics_time).to_datetime].max
end
end

View File

@ -2332,10 +2332,13 @@ en:
delete: "Delete Topics" delete: "Delete Topics"
dismiss: "Dismiss" dismiss: "Dismiss"
dismiss_read: "Dismiss all unread" dismiss_read: "Dismiss all unread"
dismiss_read_with_selected: "Dismiss %{count} unread"
dismiss_button: "Dismiss…" dismiss_button: "Dismiss…"
dismiss_button_with_selected: "Dismiss (%{count})…"
dismiss_tooltip: "Dismiss just new posts or stop tracking topics" dismiss_tooltip: "Dismiss just new posts or stop tracking topics"
also_dismiss_topics: "Stop tracking these topics so they never show up as unread for me again" also_dismiss_topics: "Stop tracking these topics so they never show up as unread for me again"
dismiss_new: "Dismiss New" dismiss_new: "Dismiss New"
dismiss_new_with_selected: "Dismiss New (%{count})"
toggle: "toggle bulk selection of topics" toggle: "toggle bulk selection of topics"
actions: "Bulk Actions" actions: "Bulk Actions"
change_category: "Set Category" change_category: "Set Category"

View File

@ -14,7 +14,7 @@ class TopicsBulkAction
@operations ||= %w(change_category close archive change_notification_level @operations ||= %w(change_category close archive change_notification_level
reset_read dismiss_posts delete unlist archive_messages reset_read dismiss_posts delete unlist archive_messages
move_messages_to_inbox change_tags append_tags remove_tags move_messages_to_inbox change_tags append_tags remove_tags
relist) relist dismiss_topics)
end end
def self.register_operation(name, &block) def self.register_operation(name, &block)
@ -26,7 +26,7 @@ class TopicsBulkAction
raise Discourse::InvalidParameters.new(:operation) unless TopicsBulkAction.operations.include?(@operation[:type]) raise Discourse::InvalidParameters.new(:operation) unless TopicsBulkAction.operations.include?(@operation[:type])
# careful these are private methods, we need send # careful these are private methods, we need send
send(@operation[:type]) send(@operation[:type])
@changed_ids @changed_ids.sort
end end
private private
@ -81,6 +81,24 @@ class TopicsBulkAction
@changed_ids.concat @topic_ids @changed_ids.concat @topic_ids
end end
def dismiss_topics
rows = Topic.where(id: @topic_ids)
.joins("LEFT JOIN topic_users ON topic_users.topic_id = topics.id AND topic_users.user_id = #{@user.id}")
.where("topics.created_at >= ?", dismiss_topics_since_date)
.where("topic_users.last_read_post_number IS NULL")
.where("topics.archetype <> ?", Archetype.private_message)
.order("topics.created_at DESC")
.limit(SiteSetting.max_new_topics).map do |topic|
{
topic_id: topic.id,
user_id: @user.id,
created_at: Time.zone.now
}
end
DismissedTopicUser.insert_all(rows) if rows.present?
@changed_ids = rows.map { |row| row[:topic_id] }
end
def reset_read def reset_read
PostTiming.destroy_for(@user.id, @topic_ids) PostTiming.destroy_for(@user.id, @topic_ids)
end end
@ -211,4 +229,18 @@ class TopicsBulkAction
@topics ||= Topic.where(id: @topic_ids) @topics ||= Topic.where(id: @topic_ids)
end end
def dismiss_topics_since_date
new_topic_duration_minutes = @user.user_option&.new_topic_duration_minutes || SiteSetting.default_other_new_topic_duration_minutes
setting_date =
case new_topic_duration_minutes
when User::NewTopicDuration::LAST_VISIT
@user.previous_visit_at || @user.created_at
when User::NewTopicDuration::ALWAYS
@user.created_at
else
new_topic_duration_minutes.minutes.ago
end
[setting_date, @user.created_at, Time.at(SiteSetting.min_new_topics_time).to_datetime].max
end
end end

View File

@ -5,6 +5,70 @@ require 'rails_helper'
describe TopicsBulkAction do describe TopicsBulkAction do
fab!(:topic) { Fabricate(:topic) } fab!(:topic) { Fabricate(:topic) }
describe type: "dismiss_topics" do
fab!(:user) { Fabricate(:user, created_at: 1.days.ago) }
fab!(:category) { Fabricate(:category) }
fab!(:topic2) { Fabricate(:topic, category: category, created_at: 60.minutes.ago) }
fab!(:topic3) { Fabricate(:topic, category: category, created_at: 120.minutes.ago) }
before do
topic.destroy!
end
it 'dismisses two topics' do
expect { TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform! }.to change { DismissedTopicUser.count }.by(2)
end
it 'returns dismissed topic ids' do
expect(TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!.sort).to match_array(
[topic2.id, topic3.id]
)
end
it 'respects max_new_topics limit' do
SiteSetting.max_new_topics = 1
expect do
TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!
end.to change { DismissedTopicUser.count }.by(1)
dismissed_topic_user = DismissedTopicUser.last
expect(dismissed_topic_user.user_id).to eq(user.id)
expect(dismissed_topic_user.topic_id).to eq(topic2.id)
expect(dismissed_topic_user.created_at).not_to be_nil
end
it 'respects seen topics' do
Fabricate(:topic_user, user: user, topic: topic2, last_read_post_number: 1)
Fabricate(:topic_user, user: user, topic: topic3, last_read_post_number: 1)
expect do
TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!
end.to change { DismissedTopicUser.count }.by(0)
end
it 'dismisses when topic user without last_read_post_number' do
Fabricate(:topic_user, user: user, topic: topic2, last_read_post_number: nil)
Fabricate(:topic_user, user: user, topic: topic3, last_read_post_number: nil)
expect do
TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!
end.to change { DismissedTopicUser.count }.by(2)
end
it 'respects new_topic_duration_minutes' do
user.user_option.update!(new_topic_duration_minutes: 70)
expect do
TopicsBulkAction.new(user, [Topic.all.pluck(:id)], type: "dismiss_topics").perform!
end.to change { DismissedTopicUser.count }.by(1)
dismissed_topic_user = DismissedTopicUser.last
expect(dismissed_topic_user.user_id).to eq(user.id)
expect(dismissed_topic_user.topic_id).to eq(topic2.id)
expect(dismissed_topic_user.created_at).not_to be_nil
end
end
describe "dismiss_posts" do describe "dismiss_posts" do
it "dismisses posts" do it "dismisses posts" do
post1 = create_post post1 = create_post

View File

@ -2801,6 +2801,17 @@ RSpec.describe TopicsController do
} }
end end
it "raises an error if topic_ids is provided and it is not an array" do
put "/topics/bulk.json", params: {
topic_ids: "1", operation: operation
}
expect(response.parsed_body["errors"].first).to match(/Expecting topic_ids to contain a list/)
put "/topics/bulk.json", params: {
topic_ids: [1], operation: operation
}
expect(response.parsed_body["errors"]).to eq(nil)
end
it "respects the tracked parameter" do it "respects the tracked parameter" do
# untracked topic # untracked topic
CategoryUser.set_notification_level_for_category(user, CategoryUser.set_notification_level_for_category(user,
@ -2975,7 +2986,7 @@ RSpec.describe TopicsController do
it 'dismisses topics for main category and subcategories' do it 'dismisses topics for main category and subcategories' do
sign_in(user) sign_in(user)
TopicTrackingState.expects(:publish_dismiss_new).with(user.id, topic_ids: [subcategory_topic.id, category_topic.id]) TopicTrackingState.expects(:publish_dismiss_new).with(user.id, topic_ids: [category_topic.id, subcategory_topic.id])
put "/topics/reset-new.json?category_id=#{category.id}&include_subcategories=true" put "/topics/reset-new.json?category_id=#{category.id}&include_subcategories=true"
@ -3011,6 +3022,69 @@ RSpec.describe TopicsController do
expect(DismissedTopicUser.where(user_id: user.id).pluck(:topic_id)).to eq([tag_and_category_topic.id]) expect(DismissedTopicUser.where(user_id: user.id).pluck(:topic_id)).to eq([tag_and_category_topic.id])
end end
end end
context "specific topics" do
fab!(:topic2) { Fabricate(:topic) }
fab!(:topic3) { Fabricate(:topic) }
it "updates the `new_since` date" do
sign_in(user)
old_date = 2.years.ago
user.user_stat.update_column(:new_since, old_date)
user.update_column(:created_at, old_date)
TopicTrackingState.expects(:publish_dismiss_new).with(user.id, topic_ids: [topic2.id, topic3.id]).at_least_once
put "/topics/reset-new.json", { params: { topic_ids: [topic2.id, topic3.id] } }
expect(response.status).to eq(200)
user.reload
expect(user.user_stat.new_since.to_date).not_to eq(old_date.to_date)
expect(DismissedTopicUser.where(user_id: user.id).pluck(:topic_id)).to match_array([topic2.id, topic3.id])
end
it "raises an error if topic_ids is provided and it is not an array" do
sign_in(user)
put "/topics/reset-new.json", params: { topic_ids: topic2.id }
expect(response.parsed_body["errors"].first).to match(/Expecting topic_ids to contain a list/)
put "/topics/reset-new.json", params: { topic_ids: [topic2.id] }
expect(response.parsed_body["errors"]).to eq(nil)
end
describe "when tracked param is true" do
it "does not update user_stat.new_since and does not dismiss untracked topics" do
sign_in(user)
old_date = 2.years.ago
user.user_stat.update_column(:new_since, old_date)
put "/topics/reset-new.json?tracked=true", { params: { topic_ids: [topic2.id, topic3.id] } }
expect(response.status).to eq(200)
user.reload
expect(user.user_stat.new_since.to_date).to eq(old_date.to_date)
expect(DismissedTopicUser.where(user_id: user.id).pluck(:topic_id)).to be_empty
end
it "creates topic user records for each unread topic" do
sign_in(user)
user.user_stat.update_column(:new_since, 2.years.ago)
tracked_category = Fabricate(:category)
CategoryUser.set_notification_level_for_category(user,
NotificationLevels.all[:tracking],
tracked_category.id)
tracked_topic = create_post.topic
tracked_topic.update!(category_id: tracked_category.id)
topic2.update!(category_id: tracked_category.id)
create_post # This is a new post, but is not tracked so a record will not be created for it
expect do
put "/topics/reset-new.json?tracked=true", { params: { topic_ids: [tracked_topic.id, topic2.id, topic3.id] } }
end.to change { DismissedTopicUser.where(user_id: user.id).count }.by(2)
expect(DismissedTopicUser.where(user_id: user.id).pluck(:topic_id)).to match_array([tracked_topic.id, topic2.id])
end
end
end
end end
describe '#feature_stats' do describe '#feature_stats' do

View File

@ -1,55 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
describe DismissTopics do
fab!(:user) { Fabricate(:user, created_at: 1.days.ago) }
fab!(:category) { Fabricate(:category) }
fab!(:topic1) { Fabricate(:topic, category: category, created_at: 60.minutes.ago) }
fab!(:topic2) { Fabricate(:topic, category: category, created_at: 120.minutes.ago) }
describe '#perform!' do
it 'dismisses two topics' do
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(2)
end
it 'returns dismissed topic ids' do
expect(described_class.new(user, Topic.all).perform!.sort).to eq([topic1.id, topic2.id])
end
it 'respects max_new_topics limit' do
SiteSetting.max_new_topics = 1
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(1)
dismissed_topic_user = DismissedTopicUser.last
expect(dismissed_topic_user.user_id).to eq(user.id)
expect(dismissed_topic_user.topic_id).to eq(topic1.id)
expect(dismissed_topic_user.created_at).not_to be_nil
end
it 'respects seen topics' do
Fabricate(:topic_user, user: user, topic: topic1, last_read_post_number: 1)
Fabricate(:topic_user, user: user, topic: topic2, last_read_post_number: 1)
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(0)
end
it 'dismisses when topic user without last_read_post_number' do
Fabricate(:topic_user, user: user, topic: topic1, last_read_post_number: nil)
Fabricate(:topic_user, user: user, topic: topic2, last_read_post_number: nil)
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(2)
end
it 'respects new_topic_duration_minutes' do
user.user_option.update!(new_topic_duration_minutes: 70)
expect { described_class.new(user, Topic.all).perform! }.to change { DismissedTopicUser.count }.by(1)
dismissed_topic_user = DismissedTopicUser.last
expect(dismissed_topic_user.user_id).to eq(user.id)
expect(dismissed_topic_user.topic_id).to eq(topic1.id)
expect(dismissed_topic_user.created_at).not_to be_nil
end
end
end