[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 <tessathornton@gmail.com>
Co-authored-by: Chi Vinh Le <vinh@vinh.tech>
This commit is contained in:
Wyatt Johnson
2020-07-30 21:18:59 +00:00
committed by GitHub
parent 9a9c92d360
commit dfdea2b9d2
25 changed files with 886 additions and 48 deletions
+9 -1
View File
@@ -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<AppProps> = (props) => {
>
<Comments />
</TabPane>
<TabPane
className={CLASSES.discussionsTabPane.$root}
tabID="DISCUSSIONS"
data-testid="current-tab-pane"
>
<Discussions />
</TabPane>
<TabPane
className={CLASSES.myProfileTabPane.$root}
tabID="PROFILE"
@@ -5,7 +5,7 @@ import { createMutationContainer, LOCAL_ID } from "coral-framework/lib/relay";
import { SetMainTabEvent } from "coral-stream/events";
export interface SetActiveTabInput {
tab: "COMMENTS" | "PROFILE" | "%future added value";
tab: "COMMENTS" | "PROFILE" | "DISCUSSIONS" | "%future added value";
}
export type SetActiveTabMutation = (input: SetActiveTabInput) => Promise<void>;
+28 -1
View File
@@ -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) => {
)}
</Tab>
{props.showDiscussionsTab && (
<Tab
className={cn(CLASSES.tabBar.discussions, {
[CLASSES.tabBar.activeTab]: props.activeTab === "DISCUSSIONS",
[styles.smallTab]: !matches,
})}
tabID="DISCUSSIONS"
variant="streamPrimary"
localizationId="general-tabBar-aria-discussions"
>
{matches ? (
<Localized id="general-tabBar-discussionsTab">
<span>Discussions</span>
</Localized>
) : (
<div>
<Icon size="lg">list_alt</Icon>
<Localized id="general-tabBar-discussionsTab">
<div className={styles.smallText}>Discussions</div>
</Localized>
</div>
)}
</Tab>
)}
{props.showProfileTab && (
<Tab
className={cn(CLASSES.tabBar.myProfile, {
@@ -93,6 +119,7 @@ const AppTabBar: FunctionComponent<Props> = (props) => {
)}
</Tab>
)}
{props.showConfigureTab && (
<Tab
className={cn(CLASSES.tabBar.configure, {
+53 -36
View File
@@ -1,16 +1,17 @@
import React, { Component } from "react";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { graphql } from "react-relay";
import {
withFragmentContainer,
withLocalStateContainer,
} from "coral-framework/lib/relay";
import { GQLSTORY_MODE } from "coral-framework/schema";
import { GQLFEATURE_FLAG, GQLSTORY_MODE } from "coral-framework/schema";
import { Ability, can } from "coral-stream/permissions";
import { TabBarContainer_settings } from "coral-stream/__generated__/TabBarContainer_settings.graphql";
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";
import { TabBarContainer_viewer } from "coral-stream/__generated__/TabBarContainer_viewer.graphql";
import { TabBarContainerLocal } from "coral-stream/__generated__/TabBarContainerLocal.graphql";
import {
SetActiveTabInput,
@@ -21,43 +22,54 @@ import TabBar from "./TabBar";
interface Props {
story: TabBarContainer_story | null;
viewer: ViewerData | null;
local: Local;
settings: TabBarContainer_settings | null;
viewer: TabBarContainer_viewer | null;
local: TabBarContainerLocal;
setActiveTab: SetActiveTabMutation;
}
export class TabBarContainer extends Component<Props> {
private handleSetActiveTab = (tab: SetActiveTabInput["tab"]) => {
void this.props.setActiveTab({ tab });
};
export const TabBarContainer: FunctionComponent<Props> = ({
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 (
<TabBar
mode={
this.props.story
? this.props.story.settings.mode
: GQLSTORY_MODE.COMMENTS
}
activeTab={activeTab}
showProfileTab={Boolean(viewer)}
showConfigureTab={
!!viewer &&
!!story &&
story.canModerate &&
can(viewer, Ability.CHANGE_STORY_CONFIGURATION)
}
onTabClick={this.handleSetActiveTab}
/>
);
}
}
const showConfigureTab = useMemo(
() =>
!!viewer &&
!!story &&
story.canModerate &&
can(viewer, Ability.CHANGE_STORY_CONFIGURATION),
[viewer, story]
);
return (
<TabBar
mode={story ? story.settings.mode : GQLSTORY_MODE.COMMENTS}
activeTab={activeTab}
showProfileTab={!!viewer}
showDiscussionsTab={showDiscussionsTab}
showConfigureTab={showConfigureTab}
onTabClick={handleSetActiveTab}
/>
);
};
const enhanced = withSetActiveTabMutation(
withLocalStateContainer(
@@ -81,6 +93,11 @@ const enhanced = withSetActiveTabMutation(
}
}
`,
settings: graphql`
fragment TabBarContainer_settings on Settings {
featureFlags
}
`,
})(TabBarContainer)
)
);
@@ -28,6 +28,9 @@ class TabBarQuery extends Component<Props> {
story(id: $storyID, url: $storyURL) {
...TabBarContainer_story
}
settings {
...TabBarContainer_settings
}
}
`}
variables={{
@@ -41,6 +44,7 @@ class TabBarQuery extends Component<Props> {
return (
<TabBarContainer
settings={(props && props.settings) || null}
story={(props && props.story) || null}
viewer={(props && props.viewer) || null}
/>
@@ -24,6 +24,13 @@ exports[`renders correctly 1`] = `
>
<CommentsPane />
</TabPane>
<TabPane
className="coral coral-discussions"
data-testid="current-tab-pane"
tabID="DISCUSSIONS"
>
<withContext(withLocalStateContainer(DiscussionsQuery)) />
</TabPane>
<TabPane
className="coral coral-myProfile"
data-testid="current-tab-pane"
+24
View File
@@ -60,6 +60,8 @@ const CLASSES = {
*/
myProfile: "coral coral-tabBar-tab coral-tabBar-myProfile",
discussions: "coral coral-tabBar-tab coral-tabBar-discussions",
/**
* configure is the button for the "Configure" tab.
*/
@@ -714,6 +716,10 @@ const CLASSES = {
$root: "coral coral-myProfile",
},
discussionsTabPane: {
$root: "coral coral-discussions",
},
/**
* myUsername is the username part of my profile.
*/
@@ -981,6 +987,24 @@ const CLASSES = {
},
moderateStream: "coral coral-general-moderateStreamLink",
discussions: {
$root: "coral coral-discussions",
mostActiveDiscussions: "coral coral-mostActiveDiscussions",
myOngoingDiscussions: "coral coral-myOngoingDiscussions",
header: "coral coral-discussions-header",
subHeader: "coral coral-discussions-subHeader",
discussionsList: "coral coral-discussions-list",
story: {
$root: "coral coral-discussions-story",
header: "coral coral-discussions-story-header",
commentsCount: "coral coral-discussions-story-commentsCount",
commentsCountIcon: "coral coral-discussions-story-commentsCountIcon",
date: "coral coral-discussions-story-date",
siteName: "coral coral-discussions-story-siteName",
},
viewHistoryButton: "coral coral-discussions-viewHistoryButton",
},
};
export default CLASSES;
@@ -0,0 +1,85 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { graphql } from "react-relay";
import {
createMutation,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import UserBoxContainer from "coral-stream/common/UserBox";
import { Button, HorizontalGutter } from "coral-ui/components/v2";
import { DiscussionsContainer_settings } from "coral-stream/__generated__/DiscussionsContainer_settings.graphql";
import { DiscussionsContainer_story } from "coral-stream/__generated__/DiscussionsContainer_story.graphql";
import { DiscussionsContainer_viewer } from "coral-stream/__generated__/DiscussionsContainer_viewer.graphql";
import { commit } from "../../App/SetActiveTabMutation";
import MostActiveDiscussionsContainer from "./MostActiveDiscussionsContainer";
import MyOngoingDiscussionsContainer from "./MyOngoingDiscussionsContainer";
interface Props {
viewer: DiscussionsContainer_viewer;
settings: DiscussionsContainer_settings;
story: DiscussionsContainer_story;
}
const SetActiveTabMutation = createMutation("setActiveTab", commit);
const DiscussionsContainer: FunctionComponent<Props> = (props) => {
const setActiveTab = useMutation(SetActiveTabMutation);
const onFullHistoryClick = useCallback(
async () => await setActiveTab({ tab: "PROFILE" }),
[]
);
return (
<HorizontalGutter spacing={3} className={CLASSES.discussions.$root}>
<UserBoxContainer settings={props.settings} viewer={props.viewer} />
<MostActiveDiscussionsContainer site={props.story.site} />
<MyOngoingDiscussionsContainer
viewer={props.viewer}
currentSiteID={props.story.site.id}
settings={props.settings}
/>
<Localized id="discussions-viewFullHistory">
<Button
variant="outline"
color="stream"
onClick={onFullHistoryClick}
className={CLASSES.discussions.viewHistoryButton}
>
View full comment history
</Button>
</Localized>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
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;
@@ -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;
}
@@ -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<Props> = ({
header,
subHeader,
icon,
}) => {
return (
<HorizontalGutter spacing={1} className={styles.root}>
<Flex spacing={1} alignItems="center">
<Icon size="md">{icon}</Icon>
<h2 className={cn(styles.header, CLASSES.discussions.header)}>
{header}
</h2>
</Flex>
<div className={cn(styles.subHeader, CLASSES.discussions.subHeader)}>
{subHeader}
</div>
</HorizontalGutter>
);
};
export default DiscussionsHeader;
@@ -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<QueryTypes>) => {
if (error) {
return (
<CallOut color="error" fullWidth>
{error.message}
</CallOut>
);
}
// TODO: use official React API once it has one :-)
preloadDiscussionsContainer();
if (props) {
if (!props.viewer) {
return (
<Localized id="discussions-discussionsQuery-errorLoadingProfile">
<CallOut color="error" fullWidth>
Error loading profile
</CallOut>
</Localized>
);
}
if (!props.story) {
return (
<Localized id="discussions-discussionsQuery-storyNotFound">
<CallOut>Story not found</CallOut>
</Localized>
);
}
return (
<Suspense fallback={<Spinner />}>
<LazyDiscussionsContainer
viewer={props.viewer}
story={props.story}
settings={props.settings}
/>
</Suspense>
);
}
return (
<Delay>
<Spinner />
</Delay>
);
};
const DiscussionsQuery: FunctionComponent<Props> = ({
local: { storyID, storyURL },
}) => {
const handleIncompleteAccount = useHandleIncompleteAccount();
return (
<QueryRenderer<QueryTypes>
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;
@@ -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);
}
@@ -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<Props> = ({ site }) => {
return (
<div className={cn(styles.root, CLASSES.discussions.mostActiveDiscussions)}>
<DiscussionsHeader
header={
<Localized id="discussions-mostActiveDiscussions">
Most active discussions
</Localized>
}
subHeader={
<Localized
id="discussions-mostActiveDiscussions-subhead"
$siteName={site.name}
>
<>
Ranked by the most comments received over the last 24 hours on{" "}
{site.name}
</>
</Localized>
}
icon="show_chart"
/>
<ol className={cn(styles.list, CLASSES.discussions.discussionsList)}>
{site.topStories.map((story) => (
<li
className={cn(styles.listItem, CLASSES.discussions.story.$root)}
key={story.id}
>
<StoryRowContainer story={story} currentSiteID={site.id} />
</li>
))}
</ol>
</div>
);
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment MostActiveDiscussionsContainer_site on Site {
id
name
topStories {
id
...StoryRowContainer_story
}
}
`,
})(MostActiveDiscussionsContainer);
export default enhanced;
@@ -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);
}
@@ -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<Props> = ({
viewer,
settings,
currentSiteID,
}) => {
return (
<div className={cn(styles.root, CLASSES.discussions.myOngoingDiscussions)}>
<DiscussionsHeader
header={
<Localized id="discussions-myOngoingDiscussions">
My ongoing discussions
</Localized>
}
subHeader={
<Localized
id="discussions-myOngoingDiscussions-subhead"
$orgName={settings.organization.name}
>
<>Where youve commented across {settings.organization.name}</>
</Localized>
}
icon="history"
/>
{viewer.ongoingDiscussions.length === 0 && (
<Localized id="discussions-mostActiveDiscussions-empty">
<p className={styles.emptyList}>
You havent participated in any discussions
</p>
</Localized>
)}
<ul className={cn(styles.list, CLASSES.discussions.discussionsList)}>
{viewer.ongoingDiscussions.map((story) => (
<li key={cn(story.id, CLASSES.discussions.story.$root)}>
<StoryRowContainer story={story} currentSiteID={currentSiteID} />
</li>
))}
</ul>
</div>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment MyOngoingDiscussionsContainer_viewer on User {
ongoingDiscussions {
id
...StoryRowContainer_story
}
}
`,
settings: graphql`
fragment MyOngoingDiscussionsContainer_settings on Settings {
organization {
name
}
}
`,
})(MyOngoingDiscussionsContainer);
export default enhanced;
@@ -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);
}
@@ -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<Props> = ({
story,
currentSiteID,
}) => {
return (
<a href={story.url} className={styles.root} target="_parent">
<HorizontalGutter spacing={1}>
{currentSiteID !== story.site.id && (
<p
className={cn(styles.siteName, CLASSES.discussions.story.siteName)}
>
{story.site.name}
</p>
)}
{story.metadata && story.metadata.title && (
<h3
className={cn(styles.storyTitle, CLASSES.discussions.story.header)}
>
{story.metadata.title}
</h3>
)}
<Flex spacing={3}>
{story.metadata && story.metadata.publishedAt && (
<RelativeTime
date={story.metadata.publishedAt}
className={cn(styles.time, CLASSES.discussions.story.date)}
/>
)}
<Flex spacing={1} alignItems="center">
<Icon
className={cn(
styles.commentsCountIcon,
CLASSES.discussions.story.commentsCountIcon
)}
>
mode_comment
</Icon>
<span
className={cn(
styles.commentsCount,
CLASSES.discussions.story.commentsCount
)}
>
{story.commentCounts.totalPublished}
</span>
</Flex>
</Flex>
</HorizontalGutter>
</a>
);
};
const enhanced = withFragmentContainer<Props>({
story: graphql`
fragment StoryRowContainer_story on Story {
site {
id
name
}
id
url
metadata {
title
publishedAt
}
commentCounts {
totalPublished
}
}
`,
})(StoryRowContainer);
export default enhanced;
@@ -0,0 +1 @@
export { default } from "./DiscussionsQuery";
+27
View File
@@ -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({
+33 -8
View File
@@ -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<Tenant> = {
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) => {
+13
View File
@@ -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<site.Site> = {
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));
},
};
+12
View File
@@ -69,4 +69,16 @@ export const User: GQLUserTypeResolver<user.User> = {
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));
},
};
+20 -1
View File
@@ -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])
}
"""
+51
View File
@@ -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;
}
+15
View File
@@ -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 havent participated in any discussions
discussions-myOngoingDiscussions = My ongoing discussions
discussions-myOngoingDiscussions-subhead = Where youve 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 =