diff --git a/app/assets/javascripts/discourse/app/components/discourse-topic.js b/app/assets/javascripts/discourse/app/components/discourse-topic.js
index d09f9cd245e..de5d69259c4 100644
--- a/app/assets/javascripts/discourse/app/components/discourse-topic.js
+++ b/app/assets/javascripts/discourse/app/components/discourse-topic.js
@@ -6,17 +6,11 @@ import DiscourseURL from "discourse/lib/url";
import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction";
import Scrolling from "discourse/mixins/scrolling";
import { alias } from "@ember/object/computed";
+import { highlightPost } from "discourse/lib/utilities";
import { observes } from "discourse-common/utils/decorators";
const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 300;
-function highlight(postNumber) {
- const $contents = $(`#post_${postNumber} .topic-body`);
-
- $contents.addClass("highlighted");
- $contents.on("animationend", () => $contents.removeClass("highlighted"));
-}
-
export default Component.extend(
AddArchetypeClass,
Scrolling,
@@ -58,7 +52,7 @@ export default Component.extend(
},
_highlightPost(postNumber) {
- scheduleOnce("afterRender", null, highlight, postNumber);
+ scheduleOnce("afterRender", null, highlightPost, postNumber);
},
_hideTopicInHeader() {
diff --git a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js
index c6fb29a8f64..654f69247af 100644
--- a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js
+++ b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js
@@ -45,7 +45,8 @@ export default MountWidget.extend({
"selectedQuery",
"selectedPostsCount",
"searchService",
- "showReadIndicator"
+ "showReadIndicator",
+ "streamFilters"
);
},
diff --git a/app/assets/javascripts/discourse/app/components/user-card-contents.js b/app/assets/javascripts/discourse/app/components/user-card-contents.js
index 6c128fd2b60..a2f66957b7d 100644
--- a/app/assets/javascripts/discourse/app/components/user-card-contents.js
+++ b/app/assets/javascripts/discourse/app/components/user-card-contents.js
@@ -88,7 +88,7 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
usernameClass: (username) => (username ? `user-card-${username}` : ""),
@discourseComputed("username", "topicPostCount")
- togglePostsLabel(username, count) {
+ filterPostsLabel(username, count) {
return I18n.t("topic.filter_to", { username, count });
},
@@ -210,8 +210,8 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
this._close();
},
- togglePosts() {
- this.togglePosts(this.user);
+ filterPosts() {
+ this.filterPosts(this.user);
this._close();
},
diff --git a/app/assets/javascripts/discourse/app/controllers/topic.js b/app/assets/javascripts/discourse/app/controllers/topic.js
index 42a88432c0e..4e714b7c1d1 100644
--- a/app/assets/javascripts/discourse/app/controllers/topic.js
+++ b/app/assets/javascripts/discourse/app/controllers/topic.js
@@ -421,14 +421,31 @@ export default Controller.extend(bufferedProperty("model"), {
}
},
- toggleSummary() {
+ showSummary() {
return this.get("model.postStream")
- .toggleSummary()
+ .showSummary()
.then(() => {
this.updateQueryParams();
});
},
+ cancelFilter(previousFilters) {
+ this.get("model.postStream").cancelFilter();
+ this.get("model.postStream")
+ .refresh()
+ .then(() => {
+ if (previousFilters) {
+ if (previousFilters.replies_to_post_number) {
+ this._jumpToPostNumber(previousFilters.replies_to_post_number);
+ }
+ if (previousFilters.filter_upwards_post_id) {
+ this._jumpToPostId(previousFilters.filter_upwards_post_id);
+ }
+ }
+ this.updateQueryParams();
+ });
+ },
+
removeAllowedUser(user) {
return this.get("model.details")
.removeAllowedUser(user)
@@ -867,9 +884,9 @@ export default Controller.extend(bufferedProperty("model"), {
});
},
- toggleParticipant(user) {
+ filterParticipant(user) {
this.get("model.postStream")
- .toggleParticipant(user.get("username"))
+ .filterParticipant(user.username)
.then(() => this.updateQueryParams);
},
diff --git a/app/assets/javascripts/discourse/app/controllers/user-card.js b/app/assets/javascripts/discourse/app/controllers/user-card.js
index b08b8d3639f..af91ee22d4d 100644
--- a/app/assets/javascripts/discourse/app/controllers/user-card.js
+++ b/app/assets/javascripts/discourse/app/controllers/user-card.js
@@ -5,9 +5,9 @@ export default Controller.extend({
topic: controller(),
actions: {
- togglePosts(user) {
+ filterPosts(user) {
const topicController = this.topic;
- topicController.send("toggleParticipant", user);
+ topicController.send("filterParticipant", user);
},
showUser(user) {
diff --git a/app/assets/javascripts/discourse/app/lib/lock-on.js b/app/assets/javascripts/discourse/app/lib/lock-on.js
index 74df39c2ca1..b8a86ae2f6f 100644
--- a/app/assets/javascripts/discourse/app/lib/lock-on.js
+++ b/app/assets/javascripts/discourse/app/lib/lock-on.js
@@ -41,7 +41,14 @@ export default class LockOn {
}
const { top } = element.getBoundingClientRect();
- const offset = top + window.scrollY;
+ let offset = top + window.scrollY;
+ if (this.options.originalTopOffset) {
+ // if element's original top offset is in the bottom half of the viewport
+ // jump to it, otherwise respect the offset
+ if (window.innerHeight / 2.25 > this.options.originalTopOffset) {
+ return offset - this.options.originalTopOffset;
+ }
+ }
return offset - minimumOffset();
}
@@ -118,6 +125,11 @@ export default class LockOn {
this.previousTop = top;
}
+ // Stop early when maintaining the original offset
+ if (this.options.originalTopOffset) {
+ return this.clearLock();
+ }
+
// Stop after a little while
if (Date.now() - this.startedAt > LOCK_DURATION_MS) {
return this.clearLock();
diff --git a/app/assets/javascripts/discourse/app/lib/transform-post.js b/app/assets/javascripts/discourse/app/lib/transform-post.js
index 0af2e5f9a4b..276f806b196 100644
--- a/app/assets/javascripts/discourse/app/lib/transform-post.js
+++ b/app/assets/javascripts/discourse/app/lib/transform-post.js
@@ -101,6 +101,10 @@ export default function transformPost(
const postTypes = site.post_types;
const topic = post.topic;
const details = topic.get("details");
+ const filteredUpwardsPostID = topic.get("postStream.filterUpwardsPostID");
+ const filteredRepliesPostNumber = topic.get(
+ "postStream.filterRepliesToPostNumber"
+ );
const postAtts = transformBasicPost(post);
@@ -131,9 +135,13 @@ export default function transformPost(
postAtts.isWarning = topic.is_warning;
postAtts.links = post.get("internalLinks");
postAtts.replyDirectlyBelow =
- nextPost && nextPost.reply_to_post_number === post.post_number;
+ nextPost &&
+ nextPost.reply_to_post_number === post.post_number &&
+ post.post_number !== filteredRepliesPostNumber;
postAtts.replyDirectlyAbove =
- prevPost && post.reply_to_post_number === prevPost.post_number;
+ prevPost &&
+ post.id !== filteredUpwardsPostID &&
+ post.reply_to_post_number === prevPost.post_number;
postAtts.linkCounts = post.link_counts;
postAtts.actionCode = post.action_code;
postAtts.actionCodeWho = post.action_code_who;
diff --git a/app/assets/javascripts/discourse/app/lib/url.js b/app/assets/javascripts/discourse/app/lib/url.js
index f58fbe5ac38..199b465b496 100644
--- a/app/assets/javascripts/discourse/app/lib/url.js
+++ b/app/assets/javascripts/discourse/app/lib/url.js
@@ -146,6 +146,7 @@ const DiscourseURL = EmberObject.extend({
}
lockon = new LockOn(selector, {
+ originalTopOffset: opts.originalTopOffset,
finished() {
_transitioning = false;
lockon = null;
diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js
index 50b51d59ac4..a350d011813 100644
--- a/app/assets/javascripts/discourse/app/lib/utilities.js
+++ b/app/assets/javascripts/discourse/app/lib/utilities.js
@@ -104,6 +104,25 @@ export function postUrl(slug, topicId, postNumber) {
return url;
}
+export function highlightPost(postNumber) {
+ const container = document.querySelector(`#post_${postNumber}`);
+ if (!container) {
+ return;
+ }
+ const element = container.querySelector(".topic-body");
+ if (!element || element.classList.contains("highlighted")) {
+ return;
+ }
+
+ element.classList.add("highlighted");
+
+ const removeHighlighted = function () {
+ element.classList.remove("highlighted");
+ element.removeEventListener("animationend", removeHighlighted);
+ };
+ element.addEventListener("animationend", removeHighlighted);
+}
+
export function emailValid(email) {
// see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
const re = /^[a-zA-Z0-9!#$%&'*+\/=?\^_`{|}~\-]+(?:\.[a-zA-Z0-9!#$%&'\*+\/=?\^_`{|}~\-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?$/;
diff --git a/app/assets/javascripts/discourse/app/models/post-stream.js b/app/assets/javascripts/discourse/app/models/post-stream.js
index 3ae9c1429af..0f6fd114af5 100644
--- a/app/assets/javascripts/discourse/app/models/post-stream.js
+++ b/app/assets/javascripts/discourse/app/models/post-stream.js
@@ -10,8 +10,10 @@ import { deepMerge } from "discourse-common/lib/object";
import deprecated from "discourse-common/lib/deprecated";
import discourseComputed from "discourse-common/utils/decorators";
import { get } from "@ember/object";
+import { highlightPost } from "discourse/lib/utilities";
import { isEmpty } from "@ember/utils";
import { loadTopicView } from "discourse/models/topic";
+import { schedule } from "@ember/runloop";
export default RestModel.extend({
_identityMap: null,
@@ -27,6 +29,8 @@ export default RestModel.extend({
stagingPost: null,
postsWithPlaceholders: null,
timelineLookup: null,
+ filterRepliesToPostNumber: null,
+ filterUpwardsPostID: null,
init() {
this._identityMap = {};
@@ -42,6 +46,8 @@ export default RestModel.extend({
stream: [],
userFilters: [],
summary: false,
+ filterRepliesToPostNumber: false,
+ filterUpwardsPostID: false,
loaded: false,
loadingAbove: false,
loadingBelow: false,
@@ -117,10 +123,16 @@ export default RestModel.extend({
Returns a JS Object of current stream filter options. It should match the query
params for the stream.
**/
- @discourseComputed("summary", "userFilters.[]")
- streamFilters(summary) {
+ @discourseComputed(
+ "summary",
+ "userFilters.[]",
+ "filterRepliesToPostNumber",
+ "filterUpwardsPostID"
+ )
+ streamFilters() {
const result = {};
- if (summary) {
+
+ if (this.summary) {
result.filter = "summary";
}
@@ -129,6 +141,14 @@ export default RestModel.extend({
result.username_filters = userFilters.join(",");
}
+ if (this.filterRepliesToPostNumber) {
+ result.replies_to_post_number = this.filterRepliesToPostNumber;
+ }
+
+ if (this.filterUpwardsPostID) {
+ result.filter_upwards_post_id = this.filterUpwardsPostID;
+ }
+
return result;
},
@@ -200,49 +220,75 @@ export default RestModel.extend({
},
cancelFilter() {
- this.set("summary", false);
- this.userFilters.clear();
+ this.setProperties({
+ userFilters: [],
+ summary: false,
+ filterRepliesToPostNumber: false,
+ filterUpwardsPostID: false,
+ mixedHiddenPosts: false,
+ });
},
- toggleSummary() {
- this.userFilters.clear();
- this.toggleProperty("summary");
- const opts = {};
-
- if (!this.summary) {
- opts.filter = "none";
- }
-
- return this.refresh(opts).then(() => {
- if (this.summary) {
- this.jumpToSecondVisible();
+ refreshAndJumptoSecondVisible() {
+ return this.refresh({}).then(() => {
+ if (this.posts && this.posts.length > 1) {
+ DiscourseURL.jumpToPost(this.posts[1].get("post_number"));
}
});
},
- jumpToSecondVisible() {
- const posts = this.posts;
- if (posts.length > 1) {
- const secondPostNum = posts[1].get("post_number");
- DiscourseURL.jumpToPost(secondPostNum);
- }
+ showSummary() {
+ this.cancelFilter();
+ this.set("summary", true);
+ return this.refreshAndJumptoSecondVisible();
},
// Filter the stream to a particular user.
- toggleParticipant(username) {
- const userFilters = this.userFilters;
- this.set("summary", false);
+ filterParticipant(username) {
+ this.cancelFilter();
+ this.userFilters.addObject(username);
+ return this.refreshAndJumptoSecondVisible();
+ },
- let jump = false;
- if (userFilters.includes(username)) {
- userFilters.removeObject(username);
- } else {
- userFilters.addObject(username);
- jump = true;
- }
- return this.refresh().then(() => {
- if (jump) {
- this.jumpToSecondVisible();
+ filterReplies(postNumber) {
+ this.cancelFilter();
+ this.set("filterRepliesToPostNumber", postNumber);
+ return this.refresh({ refreshInPlace: true }).then(() => {
+ const element = document.querySelector(`#post_${postNumber}`);
+
+ // order is important, we need to get the offset before triggering a refresh
+ const originalTopOffset = element
+ ? element.getBoundingClientRect().top
+ : null;
+
+ this.appEvents.trigger("post-stream:refresh");
+ DiscourseURL.jumpToPost(postNumber, {
+ originalTopOffset,
+ });
+
+ const replyPostNumbers = this.posts.mapBy("post_number");
+ replyPostNumbers.splice(0, 2);
+ schedule("afterRender", () => {
+ replyPostNumbers.forEach((postNum) => {
+ highlightPost(postNum);
+ });
+ });
+ });
+ },
+
+ filterUpwards(postID) {
+ this.cancelFilter();
+ this.set("filterUpwardsPostID", postID);
+ return this.refresh({ refreshInPlace: true }).then(() => {
+ this.appEvents.trigger("post-stream:refresh");
+
+ if (this.posts && this.posts.length > 1) {
+ const postNumber = this.posts[1].get("post_number");
+ DiscourseURL.jumpToPost(postNumber, { skipIfOnScreen: true });
+
+ schedule("afterRender", () => {
+ highlightPost(postNumber);
+ });
}
});
},
@@ -273,7 +319,9 @@ export default RestModel.extend({
}
// TODO: if we have all the posts in the filter, don't go to the server for them.
- this.set("loadingFilter", true);
+ if (!opts.refreshInPlace) {
+ this.set("loadingFilter", true);
+ }
this.set("loadingNearPost", opts.nearPost);
opts = deepMerge(opts, this.streamFilters);
@@ -327,13 +375,13 @@ export default RestModel.extend({
} else {
delete this.get("gaps.before")[postId];
}
- this.stream.arrayContentDidChange();
this.postsWithPlaceholders.arrayContentDidChange(
origIdx,
0,
posts.length
);
post.set("hasGap", false);
+ this.gapExpanded();
});
}
}
@@ -350,12 +398,22 @@ export default RestModel.extend({
stream.pushObjects(gap);
return this.appendMore().then(() => {
delete this.get("gaps.after")[postId];
- this.stream.arrayContentDidChange();
+ this.gapExpanded();
});
}
return Promise.resolve();
},
+ gapExpanded() {
+ this.appEvents.trigger("post-stream:refresh");
+
+ // resets the reply count in posts-filtered-notice
+ // because once a gap has been expanded that count is no longer exact
+ if (this.streamFilters && this.streamFilters.replies_to_post_number) {
+ this.set("streamFilters.mixedHiddenPosts", true);
+ }
+ },
+
// Appends the next window of posts to the stream. Call it when scrolling downwards.
appendMore() {
// Make sure we can append more posts
diff --git a/app/assets/javascripts/discourse/app/templates/components/user-card-contents.hbs b/app/assets/javascripts/discourse/app/templates/components/user-card-contents.hbs
index 80eb6ad2527..b14298cd261 100644
--- a/app/assets/javascripts/discourse/app/templates/components/user-card-contents.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/user-card-contents.hbs
@@ -81,9 +81,9 @@
{{d-button
class="btn-default"
- action=(action "togglePosts" this.user)
+ action=(action "filterPosts" this.user)
icon="filter"
- translatedLabel=this.togglePostsLabel}}
+ translatedLabel=this.filterPostsLabel}}
{{/if}}
{{#if this.hasUserFilters}}
diff --git a/app/assets/javascripts/discourse/app/templates/topic.hbs b/app/assets/javascripts/discourse/app/templates/topic.hbs
index 03663598558..86522770cfb 100644
--- a/app/assets/javascripts/discourse/app/templates/topic.hbs
+++ b/app/assets/javascripts/discourse/app/templates/topic.hbs
@@ -203,6 +203,7 @@
selectedQuery=selectedQuery
gaps=model.postStream.gaps
showReadIndicator=model.show_read_indicator
+ streamFilters=model.postStream.streamFilters
showFlags=(action "showPostFlags")
editPost=(action "editPost")
showHistory=(route-action "showHistory")
@@ -222,7 +223,8 @@
unhidePost=(action "unhidePost")
replyToPost=(action "replyToPost")
toggleWiki=(action "toggleWiki")
- toggleSummary=(action "toggleSummary")
+ showSummary=(action "showSummary")
+ cancelFilter=(action "cancelFilter")
removeAllowedUser=(action "removeAllowedUser")
removeAllowedGroup=(action "removeAllowedGroup")
topVisibleChanged=(action "topVisibleChanged")
diff --git a/app/assets/javascripts/discourse/app/templates/user-card.hbs b/app/assets/javascripts/discourse/app/templates/user-card.hbs
index 5475c398178..fc75dbece53 100644
--- a/app/assets/javascripts/discourse/app/templates/user-card.hbs
+++ b/app/assets/javascripts/discourse/app/templates/user-card.hbs
@@ -5,7 +5,7 @@
{{user-card-contents
topic=topic.model
showUser=(action "showUser")
- togglePosts=(action "togglePosts")
+ filterPosts=(action "filterPosts")
composePrivateMessage=(route-action "composePrivateMessage")
createNewMessageViaParams=(route-action "createNewMessageViaParams")}}
diff --git a/app/assets/javascripts/discourse/app/widgets/post-menu.js b/app/assets/javascripts/discourse/app/widgets/post-menu.js
index ed5fe33b137..29aa0de1951 100644
--- a/app/assets/javascripts/discourse/app/widgets/post-menu.js
+++ b/app/assets/javascripts/discourse/app/widgets/post-menu.js
@@ -233,11 +233,17 @@ registerButton("wiki-edit", (attrs) => {
registerButton("replies", (attrs, state, siteSettings) => {
const replyCount = attrs.replyCount;
-
if (!replyCount) {
return;
}
+ let action = "toggleRepliesBelow",
+ icon = state.repliesShown ? "chevron-up" : "chevron-down";
+
+ if (siteSettings.enable_filtered_replies_view) {
+ action = "filterRepliesView";
+ }
+
// Omit replies if the setting `suppress_reply_directly_below` is enabled
if (
replyCount === 1 &&
@@ -248,14 +254,16 @@ registerButton("replies", (attrs, state, siteSettings) => {
}
return {
- action: "toggleRepliesBelow",
+ action,
+ icon,
className: "show-replies",
- icon: state.repliesShown ? "chevron-up" : "chevron-down",
titleOptions: { count: replyCount },
- title: "post.has_replies",
+ title: siteSettings.enable_filtered_replies_view
+ ? "post.filtered_replies_hint"
+ : "post.has_replies",
labelOptions: { count: replyCount },
- label: "post.has_replies",
- iconRight: true,
+ label: attrs.mobileView ? "post.has_replies_count" : "post.has_replies",
+ iconRight: !siteSettings.enable_filtered_replies_view || attrs.mobileView,
};
});
@@ -575,7 +583,10 @@ export default createWidget("post-menu", {
const contents = [
h(
"nav.post-controls.clearfix" +
- (this.state.collapsed ? ".collapsed" : ".expanded"),
+ (this.state.collapsed ? ".collapsed" : ".expanded") +
+ (siteSettings.enable_filtered_replies_view
+ ? ".replies-button-visible"
+ : ""),
postControls
),
];
diff --git a/app/assets/javascripts/discourse/app/widgets/post-stream.js b/app/assets/javascripts/discourse/app/widgets/post-stream.js
index 69eccd84e2b..4ab76571105 100644
--- a/app/assets/javascripts/discourse/app/widgets/post-stream.js
+++ b/app/assets/javascripts/discourse/app/widgets/post-stream.js
@@ -1,7 +1,12 @@
+import DiscourseURL from "discourse/lib/url";
+import I18n from "I18n";
import { Placeholder } from "discourse/lib/posts-with-placeholders";
import { addWidgetCleanCallback } from "discourse/components/mount-widget";
+import { avatarFor } from "discourse/widgets/post";
import { createWidget } from "discourse/widgets/widget";
import discourseDebounce from "discourse-common/lib/debounce";
+import { h } from "virtual-dom";
+import { iconNode } from "discourse-common/lib/icon-library";
import { isTesting } from "discourse-common/config/environment";
import transformPost from "discourse/lib/transform-post";
@@ -58,22 +63,133 @@ addWidgetCleanCallback("post-stream", () => {
_heights = {};
});
+createWidget("posts-filtered-notice", {
+ buildKey: (attrs) => `posts-filtered-notice-${attrs.id}`,
+
+ buildClasses() {
+ return ["posts-filtered-notice"];
+ },
+
+ html(attrs) {
+ const filters = attrs.streamFilters;
+
+ if (filters.filter_upwards_post_id || filters.mixedHiddenPosts) {
+ return [
+ h(
+ "span.filtered-replies-viewing",
+ I18n.t("post.filtered_replies.viewing_subset")
+ ),
+ this.attach("filter-show-all", attrs),
+ ];
+ } else if (filters.replies_to_post_number) {
+ const sourcePost = attrs.posts.findBy(
+ "post_number",
+ filters.replies_to_post_number
+ );
+
+ return [
+ h(
+ "span.filtered-replies-viewing",
+ I18n.t("post.filtered_replies.viewing", {
+ reply_count: sourcePost.reply_count,
+ })
+ ),
+ h("span.filtered-user-row", [
+ h(
+ "span.filtered-avatar",
+ avatarFor.call(this, "small", {
+ template: sourcePost.avatar_template,
+ username: sourcePost.username,
+ url: sourcePost.usernameUrl,
+ })
+ ),
+ this.attach("filter-jump-to-post", {
+ username: sourcePost.username,
+ postNumber: filters.replies_to_post_number,
+ }),
+ ]),
+ this.attach("filter-show-all", attrs),
+ ];
+ } else if (filters.filter && filters.filter === "summary") {
+ return [
+ h(
+ "span.filtered-replies-viewing",
+ I18n.t("post.filtered_replies.viewing_summary")
+ ),
+ this.attach("filter-show-all", attrs),
+ ];
+ } else if (filters.username_filters) {
+ return [
+ h(
+ "span.filtered-replies-viewing",
+ I18n.t("post.filtered_replies.viewing_posts_by", {
+ post_count: attrs.posts.length,
+ })
+ ),
+ h(
+ "span.filtered-avatar",
+ avatarFor.call(this, "small", {
+ template: attrs.posts[0].avatar_template,
+ username: attrs.posts[0].username,
+ url: attrs.posts[0].usernameUrl,
+ })
+ ),
+ this.attach("poster-name", attrs.posts[0]),
+ this.attach("filter-show-all", attrs),
+ ];
+ }
+
+ return [];
+ },
+});
+
+createWidget("filter-jump-to-post", {
+ tagName: "a.filtered-jump-to-post",
+ buildKey: (attrs) => `jump-to-post-${attrs.id}`,
+
+ html(attrs) {
+ return I18n.t("post.filtered_replies.post_number", {
+ username: attrs.username,
+ post_number: attrs.postNumber,
+ });
+ },
+
+ click() {
+ DiscourseURL.jumpToPost(this.attrs.postNumber);
+ },
+});
+
+createWidget("filter-show-all", {
+ tagName: "a.filtered-replies-show-all",
+ buildKey: (attrs) => `filtered-show-all-${attrs.id}`,
+
+ buildClasses() {
+ return ["btn", "btn-primary"];
+ },
+
+ html() {
+ return [iconNode("far-comments"), I18n.t("post.filtered_replies.show_all")];
+ },
+
+ click() {
+ this.sendWidgetAction("cancelFilter", this.attrs.streamFilters);
+ },
+});
+
export default createWidget("post-stream", {
tagName: "div.post-stream",
html(attrs) {
- const posts = attrs.posts || [];
- const postArray = posts.toArray();
-
- const result = [];
-
- const before = attrs.gaps && attrs.gaps.before ? attrs.gaps.before : {};
- const after = attrs.gaps && attrs.gaps.after ? attrs.gaps.after : {};
+ const posts = attrs.posts || [],
+ postArray = posts.toArray(),
+ result = [],
+ before = attrs.gaps && attrs.gaps.before ? attrs.gaps.before : {},
+ after = attrs.gaps && attrs.gaps.after ? attrs.gaps.after : {},
+ mobileView = this.site.mobileView;
let prevPost;
let prevDate;
- const mobileView = this.site.mobileView;
for (let i = 0; i < postArray.length; i++) {
const post = postArray[i];
@@ -156,6 +272,20 @@ export default createWidget("post-stream", {
prevPost = post;
}
+
+ if (
+ attrs.streamFilters &&
+ Object.keys(attrs.streamFilters).length &&
+ (Object.keys(before).length > 0 || Object.keys(after).length > 0)
+ ) {
+ result.push(
+ this.attach("posts-filtered-notice", {
+ posts: postArray,
+ streamFilters: attrs.streamFilters,
+ })
+ );
+ }
+
return result;
},
});
diff --git a/app/assets/javascripts/discourse/app/widgets/post.js b/app/assets/javascripts/discourse/app/widgets/post.js
index 0a97e6332ee..277dc987ef6 100644
--- a/app/assets/javascripts/discourse/app/widgets/post.js
+++ b/app/assets/javascripts/discourse/app/widgets/post.js
@@ -126,12 +126,10 @@ createWidget("reply-to-tab", {
},
html(attrs, state) {
- if (state.loading) {
- return I18n.t("loading");
- }
+ const icon = state.loading ? h("div.spinner.small") : iconNode("share");
return [
- iconNode("share"),
+ icon,
" ",
avatarImg("small", {
template: attrs.replyToAvatarTemplate,
@@ -436,6 +434,17 @@ createWidget("post-contents", {
return lastWikiEdit ? lastWikiEdit : createdAt;
},
+ filterRepliesView() {
+ const post = this.findAncestorModel();
+ const controller = this.register.lookup("controller:topic");
+ post
+ .get("topic.postStream")
+ .filterReplies(this.attrs.post_number)
+ .then(() => {
+ controller.updateQueryParams();
+ });
+ },
+
toggleRepliesBelow(goToPost = "false") {
if (this.state.repliesBelow.length) {
this.state.repliesBelow = [];
@@ -617,6 +626,17 @@ createWidget("post-article", {
toggleReplyAbove(goToPost = "false") {
const replyPostNumber = this.attrs.reply_to_post_number;
+ if (this.siteSettings.enable_filtered_replies_view) {
+ const post = this.findAncestorModel();
+ const controller = this.register.lookup("controller:topic");
+ return post
+ .get("topic.postStream")
+ .filterUpwards(this.attrs.id)
+ .then(() => {
+ controller.updateQueryParams();
+ });
+ }
+
// jump directly on mobile
if (this.attrs.mobileView) {
const topicUrl = this._getTopicUrl();
diff --git a/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js b/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js
index 90906385bc0..32a2133670a 100644
--- a/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js
+++ b/app/assets/javascripts/discourse/app/widgets/toggle-topic-summary.js
@@ -39,7 +39,7 @@ export default createWidget("toggle-topic-summary", {
this.attach("button", {
className: "btn btn-primary",
label: attrs.topicSummaryEnabled ? "summary.disable" : "summary.enable",
- action: "toggleSummary",
+ action: attrs.topicSummaryEnabled ? "cancelFilter" : "showSummary",
}),
];
},
diff --git a/app/assets/javascripts/discourse/tests/integration/widgets/post-test.js b/app/assets/javascripts/discourse/tests/integration/widgets/post-test.js
index c996ee2edc5..9206c230eb4 100644
--- a/app/assets/javascripts/discourse/tests/integration/widgets/post-test.js
+++ b/app/assets/javascripts/discourse/tests/integration/widgets/post-test.js
@@ -937,10 +937,10 @@ discourseModule("Integration | Component | Widget | post", function (hooks) {
componentTest("topic map - has summary", {
template:
- '{{mount-widget widget="post" args=args toggleSummary=(action "toggleSummary")}}',
+ '{{mount-widget widget="post" args=args showSummary=(action "showSummary")}}',
beforeEach() {
this.set("args", { showTopicMap: true, hasTopicSummary: true });
- this.on("toggleSummary", () => (this.summaryToggled = true));
+ this.on("showSummary", () => (this.summaryToggled = true));
},
async test(assert) {
assert.equal(queryAll(".toggle-summary").length, 1);
diff --git a/app/assets/javascripts/discourse/tests/unit/models/post-stream-test.js b/app/assets/javascripts/discourse/tests/unit/models/post-stream-test.js
index 849c8e7d6d3..1a5c4b4b945 100644
--- a/app/assets/javascripts/discourse/tests/unit/models/post-stream-test.js
+++ b/app/assets/javascripts/discourse/tests/unit/models/post-stream-test.js
@@ -1,4 +1,5 @@
import { module, test } from "qunit";
+import AppEvents from "discourse/services/app-events";
import ArrayProxy from "@ember/array/proxy";
import Post from "discourse/models/post";
import { Promise } from "rsvp";
@@ -14,6 +15,7 @@ function buildStream(id, stream) {
if (stream) {
ps.set("stream", stream);
}
+ ps.appEvents = AppEvents.create();
return ps;
}
@@ -232,7 +234,7 @@ module("Unit | Model | post-stream", function () {
postStream.cancelFilter();
assert.ok(!postStream.get("summary"), "summary is cancelled");
- postStream.toggleParticipant(participant);
+ postStream.filterParticipant(participant);
postStream.cancelFilter();
assert.blank(
postStream.get("userFilters"),
@@ -282,7 +284,7 @@ module("Unit | Model | post-stream", function () {
);
});
- test("toggleParticipant", function (assert) {
+ test("filterParticipant", function (assert) {
const postStream = buildStream(1236);
sinon.stub(postStream, "refresh").returns(Promise.resolve());
@@ -292,16 +294,71 @@ module("Unit | Model | post-stream", function () {
"by default no participants are toggled"
);
- postStream.toggleParticipant(participant.username);
+ postStream.filterParticipant(participant.username);
assert.ok(
postStream.get("userFilters").includes("eviltrout"),
"eviltrout is in the filters"
);
- postStream.toggleParticipant(participant.username);
- assert.blank(
- postStream.get("userFilters"),
- "toggling the participant again removes them"
+ postStream.cancelFilter();
+ assert.blank(postStream.get("userFilters"), "cancelFilter clears");
+ });
+
+ test("filterReplies", function (assert) {
+ const postStream = buildStream(1234),
+ store = postStream.store;
+
+ postStream.appendPost(
+ store.createRecord("post", { id: 2, post_number: 3 })
+ );
+
+ sinon.stub(postStream, "refresh").returns(Promise.resolve());
+
+ assert.equal(
+ postStream.get("filterRepliesToPostNumber"),
+ false,
+ "by default no replies are filtered"
+ );
+
+ postStream.filterReplies(3);
+ assert.equal(
+ postStream.get("filterRepliesToPostNumber"),
+ 3,
+ "postNumber is in the filters"
+ );
+
+ postStream.cancelFilter();
+ assert.equal(
+ postStream.get("filterRepliesToPostNumber"),
+ false,
+ "cancelFilter clears"
+ );
+ });
+
+ test("filterUpwards", function (assert) {
+ const postStream = buildStream(1234),
+ store = postStream.store;
+
+ postStream.appendPost(
+ store.createRecord("post", { id: 2, post_number: 3 })
+ );
+
+ sinon.stub(postStream, "refresh").returns(Promise.resolve());
+
+ assert.equal(
+ postStream.get("filterUpwardsPostID"),
+ false,
+ "by default filter is false"
+ );
+
+ postStream.filterUpwards(2);
+ assert.equal(postStream.get("filterUpwardsPostID"), 2, "filter is set");
+
+ postStream.cancelFilter();
+ assert.equal(
+ postStream.get("filterUpwardsPostID"),
+ false,
+ "filter cleared"
);
});
@@ -327,7 +384,7 @@ module("Unit | Model | post-stream", function () {
);
assert.ok(!postStream.get("hasNoFilters"), "now there are filters present");
- postStream.toggleParticipant(participant.username);
+ postStream.filterParticipant(participant.username);
assert.deepEqual(
postStream.get("streamFilters"),
{
@@ -335,6 +392,24 @@ module("Unit | Model | post-stream", function () {
},
"streamFilters contains the username we filtered"
);
+
+ postStream.filterUpwards(2);
+ assert.deepEqual(
+ postStream.get("streamFilters"),
+ {
+ filter_upwards_post_id: 2,
+ },
+ "streamFilters contains only the post ID"
+ );
+
+ postStream.filterReplies(1);
+ assert.deepEqual(
+ postStream.get("streamFilters"),
+ {
+ replies_to_post_number: 1,
+ },
+ "streamFilters contains only the last filter"
+ );
});
test("loading", function (assert) {
diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss
index 71f7fd146ab..67eb33a49ac 100644
--- a/app/assets/stylesheets/common/base/topic-post.scss
+++ b/app/assets/stylesheets/common/base/topic-post.scss
@@ -1109,3 +1109,36 @@ a.mention-group {
bottom: -2px;
right: 15px;
}
+
+.posts-filtered-notice {
+ position: -webkit-sticky;
+ position: sticky;
+ background-color: var(--tertiary-low);
+ bottom: 0;
+ padding: 1em;
+ margin-top: 0.5em;
+ text-align: center;
+ z-index: 2;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ max-width: calc(
+ #{$topic-body-width} + (#{$topic-body-width-padding} * 2) + #{$topic-avatar-width} -
+ (0.8em * 2)
+ );
+
+ .filtered-avatar {
+ margin: 0 0.5em;
+ + .names {
+ flex: inherit;
+ }
+ }
+
+ .filtered-replies-show-all {
+ margin-left: 1em;
+ }
+
+ .filtered-user-row {
+ @include ellipsis;
+ }
+}
diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss
index 63d23c1e743..064421d9fe6 100644
--- a/app/assets/stylesheets/desktop/topic-post.scss
+++ b/app/assets/stylesheets/desktop/topic-post.scss
@@ -208,9 +208,13 @@ nav.post-controls {
background: var(--primary-low);
}
.d-icon {
- margin-left: 5px;
+ margin-right: 5px;
font-size: $font-down-1;
}
+ .d-button-label + .d-icon {
+ margin-left: 5px;
+ margin-right: 0;
+ }
}
}
diff --git a/app/assets/stylesheets/mobile/topic-post.scss b/app/assets/stylesheets/mobile/topic-post.scss
index 2b8a34217ec..7f778d1e661 100644
--- a/app/assets/stylesheets/mobile/topic-post.scss
+++ b/app/assets/stylesheets/mobile/topic-post.scss
@@ -102,6 +102,21 @@ span.badge-posts {
}
}
}
+ &.replies-button-visible {
+ display: flex;
+ align-items: center;
+ .show-replies {
+ display: flex;
+ padding: 8px;
+ font-size: $font-up-1;
+ .d-icon {
+ padding-left: 8px;
+ }
+ }
+ .actions {
+ flex-grow: 2;
+ }
+ }
}
}
@@ -268,20 +283,6 @@ span.post-count {
padding: 15px 0;
}
-// mobile has no fixed width on topic-body so overflow: hidden causes problems
-.topic-body {
- overflow: visible;
- .cooked {
- overflow: visible;
- }
-}
-
-// instead, for mobile we set overflow hidden on the post's #main-outlet
-// this prevents image overflow on deeply nested blockquotes, lists, etc
-[class*="archetype-"] #main-outlet {
- overflow: hidden;
-}
-
.quote-button.visible {
z-index: z("tooltip");
}
@@ -426,3 +427,30 @@ span.highlighted {
padding-top: 0;
}
}
+
+.posts-filtered-notice {
+ padding-right: 10em;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ padding-bottom: unquote("max(0.75em, env(safe-area-inset-bottom))");
+ margin: 1em -9px;
+
+ z-index: 101;
+ .filtered-replies-show-all {
+ position: absolute;
+ right: 2em;
+ }
+
+ .filtered-replies-viewing {
+ text-align: left;
+ width: 100%;
+ }
+
+ .filtered-avatar {
+ margin-left: 0;
+ img.avatar {
+ width: 20px;
+ height: 20px;
+ }
+ }
+}
diff --git a/app/controllers/topics_controller.rb b/app/controllers/topics_controller.rb
index 91063417ee3..6d7af2ba359 100644
--- a/app/controllers/topics_controller.rb
+++ b/app/controllers/topics_controller.rb
@@ -56,7 +56,7 @@ class TopicsController < ApplicationController
# arrays are not supported
params[:page] = params[:page].to_i rescue 1
- opts = params.slice(:username_filters, :filter, :page, :post_number, :show_deleted)
+ opts = params.slice(:username_filters, :filter, :page, :post_number, :show_deleted, :replies_to_post_number, :filter_upwards_post_id)
username_filters = opts[:username_filters]
opts[:print] = true if params[:print].present?
@@ -1050,7 +1050,8 @@ class TopicsController < ApplicationController
@topic_view,
scope: guardian,
root: false,
- include_raw: !!params[:include_raw]
+ include_raw: !!params[:include_raw],
+ exclude_suggested_and_related: !!params[:replies_to_post_number] || !!params[:filter_upwards_post_id]
)
respond_to do |format|
diff --git a/app/serializers/suggested_topics_mixin.rb b/app/serializers/suggested_topics_mixin.rb
index 74d3d65a9cf..cfd8f23ce18 100644
--- a/app/serializers/suggested_topics_mixin.rb
+++ b/app/serializers/suggested_topics_mixin.rb
@@ -7,10 +7,12 @@ module SuggestedTopicsMixin
end
def include_related_messages?
+ return false if @options[:exclude_suggested_and_related]
object.next_page.nil? && object.related_messages&.topics
end
def include_suggested_topics?
+ return false if @options[:exclude_suggested_and_related]
object.next_page.nil? && object.suggested_topics&.topics
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 3952c25d508..39d1b298696 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2736,6 +2736,7 @@ en:
has_replies:
one: "%{count} Reply"
other: "%{count} Replies"
+ has_replies_count: "%{count}"
unknown_user: "(unknown/deleted user)"
has_likes_title:
@@ -2747,6 +2748,10 @@ en:
one: "you and %{count} other person liked this post"
other: "you and %{count} other people liked this post"
+ filtered_replies_hint:
+ one: "View this post and its reply"
+ other: "View this post and its %{count} replies"
+
errors:
create: "Sorry, there was an error creating your post. Please try again."
edit: "Sorry, there was an error editing your post. Please try again."
@@ -2917,6 +2922,14 @@ en:
name: "Edit bookmark"
description: "Edit the bookmark name or change the reminder date and time"
+ filtered_replies:
+ viewing: "Viewing %{reply_count} replies to"
+ viewing_posts_by: "Viewing %{post_count} posts by"
+ viewing_subset: "Some replies are collapsed"
+ viewing_summary: "Viewing a summary of this topic"
+ post_number: "%{username}, post #%{post_number}"
+ show_all: "Show all"
+
category:
can: "can… "
none: "(no category)"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 47c22b496d8..bfde2e57a0d 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1863,6 +1863,7 @@ en:
body_min_entropy: "The minimum entropy (unique characters, non-english count for more) required for a post body."
allow_uppercase_posts: "Allow all caps in a topic title or a post body."
max_consecutive_replies: "Number of posts a user has to make in a row in a topic before being prevented from adding another reply."
+ enable_filtered_replies_view: "(n) replies button should collapse all other posts and only show the selected replies."
title_fancy_entities: "Convert common ASCII characters to fancy HTML entities in topic titles, ala SmartyPants https://daringfireball.net/projects/smartypants/"
diff --git a/config/site_settings.yml b/config/site_settings.yml
index fa35652b885..9271511311c 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -725,6 +725,10 @@ posting:
ja: true
max_consecutive_replies:
default: 3
+ enable_filtered_replies_view:
+ default: false
+ client: true
+ hidden: true
title_prettify:
default: true
locale_default:
diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb
index 35cac572909..33d15da2cbc 100644
--- a/lib/svg_sprite/svg_sprite.rb
+++ b/lib/svg_sprite/svg_sprite.rb
@@ -94,6 +94,7 @@ module SvgSprite
"far-clipboard",
"far-clock",
"far-comment",
+ "far-comments",
"far-copyright",
"far-dot-circle",
"far-edit",
diff --git a/lib/topic_view.rb b/lib/topic_view.rb
index d02d4028b16..86f0f0124c1 100644
--- a/lib/topic_view.rb
+++ b/lib/topic_view.rb
@@ -771,6 +771,44 @@ class TopicView
@contains_gaps = true
end
+ # Filter replies
+ if @replies_to_post_number.present?
+ @filtered_posts = @filtered_posts.where('
+ posts.post_number = 1
+ OR posts.post_number = :post_number
+ OR posts.reply_to_post_number = :post_number', { post_number: @replies_to_post_number.to_i })
+
+ @contains_gaps = true
+ end
+
+ # Filtering upwards
+ if @filter_upwards_post_id.present?
+ post = Post.find(@filter_upwards_post_id)
+ post_ids = DB.query_single(<<~SQL, post_id: post.id, topic_id: post.topic_id)
+ WITH RECURSIVE breadcrumb(id, reply_to_post_number) AS (
+ SELECT p.id, p.reply_to_post_number FROM posts AS p
+ WHERE p.id = :post_id
+ UNION
+ SELECT p.id, p.reply_to_post_number FROM posts AS p, breadcrumb
+ WHERE breadcrumb.reply_to_post_number = p.post_number
+ AND p.topic_id = :topic_id
+ )
+ SELECT id from breadcrumb
+ WHERE id <> :post_id
+ ORDER by id
+ SQL
+
+ post_ids = (post_ids[(0 - SiteSetting.max_reply_history)..-1] || post_ids)
+ post_ids.push(post.id)
+
+ @filtered_posts = @filtered_posts.where('
+ posts.post_number = 1
+ OR posts.id IN (:post_ids)
+ OR posts.id > :max_post_id', { post_ids: post_ids, max_post_id: post_ids.max })
+
+ @contains_gaps = true
+ end
+
# Deleted
# This should be last - don't want to tell the admin about deleted posts that clicking the button won't show
# copy the filter for has_deleted? method
diff --git a/spec/requests/topics_controller_spec.rb b/spec/requests/topics_controller_spec.rb
index 92d1eed19dd..07e3448bafc 100644
--- a/spec/requests/topics_controller_spec.rb
+++ b/spec/requests/topics_controller_spec.rb
@@ -2096,6 +2096,77 @@ RSpec.describe TopicsController do
end
end
+ describe '#show filters' do
+ let(:post) { Fabricate(:post) }
+ let(:topic) { post.topic }
+
+ describe 'filter by replies to a post' do
+ let!(:post2) { Fabricate(:post, topic: topic) }
+ let!(:post3) { Fabricate(:post, topic: topic, reply_to_post_number: post2.post_number) }
+ let!(:post4) { Fabricate(:post, topic: topic, reply_to_post_number: post2.post_number) }
+ let!(:post5) { Fabricate(:post, topic: topic) }
+
+ it 'should return the right posts' do
+ get "/t/#{topic.id}.json", params: {
+ replies_to_post_number: post2.post_number
+ }
+
+ expect(response.status).to eq(200)
+
+ body = response.parsed_body
+
+ expect(body.has_key?("suggested_topics")).to eq(false)
+ expect(body.has_key?("related_messages")).to eq(false)
+
+ ids = body["post_stream"]["posts"].map { |p| p["id"] }
+ expect(ids).to eq([post.id, post2.id, post3.id, post4.id])
+ end
+ end
+
+ describe 'filter upwards by post id' do
+ let!(:post2) { Fabricate(:post, topic: topic) }
+ let!(:post3) { Fabricate(:post, topic: topic) }
+ let!(:post4) { Fabricate(:post, topic: topic, reply_to_post_number: post3.post_number) }
+ let!(:post5) { Fabricate(:post, topic: topic, reply_to_post_number: post4.post_number) }
+ let!(:post6) { Fabricate(:post, topic: topic) }
+
+ it 'should return the right posts' do
+ get "/t/#{topic.id}.json", params: {
+ filter_upwards_post_id: post5.id
+ }
+
+ expect(response.status).to eq(200)
+
+ body = response.parsed_body
+
+ expect(body.has_key?("suggested_topics")).to eq(false)
+ expect(body.has_key?("related_messages")).to eq(false)
+
+ ids = body["post_stream"]["posts"].map { |p| p["id"] }
+ # includes topic OP, current post and subsequent posts
+ # but only one level of parents, respecting default max_reply_history = 1
+ expect(ids).to eq([post.id, post4.id, post5.id, post6.id])
+ end
+
+ it 'should respect max_reply_history site setting' do
+ SiteSetting.max_reply_history = 2
+
+ get "/t/#{topic.id}.json", params: {
+ filter_upwards_post_id: post5.id
+ }
+
+ expect(response.status).to eq(200)
+
+ body = response.parsed_body
+ ids = body["post_stream"]["posts"].map { |p| p["id"] }
+
+ # includes 2 levels of replies (post3 and post4)
+ expect(ids).to eq([post.id, post3.id, post4.id, post5.id, post6.id])
+ end
+ end
+
+ end
+
context "when 'login required' site setting has been enabled" do
before { SiteSetting.login_required = true }