From ff9a139c674129e550e2f7d54ab6e78f07db1608 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 24 Feb 2026 11:31:35 +0200 Subject: [PATCH 1/4] feat: add key factors vote panel --- .../item_view/use_vote_impact_panel.ts | 52 +++++++++ .../item_view/vote_impact_panel.tsx | 107 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_impact_panel.ts create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_impact_panel.tsx diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_impact_panel.ts b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_impact_panel.ts new file mode 100644 index 0000000000..d5e5e57759 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_impact_panel.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export type ImpactOption = "low" | "medium" | "high"; + +export function useVoteImpactPanel() { + const [showVotePanel, setShowVotePanel] = useState(false); + const [selectedImpact, setSelectedImpact] = useState( + null + ); + const anchorRef = useRef(null); + const panelRef = useRef(null); + + const closePanel = useCallback(() => { + setShowVotePanel(false); + }, []); + + const toggleImpact = useCallback((option: ImpactOption) => { + setSelectedImpact((prev) => (prev === option ? null : option)); + }, []); + + useEffect(() => { + if (!showVotePanel) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + if (panelRef.current && !panelRef.current.contains(target)) { + setShowVotePanel(false); + } + }; + + const handleScroll = () => { + setShowVotePanel(false); + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("scroll", handleScroll, true); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("scroll", handleScroll, true); + }; + }, [showVotePanel]); + + return { + showVotePanel, + selectedImpact, + anchorRef, + panelRef, + setShowVotePanel, + closePanel, + toggleImpact, + }; +} diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_impact_panel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_impact_panel.tsx new file mode 100644 index 0000000000..b00dbb4250 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_impact_panel.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { capitalize } from "lodash"; +import { useTranslations } from "next-intl"; +import { FC, RefObject, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import cn from "@/utils/core/cn"; + +import { ImpactOption } from "./use_vote_impact_panel"; + +type Props = { + ref?: RefObject; + selectedOption: ImpactOption | null; + isCompact?: boolean; + anchorRef: RefObject; + onSelect: (option: ImpactOption) => void; + onClose: () => void; +}; + +const IMPACT_OPTIONS: ImpactOption[] = ["low", "medium", "high"]; + +const VoteImpactPanel: FC = ({ + ref, + selectedOption, + isCompact, + anchorRef, + onSelect, + onClose, +}) => { + const t = useTranslations(); + const [style, setStyle] = useState({ + position: "fixed", + opacity: 0, + }); + + useEffect(() => { + if (!anchorRef.current) return; + const rect = anchorRef.current.getBoundingClientRect(); + setStyle({ + position: "fixed", + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + zIndex: 50, + opacity: 1, + }); + }, [anchorRef]); + + const panel = ( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + + {t("voteOnImpact")} + + +
+ {IMPACT_OPTIONS.map((option) => { + const isSelected = selectedOption === option; + return ( + + ); + })} +
+ + +
+ ); + + return createPortal(panel, document.body); +}; + +export default VoteImpactPanel; From 2ffdfdb3892162dffcd3b5a9fb737c9aa07f66db Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 24 Feb 2026 16:23:32 +0200 Subject: [PATCH 2/4] feat: add downward pannel --- .../key_factors/item_view/index.tsx | 5 - .../item_view/use_vote_impact_panel.ts | 52 --------- .../item_view/vote_impact_panel.tsx | 107 ------------------ 3 files changed, 164 deletions(-) delete mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_impact_panel.ts delete mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_impact_panel.tsx diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx index 2553235e85..f39826e57f 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx @@ -1,6 +1,5 @@ "use client"; -import dynamic from "next/dynamic"; import { FC } from "react"; import { ImpactMetadata, KeyFactor } from "@/types/comment"; @@ -119,7 +118,3 @@ export const KeyFactorItem: FC = ({ ); }; - -export default dynamic(() => Promise.resolve(KeyFactorItem), { - ssr: false, -}); diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_impact_panel.ts b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_impact_panel.ts deleted file mode 100644 index d5e5e57759..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_impact_panel.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -export type ImpactOption = "low" | "medium" | "high"; - -export function useVoteImpactPanel() { - const [showVotePanel, setShowVotePanel] = useState(false); - const [selectedImpact, setSelectedImpact] = useState( - null - ); - const anchorRef = useRef(null); - const panelRef = useRef(null); - - const closePanel = useCallback(() => { - setShowVotePanel(false); - }, []); - - const toggleImpact = useCallback((option: ImpactOption) => { - setSelectedImpact((prev) => (prev === option ? null : option)); - }, []); - - useEffect(() => { - if (!showVotePanel) return; - - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as Node; - if (panelRef.current && !panelRef.current.contains(target)) { - setShowVotePanel(false); - } - }; - - const handleScroll = () => { - setShowVotePanel(false); - }; - - document.addEventListener("mousedown", handleClickOutside); - document.addEventListener("scroll", handleScroll, true); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - document.removeEventListener("scroll", handleScroll, true); - }; - }, [showVotePanel]); - - return { - showVotePanel, - selectedImpact, - anchorRef, - panelRef, - setShowVotePanel, - closePanel, - toggleImpact, - }; -} diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_impact_panel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_impact_panel.tsx deleted file mode 100644 index b00dbb4250..0000000000 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_impact_panel.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client"; - -import { faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { capitalize } from "lodash"; -import { useTranslations } from "next-intl"; -import { FC, RefObject, useEffect, useState } from "react"; -import { createPortal } from "react-dom"; - -import cn from "@/utils/core/cn"; - -import { ImpactOption } from "./use_vote_impact_panel"; - -type Props = { - ref?: RefObject; - selectedOption: ImpactOption | null; - isCompact?: boolean; - anchorRef: RefObject; - onSelect: (option: ImpactOption) => void; - onClose: () => void; -}; - -const IMPACT_OPTIONS: ImpactOption[] = ["low", "medium", "high"]; - -const VoteImpactPanel: FC = ({ - ref, - selectedOption, - isCompact, - anchorRef, - onSelect, - onClose, -}) => { - const t = useTranslations(); - const [style, setStyle] = useState({ - position: "fixed", - opacity: 0, - }); - - useEffect(() => { - if (!anchorRef.current) return; - const rect = anchorRef.current.getBoundingClientRect(); - setStyle({ - position: "fixed", - top: rect.bottom + 4, - left: rect.left, - width: rect.width, - zIndex: 50, - opacity: 1, - }); - }, [anchorRef]); - - const panel = ( -
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - > - - {t("voteOnImpact")} - - -
- {IMPACT_OPTIONS.map((option) => { - const isSelected = selectedOption === option; - return ( - - ); - })} -
- - -
- ); - - return createPortal(panel, document.body); -}; - -export default VoteImpactPanel; From 8cffc9d3f08807ace189f788d83407cb0a43a738 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 11 Mar 2026 17:31:34 +0200 Subject: [PATCH 3/4] feat: 2nd iteration --- .../components/comments_feed_provider.tsx | 46 ++- .../key_factors/comment_detail_panel.tsx | 141 +++++++++ .../key_factors/item_view/index.tsx | 30 +- .../item_view/key_factor_card_container.tsx | 2 +- .../item_view/key_factor_strength_item.tsx | 47 +-- .../item_view/key_factor_vote_panels.tsx | 5 + .../key_factors/item_view/more_panel.tsx | 3 + .../key_factors/item_view/panel_container.tsx | 25 +- .../question_link_key_factor_item.tsx | 39 +-- .../key_factors/item_view/vote_panel.tsx | 3 + .../key_factors/key_factor_detail_overlay.tsx | 245 +++++++++++++++ .../key_factors/key_factors_feed.tsx | 161 ++++++---- .../key_factors_question_section.tsx | 9 +- .../key_factors/mobile_key_factor_overlay.tsx | 288 ++++++++++++++++++ .../comment_feed/comment_action_bar.tsx | 111 +++++++ .../components/comment_feed/comment_cmm.tsx | 9 + .../components/comment_feed/comment_voter.tsx | 12 +- .../src/components/ui/expandable_content.tsx | 6 + .../mocks/mock_comments_feed_provider.tsx | 3 + 19 files changed, 1069 insertions(+), 116 deletions(-) create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/comment_detail_panel.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx create mode 100644 front_end/src/app/(main)/questions/[id]/components/key_factors/mobile_key_factor_overlay.tsx create mode 100644 front_end/src/components/comment_feed/comment_action_bar.tsx diff --git a/front_end/src/app/(main)/components/comments_feed_provider.tsx b/front_end/src/app/(main)/components/comments_feed_provider.tsx index 13fbcf598d..a8564b2e1c 100644 --- a/front_end/src/app/(main)/components/comments_feed_provider.tsx +++ b/front_end/src/app/(main)/components/comments_feed_provider.tsx @@ -21,6 +21,7 @@ import { import { PostWithForecasts, ProjectPermissions } from "@/types/post"; import { VoteDirection } from "@/types/votes"; import { parseComment } from "@/utils/comments"; +import { logError } from "@/utils/core/errors"; type ErrorType = Error & { digest?: string }; @@ -53,6 +54,9 @@ export type CommentsFeedContextType = { parentId: number, text: string ) => Promise; + ensureCommentLoaded: (id: number) => Promise; + refreshComment: (id: number) => Promise; + updateComment: (id: number, changes: Partial) => void; }; const COMMENTS_PER_PAGE = 10; @@ -296,6 +300,43 @@ const CommentsFeedProvider: FC< } }; + const refreshComment = async (id: number): Promise => { + try { + const response = await ClientCommentsApi.getComments({ + post: postData?.id, + author: profileId, + limit: 50, + use_root_comments_pagination: rootCommentStructure, + sort, + focus_comment_id: String(id), + }); + const results = response.results as unknown as BECommentType[]; + const found = results.find((c) => c.id === id); + if (found) { + const parsed = parseComment(found); + setComments((prev) => { + if (findById(prev, id)) { + return replaceById(prev, id, parsed); + } + // Not in feed yet — insert the full focused page + const focusedPage = parseCommentsArray(results, rootCommentStructure); + const merged = [...focusedPage, ...prev].sort((a, b) => b.id - a.id); + return uniqueById(merged); + }); + } + } catch (e) { + logError(e); + } + }; + + const updateComment = (id: number, changes: Partial) => { + setComments((prev) => { + const existing = findById(prev, id); + if (!existing) return prev; + return replaceById(prev, id, { ...existing, ...changes }); + }); + }; + const optimisticallyAddReplyEnsuringParent = async ( parentId: number, text: string @@ -327,6 +368,9 @@ const CommentsFeedProvider: FC< finalizeReply, removeTempReply, optimisticallyAddReplyEnsuringParent, + ensureCommentLoaded, + refreshComment, + updateComment, }} > {children} @@ -347,7 +391,7 @@ export const useCommentsFeed = () => { return context; }; -function findById(list: CommentType[], id: number): CommentType | null { +export function findById(list: CommentType[], id: number): CommentType | null { for (const c of list) { if (c.id === id) return c; const kids = c.children ?? []; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/comment_detail_panel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/comment_detail_panel.tsx new file mode 100644 index 0000000000..d9877a7601 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/comment_detail_panel.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { FC } from "react"; + +import CommentActionBar from "@/components/comment_feed/comment_action_bar"; +import MarkdownEditor from "@/components/markdown_editor"; +import { BECommentType, KeyFactor } from "@/types/comment"; +import { PostWithForecasts } from "@/types/post"; +import { VoteDirection } from "@/types/votes"; +import { parseUserMentions } from "@/utils/comments"; +import { formatDate } from "@/utils/formatters/date"; + +import { KeyFactorItem } from "./item_view"; + +type Props = { + keyFactor: KeyFactor; + relatedKeyFactors: KeyFactor[]; + post: PostWithForecasts; + comment: BECommentType | null; + isLoading: boolean; + onScrollToComment: () => void; + onSelectKeyFactor: (keyFactor: KeyFactor) => void; + onVoteChange: (voteScore: number, userVote: VoteDirection | null) => void; + onCmmToggle: (enabled: boolean) => void; +}; + +const CommentDetailPanel: FC = ({ + keyFactor, + relatedKeyFactors, + post, + comment, + isLoading, + onScrollToComment, + onSelectKeyFactor, + onVoteChange, + onCmmToggle, +}) => { + const t = useTranslations(); + const locale = useLocale(); + + return ( +
e.stopPropagation()} + > +
+
+ + {keyFactor.author.username} + + + + + {t("onDate", { + date: formatDate(locale, new Date(keyFactor.created_at)), + })} + + +
+ +
+ {isLoading && ( +
+
+
+
+
+
+ )} + {comment && ( + + )} +
+ + {relatedKeyFactors.length > 0 && ( +
+ + {t("keyFactors")} + +
+ {relatedKeyFactors.map((kf) => ( + onSelectKeyFactor(kf)} + /> + ))} +
+
+ )} + + {isLoading && ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )} + {comment && ( + + )} +
+
+ ); +}; + +export default CommentDetailPanel; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx index f39826e57f..b78198d76e 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx @@ -2,6 +2,7 @@ import { FC } from "react"; +import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; import { ImpactMetadata, KeyFactor } from "@/types/comment"; import { ProjectPermissions } from "@/types/post"; import { getImpactDirectionFromMetadata } from "@/utils/key_factors"; @@ -24,6 +25,7 @@ type Props = { className?: string; projectPermission?: ProjectPermissions; isSuggested?: boolean; + inlineVotePanels?: boolean; }; function getImpactMetadata(keyFactor: KeyFactor): ImpactMetadata | null { @@ -40,13 +42,18 @@ export const KeyFactorItem: FC = ({ className, projectPermission, isSuggested, + inlineVotePanels, }) => { - const isFlagged = keyFactor.flagged_by_me; - const hasImpactBar = !keyFactor.base_rate; + const { combinedKeyFactors } = useCommentsFeed(); + const liveKeyFactor = + combinedKeyFactors.find((kf) => kf.id === keyFactor.id) ?? keyFactor; + + const isFlagged = liveKeyFactor.flagged_by_me; + const hasImpactBar = !liveKeyFactor.base_rate; const impactDirection = hasImpactBar - ? getImpactDirectionFromMetadata(getImpactMetadata(keyFactor)) + ? getImpactDirectionFromMetadata(getImpactMetadata(liveKeyFactor)) : undefined; - const impactStrength = keyFactor.vote?.score ?? 0; + const impactStrength = liveKeyFactor.vote?.score ?? 0; const { impactPanel, @@ -70,9 +77,9 @@ export const KeyFactorItem: FC = ({ impactDirection={impactDirection} impactStrength={impactStrength} > - {keyFactor.driver && ( + {liveKeyFactor.driver && ( = ({ isMorePanelOpen={morePanel.showPanel} /> )} - {keyFactor.base_rate && ( + {liveKeyFactor.base_rate && ( = ({ isMorePanelOpen={morePanel.showPanel} /> )} - {keyFactor.news && ( + {liveKeyFactor.news && ( = ({ morePanel={morePanel} anchorRef={impactPanel.anchorRef} isCompact={isCompact} - keyFactor={keyFactor} + inline={inlineVotePanels} + keyFactor={liveKeyFactor} projectPermission={projectPermission} />
diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_card_container.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_card_container.tsx index 96f0e2d8df..ba01f02003 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_card_container.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_card_container.tsx @@ -58,7 +58,7 @@ const KeyFactorCardContainer: FC = ({ id={id} onClick={onClick} className={cn( - "relative flex gap-3 rounded-xl p-5 [&:hover_.target]:visible", + "relative flex gap-3 overflow-hidden rounded-xl p-5 [&:hover_.target]:visible", linkToComment ? "border border-blue-400 bg-gray-0 dark:border-blue-400-dark dark:bg-gray-0-dark" : "bg-blue-200 dark:bg-blue-200-dark", diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_strength_item.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_strength_item.tsx index 44f66b817d..3ec5eaed1b 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_strength_item.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_strength_item.tsx @@ -144,26 +144,27 @@ const KeyFactorStrengthItem: FC = ({ )}
-
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - > - { - toggle(upScore); - onVotePanelToggle?.(selection !== "up"); - }} - onClickDown={() => { - toggle(downScore); - onVotePanelToggle?.(false); - onDownvotePanelToggle?.(selection !== "down"); - }} - /> +
+
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + { + toggle(upScore); + onVotePanelToggle?.(selection !== "up"); + }} + onClickDown={() => { + toggle(downScore); + onVotePanelToggle?.(false); + onDownvotePanelToggle?.(selection !== "down"); + }} + /> +
{!isCompactConsumer && onMorePanelToggle && ( diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_vote_panels.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_vote_panels.tsx index 6d50596e14..1f6dc90a9f 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_vote_panels.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_vote_panels.tsx @@ -59,6 +59,7 @@ type KeyFactorVotePanelsProps = { morePanel?: ReturnType>; anchorRef: RefObject; isCompact?: boolean; + inline?: boolean; keyFactor?: KeyFactor; projectPermission?: ProjectPermissions; }; @@ -69,6 +70,7 @@ const KeyFactorVotePanels: FC = ({ morePanel, anchorRef, isCompact, + inline, keyFactor, projectPermission, }) => { @@ -83,6 +85,7 @@ const KeyFactorVotePanels: FC = ({ selectedOption={impactPanel.selectedOption} title={t("voteOnImpact")} isCompact={isCompact} + inline={inline} anchorRef={anchorRef} onSelect={impactPanel.toggleOption} onClose={impactPanel.closePanel} @@ -99,6 +102,7 @@ const KeyFactorVotePanels: FC = ({ title={t("why")} direction="column" isCompact={isCompact} + inline={inline} anchorRef={anchorRef} onSelect={downvotePanel.toggleOption} onClose={downvotePanel.closePanel} @@ -120,6 +124,7 @@ const KeyFactorVotePanels: FC = ({ projectPermission={projectPermission} anchorRef={anchorRef} isCompact={isCompact} + inline={inline} onClose={morePanel.closePanel} /> )} diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx index 82b8ca5617..b5eeff591c 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx @@ -30,6 +30,7 @@ type Props = { projectPermission?: ProjectPermissions; anchorRef: RefObject; isCompact?: boolean; + inline?: boolean; onClose: () => void; }; @@ -39,6 +40,7 @@ const MorePanel: FC = ({ projectPermission, anchorRef, isCompact, + inline, onClose, }) => { const t = useTranslations(); @@ -104,6 +106,7 @@ const MorePanel: FC = ({ ref={ref} anchorRef={anchorRef} isCompact={isCompact} + inline={inline} onClose={onClose} > ; anchorRef: RefObject; isCompact?: boolean; + inline?: boolean; onClose: () => void; }>; function getAnchorStyle( anchorRef: RefObject -): React.CSSProperties { +): CSSProperties { if (!anchorRef.current) { return { position: "fixed", opacity: 0 }; } @@ -26,7 +27,7 @@ function getAnchorStyle( top: rect.bottom + 4, left: rect.left, width: rect.width, - zIndex: 50, + zIndex: 400, }; } @@ -34,21 +35,30 @@ const PanelContainer: FC = ({ ref, anchorRef, isCompact, + inline, onClose, children, }) => { const panel = (
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > - {children} +
+ {children} +
); + if (inline) return panel; return createPortal(panel, document.body); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/question_link/question_link_key_factor_item.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/question_link/question_link_key_factor_item.tsx index 13e383ef19..9ae362d7d8 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/question_link/question_link_key_factor_item.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/question_link/question_link_key_factor_item.tsx @@ -35,6 +35,7 @@ type Props = { mode?: "forecaster" | "consumer"; linkToComment?: boolean; className?: string; + onClick?: () => void; }; const otherQuestionCache = new Map(); @@ -47,6 +48,7 @@ const QuestionLinkKeyFactorItem: FC = ({ mode = "forecaster", linkToComment = true, className, + onClick, }) => { const isConsumer = mode === "consumer"; const isCompactConsumer = isConsumer && compact; @@ -206,12 +208,14 @@ const QuestionLinkKeyFactorItem: FC = ({ impactDirection={impactDirection} impactStrength={strengthScore} className={cn("shadow-sm", className)} + onClick={onClick} >
e.stopPropagation()} className={cn( "min-w-0 flex-1 font-medium text-gray-800 no-underline hover:underline dark:text-gray-800-dark", compact ? "text-xs leading-4" : "text-sm leading-5" @@ -254,23 +258,24 @@ const QuestionLinkKeyFactorItem: FC = ({ )}
-
e.stopPropagation()} - onPointerDown={(e) => e.stopPropagation()} - > - setUserVote(next)} - onStrengthChange={(s) => setLocalStrength(s)} - onVotePanelToggle={handleUpvotePanelToggle} - onDownvotePanelToggle={handleDownvotePanelToggle} - /> +
+
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + setUserVote(next)} + onStrengthChange={(s) => setLocalStrength(s)} + onVotePanelToggle={handleUpvotePanelToggle} + onDownvotePanelToggle={handleDownvotePanelToggle} + /> +
diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_panel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_panel.tsx index 6e0f30c835..ec4ae07dc5 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_panel.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vote_panel.tsx @@ -13,6 +13,7 @@ type Props = { title: string; direction?: "row" | "column"; isCompact?: boolean; + inline?: boolean; anchorRef: RefObject; onSelect: (option: T) => void; onClose: () => void; @@ -28,6 +29,7 @@ function VotePanelInner({ title, direction = "row", isCompact, + inline, anchorRef, onSelect, onClose, @@ -40,6 +42,7 @@ function VotePanelInner({ ref={ref} anchorRef={anchorRef} isCompact={isCompact} + inline={inline} onClose={onClose} > void; + onSelectKeyFactor: (keyFactor: KeyFactor) => void; + questionLink?: never; +}; + +type QuestionLinkOverlayProps = { + questionLink: FetchedAggregateCoherenceLink; + post: PostWithForecasts; + onClose: () => void; + keyFactor?: never; + allKeyFactors?: never; + preloadedComment?: never; + onSelectKeyFactor?: never; +}; + +type Props = KeyFactorOverlayProps | QuestionLinkOverlayProps; + +const KeyFactorDetailOverlay: FC = (props) => { + const { post, onClose } = props; + const t = useTranslations(); + const { comments, ensureCommentLoaded, updateComment } = useCommentsFeed(); + const isAboveSm = useBreakpoint("sm"); + + const keyFactor = props.keyFactor ?? null; + const questionLink = props.questionLink ?? null; + + const feedComment = useMemo( + () => (keyFactor ? findById(comments, keyFactor.comment_id) : null), + [comments, keyFactor] + ); + + const comment = feedComment ?? props.preloadedComment ?? null; + + useEffect(() => { + if (keyFactor && !comment) { + ensureCommentLoaded(keyFactor.comment_id); + } + }, [keyFactor, comment, ensureCommentLoaded]); + + const handleVoteChange = ( + voteScore: number, + userVote: VoteDirection | null + ) => { + if (!keyFactor) return; + updateComment(keyFactor.comment_id, { + vote_score: voteScore, + user_vote: userVote, + }); + }; + + const handleCmmToggle = (enabled: boolean) => { + if (!keyFactor) return; + const existing = feedComment ?? props.preloadedComment; + if (!existing) return; + const prev = existing.changed_my_mind; + const countDelta = prev.for_this_user === enabled ? 0 : enabled ? 1 : -1; + updateComment(keyFactor.comment_id, { + changed_my_mind: { + for_this_user: enabled, + count: prev.count + countDelta, + }, + }); + }; + + const relatedKeyFactors = keyFactor + ? (props.allKeyFactors ?? []).filter( + (kf) => kf.id !== keyFactor.id && kf.comment_id === keyFactor.comment_id + ) + : []; + + const allPostKeyFactors = useMemo( + () => props.allKeyFactors ?? [], + [props.allKeyFactors] + ); + const currentIndex = allPostKeyFactors.findIndex( + (kf) => kf.id === keyFactor?.id + ); + const hasPrev = currentIndex > 0; + const hasNext = currentIndex < allPostKeyFactors.length - 1; + + const binaryQuestion = useMemo(() => { + const q = post.question; + if (q && q.type === QuestionType.Binary) { + return q as QuestionWithNumericForecasts; + } + return null; + }, [post.question]); + + const handleScrollToComment = async () => { + if (!keyFactor) return; + await ensureCommentLoaded(keyFactor.comment_id); + onClose(); + setTimeout(() => { + const el = document.getElementById(`comment-${keyFactor.comment_id}`); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 200); + }; + + const hasComment = !!(keyFactor && (comment?.text?.trim() || !comment)); + const isSimple = + questionLink || + !keyFactor?.driver || + (relatedKeyFactors.length === 0 && !comment?.text?.trim()); + + if (!isAboveSm) { + return ( + + ); + } + + const closeButton = ( + + ); + + if (isSimple || !keyFactor || !props.onSelectKeyFactor) { + return ( + + +
+
+ +
e.stopPropagation()}> + {closeButton} + {questionLink ? ( + + ) : ( + keyFactor && ( + + ) + )} +
+
+
+
+
+ ); + } + + return ( + + +
+
+ + {closeButton} +
e.stopPropagation()} + > +
+ +
+
+ + +
+
+
+
+ ); +}; + +export default KeyFactorDetailOverlay; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_feed.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_feed.tsx index 54ba0b11f8..bf742b7343 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_feed.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_feed.tsx @@ -1,13 +1,14 @@ "use client"; import { useTranslations } from "next-intl"; -import { FC, useEffect, useState } from "react"; +import { FC, useEffect, useMemo, useState } from "react"; import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider"; import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; -import { useBreakpoint } from "@/hooks/tailwind"; +import { FetchedAggregateCoherenceLink } from "@/types/coherence"; +import { KeyFactor } from "@/types/comment"; import { PostStatus, PostWithForecasts } from "@/types/post"; import { sendAnalyticsEvent } from "@/utils/analytics"; @@ -16,25 +17,29 @@ import KeyFactorsAddModal from "./add_modal/key_factors_add_modal"; import { getKeyFactorsLimits } from "./hooks"; import KeyFactorItem from "./item_view"; import QuestionLinkKeyFactorItem from "./item_view/question_link/question_link_key_factor_item"; +import KeyFactorDetailOverlay from "./key_factor_detail_overlay"; import KeyFactorsGridPlaceholder from "./key_factors_grid_placeholder"; const GRID_PLACEHOLDER_SLOTS = 3; type Props = { post: PostWithForecasts; + isExpanded?: boolean; }; -const KeyFactorsFeed: FC = ({ post }) => { +const KeyFactorsFeed: FC = ({ post, isExpanded = true }) => { const t = useTranslations(); - const { combinedKeyFactors } = useCommentsFeed(); + const { combinedKeyFactors, comments } = useCommentsFeed(); const { aggregateCoherenceLinks } = useCoherenceLinksContext(); const { user } = useAuth(); const { setCurrentModal } = useModal(); const [order, setOrder] = useState(null); const [isAddModalOpen, setIsAddModalOpen] = useState(false); - const isSmUp = useBreakpoint("sm"); - const isMobileCompact = !isSmUp; - + const [selectedKeyFactor, setSelectedKeyFactor] = useState( + null + ); + const [selectedQuestionLink, setSelectedQuestionLink] = + useState(null); const questionLinkAggregates = aggregateCoherenceLinks?.data.filter( (it) => it.links_nr > 1 && it.strength !== null && it.direction !== null @@ -93,6 +98,28 @@ const KeyFactorsFeed: FC = ({ post }) => { }); }; + const handleKeyFactorClick = (kf: KeyFactor) => { + if (!isExpanded) return; + setSelectedKeyFactor(kf); + sendAnalyticsEvent("KeyFactorClick", { event_label: "fromGrid" }); + }; + + const handleQuestionLinkClick = (link: FetchedAggregateCoherenceLink) => { + if (!isExpanded) return; + setSelectedQuestionLink(link); + sendAnalyticsEvent("KeyFactorClick", { event_label: "questionLink" }); + }; + + const preloadedComment = useMemo( + () => + selectedKeyFactor + ? comments + .flatMap((c) => [c, ...(c.children ?? [])]) + .find((c) => c.id === selectedKeyFactor.comment_id) ?? null + : null, + [comments, selectedKeyFactor] + ); + const addModal = user && ( = ({ post }) => { /> ); - // 0 items: empty state + const overlay = selectedKeyFactor ? ( + setSelectedKeyFactor(null)} + onSelectKeyFactor={setSelectedKeyFactor} + /> + ) : selectedQuestionLink ? ( + setSelectedQuestionLink(null)} + /> + ) : null; + if (totalItemCount === 0) { return ( -
-
- - {t("noKeyFactorsP1")} - - - {t("noKeyFactorsP2")} - + <> +
+
+ + {t("noKeyFactorsP1")} + + + {t("noKeyFactorsP2")} + +
+ {canAddKeyFactor && }
- {canAddKeyFactor && } -
+ {addModal} + ); } - // 1-3 items: grid layout with placeholders if (totalItemCount <= GRID_PLACEHOLDER_SLOTS) { const placeholderCount = GRID_PLACEHOLDER_SLOTS - totalItemCount; return ( <>
{items.map((kf) => ( @@ -138,7 +185,7 @@ const KeyFactorsFeed: FC = ({ post }) => { key={`post-key-factor-${kf.id}`} keyFactor={kf} projectPermission={post.user_permission} - isCompact={isMobileCompact} + onClick={() => handleKeyFactorClick(kf)} /> ))} @@ -148,7 +195,7 @@ const KeyFactorsFeed: FC = ({ post }) => { key={`question-link-kf-${link.id}`} link={link} post={post} - compact={isMobileCompact} + onClick={() => handleQuestionLinkClick(link)} /> ))} @@ -167,41 +214,47 @@ const KeyFactorsFeed: FC = ({ post }) => {
{addModal} + {overlay} ); } - // 4+ items: masonry layout return ( -
- {items.map((kf) => ( -
- -
- ))} + <> +
+ {items.map((kf) => ( +
+ handleKeyFactorClick(kf)} + /> +
+ ))} - {questionLinkAggregates.map((link) => ( -
- -
- ))} -
+ {questionLinkAggregates.map((link) => ( +
+ handleQuestionLinkClick(link)} + /> +
+ ))} +
+ {overlay} + ); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_section.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_section.tsx index b8a649ff0d..1ca316bb51 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_section.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_question_section.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { FC, useEffect, useMemo } from "react"; +import { FC, useCallback, useEffect, useMemo, useState } from "react"; import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider"; import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; @@ -43,6 +43,10 @@ const KeyFactorsQuestionSection: FC = ({ const { keyFactorsExpanded } = useQuestionLayout(); const { combinedKeyFactors } = useCommentsFeed(); const shouldHideKeyFactors = useShouldHideKeyFactors(); + const [isFeedExpanded, setIsFeedExpanded] = useState(false); + const handleExpandedChange = useCallback((expanded: boolean) => { + setIsFeedExpanded(expanded); + }, []); const { aggregateCoherenceLinks } = useCoherenceLinksContext(); const questionLinkAggregates = useMemo( @@ -135,8 +139,9 @@ const KeyFactorsQuestionSection: FC = ({ expandLabel={t("showMore")} collapseLabel={t("showLess")} forceState={keyFactorsExpanded} + onExpandedChange={handleExpandedChange} > - + )} diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/mobile_key_factor_overlay.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/mobile_key_factor_overlay.tsx new file mode 100644 index 0000000000..954aa2939e --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/mobile_key_factor_overlay.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { + faArrowUpRightFromSquare, + faChevronLeft, + faChevronRight, + faXmark, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Dialog, DialogPanel, Transition } from "@headlessui/react"; +import { useLocale, useTranslations } from "next-intl"; +import { FC, Fragment } from "react"; + +import CommentActionBar from "@/components/comment_feed/comment_action_bar"; +import BinaryCPBar from "@/components/consumer_post_card/binary_cp_bar"; +import MarkdownEditor from "@/components/markdown_editor"; +import { FetchedAggregateCoherenceLink } from "@/types/coherence"; +import { BECommentType, KeyFactor } from "@/types/comment"; +import { PostWithForecasts } from "@/types/post"; +import { QuestionWithNumericForecasts } from "@/types/question"; +import { VoteDirection } from "@/types/votes"; +import { parseUserMentions } from "@/utils/comments"; +import cn from "@/utils/core/cn"; +import { formatDate } from "@/utils/formatters/date"; + +import { KeyFactorItem } from "./item_view"; +import QuestionLinkKeyFactorItem from "./item_view/question_link/question_link_key_factor_item"; + +type Props = { + keyFactor: KeyFactor | null; + questionLink: FetchedAggregateCoherenceLink | null; + post: PostWithForecasts; + comment: BECommentType | null; + binaryQuestion: QuestionWithNumericForecasts | null; + relatedKeyFactors: KeyFactor[]; + allPostKeyFactors: KeyFactor[]; + currentIndex: number; + hasPrev: boolean; + hasNext: boolean; + hasComment: boolean; + onClose: () => void; + onSelectKeyFactor?: (keyFactor: KeyFactor) => void; + onScrollToComment: () => void; + onVoteChange: (voteScore: number, userVote: VoteDirection | null) => void; + onCmmToggle: (enabled: boolean) => void; +}; + +const MobileKeyFactorOverlay: FC = ({ + keyFactor, + questionLink, + post, + comment, + binaryQuestion, + relatedKeyFactors, + allPostKeyFactors, + currentIndex, + hasPrev, + hasNext, + hasComment, + onClose, + onSelectKeyFactor, + onScrollToComment, + onVoteChange, + onCmmToggle, +}) => { + const t = useTranslations(); + const locale = useLocale(); + + return ( + + +
+
+ + {keyFactor && allPostKeyFactors.length > 1 && ( + <> + + + + )} + +
+ +
+ +
+
+

+ {post.title} +

+
+ {binaryQuestion && ( + + )} +
+ + + {t("keyFactor")} + + +
+ {hasPrev && + (() => { + const prevKf = allPostKeyFactors[currentIndex - 1]; + return prevKf ? ( +
+
+ +
+
+ ) : null; + })()} +
+ {questionLink ? ( + + ) : ( + keyFactor && ( + + ) + )} +
+ {hasNext && + (() => { + const nextKf = allPostKeyFactors[currentIndex + 1]; + return nextKf ? ( +
+
+ +
+
+ ) : null; + })()} +
+ + {keyFactor && hasComment && ( + <> + + {t("comment")} + + +
+
+ + {keyFactor.author.username} + + + + {t("onDate", { + date: formatDate( + locale, + new Date(keyFactor.created_at) + ), + })} + + +
+ +
+ {!comment && ( +
+
+
+
+
+ )} + {comment && ( + + )} +
+ + {relatedKeyFactors.length > 0 && ( +
+ + {t("keyFactors")} + +
+ {relatedKeyFactors.map((kf) => ( + onSelectKeyFactor?.(kf)} + /> + ))} +
+
+ )} +
+ + {!comment && ( +
+
+
+
+
+
+
+ )} + {comment && ( + + )} + + )} + +
+
+
+ ); +}; + +export default MobileKeyFactorOverlay; diff --git a/front_end/src/components/comment_feed/comment_action_bar.tsx b/front_end/src/components/comment_feed/comment_action_bar.tsx new file mode 100644 index 0000000000..10ef8b7903 --- /dev/null +++ b/front_end/src/components/comment_feed/comment_action_bar.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { faReply, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import { + CmmOverlay, + CmmToggleButton, + useCmmContext, +} from "@/components/comment_feed/comment_cmm"; +import CommentVoter from "@/components/comment_feed/comment_voter"; +import Button from "@/components/ui/button"; +import { useAuth } from "@/contexts/auth_context"; +import { BECommentType } from "@/types/comment"; +import { PostWithForecasts } from "@/types/post"; +import { VoteDirection } from "@/types/votes"; +import { canPredictQuestion } from "@/utils/questions/predictions"; + +type Props = { + comment: BECommentType; + post: PostWithForecasts; + onReply: () => void; + isReplying?: boolean; + onScrollToLink?: () => void; + onVoteChange?: (voteScore: number, userVote: VoteDirection | null) => void; + onCmmToggle?: (enabled: boolean) => void; +}; + +const CommentActionBar: FC = ({ + comment, + post, + onReply, + isReplying = false, + onScrollToLink, + onVoteChange, + onCmmToggle, +}) => { + const t = useTranslations(); + const { user } = useAuth(); + + const userCanPredict = canPredictQuestion(post, user); + const isCommentAuthor = comment.author.id === user?.id; + + const isCmmVisible = + !!post.question || !!post.group_of_questions || !!post.conditional; + const isCmmDisabled = !user || !userCanPredict || isCommentAuthor; + + const baseCmmContext = useCmmContext( + comment.changed_my_mind.count, + comment.changed_my_mind.for_this_user + ); + + const cmmContext = onCmmToggle + ? { + ...baseCmmContext, + onCMMToggled: (enabled: boolean) => { + baseCmmContext.onCMMToggled(enabled); + onCmmToggle(enabled); + }, + } + : baseCmmContext; + + return ( + <> +
+ + {isReplying ? ( + + ) : ( + + )} + {isCmmVisible && ( + + )} +
+ {})} + /> + + ); +}; + +export default CommentActionBar; diff --git a/front_end/src/components/comment_feed/comment_cmm.tsx b/front_end/src/components/comment_feed/comment_cmm.tsx index 2321551e8e..f52cf4f4bb 100644 --- a/front_end/src/components/comment_feed/comment_cmm.tsx +++ b/front_end/src/components/comment_feed/comment_cmm.tsx @@ -23,6 +23,7 @@ import React, { FC, useLayoutEffect, useCallback, + useEffect, } from "react"; import { toggleCMMComment } from "@/app/(main)/questions/actions"; @@ -193,6 +194,14 @@ export const useCmmContext = ( isModalOpen: false, }); + useEffect(() => { + setCmmState((prev) => ({ + ...prev, + count: initialCount, + isCmmEnabled: initialCmmEnabled, + })); + }, [initialCount, initialCmmEnabled]); + const setIsOverlayOpen = (open: boolean) => setCmmState({ ...cmmState, isModalOpen: open }); diff --git a/front_end/src/components/comment_feed/comment_voter.tsx b/front_end/src/components/comment_feed/comment_voter.tsx index 0c4426b306..9486e8409d 100644 --- a/front_end/src/components/comment_feed/comment_voter.tsx +++ b/front_end/src/components/comment_feed/comment_voter.tsx @@ -1,5 +1,5 @@ "use client"; -import { FC, useState } from "react"; +import { FC, useEffect, useState } from "react"; import { voteComment } from "@/app/(main)/questions/actions"; import Voter from "@/components/voter"; @@ -12,6 +12,7 @@ import { logError } from "@/utils/core/errors"; type Props = { voteData: VoteData; className?: string; + onVoteChange?: (voteScore: number, userVote: VoteDirection | null) => void; }; type VoteData = { @@ -21,12 +22,18 @@ type VoteData = { userVote: VoteDirection; }; -const CommentVoter: FC = ({ voteData, className }) => { +const CommentVoter: FC = ({ voteData, className, onVoteChange }) => { const { user } = useAuth(); const { setCurrentModal } = useModal(); const [userVote, setUserVote] = useState(voteData.userVote); const [voteScore, setVoteScore] = useState(voteData.voteScore); + + useEffect(() => { + setUserVote(voteData.userVote); + setVoteScore(voteData.voteScore); + }, [voteData.userVote, voteData.voteScore]); + const handleVote = async (direction: VoteDirection) => { if (!user) { setCurrentModal({ type: "signin" }); @@ -44,6 +51,7 @@ const CommentVoter: FC = ({ voteData, className }) => { if (response && "score" in response) { setUserVote(newDirection); setVoteScore(response.score as number); + onVoteChange?.(response.score as number, newDirection); } } catch (e) { logError(e); diff --git a/front_end/src/components/ui/expandable_content.tsx b/front_end/src/components/ui/expandable_content.tsx index 28e21ec488..cd783fdf1b 100644 --- a/front_end/src/components/ui/expandable_content.tsx +++ b/front_end/src/components/ui/expandable_content.tsx @@ -17,6 +17,7 @@ type Props = { className?: string; gradientClassName?: string; forceState?: boolean; + onExpandedChange?: (expanded: boolean) => void; }; const ExpandableContent: FC> = ({ @@ -26,6 +27,7 @@ const ExpandableContent: FC> = ({ gradientClassName = "from-blue-200 dark:from-blue-200-dark", className, forceState, + onExpandedChange, children, }) => { const t = useTranslations(); @@ -61,6 +63,10 @@ const ExpandableContent: FC> = ({ } }, [forceState]); + useEffect(() => { + onExpandedChange?.(isExpanded); + }, [isExpanded, onExpandedChange]); + return (
diff --git a/front_end/src/stories/utils/mocks/mock_comments_feed_provider.tsx b/front_end/src/stories/utils/mocks/mock_comments_feed_provider.tsx index 292882f05b..5ec9b99090 100644 --- a/front_end/src/stories/utils/mocks/mock_comments_feed_provider.tsx +++ b/front_end/src/stories/utils/mocks/mock_comments_feed_provider.tsx @@ -27,6 +27,9 @@ export const MockCommentsFeedProvider: React.FC = ({ finalizeReply: () => {}, optimisticallyAddReplyEnsuringParent: async () => 0, removeTempReply: () => {}, + ensureCommentLoaded: async () => false, + refreshComment: async () => {}, + updateComment: () => {}, }} > {children} From a0511e6284947137959cd52d7fc7fc0929bb71a0 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 19 Mar 2026 16:30:21 +0200 Subject: [PATCH 4/4] feat: qa updates --- front_end/messages/cs.json | 1 + front_end/messages/en.json | 1 + front_end/messages/es.json | 1 + front_end/messages/pt.json | 1 + front_end/messages/zh-TW.json | 1 + front_end/messages/zh.json | 1 + .../key_factors/comment_detail_panel.tsx | 4 +- .../[id]/components/key_factors/hooks.ts | 4 + .../base_rate/key_factor_base_rate.tsx | 7 +- .../key_factors/item_view/index.tsx | 15 +- .../item_view/key_factor_card_container.tsx | 2 +- .../item_view/key_factor_strength_item.tsx | 135 ++++++++++++----- .../item_view/key_factor_vote_panels.tsx | 26 +++- .../key_factors/item_view/more_panel.tsx | 27 +++- .../item_view/news/key_factor_news_item.tsx | 137 ++++++++++-------- .../key_factors/item_view/panel_container.tsx | 2 +- .../question_link_key_factor_item.tsx | 66 +++++---- .../item_view/use_optimistic_vote.ts | 15 +- .../key_factors/item_view/use_vote_panel.ts | 16 +- .../item_view/vertical_impact_bar.tsx | 64 +++++--- .../key_factors/item_view/vote_panel.tsx | 4 +- .../key_factors/key_factor_detail_overlay.tsx | 16 +- .../key_factors_comment_section.tsx | 11 +- .../key_factors_consumer_carousel.tsx | 27 ++-- .../key_factors/key_factors_feed.tsx | 51 ++++--- .../key_factors_question_section.tsx | 9 +- .../key_factors/mobile_key_factor_overlay.tsx | 133 ++++++++++------- .../question_layout_context.tsx | 63 +++++++- .../src/components/comment_feed/comment.tsx | 42 ++++-- .../components/dispute_key_factor_modal.tsx | 5 +- front_end/src/components/global_modals.tsx | 1 + .../src/components/ui/expandable_content.tsx | 11 +- front_end/src/contexts/modal_context.tsx | 1 + 33 files changed, 602 insertions(+), 298 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 4babce620d..d93ef8ab3a 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -2072,6 +2072,7 @@ "switchBackToSlidersHint": "přepněte zpět na posuvníky pro plynulé přizpůsobení", "view": "Zobrazit", "viewComment": "Zobrazit komentář", + "viewArticle": "Zobrazit článek", "createdTimeAgoBy": "Vytvořeno {timeAgo} uživatelem @{author}", "createdTimeAgo": "Vytvořeno {timeAgo}", "direction": "Směr", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 118b88ba91..9d8c0b88ba 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -2070,6 +2070,7 @@ "redundant": "Redundant", "thanksForVoting": "Thanks for voting!", "viewComment": "View comment", + "viewArticle": "View Article", "createdTimeAgoBy": "Created {timeAgo} by @{author}", "createdTimeAgo": "Created {timeAgo}", "direction": "Direction", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 44dfa6af4e..0300c88f65 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -2072,6 +2072,7 @@ "switchBackToSlidersHint": "vuelve a los deslizadores para un ajuste suave", "view": "Ver", "viewComment": "Ver comentario", + "viewArticle": "Ver artículo", "createdTimeAgoBy": "Creado {timeAgo} por @{author}", "createdTimeAgo": "Creado {timeAgo}", "direction": "Dirección", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 7056e900a2..a2028243c1 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -2070,6 +2070,7 @@ "switchBackToSlidersHint": "volte para os controles deslizantes para um ajuste suave", "view": "Visualizar", "viewComment": "Ver comentário", + "viewArticle": "Ver artigo", "createdTimeAgoBy": "Criado {timeAgo} por @{author}", "createdTimeAgo": "Criado {timeAgo}", "direction": "Direção", diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 771c1edfe9..d21ecf893d 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -2069,6 +2069,7 @@ "switchBackToSlidersHint": "切換回滑桿以平滑調整", "view": "檢視", "viewComment": "查看評論", + "viewArticle": "查看文章", "createdTimeAgoBy": "由 @{author} 建立於 {timeAgo}", "createdTimeAgo": "建立於 {timeAgo}", "direction": "方向", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index e7d76de5f9..92848e4e35 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -2074,6 +2074,7 @@ "switchBackToSlidersHint": "切回滑块以进行更精细的调整", "view": "查看", "viewComment": "查看评论", + "viewArticle": "查看文章", "createdTimeAgoBy": "由 @{author} 创建于 {timeAgo}", "createdTimeAgo": "创建于 {timeAgo}", "direction": "方向", diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/comment_detail_panel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/comment_detail_panel.tsx index d9877a7601..01ee05080a 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/comment_detail_panel.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/comment_detail_panel.tsx @@ -20,6 +20,7 @@ type Props = { comment: BECommentType | null; isLoading: boolean; onScrollToComment: () => void; + onReplyToComment: () => void; onSelectKeyFactor: (keyFactor: KeyFactor) => void; onVoteChange: (voteScore: number, userVote: VoteDirection | null) => void; onCmmToggle: (enabled: boolean) => void; @@ -32,6 +33,7 @@ const CommentDetailPanel: FC = ({ comment, isLoading, onScrollToComment, + onReplyToComment, onSelectKeyFactor, onVoteChange, onCmmToggle, @@ -127,7 +129,7 @@ const CommentDetailPanel: FC = ({ { export const useKeyFactorModeration = () => { const t = useTranslations(); const { setCurrentModal } = useModal(); + const questionLayout = useQuestionLayoutSafe(); const { combinedKeyFactors, setCombinedKeyFactors } = useCommentsFeed(); const [doReportKeyFactor] = useServerAction(reportKeyFactor); @@ -535,11 +537,13 @@ export const useKeyFactorModeration = () => { optimisticallyAddReplyEnsuringParent(kf.comment_id, text), onFinalize: finalizeReply, onRemove: removeTempReply, + onSubmitted: () => questionLayout?.closeKeyFactorOverlay(), }, }); }, [ setCurrentModal, + questionLayout, optimisticallyAddReplyEnsuringParent, finalizeReply, removeTempReply, diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/base_rate/key_factor_base_rate.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/base_rate/key_factor_base_rate.tsx index a5ebff201a..d6edcb63bc 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/base_rate/key_factor_base_rate.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/base_rate/key_factor_base_rate.tsx @@ -5,7 +5,9 @@ import { useTranslations } from "next-intl"; import { KeyFactor, KeyFactorVoteTypes } from "@/types/comment"; import cn from "@/utils/core/cn"; -import KeyFactorStrengthItem from "../key_factor_strength_item"; +import KeyFactorStrengthItem, { + ImpactVoteHandler, +} from "../key_factor_strength_item"; import KeyFactorText from "../key_factor_text"; import KeyFactorBaseRateFrequency from "./key_factor_base_rate_frequency"; import KeyFactorBaseRateTrend from "./key_factor_base_rate_trend"; @@ -15,6 +17,7 @@ type Props = { mode?: "forecaster" | "consumer"; isCompact?: boolean; isSuggested?: boolean; + impactVoteRef?: React.MutableRefObject; onVotePanelToggle?: (open: boolean) => void; onDownvotePanelToggle?: (open: boolean) => void; onMorePanelToggle?: (open: boolean) => void; @@ -26,6 +29,7 @@ const KeyFactorBaseRate: React.FC = ({ isCompact, mode, isSuggested, + impactVoteRef, onVotePanelToggle, onDownvotePanelToggle, onMorePanelToggle, @@ -45,6 +49,7 @@ const KeyFactorBaseRate: React.FC = ({ isCompact={isCompact} mode={mode} voteType={KeyFactorVoteTypes.DIRECTION} + impactVoteRef={impactVoteRef} onVotePanelToggle={onVotePanelToggle} onDownvotePanelToggle={onDownvotePanelToggle} onMorePanelToggle={onMorePanelToggle} diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx index b78198d76e..8a20904634 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import { FC, useRef } from "react"; import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; import { ImpactMetadata, KeyFactor } from "@/types/comment"; @@ -10,6 +10,7 @@ import { getImpactDirectionFromMetadata } from "@/utils/key_factors"; import KeyFactorBaseRate from "./base_rate/key_factor_base_rate"; import KeyFactorDriver from "./driver/key_factor_driver"; import KeyFactorCardContainer from "./key_factor_card_container"; +import { ImpactVoteHandler } from "./key_factor_strength_item"; import KeyFactorVotePanels, { useKeyFactorVotePanels, } from "./key_factor_vote_panels"; @@ -62,8 +63,11 @@ export const KeyFactorItem: FC = ({ handleUpvotePanelToggle, handleDownvotePanelToggle, handleMorePanelToggle, + closeAllPanels, } = useKeyFactorVotePanels(); + const impactVoteRef = useRef(null); + return (
= ({ linkToComment={linkToComment} isCompact={isCompact} mode={mode} - onClick={onClick} + onClick={() => { + closeAllPanels(); + onClick?.(); + }} className={className} impactDirection={impactDirection} impactStrength={impactStrength} @@ -82,6 +89,7 @@ export const KeyFactorItem: FC = ({ keyFactor={liveKeyFactor} mode={mode} isCompact={isCompact} + impactVoteRef={impactVoteRef} onVotePanelToggle={handleUpvotePanelToggle} onDownvotePanelToggle={handleDownvotePanelToggle} onMorePanelToggle={handleMorePanelToggle} @@ -94,6 +102,7 @@ export const KeyFactorItem: FC = ({ isCompact={isCompact} mode={mode} isSuggested={isSuggested} + impactVoteRef={impactVoteRef} onVotePanelToggle={handleUpvotePanelToggle} onDownvotePanelToggle={handleDownvotePanelToggle} onMorePanelToggle={handleMorePanelToggle} @@ -105,6 +114,7 @@ export const KeyFactorItem: FC = ({ keyFactor={liveKeyFactor} mode={mode} isCompact={isCompact} + impactVoteRef={impactVoteRef} onVotePanelToggle={handleUpvotePanelToggle} onDownvotePanelToggle={handleDownvotePanelToggle} onMorePanelToggle={handleMorePanelToggle} @@ -122,6 +132,7 @@ export const KeyFactorItem: FC = ({ inline={inlineVotePanels} keyFactor={liveKeyFactor} projectPermission={projectPermission} + onImpactSelect={(option) => impactVoteRef.current?.(option)} />
); diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_card_container.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_card_container.tsx index ba01f02003..b7c9304acd 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_card_container.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_card_container.tsx @@ -58,7 +58,7 @@ const KeyFactorCardContainer: FC = ({ id={id} onClick={onClick} className={cn( - "relative flex gap-3 overflow-hidden rounded-xl p-5 [&:hover_.target]:visible", + "relative flex gap-3 overflow-hidden rounded-xl p-4 md:p-5 [&:hover_.target]:visible", linkToComment ? "border border-blue-400 bg-gray-0 dark:border-blue-400-dark dark:bg-gray-0-dark" : "bg-blue-200 dark:bg-blue-200-dark", diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_strength_item.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_strength_item.tsx index 3ec5eaed1b..eadf02c455 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_strength_item.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_strength_item.tsx @@ -2,7 +2,7 @@ import { faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { isNil } from "lodash"; -import { FC, PropsWithChildren, useMemo, useState } from "react"; +import { FC, PropsWithChildren, useCallback, useMemo, useState } from "react"; import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; import { voteKeyFactor } from "@/app/(main)/questions/actions"; @@ -21,6 +21,15 @@ import { KeyFactorImpactDirectionLabel } from "../item_creation/driver/impact_di import { convertNumericImpactToDirectionCategory } from "../utils"; import ThumbVoteButtons, { ThumbVoteSelection } from "./thumb_vote_buttons"; import { useOptimisticVote } from "./use_optimistic_vote"; +import { ImpactOption } from "./use_vote_panel"; + +const IMPACT_SCORE_MAP: Record = { + low: StrengthValues.LOW, + medium: StrengthValues.MEDIUM, + high: StrengthValues.HIGH, +}; + +export type ImpactVoteHandler = (option: ImpactOption) => void; type Props = PropsWithChildren<{ keyFactor: KeyFactor; @@ -32,6 +41,7 @@ type Props = PropsWithChildren<{ onDownvotePanelToggle?: (open: boolean) => void; onMorePanelToggle?: (open: boolean) => void; isMorePanelOpen?: boolean; + impactVoteRef?: React.MutableRefObject; }>; const KeyFactorStrengthItem: FC = ({ @@ -45,6 +55,7 @@ const KeyFactorStrengthItem: FC = ({ onDownvotePanelToggle, onMorePanelToggle, isMorePanelOpen, + impactVoteRef, }) => { const { user } = useAuth(); const { setCurrentModal } = useModal(); @@ -59,7 +70,7 @@ const KeyFactorStrengthItem: FC = ({ }, [combinedKeyFactors, keyFactor.id, keyFactor.vote]); const isDirection = voteType === KeyFactorVoteTypes.DIRECTION; - const upScore = 5; + const upScore = isDirection ? 5 : StrengthValues.MEDIUM; const downScore = isDirection ? -5 : StrengthValues.NO_IMPACT; const directionCategory = @@ -74,6 +85,15 @@ const KeyFactorStrengthItem: FC = ({ const isCompactConsumer = mode === "consumer" && isCompact; const aggregatedData = aggregate?.aggregated_data ?? []; + const serverUpCount = isDirection + ? aggregatedData.find((a) => a.score === upScore)?.count ?? 0 + : aggregatedData + .filter((a) => a.score > 0) + .reduce((sum, a) => sum + a.count, 0); + const isUpVote = useCallback( + (v: number | null) => (isDirection ? v === upScore : v !== null && v > 0), + [isDirection, upScore] + ); const { vote: userVote, upCount, @@ -82,45 +102,88 @@ const KeyFactorStrengthItem: FC = ({ clearOptimistic, } = useOptimisticVote({ serverVote: aggregate?.user_vote ?? null, - serverUpCount: aggregatedData.find((a) => a.score === upScore)?.count ?? 0, + serverUpCount, serverDownCount: aggregatedData.find((a) => a.score === downScore)?.count ?? 0, upValue: upScore, downValue: downScore, + isUpVote, }); const selection: ThumbVoteSelection = - userVote === upScore ? "up" : userVote === downScore ? "down" : null; - - const submit = async (next: number | null) => { - if (!user) { - setCurrentModal({ type: "signin" }); - return; - } - if (user.is_bot || submitting) return; - setSubmitting(true); - setOptimistic(next); - - try { - const resp = await voteKeyFactor({ - id: keyFactor.id, - vote: next, - user: user.id, - vote_type: voteType, - }); - if (resp) { - const updated = resp as unknown as KeyFactorVoteAggregate; - setKeyFactorVote(keyFactor.id, updated); + userVote !== null && userVote > 0 + ? "up" + : userVote === downScore + ? "down" + : null; + + const submit = useCallback( + async (next: number | null) => { + if (!user) { + setCurrentModal({ type: "signin" }); + return; + } + if (user.is_bot || submitting) return; + setSubmitting(true); + setOptimistic(next); + + try { + const resp = await voteKeyFactor({ + id: keyFactor.id, + vote: next, + user: user.id, + vote_type: voteType, + }); + if (resp) { + const updated = resp as unknown as KeyFactorVoteAggregate; + setKeyFactorVote(keyFactor.id, updated); + } + } catch (e) { + console.error("Failed to vote key factor", e); + } finally { + clearOptimistic(); + setSubmitting(false); } - } catch (e) { - console.error("Failed to vote key factor", e); - } finally { - clearOptimistic(); - setSubmitting(false); - } + }, + [ + user, + submitting, + setOptimistic, + keyFactor.id, + voteType, + setKeyFactorVote, + clearOptimistic, + setCurrentModal, + ] + ); + + const toggle = (value: number) => { + const isCurrentlyUp = isUpVote(userVote); + const isCurrentlyDown = userVote === downScore; + const togglingUp = value > 0; + + if (togglingUp && isCurrentlyUp) return submit(null); + if (!togglingUp && isCurrentlyDown) return submit(null); + return submit(value); }; - const toggle = (value: number) => submit(userVote === value ? null : value); + const submitImpactVote = useCallback( + (option: ImpactOption) => { + if (!user || user.is_bot) return; + const currentVote = aggregate?.user_vote; + if (!currentVote || currentVote <= 0) return; + + const newScore = IMPACT_SCORE_MAP[option]; + if (newScore === currentVote) return; + + submit(newScore); + }, + [user, aggregate?.user_vote, submit] + ); + + if (impactVoteRef) { + impactVoteRef.current = submitImpactVote; + } return ( <> @@ -156,12 +219,16 @@ const KeyFactorStrengthItem: FC = ({ disabled={submitting} onClickUp={() => { toggle(upScore); - onVotePanelToggle?.(selection !== "up"); + if (user) { + onVotePanelToggle?.(selection !== "up"); + } }} onClickDown={() => { toggle(downScore); - onVotePanelToggle?.(false); - onDownvotePanelToggle?.(selection !== "down"); + if (user) { + onVotePanelToggle?.(false); + onDownvotePanelToggle?.(selection !== "down"); + } }} />
diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_vote_panels.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_vote_panels.tsx index 1f6dc90a9f..0e6036f181 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_vote_panels.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/key_factor_vote_panels.tsx @@ -20,8 +20,8 @@ const DOWNVOTE_REASONS: DownvoteReason[] = [ export function useKeyFactorVotePanels() { const impactPanel = useVotePanel(); - const downvotePanel = useVotePanel(); - const morePanel = useVotePanel(); + const downvotePanel = useVotePanel(impactPanel.anchorRef); + const morePanel = useVotePanel(impactPanel.anchorRef); const toggleExclusive = ( target: Pick, "setShowPanel">, @@ -34,8 +34,12 @@ export function useKeyFactorVotePanels() { } }; - const handleUpvotePanelToggle = (open: boolean) => + const handleUpvotePanelToggle = (open: boolean) => { toggleExclusive(impactPanel, [downvotePanel, morePanel], open); + if (open) { + impactPanel.setSelectedOption("medium" as ImpactOption); + } + }; const handleDownvotePanelToggle = (open: boolean) => toggleExclusive(downvotePanel, [impactPanel, morePanel], open); @@ -43,6 +47,12 @@ export function useKeyFactorVotePanels() { const handleMorePanelToggle = (open: boolean) => toggleExclusive(morePanel, [impactPanel, downvotePanel], open); + const closeAllPanels = () => { + impactPanel.setShowPanel(false); + downvotePanel.setShowPanel(false); + morePanel.setShowPanel(false); + }; + return { impactPanel, downvotePanel, @@ -50,6 +60,7 @@ export function useKeyFactorVotePanels() { handleUpvotePanelToggle, handleDownvotePanelToggle, handleMorePanelToggle, + closeAllPanels, }; } @@ -62,6 +73,7 @@ type KeyFactorVotePanelsProps = { inline?: boolean; keyFactor?: KeyFactor; projectPermission?: ProjectPermissions; + onImpactSelect?: (option: ImpactOption) => void; }; const KeyFactorVotePanels: FC = ({ @@ -73,6 +85,7 @@ const KeyFactorVotePanels: FC = ({ inline, keyFactor, projectPermission, + onImpactSelect, }) => { const t = useTranslations(); @@ -87,10 +100,13 @@ const KeyFactorVotePanels: FC = ({ isCompact={isCompact} inline={inline} anchorRef={anchorRef} - onSelect={impactPanel.toggleOption} + onSelect={(option) => { + impactPanel.toggleOption(option); + onImpactSelect?.(option); + }} onClose={impactPanel.closePanel} renderLabel={(option) => capitalize(t(option))} - buttonClassName={!isCompact ? "py-[11px]" : undefined} + buttonClassName={!isCompact ? "sm:py-[11px]" : undefined} /> )} diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx index b5eeff591c..0e8bcf7f56 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/more_panel.tsx @@ -7,7 +7,9 @@ import { useKeyFactorDelete, useKeyFactorModeration, } from "@/app/(main)/questions/[id]/components/key_factors/hooks"; +import { useQuestionLayoutSafe } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; import { useAuth } from "@/contexts/auth_context"; +import { useModal } from "@/contexts/modal_context"; import { KeyFactor } from "@/types/comment"; import { ProjectPermissions } from "@/types/post"; import cn from "@/utils/core/cn"; @@ -46,7 +48,9 @@ const MorePanel: FC = ({ const t = useTranslations(); const locale = useLocale(); const { user } = useAuth(); + const { setCurrentModal } = useModal(); + const questionLayout = useQuestionLayoutSafe(); const isAdmin = projectPermission === ProjectPermissions.ADMIN; const { openDeleteModal } = useKeyFactorDelete(); const { reportSpam, openDispute } = useKeyFactorModeration(); @@ -60,11 +64,14 @@ const MorePanel: FC = ({ ); const handleViewComment = () => { - const el = document.getElementById(`comment-${keyFactor.comment_id}`); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - } onClose(); + questionLayout?.closeKeyFactorOverlay(); + setTimeout(() => { + const el = document.getElementById(`comment-${keyFactor.comment_id}`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, 100); }; const actions: ActionItem[] = [ @@ -74,15 +81,23 @@ const MorePanel: FC = ({ { label: t("dispute"), onClick: () => { - openDispute(keyFactor); onClose(); + if (!user) { + setCurrentModal({ type: "signin" }); + return; + } + openDispute(keyFactor); }, }, { label: t("reportSpam"), onClick: () => { - reportSpam(keyFactor); onClose(); + if (!user) { + setCurrentModal({ type: "signin" }); + return; + } + reportSpam(keyFactor); }, }, ] diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/news/key_factor_news_item.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/news/key_factor_news_item.tsx index ae54aa76bc..d0b94cf70f 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/news/key_factor_news_item.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/news/key_factor_news_item.tsx @@ -1,6 +1,7 @@ "use client"; import { faNewspaper } from "@fortawesome/free-regular-svg-icons"; +import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useLocale } from "next-intl"; import React from "react"; @@ -52,69 +53,87 @@ const KeyFactorNewsItem: React.FC = ({ }; return ( - ); }; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/panel_container.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/panel_container.tsx index a73d43ea8d..2052d293ea 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/panel_container.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/panel_container.tsx @@ -27,7 +27,7 @@ function getAnchorStyle( top: rect.bottom + 4, left: rect.left, width: rect.width, - zIndex: 400, + zIndex: 210, }; } diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/question_link/question_link_key_factor_item.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/question_link/question_link_key_factor_item.tsx index 9ae362d7d8..b43fe04812 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/question_link/question_link_key_factor_item.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/question_link/question_link_key_factor_item.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { FC, useEffect, useMemo, useState } from "react"; import BinaryCPBar from "@/components/consumer_post_card/binary_cp_bar"; @@ -50,6 +51,7 @@ const QuestionLinkKeyFactorItem: FC = ({ className, onClick, }) => { + const t = useTranslations(); const isConsumer = mode === "consumer"; const isCompactConsumer = isConsumer && compact; @@ -210,41 +212,43 @@ const QuestionLinkKeyFactorItem: FC = ({ className={cn("shadow-sm", className)} onClick={onClick} > -
-
- e.stopPropagation()} - className={cn( - "min-w-0 flex-1 font-medium text-gray-800 no-underline hover:underline dark:text-gray-800-dark", - compact ? "text-xs leading-4" : "text-sm leading-5" - )} - > - {otherQuestion.title} - - - {binaryForecastQuestion && ( -
-
- +
+ {t("questionLink")} +
+ + e.stopPropagation()} + className={cn( + "min-w-0 font-medium text-gray-800 no-underline hover:underline dark:text-gray-800-dark", + compact ? "text-xs leading-4" : "text-sm leading-5" + )} + > + {otherQuestion.title} + + + {binaryForecastQuestion && ( +
+
+ +
+ -
- -
- )} -
+
+ )} {impactCategory !== null && ( = { vote: V | null; @@ -19,12 +19,14 @@ export function useOptimisticVote({ serverDownCount, upValue, downValue, + isUpVote, }: { serverVote: V | null; serverUpCount: number; serverDownCount: number; upValue: V; downValue: V; + isUpVote?: (value: V | null) => boolean; }): OptimisticVoteResult { const [optimistic, setOptimisticRaw] = useState( undefined @@ -32,14 +34,19 @@ export function useOptimisticVote({ const vote = optimistic !== undefined ? optimistic : serverVote; + const matchUp = useCallback( + (v: V | null) => (isUpVote ? isUpVote(v) : v === upValue), + [isUpVote, upValue] + ); + const { upCount, downCount } = useMemo(() => { let up = serverUpCount; let down = serverDownCount; if (optimistic !== undefined) { - const wasUp = serverVote === upValue; + const wasUp = matchUp(serverVote); const wasDown = serverVote === downValue; - const isUp = optimistic === upValue; + const isUp = matchUp(optimistic); const isDown = optimistic === downValue; if (wasUp && !isUp) up = Math.max(0, up - 1); @@ -53,7 +60,7 @@ export function useOptimisticVote({ serverUpCount, serverDownCount, serverVote, - upValue, + matchUp, downValue, optimistic, ]); diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_panel.ts b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_panel.ts index 9777e80439..de7d12c28f 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_panel.ts +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_vote_panel.ts @@ -1,9 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { RefObject, useCallback, useEffect, useRef, useState } from "react"; export type ImpactOption = "low" | "medium" | "high"; export type DownvoteReason = "wrongDirection" | "noImpact" | "redundant"; -export function useVotePanel() { +export function useVotePanel( + excludeRef?: RefObject +) { const [showPanel, setShowPanel] = useState(false); const [selectedOption, setSelectedOption] = useState(null); const anchorRef = useRef(null); @@ -22,7 +24,12 @@ export function useVotePanel() { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; - if (panelRef.current && !panelRef.current.contains(target)) { + if ( + panelRef.current && + !panelRef.current.contains(target) && + (!anchorRef.current || !anchorRef.current.contains(target)) && + (!excludeRef?.current || !excludeRef.current.contains(target)) + ) { setShowPanel(false); } }; @@ -37,7 +44,7 @@ export function useVotePanel() { document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("scroll", handleScroll, true); }; - }, [showPanel]); + }, [showPanel, excludeRef]); return { showPanel, @@ -45,6 +52,7 @@ export function useVotePanel() { anchorRef, panelRef, setShowPanel, + setSelectedOption, closePanel, toggleOption, }; diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vertical_impact_bar.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vertical_impact_bar.tsx index 681c0d0a9d..d6d748640e 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vertical_impact_bar.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/vertical_impact_bar.tsx @@ -15,13 +15,13 @@ type VerticalImpactBarProps = { const SIZE_CONFIG = { default: { - width: 20, - iconSize: "text-[14px]", + widthClass: "w-3 md:w-5", + iconSize: "text-[10px] md:text-[14px]", radius: 2, lineOverhang: 2, }, narrow: { - width: 12, + widthClass: "w-3", iconSize: "text-[10px]", radius: 2, lineOverhang: 2, @@ -54,16 +54,24 @@ const HalfBar: FC<{ const emptyPercent = 100 - fillPercent; const isTop = position === "top"; + const outerRadius = isTop + ? `${radius}px ${radius}px 0 0` + : `0 0 ${radius}px ${radius}px`; + const innerRadius = Math.max(0, radius - 1); + const innerBorderRadius = isTop + ? `${innerRadius}px ${innerRadius}px 0 0` + : `0 0 ${innerRadius}px ${innerRadius}px`; + return (
@@ -74,7 +82,7 @@ const HalfBar: FC<{ inset: isTop ? `${emptyPercent}% 1px 1px 1px` : `1px 1px ${emptyPercent}% 1px`, - borderRadius: Math.max(0, radius - 1), + borderRadius: innerBorderRadius, }} /> )} @@ -82,17 +90,31 @@ const HalfBar: FC<{ ); }; -const EmptyHalf: FC<{ radius: number }> = ({ radius }) => ( -
+const EmptyHalf: FC<{ position: "top" | "bottom"; radius: number }> = ({ + position, + radius, +}) => { + const isTop = position === "top"; + const outerRadius = isTop + ? `${radius}px ${radius}px 0 0` + : `0 0 ${radius}px ${radius}px`; + const innerRadius = Math.max(0, radius - 1); + const innerBorderRadius = isTop + ? `${innerRadius}px ${innerRadius}px 0 0` + : `0 0 ${innerRadius}px ${innerRadius}px`; + + return (
-
-); + className="relative flex-1 bg-blue-500 dark:bg-blue-500-dark" + style={{ borderRadius: outerRadius }} + > +
+
+ ); +}; /** * Computes the clip-path for the white (on-fill) arrow. @@ -126,14 +148,14 @@ const VerticalImpactBar: FC = ({ strength, size = "default", }) => { - const { width, iconSize, radius, lineOverhang } = SIZE_CONFIG[size]; + const { widthClass, iconSize, radius, lineOverhang } = SIZE_CONFIG[size]; const fillPercent = (Math.max(0, Math.min(5, strength)) / 5) * 90; if (!direction) { return ( -
- - +
+ +
); } @@ -145,7 +167,7 @@ const VerticalImpactBar: FC = ({ const fillClip = getFillClipPath(direction, fillPercent); return ( -
+
({
@@ -70,7 +70,7 @@ function VotePanelInner({ onClick={() => onSelect(option)} className={cn( "rounded border text-xs font-medium leading-4 transition-colors", - direction === "row" && "flex-1", + direction === "row" && "sm:flex-1", isCompact ? "px-1.5 py-0.5" : "px-2 py-1", buttonClassName, isSelected diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx index e7c13dc19d..a1e56df9b4 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx @@ -19,6 +19,7 @@ import { VoteDirection } from "@/types/votes"; import CommentDetailPanel from "./comment_detail_panel"; import { KeyFactorItem } from "./item_view"; +import { useQuestionLayoutSafe } from "../question_layout/question_layout_context"; import QuestionLinkKeyFactorItem from "./item_view/question_link/question_link_key_factor_item"; import MobileKeyFactorOverlay from "./mobile_key_factor_overlay"; @@ -48,6 +49,7 @@ const KeyFactorDetailOverlay: FC = (props) => { const { post, onClose } = props; const t = useTranslations(); const { comments, ensureCommentLoaded, updateComment } = useCommentsFeed(); + const questionLayout = useQuestionLayoutSafe(); const isAboveSm = useBreakpoint("sm"); const keyFactor = props.keyFactor ?? null; @@ -125,6 +127,15 @@ const KeyFactorDetailOverlay: FC = (props) => { }, 200); }; + const handleReplyToComment = async () => { + if (!keyFactor) return; + await ensureCommentLoaded(keyFactor.comment_id); + onClose(); + setTimeout(() => { + questionLayout?.requestReplyToComment(keyFactor.comment_id); + }, 500); + }; + const hasComment = !!(keyFactor && (comment?.text?.trim() || !comment)); const isSimple = questionLink || @@ -171,7 +182,7 @@ const KeyFactorDetailOverlay: FC = (props) => { return ( -
+
e.stopPropagation()}> @@ -203,7 +214,7 @@ const KeyFactorDetailOverlay: FC = (props) => { return ( -
+
= (props) => { comment={comment} isLoading={!comment} onScrollToComment={handleScrollToComment} + onReplyToComment={handleReplyToComment} onSelectKeyFactor={props.onSelectKeyFactor} onVoteChange={handleVoteChange} onCmmToggle={handleCmmToggle} diff --git a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_comment_section.tsx b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_comment_section.tsx index 487d26fc6b..4b6df1a9ac 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_comment_section.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factors_comment_section.tsx @@ -7,7 +7,6 @@ import { useKeyFactorDelete } from "@/app/(main)/questions/[id]/components/key_f import { KeyFactorItem } from "@/app/(main)/questions/[id]/components/key_factors/item_view"; import KeyFactorsCarousel from "@/app/(main)/questions/[id]/components/key_factors/key_factors_carousel"; import { useShouldHideKeyFactors } from "@/app/(main)/questions/[id]/components/key_factors/use_should_hide_key_factors"; -import { openKeyFactorsSectionAndScrollTo } from "@/app/(main)/questions/[id]/components/key_factors/utils"; import { useQuestionLayoutSafe } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; import { useAuth } from "@/contexts/auth_context"; import { KeyFactor } from "@/types/comment"; @@ -57,13 +56,7 @@ const KeyFactorsCommentSection: FC = ({ gapClassName="gap-1" renderItem={(kf) => { const handleClick = () => { - questionLayout?.requestKeyFactorsExpand?.(); - - openKeyFactorsSectionAndScrollTo({ - selector: `[id="key-factor-${kf.id}"]`, - mobileOnly: false, - }); - + questionLayout?.openKeyFactorOverlay?.(kf); sendAnalyticsEvent("KeyFactorClick", { event_label: "fromComment", }); @@ -86,7 +79,7 @@ const KeyFactorsCommentSection: FC = ({
{canEdit && ( -
+
-
+
-

+

{post.title}

@@ -128,63 +152,70 @@ const MobileKeyFactorOverlay: FC = ({ )}
- + {t("keyFactor")} -
- {hasPrev && - (() => { - const prevKf = allPostKeyFactors[currentIndex - 1]; - return prevKf ? ( -
-
- -
+ {allPostKeyFactors.length > 1 ? ( +
+
+ {allPostKeyFactors.map((kf) => ( +
+
- ) : null; - })()} -
- {questionLink ? ( - - ) : ( - keyFactor && ( - +
+ ) : ( +
+
+ {questionLink ? ( + - ) - )} + ) : ( + keyFactor && ( + + ) + )} +
- {hasNext && - (() => { - const nextKf = allPostKeyFactors[currentIndex + 1]; - return nextKf ? ( -
-
- -
-
- ) : null; - })()} -
+ )} + + {keyFactor && + !hasComment && + isNewsKF(keyFactor) && + keyFactor.news && ( + + )} {keyFactor && hasComment && ( <> - + {t("comment")} diff --git a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_layout_context.tsx b/front_end/src/app/(main)/questions/[id]/components/question_layout/question_layout_context.tsx index 75641848c1..24a7e085ea 100644 --- a/front_end/src/app/(main)/questions/[id]/components/question_layout/question_layout_context.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/question_layout/question_layout_context.tsx @@ -11,12 +11,30 @@ import { } from "react"; import useHash from "@/hooks/use_hash"; +import { FetchedAggregateCoherenceLink } from "@/types/coherence"; +import { KeyFactor } from "@/types/comment"; + +type KeyFactorOverlayState = + | { kind: "keyFactor"; keyFactor: KeyFactor } + | { kind: "questionLink"; link: FetchedAggregateCoherenceLink } + | null; type QuestionLayoutContextValue = { // Key Factors Section UI State keyFactorsExpanded?: boolean; requestKeyFactorsExpand: () => void; + // Key Factor Overlay + keyFactorOverlay: KeyFactorOverlayState; + openKeyFactorOverlay: (kf: KeyFactor) => void; + openQuestionLinkOverlay: (link: FetchedAggregateCoherenceLink) => void; + closeKeyFactorOverlay: () => void; + + // Comment reply trigger + replyToCommentId: number | null; + requestReplyToComment: (commentId: number) => void; + clearReplyToComment: () => void; + // Mobile tab state mobileActiveTab?: string; setMobileActiveTab: (tab: string) => void; @@ -28,6 +46,8 @@ export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => { const hash = useHash(); const [keyFactorsExpanded, setKeyFactorsExpanded] = useState(); const [mobileActiveTab, setMobileActiveTab] = useState(); + const [keyFactorOverlay, setKeyFactorOverlay] = + useState(null); // Expand key factors section if URL hash points to it useEffect(() => { @@ -42,14 +62,55 @@ export const QuestionLayoutProvider = ({ children }: PropsWithChildren) => { setMobileActiveTab("key-factors"); }, []); + const openKeyFactorOverlay = useCallback((kf: KeyFactor) => { + setKeyFactorOverlay({ kind: "keyFactor", keyFactor: kf }); + }, []); + + const openQuestionLinkOverlay = useCallback( + (link: FetchedAggregateCoherenceLink) => { + setKeyFactorOverlay({ kind: "questionLink", link }); + }, + [] + ); + + const closeKeyFactorOverlay = useCallback(() => { + setKeyFactorOverlay(null); + }, []); + + const [replyToCommentId, setReplyToCommentId] = useState(null); + const requestReplyToComment = useCallback((commentId: number) => { + setReplyToCommentId(commentId); + }, []); + const clearReplyToComment = useCallback(() => { + setReplyToCommentId(null); + }, []); + const value = useMemo( () => ({ keyFactorsExpanded, requestKeyFactorsExpand, + keyFactorOverlay, + openKeyFactorOverlay, + openQuestionLinkOverlay, + closeKeyFactorOverlay, + replyToCommentId, + requestReplyToComment, + clearReplyToComment, mobileActiveTab, setMobileActiveTab, }), - [keyFactorsExpanded, requestKeyFactorsExpand, mobileActiveTab] + [ + keyFactorsExpanded, + requestKeyFactorsExpand, + keyFactorOverlay, + openKeyFactorOverlay, + openQuestionLinkOverlay, + closeKeyFactorOverlay, + replyToCommentId, + requestReplyToComment, + clearReplyToComment, + mobileActiveTab, + ] ); return ( diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index 1f27d549a8..7a65d326fc 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -17,6 +17,7 @@ import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider" import KeyFactorsAddInComment from "@/app/(main)/questions/[id]/components/key_factors/add_in_comment/key_factors_add_in_comment"; import KeyFactorsCommentSection from "@/app/(main)/questions/[id]/components/key_factors/key_factors_comment_section"; import { useKeyFactorsCtx } from "@/app/(main)/questions/[id]/components/key_factors/key_factors_context"; +import { useQuestionLayoutSafe } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; import { createForecasts, editComment, @@ -236,7 +237,22 @@ const Comment: FC = ({ const originalTextRef = useRef(comment.text); const [isDeleted, setIsDeleted] = useState(comment.is_soft_deleted); const [isLoading, setIsLoading] = useState(false); + const questionLayout = useQuestionLayoutSafe(); const [isReplying, setIsReplying] = useState(false); + const replyEditorRef = useRef(null); + + useEffect(() => { + if (questionLayout?.replyToCommentId === comment.id) { + setIsReplying(true); + questionLayout.clearReplyToComment(); + requestAnimationFrame(() => { + replyEditorRef.current?.scrollIntoView({ + behavior: "smooth", + block: "end", + }); + }); + } + }, [questionLayout?.replyToCommentId, comment.id, questionLayout]); const [errorMessage, setErrorMessage] = useState(); const [commentMarkdown, setCommentMarkdown] = useState(comment.text); const [tempCommentMarkdown, setTempCommentMarkdown] = useState(""); @@ -1039,18 +1055,20 @@ const Comment: FC = ({ )}
{isReplying && ( - { - addNewChildrenComment(comment, newComment); - setIsReplying(false); - }} - isReplying={isReplying} - shouldIncludeForecast={canIncludeForecastInReply} - userPermission={postData?.user_permission} - /> +
+ { + addNewChildrenComment(comment, newComment); + setIsReplying(false); + }} + isReplying={isReplying} + shouldIncludeForecast={canIncludeForecastInReply} + userPermission={postData?.user_permission} + /> +
)} {isKeyfactorsFormOpen && postData && ( number | Promise; onFinalize: (tempId: number, real: CommentType) => void; onRemove: (tempId: number) => void; + onSubmitted?: () => void; }; const scrollIntoViewById = (domId: string, opts?: ScrollIntoViewOptions) => { @@ -36,6 +37,7 @@ const DisputeKeyFactorModal: React.FC = ({ onOptimisticAdd, onFinalize, onRemove, + onSubmitted, }) => { const t = useTranslations(); const [text, setText] = useState(""); @@ -51,8 +53,9 @@ const DisputeKeyFactorModal: React.FC = ({ tempId = await onOptimisticAdd(text); const be = await replyToComment(parentCommentId, postId, text); const real: CommentType = { ...parseComment(be), children: [] }; - setTimeout(() => scrollIntoViewById(`comment-${real.id}`), 40); onClose(); + onSubmitted?.(); + setTimeout(() => scrollIntoViewById(`comment-${real.id}`), 200); onFinalize(tempId, real); } catch { diff --git a/front_end/src/components/global_modals.tsx b/front_end/src/components/global_modals.tsx index 328fc7ea66..6a7e38ca0c 100644 --- a/front_end/src/components/global_modals.tsx +++ b/front_end/src/components/global_modals.tsx @@ -150,6 +150,7 @@ const GlobalModals: FC = () => { onOptimisticAdd={currentModal.data.onOptimisticAdd} onFinalize={currentModal.data.onFinalize} onRemove={currentModal.data.onRemove} + onSubmitted={currentModal.data.onSubmitted} /> )} {isModal(currentModal, "copyQuestionLink") && currentModal.data && ( diff --git a/front_end/src/components/ui/expandable_content.tsx b/front_end/src/components/ui/expandable_content.tsx index cd783fdf1b..ddee5a3540 100644 --- a/front_end/src/components/ui/expandable_content.tsx +++ b/front_end/src/components/ui/expandable_content.tsx @@ -72,18 +72,19 @@ const ExpandableContent: FC> = ({
{children}
{ + userInteractedRef.current = true; + setIsExpanded(true); + }} />
number | Promise; onFinalize: (tempId: number, real: CommentType) => void; onRemove: (tempId: number) => void; + onSubmitted?: () => void; }; copyQuestionLink: { fromQuestionTitle: string;