From dfdea2b9d284ae1cbe74de9ee4d815f1436ee81f Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 30 Jul 2020 21:18:59 +0000 Subject: [PATCH] [CORL-1150] Discussions Tab (#3050) * feat: Added Site.topStories edge * feat: Added User.ongoingDiscussions edge * connect discussions tab to relay * style discussions tab * add message if no ongoing discussions * style site name in story row * fix: make line-heights relative * add custom class hooks * feat: condition display based on feature flag * add target attribute to story links * fix: expose some featureFlags * fix: fixed sorting * fix: copy fixes Co-authored-by: tessalt Co-authored-by: Chi Vinh Le --- src/core/client/stream/App/App.tsx | 10 +- .../client/stream/App/SetActiveTabMutation.ts | 2 +- src/core/client/stream/App/TabBar.tsx | 29 +++- .../client/stream/App/TabBarContainer.tsx | 89 ++++++++----- src/core/client/stream/App/TabBarQuery.tsx | 4 + .../App/__snapshots__/App.spec.tsx.snap | 7 + src/core/client/stream/classes.ts | 24 ++++ .../tabs/Discussions/DiscussionsContainer.tsx | 85 ++++++++++++ .../tabs/Discussions/DiscussionsHeader.css | 19 +++ .../tabs/Discussions/DiscussionsHeader.tsx | 36 +++++ .../tabs/Discussions/DiscussionsQuery.tsx | 126 ++++++++++++++++++ .../MostActiveDiscussionsContainer.css | 25 ++++ .../MostActiveDiscussionsContainer.tsx | 69 ++++++++++ .../MyOngoingDiscussionsContainer.css | 14 ++ .../MyOngoingDiscussionsContainer.tsx | 82 ++++++++++++ .../tabs/Discussions/StoryRowContainer.css | 37 +++++ .../tabs/Discussions/StoryRowContainer.tsx | 95 +++++++++++++ .../client/stream/tabs/Discussions/index.tsx | 1 + src/core/server/graph/loaders/Stories.ts | 27 ++++ src/core/server/graph/resolvers/Settings.ts | 41 ++++-- src/core/server/graph/resolvers/Site.ts | 13 ++ src/core/server/graph/resolvers/User.ts | 12 ++ src/core/server/graph/schema/schema.graphql | 21 ++- src/core/server/models/comment/comment.ts | 51 +++++++ src/locales/en-US/stream.ftl | 15 +++ 25 files changed, 886 insertions(+), 48 deletions(-) create mode 100644 src/core/client/stream/tabs/Discussions/DiscussionsContainer.tsx create mode 100644 src/core/client/stream/tabs/Discussions/DiscussionsHeader.css create mode 100644 src/core/client/stream/tabs/Discussions/DiscussionsHeader.tsx create mode 100644 src/core/client/stream/tabs/Discussions/DiscussionsQuery.tsx create mode 100644 src/core/client/stream/tabs/Discussions/MostActiveDiscussionsContainer.css create mode 100644 src/core/client/stream/tabs/Discussions/MostActiveDiscussionsContainer.tsx create mode 100644 src/core/client/stream/tabs/Discussions/MyOngoingDiscussionsContainer.css create mode 100644 src/core/client/stream/tabs/Discussions/MyOngoingDiscussionsContainer.tsx create mode 100644 src/core/client/stream/tabs/Discussions/StoryRowContainer.css create mode 100644 src/core/client/stream/tabs/Discussions/StoryRowContainer.tsx create mode 100644 src/core/client/stream/tabs/Discussions/index.tsx diff --git a/src/core/client/stream/App/App.tsx b/src/core/client/stream/App/App.tsx index 70d71c1bc..674402abc 100644 --- a/src/core/client/stream/App/App.tsx +++ b/src/core/client/stream/App/App.tsx @@ -11,12 +11,13 @@ import { import Comments from "../tabs/Comments"; import Configure from "../tabs/Configure"; +import Discussions from "../tabs/Discussions"; import Profile from "../tabs/Profile"; import TabBarQuery from "./TabBarQuery"; import styles from "./App.css"; -type TabValue = "COMMENTS" | "PROFILE" | "%future added value"; +type TabValue = "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value"; export interface AppProps { activeTab: TabValue; @@ -38,6 +39,13 @@ const App: FunctionComponent = (props) => { > + + + Promise; diff --git a/src/core/client/stream/App/TabBar.tsx b/src/core/client/stream/App/TabBar.tsx index f7d3fd721..1be8b16ae 100644 --- a/src/core/client/stream/App/TabBar.tsx +++ b/src/core/client/stream/App/TabBar.tsx @@ -8,12 +8,13 @@ import { Icon, MatchMedia, Tab, TabBar } from "coral-ui/components/v2"; import styles from "./TabBar.css"; -type TabValue = "COMMENTS" | "PROFILE" | "%future added value"; +type TabValue = "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value"; export interface Props { activeTab: TabValue; onTabClick: (tab: TabValue) => void; showProfileTab: boolean; + showDiscussionsTab: boolean; showConfigureTab: boolean; mode: "%future added value" | "COMMENTS" | "QA" | null; } @@ -69,6 +70,31 @@ const AppTabBar: FunctionComponent = (props) => { )} + {props.showDiscussionsTab && ( + + {matches ? ( + + Discussions + + ) : ( +
+ list_alt + +
Discussions
+
+
+ )} +
+ )} + {props.showProfileTab && ( = (props) => { )} )} + {props.showConfigureTab && ( { - private handleSetActiveTab = (tab: SetActiveTabInput["tab"]) => { - void this.props.setActiveTab({ tab }); - }; +export const TabBarContainer: FunctionComponent = ({ + local: { activeTab }, + viewer, + story, + settings, + setActiveTab, +}) => { + const handleSetActiveTab = useCallback( + (tab: SetActiveTabInput["tab"]) => { + void setActiveTab({ tab }); + }, + [setActiveTab] + ); - public render() { - const { - local: { activeTab }, - viewer, - story, - } = this.props; + const showDiscussionsTab = useMemo( + () => + !!viewer && + !!settings && + settings.featureFlags.includes(GQLFEATURE_FLAG.DISCUSSIONS), + [viewer, settings] + ); - return ( - - ); - } -} + const showConfigureTab = useMemo( + () => + !!viewer && + !!story && + story.canModerate && + can(viewer, Ability.CHANGE_STORY_CONFIGURATION), + [viewer, story] + ); + + return ( + + ); +}; const enhanced = withSetActiveTabMutation( withLocalStateContainer( @@ -81,6 +93,11 @@ const enhanced = withSetActiveTabMutation( } } `, + settings: graphql` + fragment TabBarContainer_settings on Settings { + featureFlags + } + `, })(TabBarContainer) ) ); diff --git a/src/core/client/stream/App/TabBarQuery.tsx b/src/core/client/stream/App/TabBarQuery.tsx index 839da7935..90c39aec4 100644 --- a/src/core/client/stream/App/TabBarQuery.tsx +++ b/src/core/client/stream/App/TabBarQuery.tsx @@ -28,6 +28,9 @@ class TabBarQuery extends Component { story(id: $storyID, url: $storyURL) { ...TabBarContainer_story } + settings { + ...TabBarContainer_settings + } } `} variables={{ @@ -41,6 +44,7 @@ class TabBarQuery extends Component { return ( diff --git a/src/core/client/stream/App/__snapshots__/App.spec.tsx.snap b/src/core/client/stream/App/__snapshots__/App.spec.tsx.snap index 82159a7a4..3004c9bc3 100644 --- a/src/core/client/stream/App/__snapshots__/App.spec.tsx.snap +++ b/src/core/client/stream/App/__snapshots__/App.spec.tsx.snap @@ -24,6 +24,13 @@ exports[`renders correctly 1`] = ` >
+ + + = (props) => { + const setActiveTab = useMutation(SetActiveTabMutation); + const onFullHistoryClick = useCallback( + async () => await setActiveTab({ tab: "PROFILE" }), + [] + ); + return ( + + + + + + + + + ); +}; + +const enhanced = withFragmentContainer({ + story: graphql` + fragment DiscussionsContainer_story on Story { + site { + id + ...MostActiveDiscussionsContainer_site + } + } + `, + viewer: graphql` + fragment DiscussionsContainer_viewer on User { + ...MyOngoingDiscussionsContainer_viewer + ...UserBoxContainer_viewer + } + `, + settings: graphql` + fragment DiscussionsContainer_settings on Settings { + ...MyOngoingDiscussionsContainer_settings + ...UserBoxContainer_settings + organization { + name + } + } + `, +})(DiscussionsContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Discussions/DiscussionsHeader.css b/src/core/client/stream/tabs/Discussions/DiscussionsHeader.css new file mode 100644 index 000000000..4fe08c119 --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/DiscussionsHeader.css @@ -0,0 +1,19 @@ +.root { + border-bottom: 2px solid var(--palette-grey-200); + padding-bottom: var(--spacing-2); + font-family: var(--font-family-primary); +} + +.header { + font-weight: var(--font-weight-primary-bold); + font-size: var(--font-size-4); + line-height: 1; + color: var(--palette-text-900); + margin: 0; +} + +.subHeader { + color: var(--palette-text-100); + font-size: var(--font-size-2); + line-height: 1.285; +} diff --git a/src/core/client/stream/tabs/Discussions/DiscussionsHeader.tsx b/src/core/client/stream/tabs/Discussions/DiscussionsHeader.tsx new file mode 100644 index 000000000..4dfdbca94 --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/DiscussionsHeader.tsx @@ -0,0 +1,36 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import CLASSES from "coral-stream/classes"; +import { Flex, HorizontalGutter, Icon } from "coral-ui/components/v2"; + +import styles from "./DiscussionsHeader.css"; + +interface Props { + header: React.ReactNode; + subHeader: React.ReactNode; + icon: string; +} + +const DiscussionsHeader: FunctionComponent = ({ + header, + subHeader, + icon, +}) => { + return ( + + + {icon} +

+ {header} +

+
+ +
+ {subHeader} +
+
+ ); +}; + +export default DiscussionsHeader; diff --git a/src/core/client/stream/tabs/Discussions/DiscussionsQuery.tsx b/src/core/client/stream/tabs/Discussions/DiscussionsQuery.tsx new file mode 100644 index 000000000..feb2851a7 --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/DiscussionsQuery.tsx @@ -0,0 +1,126 @@ +import { Localized } from "@fluent/react/compat"; +import { once } from "lodash"; +import React, { FunctionComponent, Suspense } from "react"; +import { graphql } from "react-relay"; + +import { polyfillCSSVarsForIE11 } from "coral-framework/helpers"; +import { + QueryRenderData, + QueryRenderer, + withLocalStateContainer, +} from "coral-framework/lib/relay"; +import useHandleIncompleteAccount from "coral-stream/common/useHandleIncompleteAccount"; +import { CallOut, Delay, Spinner } from "coral-ui/components/v2"; + +import { DiscussionsQuery as QueryTypes } from "coral-stream/__generated__/DiscussionsQuery.graphql"; +import { DiscussionsQueryLocal as Local } from "coral-stream/__generated__/DiscussionsQueryLocal.graphql"; + +const loadDiscussionsContainer = () => + import("./DiscussionsContainer" /* webpackChunkName: "profile" */).then( + (x) => { + // New css is loaded, take care of polyfilling those css vars for IE11. + void polyfillCSSVarsForIE11(); + return x; + } + ); +// (cvle) For some reason without `setTimeout` this request will block other requests. +const preloadDiscussionsContainer = once(() => + setTimeout(loadDiscussionsContainer) +); + +const LazyDiscussionsContainer = React.lazy(loadDiscussionsContainer); + +interface Props { + local: Local; +} + +export const render = ({ error, props }: QueryRenderData) => { + if (error) { + return ( + + {error.message} + + ); + } + + // TODO: use official React API once it has one :-) + preloadDiscussionsContainer(); + + if (props) { + if (!props.viewer) { + return ( + + + Error loading profile + + + ); + } + if (!props.story) { + return ( + + Story not found + + ); + } + return ( + }> + + + ); + } + + return ( + + + + ); +}; + +const DiscussionsQuery: FunctionComponent = ({ + local: { storyID, storyURL }, +}) => { + const handleIncompleteAccount = useHandleIncompleteAccount(); + return ( + + query={graphql` + query DiscussionsQuery($storyID: ID, $storyURL: String) { + story: stream(id: $storyID, url: $storyURL) { + ...DiscussionsContainer_story + } + viewer { + ...DiscussionsContainer_viewer + } + settings { + ...DiscussionsContainer_settings + } + } + `} + variables={{ + storyID, + storyURL, + }} + render={(data) => { + if (handleIncompleteAccount(data)) { + return null; + } + return render(data); + }} + /> + ); +}; + +const enhanced = withLocalStateContainer( + graphql` + fragment DiscussionsQueryLocal on Local { + storyID + storyURL + } + ` +)(DiscussionsQuery); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Discussions/MostActiveDiscussionsContainer.css b/src/core/client/stream/tabs/Discussions/MostActiveDiscussionsContainer.css new file mode 100644 index 000000000..3c4dfcc62 --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/MostActiveDiscussionsContainer.css @@ -0,0 +1,25 @@ +.root { + font-family: var(--font-family-primary); +} + +.list { + margin-left: 0; + padding-left: 0; + counter-reset: activeDiscussionsCounter; + list-style: none; +} + +.listItem { + counter-increment: activeDiscussionsCounter; + display: flex; + align-items: flex-start; +} + +.listItem:before { + content: counter(activeDiscussionsCounter); + color: var(--palette-text-100); + font-size: var(--font-size-3); + font-weight: var(--font-weight-primary-bold); + line-height: 1.125; + padding-right: var(--spacing-3); +} diff --git a/src/core/client/stream/tabs/Discussions/MostActiveDiscussionsContainer.tsx b/src/core/client/stream/tabs/Discussions/MostActiveDiscussionsContainer.tsx new file mode 100644 index 000000000..e5ac21f01 --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/MostActiveDiscussionsContainer.tsx @@ -0,0 +1,69 @@ +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"; +import CLASSES from "coral-stream/classes"; + +import { MostActiveDiscussionsContainer_site } from "coral-stream/__generated__/MostActiveDiscussionsContainer_site.graphql"; + +import DiscussionsHeader from "./DiscussionsHeader"; +import StoryRowContainer from "./StoryRowContainer"; + +import styles from "./MostActiveDiscussionsContainer.css"; + +interface Props { + site: MostActiveDiscussionsContainer_site; +} + +const MostActiveDiscussionsContainer: FunctionComponent = ({ site }) => { + return ( +
+ + Most active discussions + + } + subHeader={ + + <> + Ranked by the most comments received over the last 24 hours on{" "} + {site.name} + + + } + icon="show_chart" + /> +
    + {site.topStories.map((story) => ( +
  1. + +
  2. + ))} +
+
+ ); +}; + +const enhanced = withFragmentContainer({ + site: graphql` + fragment MostActiveDiscussionsContainer_site on Site { + id + name + topStories { + id + ...StoryRowContainer_story + } + } + `, +})(MostActiveDiscussionsContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Discussions/MyOngoingDiscussionsContainer.css b/src/core/client/stream/tabs/Discussions/MyOngoingDiscussionsContainer.css new file mode 100644 index 000000000..889e528ce --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/MyOngoingDiscussionsContainer.css @@ -0,0 +1,14 @@ +.root { + font-family: var(--font-family-primary); +} + +.list { + margin-left: 0; + padding-left: 0; +} + +.emptyList { + margin: var(--spacing-3) 0 0 0; + color: var(--palette-text-100); + font-weight: var(--font-weight-primary-semi-bold); +} diff --git a/src/core/client/stream/tabs/Discussions/MyOngoingDiscussionsContainer.tsx b/src/core/client/stream/tabs/Discussions/MyOngoingDiscussionsContainer.tsx new file mode 100644 index 000000000..20db00d4a --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/MyOngoingDiscussionsContainer.tsx @@ -0,0 +1,82 @@ +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"; +import CLASSES from "coral-stream/classes"; + +import { MyOngoingDiscussionsContainer_settings } from "coral-stream/__generated__/MyOngoingDiscussionsContainer_settings.graphql"; +import { MyOngoingDiscussionsContainer_viewer } from "coral-stream/__generated__/MyOngoingDiscussionsContainer_viewer.graphql"; + +import DiscussionsHeader from "./DiscussionsHeader"; +import StoryRowContainer from "./StoryRowContainer"; + +import styles from "./MyOngoingDiscussionsContainer.css"; + +interface Props { + viewer: MyOngoingDiscussionsContainer_viewer; + settings: MyOngoingDiscussionsContainer_settings; + currentSiteID: string; +} + +const MyOngoingDiscussionsContainer: FunctionComponent = ({ + viewer, + settings, + currentSiteID, +}) => { + return ( +
+ + My ongoing discussions + + } + subHeader={ + + <>Where you’ve commented across {settings.organization.name} + + } + icon="history" + /> + {viewer.ongoingDiscussions.length === 0 && ( + +

+ You haven’t participated in any discussions +

+
+ )} +
    + {viewer.ongoingDiscussions.map((story) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment MyOngoingDiscussionsContainer_viewer on User { + ongoingDiscussions { + id + ...StoryRowContainer_story + } + } + `, + settings: graphql` + fragment MyOngoingDiscussionsContainer_settings on Settings { + organization { + name + } + } + `, +})(MyOngoingDiscussionsContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Discussions/StoryRowContainer.css b/src/core/client/stream/tabs/Discussions/StoryRowContainer.css new file mode 100644 index 000000000..023daeed3 --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/StoryRowContainer.css @@ -0,0 +1,37 @@ +.root { + text-decoration: none; + font-family: var(--font-family-primary); + color: var(--palette-text-100); + margin-bottom: var(--spacing-4); + display: block; +} + +.siteName { + text-transform: uppercase; + font-size: var(--font-size-1); + margin-bottom: 0; + font-weight: var(--font-weight-primary-bold); +} + +.storyTitle { + font-weight: var(--font-weight-primary-semi-bold); + color: var(--palette-text-500); + font-size: var(--font-size-3); + line-height: 1.125; +} + +.commentsCount { + color: var(--palette-primary-500); + font-weight: var(--font-weight-primary-bold); + font-size: var(--font-size-1); +} + +.commentsCountIcon { + color: var(--palette-primary-400); + font-size: var(--font-size-1); +} + +.time { + font-size: var(--font-size-1); + font-weight: var(--font-weight-primary-semi-bold); +} diff --git a/src/core/client/stream/tabs/Discussions/StoryRowContainer.tsx b/src/core/client/stream/tabs/Discussions/StoryRowContainer.tsx new file mode 100644 index 000000000..f4f69af50 --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/StoryRowContainer.tsx @@ -0,0 +1,95 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { withFragmentContainer } from "coral-framework/lib/relay"; +import CLASSES from "coral-stream/classes"; +import { + Flex, + HorizontalGutter, + Icon, + RelativeTime, +} from "coral-ui/components/v2"; + +import { StoryRowContainer_story } from "coral-stream/__generated__/StoryRowContainer_story.graphql"; + +import styles from "./StoryRowContainer.css"; + +interface Props { + story: StoryRowContainer_story; + currentSiteID: string; +} + +const StoryRowContainer: FunctionComponent = ({ + story, + currentSiteID, +}) => { + return ( + + + {currentSiteID !== story.site.id && ( +

+ {story.site.name} +

+ )} + {story.metadata && story.metadata.title && ( +

+ {story.metadata.title} +

+ )} + + {story.metadata && story.metadata.publishedAt && ( + + )} + + + mode_comment + + + {story.commentCounts.totalPublished} + + + +
+
+ ); +}; + +const enhanced = withFragmentContainer({ + story: graphql` + fragment StoryRowContainer_story on Story { + site { + id + name + } + id + url + metadata { + title + publishedAt + } + commentCounts { + totalPublished + } + } + `, +})(StoryRowContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Discussions/index.tsx b/src/core/client/stream/tabs/Discussions/index.tsx new file mode 100644 index 000000000..b482a52ea --- /dev/null +++ b/src/core/client/stream/tabs/Discussions/index.tsx @@ -0,0 +1 @@ +export { default } from "./DiscussionsQuery"; diff --git a/src/core/server/graph/loaders/Stories.ts b/src/core/server/graph/loaders/Stories.ts index 6e93e6c47..3f30c7d2f 100644 --- a/src/core/server/graph/loaders/Stories.ts +++ b/src/core/server/graph/loaders/Stories.ts @@ -3,6 +3,8 @@ import { defaultTo } from "lodash"; import { DateTime } from "luxon"; import GraphContext from "coral-server/graph/context"; +import { retrieveOngoingDiscussions } from "coral-server/models/comment"; +import { retrieveTopStoryMetrics } from "coral-server/models/comment/metrics"; import { Connection } from "coral-server/models/helpers"; import { CloseCommenting } from "coral-server/models/settings"; import { @@ -24,6 +26,8 @@ import { scraper } from "coral-server/services/stories/scraper"; import { GQLSTORY_STATUS, QueryToStoriesArgs, + SiteToTopStoriesArgs, + UserToOngoingDiscussionsArgs, } from "coral-server/graph/schema/__generated__/types"; import { createManyBatchLoadFn } from "./util"; @@ -208,6 +212,29 @@ export default (ctx: GraphContext) => ({ ...queryFilter(query), }, }).then(primeStoriesFromConnection(ctx)), + topStories: (siteID: string, { limit }: SiteToTopStoriesArgs) => { + // Find top active stories in the last 24 hours. + const start = DateTime.fromJSDate(ctx.now).minus({ hours: 24 }).toJSDate(); + + return retrieveTopStoryMetrics( + ctx.mongo, + ctx.tenant.id, + siteID, + defaultTo(limit, 5), + start, + ctx.now + ); + }, + ongoingDiscussions: ( + authorID: string, + { limit }: UserToOngoingDiscussionsArgs + ) => + retrieveOngoingDiscussions( + ctx.mongo, + ctx.tenant.id, + authorID, + defaultTo(limit, 5) + ), debugScrapeMetadata: new DataLoader( createManyBatchLoadFn((url: string) => scraper.scrape({ diff --git a/src/core/server/graph/resolvers/Settings.ts b/src/core/server/graph/resolvers/Settings.ts index 73c0b55c0..52b504507 100644 --- a/src/core/server/graph/resolvers/Settings.ts +++ b/src/core/server/graph/resolvers/Settings.ts @@ -3,6 +3,7 @@ import { retrieveAnnouncementIfEnabled, Tenant, } from "coral-server/models/tenant"; +import { hasModeratorRole } from "coral-server/models/user/helpers"; import { GQLFEATURE_FLAG, @@ -10,19 +11,43 @@ import { GQLWEBHOOK_EVENT_NAME, } from "coral-server/graph/schema/__generated__/types"; -const filterValidFeatureFlags = () => { - // Compute the valid flags based on this enum. - const flags = Object.values(GQLFEATURE_FLAG); +import GraphContext from "../context"; - // Return a type guard for the feature flag. - return (flag: string | GQLFEATURE_FLAG): flag is GQLFEATURE_FLAG => - flags.includes(flag as GQLFEATURE_FLAG); +/** + * FEATURE_FLAGS is an array of all the valid feature flags. + */ +const FEATURE_FLAGS = Object.values(GQLFEATURE_FLAG); + +/** + * PUBLIC_FEATURE_FLAGS are flags that are allowed to be returned when accessed + * by a user with non-staff permissions. + */ +const PUBLIC_FEATURE_FLAGS = [GQLFEATURE_FLAG.DISCUSSIONS]; + +type FlagFilter = (flag: GQLFEATURE_FLAG | string) => boolean; + +const filterValidFeatureFlags = (ctx: GraphContext): FlagFilter => { + const filters: FlagFilter[] = [ + // Return a type guard for the feature flag. + (flag): flag is GQLFEATURE_FLAG => + FEATURE_FLAGS.includes(flag as GQLFEATURE_FLAG), + ]; + + // For anonomous users or users without a moderator role, ensure we only send + // back the public flags. + if (!ctx.user || !hasModeratorRole(ctx.user)) { + filters.push((flag) => + PUBLIC_FEATURE_FLAGS.includes(flag as GQLFEATURE_FLAG) + ); + } + + return (flag) => filters.every((filter) => filter(flag)); }; export const Settings: GQLSettingsTypeResolver = { slack: ({ slack = {} }) => slack, - featureFlags: ({ featureFlags = [] }) => - featureFlags.filter(filterValidFeatureFlags()), + featureFlags: ({ featureFlags = [] }, args, ctx) => + featureFlags.filter(filterValidFeatureFlags(ctx)), announcement: ({ announcement }) => retrieveAnnouncementIfEnabled(announcement), multisite: async ({ id }, input, ctx) => { diff --git a/src/core/server/graph/resolvers/Site.ts b/src/core/server/graph/resolvers/Site.ts index 0f6860b7b..acc6d0689 100644 --- a/src/core/server/graph/resolvers/Site.ts +++ b/src/core/server/graph/resolvers/Site.ts @@ -1,4 +1,5 @@ import * as site from "coral-server/models/site"; + import { hasFeatureFlag } from "coral-server/models/tenant"; import { canModerate, @@ -24,4 +25,16 @@ export const Site: GQLSiteTypeResolver = { return canModerate(ctx.user, { siteID: id }); }, + topStories: async ({ id }, args, ctx) => { + // Get the top Story ID's from the loader. + const results = await ctx.loaders.Stories.topStories(id, args); + + // If there isn't any ids, then return nothing! + if (results.length === 0) { + return []; + } + + // Get the Stories! + return ctx.loaders.Stories.story.loadMany(results.map(({ _id }) => _id)); + }, }; diff --git a/src/core/server/graph/resolvers/User.ts b/src/core/server/graph/resolvers/User.ts index a654645fc..de10d3bee 100644 --- a/src/core/server/graph/resolvers/User.ts +++ b/src/core/server/graph/resolvers/User.ts @@ -69,4 +69,16 @@ export const User: GQLUserTypeResolver = { ignoreable: ({ role }) => !roleIsStaff(role), recentCommentHistory: ({ id }): RecentCommentHistoryInput => ({ userID: id }), profiles: ({ profiles = [] }) => profiles, + ongoingDiscussions: async ({ id }, input, ctx) => { + // Get the ongoing discussions from the loader. + const results = await ctx.loaders.Stories.ongoingDiscussions(id, input); + + // If there isn't any ids, then return nothing! + if (results.length === 0) { + return []; + } + + // Get the Stories! + return ctx.loaders.Stories.story.loadMany(results.map(({ _id }) => _id)); + }, }; diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index 7787803ec..78dbbcdf0 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -402,6 +402,11 @@ enum FEATURE_FLAG { in production environments. """ REDUCED_SECURITY_MODE + + """ + DISCUSSIONS will enable the discussions tab for the comment stream. + """ + DISCUSSIONS } # The moderation mode of the site. @@ -1583,6 +1588,12 @@ type Site { """ canModerate: Boolean! + """ + topStories will return stories that have had the most comments within the last + 24 hours on this Site. + """ + topStories(limit: Int = 5 @constraint(max: 5)): [Story!]! + """ createdAt is when the site was created. """ @@ -1754,7 +1765,7 @@ type Settings { """ featureFlags provides the enabled feature flags. """ - featureFlags: [FEATURE_FLAG!]! @auth(roles: [ADMIN, MODERATOR]) + featureFlags: [FEATURE_FLAG!]! """ createdAt is the time that the Settings was created at. @@ -2336,6 +2347,13 @@ type User { after: Cursor ): CommentsConnection! @auth(roles: [ADMIN, MODERATOR]) + """ + ongoingDiscussions are stories where the given user has written comments in + sorted by their last comment date. + """ + ongoingDiscussions(limit: Int = 5 @constraint(max: 5)): [Story!]! + @auth(userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION]) + """ recentCommentHistory returns recent commenting history by the User. """ @@ -2429,6 +2447,7 @@ type User { ssoURL is the url for managing sso account """ ssoURL: String + @auth(userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION]) } """ diff --git a/src/core/server/models/comment/comment.ts b/src/core/server/models/comment/comment.ts index 367c89da3..0c0eccde0 100644 --- a/src/core/server/models/comment/comment.ts +++ b/src/core/server/models/comment/comment.ts @@ -1059,3 +1059,54 @@ export async function retrieveRecentStatusCounts( ]); return counts[0]; } + +/** + * retrieveOngoingDiscussions will return the id's of stories where the user has + * participated in ordered by most recent where the comment's are published. + * + * @param mongo the database handle + * @param tenantID the ID of the Tenant for which to get story id's for + * @param authorID the User's ID for the discussions we want to find + * @param limit the maximum number of story id's we want to return. + */ +export async function retrieveOngoingDiscussions( + mongo: Db, + tenantID: string, + authorID: string, + limit: number +) { + const timer = createTimer(); + + // NOTE: (wyattjoh) this operation technically might have an issue when + // handling users that have commented across many stories because the $sort + // and $limit stages do not coalesce. This means that the $group phase may + // have to collect _all_ the stories that a user has commented on (their id's + // at least) before limiting the result. This may change on different versions + // of MongoDB though. + const results = await collection<{ _id: string }>(mongo) + .aggregate([ + { + $match: { + tenantID, + status: { + $in: PUBLISHED_STATUSES, + }, + authorID, + }, + }, + { $sort: { createdAt: -1 } }, + { + $group: { + _id: "$storyID", + createdAt: { $first: "$createdAt" }, + }, + }, + { $sort: { createdAt: -1 } }, + { $limit: limit }, + ]) + .toArray(); + + logger.info({ took: timer() }, "ongoing discussions query"); + + return results; +} diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index e0516e4f4..3abfd8be6 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -19,6 +19,7 @@ general-userBox-youHaveBeenSuccessfullySignedOut = general-tabBar-commentsTab = Comments general-tabBar-myProfileTab = My Profile +general-tabBar-discussionsTab = Discussions general-tabBar-configure = Configure general-tabBar-aria-comments = @@ -33,6 +34,9 @@ general-tabBar-aria-myProfile = general-tabBar-aria-configure = .aria-label = Configure .title = My Profile +general-tabBar-aria-discussions = + .aria-label = Discussions + .title = Discussions ## Comment Count @@ -545,6 +549,17 @@ profile-changeUsername-youChangedYourUsernameWithin = You changed your username within the last { framework-timeago-time }. You may change your username again on: { $nextUpdate }. profile-changeUsername-close = Close +## Discussions tab + +discussions-mostActiveDiscussions = Most active discussions +discussions-mostActiveDiscussions-subhead = Ranked by the most comments received over the last 24 hours on { $siteName } +discussions-mostActiveDiscussions-empty = You haven’t participated in any discussions +discussions-myOngoingDiscussions = My ongoing discussions +discussions-myOngoingDiscussions-subhead = Where you’ve commented across { $orgName } +discussions-viewFullHistory = View full comment history +discussions-discussionsQuery-errorLoadingProfile = Error loading profile +discussions-discussionsQuery-storyNotFound = Story not found + ## Comment Stream configure-stream-title = configure-stream-title-configureThisStream =