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)/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..01ee05080a --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/comment_detail_panel.tsx @@ -0,0 +1,143 @@ +"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; + onReplyToComment: () => 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, + onReplyToComment, + 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/hooks.ts b/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts index 2de286fdb2..ff002332f8 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/hooks.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useCoherenceLinksContext from "@/app/(main)/components/coherence_links_provider"; import { useCommentsFeed } from "@/app/(main)/components/comments_feed_provider"; +import { useQuestionLayoutSafe } from "@/app/(main)/questions/[id]/components/question_layout/question_layout_context"; import { addKeyFactorsToComment, createComment, @@ -485,6 +486,7 @@ export const useKeyFactorDelete = () => { 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 2553235e85..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,8 +1,8 @@ "use client"; -import dynamic from "next/dynamic"; -import { FC } from "react"; +import { FC, useRef } 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"; @@ -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"; @@ -25,6 +26,7 @@ type Props = { className?: string; projectPermission?: ProjectPermissions; isSuggested?: boolean; + inlineVotePanels?: boolean; }; function getImpactMetadata(keyFactor: KeyFactor): ImpactMetadata | null { @@ -41,13 +43,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, @@ -56,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} > - {keyFactor.driver && ( + {liveKeyFactor.driver && ( )} - {keyFactor.base_rate && ( + {liveKeyFactor.base_rate && ( )} - {keyFactor.news && ( + {liveKeyFactor.news && ( = ({ morePanel={morePanel} anchorRef={impactPanel.anchorRef} isCompact={isCompact} - keyFactor={keyFactor} + inline={inlineVotePanels} + keyFactor={liveKeyFactor} projectPermission={projectPermission} + onImpactSelect={(option) => impactVoteRef.current?.(option)} />
); }; - -export default dynamic(() => Promise.resolve(KeyFactorItem), { - ssr: false, -}); 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..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 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 44f66b817d..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 ( <> @@ -144,26 +207,31 @@ 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); + if (user) { + onVotePanelToggle?.(selection !== "up"); + } + }} + onClickDown={() => { + toggle(downScore); + if (user) { + 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..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, }; } @@ -59,8 +70,10 @@ type KeyFactorVotePanelsProps = { morePanel?: ReturnType>; anchorRef: RefObject; isCompact?: boolean; + inline?: boolean; keyFactor?: KeyFactor; projectPermission?: ProjectPermissions; + onImpactSelect?: (option: ImpactOption) => void; }; const KeyFactorVotePanels: FC = ({ @@ -69,8 +82,10 @@ const KeyFactorVotePanels: FC = ({ morePanel, anchorRef, isCompact, + inline, keyFactor, projectPermission, + onImpactSelect, }) => { const t = useTranslations(); @@ -83,11 +98,15 @@ const KeyFactorVotePanels: FC = ({ selectedOption={impactPanel.selectedOption} title={t("voteOnImpact")} 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} /> )} @@ -99,6 +118,7 @@ const KeyFactorVotePanels: FC = ({ title={t("why")} direction="column" isCompact={isCompact} + inline={inline} anchorRef={anchorRef} onSelect={downvotePanel.toggleOption} onClose={downvotePanel.closePanel} @@ -120,6 +140,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..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"; @@ -30,6 +32,7 @@ type Props = { projectPermission?: ProjectPermissions; anchorRef: RefObject; isCompact?: boolean; + inline?: boolean; onClose: () => void; }; @@ -39,12 +42,15 @@ const MorePanel: FC = ({ projectPermission, anchorRef, isCompact, + inline, onClose, }) => { 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(); @@ -58,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[] = [ @@ -72,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); }, }, ] @@ -104,6 +121,7 @@ const MorePanel: FC = ({ ref={ref} anchorRef={anchorRef} isCompact={isCompact} + inline={inline} onClose={onClose} > = ({ }; 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 61e479f4f0..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 @@ -2,7 +2,7 @@ import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { FC, PropsWithChildren, RefObject } from "react"; +import { CSSProperties, FC, PropsWithChildren, RefObject } from "react"; import { createPortal } from "react-dom"; import cn from "@/utils/core/cn"; @@ -11,12 +11,13 @@ type Props = PropsWithChildren<{ ref?: RefObject; 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: 210, }; } @@ -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..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"; @@ -35,6 +36,7 @@ type Props = { mode?: "forecaster" | "consumer"; linkToComment?: boolean; className?: string; + onClick?: () => void; }; const otherQuestionCache = new Map(); @@ -47,7 +49,9 @@ const QuestionLinkKeyFactorItem: FC = ({ mode = "forecaster", linkToComment = true, className, + onClick, }) => { + const t = useTranslations(); const isConsumer = mode === "consumer"; const isCompactConsumer = isConsumer && compact; @@ -206,41 +210,45 @@ const QuestionLinkKeyFactorItem: FC = ({ impactDirection={impactDirection} impactStrength={strengthScore} className={cn("shadow-sm", className)} + onClick={onClick} > -
-
- - {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 && ( = ({ )}
-
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/use_optimistic_vote.ts b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_optimistic_vote.ts index 60ed84f607..be66d987be 100644 --- a/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_optimistic_vote.ts +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/item_view/use_optimistic_vote.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; type OptimisticVoteResult = { 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 ( -
+
= { 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} > ({
@@ -67,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 new file mode 100644 index 0000000000..a1e56df9b4 --- /dev/null +++ b/front_end/src/app/(main)/questions/[id]/components/key_factors/key_factor_detail_overlay.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Dialog, DialogPanel, Transition } from "@headlessui/react"; +import { useTranslations } from "next-intl"; +import { FC, Fragment, useEffect, useMemo } from "react"; + +import { + findById, + useCommentsFeed, +} from "@/app/(main)/components/comments_feed_provider"; +import { useBreakpoint } from "@/hooks/tailwind"; +import { FetchedAggregateCoherenceLink } from "@/types/coherence"; +import { BECommentType, KeyFactor } from "@/types/comment"; +import { PostWithForecasts } from "@/types/post"; +import { QuestionType, QuestionWithNumericForecasts } from "@/types/question"; +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"; + +type KeyFactorOverlayProps = { + keyFactor: KeyFactor; + allKeyFactors: KeyFactor[]; + post: PostWithForecasts; + preloadedComment?: BECommentType | null; + 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 questionLayout = useQuestionLayoutSafe(); + 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 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 || + !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_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} +

+
+ {binaryQuestion && ( + + )} +
+ + + {t("keyFactor")} + + + {allPostKeyFactors.length > 1 ? ( +
+
+ {allPostKeyFactors.map((kf) => ( +
+ +
+ ))} +
+
+ ) : ( +
+
+ {questionLink ? ( + + ) : ( + keyFactor && ( + + ) + )} +
+
+ )} + + {keyFactor && + !hasComment && + isNewsKF(keyFactor) && + keyFactor.news && ( + + )} + + {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/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 && ( 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/dispute_key_factor_modal.tsx b/front_end/src/components/dispute_key_factor_modal.tsx index d2416e6798..ef705c622e 100644 --- a/front_end/src/components/dispute_key_factor_modal.tsx +++ b/front_end/src/components/dispute_key_factor_modal.tsx @@ -21,6 +21,7 @@ type Props = { onOptimisticAdd: (text: string) => 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 28e21ec488..ddee5a3540 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,23 +63,28 @@ const ExpandableContent: FC> = ({ } }, [forceState]); + useEffect(() => { + onExpandedChange?.(isExpanded); + }, [isExpanded, onExpandedChange]); + return (
{children}
{ + userInteractedRef.current = true; + setIsExpanded(true); + }} />
number | Promise; onFinalize: (tempId: number, real: CommentType) => void; onRemove: (tempId: number) => void; + onSubmitted?: () => void; }; copyQuestionLink: { fromQuestionTitle: string; 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}