diff --git a/.vscode/settings.json b/.vscode/settings.json index 601544d91..bf9fe873a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,5 +54,5 @@ "search.exclude": { "package-lock.json": true }, - "debug.node.autoAttach": "off" + "debug.node.autoAttach": "on" } diff --git a/src/core/client/stream/App/TabBar.tsx b/src/core/client/stream/App/TabBar.tsx index 669476bf6..d270b1557 100644 --- a/src/core/client/stream/App/TabBar.tsx +++ b/src/core/client/stream/App/TabBar.tsx @@ -1,6 +1,7 @@ import { Localized } from "@fluent/react/compat"; import React, { FunctionComponent } from "react"; +import { GQLSTORY_MODE } from "coral-framework/schema"; import CLASSES from "coral-stream/classes"; import { Icon, MatchMedia, Tab, TabBar } from "coral-ui/components"; @@ -11,6 +12,7 @@ export interface Props { onTabClick: (tab: TabValue) => void; showProfileTab: boolean; showConfigureTab: boolean; + mode: "%future added value" | "COMMENTS" | "QA" | null; } const AppTabBar: FunctionComponent = props => { @@ -21,9 +23,15 @@ const AppTabBar: FunctionComponent = props => { onTabClick={props.onTabClick} > - - Comments - + {props.mode === GQLSTORY_MODE.QA ? ( + + Q&A + + ) : ( + + Comments + + )} {props.showProfileTab && ( diff --git a/src/core/client/stream/App/TabBarContainer.tsx b/src/core/client/stream/App/TabBarContainer.tsx index dca25a6f8..0e17f2240 100644 --- a/src/core/client/stream/App/TabBarContainer.tsx +++ b/src/core/client/stream/App/TabBarContainer.tsx @@ -5,8 +5,10 @@ import { withFragmentContainer, withLocalStateContainer, } from "coral-framework/lib/relay"; +import { GQLSTORY_MODE } from "coral-framework/schema"; import { Ability, can } from "coral-stream/permissions"; +import { TabBarContainer_story } from "coral-stream/__generated__/TabBarContainer_story.graphql"; import { TabBarContainer_viewer as ViewerData } from "coral-stream/__generated__/TabBarContainer_viewer.graphql"; import { TabBarContainerLocal as Local } from "coral-stream/__generated__/TabBarContainerLocal.graphql"; @@ -18,6 +20,7 @@ import { import TabBar from "./TabBar"; interface Props { + story: TabBarContainer_story | null; viewer: ViewerData | null; local: Local; setActiveTab: SetActiveTabMutation; @@ -36,6 +39,11 @@ export class TabBarContainer extends Component { return ( { return ( query={graphql` - query TabBarQuery { + query TabBarQuery($storyID: ID, $storyURL: String) { viewer { ...TabBarContainer_viewer } + story(id: $storyID, url: $storyURL) { + ...TabBarContainer_story + } } `} variables={{ @@ -36,7 +39,12 @@ class TabBarQuery extends Component { return
{error.message}
; } - return ; + return ( + + ); }} /> ); diff --git a/src/core/client/stream/local/local.graphql b/src/core/client/stream/local/local.graphql index 67bc8b7f2..5e0845d82 100644 --- a/src/core/client/stream/local/local.graphql +++ b/src/core/client/stream/local/local.graphql @@ -23,6 +23,7 @@ enum COMMENTS_TAB { NONE FEATURED_COMMENTS ALL_COMMENTS + UNANSWERED_COMMENTS } enum COMMENT_VIEWER_ACTION { diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.css b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.css new file mode 100644 index 000000000..b00ce95a3 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.css @@ -0,0 +1,19 @@ +.tagIcon { + margin-right: var(--v2-spacing-1); +} + +.answeredTag { + color: #03AB61; + background: #E6FAF1; + + border-color: #03AB61; + border-style: solid; + border-radius: 2px; + border-width: 1px; + + text-transform: uppercase; +} + +.removeAnswered { + width: 100%; +} diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx index bfc89d7ed..d3cfd2c07 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.spec.tsx @@ -22,6 +22,9 @@ function createDefaultProps(add: DeepPartial = {}): Props { story: { url: "http://localhost/story", isClosed: false, + settings: { + mode: "COMMENTS", + }, }, comment: { id: "comment-id", @@ -53,6 +56,7 @@ function createDefaultProps(add: DeepPartial = {}): Props { setCommentID: noop as any, localReply: false, disableReplies: false, + onRemoveAnswered: undefined, }, add ); diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index 50b5f8e28..9478c6223 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -8,7 +8,7 @@ import { isBeforeDate } from "coral-common/utils"; import { getURLWithCommentID } from "coral-framework/helpers"; import { withContext } from "coral-framework/lib/bootstrap"; import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer"; -import { GQLTAG, GQLUSER_STATUS } from "coral-framework/schema"; +import { GQLSTORY_MODE, GQLTAG, GQLUSER_STATUS } from "coral-framework/schema"; import { PropTypesOf } from "coral-framework/types"; import CLASSES from "coral-stream/classes"; import { @@ -23,7 +23,7 @@ import { withShowAuthPopupMutation, } from "coral-stream/mutations"; import { Ability, can } from "coral-stream/permissions"; -import { Button, Flex, HorizontalGutter, Tag } from "coral-ui/components"; +import { Button, Flex, HorizontalGutter, Icon, Tag } from "coral-ui/components"; import { CommentContainer_comment as CommentData } from "coral-stream/__generated__/CommentContainer_comment.graphql"; import { CommentContainer_settings as SettingsData } from "coral-stream/__generated__/CommentContainer_settings.graphql"; @@ -47,6 +47,8 @@ import ShowConversationLink from "./ShowConversationLink"; import { UsernameWithPopoverContainer } from "./Username"; import UserTagsContainer from "./UserTagsContainer"; +import styles from "./CommentContainer.css"; + interface Props { viewer: ViewerData | null; comment: CommentData; @@ -67,6 +69,11 @@ interface Props { showConversationLink?: boolean; highlight?: boolean; className?: string; + + hideAnsweredTag?: boolean; + hideReportButton?: boolean; + hideModerationCarat?: boolean; + onRemoveAnswered?: () => void; } interface State { @@ -192,14 +199,40 @@ export class CommentContainer extends Component { highlight, viewer, className, + hideAnsweredTag, } = this.props; const { showReplyDialog, showEditDialog, editable } = this.state; const hasFeaturedTag = Boolean( comment.tags.find(t => t.code === GQLTAG.FEATURED) ); + // We are in a Q&A if the story mode is set to QA. + const isQA = Boolean(story.settings.mode === GQLSTORY_MODE.QA); + // Author is expert if comment is tagged Expert and the + // story mode is Q&A. + const authorIsExpert = + isQA && comment.tags.find(t => t.code === GQLTAG.EXPERT); + // Only show a button to clear removed answers if + // this comment is by an expert, reply to a top level + // comment (question) with an answer + const showRemoveAnswered = Boolean( + !comment.deleted && + isQA && + authorIsExpert && + indentLevel === 1 && + this.props.onRemoveAnswered + ); + // When we're in Q&A and we are not un-answered (answered) + // and we're a top level comment (no parent), then we + // are an answered question + const hasAnsweredTag = Boolean( + !hideAnsweredTag && + isQA && + comment.tags.every(t => t.code !== GQLTAG.UNANSWERED) && + !comment.parent + ); const commentTags = ( <> - {hasFeaturedTag && ( + {hasFeaturedTag && !isQA && ( { )} + {hasAnsweredTag && isQA && ( + + + + check + + answered + + + )} ); const banned = Boolean( @@ -224,7 +267,9 @@ export class CommentContainer extends Component { this.props.viewer && this.props.viewer.scheduledDeletionDate ); const showCaret = - this.props.viewer && can(this.props.viewer, Ability.MODERATE); + this.props.viewer && + can(this.props.viewer, Ability.MODERATE) && + !this.props.hideModerationCarat; if (showEditDialog) { return (
@@ -277,6 +322,7 @@ export class CommentContainer extends Component { /> @@ -327,6 +373,7 @@ export class CommentContainer extends Component { reactedClassName={ CLASSES.comment.actionBar.reactedButton } + isQA={story.settings.mode === GQLSTORY_MODE.QA} /> {!disableReplies && !banned && @@ -350,16 +397,18 @@ export class CommentContainer extends Component { /> - {!banned && !suspended && ( - - )} + {!banned && + !suspended && + !this.props.hideReportButton && ( + + )} {showConversationLink && ( @@ -386,6 +435,18 @@ export class CommentContainer extends Component { localReply={localReply} /> )} + {showRemoveAnswered && ( + + + + )}
); @@ -418,10 +479,14 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))( fragment CommentContainer_story on Story { url isClosed + settings { + mode + } ...CaretContainer_story ...ReplyCommentFormContainer_story ...PermalinkButtonContainer_story ...EditCommentFormContainer_story + ...UserTagsContainer_story } `, comment: graphql` diff --git a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButton.tsx b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButton.tsx index 2be159525..6a169e066 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButton.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButton.tsx @@ -1,3 +1,4 @@ +import { Localized } from "@fluent/react/compat"; import cn from "classnames"; import React from "react"; @@ -17,6 +18,7 @@ interface ReactionButtonProps { readOnly?: boolean; className?: string; color?: typeof styles & ButtonProps["color"]; + isQA?: boolean; } class ReactionButton extends React.Component { @@ -36,15 +38,29 @@ class ReactionButton extends React.Component { )} > - - {reacted - ? this.props.iconActive + {this.props.isQA ? ( + arrow_upward + ) : ( + + {reacted ? this.props.iconActive - : this.props.icon - : this.props.icon} - + ? this.props.iconActive + : this.props.icon + : this.props.icon} + + )} - {reacted ? this.props.labelActive : this.props.label} + {this.props.isQA ? ( + + {reacted ? ( + Voted + ) : ( + Vote + )} + + ) : ( + {reacted ? this.props.labelActive : this.props.label} + )} {!!totalReactions && {totalReactions}} ); diff --git a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButtonContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButtonContainer.tsx index 0fe2988cc..fc9780847 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButtonContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButtonContainer.tsx @@ -32,6 +32,7 @@ interface Props { readOnly?: boolean; className?: string; reactedClassName?: string; + isQA?: boolean; } class ReactionButtonContainer extends React.Component { @@ -85,6 +86,7 @@ class ReactionButtonContainer extends React.Component { icon={icon} iconActive={iconActive} readOnly={readOnly} + isQA={this.props.isQA} /> ) : null; } diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts index cd18f0d5b..61a38d824 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts @@ -39,7 +39,8 @@ function sharedUpdater( const commentEdge = store .getRootField("createCommentReply")! .getLinkedRecord("edge")!; - const status = commentEdge.getLinkedRecord("node")!.getValue("status"); + const node = commentEdge.getLinkedRecord("node")!; + const status = node.getValue("status"); // If comment is not published, we don't need to add it. if (!isPublished(status)) { @@ -134,6 +135,12 @@ const mutation = graphql` node { ...AllCommentsTabContainer_comment @relay(mask: false) status + parent { + id + tags { + code + } + } } } clientMutationId @@ -207,6 +214,7 @@ async function commit( author: parentComment.author ? pick(parentComment.author, "username", "id") : null, + tags: [], }, editing: { editableUntil: new Date(Date.now() + 10000).toISOString(), diff --git a/src/core/client/stream/tabs/Comments/Comment/UserTagsContainer.css b/src/core/client/stream/tabs/Comments/Comment/UserTagsContainer.css new file mode 100644 index 000000000..c7b664a60 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Comment/UserTagsContainer.css @@ -0,0 +1,7 @@ +.icon { + margin-right: var(--v2-spacing-1); +} + +.tag { + margin-right: var(--v2-spacing-1); +} diff --git a/src/core/client/stream/tabs/Comments/Comment/UserTagsContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserTagsContainer.tsx index 7b1f98e03..092742104 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserTagsContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserTagsContainer.tsx @@ -1,26 +1,50 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer"; -import { Tag } from "coral-ui/components"; +import { GQLSTORY_MODE } from "coral-framework/schema"; +import { Flex, Icon, Tag } from "coral-ui/components"; import { UserTagsContainer_comment } from "coral-stream/__generated__/UserTagsContainer_comment.graphql"; import { UserTagsContainer_settings } from "coral-stream/__generated__/UserTagsContainer_settings.graphql"; +import { UserTagsContainer_story } from "coral-stream/__generated__/UserTagsContainer_story.graphql"; + +import styles from "./UserTagsContainer.css"; interface Props { + story: UserTagsContainer_story; comment: UserTagsContainer_comment; settings: UserTagsContainer_settings; className?: string; } const UserTagsContainer: FunctionComponent = ({ + story, settings, comment, className, }) => { + const isQA = story.settings.mode === GQLSTORY_MODE.QA; const staffTag = comment.tags.find(t => t.code === "STAFF"); + const expertTag = isQA && comment.tags.find(t => t.code === "EXPERT"); return ( - <>{staffTag && {settings.staff.label}} + + {expertTag && ( + + + + star + + expert + + + )} + {staffTag && ( + {settings.staff.label} + )} + ); }; @@ -32,6 +56,13 @@ const enhanced = withFragmentContainer({ } } `, + story: graphql` + fragment UserTagsContainer_story on Story { + settings { + mode + } + } + `, settings: graphql` fragment UserTagsContainer_settings on Settings { staff { diff --git a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap index 927cac806..65e2b4cbe 100644 --- a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap @@ -41,6 +41,7 @@ exports[`hide reply button 1`] = ` "tags": Array [], } } + isQA={false} reactedClassName="coral-reactedButton coral-comment-reactedButton" readOnly={false} settings={ @@ -58,6 +59,9 @@ exports[`hide reply button 1`] = ` story={ Object { "isClosed": false, + "settings": Object { + "mode": "COMMENTS", + }, "url": "http://localhost/story", } } @@ -165,6 +169,15 @@ exports[`hide reply button 1`] = ` }, } } + story={ + Object { + "isClosed": false, + "settings": Object { + "mode": "COMMENTS", + }, + "url": "http://localhost/story", + } + } /> = ({ tags={ @@ -186,6 +187,7 @@ const enhanced = withContext(ctx => ({ fragment ConversationThreadContainer_story on Story { ...CommentContainer_story ...LocalReplyListContainer_story + ...UserTagsContainer_story } `, settings: graphql` diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx index 2918ba364..3169021f7 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyList.tsx @@ -35,6 +35,7 @@ export interface ReplyListProps { disableReplies?: boolean; viewNewCount?: number; onViewNew?: () => void; + onRemoveAnswered?: () => void; } const ReplyList: FunctionComponent = props => { @@ -64,6 +65,7 @@ const ReplyList: FunctionComponent = props => { localReply={props.localReply} disableReplies={props.disableReplies} showConversationLink={!!comment.showConversationLink} + onRemoveAnswered={props.onRemoveAnswered} /> {comment.replyListElement} diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx index 6ab84d1cf..085f00655 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx @@ -50,6 +50,7 @@ type Props = BaseProps & { * instead of hiding behind a button. */ liveDirectRepliesInsertion?: boolean; + onRemoveAnswered?: () => void; }; // TODO: (cvle) If this could be autogenerated. @@ -151,6 +152,7 @@ export const ReplyListContainer: React.FunctionComponent = props => { localReply={props.localReply} viewNewCount={viewNewCount} onViewNew={onViewNew} + onRemoveAnswered={props.onRemoveAnswered} /> ); }; diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx index c7dc1545b..2177e490f 100644 --- a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx @@ -11,11 +11,11 @@ import { useSubscription, withPaginationContainer, } from "coral-framework/lib/relay"; -import { GQLCOMMENT_SORT } from "coral-framework/schema"; +import { GQLCOMMENT_SORT, GQLSTORY_MODE } from "coral-framework/schema"; import { Omit, PropTypesOf } from "coral-framework/types"; import CLASSES from "coral-stream/classes"; import { LoadMoreAllCommentsEvent } from "coral-stream/events"; -import { Box, Button, CallOut, HorizontalGutter } from "coral-ui/components"; +import { Box, Button, HorizontalGutter } from "coral-ui/components"; import { AllCommentsTabContainer_settings } from "coral-stream/__generated__/AllCommentsTabContainer_settings.graphql"; import { AllCommentsTabContainer_story } from "coral-stream/__generated__/AllCommentsTabContainer_story.graphql"; @@ -29,6 +29,7 @@ import { ReplyListContainer } from "../../ReplyList"; import AllCommentsTabViewNewMutation from "./AllCommentsTabViewNewMutation"; import CommentCreatedSubscription from "./CommentCreatedSubscription"; import CommentReleasedSubscription from "./CommentReleasedSubscription"; +import NoComments from "./NoComments"; import styles from "./AllCommentsTabContainer.css"; @@ -139,9 +140,15 @@ export const AllCommentsTabContainer: FunctionComponent = props => { className={CLASSES.allCommentsTabPane.viewNewButton} fullWidth > - - View {viewNewCount} New Comments - + {props.story.settings.mode === GQLSTORY_MODE.QA ? ( + + View {viewNewCount} New Questions + + ) : ( + + View {viewNewCount} New Comments + + )} )} @@ -153,17 +160,11 @@ export const AllCommentsTabContainer: FunctionComponent = props => { size="oneAndAHalf" className={styles.stream} > - {comments.length <= 0 && props.story.isClosed && ( - - There are no comments on this story. - - )} - {comments.length <= 0 && !props.story.isClosed && ( - - - There are no comments yet. Why don't you write one? - - + {comments.length <= 0 && ( + )} {comments.length > 0 && !props.story.isClosed && @@ -235,6 +236,7 @@ const enhanced = withPaginationContainer< live { enabled } + mode } comments(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "Stream_comments") { diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/NoComments.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/NoComments.tsx new file mode 100644 index 000000000..739d2f464 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/NoComments.tsx @@ -0,0 +1,50 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { GQLSTORY_MODE } from "coral-framework/schema"; +import { CallOut } from "coral-ui/components"; + +interface Props { + mode: "COMMENTS" | "QA" | "%future added value"; + isClosed: boolean; +} + +const NoComments: FunctionComponent = ({ mode, isClosed }) => { + if (mode === GQLSTORY_MODE.COMMENTS) { + if (isClosed) { + return ( + + There are no comments on this story. + + ); + } else { + return ( + + + There are no comments yet. Why don't you write one? + + + ); + } + } else if (mode === GQLSTORY_MODE.QA) { + if (isClosed) { + return ( + + There are no questions on this story. + + ); + } else { + return ( + + + There are no questions yet. Why don't you ask one? + + + ); + } + } + + return null; +}; + +export default NoComments; diff --git a/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentContainer.css b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentContainer.css new file mode 100644 index 000000000..b2072eb18 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentContainer.css @@ -0,0 +1,45 @@ +.root { + color: var(--palette-text-primary); + padding-left: var(--spacing-4); + box-sizing: border-box; + + border-left-width: 2px; + border-left-style: solid; + border-left-color: #3498DB; + + margin-left: var(--spacing-2); +} + +.replies, +.gotoConversation { + font-size: calc(14rem / var(--rem-base)); + font-weight: var(--font-weight-medium); + font-family: var(--font-family-sans-serif); + line-height: 0; +} + +.gotoArrow { + display: inline-block; + text-decoration: none; + margin-left: var(--spacing-1); +} + +.username { + margin-right: var(--spacing-1); +} + +.timestamp { + margin-left: var(--spacing-2); +} + +.replies { + color: var(--palette-grey-main); +} + +.repliesText { + margin: var(--spacing-2); +} + +.repliesDivider { + margin: var(--spacing-2); +} diff --git a/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentContainer.tsx new file mode 100644 index 000000000..9c07ae54c --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentContainer.tsx @@ -0,0 +1,221 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; +import React, { FunctionComponent, MouseEvent, useCallback } from "react"; +import { graphql } from "react-relay"; + +import { getURLWithCommentID } from "coral-framework/helpers"; +import { useViewerEvent } from "coral-framework/lib/events"; +import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer"; +import { GQLUSER_STATUS } from "coral-framework/schema"; +import CLASSES from "coral-stream/classes"; +import HTMLContent from "coral-stream/common/HTMLContent"; +import Timestamp from "coral-stream/common/Timestamp"; +import { ViewConversationEvent } from "coral-stream/events"; +import { + SetCommentIDMutation, + withSetCommentIDMutation, +} from "coral-stream/mutations"; +import { Flex, Icon, TextLink } from "coral-ui/components"; + +import { AnsweredCommentContainer_comment as CommentData } from "coral-stream/__generated__/AnsweredCommentContainer_comment.graphql"; +import { AnsweredCommentContainer_settings as SettingsData } from "coral-stream/__generated__/AnsweredCommentContainer_settings.graphql"; +import { AnsweredCommentContainer_story as StoryData } from "coral-stream/__generated__/AnsweredCommentContainer_story.graphql"; +import { AnsweredCommentContainer_viewer as ViewerData } from "coral-stream/__generated__/AnsweredCommentContainer_viewer.graphql"; + +import { CommentContainer, UserTagsContainer } from "../../Comment"; +import ReactionButtonContainer from "../../Comment/ReactionButton"; +import { UsernameWithPopoverContainer } from "../../Comment/Username"; + +import styles from "./AnsweredCommentContainer.css"; + +interface Props { + viewer: ViewerData | null; + comment: CommentData; + story: StoryData; + settings: SettingsData; + setCommentID: SetCommentIDMutation; +} + +const AnsweredCommentContainer: FunctionComponent = props => { + const { comment, settings, story, viewer, setCommentID } = props; + const banned = Boolean( + viewer && viewer.status.current.includes(GQLUSER_STATUS.BANNED) + ); + const emitViewConversationEvent = useViewerEvent(ViewConversationEvent); + const onGotoConversation = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + emitViewConversationEvent({ + from: "FEATURED_COMMENTS", + commentID: comment.id, + }); + setCommentID({ id: comment.id }); + return false; + }, + [setCommentID, comment] + ); + + return ( + <> + {comment.parent && ( + + )} +
+ + {comment.author && ( + + )} + + + + {comment.createdAt} + + + + + {comment.body || ""} + + + + + {comment.replyCount > 0 && ( + + + reply + + Replies + + {comment.replyCount} + + | + + )} +
+ + + Go to Conversation + + > + +
+
+
+
+ + ); +}; + +const enhanced = withSetCommentIDMutation( + withFragmentContainer({ + viewer: graphql` + fragment AnsweredCommentContainer_viewer on User { + id + status { + current + } + ignoredUsers { + id + } + role + ...UsernameWithPopoverContainer_viewer + ...ReactionButtonContainer_viewer + ...CommentContainer_viewer + } + `, + story: graphql` + fragment AnsweredCommentContainer_story on Story { + url + ...UserTagsContainer_story + ...CommentContainer_story + } + `, + comment: graphql` + fragment AnsweredCommentContainer_comment on Comment { + id + author { + id + username + } + parent { + author { + username + } + ...CommentContainer_comment + } + body + createdAt + lastViewerAction + replyCount + ...UsernameWithPopoverContainer_comment + ...ReactionButtonContainer_comment + ...UserTagsContainer_comment + } + `, + settings: graphql` + fragment AnsweredCommentContainer_settings on Settings { + ...ReactionButtonContainer_settings + ...UserTagsContainer_settings + ...CommentContainer_settings + } + `, + })(AnsweredCommentContainer) +); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentsContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentsContainer.tsx new file mode 100644 index 000000000..fdfe65dcd --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentsContainer.tsx @@ -0,0 +1,180 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback } from "react"; +import { graphql, RelayPaginationProp } from "react-relay"; + +import { useViewerNetworkEvent } from "coral-framework/lib/events"; +import { + useLoadMore, + withPaginationContainer, +} from "coral-framework/lib/relay"; +import { Omit, PropTypesOf } from "coral-framework/types"; +import CLASSES from "coral-stream/classes"; +import { LoadMoreFeaturedCommentsEvent } from "coral-stream/events"; +import { Button, HorizontalGutter } from "coral-ui/components"; + +import { AnsweredCommentsContainer_settings as SettingsData } from "coral-stream/__generated__/AnsweredCommentsContainer_settings.graphql"; +import { AnsweredCommentsContainer_story as StoryData } from "coral-stream/__generated__/AnsweredCommentsContainer_story.graphql"; +import { AnsweredCommentsContainer_viewer as ViewerData } from "coral-stream/__generated__/AnsweredCommentsContainer_viewer.graphql"; +import { AnsweredCommentsContainerPaginationQueryVariables } from "coral-stream/__generated__/AnsweredCommentsContainerPaginationQuery.graphql"; + +import IgnoredTombstoneOrHideContainer from "../../IgnoredTombstoneOrHideContainer"; +import AnsweredCommentContainer from "./AnsweredCommentContainer"; + +interface Props { + story: StoryData; + settings: SettingsData; + viewer: ViewerData | null; + relay: RelayPaginationProp; +} + +export const AnsweredCommentsContainer: FunctionComponent = props => { + const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); + const beginLoadMoreEvent = useViewerNetworkEvent( + LoadMoreFeaturedCommentsEvent + ); + const loadMoreAndEmit = useCallback(async () => { + const loadMoreEvent = beginLoadMoreEvent({ storyID: props.story.id }); + try { + await loadMore(); + loadMoreEvent.success(); + } catch (error) { + loadMoreEvent.error({ message: error.message, code: error.code }); + // eslint-disable-next-line no-console + console.error(error); + } + }, [loadMore, beginLoadMoreEvent, props.story.id]); + const comments = props.story.featuredComments.edges.map(edge => edge.node); + return ( + + {comments.map(comment => ( + + + + ))} + {props.relay.hasMore() && ( + + + + )} + + ); +}; + +// TODO: (cvle) if this could be autogenerated.. +type FragmentVariables = Omit< + AnsweredCommentsContainerPaginationQueryVariables, + "storyID" +>; + +const enhanced = withPaginationContainer< + Props, + AnsweredCommentsContainerPaginationQueryVariables, + FragmentVariables +>( + { + story: graphql` + fragment AnsweredCommentsContainer_story on Story + @argumentDefinitions( + count: { type: "Int!", defaultValue: 5 } + cursor: { type: "Cursor" } + orderBy: { type: "COMMENT_SORT!", defaultValue: CREATED_AT_DESC } + ) { + id + featuredComments(first: $count, after: $cursor, orderBy: $orderBy) + @connection(key: "Stream_featuredComments") { + edges { + node { + id + ...AnsweredCommentContainer_comment + ...IgnoredTombstoneOrHideContainer_comment + } + } + } + ...PostCommentFormContainer_story + ...AnsweredCommentContainer_story + } + `, + viewer: graphql` + fragment AnsweredCommentsContainer_viewer on User { + ...AnsweredCommentContainer_viewer + ...IgnoredTombstoneOrHideContainer_viewer + status { + current + } + } + `, + settings: graphql` + fragment AnsweredCommentsContainer_settings on Settings { + reaction { + sortLabel + } + ...AnsweredCommentContainer_settings + } + `, + }, + { + direction: "forward", + getConnectionFromProps(props) { + return props.story && props.story.featuredComments; + }, + // This is also the default implementation of `getFragmentVariables` if it isn't provided. + getFragmentVariables(prevVars, totalCount) { + return { + ...prevVars, + count: totalCount, + }; + }, + getVariables(props, { count, cursor }, fragmentVariables) { + return { + count, + cursor, + orderBy: fragmentVariables.orderBy, + // storyID isn't specified as an @argument for the fragment, but it should be a + // variable available for the fragment under the query root. + storyID: props.story.id, + }; + }, + query: graphql` + # Pagination query to be fetched upon calling 'loadMore'. + # Notice that we re-use our fragment, and the shape of this query matches our fragment spec. + query AnsweredCommentsContainerPaginationQuery( + $count: Int! + $cursor: Cursor + $orderBy: COMMENT_SORT! + $storyID: ID + ) { + story(id: $storyID) { + ...AnsweredCommentsContainer_story + @arguments(count: $count, cursor: $cursor, orderBy: $orderBy) + } + } + `, + } +)(AnsweredCommentsContainer); + +export type AnsweredCommentsContainerProps = PropTypesOf; +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentsQuery.tsx b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentsQuery.tsx new file mode 100644 index 000000000..99e20229e --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/AnsweredCommentsQuery.tsx @@ -0,0 +1,107 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { + graphql, + QueryRenderData, + QueryRenderer, + withLocalStateContainer, +} from "coral-framework/lib/relay"; +import Spinner from "coral-stream/common/Spinner"; +import { Delay, Flex } from "coral-ui/components"; + +import { AnsweredCommentsQuery as QueryTypes } from "coral-stream/__generated__/AnsweredCommentsQuery.graphql"; +import { AnsweredCommentsQueryLocal as Local } from "coral-stream/__generated__/AnsweredCommentsQueryLocal.graphql"; + +import AnsweredCommentsContainer from "./AnsweredCommentsContainer"; + +interface Props { + local: Local; + preload?: boolean; +} + +export const render = (data: QueryRenderData) => { + if (data.error) { + return
{data.error.message}
; + } + + if (!data.props) { + return ( + + + + ); + } + + if (data.props) { + if (!data.props.story) { + return ( + +
Story not found
+
+ ); + } + + return ( + + ); + } + + return ( + + + + + + ); +}; + +const AnsweredCommentsQuery: FunctionComponent = props => { + const { + local: { storyID, storyURL, commentsOrderBy }, + } = props; + return ( + + query={graphql` + query AnsweredCommentsQuery( + $storyID: ID + $storyURL: String + $commentsOrderBy: COMMENT_SORT + ) { + viewer { + ...AnsweredCommentsContainer_viewer + } + story: stream(id: $storyID, url: $storyURL) { + ...AnsweredCommentsContainer_story + @arguments(orderBy: $commentsOrderBy) + } + settings { + ...AnsweredCommentsContainer_settings + } + } + `} + variables={{ + storyID, + storyURL, + commentsOrderBy, + }} + render={data => (props.preload ? null : render(data))} + /> + ); +}; + +const enhanced = withLocalStateContainer( + graphql` + fragment AnsweredCommentsQueryLocal on Local { + storyID + storyURL + commentsOrderBy + } + ` +)(AnsweredCommentsQuery); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/index.ts b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/index.ts new file mode 100644 index 000000000..05a63c195 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/AnsweredCommentsTab/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as AnsweredCommentsQuery, +} from "./AnsweredCommentsQuery"; diff --git a/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.tsx b/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.tsx index 59beef0e9..a8a282fb7 100644 --- a/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/FeaturedCommentTooltip.tsx @@ -5,16 +5,32 @@ import { useViewerEvent } from "coral-framework/lib/events"; import { ShowFeaturedCommentTooltipEvent } from "coral-stream/events"; import { Tooltip, TooltipButton } from "coral-ui/components"; -interface Props { +interface TooltipProps { className?: string; active?: boolean; + isQA?: boolean; } -const FeaturedCommentTooltipContent: FunctionComponent = props => { +interface ContentProps { + isQA?: boolean; +} + +const FeaturedCommentTooltipContent: FunctionComponent< + ContentProps +> = props => { const emitShowTooltipEvent = useViewerEvent(ShowFeaturedCommentTooltipEvent); useEffect(() => { emitShowTooltipEvent(); }, []); + + if (props.isQA) { + return ( + + Questions are answered by a Q&A expert. + + ); + } + return ( Comments are hand selected by our team as worth reading. @@ -22,7 +38,37 @@ const FeaturedCommentTooltipContent: FunctionComponent = props => { ); }; -export const FeaturedCommentTooltip: FunctionComponent = props => { +export const FeaturedCommentTooltip: FunctionComponent< + TooltipProps +> = props => { + if (props.isQA) { + return ( + + How is a question answered? + + } + body={} + button={({ toggleVisibility, ref, visible }) => ( + + + + )} + /> + ); + } + return ( = props => { @@ -158,6 +159,7 @@ const enhanced = withSetCommentIDMutation( story: graphql` fragment FeaturedCommentContainer_story on Story { url + ...UserTagsContainer_story } `, comment: graphql` diff --git a/src/core/client/stream/tabs/Comments/Stream/MessageBoxContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/MessageBoxContainer.tsx index f537e8bb9..9b3f0fb85 100644 --- a/src/core/client/stream/tabs/Comments/Stream/MessageBoxContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/MessageBoxContainer.tsx @@ -45,6 +45,7 @@ const enhanced = withFragmentContainer({ content icon } + mode } } `, diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentForm.tsx b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentForm.tsx index fd4c3ed37..027c45769 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentForm.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentForm.tsx @@ -6,6 +6,7 @@ import { Field, Form, FormSpy } from "react-final-form"; import { useViewerEvent } from "coral-framework/lib/events"; import { FormError, OnSubmit } from "coral-framework/lib/form"; +import { GQLSTORY_MODE } from "coral-framework/schema"; import { PropTypesOf } from "coral-framework/types"; import CLASSES from "coral-stream/classes"; import ValidationMessage from "coral-stream/common/ValidationMessage"; @@ -26,6 +27,12 @@ interface FormProps { interface FormSubmitProps extends FormProps, FormError {} +interface StorySettings { + settings?: { + mode?: "COMMENTS" | "QA" | "%future added value" | null; + }; +} + interface Props { onSubmit: OnSubmit; onChange?: (state: FormState, form: FormApi) => void; @@ -36,7 +43,7 @@ interface Props { disabledMessage?: React.ReactNode; submitStatus: PropTypesOf["status"]; showMessageBox?: boolean; - story: PropTypesOf["story"]; + story: PropTypesOf["story"] & StorySettings; } const PostCommentForm: FunctionComponent = props => { @@ -44,6 +51,8 @@ const PostCommentForm: FunctionComponent = props => { const onFocus = useCallback(() => { emitFocusEvent(); }, [emitFocusEvent]); + const isQA = + props.story.settings && props.story.settings.mode === GQLSTORY_MODE.QA; return (
{props.showMessageBox && ( @@ -70,16 +79,31 @@ const PostCommentForm: FunctionComponent = props => { {({ input, meta }) => ( <> - - - Post a comment - - + {isQA ? ( + + + Post a question + + + ) : ( + + + Post a comment + + + )} = {}): Props { messageBox: { enabled: false, }, + mode: "COMMENTS", }, }, sessionStorage: createPromisifiedStorage(), diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.tsx index cdc6d462e..3b1080692 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.tsx @@ -295,6 +295,7 @@ const enhanced = withContext(({ sessionStorage }) => ({ messageBox { enabled } + mode } } `, diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormFake.tsx b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormFake.tsx index 7aae31e68..ba374ff6d 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormFake.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormFake.tsx @@ -3,6 +3,7 @@ import cn from "classnames"; import React, { FunctionComponent, useCallback } from "react"; import { useViewerEvent } from "coral-framework/lib/events"; +import { GQLSTORY_MODE } from "coral-framework/schema"; import { PropTypesOf } from "coral-framework/types"; import CLASSES from "coral-stream/classes"; import { CreateCommentFocusEvent } from "coral-stream/events"; @@ -13,9 +14,15 @@ import MessageBoxContainer from "../MessageBoxContainer"; import styles from "./PostCommentFormFake.css"; +interface StorySettings { + settings?: { + mode?: "COMMENTS" | "QA" | "%future added value" | null; + }; +} + interface Props { showMessageBox?: boolean; - story: PropTypesOf["story"]; + story: PropTypesOf["story"] & StorySettings; draft: string; onDraftChange: (draft: string) => void; onSignIn: () => void; @@ -30,6 +37,8 @@ const PostCommentFormFake: FunctionComponent = props => { (data: { html: string; text: string }) => props.onDraftChange(data.html), [props.onDraftChange] ); + const isQA = + props.story.settings && props.story.settings.mode === GQLSTORY_MODE.QA; return (
{props.showMessageBox && ( @@ -41,11 +50,15 @@ const PostCommentFormFake: FunctionComponent = props => {
) => void; reactionSortLabel: string; + isQA?: boolean; } const SortMenu: FunctionComponent = props => { @@ -72,7 +73,13 @@ const SortMenu: FunctionComponent = props => { - + {props.isQA ? ( + + + + ) : ( + + )} )} diff --git a/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx index 1d3fd8bbb..d5be91c8f 100644 --- a/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/StreamContainer.tsx @@ -5,7 +5,7 @@ import { graphql } from "react-relay"; import { useViewerEvent } from "coral-framework/lib/events"; import { useLocal, withFragmentContainer } from "coral-framework/lib/relay"; -import { GQLUSER_STATUS } from "coral-framework/schema"; +import { GQLSTORY_MODE, GQLUSER_STATUS } from "coral-framework/schema"; import CLASSES from "coral-stream/classes"; import Counter from "coral-stream/common/Counter"; import { UserBoxContainer } from "coral-stream/common/UserBox"; @@ -33,6 +33,7 @@ import { import AllCommentsTab from "./AllCommentsTab"; import AnnouncementContainer from "./Announcement"; +import AnsweredComments from "./AnsweredCommentsTab"; import BannedInfo from "./BannedInfo"; import { CommunityGuidelinesContainer } from "./CommunityGuidelines"; import StreamDeletionRequestCalloutContainer from "./DeleteAccount/StreamDeletionRequestCalloutContainer"; @@ -42,6 +43,7 @@ import { PostCommentFormContainer } from "./PostCommentForm"; import SortMenu from "./SortMenu"; import StoryClosedTimeoutContainer from "./StoryClosedTimeout"; import { SuspendedInfoContainer } from "./SuspendedInfo/index"; +import UnansweredCommentsTab from "./UnansweredCommentsTab"; import useCommentCountEvent from "./useCommentCountEvent"; import styles from "./StreamContainer.css"; @@ -52,10 +54,15 @@ interface Props { viewer: ViewerData | null; } +interface TooltipTabProps extends PropTypesOf { + isQA?: boolean; +} + // Use a custom tab for featured comments, because we need to put the tooltip // button logically next to the tab as both are buttons and position them together // using absolute positioning. -const TabWithFeaturedTooltip: FunctionComponent> = ({ +const TabWithFeaturedTooltip: FunctionComponent = ({ + isQA, ...props }) => (
@@ -72,6 +79,7 @@ const TabWithFeaturedTooltip: FunctionComponent> = ({ /> = props => { const allCommentsCount = props.story.commentCounts.totalPublished; const featuredCommentsCount = props.story.commentCounts.tags.FEATURED; + const unansweredCommentsCount = props.story.commentCounts.tags.UNANSWERED; + const isQA = Boolean(props.story.settings.mode === GQLSTORY_MODE.QA); // Emit comment count event. useCommentCountEvent(props.story.id, props.story.url, allCommentsCount); @@ -140,8 +150,14 @@ export const StreamContainer: FunctionComponent = props => { } else { onChangeTab("FEATURED_COMMENTS", false); } + + // If we are in Q&A mode, we default to most voted + // sorting by default + if (props.story.settings.mode === GQLSTORY_MODE.QA) { + setLocal({ commentsOrderBy: "REACTION_DESC" }); + } } - }, [featuredCommentsCount, local.commentsTab, onChangeTab]); + }, [local, setLocal, props, featuredCommentsCount, onChangeTab]); return ( <> @@ -188,6 +204,7 @@ export const StreamContainer: FunctionComponent = props => { orderBy={local.commentsOrderBy} onChange={onChangeOrder} reactionSortLabel={props.settings.reaction.sortLabel} + isQA={isQA} /> = props => { className={cn(CLASSES.tabBarComments.$root, styles.tabBarRoot)} > {featuredCommentsCount > 0 && ( - + - - Featured - + {isQA ? ( + + Answered + + ) : ( + + Featured + + )} + = props => { )} + {isQA && ( + 0, + }, + CLASSES.tabBarComments.allComments + )} + > + + + Unanswered + + + {unansweredCommentsCount} + + + + )} = props => { )} > - - All Comments - + {isQA ? ( + + All + + ) : ( + + All Comments + + )} + = props => { - - - + {isQA ? ( + + + + ) : ( + + + + )} + {isQA && ( + + + + )} ({ ...CreateCommentMutation_story id url + settings { + mode + } commentCounts { totalPublished tags { FEATURED + UNANSWERED } } } diff --git a/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/SpinnerWhileRendering.tsx b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/SpinnerWhileRendering.tsx new file mode 100644 index 000000000..ab386de5c --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/SpinnerWhileRendering.tsx @@ -0,0 +1,64 @@ +import React, { FunctionComponent, useEffect, useState } from "react"; + +import Spinner from "coral-stream/common/Spinner"; +import { Flex } from "coral-ui/components"; + +interface Props { + children: React.ReactNode; +} + +function callWhenReallyIdle(callback: () => void) { + let handle: any = null; + const rIC = (cb: () => void) => { + if ((window as any).requestIdleCallback) { + handle = (window as any).requestIdleCallback(cb, { timeout: 300 }); + } else { + handle = setTimeout(cb, 0); + } + }; + + // Call `requestIdleCallback` multiple times to ensure + // that the browser is really idelling. + const times = 5; + let chained = callback; + for (let i = 0; i <= times; i++) { + const cur = chained; + chained = () => rIC(cur); + } + chained(); + + return () => { + if ((window as any).requestIdleCallback) { + (window as any).cancelIdleCallback(handle); + } else { + clearTimeout(handle); + } + }; +} + +/** + * Show spinner, wait for browser to idle and start rendering. + */ +const SpinnerWhileRendering: FunctionComponent = props => { + // In our tests, we don't actually "render", so just skip this. + if (process.env.NODE_ENV === "test") { + return <>{props.children}; + } + const [hidden, setHidden] = useState(true); + useEffect(() => { + // Ensure window has bee + return callWhenReallyIdle(() => setHidden(false)); + }, [setHidden]); + return ( + <> + {hidden && ( + + + + )} + {!hidden && props.children} + + ); +}; + +export default SpinnerWhileRendering; diff --git a/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentCreatedSubscription.tsx b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentCreatedSubscription.tsx new file mode 100644 index 000000000..2a3aa9e97 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentCreatedSubscription.tsx @@ -0,0 +1,92 @@ +import { graphql, requestSubscription } from "react-relay"; +import { + ConnectionHandler, + Environment, + RecordProxy, + RecordSourceSelectorProxy, +} from "relay-runtime"; + +import { + createSubscription, + SubscriptionVariables, +} from "coral-framework/lib/relay"; +import { GQLCOMMENT_SORT, GQLCOMMENT_SORT_RL } from "coral-framework/schema"; + +import { UnansweredCommentCreatedSubscription } from "coral-stream/__generated__/UnansweredCommentCreatedSubscription.graphql"; + +function updateForNewestFirst( + store: RecordSourceSelectorProxy, + storyID: string +) { + const rootField = store.getRootField("commentCreated"); + if (!rootField) { + return; + } + const comment = rootField.getLinkedRecord("comment")!; + comment.setValue(true, "enteredLive"); + const commentsEdge = store.create( + `edge-${comment.getValue("id")!}`, + "CommentsEdge" + ); + commentsEdge.setValue(comment.getValue("createdAt"), "cursor"); + commentsEdge.setLinkedRecord(comment, "node"); + const story = store.get(storyID)!; + const connection = ConnectionHandler.getConnection(story, "Stream_comments", { + orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC, + })!; + const linked = connection.getLinkedRecords("viewNewEdges") || []; + connection.setLinkedRecords(linked.concat(commentsEdge), "viewNewEdges"); +} + +function updateForOldestFirst( + store: RecordSourceSelectorProxy, + storyID: string +) { + const story = store.get(storyID)!; + const connection = ConnectionHandler.getConnection(story, "Stream_comments", { + orderBy: GQLCOMMENT_SORT.CREATED_AT_ASC, + })!; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const pageInfo = connection.getLinkedRecord("pageInfo") as RecordProxy; + pageInfo.setValue(true, "hasNextPage"); +} + +const UnansweredCommentCreatedSubscription = createSubscription( + "subscribeToUnansweredCommentCreated", + ( + environment: Environment, + variables: SubscriptionVariables & { + orderBy: GQLCOMMENT_SORT_RL; + } + ) => + requestSubscription(environment, { + subscription: graphql` + subscription UnansweredCommentCreatedSubscription($storyID: ID!) { + commentCreated(storyID: $storyID) { + comment { + id + createdAt + ...UnansweredCommentsTabContainer_comment + } + } + } + `, + variables, + updater: store => { + if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_DESC) { + updateForNewestFirst(store, variables.storyID); + return; + } + if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_ASC) { + updateForOldestFirst(store, variables.storyID); + return; + } + throw new Error( + `Unsupport new top level comment live updates for sort ${variables.orderBy}` + ); + }, + }) +); + +export default UnansweredCommentCreatedSubscription; diff --git a/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentReleasedSubscription.tsx b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentReleasedSubscription.tsx new file mode 100644 index 000000000..61d4ad9ce --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentReleasedSubscription.tsx @@ -0,0 +1,92 @@ +import { graphql, requestSubscription } from "react-relay"; +import { + ConnectionHandler, + Environment, + RecordProxy, + RecordSourceSelectorProxy, +} from "relay-runtime"; + +import { + createSubscription, + SubscriptionVariables, +} from "coral-framework/lib/relay"; +import { GQLCOMMENT_SORT, GQLCOMMENT_SORT_RL } from "coral-framework/schema"; + +import { UnansweredCommentReleasedSubscription } from "coral-stream/__generated__/UnansweredCommentReleasedSubscription.graphql"; + +function updateForNewestFirst( + store: RecordSourceSelectorProxy, + storyID: string +) { + const rootField = store.getRootField("commentReleased"); + if (!rootField) { + return; + } + const comment = rootField.getLinkedRecord("comment")!; + comment.setValue(true, "enteredLive"); + const commentsEdge = store.create( + `edge-${comment.getValue("id")!}`, + "CommentsEdge" + ); + commentsEdge.setValue(comment.getValue("createdAt"), "cursor"); + commentsEdge.setLinkedRecord(comment, "node"); + const story = store.get(storyID)!; + const connection = ConnectionHandler.getConnection(story, "Stream_comments", { + orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC, + })!; + const linked = connection.getLinkedRecords("viewNewEdges") || []; + connection.setLinkedRecords(linked.concat(commentsEdge), "viewNewEdges"); +} + +function updateForOldestFirst( + store: RecordSourceSelectorProxy, + storyID: string +) { + const story = store.get(storyID)!; + const connection = ConnectionHandler.getConnection(story, "Stream_comments", { + orderBy: GQLCOMMENT_SORT.CREATED_AT_ASC, + })!; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const pageInfo = connection.getLinkedRecord("pageInfo") as RecordProxy; + pageInfo.setValue(true, "hasNextPage"); +} + +const UnansweredCommentReleasedSubscription = createSubscription( + "subscribeToUnansweredCommentReleased", + ( + environment: Environment, + variables: SubscriptionVariables & { + orderBy: GQLCOMMENT_SORT_RL; + } + ) => + requestSubscription(environment, { + subscription: graphql` + subscription UnansweredCommentReleasedSubscription($storyID: ID!) { + commentReleased(storyID: $storyID) { + comment { + id + createdAt + ...UnansweredCommentsTabContainer_comment + } + } + } + `, + variables, + updater: store => { + if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_DESC) { + updateForNewestFirst(store, variables.storyID); + return; + } + if (variables.orderBy === GQLCOMMENT_SORT.CREATED_AT_ASC) { + updateForOldestFirst(store, variables.storyID); + return; + } + throw new Error( + `Unsupport new top level comment live updates for sort ${variables.orderBy}` + ); + }, + }) +); + +export default UnansweredCommentReleasedSubscription; diff --git a/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabContainer.css b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabContainer.css new file mode 100644 index 000000000..0d52606c9 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabContainer.css @@ -0,0 +1,4 @@ +.stream > *:not(:first-child):not(button) { + border-top: 1px solid var(--palette-divider); + padding-top: var(--spacing-3) +} \ No newline at end of file diff --git a/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabContainer.tsx new file mode 100644 index 000000000..feece61b7 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabContainer.tsx @@ -0,0 +1,334 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback, useEffect } from "react"; +import { graphql, RelayPaginationProp } from "react-relay"; + +import FadeInTransition from "coral-framework/components/FadeInTransition"; +import { useViewerNetworkEvent } from "coral-framework/lib/events"; +import { + useLoadMore, + useLocal, + useMutation, + useSubscription, + withPaginationContainer, +} from "coral-framework/lib/relay"; +import { GQLCOMMENT_SORT, GQLTAG } from "coral-framework/schema"; +import { Omit, PropTypesOf } from "coral-framework/types"; +import CLASSES from "coral-stream/classes"; +import { LoadMoreAllCommentsEvent } from "coral-stream/events"; +import { Box, Button, CallOut, HorizontalGutter } from "coral-ui/components"; + +import { UnansweredCommentsTabContainer_settings } from "coral-stream/__generated__/UnansweredCommentsTabContainer_settings.graphql"; +import { UnansweredCommentsTabContainer_story } from "coral-stream/__generated__/UnansweredCommentsTabContainer_story.graphql"; +import { UnansweredCommentsTabContainer_viewer } from "coral-stream/__generated__/UnansweredCommentsTabContainer_viewer.graphql"; +import { UnansweredCommentsTabContainerLocal } from "coral-stream/__generated__/UnansweredCommentsTabContainerLocal.graphql"; +import { UnansweredCommentsTabContainerPaginationQueryVariables } from "coral-stream/__generated__/UnansweredCommentsTabContainerPaginationQuery.graphql"; + +import { CommentContainer } from "../../Comment"; +import IgnoredTombstoneOrHideContainer from "../../IgnoredTombstoneOrHideContainer"; +import { ReplyListContainer } from "../../ReplyList"; +import CommentCreatedSubscription from "./UnansweredCommentCreatedSubscription"; +import CommentReleasedSubscription from "./UnansweredCommentReleasedSubscription"; +import UnansweredCommentsTabViewNewMutation from "./UnansweredCommentsTabViewNewMutation"; + +import styles from "./UnansweredCommentsTabContainer.css"; + +interface Props { + story: UnansweredCommentsTabContainer_story; + settings: UnansweredCommentsTabContainer_settings; + viewer: UnansweredCommentsTabContainer_viewer | null; + relay: RelayPaginationProp; +} + +// eslint-disable-next-line no-unused-expressions +graphql` + fragment UnansweredCommentsTabContainer_comment on Comment { + id + ...CommentContainer_comment + ...ReplyListContainer1_comment + ...IgnoredTombstoneOrHideContainer_comment + } +`; + +export const UnansweredCommentsTabContainer: FunctionComponent< + Props +> = props => { + const [{ commentsOrderBy }] = useLocal( + graphql` + fragment UnansweredCommentsTabContainerLocal on Local { + commentsOrderBy + } + ` + ); + const subscribeToCommentCreated = useSubscription(CommentCreatedSubscription); + const subscribeToCommentReleased = useSubscription( + CommentReleasedSubscription + ); + useEffect(() => { + if (!props.story.settings.live.enabled) { + return; + } + + if (props.story.isClosed || props.settings.disableCommenting.enabled) { + return; + } + if ( + commentsOrderBy === GQLCOMMENT_SORT.CREATED_AT_ASC && + props.relay.hasMore() + ) { + // If sort by oldest we only need to know if there is more to load. + return; + } + if ( + ![ + GQLCOMMENT_SORT.CREATED_AT_ASC, + GQLCOMMENT_SORT.CREATED_AT_DESC, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + ].includes(commentsOrderBy as GQLCOMMENT_SORT) + ) { + // Only chronological sort supports top level live updates of incoming comments. + return; + } + const newCommentDisposable = subscribeToCommentCreated({ + storyID: props.story.id, + orderBy: commentsOrderBy, + }); + const releasedCommentDisposable = subscribeToCommentReleased({ + storyID: props.story.id, + orderBy: commentsOrderBy, + }); + return () => { + newCommentDisposable.dispose(); + releasedCommentDisposable.dispose(); + }; + }, [ + commentsOrderBy, + subscribeToCommentCreated, + subscribeToCommentReleased, + props.story.id, + props.relay.hasMore(), + props.story.settings.live.enabled, + ]); + const [loadMore, isLoadingMore] = useLoadMore(props.relay, 20); + const beginLoadMoreEvent = useViewerNetworkEvent(LoadMoreAllCommentsEvent); + const loadMoreAndEmit = useCallback(async () => { + const loadMoreEvent = beginLoadMoreEvent({ storyID: props.story.id }); + try { + await loadMore(); + loadMoreEvent.success(); + } catch (error) { + loadMoreEvent.error({ message: error.message, code: error.code }); + // eslint-disable-next-line no-console + console.error(error); + } + }, [loadMore, beginLoadMoreEvent, props.story.id]); + const viewMore = useMutation(UnansweredCommentsTabViewNewMutation); + const onViewMore = useCallback(() => viewMore({ storyID: props.story.id }), [ + props.story.id, + viewMore, + ]); + const comments = props.story.comments.edges.map(edge => edge.node); + const viewNewCount = + (props.story.comments.viewNewEdges && + props.story.comments.viewNewEdges.length) || + 0; + const onRemoveAnswered = useCallback(() => { + const { relay } = props; + relay.refetchConnection(20); + }, [props]); + return ( + <> + {Boolean(viewNewCount && viewNewCount > 0) && ( + + + + )} + + {comments.length <= 0 && props.story.isClosed && ( + + There are no questions on this story. + + )} + {comments.length <= 0 && !props.story.isClosed && ( + + + There are no questions yet. Why don't you ask one? + + + )} + {comments.length > 0 && + !props.story.isClosed && + comments.map(comment => ( + + + + + + + + + ))} + {props.relay.hasMore() && ( + + + + )} + + + ); +}; + +// TODO: (cvle) if this could be autogenerated.. +type FragmentVariables = Omit< + UnansweredCommentsTabContainerPaginationQueryVariables, + "storyID" +>; + +const enhanced = withPaginationContainer< + Props, + UnansweredCommentsTabContainerPaginationQueryVariables, + FragmentVariables +>( + { + story: graphql` + fragment UnansweredCommentsTabContainer_story on Story + @argumentDefinitions( + count: { type: "Int!", defaultValue: 20 } + cursor: { type: "Cursor" } + orderBy: { type: "COMMENT_SORT!", defaultValue: CREATED_AT_DESC } + tag: { type: "TAG!", defaultValue: UNANSWERED } + ) { + id + isClosed + settings { + live { + enabled + } + } + comments(first: $count, after: $cursor, orderBy: $orderBy, tag: $tag) + @connection(key: "UnansweredStream_comments") { + viewNewEdges { + cursor + } + edges { + node { + enteredLive + ...UnansweredCommentsTabContainer_comment @relay(mask: false) + } + } + } + ...PostCommentFormContainer_story + ...CommentContainer_story + ...ReplyListContainer1_story + ...CreateCommentReplyMutation_story + ...CreateCommentMutation_story + } + `, + viewer: graphql` + fragment UnansweredCommentsTabContainer_viewer on User { + ...ReplyListContainer1_viewer + ...CommentContainer_viewer + ...CreateCommentReplyMutation_viewer + ...CreateCommentMutation_viewer + ...IgnoredTombstoneOrHideContainer_viewer + status { + current + } + } + `, + settings: graphql` + fragment UnansweredCommentsTabContainer_settings on Settings { + reaction { + sortLabel + } + disableCommenting { + enabled + } + ...ReplyListContainer1_settings + ...CommentContainer_settings + } + `, + }, + { + direction: "forward", + getConnectionFromProps(props) { + return props.story && props.story.comments; + }, + // This is also the default implementation of `getFragmentVariables` if it isn't provided. + getFragmentVariables(prevVars, totalCount) { + return { + ...prevVars, + count: totalCount, + }; + }, + getVariables(props, { count, cursor }, fragmentVariables) { + return { + count, + cursor, + orderBy: fragmentVariables.orderBy, + // storyID isn't specified as an @argument for the fragment, but it should be a + // variable available for the fragment under the query root. + storyID: props.story.id, + tag: GQLTAG.UNANSWERED, + }; + }, + query: graphql` + # Pagination query to be fetched upon calling 'loadMore'. + # Notice that we re-use our fragment, and the shape of this query matches our fragment spec. + query UnansweredCommentsTabContainerPaginationQuery( + $count: Int! + $cursor: Cursor + $orderBy: COMMENT_SORT! + $storyID: ID + ) { + story(id: $storyID) { + ...UnansweredCommentsTabContainer_story + @arguments(count: $count, cursor: $cursor, orderBy: $orderBy) + } + } + `, + } +)(UnansweredCommentsTabContainer); + +export type UnansweredCommentsTabContainerProps = PropTypesOf; +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabQuery.tsx b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabQuery.tsx new file mode 100644 index 000000000..a33f24fc3 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabQuery.tsx @@ -0,0 +1,99 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { + graphql, + QueryRenderData, + QueryRenderer, + withLocalStateContainer, +} from "coral-framework/lib/relay"; +import Spinner from "coral-stream/common/Spinner"; +import { Flex } from "coral-ui/components"; + +import { UnansweredCommentsTabQuery as QueryTypes } from "coral-stream/__generated__/UnansweredCommentsTabQuery.graphql"; +import { UnansweredCommentsTabQueryLocal as Local } from "coral-stream/__generated__/UnansweredCommentsTabQueryLocal.graphql"; + +import SpinnerWhileRendering from "./SpinnerWhileRendering"; +import UnansweredCommentsTabContainer from "./UnansweredCommentsTabContainer"; + +interface Props { + local: Local; + preload?: boolean; +} + +export const render = (data: QueryRenderData) => { + if (data.error) { + return
{data.error.message}
; + } + if (data.props) { + if (!data.props.story) { + return ( + +
Story not found
+
+ ); + } + + return ( + + + + ); + } + return ( + + + + ); +}; + +const UnansweredCommentsTabQuery: FunctionComponent = props => { + const { + local: { storyID, storyURL, commentsOrderBy }, + } = props; + return ( + + query={graphql` + query UnansweredCommentsTabQuery( + $storyID: ID + $storyURL: String + $commentsOrderBy: COMMENT_SORT + $tag: TAG + ) { + viewer { + ...UnansweredCommentsTabContainer_viewer + } + story: stream(id: $storyID, url: $storyURL) { + ...UnansweredCommentsTabContainer_story + @arguments(orderBy: $commentsOrderBy, tag: UNANSWERED) + } + settings { + ...UnansweredCommentsTabContainer_settings + } + } + `} + variables={{ + storyID, + storyURL, + commentsOrderBy, + }} + render={data => (props.preload ? null : render(data))} + /> + ); +}; + +const enhanced = withLocalStateContainer( + graphql` + fragment UnansweredCommentsTabQueryLocal on Local { + storyID + storyURL + commentsOrderBy + } + ` +)(UnansweredCommentsTabQuery); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabViewNewMutation.tsx b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabViewNewMutation.tsx new file mode 100644 index 000000000..7b698eede --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/UnansweredCommentsTabViewNewMutation.tsx @@ -0,0 +1,51 @@ +import { ConnectionHandler, Environment, RecordProxy } from "relay-runtime"; + +import { CoralContext } from "coral-framework/lib/bootstrap"; +import { + commitLocalUpdatePromisified, + createMutation, +} from "coral-framework/lib/relay"; +import { GQLCOMMENT_SORT } from "coral-framework/schema"; +import { ViewNewCommentsEvent } from "coral-stream/events"; + +interface Input { + storyID: string; +} + +const UnansweredCommentsTabViewNewMutation = createMutation( + "viewNewUnanswered", + async ( + environment: Environment, + input: Input, + { eventEmitter }: CoralContext + ) => { + await commitLocalUpdatePromisified(environment, async store => { + const story = store.get(input.storyID)!; + const connection = ConnectionHandler.getConnection( + story, + "UnansweredStream_comments", + { + orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC, + } + )!; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const viewNewEdges = connection.getLinkedRecords( + "viewNewEdges" + ) as ReadonlyArray; + if (!viewNewEdges || viewNewEdges.length === 0) { + return; + } + viewNewEdges.forEach(edge => { + ConnectionHandler.insertEdgeBefore(connection, edge); + }); + ViewNewCommentsEvent.emit(eventEmitter, { + storyID: input.storyID, + count: viewNewEdges.length, + }); + connection.setLinkedRecords([], "viewNewEdges"); + }); + } +); + +export default UnansweredCommentsTabViewNewMutation; diff --git a/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/index.ts b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/index.ts new file mode 100644 index 000000000..c3c9a08ef --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/UnansweredCommentsTab/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as UnansweredCommentsTabQuery, +} from "./UnansweredCommentsTabQuery"; diff --git a/src/core/client/stream/tabs/Configure/Configure.tsx b/src/core/client/stream/tabs/Configure/Configure.tsx index 52c905544..0d7b3270c 100644 --- a/src/core/client/stream/tabs/Configure/Configure.tsx +++ b/src/core/client/stream/tabs/Configure/Configure.tsx @@ -8,6 +8,7 @@ import ConfigureStreamContainer from "./ConfigureStream"; import HorizontalRule from "./HorizontalRule"; import ModerateStreamContainer from "./ModerateStreamContainer"; import OpenOrCloseStreamContainer from "./OpenOrCloseStream"; +import { QAConfigContainer } from "./Q&A"; export interface Props { viewer: PropTypesOf["viewer"]; @@ -15,7 +16,8 @@ export interface Props { PropTypesOf["settings"]; story: PropTypesOf["story"] & PropTypesOf["story"] & - PropTypesOf["story"]; + PropTypesOf["story"] & + PropTypesOf["story"]; } const Configure: FunctionComponent = props => { @@ -30,6 +32,8 @@ const Configure: FunctionComponent = props => { + +
diff --git a/src/core/client/stream/tabs/Configure/ConfigureContainer.tsx b/src/core/client/stream/tabs/Configure/ConfigureContainer.tsx index d36736429..5733cce58 100644 --- a/src/core/client/stream/tabs/Configure/ConfigureContainer.tsx +++ b/src/core/client/stream/tabs/Configure/ConfigureContainer.tsx @@ -32,6 +32,7 @@ const enhanced = withFragmentContainer({ ...ConfigureStreamContainer_story ...OpenOrCloseStreamContainer_story ...ModerateStreamContainer_story + ...QAConfigContainer_story } `, viewer: graphql` diff --git a/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStream.tsx b/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStream.tsx index cf370033e..dad2fda88 100644 --- a/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStream.tsx +++ b/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStream.tsx @@ -22,6 +22,7 @@ import PremodLinksConfigContainer from "./PremodLinksConfig"; import styles from "./ConfigureStream.css"; interface Props { + storyID: string; onSubmit: (settings: any, form: FormApi) => void; storySettings: PropTypesOf< typeof LiveUpdatesConfigContainer @@ -41,9 +42,9 @@ const ConfigureStream: FunctionComponent = ({ id="configure-form" > - + - Configure this Comment Stream + Configure this Stream diff --git a/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStreamContainer.tsx b/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStreamContainer.tsx index 0767fc04a..96678fadf 100644 --- a/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStreamContainer.tsx +++ b/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStreamContainer.tsx @@ -36,6 +36,7 @@ class ConfigureStreamContainer extends React.Component { {({ onSubmit }) => ( )} diff --git a/src/core/client/stream/tabs/Configure/Q&A/AddExpertMutation.ts b/src/core/client/stream/tabs/Configure/Q&A/AddExpertMutation.ts new file mode 100644 index 000000000..bf71f37b3 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/AddExpertMutation.ts @@ -0,0 +1,45 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { AddExpertMutation } from "coral-stream/__generated__/AddExpertMutation.graphql"; + +let clientMutationId = 0; + +const AddExpertMutation = createMutation( + "addExpert", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation AddExpertMutation($input: AddExpertInput!) { + addStoryExpert(input: $input) { + story { + id + settings { + experts { + id + username + email + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + storyID: input.storyID, + userID: input.userID, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default AddExpertMutation; diff --git a/src/core/client/stream/tabs/Configure/Q&A/DisableQA.css b/src/core/client/stream/tabs/Configure/Q&A/DisableQA.css new file mode 100644 index 000000000..44759a6ca --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/DisableQA.css @@ -0,0 +1,7 @@ +.heading { + padding-bottom: calc(1.5 * var(--mini-unit)); +} + +.button { + flex-shrink: 0; +} diff --git a/src/core/client/stream/tabs/Configure/Q&A/DisableQA.tsx b/src/core/client/stream/tabs/Configure/Q&A/DisableQA.tsx new file mode 100644 index 000000000..50c8171a6 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/DisableQA.tsx @@ -0,0 +1,44 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import CLASSES from "coral-stream/classes"; +import { Button, Flex, Typography } from "coral-ui/components"; + +import styles from "./DisableQA.css"; + +interface Props { + onClick: () => void; + disableButton?: boolean; +} + +const DisableQA: FunctionComponent = ({ onClick, disableButton }) => ( +
+ + + Configure this Q&A + + + + + + The Q&A format allows community members to submit questions for chosen + experts to answer. + + + + + + +
+); + +export default DisableQA; diff --git a/src/core/client/stream/tabs/Configure/Q&A/EnableQA.css b/src/core/client/stream/tabs/Configure/Q&A/EnableQA.css new file mode 100644 index 000000000..44759a6ca --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/EnableQA.css @@ -0,0 +1,7 @@ +.heading { + padding-bottom: calc(1.5 * var(--mini-unit)); +} + +.button { + flex-shrink: 0; +} diff --git a/src/core/client/stream/tabs/Configure/Q&A/EnableQA.tsx b/src/core/client/stream/tabs/Configure/Q&A/EnableQA.tsx new file mode 100644 index 000000000..26448760e --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/EnableQA.tsx @@ -0,0 +1,44 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import CLASSES from "coral-stream/classes"; +import { Button, Flex, Typography } from "coral-ui/components"; + +import styles from "./EnableQA.css"; + +interface Props { + onClick: () => void; + disableButton?: boolean; +} + +const EnableQA: FunctionComponent = ({ onClick, disableButton }) => ( +
+ + + Switch to Q&A Format + + + + + + The Q&A format allows community members to submit questions for chosen + experts to answer. + + + + + + +
+); + +export default EnableQA; diff --git a/src/core/client/stream/tabs/Configure/Q&A/ExpertListItem.css b/src/core/client/stream/tabs/Configure/Q&A/ExpertListItem.css new file mode 100644 index 000000000..72d3ab129 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/ExpertListItem.css @@ -0,0 +1,45 @@ +.root { + margin-left: var(--v2-spacing-1); + margin-bottom: var(--v2-spacing-1); + + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + + max-width: 80%; +} + +.usernameEmail { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; +} + +.username { + color: var(--palette-text-dark); + font-size: calc(16rem / var(--rem-base)); + font-weight: var(--font-weight-medium); + font-family: var(--font-family-sans-serif); + line-height: 1.375em; + letter-spacing: 0.0125em; + + margin-right: var(--v2-spacing-1); +} + +.email { + color: var(--palette-text-primary); + font-size: calc(16rem / var(--rem-base)); + font-weight: var(--font-weight-regular); + font-family: var(--font-family-sans-serif); + line-height: 1.375em; + letter-spacing: 0.0125em; + + margin-bottom: var(--v2-spacing-1); +} + +.removeButton { + padding: var(--v2-spacing-1); +} diff --git a/src/core/client/stream/tabs/Configure/Q&A/ExpertListItem.tsx b/src/core/client/stream/tabs/Configure/Q&A/ExpertListItem.tsx new file mode 100644 index 000000000..e254cfc51 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/ExpertListItem.tsx @@ -0,0 +1,49 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback } from "react"; + +import { Button } from "coral-ui/components"; + +import styles from "./ExpertListItem.css"; + +interface Props { + id: string; + username: string | null; + email: string | null; + onClickRemove: (id: string) => void; +} + +const ExpertListItem: FunctionComponent = ({ + id, + username, + email, + onClickRemove, +}) => { + const onClick = useCallback(() => { + onClickRemove(id); + }, [id, onClickRemove]); + + return ( +
  • +
    + {username && {username}} + {email && ( + + + email + + + )} +
    + +
  • + ); +}; + +export default ExpertListItem; diff --git a/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchItem.css b/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchItem.css new file mode 100644 index 000000000..339597dae --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchItem.css @@ -0,0 +1,31 @@ +.button { + padding-top: var(--v2-spacing-1); + padding-bottom: var(--v2-spacing-1); +} + +.usernameEmail { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; +} + +.username { + color: var(--palette-text-dark); + font-size: calc(16rem / var(--rem-base)); + font-weight: var(--font-weight-medium); + font-family: var(--font-family-sans-serif); + line-height: 1.375em; + letter-spacing: 0.0125em; + + margin-right: var(--v2-spacing-1); +} + +.email { + color: var(--palette-text-primary); + font-size: calc(16rem / var(--rem-base)); + font-weight: var(--font-weight-regular); + font-family: var(--font-family-sans-serif); + line-height: 1.375em; + letter-spacing: 0.0125em; +} diff --git a/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchItem.tsx b/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchItem.tsx new file mode 100644 index 000000000..b34dc0d93 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchItem.tsx @@ -0,0 +1,43 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback } from "react"; + +import { Button, Flex } from "coral-ui/components"; + +import styles from "./ExpertSearchItem.css"; + +interface Props { + id: string; + username: string | null; + email: string | null; + onClickAdd: (id: string) => void; +} + +const ExpertSearchItem: FunctionComponent = ({ + id, + username, + email, + onClickAdd, +}) => { + const onClick = useCallback(() => { + onClickAdd(id); + }, [id, onClickAdd]); + + return ( + + + + ); +}; + +export default ExpertSearchItem; diff --git a/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchList.css b/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchList.css new file mode 100644 index 000000000..16c78a8da --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchList.css @@ -0,0 +1,37 @@ +.list { + position: absolute; + z-index: 99; + + min-width: calc(80% - 47px); + max-height: 120px; + overflow-y: auto; + + padding-left: 0; + padding-right: 0; + padding-top: var(--v2-spacing-1); + + margin-bottom: var(--v2-spacing-2); +} + +.loadMore { + margin-top: var(--v2-spacing-1); + + padding-top: var(--v2-spacing-1); + padding-bottom: var(--v2-spacing-1); + + width: 100%; +} + +.noneFound { + width: 100%; + padding-top: var(--v2-spacing-2); + + text-align: center; + + color: var(--palette-text-dark); + font-size: calc(16rem / var(--rem-base)); + font-weight: var(--font-weight-medium); + font-family: var(--font-family-sans-serif); + line-height: 1.375em; + letter-spacing: 0.0125em; +} diff --git a/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchList.tsx b/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchList.tsx new file mode 100644 index 000000000..8f54e6854 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/ExpertSearchList.tsx @@ -0,0 +1,90 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useCallback } from "react"; + +import { Button, Card, Flex, Spinner } from "coral-ui/components"; + +import ExpertSearchItem from "./ExpertSearchItem"; + +import styles from "./ExpertSearchList.css"; + +interface UserItem { + id: string; + username: string | null; + email: string | null; +} + +interface Props { + isVisible: boolean; + users: UserItem[]; + onAdd: (id: string) => void; + + hasMore: boolean; + loading: boolean; + disableLoadMore: boolean; + onLoadMore: () => void; +} + +const ExpertSearchList: FunctionComponent = ({ + isVisible, + users, + loading, + hasMore, + disableLoadMore, + onLoadMore, + onAdd, +}) => { + const onAddClick = useCallback( + (id: string) => { + onAdd(id); + }, + [onAdd] + ); + const loadMore = useCallback(() => { + onLoadMore(); + }, [onLoadMore]); + + if (!isVisible) { + return null; + } + + return ( + + {users.map(u => ( + + ))} + {!loading && users.length === 0 && ( +
    + + No users were found with that email or username + +
    + )} + {loading && ( + + + + )} + {hasMore && ( + + + + )} +
    + ); +}; + +export default ExpertSearchList; diff --git a/src/core/client/stream/tabs/Configure/Q&A/ExpertSelectionContainer.css b/src/core/client/stream/tabs/Configure/Q&A/ExpertSelectionContainer.css new file mode 100644 index 000000000..f77f37ecf --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/ExpertSelectionContainer.css @@ -0,0 +1,29 @@ +.searchRoot { + position: relative; + width: 100%; +} + +.searchField { + width: 80%; + + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.searchButton { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.description { + max-width: 350px; +} + +.expertListTitle { + margin-top: var(--v2-spacing-3); +} + +.list { + padding: 0; + list-style-type: none; +} diff --git a/src/core/client/stream/tabs/Configure/Q&A/ExpertSelectionContainer.tsx b/src/core/client/stream/tabs/Configure/Q&A/ExpertSelectionContainer.tsx new file mode 100644 index 000000000..58e6614e9 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/ExpertSelectionContainer.tsx @@ -0,0 +1,348 @@ +import { Localized } from "@fluent/react/compat"; +import React, { + FunctionComponent, + useCallback, + useEffect, + useState, +} from "react"; +import { RelayPaginationProp } from "react-relay"; + +import { + graphql, + useLoadMore, + useMutation, + useRefetch, + withPaginationContainer, +} from "coral-framework/lib/relay"; +import { GQLUSER_ROLE_RL, GQLUSER_STATUS_RL } from "coral-framework/schema"; +import { Button, Flex, Icon, Typography } from "coral-ui/components"; +import { TextField as SearchTextField } from "coral-ui/components/v2"; + +import { ExpertSelectionContainer_query as QueryData } from "coral-stream/__generated__/ExpertSelectionContainer_query.graphql"; +import { ExpertSelectionContainerPaginationQueryVariables } from "coral-stream/__generated__/ExpertSelectionContainerPaginationQuery.graphql"; + +import AddExpertMutation from "./AddExpertMutation"; +import ExpertListItem from "./ExpertListItem"; +import ExpertSearchList from "./ExpertSearchList"; +import RemoveExpertMutation from "./RemoveExpertMutation"; + +import styles from "./ExpertSelectionContainer.css"; + +interface Props { + storyID: string; + query: QueryData | null; + relay: RelayPaginationProp; +} + +function computeUsers(query: QueryData | null) { + if (!query) { + return []; + } + + return query.users.edges.map(edge => edge.node); +} + +function computeExperts(query: QueryData | null) { + if (!query) { + return []; + } + if (!query.story) { + return []; + } + + return query.story.settings.experts; +} + +const ExpertSelectionContainer: FunctionComponent = ({ + storyID, + query, + relay, +}) => { + const users = computeUsers(query); + const experts = computeExperts(query); + + const searchRootRef = React.createRef(); + + const [loadMore, isLoadingMore] = useLoadMore(relay, 10); + const [searchFilter, setSearchFilter] = useState(""); + const [roleFilter] = useState(null); + const [statusFilter] = useState(null); + const [, isRefetching] = useRefetch(relay, { + searchFilter: searchFilter || null, + roleFilter, + statusFilter, + }); + const [tempSearchFilter, setTempSearchFilter] = useState(""); + const loading = !query || isRefetching; + const hasMore = !isRefetching && relay.hasMore(); + + const addExpertMutation = useMutation(AddExpertMutation); + const removeExpertMutation = useMutation(RemoveExpertMutation); + + const clearSearchFilter = useCallback(() => { + setTempSearchFilter(""); + setSearchFilter(""); + }, [setSearchFilter, setTempSearchFilter]); + + const onClickOutside = useCallback( + (e: any) => { + if ( + searchRootRef && + searchRootRef.current && + searchRootRef.current.contains(e.target) + ) { + return; + } + + clearSearchFilter(); + }, + [clearSearchFilter, searchRootRef] + ); + useEffect(() => { + document.addEventListener("mousedown", onClickOutside); + return () => { + document.removeEventListener("mousedown", onClickOutside); + }; + }, [onClickOutside]); + + const onAddExpert = useCallback( + (id: string) => { + addExpertMutation({ + storyID, + userID: id, + }); + clearSearchFilter(); + }, + [addExpertMutation, clearSearchFilter] + ); + const onRemoveExpert = useCallback( + (id: string) => { + removeExpertMutation({ + storyID, + userID: id, + }); + }, + [removeExpertMutation] + ); + + const onSubmitSearch = useCallback(() => { + setSearchFilter(tempSearchFilter); + }, [tempSearchFilter]); + const onSearchTextChanged = useCallback( + (event: React.ChangeEvent) => { + setTempSearchFilter(event.target.value); + }, + [setTempSearchFilter] + ); + const onSearchKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + clearSearchFilter(); + } + }, + [clearSearchFilter] + ); + const onSearchKeyPress = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + onSubmitSearch(); + } + if (event.key === "Escape") { + clearSearchFilter(); + } + }, + [onSubmitSearch, clearSearchFilter] + ); + + return ( + <> + + + Add an Expert + + + + + Adds an Expert Badge to comments by registered users, only on this + page. New users must first sign up and open the comments on a page to + create their account. + + +
    + + + + + + } + /> + + + 0} + users={users} + onAdd={onAddExpert} + loading={loading} + hasMore={hasMore} + disableLoadMore={isLoadingMore} + onLoadMore={loadMore} + /> + + + Experts + + + {experts.length > 0 ? ( +
      + {experts.map(u => ( + + ))} +
    + ) : ( + + + There are currently no experts for this Q&A. + + + )} +
    + + ); +}; + +type FragmentVariables = ExpertSelectionContainerPaginationQueryVariables; + +const enhanced = withPaginationContainer< + Props, + ExpertSelectionContainerPaginationQueryVariables, + FragmentVariables +>( + { + query: graphql` + fragment ExpertSelectionContainer_query on Query + @argumentDefinitions( + storyID: { type: "ID!" } + count: { type: "Int!", defaultValue: 10 } + cursor: { type: "Cursor" } + roleFilter: { type: "USER_ROLE" } + statusFilter: { type: "USER_STATUS" } + searchFilter: { type: "String" } + ) { + viewer { + id + username + } + story(id: $storyID) { + settings { + experts { + id + email + username + } + } + } + users( + first: $count + after: $cursor + role: $roleFilter + status: $statusFilter + query: $searchFilter + ) @connection(key: "ExpertSelection_users") { + edges { + node { + id + email + username + } + } + } + } + `, + }, + { + direction: "forward", + getConnectionFromProps(props) { + return props.query && props.query.users; + }, + // This is also the default implementation of `getFragmentVariables` if it isn't provided. + getFragmentVariables(prevVars, totalCount) { + return { + ...prevVars, + count: totalCount, + }; + }, + getVariables(props, { count, cursor }, fragmentVariables) { + return { + storyID: props.storyID, + count, + cursor, + roleFilter: fragmentVariables.roleFilter, + statusFilter: fragmentVariables.statusFilter, + searchFilter: fragmentVariables.searchFilter, + }; + }, + query: graphql` + # Pagination query to be fetched upon calling 'loadMore'. + # Notice that we re-use our fragment, and the shape of this query matches our fragment spec. + query ExpertSelectionContainerPaginationQuery( + $storyID: ID! + $count: Int! + $cursor: Cursor + $roleFilter: USER_ROLE + $statusFilter: USER_STATUS + $searchFilter: String + ) { + ...ExpertSelectionContainer_query + @arguments( + storyID: $storyID + count: $count + cursor: $cursor + roleFilter: $roleFilter + statusFilter: $statusFilter + searchFilter: $searchFilter + ) + } + `, + } +)(ExpertSelectionContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Configure/Q&A/ExpertSelectionQuery.tsx b/src/core/client/stream/tabs/Configure/Q&A/ExpertSelectionQuery.tsx new file mode 100644 index 000000000..042c720b5 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/ExpertSelectionQuery.tsx @@ -0,0 +1,43 @@ +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { QueryRenderer } from "coral-framework/lib/relay"; +import { Flex, Spinner } from "coral-ui/components"; + +import { ExpertSelectionQuery as QueryTypes } from "coral-stream/__generated__/ExpertSelectionQuery.graphql"; + +import ExpertSelectionContainer from "./ExpertSelectionContainer"; + +interface Props { + storyID: string; +} + +const ExpertSelectionQuery: FunctionComponent = ({ storyID }) => ( + + query={graphql` + query ExpertSelectionQuery($storyID: ID) { + ...ExpertSelectionContainer_query @arguments(storyID: $storyID) + } + `} + cacheConfig={{ force: true }} + variables={{ + storyID, + }} + render={({ error, props }: any) => { + if (error) { + return
    {error.message}
    ; + } + if (!props) { + return ( + + + + ); + } + + return ; + }} + /> +); + +export default ExpertSelectionQuery; diff --git a/src/core/client/stream/tabs/Configure/Q&A/QAConfigContainer.tsx b/src/core/client/stream/tabs/Configure/Q&A/QAConfigContainer.tsx new file mode 100644 index 000000000..012d18274 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/QAConfigContainer.tsx @@ -0,0 +1,59 @@ +import React, { FunctionComponent, useCallback, useState } from "react"; + +import { + graphql, + useMutation, + withFragmentContainer, +} from "coral-framework/lib/relay"; +import { GQLSTORY_MODE } from "coral-framework/schema"; + +import { QAConfigContainer_story } from "coral-stream/__generated__/QAConfigContainer_story.graphql"; + +import DisableQA from "./DisableQA"; +import EnableQA from "./EnableQA"; +import ExpertSelectionQuery from "./ExpertSelectionQuery"; +import UpdateStoryModeMutation from "./UpdateStoryModeMutation"; + +interface Props { + story: QAConfigContainer_story; +} + +const QAConfigContainer: FunctionComponent = ({ story }) => { + const [waiting, setWaiting] = useState(false); + const updateStoryMode = useMutation(UpdateStoryModeMutation); + + const handleOnClick = useCallback(async () => { + if (!waiting) { + setWaiting(true); + if (story.settings.mode === GQLSTORY_MODE.COMMENTS) { + updateStoryMode({ storyID: story.id, mode: GQLSTORY_MODE.QA }); + } else { + updateStoryMode({ storyID: story.id, mode: GQLSTORY_MODE.COMMENTS }); + } + setWaiting(false); + } + }, [waiting, setWaiting, story, updateStoryMode]); + + const isQA = story.settings.mode === GQLSTORY_MODE.QA; + + return isQA ? ( + <> + + + + ) : ( + + ); +}; + +const enhanced = withFragmentContainer({ + story: graphql` + fragment QAConfigContainer_story on Story { + id + settings { + mode + } + } + `, +})(QAConfigContainer); +export default enhanced; diff --git a/src/core/client/stream/tabs/Configure/Q&A/RemoveExpertMutation.ts b/src/core/client/stream/tabs/Configure/Q&A/RemoveExpertMutation.ts new file mode 100644 index 000000000..aecfe7a56 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/RemoveExpertMutation.ts @@ -0,0 +1,45 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { RemoveExpertMutation } from "coral-stream/__generated__/RemoveExpertMutation.graphql"; + +let clientMutationId = 0; + +const RemoveExpertMutation = createMutation( + "removeExpert", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation RemoveExpertMutation($input: RemoveExpertInput!) { + removeStoryExpert(input: $input) { + story { + id + settings { + experts { + id + username + email + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + storyID: input.storyID, + userID: input.userID, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default RemoveExpertMutation; diff --git a/src/core/client/stream/tabs/Configure/Q&A/UpdateStoryModeMutation.ts b/src/core/client/stream/tabs/Configure/Q&A/UpdateStoryModeMutation.ts new file mode 100644 index 000000000..21fd8c45b --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/UpdateStoryModeMutation.ts @@ -0,0 +1,47 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { UpdateStoryModeMutation } from "coral-stream/__generated__/UpdateStoryModeMutation.graphql"; + +let clientMutationId = 0; + +const UpdateStoryModeMutation = createMutation( + "disableQA", + ( + environment: Environment, + input: MutationInput & { + storyID: string; + mode: string; + } + ) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation UpdateStoryModeMutation($input: UpdateStoryModeInput!) { + updateStoryMode(input: $input) { + story { + id + settings { + mode + } + } + clientMutationId + } + } + `, + variables: { + input: { + storyID: input.storyID, + mode: input.mode, + clientMutationId: (clientMutationId++).toString(), + }, + }, + }) +); + +export default UpdateStoryModeMutation; diff --git a/src/core/client/stream/tabs/Configure/Q&A/index.ts b/src/core/client/stream/tabs/Configure/Q&A/index.ts new file mode 100644 index 000000000..3c4681af4 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/Q&A/index.ts @@ -0,0 +1 @@ +export { default, default as QAConfigContainer } from "./QAConfigContainer"; diff --git a/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap b/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap index 6d4fd6d20..ea472c406 100644 --- a/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap +++ b/src/core/client/stream/test/comments/featured/__snapshots__/renderFeaturedStream.spec.tsx.snap @@ -424,7 +424,11 @@ exports[`renders comment stream 1`] = `
    + > +
    +
    +
    +
    +
    +
    +
    - - Staff - + + Staff + +
    +
    +
    - - Staff - + + Staff + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    - - Staff - + + Staff + +
    +
    +
    +
    +
    - Configure this Comment Stream + Configure this Stream +
    + +
    diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index a7c5e2215..3b0ce12a5 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -6,6 +6,7 @@ import { GQLSettings, GQLSite, GQLStory, + GQLSTORY_MODE, GQLTAG, GQLTag, GQLUser, @@ -527,6 +528,7 @@ export const baseStory = createFixture({ totalPublished: 0, tags: { FEATURED: 0, + UNANSWERED: 0, }, }, settings: { @@ -539,6 +541,8 @@ export const baseStory = createFixture({ enabled: true, configurable: true, }, + mode: GQLSTORY_MODE.COMMENTS, + experts: [], }, site, }); diff --git a/src/core/client/stream/test/helpers/fixture.ts b/src/core/client/stream/test/helpers/fixture.ts index 8aff9fa2a..4aa609a10 100644 --- a/src/core/client/stream/test/helpers/fixture.ts +++ b/src/core/client/stream/test/helpers/fixture.ts @@ -5,6 +5,7 @@ import { GQLCOMMENT_STATUS, GQLMODERATION_MODE, GQLStory, + GQLSTORY_MODE, GQLUser, GQLUSER_ROLE, GQLUSER_STATUS, @@ -124,6 +125,7 @@ export function createStory(createComments = true) { totalPublished: 0, tags: { FEATURED: 0, + UNANSWERED: 0, }, }, settings: { @@ -136,6 +138,8 @@ export function createStory(createComments = true) { enabled: true, configurable: true, }, + mode: GQLSTORY_MODE.COMMENTS, + experts: [], }, }) ); diff --git a/src/core/client/ui/components/TextField/TextField.tsx b/src/core/client/ui/components/TextField/TextField.tsx index 71dbbf5df..3e65454c5 100644 --- a/src/core/client/ui/components/TextField/TextField.tsx +++ b/src/core/client/ui/components/TextField/TextField.tsx @@ -64,6 +64,8 @@ export interface TextFieldProps { * onChange */ onChange?: EventHandler>; + onKeyPress?: React.EventHandler; + onKeyDown?: React.EventHandler; disabled?: boolean; diff --git a/src/core/client/ui/components/v2/TextField/TextField.tsx b/src/core/client/ui/components/v2/TextField/TextField.tsx index 753ecefae..ded7e084f 100644 --- a/src/core/client/ui/components/v2/TextField/TextField.tsx +++ b/src/core/client/ui/components/v2/TextField/TextField.tsx @@ -1,8 +1,12 @@ import cn from "classnames"; -import React, { AllHTMLAttributes, ChangeEvent, EventHandler } from "react"; +import React, { + AllHTMLAttributes, + ChangeEvent, + EventHandler, + FunctionComponent, +} from "react"; import { withStyles } from "coral-ui/hocs"; -import { FunctionComponent } from "react"; import styles from "./TextField.css"; @@ -60,6 +64,8 @@ export interface TextFieldProps { * onChange */ onChange?: EventHandler>; + onKeyPress?: React.EventHandler; + onKeyDown?: React.EventHandler; disabled?: boolean; diff --git a/src/core/server/graph/loaders/Comments.ts b/src/core/server/graph/loaders/Comments.ts index a9ce5ebea..29a75eee7 100644 --- a/src/core/server/graph/loaders/Comments.ts +++ b/src/core/server/graph/loaders/Comments.ts @@ -200,12 +200,17 @@ export default (ctx: Context) => ({ "tags.type": tag, }, }).then(primeCommentsFromConnection(ctx)), - forStory: (storyID: string, { first, orderBy, after }: StoryToCommentsArgs) => + forStory: ( + storyID: string, + { first, orderBy, after, tag }: StoryToCommentsArgs + ) => retrieveCommentStoryConnection(ctx.mongo, ctx.tenant.id, storyID, { first: defaultTo(first, 10), orderBy: defaultTo(orderBy, GQLCOMMENT_SORT.CREATED_AT_DESC), after, filter: { + // Ensure we filter by the requested tag + ...tagFilter(tag), // Only get Comments that are top level. If the client wants to load // another layer, they can request another nested connection. parentID: null, diff --git a/src/core/server/graph/mutators/Stories.ts b/src/core/server/graph/mutators/Stories.ts index aa2611606..e60239a27 100644 --- a/src/core/server/graph/mutators/Stories.ts +++ b/src/core/server/graph/mutators/Stories.ts @@ -5,24 +5,30 @@ import GraphContext from "coral-server/graph/context"; import { mapFieldsetToErrorCodes } from "coral-server/graph/errors"; import { Story } from "coral-server/models/story"; import { + addStoryExpert, close, create, merge, open, remove, + removeStoryExpert, update, updateSettings, + updateStoryMode, } from "coral-server/services/stories"; import { scrape } from "coral-server/services/stories/scraper"; import { + GQLAddExpertInput, GQLCloseStoryInput, GQLCreateStoryInput, GQLMergeStoriesInput, GQLOpenStoryInput, + GQLRemoveExpertInput, GQLRemoveStoryInput, GQLScrapeStoryInput, GQLUpdateStoryInput, + GQLUpdateStoryModeInput, GQLUpdateStorySettingsInput, } from "coral-server/graph/schema/__generated__/types"; @@ -70,4 +76,10 @@ export const Stories = (ctx: GraphContext) => ({ remove(ctx.mongo, ctx.tenant, input.id, input.includeComments), scrape: async (input: GQLScrapeStoryInput): Promise | null> => scrape(ctx.mongo, ctx.config, ctx.tenant.id, input.id), + updateStoryMode: async (input: GQLUpdateStoryModeInput) => + updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode), + addStoryExpert: async (input: GQLAddExpertInput) => + addStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID), + removeStoryExpert: async (input: GQLRemoveExpertInput) => + removeStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID), }); diff --git a/src/core/server/graph/resolvers/Mutation.ts b/src/core/server/graph/resolvers/Mutation.ts index c194922b7..5db79823e 100644 --- a/src/core/server/graph/resolvers/Mutation.ts +++ b/src/core/server/graph/resolvers/Mutation.ts @@ -256,6 +256,18 @@ export const Mutation: Required> = { site: await ctx.mutators.Sites.update(input), clientMutationId: input.clientMutationId, }), + updateStoryMode: async (source, { input }, ctx) => ({ + story: await ctx.mutators.Stories.updateStoryMode(input), + clientMutationId: input.clientMutationId, + }), + addStoryExpert: async (source, { input }, ctx) => ({ + story: await ctx.mutators.Stories.addStoryExpert(input), + clientMutationId: input.clientMutationId, + }), + removeStoryExpert: async (source, { input }, ctx) => ({ + story: await ctx.mutators.Stories.removeStoryExpert(input), + clientMutationId: input.clientMutationId, + }), createWebhookEndpoint: async ( source, { input: { clientMutationId, ...input } }, diff --git a/src/core/server/graph/resolvers/StorySettings.ts b/src/core/server/graph/resolvers/StorySettings.ts index 67d9a63ae..251c1028a 100644 --- a/src/core/server/graph/resolvers/StorySettings.ts +++ b/src/core/server/graph/resolvers/StorySettings.ts @@ -1,6 +1,9 @@ import * as story from "coral-server/models/story"; -import { GQLStorySettingsTypeResolver } from "../schema/__generated__/types"; +import { + GQLSTORY_MODE, + GQLStorySettingsTypeResolver, +} from "../schema/__generated__/types"; export const StorySettings: GQLStorySettingsTypeResolver< story.StorySettings @@ -18,4 +21,18 @@ export const StorySettings: GQLStorySettingsTypeResolver< enabled: false, }; }, + mode: s => { + if (s.mode) { + return s.mode; + } + + return GQLSTORY_MODE.COMMENTS; + }, + experts: (s, input, ctx) => { + if (s.expertIDs) { + return ctx.loaders.Users.user.loadMany(s.expertIDs); + } + + return []; + }, }; diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index 6bf856548..d66a4c4a5 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -372,6 +372,18 @@ type WordList { suspect: [String!]! } +enum STORY_MODE { + """ + Coments is when a story is used for general commenting. + """ + COMMENTS + + """ + QA is used for when the story is in Q&A mode. + """ + QA +} + ################################################################################ ## Moderation ################################################################################ @@ -2345,6 +2357,18 @@ enum TAG { FEATURED is used when a Comment is marked as such by a staff member. """ FEATURED + + """ + EXPERT is used when an a Comment is written by a User that is assigned as + an expert on a story. + """ + EXPERT + + """ + UNANSWERED is used when a Comment is written by a User and is unanswered + by an expert for that Comment's story. + """ + UNANSWERED } """ @@ -2376,6 +2400,11 @@ type CommentTagCounts { FEATURED is the count of Comment's with the FEATURED tag. """ FEATURED: Int! + + """ + UNANSWERED is the count of Comment's with the UNANSWERED tag. + """ + UNANSWERED: Int! } """ @@ -2720,6 +2749,19 @@ type StorySettings { messageBox stores settings related to the Story Message Box. """ messageBox: StoryMessageBox! + + """ + mode is whether the story stream is in commenting or Q&A mode. + This will determine the appearance of the stream and how it functions. + """ + mode: STORY_MODE! + + """ + experts are used during Q&A mode to assign users to answer questions + on a Q&A stream. It is an optional parameter and is only used when + the story stream is in Q&A mode. + """ + experts: [User!]! } """ @@ -2783,6 +2825,14 @@ type Story { first: Int = 20 @constraint(max: 50) orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor + + """ + This is a workaround to allow filtering for current Q&A + functionality. This is used to filter on UNANSWERED to + populate its corresponding Unanswered questions tab. In + the future, we want a dedicated edge for unansweredComments. + """ + tag: TAG ): CommentsConnection! """ @@ -5827,6 +5877,97 @@ type DisableFeatureFlagPayload { flags: [FEATURE_FLAG!]! } +######################### +# Add / Remove Expert +######################### + +input AddExpertInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + storyID is the story to add the expert to. + """ + storyID: ID! + + """ + userID is the user to add as an expert to the story. + """ + userID: ID! +} + +type AddExpertPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + story is the resultant story the expert was added to. + """ + story: Story! +} + +input RemoveExpertInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + storyID is the story to remove the expert from. + """ + storyID: ID! + + """ + userID is the user to remove as an expert from the story. + """ + userID: ID! +} + +type RemoveExpertPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! + + """ + story is the resultant story the expert was removed from. + """ + story: Story! +} + +input UpdateStoryModeInput { + """ + storyID is the story id to enable Q&A on. + """ + storyID: ID! + + """ + mode is the mode to set the story to. + """ + mode: STORY_MODE! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdateStoryModePayload { + """ + story is the resultant story that Q&A was enabled on. + """ + story: Story! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -6199,10 +6340,16 @@ type Mutation { input: DisableFeatureFlagInput! ): DisableFeatureFlagPayload! @auth(roles: [ADMIN]) + """ + createAnnouncement creates a global announcement. + """ createAnnouncement( input: CreateAnnouncementInput! ): CreateAnnouncementPayload! @auth(roles: [ADMIN]) + """ + deleteAnnouncement removes a global announcement. + """ deleteAnnouncement( input: DeleteAnnouncementInput! ): DeleteAnnouncementPayload! @auth(roles: [ADMIN]) @@ -6253,6 +6400,26 @@ type Mutation { rotateWebhookEndpointSecret( input: RotateWebhookEndpointSecretInput! ): RotateWebhookEndpointSecretPayload! @auth(roles: [ADMIN]) + + """ + updateStoryMode will set the story mode. + """ + updateStoryMode(input: UpdateStoryModeInput!): UpdateStoryModePayload! + @auth(roles: [ADMIN, MODERATOR]) + + """ + addStoryExpert adds an expert to a story. + """ + addStoryExpert( + input: AddExpertInput! + ): AddExpertPayload! @auth(roles: [ADMIN, MODERATOR]) + + """ + removeStoryExpert removes an expert from a story. + """ + removeStoryExpert( + input: RemoveExpertInput! + ): RemoveExpertPayload! @auth(roles: [ADMIN, MODERATOR]) } ################## diff --git a/src/core/server/models/comment/comment.ts b/src/core/server/models/comment/comment.ts index 67652fcb0..49b319bd4 100644 --- a/src/core/server/models/comment/comment.ts +++ b/src/core/server/models/comment/comment.ts @@ -926,12 +926,9 @@ export async function retrieveStoryCommentTagCounts( // Build up the $match query. const $match: FilterQuery = { tenantID, - // We're filtering only for featured comments for now because that's all - // that is returned by the tag counts at the moment. If we ever extend this - // we should switch this out to something like - // `"tags.type": { $exists: true }` to ensure that we are using the - // specified index. - "tags.type": GQLTAG.FEATURED, + // ensure we are using the tags.type index, this + // currently includes FEATURED and UNANSWERED tags + "tags.type": { $exists: true }, // Only show published comment's tag counts. status: { $in: PUBLISHED_STATUSES }, }; @@ -986,6 +983,7 @@ export async function retrieveStoryCommentTagCounts( // missing/extra tags here. { [GQLTAG.FEATURED]: 0, + [GQLTAG.UNANSWERED]: 0, } ); }); diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index a2b5235f6..c23ccb8fe 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -6,6 +6,7 @@ import { dotize } from "coral-common/utils/dotize"; import { DuplicateStoryIDError, DuplicateStoryURLError, + StoryNotFoundError, } from "coral-server/errors"; import { Connection, @@ -18,6 +19,7 @@ import { TenantResource } from "coral-server/models/tenant"; import { stories as collection } from "coral-server/services/mongodb/collections"; import { + GQLSTORY_MODE, GQLStoryMetadata, GQLStorySettings, } from "coral-server/graph/schema/__generated__/types"; @@ -30,8 +32,26 @@ import { export * from "./helpers"; +export interface StreamModeSettings { + /** + * mode is whether the story stream is in commenting or Q&A mode. + * This will determine the appearance of the stream and how it functions. + * This is an optional parameter and if unset, defaults to commenting. + */ + mode?: GQLSTORY_MODE; + + /** + * experts are used during Q&A mode to assign users to answer questions + * on a Q&A stream. It is an optional parameter and is only used when + * the story stream is in Q&A mode. + */ + expertIDs?: string[]; +} + export type StorySettings = DeepPartial< - Pick & GlobalModerationSettings + StreamModeSettings & + GlobalModerationSettings & + Pick >; export type StoryMetadata = GQLStoryMetadata; @@ -544,3 +564,102 @@ export const updateStoryCounts = ( id: string, commentCounts: FirstDeepPartial ) => updateRelatedCommentCounts(collection(mongo), tenantID, id, commentCounts); + +export async function addExpert( + mongo: Db, + tenantID: string, + storyID: string, + userID: string +) { + const story = await collection(mongo).findOne({ tenantID, id: storyID }); + if (!story) { + throw new StoryNotFoundError(storyID); + } + + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id: storyID, + }, + { + $addToSet: { + "settings.expertIDs": userID, + }, + }, + { + returnOriginal: false, + } + ); + + if (!result.ok) { + throw new Error("unable to add expert to story"); + } + + return result.value || null; +} + +export async function removeExpert( + mongo: Db, + tenantID: string, + storyID: string, + userID: string +) { + const story = await collection(mongo).findOne({ tenantID, id: storyID }); + if (!story) { + throw new StoryNotFoundError(storyID); + } + + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id: storyID, + }, + { + $pull: { + "settings.expertIDs": userID, + }, + }, + { + returnOriginal: false, + } + ); + + if (!result.ok) { + throw new Error("unable to remove expert from story"); + } + + return result.value || null; +} + +export async function setStoryMode( + mongo: Db, + tenantID: string, + storyID: string, + mode: GQLSTORY_MODE +) { + const story = await collection(mongo).findOne({ tenantID, id: storyID }); + if (!story) { + throw new StoryNotFoundError(storyID); + } + + const result = await collection(mongo).findOneAndUpdate( + { + tenantID, + id: storyID, + }, + { + $set: { + "settings.mode": mode, + }, + }, + { + returnOriginal: false, + } + ); + + if (!result.ok) { + throw new Error("unable to enable Q&A on story"); + } + + return result.value || null; +} diff --git a/src/core/server/services/comments/pipeline/phases/approve.ts b/src/core/server/services/comments/pipeline/phases/approve.ts new file mode 100644 index 000000000..fafb57a73 --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/approve.ts @@ -0,0 +1,41 @@ +import { CommentTag } from "coral-server/models/comment/tag"; +import { + IntermediateModerationPhase, + IntermediatePhaseResult, +} from "coral-server/services/comments/pipeline"; + +import { + GQLCOMMENT_STATUS, + GQLTAG, +} from "coral-server/graph/schema/__generated__/types"; + +interface Result { + status?: GQLCOMMENT_STATUS; + tags: CommentTag[]; +} + +export const approve: IntermediateModerationPhase = ({ + tags, + now, +}): IntermediatePhaseResult | void => { + const result: Result = { + tags: [], + }; + + // If the user is tagged STAFF or EXPERT then we approve + // their comment. + // + // STAFF: all staff comments are automatically approved. + // + // EXPERT: when in Q&A mode, all expert comments are + // automatically approved. We will only see EXPERT + // tags assigned when we are in Q&A mode, so we can + // trust this simple tag type check. + if ( + tags.some(tag => tag.type === GQLTAG.STAFF || tag.type === GQLTAG.EXPERT) + ) { + result.status = GQLCOMMENT_STATUS.APPROVED; + } + + return result; +}; diff --git a/src/core/server/services/comments/pipeline/phases/index.ts b/src/core/server/services/comments/pipeline/phases/index.ts index 70506a164..6507dd2cf 100644 --- a/src/core/server/services/comments/pipeline/phases/index.ts +++ b/src/core/server/services/comments/pipeline/phases/index.ts @@ -1,5 +1,6 @@ import { IntermediateModerationPhase } from "coral-server/services/comments/pipeline"; +import { approve } from "./approve"; import { commentingDisabled } from "./commentingDisabled"; import { commentLength } from "./commentLength"; import { detectLinks } from "./detectLinks"; @@ -13,6 +14,8 @@ import { repeatPost } from "./repeatPost"; import { spam } from "./spam"; import { staff } from "./staff"; import { storyClosed } from "./storyClosed"; +import { tagExpertAnswers } from "./tagExpertAnswers"; +import { tagUnansweredQuestions } from "./tagUnansweredQuestions"; import { toxic } from "./toxic"; import { wordList } from "./wordList"; @@ -27,7 +30,10 @@ export const moderationPhases: IntermediateModerationPhase[] = [ purify, repeatPost, wordList, + tagExpertAnswers, staff, + tagUnansweredQuestions, + approve, toxic, recentCommentHistory, spam, diff --git a/src/core/server/services/comments/pipeline/phases/staff.ts b/src/core/server/services/comments/pipeline/phases/staff.ts index cc100a2c2..638f27095 100755 --- a/src/core/server/services/comments/pipeline/phases/staff.ts +++ b/src/core/server/services/comments/pipeline/phases/staff.ts @@ -1,5 +1,4 @@ import { - GQLCOMMENT_STATUS, GQLTAG, GQLUSER_ROLE, } from "coral-server/graph/schema/__generated__/types"; @@ -15,7 +14,6 @@ export const staff: IntermediateModerationPhase = ({ }): IntermediatePhaseResult | void => { if (author.role !== GQLUSER_ROLE.COMMENTER) { return { - status: GQLCOMMENT_STATUS.APPROVED, tags: [ { type: GQLTAG.STAFF, diff --git a/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts b/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts new file mode 100644 index 000000000..03fd70086 --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/tagExpertAnswers.ts @@ -0,0 +1,34 @@ +import { + IntermediateModerationPhase, + IntermediatePhaseResult, +} from "coral-server/services/comments/pipeline"; + +import { + GQLSTORY_MODE, + GQLTAG, +} from "coral-server/graph/schema/__generated__/types"; + +export const tagExpertAnswers: IntermediateModerationPhase = ({ + author, + now, + story, +}): IntermediatePhaseResult | void => { + if ( + story.settings.mode === GQLSTORY_MODE.QA && + story.settings.expertIDs && + story.settings.expertIDs.some(id => id === author.id) + ) { + return { + tags: [ + { + type: GQLTAG.EXPERT, + createdAt: now, + }, + { + type: GQLTAG.FEATURED, + createdAt: now, + }, + ], + }; + } +}; diff --git a/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts b/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts new file mode 100644 index 000000000..f228de008 --- /dev/null +++ b/src/core/server/services/comments/pipeline/phases/tagUnansweredQuestions.ts @@ -0,0 +1,42 @@ +import { + IntermediateModerationPhase, + IntermediatePhaseResult, +} from "coral-server/services/comments/pipeline"; + +import { + GQLSTORY_MODE, + GQLTAG, +} from "coral-server/graph/schema/__generated__/types"; + +export const tagUnansweredQuestions: IntermediateModerationPhase = ({ + comment, + story, + now, +}): IntermediatePhaseResult | void => { + // We only show unanswered tags in Q&A. + if (story.settings.mode !== GQLSTORY_MODE.QA) { + return; + } + + // Only top level comments are questions, + // everything else is replies for Q&A. + if (comment.parentID) { + return; + } + + // If we have no experts, or the current author is + // not an expert, then this is an UNANSWERED comment. + if ( + !story.settings.expertIDs || + story.settings.expertIDs.every(id => id !== comment.authorID) + ) { + return { + tags: [ + { + type: GQLTAG.UNANSWERED, + createdAt: now, + }, + ], + }; + } +}; diff --git a/src/core/server/services/comments/pipeline/pipeline.ts b/src/core/server/services/comments/pipeline/pipeline.ts index 91b4815bf..61d81891d 100644 --- a/src/core/server/services/comments/pipeline/pipeline.ts +++ b/src/core/server/services/comments/pipeline/pipeline.ts @@ -6,7 +6,7 @@ import { Config } from "coral-server/config"; import { Logger } from "coral-server/logger"; import { CreateActionInput } from "coral-server/models/action/comment"; import { - EditCommentInput, + CreateCommentInput, RevisionMetadata, } from "coral-server/models/comment"; import { CommentTag } from "coral-server/models/comment/tag"; @@ -40,7 +40,7 @@ export interface ModerationPhaseContextInput { log: Logger; story: Story; tenant: Tenant; - comment: RequireProperty, "body">; + comment: RequireProperty, "body">; author: User; now: Date; action: "NEW" | "EDIT"; @@ -64,6 +64,7 @@ export type IntermediatePhaseResult = Partial | void; export interface IntermediateModerationPhaseContext extends ModerationPhaseContext { metadata: RevisionMetadata; + tags: CommentTag[]; } export type IntermediateModerationPhase = ( @@ -103,6 +104,7 @@ export const compose = ( ...context.comment, body: final.body, }, + tags: final.tags, htmlStripped, metadata: final.metadata, }); diff --git a/src/core/server/services/stories/index.ts b/src/core/server/services/stories/index.ts index 58a990487..b5e787d9d 100644 --- a/src/core/server/services/stories/index.ts +++ b/src/core/server/services/stories/index.ts @@ -3,7 +3,7 @@ import { Db } from "mongodb"; import isNonNullArray from "coral-common/helpers/isNonNullArray"; import { Config } from "coral-server/config"; -import { StoryURLInvalidError } from "coral-server/errors"; +import { StoryURLInvalidError, UserNotFoundError } from "coral-server/errors"; import { StoryCreatedCoralEvent } from "coral-server/events"; import { CoralEventPublisherBroker } from "coral-server/events/publisher"; import logger from "coral-server/logger"; @@ -20,6 +20,7 @@ import { removeStoryComments, } from "coral-server/models/comment"; import { + addExpert, closeStory, createStory, CreateStoryInput, @@ -28,10 +29,12 @@ import { findStory, FindStoryInput, openStory, + removeExpert, removeStories, removeStory, retrieveManyStories, retrieveStory, + setStoryMode, Story, updateStory, updateStoryCounts, @@ -40,10 +43,13 @@ import { UpdateStorySettingsInput, } from "coral-server/models/story"; import { Tenant } from "coral-server/models/tenant"; +import { retrieveUser } from "coral-server/models/user"; import { ScraperQueue } from "coral-server/queue/tasks/scraper"; import { findSiteByURL } from "coral-server/services/sites"; import { scrape } from "coral-server/services/stories/scraper"; +import { GQLSTORY_MODE } from "coral-server/graph/schema/__generated__/types"; + export type FindStory = FindStoryInput; export async function find(mongo: Db, tenant: Tenant, input: FindStory) { @@ -367,3 +373,40 @@ export async function merge( // Return the story that had the other stories merged into. return destinationStory; } + +export async function addStoryExpert( + mongo: Db, + tenant: Tenant, + storyID: string, + userID: string +) { + const user = await retrieveUser(mongo, tenant.id, userID); + if (!user) { + throw new UserNotFoundError(userID); + } + + return addExpert(mongo, tenant.id, storyID, userID); +} + +export async function removeStoryExpert( + mongo: Db, + tenant: Tenant, + storyID: string, + userID: string +) { + const user = await retrieveUser(mongo, tenant.id, userID); + if (!user) { + throw new UserNotFoundError(userID); + } + + return removeExpert(mongo, tenant.id, storyID, userID); +} + +export async function updateStoryMode( + mongo: Db, + tenant: Tenant, + storyID: string, + mode: GQLSTORY_MODE +) { + return setStoryMode(mongo, tenant.id, storyID, mode); +} diff --git a/src/core/server/stacks/createComment.ts b/src/core/server/stacks/createComment.ts index a793b14be..729bd81f6 100644 --- a/src/core/server/stacks/createComment.ts +++ b/src/core/server/stacks/createComment.ts @@ -16,6 +16,7 @@ import { filterDuplicateActions, } from "coral-server/models/action/comment"; import { + Comment, createComment, CreateCommentInput, pushChildCommentIDOntoParent, @@ -28,10 +29,12 @@ import { } from "coral-server/models/comment/helpers"; import { retrieveStory, + Story, updateStoryLastCommentedAt, } from "coral-server/models/story"; import { Tenant } from "coral-server/models/tenant"; import { User } from "coral-server/models/user"; +import { removeTag } from "coral-server/services/comments"; import { addCommentActions, CreateAction, @@ -48,6 +51,12 @@ import { AugmentedRedis } from "coral-server/services/redis"; import { updateUserLastCommentID } from "coral-server/services/users"; import { Request } from "coral-server/types/express"; +import { + GQLSTORY_MODE, + GQLTAG, +} from "coral-server/graph/schema/__generated__/types"; + +import approveComment from "./approveComment"; import { publishChanges, updateAllCommentCounts } from "./helpers"; export type CreateComment = Omit< @@ -55,6 +64,55 @@ export type CreateComment = Omit< "status" | "metadata" | "ancestorIDs" | "actionCounts" | "tags" | "siteID" >; +const markCommentAsAnswered = async ( + mongo: Db, + redis: AugmentedRedis, + config: Config, + broker: CoralEventPublisherBroker, + tenant: Tenant, + comment: Readonly, + story: Story, + author: User, + now: Date +) => { + // We only process this if we're in Q&A mode. + if (story.settings.mode !== GQLSTORY_MODE.QA) { + return; + } + + // Answers are always a reply to another comment. + // If we have a parentID and a parentRevisionID, then + // we have a parent, which means we are replying. + if (!comment.parentID || !comment.parentRevisionID) { + return; + } + + // If we have no experts, there cannot be anyone + // providing expert answers. + if (!story.settings.expertIDs) { + return; + } + + // If we have experts and this reply is created by + // one of them, then this is an expert's answer. + if (story.settings.expertIDs.some(id => id === author.id)) { + // We need to mark this question as answered now. + // We can now remove the unanswered tag. + await removeTag(mongo, tenant, comment.parentID, GQLTAG.UNANSWERED); + await approveComment( + mongo, + redis, + config, + broker, + tenant, + comment.parentID, + comment.parentRevisionID, + author.id, + now + ); + } +}; + export default async function create( mongo: Db, redis: AugmentedRedis, @@ -180,6 +238,17 @@ export default async function create( await Promise.all([ updateUserLastCommentID(redis, tenant, author, comment.id), updateStoryLastCommentedAt(mongo, tenant.id, story.id, now), + markCommentAsAnswered( + mongo, + redis, + config, + broker, + tenant, + comment, + story, + author, + now + ), ]); // Pull the revision out. diff --git a/src/core/server/stacks/editComment.ts b/src/core/server/stacks/editComment.ts index 79a9f3376..e92ca613f 100644 --- a/src/core/server/stacks/editComment.ts +++ b/src/core/server/stacks/editComment.ts @@ -111,7 +111,11 @@ export default async function edit( config, story, tenant, - comment: input, + comment: { + ...originalStaleComment, + ...input, + authorID: author.id, + }, author, req, now, diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index f63e3fbee..184e3f15a 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -168,6 +168,47 @@ comments-rejectedTombstone = comments-featuredTag = Featured +### Q&A + +general-tabBar-qaTab = Q&A + +qa-answeredTab = Answered +qa-allCommentsTab = All + +qa-noQuestionsAtAll = + There are no questions on this story. +qa-noQuestionsYet = + There are no questions yet. Why don't you ask one? +qa-viewNew = + { $count -> + [1] View {$count} New Question + *[other] View {$count} New Questions + } + +qa-postQuestionForm-rteLabel = Post a question +qa-postQuestionForm-rte = + .placeholder = { qa-postQuestionForm-rteLabel } +qa-postQuestionFormFake-rte = + .placeholder = { qa-postQuestionForm-rteLabel } + +qa-sortMenu-mostVoted = Most Voted + +qa-answered-tag = answered +qa-expert-tag = expert + +qa-reaction-vote = Vote +qa-reaction-voted = Voted + +qa-unansweredTab-doneAnswering = Done + +qa-expert-email = ({ $email }) + +qa-answeredTooltip-how = How is a question answered? +qa-answeredTooltip-answeredComments = + Questions are answered by a Q&A expert. +qa-answeredTooltip-toggleButton = + .aria-label = Toggle answered questions tooltip + ### Account Deletion Stream comments-stream-deleteAccount-callOut-title = @@ -397,6 +438,8 @@ profile-changeUsername-close = Close ## Comment Stream configure-stream-title = Configure this Comment Stream +configure-stream-title-configureThisStream = + Configure this Stream configure-stream-apply = Apply configure-premod-title = Enable Pre-Moderation @@ -444,6 +487,34 @@ configure-openStream-openStream = Open Stream configure-moderateThisStream = Moderate this stream +configure-enableQA-title = Switch to Q&A Format +configure-enableQA-description = + The Q&A format allows community members to submit questions for chosen + experts to answer. +configure-enableQA-enableQA = Switch to Q&A + +configure-disableQA-title = Configure this Q&A +configure-disableQA-description = + The Q&A format allows community members to submit questions for chosen + experts to answer. +configure-disableQA-disableQA = Switch to Comments + +configure-experts-title = Add an Expert +configure-experts-filter-searchField = + .placeholder = Search by email or username + .aria-label = Search by email or username +configure-experts-filter-searchButton = + .aria-label = Search +configure-experts-filter-description = + Adds an Expert Badge to comments by registered users, only on this + page. New users must first sign up and open the comments on a page + to create their account. +configure-experts-search-none-found = No users were found with that email or username +configure-experts-remove-button = Remove +configure-experts-load-more = Load More +configure-experts-none-yet = There are currently no experts for this Q&A. +configure-experts-assigned-title = Experts + comments-tombstone-ignore = This comment is hidden because you ignored {$username} comments-tombstone-deleted = This comment is no longer available. The commenter has deleted their account.