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:
parent
de0f2b9546
commit
7a79bd7da3
|
@ -1,5 +1,6 @@
|
|||
import Component from "@ember/component";
|
||||
import { schedule } from "@ember/runloop";
|
||||
import { reads } from "@ember/object/computed";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
|
||||
export default Component.extend({
|
||||
|
@ -17,6 +18,8 @@ export default Component.extend({
|
|||
});
|
||||
},
|
||||
|
||||
canDoBulkActions: reads("currentUser.staff"),
|
||||
|
||||
actions: {
|
||||
showBulkActions() {
|
||||
const controller = showModal("topic-bulk-actions", {
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
|
@ -13,7 +13,7 @@ export default Component.extend({
|
|||
if (path === "faq" || path === "guidelines") {
|
||||
$(window).on("load.faq resize.faq scroll.faq", () => {
|
||||
const faqUnread = !currentUser.get("read_faq");
|
||||
if (faqUnread && isElementInViewport($(".contents p").last())) {
|
||||
if (faqUnread && isElementInViewport($(".contents p").last()[0])) {
|
||||
this.action();
|
||||
}
|
||||
});
|
||||
|
|
|
@ -18,7 +18,6 @@ import discourseComputed from "discourse-common/utils/decorators";
|
|||
import { endWith } from "discourse/lib/computed";
|
||||
import { routeAction } from "discourse/helpers/route-action";
|
||||
import { inject as service } from "@ember/service";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import { userPath } from "discourse/lib/url";
|
||||
|
||||
const controllerOpts = {
|
||||
|
@ -39,6 +38,18 @@ const controllerOpts = {
|
|||
order: readOnly("model.params.order"),
|
||||
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: {
|
||||
changeSort() {
|
||||
deprecated(
|
||||
|
@ -98,17 +109,20 @@ const controllerOpts = {
|
|||
(this.router.currentRoute.queryParams["f"] ||
|
||||
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(
|
||||
"refresh",
|
||||
tracked ? { skipResettingParams: ["filter", "f"] } : {}
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
dismissReadPosts() {
|
||||
showModal("dismiss-read", { title: "topics.bulk.dismiss_read" });
|
||||
},
|
||||
},
|
||||
|
||||
afterRefresh(filter, list, listModel = list) {
|
||||
|
@ -122,32 +136,6 @@ const controllerOpts = {
|
|||
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),
|
||||
allLoaded: empty("model.more_topics_url"),
|
||||
latest: endWith("model.filter", "latest"),
|
||||
|
|
|
@ -8,7 +8,6 @@ import Topic from "discourse/models/topic";
|
|||
import { alias } from "@ember/object/computed";
|
||||
import bootbox from "bootbox";
|
||||
import { queryParams } from "discourse/controllers/discovery-sortable";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
|
||||
export default Controller.extend(BulkTopicSelection, FilterModeMixin, {
|
||||
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")
|
||||
showDismissRead(filter, topicsLength) {
|
||||
return this.isFilterPage(filter, "unread") && topicsLength > 0;
|
||||
return this._isFilterPage(filter, "unread") && topicsLength > 0;
|
||||
},
|
||||
|
||||
@discourseComputed("list.filter", "list.topics.length")
|
||||
showResetNew(filter, topicsLength) {
|
||||
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
|
||||
);
|
||||
return this._isFilterPage(filter, "new") && topicsLength > 0;
|
||||
},
|
||||
|
||||
actions: {
|
||||
dismissReadPosts() {
|
||||
showModal("dismiss-read", { title: "topics.bulk.dismiss_read" });
|
||||
},
|
||||
|
||||
resetNew() {
|
||||
const tracked =
|
||||
(this.router.currentRoute.queryParams["f"] ||
|
||||
this.router.currentRoute.queryParams["filter"]) === "tracked";
|
||||
|
||||
Topic.resetNew(
|
||||
this.category,
|
||||
!this.noSubcategories,
|
||||
let topicIds = this.selected
|
||||
? this.selected.map((topic) => topic.id)
|
||||
: null;
|
||||
|
||||
Topic.resetNew(this.category, !this.noSubcategories, {
|
||||
tracked,
|
||||
this.tag
|
||||
).then(() =>
|
||||
tag: this.tag,
|
||||
topicIds,
|
||||
}).then(() =>
|
||||
this.send(
|
||||
"refresh",
|
||||
tracked ? { skipResettingParams: ["filter", "f"] } : {}
|
||||
|
|
|
@ -1639,7 +1639,7 @@ export default Controller.extend(bufferedProperty("model"), {
|
|||
function () {
|
||||
const $post = $(`.topic-post article#post_${postNumber}`);
|
||||
|
||||
if ($post.length === 0 || isElementInViewport($post)) {
|
||||
if ($post.length === 0 || isElementInViewport($post[0])) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
export default function (element) {
|
||||
if (element instanceof jQuery) {
|
||||
element = element[0];
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $window = $(window),
|
||||
|
|
|
@ -41,6 +41,8 @@ export default function (name, opts) {
|
|||
route.render(fullName, renderArgs);
|
||||
if (opts.title) {
|
||||
modalController.set("title", I18n.t(opts.title));
|
||||
} else if (opts.titleTranslated) {
|
||||
modalController.set("title", opts.titleTranslated);
|
||||
} else {
|
||||
modalController.set("title", null);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
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 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";
|
||||
|
||||
export default Mixin.create({
|
||||
|
@ -12,13 +12,20 @@ export default Mixin.create({
|
|||
autoAddTopicsToBulkSelect: false,
|
||||
selected: null,
|
||||
|
||||
canBulkSelect: alias("currentUser.staff"),
|
||||
canBulkSelect: or("currentUser.staff", "showDismissRead", "showResetNew"),
|
||||
|
||||
@on("init")
|
||||
resetSelected() {
|
||||
this.set("selected", []);
|
||||
},
|
||||
|
||||
_isFilterPage(filter, filterType) {
|
||||
if (!filter) {
|
||||
return false;
|
||||
}
|
||||
return new RegExp(filterType + "$", "gi").test(filter);
|
||||
},
|
||||
|
||||
actions: {
|
||||
toggleBulkSelect() {
|
||||
this.toggleProperty("bulkSelectEnabled");
|
||||
|
|
|
@ -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 };
|
||||
if (category) {
|
||||
data.category_id = category.id;
|
||||
|
@ -765,6 +772,9 @@ Topic.reopenClass({
|
|||
if (tag) {
|
||||
data.tag_id = tag.id;
|
||||
}
|
||||
if (topicIds) {
|
||||
data.topic_ids = topicIds;
|
||||
}
|
||||
|
||||
return ajax("/topics/reset-new", { type: "PUT", data });
|
||||
},
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{{#if canDoBulkActions}}
|
||||
{{#if selected}}
|
||||
<div id="bulk-select">
|
||||
{{d-button class="btn-default bulk-select-btn" action=(action "showBulkActions") icon="wrench"}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
|
|
@ -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}}
|
||||
|
|
@ -2,26 +2,8 @@
|
|||
<div class="alert alert-info">{{redirectedReason}}</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if showDismissAtTop}}
|
||||
<div class="row dismiss-container-top">
|
||||
{{#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}}
|
||||
{{topic-dismiss-buttons position="top" selectedTopics=selected
|
||||
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
|
||||
|
||||
{{#if model.sharedDrafts}}
|
||||
{{topic-list
|
||||
|
@ -89,22 +71,8 @@
|
|||
<footer class="topic-list-bottom">
|
||||
{{conditional-loading-spinner condition=model.loadingMore}}
|
||||
{{#if allLoaded}}
|
||||
{{#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}}
|
||||
{{topic-dismiss-buttons position="bottom" selectedTopics=selected
|
||||
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
|
||||
|
||||
{{#footer-message education=footerEducation message=footerMessage}}
|
||||
{{#if latest}}
|
||||
|
|
|
@ -1,23 +1,5 @@
|
|||
{{#if showDismissAtTop}}
|
||||
<div class="row dismiss-container-top">
|
||||
{{#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}}
|
||||
{{topic-dismiss-buttons position="top" selectedTopics=selected
|
||||
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
|
||||
|
||||
{{#discovery-topics-list model=model refresh=(action "refresh") incomingCount=topicTrackingState.incomingCount as |discoveryTopicList|}}
|
||||
{{#if top}}
|
||||
|
@ -51,22 +33,8 @@
|
|||
<footer class="topic-list-bottom">
|
||||
{{conditional-loading-spinner condition=model.loadingMore}}
|
||||
{{#if allLoaded}}
|
||||
{{#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"
|
||||
id="dismiss-new"
|
||||
action=(action "resetNew")
|
||||
icon="check"
|
||||
label="topics.bulk.dismiss_new"}}
|
||||
{{/if}}
|
||||
{{topic-dismiss-buttons position="bottom" selectedTopics=selected
|
||||
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
|
||||
|
||||
{{#footer-message education=footerEducation message=footerMessage}}
|
||||
{{#if latest}}
|
||||
|
|
|
@ -37,29 +37,8 @@
|
|||
|
||||
{{plugin-outlet name="discovery-list-container-top" args=(hash category=category)}}
|
||||
|
||||
{{#if showDismissAtTop}}
|
||||
<div class="row dismiss-container-top">
|
||||
{{#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}}
|
||||
{{topic-dismiss-buttons position="top" selectedTopics=selected
|
||||
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
|
||||
|
||||
<div class="container list-container">
|
||||
<div class="row">
|
||||
|
@ -99,25 +78,8 @@
|
|||
{{/if}}
|
||||
|
||||
<footer class="topic-list-bottom">
|
||||
{{#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}}
|
||||
{{topic-dismiss-buttons position="bottom" selectedTopics=selected
|
||||
model=model showResetNew=showResetNew showDismissRead=showDismissRead resetNew=(action "resetNew")}}
|
||||
|
||||
{{#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}}.
|
||||
|
|
|
@ -245,11 +245,11 @@ class TopicsController < ApplicationController
|
|||
params.require(:topic_id)
|
||||
params.require(:post_ids)
|
||||
|
||||
post_ids = params[:post_ids].map(&:to_i)
|
||||
unless Array === post_ids
|
||||
unless Array === params[:post_ids]
|
||||
render_json_error("Expecting post_ids to contain a list of posts ids")
|
||||
return
|
||||
end
|
||||
post_ids = params[:post_ids].map(&:to_i)
|
||||
|
||||
if post_ids.length > 100
|
||||
render_json_error("Requested a chunk that is too big")
|
||||
|
@ -911,6 +911,11 @@ class TopicsController < ApplicationController
|
|||
|
||||
def bulk
|
||||
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 }
|
||||
elsif params[:filter] == 'unread'
|
||||
tq = TopicQuery.new(current_user)
|
||||
|
@ -970,7 +975,18 @@ class TopicsController < ApplicationController
|
|||
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)
|
||||
|
||||
render body: nil
|
||||
|
|
|
@ -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
|
|
@ -2332,10 +2332,13 @@ en:
|
|||
delete: "Delete Topics"
|
||||
dismiss: "Dismiss"
|
||||
dismiss_read: "Dismiss all unread"
|
||||
dismiss_read_with_selected: "Dismiss %{count} unread"
|
||||
dismiss_button: "Dismiss…"
|
||||
dismiss_button_with_selected: "Dismiss (%{count})…"
|
||||
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"
|
||||
dismiss_new: "Dismiss New"
|
||||
dismiss_new_with_selected: "Dismiss New (%{count})"
|
||||
toggle: "toggle bulk selection of topics"
|
||||
actions: "Bulk Actions"
|
||||
change_category: "Set Category"
|
||||
|
|
|
@ -14,7 +14,7 @@ class TopicsBulkAction
|
|||
@operations ||= %w(change_category close archive change_notification_level
|
||||
reset_read dismiss_posts delete unlist archive_messages
|
||||
move_messages_to_inbox change_tags append_tags remove_tags
|
||||
relist)
|
||||
relist dismiss_topics)
|
||||
end
|
||||
|
||||
def self.register_operation(name, &block)
|
||||
|
@ -26,7 +26,7 @@ class TopicsBulkAction
|
|||
raise Discourse::InvalidParameters.new(:operation) unless TopicsBulkAction.operations.include?(@operation[:type])
|
||||
# careful these are private methods, we need send
|
||||
send(@operation[:type])
|
||||
@changed_ids
|
||||
@changed_ids.sort
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -81,6 +81,24 @@ class TopicsBulkAction
|
|||
@changed_ids.concat @topic_ids
|
||||
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
|
||||
PostTiming.destroy_for(@user.id, @topic_ids)
|
||||
end
|
||||
|
@ -211,4 +229,18 @@ class TopicsBulkAction
|
|||
@topics ||= Topic.where(id: @topic_ids)
|
||||
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
|
||||
|
|
|
@ -5,6 +5,70 @@ require 'rails_helper'
|
|||
describe TopicsBulkAction do
|
||||
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
|
||||
it "dismisses posts" do
|
||||
post1 = create_post
|
||||
|
|
|
@ -2801,6 +2801,17 @@ RSpec.describe TopicsController do
|
|||
}
|
||||
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
|
||||
# untracked topic
|
||||
CategoryUser.set_notification_level_for_category(user,
|
||||
|
@ -2975,7 +2986,7 @@ RSpec.describe TopicsController do
|
|||
it 'dismisses topics for main category and subcategories' do
|
||||
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"
|
||||
|
||||
|
@ -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])
|
||||
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
|
||||
|
||||
describe '#feature_stats' do
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue