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 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() {

View File

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

View File

@ -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();
},

View File

@ -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);
},

View File

@ -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) {

View File

@ -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();

View File

@ -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;

View File

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

View File

@ -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])?$/;

View File

@ -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

View File

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

View File

@ -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")

View File

@ -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")}}

View File

@ -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
),
];

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 { 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;
},
});

View File

@ -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();

View File

@ -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",
}),
];
},

View File

@ -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);

View File

@ -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) {

View File

@ -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;
}
}

View File

@ -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;
}
}
}

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;
}
// 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;
}
}
}

View File

@ -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|

View File

@ -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

View File

@ -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&hellip; "
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."
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 <a href='https://daringfireball.net/projects/smartypants/' target='_blank'>https://daringfireball.net/projects/smartypants/</a>"

View File

@ -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:

View File

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

View File

@ -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

View File

@ -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 }