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 cbd80289b2..ca015c6ae0 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; @@ -295,6 +299,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 @@ -326,6 +367,9 @@ const CommentsFeedProvider: FC< finalizeReply, removeTempReply, optimisticallyAddReplyEnsuringParent, + ensureCommentLoaded, + refreshComment, + updateComment, }} > {children} @@ -346,7 +390,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 2553235e85..63245d33ff 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 @@ -3,6 +3,7 @@ import dynamic from "next/dynamic"; 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"; @@ -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, @@ -71,9 +78,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}