Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions comments/serializers/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class CommentFilterSerializer(serializers.Serializer):
author_is_staff = serializers.BooleanField(required=False, allow_null=True)
sort = serializers.CharField(required=False, allow_null=True)
focus_comment_id = serializers.IntegerField(required=False, allow_null=True)
focus_thread_only = serializers.BooleanField(required=False, default=False)
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)
Expand Down
35 changes: 21 additions & 14 deletions comments/services/feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def get_comments_feed(
sort=None,
is_private=None,
focus_comment_id: int = None,
focus_thread_only: bool = False,
include_deleted: bool | None = None,
last_viewed_at: datetime = None,
time_window: str = None,
Expand Down Expand Up @@ -147,21 +148,27 @@ def get_comments_feed(
# Fetch all children
fc_q |= Q(root_id=focus_comment_id)

qs = qs.annotate(
is_focused_comment=Case(
When(fc_q, then=Value(1)),
default=Value(0),
output_field=IntegerField(),
if focus_thread_only:
# Restrict the feed to only the focused thread so the caller
# can render it as a standalone "linked comment" section
# without disturbing the natural pagination/sort.
qs = qs.filter(fc_q)
else:
qs = qs.annotate(
is_focused_comment=Case(
When(fc_q, then=Value(1)),
default=Value(0),
output_field=IntegerField(),
)
)
)
# Insert after pinned but before unread prioritization
# so focused comment always appears on the first page
pinned_idx = (
order_by_args.index("-is_pinned_thread") + 1
if "-is_pinned_thread" in order_by_args
else 0
)
order_by_args.insert(pinned_idx, "-is_focused_comment")
# Insert after pinned but before unread prioritization
# so focused comment always appears on the first page
pinned_idx = (
order_by_args.index("-is_pinned_thread") + 1
if "-is_pinned_thread" in order_by_args
else 0
)
order_by_args.insert(pinned_idx, "-is_focused_comment")

if sort:
if sort == "relevance":
Expand Down
6 changes: 6 additions & 0 deletions docs/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2468,6 +2468,12 @@ paths:
schema:
type: integer
description: The ID of a comment to place at the top of the results.
- name: focus_thread_only
in: query
required: false
schema:
type: boolean
description: When used alongside `focus_comment_id`, restricts the response to only the focused comment thread (the comment plus its root/siblings if it has a parent).
responses:
'200':
description: List of comments
Expand Down
1 change: 1 addition & 0 deletions front_end/messages/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@
"parentResolutionCriteria": "Kritéria vyřešení nadřazené otázky",
"childResolutionCriteria": "Kritéria vyřešení podřazené otázky",
"loadMoreComments": "Načíst více komentářů",
"linkedComment": "Odkazovaný komentář",
"followButton": "Sledovat",
"followingButton": "Sledujete",
"unfollowButton": "Přestat sledovat",
Expand Down
1 change: 1 addition & 0 deletions front_end/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,7 @@
"parentResolutionCriteria": "Parent Resolution Criteria",
"childResolutionCriteria": "Child Resolution Criteria",
"loadMoreComments": "Load more comments",
"linkedComment": "Linked comment",
"followers": "Followers",
"followed": "Followed",
"followButton": "Follow",
Expand Down
1 change: 1 addition & 0 deletions front_end/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@
"parentResolutionCriteria": "Criterios de Resolución de la Pregunta Principal",
"childResolutionCriteria": "Criterios de Resolución de la Pregunta Secundaria",
"loadMoreComments": "Cargar más comentarios",
"linkedComment": "Comentario enlazado",
"followButton": "Seguir",
"followingButton": "Siguiendo",
"unfollowButton": "Dejar de seguir",
Expand Down
1 change: 1 addition & 0 deletions front_end/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@
"parentResolutionCriteria": "Critérios de Resolução da Pergunta Pai",
"childResolutionCriteria": "Critérios de Resolução da Pergunta Filha",
"loadMoreComments": "Carregar mais comentários",
"linkedComment": "Comentário vinculado",
"followers": "Seguidores",
"followButton": "Seguir",
"followingButton": "Seguindo",
Expand Down
1 change: 1 addition & 0 deletions front_end/messages/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@
"parentResolutionCriteria": "父問題解析標準",
"childResolutionCriteria": "子問題解析標準",
"loadMoreComments": "載入更多評論",
"linkedComment": "連結的評論",
"followers": "追隨者",
"followed": "已追隨",
"followButton": "追隨",
Expand Down
1 change: 1 addition & 0 deletions front_end/messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@
"parentResolutionCriteria": "家長解決標準",
"childResolutionCriteria": "兒童解決標準",
"loadMoreComments": "加載更多評論",
"linkedComment": "鏈接的評論",
"followButton": "關注",
"followingButton": "正在關注",
"unfollowButton": "取消關注",
Expand Down
25 changes: 25 additions & 0 deletions front_end/src/app/(main)/components/comments_feed_provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type CommentsFeedContextType = {
ensureCommentLoaded: (id: number) => Promise<boolean>;
refreshComment: (id: number) => Promise<void>;
updateComment: (id: number, changes: Partial<CommentType>) => void;
fetchFocusedCommentThread: (id: number) => Promise<CommentType | null>;
};

const COMMENTS_PER_PAGE = 10;
Expand Down Expand Up @@ -361,6 +362,29 @@ const CommentsFeedProvider: FC<
return tempId;
};

const fetchFocusedCommentThread = useCallback(
async (id: number): Promise<CommentType | null> => {
try {
const response = await ClientCommentsApi.getComments({
post: postData?.id,
author: profileId,
focus_comment_id: String(id),
focus_thread_only: true,
use_root_comments_pagination: rootCommentStructure,
});
const parsed = parseCommentsArray(
response.results as unknown as BECommentType[],
rootCommentStructure
);
return parsed[0] ?? null;
} catch (e) {
logError(e);
return null;
}
},
[postData?.id, profileId, rootCommentStructure]
);

return (
<CommentsFeedContext.Provider
value={{
Expand All @@ -386,6 +410,7 @@ const CommentsFeedProvider: FC<
ensureCommentLoaded,
refreshComment,
updateComment,
fetchFocusedCommentThread,
}}
>
{children}
Expand Down
128 changes: 98 additions & 30 deletions front_end/src/components/comment_feed/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { useTranslations } from "next-intl";
import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";

import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider";
import {
findById,
useCommentsFeed,
} from "@/app/(main)/components/comments_feed_provider";
import {
commentTogglePin,
markPostAsRead,
Expand All @@ -28,7 +31,6 @@ import ClientCommentsApi from "@/services/api/comments/comments.client";
import { getCommentsParams } from "@/services/api/comments/comments.shared";
import { CommentType } from "@/types/comment";
import { PostStatus, PostWithForecasts } from "@/types/post";
import { getCommentIdToFocusOn } from "@/utils/comments";
import cn from "@/utils/core/cn";
import { isForecastActive } from "@/utils/forecasts/helpers";
import { getQuestionStatus } from "@/utils/questions/helpers";
Expand Down Expand Up @@ -126,7 +128,6 @@ const CommentFeed: FC<Props> = ({
const [feedFilters, setFeedFilters] = useState<getCommentsParams>(() => ({
is_private: false,
sort: "-created_at",
focus_comment_id: getCommentIdToFocusOn() || undefined,
}));

const {
Expand All @@ -139,7 +140,38 @@ const CommentFeed: FC<Props> = ({
totalCount,
fetchComments,
fetchTotalCount,
fetchFocusedCommentThread,
} = useCommentsFeed();

const [linkedFocusedThread, setLinkedFocusedThread] = useState<{
focusedId: number;
thread: CommentType;
} | null>(null);
const [isLinkedLoading, setIsLinkedLoading] = useState(false);
const linkedRequestRef = useRef(0);

const clearLinkedFocusedThread = useCallback(() => {
linkedRequestRef.current += 1; // invalidate any in-flight fetch
setIsLinkedLoading(false);
setLinkedFocusedThread(null);
}, []);

const loadLinkedFocusedThread = useCallback(
async (id: number) => {
const requestId = (linkedRequestRef.current += 1);
setIsLinkedLoading(true);
try {
const thread = await fetchFocusedCommentThread(id);
if (requestId !== linkedRequestRef.current) return; // superseded — discard
setLinkedFocusedThread(thread ? { focusedId: id, thread } : null);
} finally {
if (requestId === linkedRequestRef.current) {
setIsLinkedLoading(false);
}
}
},
[fetchFocusedCommentThread]
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const postId = postData?.id;
const includeUserForecast = shouldIncludeForecast(postData);

Expand Down Expand Up @@ -175,8 +207,6 @@ const CommentFeed: FC<Props> = ({
setOffset(0);
setFeedFilters({
...feedFilters,
// We want to reset focus comment in case of filters change
focus_comment_id: undefined,
[key]: value,
});
},
Expand All @@ -187,34 +217,48 @@ const CommentFeed: FC<Props> = ({

// Track #comment-id and #comments hash changes to load & focus on target comment
useEffect(() => {
if (hash) {
const focus_comment_id = getCommentIdToFocusOn();
if (
focus_comment_id &&
// Ensure we don't make duplicated calls
focus_comment_id != feedFilters.focus_comment_id
) {
setOffset(0);
setFeedFilters({
...feedFilters,
focus_comment_id,
});
} else if (hash === "comments" && isFirstRender.current && !isLoading) {
isFirstRender.current = false;
// same workaround as in comment.tsx
const timeoutId = setTimeout(() => {
if (commentsRef.current) {
scrollTo(commentsRef.current.getBoundingClientRect().top);
}
}, 1000);

return () => {
clearTimeout(timeoutId);
};
if (isLoading) return;
if (!hash) {
clearLinkedFocusedThread();
return;
}

const match = hash.match(/comment-(\d+)/);
if (match?.[1]) {
const numericId = Number(match[1]);
if (Number.isNaN(numericId)) {
clearLinkedFocusedThread();
return;
}

// Comment already in the natural feed — comment.tsx handles scrolling.
if (findById(comments, numericId)) {
clearLinkedFocusedThread();
return;
}

if (linkedFocusedThread?.focusedId === numericId) {
return;
}

void loadLinkedFocusedThread(numericId);
} else if (hash === "comments" && isFirstRender.current) {
isFirstRender.current = false;
// same workaround as in comment.tsx
const timeoutId = setTimeout(() => {
if (commentsRef.current) {
scrollTo(commentsRef.current.getBoundingClientRect().top);
}
}, 1000);

return () => {
clearTimeout(timeoutId);
};
} else {
clearLinkedFocusedThread();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hash, isLoading]);
}, [hash, isLoading, comments]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Handling filters change — always fetch from offset 0 and replace
useEffect(() => {
Expand Down Expand Up @@ -498,6 +542,30 @@ const CommentFeed: FC<Props> = ({
</Button>
</div>
)}
{(linkedFocusedThread || isLinkedLoading) && (
<div className="mt-6 flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm font-medium text-gray-600 dark:text-gray-600-dark">
<hr className="flex-1 border-gray-300 dark:border-gray-300-dark" />
<span>{t("linkedComment")}</span>
<hr className="flex-1 border-gray-300 dark:border-gray-300-dark" />
</div>
{isLinkedLoading && (
<LoadingIndicator className="mx-auto my-4 w-24" />
)}
{!isLinkedLoading && linkedFocusedThread && (
<CommentWrapper
key={`linked-${linkedFocusedThread.thread.id}`}
comment={linkedFocusedThread.thread}
handleCommentPin={handleCommentPin}
profileId={profileId}
last_viewed_at={lastViewedAt}
postData={postData}
onReplyCreated={setLastViewedAt}
shouldSuggestKeyFactors={shouldSuggestKeyFactors}
/>
)}
</div>
)}
</section>
</DefaultUserMentionsContextProvider>
);
Expand Down
1 change: 1 addition & 0 deletions front_end/src/services/api/comments/comments.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type getCommentsParams = {
sort?: string;
use_root_comments_pagination?: boolean;
focus_comment_id?: string;
focus_thread_only?: boolean;
is_private?: boolean;
last_viewed_at?: string;
time_window?: "all_time" | "past_week" | "past_month" | "past_year";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const MockCommentsFeedProvider: React.FC<PropsWithChildren> = ({
ensureCommentLoaded: async () => false,
refreshComment: async () => {},
updateComment: () => {},
fetchFocusedCommentThread: async () => null,
}}
>
{children}
Expand Down
16 changes: 0 additions & 16 deletions front_end/src/utils/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,3 @@ export function hasPredictorsMention(text: string): boolean {
}
return false;
}

/**
* Returns commentId to focus on if id is provided and comment is not already rendered
*/
export function getCommentIdToFocusOn() {
const match =
typeof window !== "undefined" &&
window.location.hash.match(/comment-(\d+)/);

const focus_comment_id = match ? match[1] : undefined;
// Check whether comment is already rendered. In this case we don't need to re-fetch the page
const isCommentLoaded =
focus_comment_id && document.getElementById(`comment-${focus_comment_id}`);

if (focus_comment_id && !isCommentLoaded) return focus_comment_id;
}
Loading
Loading