mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 19:17:09 +08:00
[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:
@@ -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>;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 you’ve commented across {settings.organization.name}</>
|
||||
</Localized>
|
||||
}
|
||||
icon="history"
|
||||
/>
|
||||
{viewer.ongoingDiscussions.length === 0 && (
|
||||
<Localized id="discussions-mostActiveDiscussions-empty">
|
||||
<p className={styles.emptyList}>
|
||||
You haven’t 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";
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user