Browse Source

Collapse old comments when re-visiting a topic

For users that have the "mark new comments" feature enabled, this will
collapse old comments when they re-visit a topic that has new ones. It
involves adding a new "individual collapse" style that only collapses a
single comment and doesn't also hide all of its replies.

New comments and their direct parents will stay uncollapsed, and all
other comments in a path up to the root will be individually collapsed.
Any branches with no expanded comments will be fully collapsed. We
should probably add an indicator for how many comments are in a
collapsed chain so that we can distinguish between individually
collapsed ones and larger collapsed chains.
merge-requests/34/head
Chad Birch 6 years ago
parent
commit
4069c33e58
  1. 8
      tildes/scss/modules/_comment.scss
  2. 8
      tildes/static/js/behaviors/comment-collapse-all-button.js
  3. 6
      tildes/static/js/behaviors/comment-collapse-button.js
  4. 2
      tildes/static/js/behaviors/comment-expand-all-button.js
  5. 1
      tildes/tildes/jinja.py
  6. 72
      tildes/tildes/models/comment/comment_tree.py
  7. 10
      tildes/tildes/templates/macros/comments.jinja2
  8. 8
      tildes/tildes/views/topic.py

8
tildes/scss/modules/_comment.scss

@ -142,6 +142,14 @@
} }
} }
// uses @extend to only collapse everything inside the collapsed comment itself and
// not its replies
.is-comment-collapsed-individual {
& > .comment-itself {
@extend .is-comment-collapsed;
}
}
.is-comment-deleted, .is-comment-removed { .is-comment-deleted, .is-comment-removed {
font-size: 0.7rem; font-size: 0.7rem;
font-style: italic; font-style: italic;

8
tildes/static/js/behaviors/comment-collapse-all-button.js

@ -1,5 +1,13 @@
$.onmount('[data-js-comment-collapse-all-button]', function() { $.onmount('[data-js-comment-collapse-all-button]', function() {
$(this).click(function(event) { $(this).click(function(event) {
// first uncollapse any individually collapsed comments
$('.is-comment-collapsed-individual').each(
function(idx, child) {
$(child).find(
'[data-js-comment-collapse-button]:first').trigger('click');
});
// then collapse all first-level replies
$('.comment[data-comment-depth="1"]:not(.is-comment-collapsed)').each( $('.comment[data-comment-depth="1"]:not(.is-comment-collapsed)').each(
function(idx, child) { function(idx, child) {
$(child).find( $(child).find(

6
tildes/static/js/behaviors/comment-collapse-button.js

@ -3,7 +3,13 @@ $.onmount('[data-js-comment-collapse-button]', function() {
$this = $(this); $this = $(this);
$comment = $this.closest('.comment'); $comment = $this.closest('.comment');
// if the comment is individually collapsed, just remove that class,
// otherwise toggle the collapsed state
if ($comment.hasClass('is-comment-collapsed-individual')) {
$comment.removeClass('is-comment-collapsed-individual');
} else {
$comment.toggleClass('is-comment-collapsed'); $comment.toggleClass('is-comment-collapsed');
}
if ($comment.hasClass('is-comment-collapsed')) { if ($comment.hasClass('is-comment-collapsed')) {
$this.text('+'); $this.text('+');

2
tildes/static/js/behaviors/comment-expand-all-button.js

@ -1,6 +1,6 @@
$.onmount('[data-js-comment-expand-all-button]', function() { $.onmount('[data-js-comment-expand-all-button]', function() {
$(this).click(function(event) { $(this).click(function(event) {
$('.comment.is-comment-collapsed').each(
$('.is-comment-collapsed, .is-comment-collapsed-individual').each(
function(idx, child) { function(idx, child) {
$(child).find( $(child).find(
'[data-js-comment-collapse-button]:first').trigger('click'); '[data-js-comment-collapse-button]:first').trigger('click');

1
tildes/tildes/jinja.py

@ -31,7 +31,6 @@ def includeme(config: Configurator) -> None:
settings["jinja2.lstrip_blocks"] = True settings["jinja2.lstrip_blocks"] = True
settings["jinja2.trim_blocks"] = True settings["jinja2.trim_blocks"] = True
settings["jinja2.undefined"] = "strict"
# add custom jinja filters # add custom jinja filters
settings["jinja2.filters"] = {"ago": descriptive_timedelta} settings["jinja2.filters"] = {"ago": descriptive_timedelta}

72
tildes/tildes/models/comment/comment_tree.py

@ -1,5 +1,6 @@
"""Contains the CommentTree class.""" """Contains the CommentTree class."""
from datetime import datetime
from typing import Iterator, List, Optional, Sequence from typing import Iterator, List, Optional, Sequence
from prometheus_client import Histogram from prometheus_client import Histogram
@ -16,6 +17,7 @@ class CommentTree:
- `replies`: the list of all immediate children to that comment - `replies`: the list of all immediate children to that comment
- `has_visible_descendant`: whether the comment has any visible descendants (if - `has_visible_descendant`: whether the comment has any visible descendants (if
not, it can be pruned from the tree) not, it can be pruned from the tree)
- `collapsed_state`: whether to display this comment in a collapsed state
""" """
def __init__(self, comments: Sequence[Comment], sort: CommentSortOption) -> None: def __init__(self, comments: Sequence[Comment], sort: CommentSortOption) -> None:
@ -27,6 +29,11 @@ class CommentTree:
# ensure that parent comments are always processed first # ensure that parent comments are always processed first
self.comments = sorted(comments, key=lambda c: c.created_time) self.comments = sorted(comments, key=lambda c: c.created_time)
for comment in self.comments:
comment.collapsed_state = None
self.comments_by_id = {comment.comment_id: comment for comment in comments}
# if there aren't any comments, we can just bail out here # if there aren't any comments, we can just bail out here
if not self.comments: if not self.comments:
return return
@ -45,15 +52,12 @@ class CommentTree:
def _build_tree(self) -> None: def _build_tree(self) -> None:
"""Build the initial tree from the flat list of Comments.""" """Build the initial tree from the flat list of Comments."""
comments_by_id = {}
for comment in self.comments: for comment in self.comments:
comment.replies = [] comment.replies = []
comment.has_visible_descendant = False comment.has_visible_descendant = False
comments_by_id[comment.comment_id] = comment
if comment.parent_comment_id: if comment.parent_comment_id:
parent_comment = comments_by_id[comment.parent_comment_id]
parent_comment = self.comments_by_id[comment.parent_comment_id]
parent_comment.replies.append(comment) parent_comment.replies.append(comment)
# if this comment isn't deleted, work back up towards the root, # if this comment isn't deleted, work back up towards the root,
@ -66,7 +70,7 @@ class CommentTree:
break break
next_parent_id = parent_comment.parent_comment_id next_parent_id = parent_comment.parent_comment_id
parent_comment = comments_by_id[next_parent_id]
parent_comment = self.comments_by_id[next_parent_id]
else: else:
self.tree.append(comment) self.tree.append(comment)
@ -156,3 +160,61 @@ class CommentTree:
num_comments_range=num_comments_range, num_comments_range=num_comments_range,
order=self.sort.name, order=self.sort.name,
) )
def collapse_old_comments(self, threshold: datetime) -> None:
"""Collapse old comments in the tree.
Any comments newer than the threshold will be uncollapsed, along with their
direct parents. All comments with any uncollapsed descendant will be collapsed
individually. Branches with no uncollapsed comments will be collapsed fully.
"""
for comment in reversed(self.comments):
# as soon as we reach an old comment, we can stop
if comment.created_time <= threshold:
break
if comment.is_deleted or comment.is_removed:
continue
# uncollapse the comment
comment.collapsed_state = "uncollapsed"
# fetch its direct parent and uncollapse it as well
parent_comment: Optional[Comment] = None
if comment.parent_comment_id:
parent_comment = self.comments_by_id[comment.parent_comment_id]
parent_comment.collapsed_state = "uncollapsed"
# then follow parents to the root, individually collapsing them all
while parent_comment:
if not parent_comment.collapsed_state:
parent_comment.collapsed_state = "individual"
if parent_comment.parent_comment_id:
parent_comment = self.comments_by_id[
parent_comment.parent_comment_id
]
else:
parent_comment = None
self._finalize_collapsing()
def _finalize_collapsing(self) -> None:
"""Finish collapsing that was done partially by a different method."""
# if all the comments would end up collapsed, leave them all uncollapsed
if all([comment.collapsed_state is None for comment in self.comments]):
return
# go over each top-level comment and go into each branch depth-first. Any
# comment that still has its state as None can be fully collapsed (and we can
# stop looking down that branch)
def recursively_collapse(comment: Comment) -> None:
if not comment.collapsed_state:
comment.collapsed_state = "full"
return
for reply in comment.replies:
recursively_collapse(reply)
for comment in self.tree:
recursively_collapse(comment)

10
tildes/tildes/templates/macros/comments.jinja2

@ -35,7 +35,9 @@
{% macro render_comment_contents(comment, is_individual_comment=False) %} {% macro render_comment_contents(comment, is_individual_comment=False) %}
<div class="comment-itself"> <div class="comment-itself">
<header> <header>
<button class="btn btn-comment-collapse" data-js-comment-collapse-button>&minus;</button>
<button class="btn btn-comment-collapse" data-js-comment-collapse-button>
{{ "+" if comment.collapsed_state in ("full", "individual") else "−" }}
</button>
{% if request.has_permission('view', comment) %} {% if request.has_permission('view', comment) %}
{{ username_linked(comment.user.username) }} {{ username_linked(comment.user.username) }}
@ -206,6 +208,12 @@
{% elif request.has_permission('view_author', comment.topic) and comment.user == comment.topic.user %} {% elif request.has_permission('view_author', comment.topic) and comment.user == comment.topic.user %}
{% do classes.append('is-comment-by-op') %} {% do classes.append('is-comment-by-op') %}
{% endif %} {% endif %}
{% if comment.collapsed_state == "full" %}
{% do classes.append("is-comment-collapsed") %}
{% elif comment.collapsed_state == "individual" %}
{% do classes.append("is-comment-collapsed-individual") %}
{% endif %}
{% endif %} {% endif %}
{{ classes|join(' ') }} {{ classes|join(' ') }}

8
tildes/tildes/views/topic.py

@ -272,13 +272,17 @@ def get_topic(request: Request, comment_order: CommentSortOption) -> dict:
.all() .all()
) )
# if the feature's enabled, update their last visit to this topic
# if the user has the "mark new comments" feature enabled
if request.user and request.user.track_comment_visits: if request.user and request.user.track_comment_visits:
# update their last visit time for this topic
statement = TopicVisit.generate_insert_statement(request.user, topic) statement = TopicVisit.generate_insert_statement(request.user, topic)
request.db_session.execute(statement) request.db_session.execute(statement)
mark_changed(request.db_session) mark_changed(request.db_session)
# collapse old comments if the user has a previous visit to the topic
if topic.last_visit_time:
tree.collapse_old_comments(topic.last_visit_time)
return { return {
"topic": topic, "topic": topic,
"log": log, "log": log,

Loading…
Cancel
Save