projects/deliberation_at_scale/packages/frontend/components/RoomOutcome.tsx (148 lines of code) (raw):

'use client'; import { motion } from 'framer-motion'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; import ReactMarkdown from 'react-markdown'; import { msg } from "@lingui/macro"; import { statementSolid } from "./EntityIcons"; import Pill from "./Pill"; import { FullOutcomeFragment, OpinionOptionType, OpinionType, OutcomeType, FullParticipantFragment } from '@/generated/graphql'; import { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons'; import { useEffect, useMemo, useState } from 'react'; import Button from './Button'; import { DISABLE_OPINION_INPUT_WHEN_TIMED_OUT, OUTCOME_OPINION_TIMEOUT_MS_LOOKUP } from '@/utilities/constants'; import TimeProgressBar from './TimeProgressBar'; import useGiveOpinion from '@/hooks/useGiveOpinion'; import { useLingui } from '@lingui/react'; import classNames from 'classnames'; export interface OpinionOption { content: string; icon: IconProp; optionType: OpinionOptionType; } interface Props { outcome?: FullOutcomeFragment; participantId?: string; participants?: FullParticipantFragment[] variant?: "compact" | "spacious" } export default function RoomOutcome(props: Props) { const { _ } = useLingui(); const { outcome, participantId, participants, variant } = props; const { id: outcomeId, content = '', type } = outcome ?? {}; const { setOpinion, getExistingOpinion, getGroupOpinions } = useGiveOpinion({ subjects: [outcome], participantId }); const [selectedOpinionOptionType, setSelectedOpinionOptionType] = useState<OpinionOptionType | undefined | null>(); const existingOpinion = getExistingOpinion(outcomeId); const groupOpinions = getGroupOpinions(outcomeId); const [timeoutCompleted, setTimeoutCompleted] = useState(false); const title = useMemo(() => { switch (type) { case OutcomeType.Consensus: return _(msg`Consensus Proposal`); case OutcomeType.Milestone: return _(msg`Milestone`); case OutcomeType.OffTopic: return _(msg`Off Topic`); case OutcomeType.CrossPollination: return _(msg`Statement`); case OutcomeType.SeedStatement: return _(msg`Statement`); default: return _(msg`Statement`); } }, [type, _]); const timeoutMs = useMemo(() => { if (!type || !!existingOpinion) { return 0; } return OUTCOME_OPINION_TIMEOUT_MS_LOOKUP[type]; }, [type, existingOpinion]); const hasTimeout = timeoutMs > 0; const opinionOptions = useMemo(() => { switch (type) { case OutcomeType.Consensus: case OutcomeType.CrossPollination: case OutcomeType.SeedStatement: default: return [ { content: _(msg`Agree`), icon: faCheck, optionType: OpinionOptionType.AgreeConsensus, }, { content: _(msg`Disagree`), icon: faTimes, optionType: OpinionOptionType.DisagreeConsensus, }, { content: _(msg`Pass`), icon: faTimes, optionType: OpinionOptionType.Wrong, }, ]; } }, [type, _]); const hasOpinionOptions = (opinionOptions.length > 0); const formattedContent = content?.trim(); // handle updates from the database which should reset the optimistic selected option useEffect(() => { if (existingOpinion && existingOpinion.option_type === selectedOpinionOptionType) { setSelectedOpinionOptionType(null); } }, [existingOpinion, selectedOpinionOptionType]); if (!outcome) { return null; } return ( <motion.div layoutId={outcomeId} className="gap-2 md:gap-4 flex flex-col items-start" initial={{ opacity: 0, y: 40 }} animate={{ opacity: 1, y: 0 }} > <Pill icon={statementSolid} className="border-black hidden md:inline-flex">{title}</Pill> <span className="md:hidden font-bold">{title}:</span> <ReactMarkdown>{formattedContent}</ReactMarkdown> {hasOpinionOptions && ( <div className={classNames( "flex gap-1 md:gap-2 w-full flex-wrap", variant === "compact" && "flex-row", variant === "spacious" && "flex-col", variant === "spacious" && "flex-col" )}> {opinionOptions.map((option) => { const { content, icon, optionType } = option; const isSelected = (existingOpinion?.option_type === optionType && !selectedOpinionOptionType) || selectedOpinionOptionType === optionType; const isDisabled = (timeoutCompleted && DISABLE_OPINION_INPUT_WHEN_TIMED_OUT); const participantAmount = participants?.length ?? 0; const otherOptionOpinions = groupOpinions.filter((opinion) => opinion.option_type === optionType && opinion.participant_id != participantId); const progress = (participantAmount > 0) ? ((otherOptionOpinions.length + (isSelected ? 1 : 0)) / participantAmount) : 0; const onOptionClick = () => { setSelectedOpinionOptionType(optionType); setOpinion({ subjectId: outcomeId, type: OpinionType.Option, optionType, }); }; return ( <Button key={optionType} disabled={isDisabled} selected={isSelected} icon={icon} onClick={onOptionClick} className="flex-1" progress={progress} > {content} </Button> ); })} </div> )} {hasTimeout && ( <TimeProgressBar durationMs={timeoutMs} startReferenceTime={outcome.created_at} onIsCompleted={(isCompleted) => { setTimeoutCompleted(isCompleted); }} /> )} </motion.div> ); }