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 }