projects/deliberation_at_scale/packages/frontend/components/ChatMessage.tsx (104 lines of code) (raw):

"use client"; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import dayjs from 'dayjs'; import ReactMarkdown from 'react-markdown'; import { motion } from 'framer-motion'; import { msg } from "@lingui/macro"; import useTheme, { ThemeColors } from '@/hooks/useTheme'; import { Message } from '@/types/flows'; import useProfile from '@/hooks/useProfile'; import { useCallback, useMemo } from 'react'; import { replaceTextVariables } from '@/utilities/text'; import classNames from 'classnames'; import { useLingui } from '@lingui/react'; const highlightedBgColorMap: Record<ThemeColors, string> = { 'blue': 'bg-blue-100', 'green': 'bg-green-100', 'orange': 'bg-orange-100', }; const headerTextColorMap: Record<ThemeColors, string> = { 'blue': 'text-blue-900', 'green': 'text-green-900', 'orange': 'text-orange-900', }; const bodyTextColorMap: Record<ThemeColors, string> = { 'blue': 'text-blue-900', 'green': 'text-green-900', 'orange': 'text-orange-900', }; interface Props { message: Message; enablePadding?: boolean; first?: boolean; last?: boolean; className?: string; } export default function ChatMessage(props: Props) { const { _ } = useLingui(); const { message, enablePadding = true, first = false, last = false, className } = props; const { content, name, nameIcon, date, highlighted = false, flagged = false, flaggedReason } = message; const theme = useTheme(); const { nickName } = useProfile(); const hasDate = !!date; const parsedDate = dayjs(date); const isToday = parsedDate.isSame(dayjs(), 'day'); const formattedDate = dayjs(date).format(isToday ? 'HH:mm' : 'DD/MM/YYYY HH:mm'); const wrapperClassName = classNames( `flex flex-col gap-1 md:gap-2 first:mt-4 -z-10 rounded transition-colors duration-1000 group shrink-1`, enablePadding && (highlighted ? 'p-[7px] pl-[8px] md:p-4' : 'px-[8px] md:px-4 pt-[5px] md:pt-2'), highlighted && highlightedBgColorMap[theme], first && 'rounded-t-xl', first && !highlighted && 'pt-[7px] md:pt-4', last && 'rounded-br-xl', last && !highlighted && 'pb-[7px] md:pb-4', className ); const variants = { hidden: { opacity: 0, y: 70, maxHeight: 0 }, visible: { opacity: 1, y: 0, maxHeight: 10_000 }, }; const replaceMessageVariables = useCallback((text: string) => { return replaceTextVariables(text, { nickName, }); }, [nickName]); const formattedContent = useMemo(() => { const flaggedContent = (!flagged || !flaggedReason) ? content : flaggedReason; return replaceMessageVariables(flaggedContent).trim(); }, [content, flaggedReason, flagged, replaceMessageVariables]); const formattedName = useMemo(() => { return replaceMessageVariables(name ?? _(msg`Contributor`)); }, [_, name, replaceMessageVariables]); // guard: skip when message is invalid if (!formattedContent) { return null; } return ( <motion.div layout className={wrapperClassName} variants={variants} initial="hidden" animate="visible" transition={{ duration: 0.15, bounce: 1 }} > {first && ( <div className={`flex justify-between text-sm uppercase opacity-70 ${highlighted ? headerTextColorMap[theme] : 'opacity-[.70]'} group-hover:opacity-90 transition-opacity`}> <div className="self-start inline-flex content-center items-center gap-2"> {nameIcon && ( <span><FontAwesomeIcon icon={nameIcon} /></span> )} <span>{formattedName}</span> </div> {hasDate && ( <div className="self-end"> {formattedDate} </div> )} </div> )} <div className={highlighted ? bodyTextColorMap[theme] : undefined}> <ReactMarkdown> {formattedContent} </ReactMarkdown> </div> </motion.div> ); }