import { Localized } from "@fluent/react/compat" ; import cn from "classnames" ; import { EventEmitter2 } from "eventemitter2" ; import { clearLongTimeout , LongTimeout , setLongTimeout } from "long-settimeout" ; import React , { FunctionComponent , MouseEvent , useCallback , useEffect , useState , } from "react" ; import { graphql } from "react-relay" ; import { isBeforeDate } from "coral-common/utils" ; import { getURLWithCommentID } from "coral-framework/helpers" ; import { useToggleState } from "coral-framework/hooks" ; import { withContext } from "coral-framework/lib/bootstrap" ; import { MutationProp , useMutation } from "coral-framework/lib/relay" ; import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer" ; import { GQLFEATURE_FLAG , GQLSTORY_MODE , GQLTAG , GQLUSER_STATUS , } from "coral-framework/schema" ; import { PropTypesOf } from "coral-framework/types" ; import CLASSES from "coral-stream/classes" ; import { ShowAuthPopupMutation , withShowAuthPopupMutation , } from "coral-stream/common/AuthPopup" ; import { SetTraversalFocus } from "coral-stream/common/KeyboardShortcuts/SetTraversalFocus" ; import { MAX_REPLY_INDENT_DEPTH } from "coral-stream/constants" ; import { ShowEditFormEvent , ShowReplyFormEvent , ViewConversationEvent , } from "coral-stream/events" ; import { SetCommentIDMutation } from "coral-stream/mutations" ; import { Ability , can } from "coral-stream/permissions" ; import { Button , Flex , Hidden , HorizontalGutter , Icon , RelativeTime , } from "coral-ui/components/v2" ; import MatchMedia from "coral-ui/components/v2/MatchMedia" ; import { CommentContainer_comment as CommentData } from "coral-stream/__generated__/CommentContainer_comment.graphql" ; import { CommentContainer_settings as SettingsData } from "coral-stream/__generated__/CommentContainer_settings.graphql" ; import { CommentContainer_story as StoryData } from "coral-stream/__generated__/CommentContainer_story.graphql" ; import { CommentContainer_viewer as ViewerData } from "coral-stream/__generated__/CommentContainer_viewer.graphql" ; import { useCommentSeen , useCommentSeenEnabled } from "../commentSeen" ; import { isPublished } from "../helpers" ; import AnsweredTag from "./AnsweredTag" ; import { ArchivedReportFlowContainer } from "./ArchivedReportFlow" ; import AuthorBadges from "./AuthorBadges" ; import ButtonsBar from "./ButtonsBar" ; import computeCommentElementID from "./computeCommentElementID" ; import EditCommentFormContainer from "./EditCommentForm" ; import FeaturedTag from "./FeaturedTag" ; import { isReplyFlattened } from "./flattenReplies" ; import IndentedComment from "./IndentedComment" ; import MediaSectionContainer from "./MediaSection/MediaSectionContainer" ; import CaretContainer , { ModerationRejectedTombstoneContainer , } from "./ModerationDropdown" ; import PermalinkButtonContainer from "./PermalinkButton" ; import ReactionButtonContainer from "./ReactionButton" ; import RemoveAnswered from "./RemoveAnswered" ; import ReplyButton from "./ReplyButton" ; import ReplyCommentFormContainer from "./ReplyCommentForm" ; import ReportFlowContainer , { ReportButton } from "./ReportFlow" ; import ShowConversationLink from "./ShowConversationLink" ; import { UsernameContainer , UsernameWithPopoverContainer } from "./Username" ; import UserTagsContainer , { commentHasTags } from "./UserTagsContainer" ; import styles from "./CommentContainer.css" ; interface Props { viewer : ViewerData | null ; enableJumpToParent ? : boolean ; comment : CommentData ; story : StoryData ; settings : SettingsData ; eventEmitter : EventEmitter2 ; indentLevel ? : number ; showAuthPopup : MutationProp < typeof ShowAuthPopupMutation > ; /**
     * localReply will integrate the mutation response into
     * localReplies
     */ localReply ? : boolean ; /** disableReplies will remove the ReplyButton */ disableReplies ? : boolean ; /** showConversationLink will render a link to the conversation */ showConversationLink ? : boolean ; highlight ? : boolean ; className ? : string ; hideAnsweredTag ? : boolean ; hideReportButton ? : boolean ; hideModerationCarat ? : boolean ; collapsed ? : boolean ; toggleCollapsed ? : ( ) => void ; /**
     * Set true, if this is semantically an ancestor to another comment.
     * Will add appropiate aria label.
     */ ariaIsAncestor ? : boolean ; /**
     * Set true, if this is semantically a highlighted comment.
     * Will add appropiate aria label.
     */ ariaIsHighlighted ? : boolean ; showRemoveAnswered ? : boolean ; } export const CommentContainer = ( { className , collapsed , comment , disableReplies , hideAnsweredTag , hideModerationCarat , highlight , ariaIsAncestor , ariaIsHighlighted , indentLevel , localReply , settings , showConversationLink , hideReportButton , story , toggleCollapsed , eventEmitter , viewer , showAuthPopup , showRemoveAnswered , enableJumpToParent , } ) => { const commentSeenEnabled = useCommentSeenEnabled ( ) ; const seen = useCommentSeen ( viewer ?. id , comment . id ) ; const setTraversalFocus = useMutation ( SetTraversalFocus ) ; const handleFocus = useCallback ( ( ) => { void setTraversalFocus ( { commentID : comment . id , commentSeenEnabled , skipCommitSeen : seen , } ) ; } , [ comment . id , commentSeenEnabled , seen , setTraversalFocus ] ) ; const setCommentID = useMutation ( SetCommentIDMutation ) ; const [ showReplyDialog , setShowReplyDialog ] = useState ( false ) ; const [ showEditDialog , setShowEditDialog , toggleShowEditDialog , ] = useToggleState ( false ) ; const [ showReportFlow , , toggleShowReportFlow ] = useToggleState ( false ) ; const handleShowConversation = useCallback ( ( e ) => { ViewConversationEvent . emit ( eventEmitter , { commentID : comment . id , from : "COMMENT_STREAM" , } ) ; if ( settings . featureFlags . includes ( GQLFEATURE_FLAG . READ_MORE_NEW_TAB ) ) { return ; } e . preventDefault ( ) ; void setCommentID ( { id : comment . id } ) ; return false ; } , [ eventEmitter , comment . id , settings . featureFlags , setCommentID ] ) ; const isLoggedIn = ! ! viewer ; const openEditDialog = useCallback ( ( ) => { if ( isLoggedIn ) { ShowEditFormEvent . emit ( eventEmitter , { commentID : comment . id , } ) ; setShowEditDialog ( true ) ; } else { void showAuthPopup ( { view : "SIGN_IN" } ) ; } } , [ isLoggedIn , eventEmitter , comment . id , setShowEditDialog , showAuthPopup ] ) ; const toggleShowReplyDialog = useCallback ( ( ) => { if ( isLoggedIn ) { if ( ! showReplyDialog ) { ShowReplyFormEvent . emit ( eventEmitter , { commentID : comment . id , } ) ; } setShowReplyDialog ( ( v ) => ! v ) ; } else { void showAuthPopup ( { view : "SIGN_IN" } ) ; } } , [ isLoggedIn , showReplyDialog , eventEmitter , comment . id , showAuthPopup ] ) ; const isViewerBanned = ! ! viewer ?. status . current . includes ( GQLUSER_STATUS . BANNED ) ; const isViewerSuspended = ! ! viewer ?. status . current . includes ( GQLUSER_STATUS . SUSPENDED ) ; const isViewerWarned = ! ! viewer ?. status . current . includes ( GQLUSER_STATUS . WARNED ) ; const isViewerScheduledForDeletion = ! ! viewer ?. scheduledDeletionDate ; const isViewerComment = ! ! viewer && ! ! comment . author && viewer . id === comment . author . id ; const [ editable , setEditable ] = useState ( ( ) => { if ( ! isViewerComment || isViewerBanned || isViewerSuspended || isViewerWarned ) { return false ; } if ( story . isArchiving || story . isArchived ) { return false ; } if ( ! comment . editing . editableUntil || ! isBeforeDate ( comment . editing . editableUntil ) ) { return false ; } if ( ! comment . body && comment . rating && ! comment . tags . find ( ( t ) => t . code === GQLTAG . REVIEW ) ) { return false ; } return true ; } ) ; useEffect ( ( ) => { if ( ! editable ) { return ; } const editableFor = new Date ( comment . editing . editableUntil ! ) . getTime ( ) - Date . now ( ) ; if ( editableFor <= 0 ) { setEditable ( false ) ; return ; } const timeout = setLongTimeout ( ( ) => { setEditable ( false ) ; } , editableFor ) ; return ( ) => { clearLongTimeout ( timeout ) ; } ; } , [ comment . editing . editableUntil , editable ] ) ; const hasFeaturedTag = comment . tags . some ( ( t ) => t . code === GQLTAG . FEATURED ) ; const isQA = story . settings . mode === GQLSTORY_MODE . QA ; const isRatingsAndReviews = story . settings . mode === GQLSTORY_MODE . RATINGS_AND_REVIEWS ; const authorIsExpert = isQA && comment . tags . some ( ( t ) => t . code === GQLTAG . EXPERT ) ; const removeAnswered = ! comment . deleted && isQA && authorIsExpert && indentLevel === 1 && ! ! showRemoveAnswered ; const showAvatar = settings . featureFlags . includes ( GQLFEATURE_FLAG . AVATARS ) ; const hasAnsweredTag = ! hideAnsweredTag && isQA && comment . tags . every ( ( t ) => t . code !== GQLTAG . UNANSWERED ) && ! comment . parent ; const commentTags = ( < > { hasFeaturedTag && ! isQA && < FeaturedTag collapsed = { collapsed } / > } { hasAnsweredTag && isQA && < AnsweredTag collapsed = { collapsed } />} < />); const showModerationCaret = ! ! viewer && story . canModerate && can ( viewer , Ability . MODERATE ) && ! hideModerationCarat && ! story . isArchiving && ! story . isArchived ; if ( showEditDialog ) { return ( < div data - testid = { `comment-${ comment . id }` } > < EditCommentFormContainer settings = { settings } comment = { comment } story = { story } onClose = { toggleShowEditDialog } / > < /div>); } const hasTags = commentHasTags ( story , comment ) ; const badges = ! ! comment && ! ! comment . author && ! ! comment . author . badges && comment . author . badges . length > 0 ? comment . author . badges : null ; const badgesJoin = badges ?. map ( ( b ) => `coral-badge-${ b . replace ( " " , "-" ) }` ) . join ( " " ) ; const badgesClassName = badgesJoin ? badgesJoin : "" ; if ( comment . lastViewerAction === "REJECT" && comment . status === "REJECTED" ) { return ( < ModerationRejectedTombstoneContainer comment = { comment } settings = { settings } / > ) ; } if ( comment . lastViewerAction === "EDIT" && ! isPublished ( comment . status ) ) { return null ; } const commentElementID = computeCommentElementID ( comment . id ) ; const shouldApplyNotSeenClass = ! seen && ! highlight && comment . lastViewerAction !== "CREATE" && comment . lastViewerAction !== "EDIT" ; const shouldApplyFocusClass = comment . hasTraversalFocus && commentSeenEnabled ; return ( < div className = { cn ( styles . root , className , CLASSES . comment . $root , `${ CLASSES . comment . reacted }-${ comment . actionCounts . reaction . total }` , badgesClassName ) } tabIndex = { - 1 } id = { commentElementID } role = "article" aria - labelledby = { `${ commentElementID }-label` } data - testid = { commentElementID } data - key - stop data - not - seen = { seen || ! commentSeenEnabled ? undefined : true } onFocus = { handleFocus } > { } < Hidden id = { `${ commentElementID }-label` } > { indentLevel && ( < > < Localized id = "comments-commentContainer-threadLevelLabel" $level = { indentLevel } > < span > Thread Level { indentLevel } : < /span> < /Localized>{" "} < />)} { ariaIsHighlighted && ( < > < Localized id = "comments-commentContainer-highlightedLabel" > < span > Highlighted : < /span> < /Localized>{" "} < />)} { ariaIsAncestor && ( < > < Localized id = "comments-commentContainer-ancestorLabel" > < span > Ancestor : < /span> < /Localized>{" "} < />)} { comment . parent && ( < Localized id = "comments-commentContainer-replyLabel" $username = { comment . author ?. username } RelativeTime = { < RelativeTime date = { comment . createdAt } / > } > < span > Reply from { comment . author ?. username } { " " } < RelativeTime date = { comment . createdAt } /> < /span> < /Localized>)} { ! comment . parent && isQA && ( < Localized id = "comments-commentContainer-questionLabel" $username = { comment . author ?. username } RelativeTime = { < RelativeTime date = { comment . createdAt } / > } > < span > Question from { comment . author ?. username } { " " } < RelativeTime date = { comment . createdAt } /> < /span> < /Localized>)} { ! comment . parent && ! isQA && ( < Localized id = "comments-commentContainer-commentLabel" $username = { comment . author ?. username } RelativeTime = { < RelativeTime date = { comment . createdAt } / > } > < span > Comment from { comment . author ?. username } { " " } < RelativeTime date = { comment . createdAt } /> < /span> < /Localized>)} < /Hidden> < HorizontalGutter > < IndentedComment enableJumpToParent = { enableJumpToParent } classNameIndented = { cn ( { [ styles . indented ] : indentLevel && indentLevel > 0 , [ styles . commentSeenEnabled ] : commentSeenEnabled , [ styles . notSeen ] : shouldApplyNotSeenClass , [ styles . flattenedPadding ] : isReplyFlattened ( settings . flattenReplies , indentLevel ) , [ CLASSES . comment . notSeen ] : shouldApplyNotSeenClass , [ styles . traversalFocus ] : shouldApplyFocusClass , [ CLASSES . comment . focus ] : shouldApplyFocusClass , } ) } indentLevel = { indentLevel } collapsed = { collapsed } body = { comment . body } rating = { isRatingsAndReviews ? comment . rating : null } createdAt = { comment . createdAt } blur = { ! ! comment . pending } showEditedMarker = { comment . editing . edited } highlight = { highlight } toggleCollapsed = { toggleCollapsed } parent = { comment . parent } staticUsername = { comment . author && ( < Flex direction = "row" alignItems = "center" wrap > < UsernameContainer className = { cn ( styles . staticUsername , CLASSES . comment . topBar . username ) } comment = { comment } / > < UserTagsContainer className = { CLASSES . comment . topBar . userTag } story = { story } comment = { comment } settings = { settings } / > { badges && ( < AuthorBadges className = { CLASSES . comment . topBar . userBadge } badges = { badges } / > ) } < /Flex>)} username={comment.author && (<UsernameWithPopoverContainer className={cn(styles.usernameButton, CLASSES.comment.topBar.username)} comment={comment} viewer={viewer} settings={settings}/ > ) } tags = { comment . author && hasTags && ( < UserTagsContainer className = { CLASSES . comment . topBar . userTag } story = { story } comment = { comment } settings = { settings } / > ) } badges = { comment . author && badges && ( < AuthorBadges className = { CLASSES . comment . topBar . userBadge } badges = { badges } / > ) } staticTopBarRight = { commentTags } topBarRight = { < > < MatchMedia gteWidth = "mobile" > { ( matches ) => ( < > < Flex alignItems = "center" justifyContent = "flex-end" itemGutter > { matches ? commentTags : null } { editable && ( < Button color = "stream" variant = "text" onClick = { openEditDialog } className = { cn ( CLASSES . comment . topBar . editButton , styles . editButton ) } data - testid = "comment-edit-button" > < Flex alignItems = "center" justifyContent = "center" > < Icon className = { styles . editIcon } > edit < /Icon> < Localized id = "comments-commentContainer-editButton" > Edit < /Localized> < /Flex> < /Button>)} { showAvatar && comment . author ?. avatar && ( < div className = { styles . avatarContainer } > < Localized id = "comments-commentContainer-avatar" attrs = { { alt : true } } $username = { comment . author . username } > < img src = { comment . author . avatar } className = { styles . avatar } loading = "lazy" referrerPolicy = "no-referrer" alt = { `Avatar for ${ comment . author . username }` } /> < /Localized> < /div>)} { showModerationCaret && ( < CaretContainer comment = { comment } story = { story } viewer = { viewer ! } settings = { settings } / > ) } < /Flex> { ! matches ? commentTags : null } < />)} < /MatchMedia> < />} media={<MediaSectionContainer comment={comment} settings={settings} defaultExpanded={viewer?.mediaSettings?.unfurlEmbeds}/ > } footer = { < > < Flex justifyContent = "space-between" className = { CLASSES . comment . actionBar . $root } > < ButtonsBar className = { styles . actionBar } > < ReactionButtonContainer comment = { comment } settings = { settings } viewer = { viewer } readOnly = { isViewerBanned || isViewerSuspended || isViewerWarned || story . isArchived || story . isArchiving } className = { cn ( styles . actionButton , CLASSES . comment . actionBar . reactButton ) } reactedClassName = { cn ( styles . actionButton , CLASSES . comment . actionBar . reactedButton ) } isQA = { story . settings . mode === GQLSTORY_MODE . QA } / > { ! disableReplies && ! isViewerBanned && ! isViewerSuspended && ! isViewerWarned && ! isViewerScheduledForDeletion && ( < ReplyButton id = { `comments-commentContainer-replyButton-${ comment . id }` } author = { comment . author ?. username } onClick = { toggleShowReplyDialog } active = { showReplyDialog } disabled = { settings . disableCommenting . enabled || story . isClosed || story . isArchived || story . isArchiving } className = { cn ( styles . actionButton , CLASSES . comment . actionBar . replyButton ) } / > ) } < PermalinkButtonContainer story = { story } commentID = { comment . id } author = { comment . author ?. username } className = { cn ( styles . actionButton , CLASSES . comment . actionBar . shareButton ) } / > < /ButtonsBar> < ButtonsBar > { ! isViewerBanned && ! isViewerSuspended && ! isViewerWarned && ! hideReportButton && ( < ReportButton onClick = { toggleShowReportFlow } open = { showReportFlow } viewer = { viewer } comment = { comment } / > ) } < /ButtonsBar> < /Flex> { showConversationLink && ( < ShowConversationLink className = { CLASSES . comment . readMoreOfConversation } id = { `comments-commentContainer-showConversation-${ comment . id }` } onClick = { handleShowConversation } href = { getURLWithCommentID ( story . url , comment . id ) } / > ) } < />}/ > { showReportFlow && ! story . isArchived && ! story . isArchiving && ( < ReportFlowContainer viewer = { viewer } comment = { comment } settings = { settings } onClose = { toggleShowReportFlow } / > ) } { showReportFlow && ( story . isArchived || story . isArchiving ) && ( < ArchivedReportFlowContainer settings = { settings } comment = { comment } / > ) } { showReplyDialog && ! comment . deleted && ( < ReplyCommentFormContainer settings = { settings } comment = { comment } story = { story } onClose = { toggleShowReplyDialog } localReply = { localReply } showJumpToComment = { Boolean ( indentLevel && indentLevel >= MAX_REPLY_INDENT_DEPTH - 1 && settings . flattenReplies ) } />)} { removeAnswered && ( < RemoveAnswered commentID = { comment . id } storyID = { story . id } / > ) } < /HorizontalGutter> < /div>); } ; const enhanced = withContext ( ( { eventEmitter } ) => ( { eventEmitter } ) ) ( withShowAuthPopupMutation ( withFragmentContainer < Props > ( { viewer : graphql `
        fragment CommentContainer_viewer on User {
          id
          status {
            current
          }
          ignoredUsers {
            id
          }
          badges
          role
          scheduledDeletionDate
          mediaSettings {
            unfurlEmbeds
          }
          ...UsernameWithPopoverContainer_viewer
          ...ReactionButtonContainer_viewer
          ...ReportFlowContainer_viewer
          ...ReportButton_viewer
          ...CaretContainer_viewer
        }
      ` , story : graphql `
        fragment CommentContainer_story on Story {
          id
          url
          isClosed
          canModerate
          settings {
            mode
          }
          isArchiving
          isArchived
          ...CaretContainer_story
          ...EditCommentFormContainer_story
          ...PermalinkButtonContainer_story
          ...ReplyCommentFormContainer_story
          ...UserTagsContainer_story
        }
      ` , comment : graphql `
        fragment CommentContainer_comment on Comment {
          id
          author {
            id
            username
            avatar
            badges
          }
          parent {
            id
            author {
              username
            }
          }
          body
          createdAt
          status
          rating
          editing {
            edited
            editableUntil
          }
          tags {
            code
          }
          pending
          lastViewerAction
          deleted
          actionCounts {
            reaction {
              total
            }
          }
          viewerActionPresence {
            dontAgree
            flag
          }
          hasTraversalFocus
          ...CaretContainer_comment
          ...EditCommentFormContainer_comment
          ...MediaSectionContainer_comment
          ...ReactionButtonContainer_comment
          ...ModerationRejectedTombstoneContainer_comment
          ...ReplyCommentFormContainer_comment
          ...ReportButton_comment
          ...ReportFlowContainer_comment
          ...UsernameContainer_comment
          ...UsernameWithPopoverContainer_comment
          ...UserTagsContainer_comment
          ...ArchivedReportFlowContainer_comment
        }
      ` , settings : graphql `
        fragment CommentContainer_settings on Settings {
          flattenReplies
          disableCommenting {
            enabled
          }
          featureFlags
          ...CaretContainer_settings
          ...EditCommentFormContainer_settings
          ...MediaSectionContainer_settings
          ...ReactionButtonContainer_settings
          ...ModerationRejectedTombstoneContainer_settings
          ...ReplyCommentFormContainer_settings
          ...ReportFlowContainer_settings
          ...UsernameWithPopoverContainer_settings
          ...UserTagsContainer_settings
          ...ArchivedReportFlowContainer_settings
        }
      ` , } ) ( CommentContainer ) ) ) ; export type CommentContainerProps = PropTypesOf < typeof enhanced > ; export default enhanced ;