FEATURE: Optional filtered replies view (#11387)

See PR for details
This commit is contained in:
Penar Musaraj 2020-12-10 12:02:07 -05:00 committed by GitHub
parent 2eb9c0f3dd
commit adda53c462
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 655 additions and 111 deletions

View File

@ -6,17 +6,11 @@ import DiscourseURL from "discourse/lib/url";
import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction"; import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction";
import Scrolling from "discourse/mixins/scrolling"; import Scrolling from "discourse/mixins/scrolling";
import { alias } from "@ember/object/computed"; import { alias } from "@ember/object/computed";
import { highlightPost } from "discourse/lib/utilities";
import { observes } from "discourse-common/utils/decorators"; import { observes } from "discourse-common/utils/decorators";
const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 300; 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( export default Component.extend(
AddArchetypeClass, AddArchetypeClass,
Scrolling, Scrolling,
@ -58,7 +52,7 @@ export default Component.extend(
}, },
_highlightPost(postNumber) { _highlightPost(postNumber) {
scheduleOnce("afterRender", null, highlight, postNumber); scheduleOnce("afterRender", null, highlightPost, postNumber);
}, },
_hideTopicInHeader() { _hideTopicInHeader() {

View File

@ -45,7 +45,8 @@ export default MountWidget.extend({
"selectedQuery", "selectedQuery",
"selectedPostsCount", "selectedPostsCount",
"searchService", "searchService",
"showReadIndicator" "showReadIndicator",
"streamFilters"
); );
}, },

View File

@ -88,7 +88,7 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
usernameClass: (username) => (username ? `user-card-${username}` : ""), usernameClass: (username) => (username ? `user-card-${username}` : ""),
@discourseComputed("username", "topicPostCount") @discourseComputed("username", "topicPostCount")
togglePostsLabel(username, count) { filterPostsLabel(username, count) {
return I18n.t("topic.filter_to", { username, count }); return I18n.t("topic.filter_to", { username, count });
}, },
@ -210,8 +210,8 @@ export default Component.extend(CardContentsBase, CanCheckEmails, CleansUp, {
this._close(); this._close();
}, },
togglePosts() { filterPosts() {
this.togglePosts(this.user); this.filterPosts(this.user);
this._close(); this._close();
}, },

View File

@ -421,14 +421,31 @@ export default Controller.extend(bufferedProperty("model"), {
} }
}, },
toggleSummary() { showSummary() {
return this.get("model.postStream") return this.get("model.postStream")
.toggleSummary() .showSummary()
.then(() => { .then(() => {
this.updateQueryParams(); 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) { removeAllowedUser(user) {
return this.get("model.details") return this.get("model.details")
.removeAllowedUser(user) .removeAllowedUser(user)
@ -867,9 +884,9 @@ export default Controller.extend(bufferedProperty("model"), {
}); });
}, },
toggleParticipant(user) { filterParticipant(user) {
this.get("model.postStream") this.get("model.postStream")
.toggleParticipant(user.get("username")) .filterParticipant(user.username)
.then(() => this.updateQueryParams); .then(() => this.updateQueryParams);
}, },

View File

@ -5,9 +5,9 @@ export default Controller.extend({
topic: controller(), topic: controller(),
actions: { actions: {
togglePosts(user) { filterPosts(user) {
const topicController = this.topic; const topicController = this.topic;
topicController.send("toggleParticipant", user); topicController.send("filterParticipant", user);
}, },
showUser(user) { showUser(user) {

View File

@ -41,7 +41,14 @@ export default class LockOn {
} }
const { top } = element.getBoundingClientRect(); 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(); return offset - minimumOffset();
} }
@ -118,6 +125,11 @@ export default class LockOn {
this.previousTop = top; this.previousTop = top;
} }
// Stop early when maintaining the original offset
if (this.options.originalTopOffset) {
return this.clearLock();
}
// Stop after a little while // Stop after a little while
if (Date.now() - this.startedAt > LOCK_DURATION_MS) { if (Date.now() - this.startedAt > LOCK_DURATION_MS) {
return this.clearLock(); return this.clearLock();

View File

@ -101,6 +101,10 @@ export default function transformPost(
const postTypes = site.post_types; const postTypes = site.post_types;
const topic = post.topic; const topic = post.topic;
const details = topic.get("details"); const details = topic.get("details");
const filteredUpwardsPostID = topic.get("postStream.filterUpwardsPostID");
const filteredRepliesPostNumber = topic.get(
"postStream.filterRepliesToPostNumber"
);
const postAtts = transformBasicPost(post); const postAtts = transformBasicPost(post);
@ -131,9 +135,13 @@ export default function transformPost(
postAtts.isWarning = topic.is_warning; postAtts.isWarning = topic.is_warning;
postAtts.links = post.get("internalLinks"); postAtts.links = post.get("internalLinks");
postAtts.replyDirectlyBelow = 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 = 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.linkCounts = post.link_counts;
postAtts.actionCode = post.action_code; postAtts.actionCode = post.action_code;
postAtts.actionCodeWho = post.action_code_who; postAtts.actionCodeWho = post.action_code_who;

View File

@ -146,6 +146,7 @@ const DiscourseURL = EmberObject.extend({
} }
lockon = new LockOn(selector, { lockon = new LockOn(selector, {
originalTopOffset: opts.originalTopOffset,
finished() { finished() {
_transitioning = false; _transitioning = false;
lockon = null; lockon = null;

View File

@ -104,6 +104,25 @@ export function postUrl(slug, topicId, postNumber) {
return url; 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) { export function emailValid(email) {
// see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript // 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])?$/; 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])?$/;

View File

@ -10,8 +10,10 @@ import { deepMerge } from "discourse-common/lib/object";
import deprecated from "discourse-common/lib/deprecated"; import deprecated from "discourse-common/lib/deprecated";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
import { get } from "@ember/object"; import { get } from "@ember/object";
import { highlightPost } from "discourse/lib/utilities";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { loadTopicView } from "discourse/models/topic"; import { loadTopicView } from "discourse/models/topic";
import { schedule } from "@ember/runloop";
export default RestModel.extend({ export default RestModel.extend({
_identityMap: null, _identityMap: null,
@ -27,6 +29,8 @@ export default RestModel.extend({
stagingPost: null, stagingPost: null,
postsWithPlaceholders: null, postsWithPlaceholders: null,
timelineLookup: null, timelineLookup: null,
filterRepliesToPostNumber: null,
filterUpwardsPostID: null,
init() { init() {
this._identityMap = {}; this._identityMap = {};
@ -42,6 +46,8 @@ export default RestModel.extend({
stream: [], stream: [],
userFilters: [], userFilters: [],
summary: false, summary: false,
filterRepliesToPostNumber: false,
filterUpwardsPostID: false,
loaded: false, loaded: false,
loadingAbove: false, loadingAbove: false,
loadingBelow: 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 Returns a JS Object of current stream filter options. It should match the query
params for the stream. params for the stream.
**/ **/
@discourseComputed("summary", "userFilters.[]") @discourseComputed(
streamFilters(summary) { "summary",
"userFilters.[]",
"filterRepliesToPostNumber",
"filterUpwardsPostID"
)
streamFilters() {
const result = {}; const result = {};
if (summary) {
if (this.summary) {
result.filter = "summary"; result.filter = "summary";
} }
@ -129,6 +141,14 @@ export default RestModel.extend({
result.username_filters = userFilters.join(","); 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; return result;
}, },
@ -200,49 +220,75 @@ export default RestModel.extend({
}, },
cancelFilter() { cancelFilter() {
this.set("summary", false); this.setProperties({
this.userFilters.clear(); userFilters: [],
summary: false,
filterRepliesToPostNumber: false,
filterUpwardsPostID: false,
mixedHiddenPosts: false,
});
}, },
toggleSummary() { refreshAndJumptoSecondVisible() {
this.userFilters.clear(); return this.refresh({}).then(() => {
this.toggleProperty("summary"); if (this.posts && this.posts.length > 1) {
const opts = {}; DiscourseURL.jumpToPost(this.posts[1].get("post_number"));
if (!this.summary) {
opts.filter = "none";
}
return this.refresh(opts).then(() => {
if (this.summary) {
this.jumpToSecondVisible();
} }
}); });
}, },
jumpToSecondVisible() { showSummary() {
const posts = this.posts; this.cancelFilter();
if (posts.length > 1) { this.set("summary", true);
const secondPostNum = posts[1].get("post_number"); return this.refreshAndJumptoSecondVisible();
DiscourseURL.jumpToPost(secondPostNum);
}
}, },
// Filter the stream to a particular user. // Filter the stream to a particular user.
toggleParticipant(username) { filterParticipant(username) {
const userFilters = this.userFilters; this.cancelFilter();
this.set("summary", false); this.userFilters.addObject(username);
return this.refreshAndJumptoSecondVisible();
},
let jump = false; filterReplies(postNumber) {
if (userFilters.includes(username)) { this.cancelFilter();
userFilters.removeObject(username); this.set("filterRepliesToPostNumber", postNumber);
} else { return this.refresh({ refreshInPlace: true }).then(() => {
userFilters.addObject(username); const element = document.querySelector(`#post_${postNumber}`);
jump = true;
} // order is important, we need to get the offset before triggering a refresh
return this.refresh().then(() => { const originalTopOffset = element
if (jump) { ? element.getBoundingClientRect().top
this.jumpToSecondVisible(); : 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. // 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); this.set("loadingNearPost", opts.nearPost);
opts = deepMerge(opts, this.streamFilters); opts = deepMerge(opts, this.streamFilters);
@ -327,13 +375,13 @@ export default RestModel.extend({
} else { } else {
delete this.get("gaps.before")[postId]; delete this.get("gaps.before")[postId];
} }
this.stream.arrayContentDidChange();
this.postsWithPlaceholders.arrayContentDidChange( this.postsWithPlaceholders.arrayContentDidChange(
origIdx, origIdx,
0, 0,
posts.length posts.length
); );
post.set("hasGap", false); post.set("hasGap", false);
this.gapExpanded();
}); });
} }
} }
@ -350,12 +398,22 @@ export default RestModel.extend({
stream.pushObjects(gap); stream.pushObjects(gap);
return this.appendMore().then(() => { return this.appendMore().then(() => {
delete this.get("gaps.after")[postId]; delete this.get("gaps.after")[postId];
this.stream.arrayContentDidChange(); this.gapExpanded();
}); });
} }
return Promise.resolve(); 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. // Appends the next window of posts to the stream. Call it when scrolling downwards.
appendMore() { appendMore() {
// Make sure we can append more posts // Make sure we can append more posts

View File

@ -81,9 +81,9 @@
<li> <li>
{{d-button {{d-button
class="btn-default" class="btn-default"
action=(action "togglePosts" this.user) action=(action "filterPosts" this.user)
icon="filter" icon="filter"
translatedLabel=this.togglePostsLabel}} translatedLabel=this.filterPostsLabel}}
</li> </li>
{{/if}} {{/if}}
{{#if this.hasUserFilters}} {{#if this.hasUserFilters}}

View File

@ -203,6 +203,7 @@
selectedQuery=selectedQuery selectedQuery=selectedQuery
gaps=model.postStream.gaps gaps=model.postStream.gaps
showReadIndicator=model.show_read_indicator showReadIndicator=model.show_read_indicator
streamFilters=model.postStream.streamFilters
showFlags=(action "showPostFlags") showFlags=(action "showPostFlags")
editPost=(action "editPost") editPost=(action "editPost")
showHistory=(route-action "showHistory") showHistory=(route-action "showHistory")
@ -222,7 +223,8 @@
unhidePost=(action "unhidePost") unhidePost=(action "unhidePost")
replyToPost=(action "replyToPost") replyToPost=(action "replyToPost")
toggleWiki=(action "toggleWiki") toggleWiki=(action "toggleWiki")
toggleSummary=(action "toggleSummary") showSummary=(action "showSummary")
cancelFilter=(action "cancelFilter")
removeAllowedUser=(action "removeAllowedUser") removeAllowedUser=(action "removeAllowedUser")
removeAllowedGroup=(action "removeAllowedGroup") removeAllowedGroup=(action "removeAllowedGroup")
topVisibleChanged=(action "topVisibleChanged") topVisibleChanged=(action "topVisibleChanged")

View File

@ -5,7 +5,7 @@
{{user-card-contents {{user-card-contents
topic=topic.model topic=topic.model
showUser=(action "showUser") showUser=(action "showUser")
togglePosts=(action "togglePosts") filterPosts=(action "filterPosts")
composePrivateMessage=(route-action "composePrivateMessage") composePrivateMessage=(route-action "composePrivateMessage")
createNewMessageViaParams=(route-action "createNewMessageViaParams")}} createNewMessageViaParams=(route-action "createNewMessageViaParams")}}

View File

@ -233,11 +233,17 @@ registerButton("wiki-edit", (attrs) => {
registerButton("replies", (attrs, state, siteSettings) => { registerButton("replies", (attrs, state, siteSettings) => {
const replyCount = attrs.replyCount; const replyCount = attrs.replyCount;
if (!replyCount) { if (!replyCount) {
return; 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 // Omit replies if the setting `suppress_reply_directly_below` is enabled
if ( if (
replyCount === 1 && replyCount === 1 &&
@ -248,14 +254,16 @@ registerButton("replies", (attrs, state, siteSettings) => {
} }
return { return {
action: "toggleRepliesBelow", action,
icon,
className: "show-replies", className: "show-replies",
icon: state.repliesShown ? "chevron-up" : "chevron-down",
titleOptions: { count: replyCount }, titleOptions: { count: replyCount },
title: "post.has_replies", title: siteSettings.enable_filtered_replies_view
? "post.filtered_replies_hint"
: "post.has_replies",
labelOptions: { count: replyCount }, labelOptions: { count: replyCount },
label: "post.has_replies", label: attrs.mobileView ? "post.has_replies_count" : "post.has_replies",
iconRight: true, iconRight: !siteSettings.enable_filtered_replies_view || attrs.mobileView,
}; };
}); });
@ -575,7 +583,10 @@ export default createWidget("post-menu", {
const contents = [ const contents = [
h( h(
"nav.post-controls.clearfix" + "nav.post-controls.clearfix" +
(this.state.collapsed ? ".collapsed" : ".expanded"), (this.state.collapsed ? ".collapsed" : ".expanded") +
(siteSettings.enable_filtered_replies_view
? ".replies-button-visible"
: ""),
postControls postControls
), ),
]; ];

View File

@ -1,7 +1,12 @@
import DiscourseURL from "discourse/lib/url";
import I18n from "I18n";
import { Placeholder } from "discourse/lib/posts-with-placeholders"; import { Placeholder } from "discourse/lib/posts-with-placeholders";
import { addWidgetCleanCallback } from "discourse/components/mount-widget"; import { addWidgetCleanCallback } from "discourse/components/mount-widget";
import { avatarFor } from "discourse/widgets/post";
import { createWidget } from "discourse/widgets/widget"; import { createWidget } from "discourse/widgets/widget";
import discourseDebounce from "discourse-common/lib/debounce"; 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 { isTesting } from "discourse-common/config/environment";
import transformPost from "discourse/lib/transform-post"; import transformPost from "discourse/lib/transform-post";
@ -58,22 +63,133 @@ addWidgetCleanCallback("post-stream", () => {
_heights = {}; _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", { export default createWidget("post-stream", {
tagName: "div.post-stream", tagName: "div.post-stream",
html(attrs) { html(attrs) {
const posts = attrs.posts || []; const posts = attrs.posts || [],
const postArray = posts.toArray(); postArray = posts.toArray(),
result = [],
const result = []; before = attrs.gaps && attrs.gaps.before ? attrs.gaps.before : {},
after = attrs.gaps && attrs.gaps.after ? attrs.gaps.after : {},
const before = attrs.gaps && attrs.gaps.before ? attrs.gaps.before : {}; mobileView = this.site.mobileView;
const after = attrs.gaps && attrs.gaps.after ? attrs.gaps.after : {};
let prevPost; let prevPost;
let prevDate; let prevDate;
const mobileView = this.site.mobileView;
for (let i = 0; i < postArray.length; i++) { for (let i = 0; i < postArray.length; i++) {
const post = postArray[i]; const post = postArray[i];
@ -156,6 +272,20 @@ export default createWidget("post-stream", {
prevPost = post; 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; return result;
}, },
}); });

View File

@ -126,12 +126,10 @@ createWidget("reply-to-tab", {
}, },
html(attrs, state) { html(attrs, state) {
if (state.loading) { const icon = state.loading ? h("div.spinner.small") : iconNode("share");
return I18n.t("loading");
}
return [ return [
iconNode("share"), icon,
" ", " ",
avatarImg("small", { avatarImg("small", {
template: attrs.replyToAvatarTemplate, template: attrs.replyToAvatarTemplate,
@ -436,6 +434,17 @@ createWidget("post-contents", {
return lastWikiEdit ? lastWikiEdit : createdAt; 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") { toggleRepliesBelow(goToPost = "false") {
if (this.state.repliesBelow.length) { if (this.state.repliesBelow.length) {
this.state.repliesBelow = []; this.state.repliesBelow = [];
@ -617,6 +626,17 @@ createWidget("post-article", {
toggleReplyAbove(goToPost = "false") { toggleReplyAbove(goToPost = "false") {
const replyPostNumber = this.attrs.reply_to_post_number; 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 // jump directly on mobile
if (this.attrs.mobileView) { if (this.attrs.mobileView) {
const topicUrl = this._getTopicUrl(); const topicUrl = this._getTopicUrl();

View File

@ -39,7 +39,7 @@ export default createWidget("toggle-topic-summary", {
this.attach("button", { this.attach("button", {
className: "btn btn-primary", className: "btn btn-primary",
label: attrs.topicSummaryEnabled ? "summary.disable" : "summary.enable", label: attrs.topicSummaryEnabled ? "summary.disable" : "summary.enable",
action: "toggleSummary", action: attrs.topicSummaryEnabled ? "cancelFilter" : "showSummary",
}), }),
]; ];
}, },

View File

@ -937,10 +937,10 @@ discourseModule("Integration | Component | Widget | post", function (hooks) {
componentTest("topic map - has summary", { componentTest("topic map - has summary", {
template: template:
'{{mount-widget widget="post" args=args toggleSummary=(action "toggleSummary")}}', '{{mount-widget widget="post" args=args showSummary=(action "showSummary")}}',
beforeEach() { beforeEach() {
this.set("args", { showTopicMap: true, hasTopicSummary: true }); this.set("args", { showTopicMap: true, hasTopicSummary: true });
this.on("toggleSummary", () => (this.summaryToggled = true)); this.on("showSummary", () => (this.summaryToggled = true));
}, },
async test(assert) { async test(assert) {
assert.equal(queryAll(".toggle-summary").length, 1); assert.equal(queryAll(".toggle-summary").length, 1);

View File

@ -1,4 +1,5 @@
import { module, test } from "qunit"; import { module, test } from "qunit";
import AppEvents from "discourse/services/app-events";
import ArrayProxy from "@ember/array/proxy"; import ArrayProxy from "@ember/array/proxy";
import Post from "discourse/models/post"; import Post from "discourse/models/post";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
@ -14,6 +15,7 @@ function buildStream(id, stream) {
if (stream) { if (stream) {
ps.set("stream", stream); ps.set("stream", stream);
} }
ps.appEvents = AppEvents.create();
return ps; return ps;
} }
@ -232,7 +234,7 @@ module("Unit | Model | post-stream", function () {
postStream.cancelFilter(); postStream.cancelFilter();
assert.ok(!postStream.get("summary"), "summary is cancelled"); assert.ok(!postStream.get("summary"), "summary is cancelled");
postStream.toggleParticipant(participant); postStream.filterParticipant(participant);
postStream.cancelFilter(); postStream.cancelFilter();
assert.blank( assert.blank(
postStream.get("userFilters"), 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); const postStream = buildStream(1236);
sinon.stub(postStream, "refresh").returns(Promise.resolve()); sinon.stub(postStream, "refresh").returns(Promise.resolve());
@ -292,16 +294,71 @@ module("Unit | Model | post-stream", function () {
"by default no participants are toggled" "by default no participants are toggled"
); );
postStream.toggleParticipant(participant.username); postStream.filterParticipant(participant.username);
assert.ok( assert.ok(
postStream.get("userFilters").includes("eviltrout"), postStream.get("userFilters").includes("eviltrout"),
"eviltrout is in the filters" "eviltrout is in the filters"
); );
postStream.toggleParticipant(participant.username); postStream.cancelFilter();
assert.blank( assert.blank(postStream.get("userFilters"), "cancelFilter clears");
postStream.get("userFilters"), });
"toggling the participant again removes them"
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"); assert.ok(!postStream.get("hasNoFilters"), "now there are filters present");
postStream.toggleParticipant(participant.username); postStream.filterParticipant(participant.username);
assert.deepEqual( assert.deepEqual(
postStream.get("streamFilters"), postStream.get("streamFilters"),
{ {
@ -335,6 +392,24 @@ module("Unit | Model | post-stream", function () {
}, },
"streamFilters contains the username we filtered" "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) { test("loading", function (assert) {

View File

@ -1109,3 +1109,36 @@ a.mention-group {
bottom: -2px; bottom: -2px;
right: 15px; 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;
}
}

View File

@ -208,9 +208,13 @@ nav.post-controls {
background: var(--primary-low); background: var(--primary-low);
} }
.d-icon { .d-icon {
margin-left: 5px; margin-right: 5px;
font-size: $font-down-1; font-size: $font-down-1;
} }
.d-button-label + .d-icon {
margin-left: 5px;
margin-right: 0;
}
} }
} }

View File

@ -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; 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 { .quote-button.visible {
z-index: z("tooltip"); z-index: z("tooltip");
} }
@ -426,3 +427,30 @@ span.highlighted {
padding-top: 0; 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;
}
}
}

View File

@ -56,7 +56,7 @@ class TopicsController < ApplicationController
# arrays are not supported # arrays are not supported
params[:page] = params[:page].to_i rescue 1 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] username_filters = opts[:username_filters]
opts[:print] = true if params[:print].present? opts[:print] = true if params[:print].present?
@ -1050,7 +1050,8 @@ class TopicsController < ApplicationController
@topic_view, @topic_view,
scope: guardian, scope: guardian,
root: false, 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| respond_to do |format|

View File

@ -7,10 +7,12 @@ module SuggestedTopicsMixin
end end
def include_related_messages? def include_related_messages?
return false if @options[:exclude_suggested_and_related]
object.next_page.nil? && object.related_messages&.topics object.next_page.nil? && object.related_messages&.topics
end end
def include_suggested_topics? def include_suggested_topics?
return false if @options[:exclude_suggested_and_related]
object.next_page.nil? && object.suggested_topics&.topics object.next_page.nil? && object.suggested_topics&.topics
end end

View File

@ -2736,6 +2736,7 @@ en:
has_replies: has_replies:
one: "%{count} Reply" one: "%{count} Reply"
other: "%{count} Replies" other: "%{count} Replies"
has_replies_count: "%{count}"
unknown_user: "(unknown/deleted user)" unknown_user: "(unknown/deleted user)"
has_likes_title: has_likes_title:
@ -2747,6 +2748,10 @@ en:
one: "you and %{count} other person liked this post" one: "you and %{count} other person liked this post"
other: "you and %{count} other people 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: errors:
create: "Sorry, there was an error creating your post. Please try again." create: "Sorry, there was an error creating your post. Please try again."
edit: "Sorry, there was an error editing 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" name: "Edit bookmark"
description: "Edit the bookmark name or change the reminder date and time" 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: category:
can: "can&hellip; " can: "can&hellip; "
none: "(no category)" none: "(no category)"

View File

@ -1863,6 +1863,7 @@ en:
body_min_entropy: "The minimum entropy (unique characters, non-english count for more) required for a post body." 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." 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." 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 <a href='https://daringfireball.net/projects/smartypants/' target='_blank'>https://daringfireball.net/projects/smartypants/</a>" title_fancy_entities: "Convert common ASCII characters to fancy HTML entities in topic titles, ala SmartyPants <a href='https://daringfireball.net/projects/smartypants/' target='_blank'>https://daringfireball.net/projects/smartypants/</a>"

View File

@ -725,6 +725,10 @@ posting:
ja: true ja: true
max_consecutive_replies: max_consecutive_replies:
default: 3 default: 3
enable_filtered_replies_view:
default: false
client: true
hidden: true
title_prettify: title_prettify:
default: true default: true
locale_default: locale_default:

View File

@ -94,6 +94,7 @@ module SvgSprite
"far-clipboard", "far-clipboard",
"far-clock", "far-clock",
"far-comment", "far-comment",
"far-comments",
"far-copyright", "far-copyright",
"far-dot-circle", "far-dot-circle",
"far-edit", "far-edit",

View File

@ -771,6 +771,44 @@ class TopicView
@contains_gaps = true @contains_gaps = true
end 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 # Deleted
# This should be last - don't want to tell the admin about deleted posts that clicking the button won't show # 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 # copy the filter for has_deleted? method

View File

@ -2096,6 +2096,77 @@ RSpec.describe TopicsController do
end end
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 context "when 'login required' site setting has been enabled" do
before { SiteSetting.login_required = true } before { SiteSetting.login_required = true }