From e7745a85aa58008941fa4d1f1e034c4d01d52b72 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Fri, 5 Jul 2019 23:10:19 +0000 Subject: [PATCH] [CORL-416] Disable Live Updates (#2391) * feat: initial implementation * fix: docs --- README.md | 2 + .../admin/routes/Configure/ConfigureRoute.tsx | 1 + .../sections/Advanced/AdvancedConfig.tsx | 11 ++- .../Advanced/AdvancedConfigContainer.tsx | 6 +- .../Advanced/CommentStreamLiveUpdates.tsx | 35 ++++++++++ .../CommentStreamLiveUpdatesContainer.tsx | 56 +++++++++++++++ .../__snapshots__/advanced.spec.tsx.snap | 69 +++++++++++++++++++ .../admin/test/configure/advanced.spec.tsx | 26 +++++++ src/core/client/admin/test/fixtures.ts | 4 ++ .../createManagedSubscriptionClient.ts | 14 ++++ .../ReplyList/ReplyListContainer.spec.tsx | 11 +++ .../Comments/ReplyList/ReplyListContainer.tsx | 33 ++++++++- .../ReplyListContainer.spec.tsx.snap | 30 ++++++++ .../AllCommentsTabContainer.tsx | 13 +++- .../ConfigureStream/ConfigureStream.tsx | 11 ++- .../ConfigureStreamContainer.tsx | 3 + .../LiveUpdatesConfig/LiveUpdatesConfig.tsx | 41 +++++++++++ .../LiveUpdatesConfigContainer.tsx | 56 +++++++++++++++ .../LiveUpdatesConfig/index.ts | 4 ++ .../renderConfigure.spec.tsx.snap | 42 +++++++++++ src/core/client/stream/test/fixtures.ts | 8 +++ src/core/common/errors.ts | 6 ++ src/core/server/config.ts | 8 +++ src/core/server/errors/index.ts | 8 +++ src/core/server/errors/translations.ts | 1 + .../server/graph/tenant/mutators/Settings.ts | 3 +- .../tenant/resolvers/LiveConfiguration.ts | 24 +++++++ .../graph/tenant/resolvers/StorySettings.ts | 1 + .../server/graph/tenant/resolvers/index.ts | 2 + .../server/graph/tenant/schema/schema.graphql | 47 +++++++++++++ .../graph/tenant/subscriptions/server.ts | 19 ++++- src/core/server/models/settings.ts | 4 ++ src/core/server/models/story/index.ts | 5 +- src/core/server/models/tenant.ts | 5 ++ .../comments/pipeline/phases/commentLength.ts | 3 - .../pipeline/phases/commentingDisabled.ts | 9 +-- .../comments/pipeline/phases/detectLinks.ts | 3 +- .../comments/pipeline/phases/preModerate.ts | 3 +- src/core/server/services/tenant/index.ts | 19 ++++- src/locales/en-US/admin.ftl | 4 ++ src/locales/en-US/stream.ftl | 4 ++ 41 files changed, 626 insertions(+), 28 deletions(-) create mode 100644 src/core/client/admin/routes/Configure/sections/Advanced/CommentStreamLiveUpdates.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Advanced/CommentStreamLiveUpdatesContainer.tsx create mode 100644 src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/LiveUpdatesConfig.tsx create mode 100644 src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/LiveUpdatesConfigContainer.tsx create mode 100644 src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/index.ts create mode 100644 src/core/server/graph/tenant/resolvers/LiveConfiguration.ts diff --git a/README.md b/README.md index 656fb7e8a..be687bae9 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,8 @@ the variables in a `.env` file in the root of the project in a simple - `METRICS_PASSWORD` - The password for _Basic Authentication_ at the `/metrics` and `/cluster_metrics` endpoint. - `CLUSTER_METRICS_PORT` - If `CONCURRENCY` is more than `1`, the metrics are provided at this port under `/cluster_metrics`. (Default `3001`) +- `DISABLE_LIVE_UPDATES` - When `true`, disables subscriptions for the comment + stream for all stories across all tenants (Default `false`) ## License diff --git a/src/core/client/admin/routes/Configure/ConfigureRoute.tsx b/src/core/client/admin/routes/Configure/ConfigureRoute.tsx index b6bc3f21e..e8f9acfbd 100644 --- a/src/core/client/admin/routes/Configure/ConfigureRoute.tsx +++ b/src/core/client/admin/routes/Configure/ConfigureRoute.tsx @@ -53,4 +53,5 @@ class ConfigureRoute extends React.Component { } const enhanced = withMutation(UpdateSettingsMutation)(ConfigureRoute); + export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Advanced/AdvancedConfig.tsx b/src/core/client/admin/routes/Configure/sections/Advanced/AdvancedConfig.tsx index 42af566f2..6e77f3916 100644 --- a/src/core/client/admin/routes/Configure/sections/Advanced/AdvancedConfig.tsx +++ b/src/core/client/admin/routes/Configure/sections/Advanced/AdvancedConfig.tsx @@ -3,13 +3,16 @@ import React, { FunctionComponent } from "react"; import { PropTypesOf } from "coral-framework/types"; import { HorizontalGutter } from "coral-ui/components"; +import CommentStreamLiveUpdatesContainer from "./CommentStreamLiveUpdatesContainer"; import CustomCSSConfigContainer from "./CustomCSSConfigContainer"; import PermittedDomainsConfigContainer from "./PermittedDomainsConfigContainer"; interface Props { disabled: boolean; settings: PropTypesOf["settings"] & - PropTypesOf["settings"]; + PropTypesOf["settings"] & + PropTypesOf["settings"] & + PropTypesOf["settingsReadOnly"]; onInitValues: (values: any) => void; } @@ -24,6 +27,12 @@ const AdvancedConfig: FunctionComponent = ({ settings={settings} onInitValues={onInitValues} /> + { @@ -47,6 +47,8 @@ const enhanced = withFragmentContainer({ fragment AdvancedConfigContainer_settings on Settings { ...CustomCSSConfigContainer_settings ...PermittedDomainsConfigContainer_settings + ...CommentStreamLiveUpdatesContainer_settings + ...CommentStreamLiveUpdatesContainer_settingsReadOnly } `, })(AdvancedConfigContainer); diff --git a/src/core/client/admin/routes/Configure/sections/Advanced/CommentStreamLiveUpdates.tsx b/src/core/client/admin/routes/Configure/sections/Advanced/CommentStreamLiveUpdates.tsx new file mode 100644 index 000000000..a15d7c1a6 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Advanced/CommentStreamLiveUpdates.tsx @@ -0,0 +1,35 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +import { FormField, HorizontalGutter, Typography } from "coral-ui/components"; + +import Header from "../../Header"; +import OnOffField from "../../OnOffField"; + +interface Props { + disabled: boolean; +} + +const CommentStreamLiveUpdates: FunctionComponent = ({ disabled }) => ( + + + +
}> + Comment Stream Live Updates +
+
+ } + > + + When enabled, there will be real-time loading and updating of comments + as new comments and replies are published + + + +
+
+); + +export default CommentStreamLiveUpdates; diff --git a/src/core/client/admin/routes/Configure/sections/Advanced/CommentStreamLiveUpdatesContainer.tsx b/src/core/client/admin/routes/Configure/sections/Advanced/CommentStreamLiveUpdatesContainer.tsx new file mode 100644 index 000000000..17e6d5297 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Advanced/CommentStreamLiveUpdatesContainer.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { graphql } from "react-relay"; + +import { CommentStreamLiveUpdatesContainer_settings } from "coral-admin/__generated__/CommentStreamLiveUpdatesContainer_settings.graphql"; +import { CommentStreamLiveUpdatesContainer_settingsReadOnly } from "coral-admin/__generated__/CommentStreamLiveUpdatesContainer_settingsReadOnly.graphql"; +import { withFragmentContainer } from "coral-framework/lib/relay"; + +import CommentStreamLiveUpdates from "./CommentStreamLiveUpdates"; + +interface Props { + settingsReadOnly: CommentStreamLiveUpdatesContainer_settingsReadOnly; + settings: CommentStreamLiveUpdatesContainer_settings; + onInitValues: (values: CommentStreamLiveUpdatesContainer_settings) => void; + disabled: boolean; +} + +class CommentStreamLiveUpdatesContainer extends React.Component { + constructor(props: Props) { + super(props); + props.onInitValues(props.settings); + } + + public render() { + const { + disabled, + settingsReadOnly: { + live: { configurable }, + }, + } = this.props; + + if (!configurable) { + return null; + } + + return ; + } +} + +const enhanced = withFragmentContainer({ + settings: graphql` + fragment CommentStreamLiveUpdatesContainer_settings on Settings { + live { + enabled + } + } + `, + settingsReadOnly: graphql` + fragment CommentStreamLiveUpdatesContainer_settingsReadOnly on Settings { + live { + configurable + } + } + `, +})(CommentStreamLiveUpdatesContainer); + +export default enhanced; diff --git a/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap index 167f25e21..c69aca902 100644 --- a/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/advanced.spec.tsx.snap @@ -144,6 +144,75 @@ exports[`renders configure advanced 1`] = ` +
+
+ +

+ When enabled, there will be real-time loading and updating of comments as new comments and replies are published +

+
+
+ + +
+
+ + +
+
+
+
diff --git a/src/core/client/admin/test/configure/advanced.spec.tsx b/src/core/client/admin/test/configure/advanced.spec.tsx index 579d91db4..00285ec04 100644 --- a/src/core/client/admin/test/configure/advanced.spec.tsx +++ b/src/core/client/admin/test/configure/advanced.spec.tsx @@ -141,6 +141,32 @@ it("remove custom css", async () => { }); }); +it("renders with live configuration when configurable", async () => { + const { advancedContainer } = await createTestRenderer(); + + expect( + within(advancedContainer).queryByLabelText("Comment Stream Live Updates") + ).toBeDefined(); +}); + +it("renders without live configuration when not configurable", async () => { + const resolvers = createResolversStub({ + Query: { + settings: () => + pureMerge(settings, { + live: { configurable: false }, + }), + }, + }); + const { advancedContainer } = await createTestRenderer({ + resolvers, + }); + + expect( + within(advancedContainer).queryByLabelText("Comment Stream Live Updates") + ).toEqual(null); +}); + it("change permitted domains to be empty", async () => { const resolvers = createResolversStub({ Mutation: { diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 30733a340..b06a77634 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -23,6 +23,10 @@ export const settings = createFixture({ id: "settings", moderation: GQLMODERATION_MODE.POST, premodLinksEnable: false, + live: { + enabled: true, + configurable: true, + }, wordList: { suspect: ["idiot", "stupid"], banned: ["fuck"], diff --git a/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts index edfc6bf17..e5635b08c 100644 --- a/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts +++ b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts @@ -7,6 +7,7 @@ import { import { SubscriptionClient } from "subscriptions-transport-ws"; import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants"; +import { ERROR_CODES } from "coral-common/errors"; /** * SubscriptionRequest containts the subscription @@ -88,6 +89,19 @@ export default function createManagedSubscriptionClient( if (!subscriptionClient) { subscriptionClient = new SubscriptionClient(url, { reconnect: true, + connectionCallback: err => { + if (err) { + // If an error is thrown as a result of live updates being + // disabled, then just close the subscription client. + if ( + ((err as unknown) as Error).message === + ERROR_CODES.LIVE_UPDATES_DISABLED && + subscriptionClient + ) { + subscriptionClient.close(); + } + } + }, connectionParams: { [ACCESS_TOKEN_PARAM]: accessToken, [CLIENT_ID_PARAM]: clientID, diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx index 31a34dc0b..fdb59acab 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.spec.tsx @@ -15,6 +15,7 @@ it("renders correctly", () => { const props: PropTypesOf = { story: { isClosed: false, + settings: { live: { enabled: true } }, }, comment: { id: "comment-id", @@ -50,6 +51,11 @@ it("renders correctly when replies are empty", () => { const props: PropTypesOf = { story: { isClosed: false, + settings: { + live: { + enabled: true, + }, + }, }, comment: { id: "comment-id", @@ -80,6 +86,11 @@ describe("when has more replies", () => { const props: PropTypesOf = { story: { isClosed: false, + settings: { + live: { + enabled: true, + }, + }, }, comment: { id: "comment-id", diff --git a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx index 40777a43b..9028524b9 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx +++ b/src/core/client/stream/tabs/Comments/ReplyList/ReplyListContainer.tsx @@ -61,9 +61,10 @@ export const ReplyListContainer: React.FunctionComponent = props => { CommentReplyCreatedSubscription ); useEffect(() => { - // TODO: (cvle) check for story or settings state - // for whether or not we should turn on subscriptions: - // e.g. `if (!props.story.settings.live) { return; }` + if (!props.story.settings.live.enabled) { + return; + } + if (props.story.isClosed || props.settings.disableCommenting.enabled) { return; } @@ -83,6 +84,7 @@ export const ReplyListContainer: React.FunctionComponent = props => { props.indentLevel, props.relay.hasMore(), props.liveDirectRepliesInsertion, + props.story.settings.live.enabled, ]); const viewNew = useMutation(ReplyListViewNewMutation); @@ -208,6 +210,11 @@ const ReplyListContainer5 = createReplyListContainer( story: graphql` fragment ReplyListContainer5_story on Story { isClosed + settings { + live { + enabled + } + } ...CommentContainer_story ...LocalReplyListContainer_story } @@ -282,6 +289,11 @@ const ReplyListContainer4 = createReplyListContainer( story: graphql` fragment ReplyListContainer4_story on Story { isClosed + settings { + live { + enabled + } + } ...ReplyListContainer5_story ...CommentContainer_story } @@ -354,6 +366,11 @@ const ReplyListContainer3 = createReplyListContainer( story: graphql` fragment ReplyListContainer3_story on Story { isClosed + settings { + live { + enabled + } + } ...ReplyListContainer4_story ...CommentContainer_story } @@ -426,6 +443,11 @@ const ReplyListContainer2 = createReplyListContainer( story: graphql` fragment ReplyListContainer2_story on Story { isClosed + settings { + live { + enabled + } + } ...ReplyListContainer3_story ...CommentContainer_story } @@ -498,6 +520,11 @@ const ReplyListContainer1 = createReplyListContainer( story: graphql` fragment ReplyListContainer1_story on Story { isClosed + settings { + live { + enabled + } + } ...ReplyListContainer2_story ...CommentContainer_story } diff --git a/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyListContainer.spec.tsx.snap b/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyListContainer.spec.tsx.snap index 1844e781c..e3fb7bda9 100644 --- a/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyListContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/ReplyList/__snapshots__/ReplyListContainer.spec.tsx.snap @@ -48,6 +48,11 @@ exports[`renders correctly 1`] = ` story={ Object { "isClosed": false, + "settings": Object { + "live": Object { + "enabled": true, + }, + }, } } viewer={null} @@ -74,6 +79,11 @@ exports[`renders correctly 1`] = ` story={ Object { "isClosed": false, + "settings": Object { + "live": Object { + "enabled": true, + }, + }, } } viewer={null} @@ -97,6 +107,11 @@ exports[`renders correctly 1`] = ` story={ Object { "isClosed": false, + "settings": Object { + "live": Object { + "enabled": true, + }, + }, } } viewNewCount={0} @@ -164,6 +179,11 @@ exports[`when has more replies renders hasMore 1`] = ` story={ Object { "isClosed": false, + "settings": Object { + "live": Object { + "enabled": true, + }, + }, } } viewNewCount={0} @@ -229,6 +249,11 @@ exports[`when has more replies when showing all disables show all button 1`] = ` story={ Object { "isClosed": false, + "settings": Object { + "live": Object { + "enabled": true, + }, + }, } } viewNewCount={0} @@ -294,6 +319,11 @@ exports[`when has more replies when showing all enable show all button after loa story={ Object { "isClosed": false, + "settings": Object { + "live": Object { + "enabled": true, + }, + }, } } viewNewCount={0} diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx index 10189d473..ec0bf6cf2 100644 --- a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx @@ -52,9 +52,10 @@ export const AllCommentsTabContainer: FunctionComponent = props => { ); const subscribeToCommentCreated = useSubscription(CommentCreatedSubscription); useEffect(() => { - // TODO: (cvle) check for story or settings state - // for whether or not we should turn on subscriptions: - // e.g. `if (!props.story.settings.live) { return; }` + if (!props.story.settings.live.enabled) { + return; + } + if (props.story.isClosed || props.settings.disableCommenting.enabled) { return; } @@ -86,6 +87,7 @@ export const AllCommentsTabContainer: FunctionComponent = props => { subscribeToCommentCreated, props.story.id, props.relay.hasMore(), + props.story.settings.live.enabled, ]); const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); const viewMore = useMutation(AllCommentsTabViewNewMutation); @@ -183,6 +185,11 @@ const enhanced = withPaginationContainer< ) { id isClosed + settings { + live { + enabled + } + } comments(first: $count, after: $cursor, orderBy: $orderBy) @connection(key: "Stream_comments") { viewNewEdges { diff --git a/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStream.tsx b/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStream.tsx index 5d13ffc21..f3d17a46e 100644 --- a/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStream.tsx +++ b/src/core/client/stream/tabs/Configure/ConfigureStream/ConfigureStream.tsx @@ -13,6 +13,7 @@ import { Typography, } from "coral-ui/components"; +import { LiveUpdatesConfigContainer } from "./LiveUpdatesConfig"; import MessageBoxConfigContainer from "./MessageBoxConfig"; import PremodConfigContainer from "./PremodConfig"; import PremodLinksConfigContainer from "./PremodLinksConfig"; @@ -23,7 +24,9 @@ interface Props { onSubmit: (settings: any, form: FormApi) => void; storySettings: PropTypesOf["storySettings"] & PropTypesOf["storySettings"] & - PropTypesOf["storySettings"]; + PropTypesOf["storySettings"] & + PropTypesOf["storySettings"] & + PropTypesOf["storySettingsReadOnly"]; } const ConfigureStream: FunctionComponent = ({ @@ -58,6 +61,12 @@ const ConfigureStream: FunctionComponent = ({ {submitError && {submitError}} + ({ ...PremodConfigContainer_storySettings ...PremodLinksConfigContainer_storySettings ...MessageBoxConfigContainer_storySettings + ...LiveUpdatesConfigContainer_storySettings + ...LiveUpdatesConfigContainer_storySettingsReadOnly } } `, })(withUpdateStorySettingsMutation(ConfigureStreamContainer)); + export default enhanced; diff --git a/src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/LiveUpdatesConfig.tsx b/src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/LiveUpdatesConfig.tsx new file mode 100644 index 000000000..83e25d565 --- /dev/null +++ b/src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/LiveUpdatesConfig.tsx @@ -0,0 +1,41 @@ +import { parseBool } from "coral-framework/lib/form"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; +import { Field } from "react-final-form"; + +import ToggleConfig from "../ToggleConfig"; +import WidthLimitedDescription from "../WidthLimitedDescription"; + +interface Props { + disabled: boolean; +} + +const LiveUpdatesConfig: FunctionComponent = ({ disabled }) => ( + + {({ input }) => ( + + Enable Live Updates for this Story + + } + > + + + When enabled, there will be real-time loading and updating of + comments as new comments and replies are published. + + + + )} + +); + +export default LiveUpdatesConfig; diff --git a/src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/LiveUpdatesConfigContainer.tsx b/src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/LiveUpdatesConfigContainer.tsx new file mode 100644 index 000000000..aa9e09a2d --- /dev/null +++ b/src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/LiveUpdatesConfigContainer.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { graphql } from "react-relay"; + +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { LiveUpdatesConfigContainer_storySettings } from "coral-stream/__generated__/LiveUpdatesConfigContainer_storySettings.graphql"; +import { LiveUpdatesConfigContainer_storySettingsReadOnly } from "coral-stream/__generated__/LiveUpdatesConfigContainer_storySettingsReadOnly.graphql"; + +import LiveUpdatesConfig from "./LiveUpdatesConfig"; + +interface Props { + storySettings: LiveUpdatesConfigContainer_storySettings; + storySettingsReadOnly: LiveUpdatesConfigContainer_storySettingsReadOnly; + onInitValues: (values: LiveUpdatesConfigContainer_storySettings) => void; + disabled: boolean; +} + +class LiveUpdatesConfigContainer extends React.Component { + constructor(props: Props) { + super(props); + props.onInitValues(props.storySettings); + } + + public render() { + const { + disabled, + storySettingsReadOnly: { + live: { configurable }, + }, + } = this.props; + + if (!configurable) { + return null; + } + + return ; + } +} + +const enhanced = withFragmentContainer({ + storySettings: graphql` + fragment LiveUpdatesConfigContainer_storySettings on StorySettings { + live { + enabled + } + } + `, + storySettingsReadOnly: graphql` + fragment LiveUpdatesConfigContainer_storySettingsReadOnly on StorySettings { + live { + configurable + } + } + `, +})(LiveUpdatesConfigContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/index.ts b/src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/index.ts new file mode 100644 index 000000000..004e7bc0d --- /dev/null +++ b/src/core/client/stream/tabs/Configure/ConfigureStream/LiveUpdatesConfig/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as LiveUpdatesConfigContainer, +} from "./LiveUpdatesConfigContainer"; diff --git a/src/core/client/stream/test/configure/__snapshots__/renderConfigure.spec.tsx.snap b/src/core/client/stream/test/configure/__snapshots__/renderConfigure.spec.tsx.snap index c5c20f995..2fbc85dcc 100644 --- a/src/core/client/stream/test/configure/__snapshots__/renderConfigure.spec.tsx.snap +++ b/src/core/client/stream/test/configure/__snapshots__/renderConfigure.spec.tsx.snap @@ -79,6 +79,48 @@ exports[`renders configure 1`] = `
+
+
+ + +
+
+

+ When enabled, there will be real-time loading and updating of comments as new comments and replies are published. +

+
+
({ id: "settings", moderation: GQLMODERATION_MODE.POST, premodLinksEnable: false, + live: { + enabled: true, + configurable: true, + }, communityGuidelines: { enabled: false, content: "", @@ -358,6 +362,10 @@ export const baseStory = createFixture({ messageBox: { enabled: false, }, + live: { + enabled: true, + configurable: true, + }, }, }); diff --git a/src/core/common/errors.ts b/src/core/common/errors.ts index 86cbba177..c413993a0 100644 --- a/src/core/common/errors.ts +++ b/src/core/common/errors.ts @@ -277,4 +277,10 @@ export enum ERROR_CODES { * without any email addresses specified. */ INVITE_REQUIRES_EMAIL_ADDRESSES = "INVITE_REQUIRES_EMAIL_ADDRESSES", + + /** + * LIVE_UPDATES_DISABLED is returned when a websocket request is attempted by + * someone now allowed when it is disabled on the tenant level. + */ + LIVE_UPDATES_DISABLED = "LIVE_UPDATES_DISABLED", } diff --git a/src/core/server/config.ts b/src/core/server/config.ts index 6def27bc1..2e673b1e2 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -181,6 +181,14 @@ const config = convict({ env: "DISABLE_TENANT_CACHING", arg: "disableTenantCaching", }, + disable_live_updates: { + doc: + "Disables subscriptions for the comment stream for all stories across all tenants", + format: Boolean, + default: false, + env: "DISABLE_LIVE_UPDATES", + arg: "disableLiveUpdates", + }, disable_mongodb_autoindexing: { doc: "Disables the creation of new MongoDB indexes", format: Boolean, diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index dfab602b9..5f94cb448 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -643,3 +643,11 @@ export class InviteRequiresEmailAddresses extends CoralError { }); } } + +export class LiveUpdatesDisabled extends CoralError { + constructor() { + super({ + code: ERROR_CODES.LIVE_UPDATES_DISABLED, + }); + } +} diff --git a/src/core/server/errors/translations.ts b/src/core/server/errors/translations.ts index 4fd6a13ee..9ebf407c9 100644 --- a/src/core/server/errors/translations.ts +++ b/src/core/server/errors/translations.ts @@ -48,4 +48,5 @@ export const ERROR_TRANSLATIONS: Record = { JWT_REVOKED: "error-jwtRevoked", INVITE_TOKEN_EXPIRED: "error-inviteTokenExpired", INVITE_REQUIRES_EMAIL_ADDRESSES: "error-inviteRequiresEmailAddresses", + LIVE_UPDATES_DISABLED: "error-liveUpdatesDisabled", }; diff --git a/src/core/server/graph/tenant/mutators/Settings.ts b/src/core/server/graph/tenant/mutators/Settings.ts index 768182ae6..302fe87d8 100644 --- a/src/core/server/graph/tenant/mutators/Settings.ts +++ b/src/core/server/graph/tenant/mutators/Settings.ts @@ -8,9 +8,10 @@ export const Settings = ({ redis, tenantCache, tenant, + config, }: TenantContext) => ({ update: (input: GQLUpdateSettingsInput): Promise => - update(mongo, redis, tenantCache, tenant, input.settings), + update(mongo, redis, tenantCache, config, tenant, input.settings), regenerateSSOKey: (): Promise => regenerateSSOKey(mongo, redis, tenantCache, tenant), }); diff --git a/src/core/server/graph/tenant/resolvers/LiveConfiguration.ts b/src/core/server/graph/tenant/resolvers/LiveConfiguration.ts new file mode 100644 index 000000000..746e0af85 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/LiveConfiguration.ts @@ -0,0 +1,24 @@ +import { isUndefined } from "lodash"; + +import { GQLLiveConfigurationTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; +import * as settings from "coral-server/models/settings"; + +export type LiveConfigurationInput = settings.LiveConfiguration; + +export const LiveConfiguration: GQLLiveConfigurationTypeResolver< + LiveConfigurationInput +> = { + configurable: (source, args, ctx) => + Boolean(!ctx.config.get("disable_live_updates")), + enabled: (source, args, ctx) => { + if (ctx.config.get("disable_live_updates")) { + return false; + } + + if (isUndefined(source.enabled)) { + return ctx.tenant.live.enabled; + } + + return source.enabled; + }, +}; diff --git a/src/core/server/graph/tenant/resolvers/StorySettings.ts b/src/core/server/graph/tenant/resolvers/StorySettings.ts index 7d59cd01c..67d9a63ae 100644 --- a/src/core/server/graph/tenant/resolvers/StorySettings.ts +++ b/src/core/server/graph/tenant/resolvers/StorySettings.ts @@ -5,6 +5,7 @@ import { GQLStorySettingsTypeResolver } from "../schema/__generated__/types"; export const StorySettings: GQLStorySettingsTypeResolver< story.StorySettings > = { + live: s => s.live || {}, moderation: (s, input, ctx) => s.moderation || ctx.tenant.moderation, premodLinksEnable: (s, input, ctx) => s.premodLinksEnable || ctx.tenant.premodLinksEnable, diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 2cb75ddf9..79b6b7890 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -22,6 +22,7 @@ import { FeatureCommentPayload } from "./FeatureCommentPayload"; import { Flag } from "./Flag"; import { GoogleAuthIntegration } from "./GoogleAuthIntegration"; import { Invite } from "./Invite"; +import { LiveConfiguration } from "./LiveConfiguration"; import { ModerationQueue } from "./ModerationQueue"; import { ModerationQueues } from "./ModerationQueues"; import { Mutation } from "./Mutation"; @@ -60,6 +61,7 @@ const Resolvers: GQLResolver = { Flag, GoogleAuthIntegration, Invite, + LiveConfiguration, ModerationQueue, ModerationQueues, Mutation, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 9e58008b9..33296d57e 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1086,6 +1086,12 @@ type Settings { """ locale: LOCALES! + """ + live provides configuration options related to live updates for stories on + this site. + """ + live: LiveConfiguration! + """ moderation is the moderation mode for all Stories on the site. """ @@ -2061,7 +2067,27 @@ type StoryMetadata { section: String } +""" +LiveConfiguration provides configuration options related to live updates. +""" +type LiveConfiguration { + """ + configurable when false indicates that live updates cannot be modified. + """ + configurable: Boolean! + + """ + enabled when true will allow live updates. + """ + enabled: Boolean! +} + type StorySettings { + """ + live provides configuration options related to live updates on this Story. + """ + live: LiveConfiguration! + """ moderation determines whether or not this is a PRE or POST moderated story. """ @@ -2921,6 +2947,12 @@ input StoryConfigurationInput { SettingsInput is the partial type of the Settings type for performing mutations. """ input SettingsInput { + """ + live provides configuration options related to live updates for stories on + this site. + """ + live: LiveConfigurationInput + """ allowedDomains is the list of domains that stories can come from. """ @@ -3401,10 +3433,25 @@ input StoryMessageBoxInput { content: String } +""" +LiveConfigurationInput provides configuration options related to live updates. +""" +input LiveConfigurationInput { + """ + enabled when true will allow live updates. + """ + enabled: Boolean +} + """ UpdateStorySettings is the input required to update a Story's Settings. """ input UpdateStorySettings { + """ + live provides configuration options related to live updates on this Story. + """ + live: LiveConfigurationInput + """ moderation determines whether or not this is a PRE or POST moderated story. """ diff --git a/src/core/server/graph/tenant/subscriptions/server.ts b/src/core/server/graph/tenant/subscriptions/server.ts index 8f8e55fe9..2d84369d9 100644 --- a/src/core/server/graph/tenant/subscriptions/server.ts +++ b/src/core/server/graph/tenant/subscriptions/server.ts @@ -24,6 +24,7 @@ import { import { CoralError, InternalError, + LiveUpdatesDisabled, TenantNotFoundError, } from "coral-server/errors"; import { @@ -35,6 +36,7 @@ import { getOperationMetadata } from "coral-server/graph/common/extensions/helpe import logger from "coral-server/logger"; import { extractTokenFromRequest } from "coral-server/services/jwt"; +import { userIsStaff } from "coral-server/models/user/helpers"; import TenantContext, { TenantContextOptions } from "../context"; type OnConnectFn = ( @@ -121,6 +123,17 @@ export function onConnect(options: OnConnectOptions): OnConnectFn { } } + // Check to see if live updates are disabled on the server, if they are, + // we can block the websocket request here for non-staff users. + if (options.config.get("disable_live_updates")) { + // TODO: (wyattjoh) if the story settings can only disable, and not + // enable live updates (as it takes precedence over global settings) + // then we can add a check for `!tenant.live.enabled` here too. + if (!opts.user || !userIsStaff(opts.user)) { + throw new LiveUpdatesDisabled(); + } + } + // Extract the users clientID from the request. const clientID = extractClientID(connectionParams); if (clientID) { @@ -129,7 +142,11 @@ export function onConnect(options: OnConnectOptions): OnConnectFn { return new TenantContext(opts); } catch (err) { - logger.error({ err }, "could not setup websocket connection"); + if (err instanceof LiveUpdatesDisabled) { + logger.info({ err }, "websocket connection rejected"); + } else { + logger.error({ err }, "could not setup websocket connection"); + } if (!(err instanceof CoralError)) { err = new InternalError(err, "could not setup websocket connection"); diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index 39db154cd..c9adc550b 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -3,6 +3,7 @@ import { GQLAuth, GQLFacebookAuthIntegration, GQLGoogleAuthIntegration, + GQLLiveConfiguration, GQLLocalAuthIntegration, GQLMODERATION_MODE, GQLOIDCAuthIntegration, @@ -10,7 +11,10 @@ import { GQLSSOAuthIntegration, } from "coral-server/graph/tenant/schema/__generated__/types"; +export type LiveConfiguration = Omit; + export interface GlobalModerationSettings { + live: LiveConfiguration; moderation: GQLMODERATION_MODE; premodLinksEnable: boolean; } diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index a3f4c1303..c71107f1e 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -18,6 +18,7 @@ import { createIndexFactory, } from "coral-server/models/helpers/indexing"; import Query from "coral-server/models/helpers/query"; +import { GlobalModerationSettings } from "coral-server/models/settings"; import { TenantResource } from "coral-server/models/tenant"; import { @@ -33,7 +34,9 @@ function collection(mongo: Db) { return mongo.collection>("stories"); } -export type StorySettings = DeepPartial; +export type StorySettings = DeepPartial< + Pick & GlobalModerationSettings +>; export type StoryMetadata = GQLStoryMetadata; diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 5f39c88b1..be33157cb 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -81,6 +81,11 @@ export async function createTenant( // Default to post moderation. moderation: GQLMODERATION_MODE.POST, + // Default to enabled. + live: { + enabled: true, + }, + communityGuidelines: { enabled: false, content: "", diff --git a/src/core/server/services/comments/pipeline/phases/commentLength.ts b/src/core/server/services/comments/pipeline/phases/commentLength.ts index df734c5ca..fa142adc9 100644 --- a/src/core/server/services/comments/pipeline/phases/commentLength.ts +++ b/src/core/server/services/comments/pipeline/phases/commentLength.ts @@ -35,7 +35,4 @@ export const commentLength: IntermediateModerationPhase = ({ // Reject if the comment is too long or too short. testCharCount(tenant, length); - if (story.settings) { - testCharCount(story.settings, length); - } }; diff --git a/src/core/server/services/comments/pipeline/phases/commentingDisabled.ts b/src/core/server/services/comments/pipeline/phases/commentingDisabled.ts index 75f2ec3c8..84782c37d 100644 --- a/src/core/server/services/comments/pipeline/phases/commentingDisabled.ts +++ b/src/core/server/services/comments/pipeline/phases/commentingDisabled.ts @@ -5,18 +5,13 @@ import { IntermediatePhaseResult, } from "coral-server/services/comments/pipeline"; -const testDisabledCommenting = (settings: Partial) => +const testDisabledCommenting = (settings: Settings) => settings.disableCommenting && settings.disableCommenting.enabled; export const commentingDisabled: IntermediateModerationPhase = ({ - story, tenant, }): IntermediatePhaseResult | void => { - // Check to see if the story has closed commenting. - if ( - testDisabledCommenting(tenant) || - (story.settings && testDisabledCommenting(story.settings)) - ) { + if (testDisabledCommenting(tenant)) { throw new CommentingDisabledError(); } }; diff --git a/src/core/server/services/comments/pipeline/phases/detectLinks.ts b/src/core/server/services/comments/pipeline/phases/detectLinks.ts index 83d715706..bc70c2433 100755 --- a/src/core/server/services/comments/pipeline/phases/detectLinks.ts +++ b/src/core/server/services/comments/pipeline/phases/detectLinks.ts @@ -1,3 +1,4 @@ +import { DeepPartial } from "coral-common/types"; import { GQLCOMMENT_FLAG_REASON, GQLCOMMENT_STATUS, @@ -11,7 +12,7 @@ import { } from "coral-server/services/comments/pipeline"; const testPremodLinksEnable = ( - settings: Partial, + settings: DeepPartial, comment: Pick ) => settings.premodLinksEnable && comment.metadata && comment.metadata.linkCount; diff --git a/src/core/server/services/comments/pipeline/phases/preModerate.ts b/src/core/server/services/comments/pipeline/phases/preModerate.ts index 4a2abb96f..72d4924f9 100755 --- a/src/core/server/services/comments/pipeline/phases/preModerate.ts +++ b/src/core/server/services/comments/pipeline/phases/preModerate.ts @@ -1,3 +1,4 @@ +import { DeepPartial } from "coral-common/types"; import { GQLCOMMENT_STATUS, GQLMODERATION_MODE, @@ -8,7 +9,7 @@ import { IntermediatePhaseResult, } from "coral-server/services/comments/pipeline"; -const testModerationMode = (settings: Partial) => +const testModerationMode = (settings: DeepPartial) => settings.moderation === GQLMODERATION_MODE.PRE; // This phase checks to see if the settings have premod enabled, if they do, diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index 4e749d88b..48d715344 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -1,8 +1,13 @@ import { Redis } from "ioredis"; +import { isUndefined } from "lodash"; import { Db } from "mongodb"; import { URL } from "url"; +import { discover } from "coral-server/app/middleware/passport/strategies/oidc/discover"; +import { Config } from "coral-server/config"; +import { TenantInstalledAlreadyError } from "coral-server/errors"; import { GQLSettingsInput } from "coral-server/graph/tenant/schema/__generated__/types"; +import logger from "coral-server/logger"; import { createTenant, CreateTenantInput, @@ -11,9 +16,6 @@ import { updateTenant, } from "coral-server/models/tenant"; -import { discover } from "coral-server/app/middleware/passport/strategies/oidc/discover"; -import { TenantInstalledAlreadyError } from "coral-server/errors"; -import logger from "coral-server/logger"; import TenantCache from "./cache"; export type UpdateTenant = GQLSettingsInput; @@ -22,9 +24,20 @@ export async function update( mongo: Db, redis: Redis, cache: TenantCache, + config: Config, tenant: Tenant, input: UpdateTenant ): Promise { + // If the environment variable for disabling live updates is provided, then + // ensure we don't permit changes to the database model. + if ( + config.get("disable_live_updates") && + input.live && + !isUndefined(input.live.enabled) + ) { + delete input.live.enabled; + } + const updatedTenant = await updateTenant(mongo, tenant.id, input); if (!updatedTenant) { return null; diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 0a005c43a..97a4ab582 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -292,6 +292,10 @@ configure-advanced-permittedDomains-explanation = Typical use is localhost, staging.yourdomain.com, yourdomain.com, etc. +configure-advanced-liveUpdates = Comment Stream Live Updates +configure-advanced-liveUpdates-explanation = + When enabled, there will be real-time loading and updating of comments as new comments and replies are published + ## Decision History decisionHistory-popover = .description = A dialog showing the decision history diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 3a027de4d..047c3d890 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -209,6 +209,10 @@ configure-premodLink-title = Pre-Moderate Comments Containing Links configure-premodLink-description = Moderators must approve any comment that contains a link before it is published to this stream. +configure-liveUpdates-title = Enable Live Updates for this Story +configure-liveUpdates-description = + When enabled, there will be real-time loading and updating of comments as new comments and replies are published. + configure-messageBox-title = Enable Message Box for this Stream configure-messageBox-description = Add a message to the top of the comment box for your readers. Use this to pose a topic,