diff --git a/comments/constants.py b/comments/constants.py index 1413794396..ca7799f74c 100644 --- a/comments/constants.py +++ b/comments/constants.py @@ -4,3 +4,10 @@ class CommentReportType(models.TextChoices): SPAM = "spam" VIOLATION = "violation" + + +class TimeWindow(models.TextChoices): + ALL_TIME = "all_time" + PAST_WEEK = "past_week" + PAST_MONTH = "past_month" + PAST_YEAR = "past_year" diff --git a/comments/migrations/0024_add_denormalized_fields.py b/comments/migrations/0024_add_denormalized_fields.py index 4636620ce7..dbb5860c9a 100644 --- a/comments/migrations/0024_add_denormalized_fields.py +++ b/comments/migrations/0024_add_denormalized_fields.py @@ -3,7 +3,50 @@ import django.contrib.postgres.indexes import django.contrib.postgres.search from django.conf import settings -from django.db import migrations, models +from django.db import connection, migrations, models + + +def backfill_vote_score(apps, schema_editor): + with connection.cursor() as cursor: + cursor.execute(""" + UPDATE comments_comment c + SET vote_score = COALESCE(sub.score, 0) + FROM ( + SELECT comment_id, SUM(direction) AS score + FROM comments_commentvote + GROUP BY comment_id + ) sub + WHERE c.id = sub.comment_id + """) + print(f"\n vote_score: {cursor.rowcount} rows updated") + + +def backfill_cmm_count(apps, schema_editor): + with connection.cursor() as cursor: + cursor.execute(""" + UPDATE comments_comment c + SET cmm_count = sub.cnt + FROM ( + SELECT comment_id, COUNT(*) AS cnt + FROM comments_changedmymindentry + GROUP BY comment_id + ) sub + WHERE c.id = sub.comment_id + """) + print(f"\n cmm_count: {cursor.rowcount} rows updated") + + +def backfill_text_original_search_vector(apps, schema_editor): + with connection.cursor() as cursor: + cursor.execute(""" + UPDATE comments_comment + SET text_original_search_vector = to_tsvector('english', COALESCE(text_original, '')) + WHERE text_original IS NOT NULL + AND text_original != '' + AND is_private = false + AND is_soft_deleted = false + """) + print(f"\n text_original_search_vector: {cursor.rowcount} rows updated") class Migration(migrations.Migration): @@ -45,4 +88,9 @@ class Migration(migrations.Migration): name="comment_text_search_vector_idx", ), ), + migrations.RunPython(backfill_vote_score, migrations.RunPython.noop), + migrations.RunPython(backfill_cmm_count, migrations.RunPython.noop), + migrations.RunPython( + backfill_text_original_search_vector, migrations.RunPython.noop + ), ] diff --git a/comments/models.py b/comments/models.py index 9a8a71128b..cac77d8efa 100644 --- a/comments/models.py +++ b/comments/models.py @@ -18,7 +18,6 @@ from django.db.models.functions import Coalesce from django.db.models.lookups import Exact from django.utils import timezone -from sql_util.aggregates import SubqueryAggregate from posts.models import Post from projects.models import Project @@ -28,15 +27,6 @@ class CommentQuerySet(models.QuerySet): - def annotate_vote_score(self): - return self.annotate( - annotated_vote_score=Coalesce( - SubqueryAggregate("comment_votes__direction", aggregate=Sum), - 0, - output_field=IntegerField(), - ) - ) - def annotate_user_vote(self, user: User): """ Annotates queryset with the user's vote option @@ -180,12 +170,14 @@ def update_vote_score(self): ] self.vote_score = score self.save(update_fields=["vote_score"]) + return score def update_cmm_count(self): count = self.changedmymindentry_set.count() self.cmm_count = count self.save(update_fields=["cmm_count"]) + return count diff --git a/comments/serializers/common.py b/comments/serializers/common.py index fac39366d3..255cd9f5a7 100644 --- a/comments/serializers/common.py +++ b/comments/serializers/common.py @@ -5,6 +5,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from comments.constants import TimeWindow from comments.models import Comment, KeyFactor, CommentsOfTheWeekEntry from comments.utils import comments_extract_user_mentions_mapping from posts.models import Post @@ -27,6 +28,18 @@ class CommentFilterSerializer(serializers.Serializer): is_private = serializers.BooleanField(required=False, allow_null=True) include_deleted = serializers.BooleanField(required=False, allow_null=True) last_viewed_at = serializers.DateTimeField(required=False, allow_null=True) + time_window = serializers.ChoiceField( + choices=TimeWindow.choices, + required=False, + allow_null=True, + ) + search = serializers.CharField(required=False, allow_null=True, min_length=3) + exclude_bots = serializers.BooleanField(required=False, default=False) + post_status = serializers.ChoiceField( + choices=Post.CurationStatus.choices, + required=False, + allow_null=True, + ) def validate_post(self, value: int): try: @@ -34,6 +47,14 @@ def validate_post(self, value: int): except Post.DoesNotExist: raise ValidationError("Post Does not exist") + def validate(self, attrs): + sort = attrs.get("sort") + search = attrs.get("search") + if sort == "relevance" and not search: + raise ValidationError({"sort": "Relevance sort requires a search query."}) + + return attrs + class CommentSerializer(serializers.ModelSerializer): author = BaseUserSerializer() @@ -196,8 +217,6 @@ def serialize_comment_many( qs = qs.select_related( "included_forecast__question", "author", "on_post" ).prefetch_related("key_factors") - qs = qs.annotate_vote_score() - if current_user: qs = qs.annotate_user_vote(current_user) diff --git a/comments/services/common.py b/comments/services/common.py index f1bfe1c3a0..3a5a65fb0f 100644 --- a/comments/services/common.py +++ b/comments/services/common.py @@ -1,7 +1,7 @@ import datetime import difflib -from django.db import transaction +from django.db import IntegrityError, transaction from django.db.models import ( F, Sum, @@ -267,6 +267,40 @@ def compute_comment_score( return score +def vote_comment(comment: Comment, user: User, direction: int | None) -> int: + try: + with transaction.atomic(): + CommentVote.objects.filter(user=user, comment=comment).delete() + + if direction: + CommentVote.objects.create( + user=user, comment=comment, direction=direction + ) + except IntegrityError: + pass + + return comment.update_vote_score() + + +def toggle_cmm(comment: Comment, user: User, enabled: bool) -> bool | None: + """Returns True if created, False if deleted, None if no-op.""" + try: + with transaction.atomic(): + cmm = ChangedMyMindEntry.objects.filter(user=user, comment=comment) + + if not enabled and cmm.exists(): + cmm.delete() + comment.update_cmm_count() + return False + + if enabled and not cmm.exists(): + ChangedMyMindEntry.objects.create(user=user, comment=comment) + comment.update_cmm_count() + return True + except IntegrityError: + pass + + def set_comment_excluded_from_week_top(comment: Comment, excluded: bool = True): entry = comment.comments_of_the_week_entry if entry: diff --git a/comments/services/feed.py b/comments/services/feed.py index 654f5d032a..2bd03546c3 100644 --- a/comments/services/feed.py +++ b/comments/services/feed.py @@ -1,8 +1,18 @@ -from datetime import datetime +from datetime import datetime, timedelta -from django.db.models import Q, Case, When, Value, IntegerField, Exists, OuterRef +from django.contrib.postgres.search import SearchQuery, SearchRank +from django.db.models import F, Q, Case, When, Value, IntegerField, Exists, OuterRef +from django.utils import timezone +from comments.constants import TimeWindow from comments.models import Comment +from posts.models import Post + +TIME_WINDOW_DELTAS = { + TimeWindow.PAST_WEEK: timedelta(days=7), + TimeWindow.PAST_MONTH: timedelta(days=30), + TimeWindow.PAST_YEAR: timedelta(days=365), +} def get_comments_feed( @@ -15,16 +25,19 @@ def get_comments_feed( sort=None, is_private=None, focus_comment_id: int = None, - include_deleted=False, + include_deleted: bool | None = None, last_viewed_at: datetime = None, + time_window: str = None, + search: str = None, + exclude_bots: bool = False, + post_status: Post.CurationStatus | None = None, ): user = user if user and user.is_authenticated else None sort = sort or "-created_at" order_by_args = [] - # Require at least one filter - if not post and not author and not (is_private and user): - return qs.none() + if post_status: + qs = qs.filter(on_post__curation_status=post_status) if parent_isnull is not None: qs = qs.filter(parent=None) @@ -83,7 +96,10 @@ def get_comments_feed( else: qs = qs.filter(is_private=False) - if not include_deleted: + if exclude_bots: + qs = qs.filter(author__is_bot=False) + + if include_deleted is None: qs = qs.filter( Q(is_soft_deleted=False) | Exists( @@ -91,6 +107,23 @@ def get_comments_feed( ) ) + if include_deleted is False: + qs = qs.filter(is_soft_deleted=False) + + # Time window filter + if time_window and time_window in TIME_WINDOW_DELTAS: + cutoff = timezone.now() - TIME_WINDOW_DELTAS[time_window] + qs = qs.filter(created_at__gte=cutoff) + + # Full-text search using stored search vector + if search: + query = SearchQuery(search, search_type="websearch", config="english") + qs = qs.filter(text_original_search_vector=query) + if sort == "relevance": + qs = qs.annotate( + search_rank=SearchRank(F("text_original_search_vector"), query) + ) + # Filter comments located under Posts current user is allowed to see qs = qs.filter_by_user_permission(user=user) @@ -124,11 +157,10 @@ def get_comments_feed( order_by_args.insert(pinned_idx, "-is_focused_comment") if sort: - if "vote_score" in sort: - qs = qs.annotate_vote_score() - sort = sort.replace("vote_score", "annotated_vote_score") - - order_by_args.append(sort) + if sort == "relevance": + order_by_args.append("-search_rank") + else: + order_by_args.append(sort) if order_by_args: qs = qs.order_by(*order_by_args) diff --git a/comments/views/common.py b/comments/views/common.py index a3e5bc4ff0..1b24345e31 100644 --- a/comments/views/common.py +++ b/comments/views/common.py @@ -11,7 +11,6 @@ from comments.constants import CommentReportType from comments.models import ( - ChangedMyMindEntry, Comment, CommentVote, CommentsOfTheWeekEntry, @@ -31,6 +30,8 @@ unpin_comment, soft_delete_comment, update_comment, + vote_comment, + toggle_cmm, ) from comments.services.feed import get_comments_feed from comments.services.key_factors.common import create_key_factors @@ -38,7 +39,7 @@ from posts.services.common import get_post_permission_for_user from projects.permissions import ObjectPermission from users.models import User -from utils.paginator import LimitOffsetPagination +from utils.paginator import LimitOffsetPagination, CountlessLimitOffsetPagination class RootCommentsPagination(LimitOffsetPagination): @@ -99,7 +100,7 @@ def comments_list_api_view(request: Request): paginator = ( RootCommentsPagination() if use_root_comments_pagination - else LimitOffsetPagination() + else CountlessLimitOffsetPagination() ) paginated_comments = paginator.paginate_queryset(comments, request) @@ -199,57 +200,40 @@ def comment_edit_api_view(request: Request, pk: int): @api_view(["POST"]) -@transaction.atomic def comment_vote_api_view(request: Request, pk: int): comment = get_object_or_404(Comment, pk=pk) + user: User = request.user - permission = get_post_permission_for_user(comment.on_post, user=request.user) + permission = get_post_permission_for_user(comment.on_post, user=user) ObjectPermission.can_view(permission, raise_exception=True) - if comment.author_id == request.user.pk: + if comment.author_id == user.id: raise ValidationError("You can not vote your own comment.") direction = serializers.ChoiceField( required=False, allow_null=True, choices=CommentVote.VoteDirection.choices ).run_validation(request.data.get("vote")) - # Deleting existing vote - CommentVote.objects.filter(user=request.user, comment=comment).delete() - - if direction: - CommentVote.objects.create( - user=request.user, comment=comment, direction=direction - ) - - score = comment.update_vote_score() + score = vote_comment(comment, user, direction) return Response({"score": score}) @api_view(["POST"]) @permission_classes([IsAuthenticated]) -@transaction.atomic def comment_toggle_cmm_view(request, pk=int): enabled = request.data.get("enabled", False) comment = get_object_or_404(Comment, pk=pk) - user = request.user - cmm = ChangedMyMindEntry.objects.filter(user=user, comment=comment) - - if not enabled and cmm.exists(): - cmm.delete() - comment.update_cmm_count() - return Response(status=status.HTTP_200_OK) + result = toggle_cmm(comment, request.user, enabled) - if not cmm.exists(): - ChangedMyMindEntry.objects.create(user=user, comment=comment) - comment.update_cmm_count() - return Response(status=status.HTTP_200_OK) + if result is None: + return Response( + {"error": "Already set as changed my mind"}, + status=status.HTTP_400_BAD_REQUEST, + ) - return Response( - {"error": "Already set as changed my mind"}, - status=status.HTTP_400_BAD_REQUEST, - ) + return Response(status=status.HTTP_200_OK) @api_view(["POST"]) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index b890493692..854d2a39bb 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2071,5 +2071,22 @@ "openInAggregationExplorer": "Otevřít v Průzkumníku agregací", "switchBackToSlidersHint": "přepněte zpět na posuvníky pro plynulé přizpůsobení", "view": "Zobrazit", - "thousandsOfOpenQuestions": "20 000+ otevřených otázek" + "thousandsOfOpenQuestions": "20 000+ otevřených otázek", + "commentsFeed": "Comments", + "commentsFeedTitle": "Comments Feed", + "sortRecent": "Recent", + "sortMostUpvoted": "Most Upvoted", + "sortMostMindsChanged": "Most Minds Changed", + "sortRelevance": "Relevance", + "timeWindow": "Časové období", + "bots": "Boti", + "timeWindowAllTime": "All Time", + "timeWindowPastWeek": "Past Week", + "timeWindowPastMonth": "Past Month", + "timeWindowPastYear": "Past Year", + "excludeBots": "Vyloučit boty", + "includeBots": "Zahrnout boty", + "searchComments": "Search comments...", + "loadMoreComments": "Load More", + "noCommentsFound": "No comments found" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 8c167b41a2..293cfa78ba 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2064,5 +2064,22 @@ "publicForecasters": "public forecasters", "yourInternalExperts": "your internal experts", "view": "View", - "feedTileSummaryPlaceholder": "Optional: Enter a custom summary text to display on feed tiles (if not provided, a summary will be auto-generated from the notebook content)" + "feedTileSummaryPlaceholder": "Optional: Enter a custom summary text to display on feed tiles (if not provided, a summary will be auto-generated from the notebook content)", + "commentsFeed": "Comments", + "commentsFeedTitle": "Comments Feed", + "sortRecent": "Recent", + "sortMostUpvoted": "Most Upvoted", + "sortMostMindsChanged": "Most Minds Changed", + "sortRelevance": "Relevance", + "timeWindow": "Time Window", + "bots": "Bots", + "timeWindowAllTime": "All Time", + "timeWindowPastWeek": "Past Week", + "timeWindowPastMonth": "Past Month", + "timeWindowPastYear": "Past Year", + "excludeBots": "Exclude Bots", + "includeBots": "Include Bots", + "searchComments": "Search comments...", + "loadMoreComments": "Load More", + "noCommentsFound": "No comments found" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 781d7c3a65..012bb6990e 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2071,5 +2071,22 @@ "openInAggregationExplorer": "Abrir en el Explorador de Agregación", "switchBackToSlidersHint": "vuelve a los deslizadores para un ajuste suave", "view": "Ver", - "thousandsOfOpenQuestions": "20,000+ preguntas abiertas" + "thousandsOfOpenQuestions": "20,000+ preguntas abiertas", + "commentsFeed": "Comments", + "commentsFeedTitle": "Comments Feed", + "sortRecent": "Recent", + "sortMostUpvoted": "Most Upvoted", + "sortMostMindsChanged": "Most Minds Changed", + "sortRelevance": "Relevance", + "timeWindow": "Período de tiempo", + "bots": "Bots", + "timeWindowAllTime": "All Time", + "timeWindowPastWeek": "Past Week", + "timeWindowPastMonth": "Past Month", + "timeWindowPastYear": "Past Year", + "excludeBots": "Excluir Bots", + "includeBots": "Incluir Bots", + "searchComments": "Search comments...", + "loadMoreComments": "Load More", + "noCommentsFound": "No comments found" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 7d46728750..eb43aff821 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2069,5 +2069,22 @@ "openInAggregationExplorer": "Abrir no Explorador de Agregações", "switchBackToSlidersHint": "volte para os controles deslizantes para um ajuste suave", "view": "Visualizar", - "thousandsOfOpenQuestions": "20.000+ perguntas abertas" + "thousandsOfOpenQuestions": "20.000+ perguntas abertas", + "commentsFeed": "Comments", + "commentsFeedTitle": "Comments Feed", + "sortRecent": "Recent", + "sortMostUpvoted": "Most Upvoted", + "sortMostMindsChanged": "Most Minds Changed", + "sortRelevance": "Relevance", + "timeWindow": "Período de tempo", + "bots": "Bots", + "timeWindowAllTime": "All Time", + "timeWindowPastWeek": "Past Week", + "timeWindowPastMonth": "Past Month", + "timeWindowPastYear": "Past Year", + "excludeBots": "Excluir Bots", + "includeBots": "Incluir Bots", + "searchComments": "Search comments...", + "loadMoreComments": "Load More", + "noCommentsFound": "No comments found" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 2132a14e32..1a57cd5c74 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2068,5 +2068,22 @@ "openInAggregationExplorer": "在聚合探索器中開啟", "switchBackToSlidersHint": "切換回滑桿以平滑調整", "view": "檢視", - "thousandsOfOpenQuestions": "20,000+ 開放問題" + "thousandsOfOpenQuestions": "20,000+ 開放問題", + "commentsFeed": "Comments", + "commentsFeedTitle": "Comments Feed", + "sortRecent": "Recent", + "sortMostUpvoted": "Most Upvoted", + "sortMostMindsChanged": "Most Minds Changed", + "sortRelevance": "Relevance", + "timeWindow": "時間範圍", + "bots": "機器人", + "timeWindowAllTime": "All Time", + "timeWindowPastWeek": "Past Week", + "timeWindowPastMonth": "Past Month", + "timeWindowPastYear": "Past Year", + "excludeBots": "排除機器人", + "includeBots": "包含機器人", + "searchComments": "Search comments...", + "loadMoreComments": "Load More", + "noCommentsFound": "No comments found" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index cd2c8290fe..38ed84a6ee 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2073,5 +2073,22 @@ "openInAggregationExplorer": "在聚合探索器中打开", "switchBackToSlidersHint": "切回滑块以进行更精细的调整", "view": "查看", - "thousandsOfOpenQuestions": "20,000+ 开放问题" + "thousandsOfOpenQuestions": "20,000+ 开放问题", + "commentsFeed": "Comments", + "commentsFeedTitle": "Comments Feed", + "sortRecent": "Recent", + "sortMostUpvoted": "Most Upvoted", + "sortMostMindsChanged": "Most Minds Changed", + "sortRelevance": "Relevance", + "timeWindow": "时间范围", + "bots": "机器人", + "timeWindowAllTime": "All Time", + "timeWindowPastWeek": "Past Week", + "timeWindowPastMonth": "Past Month", + "timeWindowPastYear": "Past Year", + "excludeBots": "排除机器人", + "includeBots": "包含机器人", + "searchComments": "Search comments...", + "loadMoreComments": "Load More", + "noCommentsFound": "No comments found" } diff --git a/front_end/src/app/(main)/components/headers/hooks/useNavbarLinks.tsx b/front_end/src/app/(main)/components/headers/hooks/useNavbarLinks.tsx index 364dc61ce7..55780a249d 100644 --- a/front_end/src/app/(main)/components/headers/hooks/useNavbarLinks.tsx +++ b/front_end/src/app/(main)/components/headers/hooks/useNavbarLinks.tsx @@ -63,6 +63,10 @@ const useNavbarLinks = ({ label: t("communities"), href: "/questions/?communities=true", }, + commentsFeed: { + label: t("commentsFeedTitle"), + href: "/questions/?comments_feed=true", + }, about: { label: t("aboutMetaculus"), href: "/about/", @@ -173,7 +177,7 @@ const useNavbarLinks = ({ const menuLinks = useMemo(() => { // common links that are always shown const links: NavbarLinkDefinition[] = [ - ...(PUBLIC_MINIMAL_UI ? [] : [LINKS.communities]), + ...(PUBLIC_MINIMAL_UI ? [] : [LINKS.communities, LINKS.commentsFeed]), LINKS.leaderboards, LINKS.trackRecord, LINKS.aggregationExplorer, @@ -201,6 +205,7 @@ const useNavbarLinks = ({ LINKS.aggregationExplorer, LINKS.aiBenchmark, LINKS.communities, + LINKS.commentsFeed, LINKS.createQuestion, LINKS.faq, LINKS.journal, diff --git a/front_end/src/app/(main)/questions/hooks/use_feed.tsx b/front_end/src/app/(main)/questions/hooks/use_feed.tsx index 6a978567ee..7e6e5e454f 100644 --- a/front_end/src/app/(main)/questions/hooks/use_feed.tsx +++ b/front_end/src/app/(main)/questions/hooks/use_feed.tsx @@ -11,6 +11,7 @@ import { POST_TOPIC_FILTER, POST_USERNAMES_FILTER, POST_WEEKLY_TOP_COMMENTS_FILTER, + POST_COMMENTS_FEED_FILTER, } from "@/constants/posts_feed"; import { useAuth } from "@/contexts/auth_context"; import useSearchParams from "@/hooks/use_search_params"; @@ -28,6 +29,7 @@ const useFeed = () => { const orderBy = params.get(POST_ORDER_BY_FILTER); const communities = params.get(POST_COMMUNITIES_FILTER); const weeklyTopComments = params.get(POST_WEEKLY_TOP_COMMENTS_FILTER); + const commentsFeed = params.get(POST_COMMENTS_FEED_FILTER); const currentFeed = useMemo(() => { if (selectedTopic) return null; @@ -47,6 +49,7 @@ const useFeed = () => { if (weeklyTopComments) { return FeedType.WEEKLY_TOP_COMMENTS; } + if (commentsFeed) return FeedType.COMMENTS_FEED; return FeedType.HOME; }, [ selectedTopic, @@ -56,6 +59,7 @@ const useFeed = () => { communities, user, weeklyTopComments, + commentsFeed, ]); const clearInReview = useCallback(() => { @@ -84,6 +88,8 @@ const useFeed = () => { return { [POST_COMMUNITIES_FILTER]: "true" }; case FeedType.WEEKLY_TOP_COMMENTS: return { [POST_WEEKLY_TOP_COMMENTS_FILTER]: "true" }; + case FeedType.COMMENTS_FEED: + return { [POST_COMMENTS_FEED_FILTER]: "true" }; case FeedType.FOLLOWING: return { [POST_FOLLOWING_FILTER]: "true" }; case FeedType.HOME: diff --git a/front_end/src/app/(main)/questions/page.tsx b/front_end/src/app/(main)/questions/page.tsx index c45f708c12..b156f37b10 100644 --- a/front_end/src/app/(main)/questions/page.tsx +++ b/front_end/src/app/(main)/questions/page.tsx @@ -2,12 +2,14 @@ import { isNil } from "lodash"; import { Suspense } from "react"; import FeedSidebar from "@/app/(main)/questions/components/sidebar"; +import CommentFeedContent from "@/components/comment_feed/comment_feed_content"; import AwaitedCommunitiesFeed from "@/components/communities_feed"; import OnboardingCheck from "@/components/onboarding/onboarding_check"; import AwaitedPostsFeed from "@/components/posts_feed"; import LoadingIndicator from "@/components/ui/loading_indicator"; import AwaitedWeeklyTopCommentsFeed from "@/components/weekly_top_comments_feed"; import { + POST_COMMENTS_FEED_FILTER, POST_COMMUNITIES_FILTER, POST_PAGE_FILTER, POST_WEEKLY_TOP_COMMENTS_FILTER, @@ -35,6 +37,7 @@ export default async function Questions(props: { const searchParams = await props.searchParams; const isCommunityFeed = searchParams[POST_COMMUNITIES_FILTER]; const isWeeklyTopCommentsFeed = searchParams[POST_WEEKLY_TOP_COMMENTS_FILTER]; + const isCommentsFeed = searchParams[POST_COMMENTS_FEED_FILTER]; const filters = generateFiltersFromSearchParams(searchParams, { // Default Feed ordering should be hotness defaultOrderBy: QuestionOrder.HotDesc, @@ -51,7 +54,9 @@ export default async function Questions(props: {
- {isCommunityFeed ? ( + {isCommentsFeed ? ( + + ) : isCommunityFeed ? ( void; + disableVoting?: boolean; + collapsedHeight?: number; }; -// Fixed height for collapsed state - adjust this value as needed -const COLLAPSED_HEIGHT = 174; // pixels +const DEFAULT_collapsedHeight = 174; const BottomStatContainer: FC< PropsWithChildren<{ className?: string; title?: string }> @@ -76,12 +78,14 @@ const ExpandableCommentContent = ({ needsExpand, contentRef, onViewComment, + collapsedHeight, }: { comment: BECommentType; isExpanded: boolean; needsExpand: boolean; contentRef: React.RefObject; onViewComment?: () => void; + collapsedHeight: number; }) => { const locale = useLocale(); const t = useTranslations(); @@ -91,7 +95,8 @@ const ExpandableCommentContent = ({ ref={contentRef} className="relative flex flex-col gap-[10px] overflow-hidden p-3 md:p-4" style={{ - height: !isExpanded && needsExpand ? `${COLLAPSED_HEIGHT}px` : "auto", + height: !isExpanded && needsExpand ? `${collapsedHeight}px` : "auto", + //minHeight: `${collapsedHeight}px`, }} > {/* Author info */} @@ -166,6 +171,8 @@ const CommentCard: FC = ({ keyFactorVotesScore, expandOverride = "auto", onViewComment, + disableVoting = false, + collapsedHeight = DEFAULT_collapsedHeight, }) => { const t = useTranslations(); const contentRef = useRef(null); @@ -196,7 +203,7 @@ const CommentCard: FC = ({ contentRef.current.style.overflow = "visible"; const fullHeight = contentRef.current.scrollHeight; - const shouldExpand = fullHeight > COLLAPSED_HEIGHT; + const shouldExpand = fullHeight > collapsedHeight; setNeedsExpand(shouldExpand); if (controlledExpanded === undefined) { @@ -227,7 +234,13 @@ const CommentCard: FC = ({ observer.observe(node, { childList: true, subtree: true }); return () => observer.disconnect(); - }, [comment.text, comment.key_factors, comment.id, controlledExpanded]); + }, [ + comment.text, + comment.key_factors, + comment.id, + controlledExpanded, + collapsedHeight, + ]); return (
= ({ > {/* Question context for mobile */} {comment.on_post_data && ( -
+
{t("question")}
@@ -259,25 +272,37 @@ const CommentCard: FC = ({ needsExpand={needsExpand} contentRef={contentRef} onViewComment={onViewComment} + collapsedHeight={collapsedHeight} />
{/* Comment votes, change my mind and key factors */}
- - - {votesScore} - + + {votesScore} + + + ) : ( + - + )} {changedMyMindCount > 0 && ( diff --git a/front_end/src/components/comment_feed/comment_feed_card.tsx b/front_end/src/components/comment_feed/comment_feed_card.tsx new file mode 100644 index 0000000000..026ddd309c --- /dev/null +++ b/front_end/src/components/comment_feed/comment_feed_card.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { FC } from "react"; + +import CommentCard from "@/components/comment_feed/comment_card"; +import CommentPostPreview from "@/components/comment_feed/comment_post_preview"; +import { CommentType } from "@/types/comment"; +import { PostWithForecasts } from "@/types/post"; +import cn from "@/utils/core/cn"; + +type Props = { + comment: CommentType; + post?: PostWithForecasts; +}; + +const CommentFeedCard: FC = ({ comment, post }) => { + return ( +
+
+ {/* Left column: Post preview (desktop only) */} +
+ +
+ + {/* Right column: Comment */} +
+ + window.open( + `/questions/${comment.on_post}/#comment-${comment.id}`, + "_blank" + ) + } + /> +
+
+
+ ); +}; + +export default CommentFeedCard; diff --git a/front_end/src/components/comment_feed/comment_feed_content.tsx b/front_end/src/components/comment_feed/comment_feed_content.tsx new file mode 100644 index 0000000000..c530abc0d1 --- /dev/null +++ b/front_end/src/components/comment_feed/comment_feed_content.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import PopoverFilter from "@/components/popover_filter"; +import { + FilterOptionType, + FilterSection, +} from "@/components/popover_filter/types"; +import SearchInput from "@/components/search_input"; +import Button from "@/components/ui/button"; +import Listbox from "@/components/ui/listbox"; +import LoadingIndicator from "@/components/ui/loading_indicator"; +import { useDebouncedCallback } from "@/hooks/use_debounce"; +import ClientCommentsApi from "@/services/api/comments/comments.client"; +import { getCommentsParams } from "@/services/api/comments/comments.shared"; +import ClientPostsApi from "@/services/api/posts/posts.client"; +import { CommentType } from "@/types/comment"; +import { PostStatus, PostWithForecasts } from "@/types/post"; + +import CommentFeedCard from "./comment_feed_card"; + +const COMMENTS_PER_PAGE = 10; + +type SortOption = "-created_at" | "-vote_score" | "-cmm_count" | "relevance"; + +type TimeWindow = "all_time" | "past_week" | "past_month" | "past_year"; + +const CommentFeedContent: FC = () => { + const t = useTranslations(); + const [comments, setComments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [hasMore, setHasMore] = useState(true); + const [sort, setSort] = useState("-created_at"); + const [timeWindow, setTimeWindow] = useState("all_time"); + const [excludeBots, setExcludeBots] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + const updateDebouncedSearch = useDebouncedCallback((value: string) => { + setDebouncedSearch(value.length >= 3 ? value : ""); + }, 500); + + const handleSearchChange = useCallback( + (value: string) => { + setSearchQuery(value); + updateDebouncedSearch(value); + }, + [updateDebouncedSearch] + ); + + // Use ref to avoid stale closure in fetchComments + const commentsRef = useRef(comments); + commentsRef.current = comments; + + const postIds = useMemo( + () => + [ + ...new Set( + comments + .map((c) => c.on_post) + .filter((id): id is number => id != null) + ), + ].sort(), + [comments] + ); + + const { data: postsMap = {} } = useQuery({ + queryKey: ["comments-feed-posts", postIds], + queryFn: async () => { + const response = await ClientPostsApi.getPostsWithCP( + { ids: postIds }, + { include_cp_history: false } + ); + const map: Record = {}; + for (const post of response.results) { + map[post.id] = post; + } + return map; + }, + enabled: postIds.length > 0, + placeholderData: keepPreviousData, + }); + + const fetchComments = useCallback( + async (offset: number, reset: boolean = false) => { + setIsLoading(true); + try { + const effectiveSort = + sort === "relevance" && !debouncedSearch ? "-created_at" : sort; + const params: getCommentsParams = { + limit: COMMENTS_PER_PAGE, + offset, + sort: effectiveSort, + parent_isnull: true, + is_private: false, + include_deleted: false, + post_status: PostStatus.APPROVED, + ...(timeWindow !== "all_time" && { time_window: timeWindow }), + ...(debouncedSearch && { search: debouncedSearch }), + exclude_bots: excludeBots, + }; + const response = await ClientCommentsApi.getComments(params); + const prev = reset ? [] : commentsRef.current; + const newComments = [...prev, ...response.results]; + setComments(newComments); + setHasMore(response.results.length >= COMMENTS_PER_PAGE); + } finally { + setIsLoading(false); + } + }, + [sort, timeWindow, debouncedSearch, excludeBots] + ); + + // Reset and fetch when filters change + useEffect(() => { + setComments([]); + setHasMore(true); + void fetchComments(0, true); + }, [fetchComments]); + + // Auto-switch to relevance sort when searching + useEffect(() => { + if (debouncedSearch && sort !== "relevance") { + setSort("relevance"); + } else if (!debouncedSearch && sort === "relevance") { + setSort("-created_at"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch]); + + const handleLoadMore = () => { + void fetchComments(comments.length); + }; + + const sortOptions: { value: SortOption; label: string }[] = [ + { value: "-created_at", label: t("sortRecent") }, + { value: "-vote_score", label: t("sortMostUpvoted") }, + { value: "-cmm_count", label: t("sortMostMindsChanged") }, + ...(debouncedSearch + ? [ + { + value: "relevance" as SortOption, + label: t("sortRelevance"), + }, + ] + : []), + ]; + + const popoverFilters: FilterSection[] = [ + { + id: "time_window", + title: t("timeWindow"), + type: FilterOptionType.ToggleChip, + options: [ + { + label: t("timeWindowAllTime"), + value: "all_time", + active: timeWindow === "all_time", + }, + { + label: t("timeWindowPastWeek"), + value: "past_week", + active: timeWindow === "past_week", + }, + { + label: t("timeWindowPastMonth"), + value: "past_month", + active: timeWindow === "past_month", + }, + { + label: t("timeWindowPastYear"), + value: "past_year", + active: timeWindow === "past_year", + }, + ], + }, + { + id: "exclude_bots", + title: t("bots"), + type: FilterOptionType.ToggleChip, + options: [ + { label: t("excludeBots"), value: "true", active: excludeBots }, + { label: t("includeBots"), value: "false", active: !excludeBots }, + ], + }, + ]; + + const handlePopoverFilterChange = ( + filterId: string, + optionValue: string | string[] | null + ) => { + if (filterId === "time_window") { + setTimeWindow((optionValue as TimeWindow) ?? "all_time"); + } else if (filterId === "exclude_bots") { + setExcludeBots(optionValue === "true"); + } + }; + + const handlePopoverFilterClear = () => { + setTimeWindow("all_time"); + setExcludeBots(true); + }; + + return ( +
+

+ {t("commentsFeedTitle")} +

+ {/* Controls bar */} +
+ handleSearchChange(e.target.value)} + onErase={() => handleSearchChange("")} + placeholder={t("searchComments")} + iconPosition="left" + className="w-full sm:w-auto sm:min-w-[240px] sm:flex-1" + /> +
+ + +
+
+ + {/* Comments list */} + {comments.map((comment) => ( + + ))} + + {/* Loading / empty / load more states */} + {isLoading && comments.length === 0 && ( + + )} + {!isLoading && comments.length === 0 && ( +

+ {t("noCommentsFound")} +

+ )} + {hasMore && comments.length > 0 && ( +
+ {isLoading ? ( + + ) : ( + + )} +
+ )} +
+ ); +}; + +export default CommentFeedContent; diff --git a/front_end/src/components/weekly_top_comments_feed/components/highlighted_comment_card.tsx b/front_end/src/components/weekly_top_comments_feed/components/highlighted_comment_card.tsx index e400597753..3faa21483d 100644 --- a/front_end/src/components/weekly_top_comments_feed/components/highlighted_comment_card.tsx +++ b/front_end/src/components/weekly_top_comments_feed/components/highlighted_comment_card.tsx @@ -167,7 +167,7 @@ const HighlightedCommentCard: FC = ({ )} {/* Placement header */} -
+
{placement && placement <= 6 && ( @@ -233,6 +233,7 @@ const HighlightedCommentCard: FC = ({ votesScore={votes_score} className="mt-0 border-none dark:border-none md:mt-0" expandOverride={expandOverride} + disableVoting />
diff --git a/front_end/src/constants/posts_feed.ts b/front_end/src/constants/posts_feed.ts index 6dfe9b5ede..30fe4457d2 100644 --- a/front_end/src/constants/posts_feed.ts +++ b/front_end/src/constants/posts_feed.ts @@ -3,6 +3,7 @@ export enum FeedType { MY_PREDICTIONS = "my_predictions", MY_QUESTIONS_AND_POSTS = "my_questions_and_posts", WEEKLY_TOP_COMMENTS = "weekly_top_comments", + COMMENTS_FEED = "comments_feed", FOLLOWING = "following", IN_REVIEW = "in_review", COMMUNITIES = "communities", @@ -29,6 +30,7 @@ export const POST_ORDER_BY_FILTER = "order_by"; export const POST_NEWS_TYPE_FILTER = "news_type"; export const POST_COMMUNITIES_FILTER = "communities"; export const POST_WEEKLY_TOP_COMMENTS_FILTER = "weekly_top_comments"; +export const POST_COMMENTS_FEED_FILTER = "comments_feed"; export const POST_PROJECT_FILTER = "default_project_id"; export const POSTS_PER_PAGE = 10; diff --git a/front_end/src/services/api/comments/comments.shared.ts b/front_end/src/services/api/comments/comments.shared.ts index f3eb3c4ccd..be7a436dea 100644 --- a/front_end/src/services/api/comments/comments.shared.ts +++ b/front_end/src/services/api/comments/comments.shared.ts @@ -18,6 +18,11 @@ export type getCommentsParams = { focus_comment_id?: string; is_private?: boolean; last_viewed_at?: string; + time_window?: "all_time" | "past_week" | "past_month" | "past_year"; + search?: string; + exclude_bots?: boolean; + include_deleted?: boolean; + post_status?: string; }; export type KeyFactorWritePayload = KeyFactorDraft; diff --git a/tests/unit/test_comments/test_views.py b/tests/unit/test_comments/test_views.py index 6886e8566f..e06f6b408f 100644 --- a/tests/unit/test_comments/test_views.py +++ b/tests/unit/test_comments/test_views.py @@ -191,8 +191,10 @@ def test_get_comments_feed_permissions(user1, user2): factory_comment(author=user2, on_post=post, is_soft_deleted=True) - # Without filter, should return empty - assert set(get_comments_feed(Comment.objects.all())) == set() + # Without post/author filter, returns non-private non-deleted comments + assert {c.pk for c in get_comments_feed(Comment.objects.all())} == { + c3.pk, + } # Filter by post assert {c.pk for c in get_comments_feed(Comment.objects.all(), post=post)} == {