mirror of
https://github.com/wassname/talk.git
synced 2026-07-01 16:37:54 +08:00
[CORL-416] Disable Live Updates (#2391)
* feat: initial implementation * fix: docs
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -53,4 +53,5 @@ class ConfigureRoute extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
const enhanced = withMutation(UpdateSettingsMutation)(ConfigureRoute);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -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<typeof CustomCSSConfigContainer>["settings"] &
|
||||
PropTypesOf<typeof PermittedDomainsConfigContainer>["settings"];
|
||||
PropTypesOf<typeof PermittedDomainsConfigContainer>["settings"] &
|
||||
PropTypesOf<typeof CommentStreamLiveUpdatesContainer>["settings"] &
|
||||
PropTypesOf<typeof CommentStreamLiveUpdatesContainer>["settingsReadOnly"];
|
||||
onInitValues: (values: any) => void;
|
||||
}
|
||||
|
||||
@@ -24,6 +27,12 @@ const AdvancedConfig: FunctionComponent<Props> = ({
|
||||
settings={settings}
|
||||
onInitValues={onInitValues}
|
||||
/>
|
||||
<CommentStreamLiveUpdatesContainer
|
||||
disabled={disabled}
|
||||
settings={settings}
|
||||
settingsReadOnly={settings}
|
||||
onInitValues={onInitValues}
|
||||
/>
|
||||
<PermittedDomainsConfigContainer
|
||||
disabled={disabled}
|
||||
settings={settings}
|
||||
|
||||
+4
-2
@@ -3,7 +3,7 @@ import { RouteProps } from "found";
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { AdvancedConfigContainer_settings as SettingsData } from "coral-admin/__generated__/AdvancedConfigContainer_settings.graphql";
|
||||
import { AdvancedConfigContainer_settings } from "coral-admin/__generated__/AdvancedConfigContainer_settings.graphql";
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
|
||||
@@ -12,7 +12,7 @@ import AdvancedConfig from "./AdvancedConfig";
|
||||
interface Props {
|
||||
form: FormApi;
|
||||
submitting: boolean;
|
||||
settings: SettingsData;
|
||||
settings: AdvancedConfigContainer_settings;
|
||||
}
|
||||
|
||||
class AdvancedConfigContainer extends React.Component<Props> {
|
||||
@@ -47,6 +47,8 @@ const enhanced = withFragmentContainer<Props>({
|
||||
fragment AdvancedConfigContainer_settings on Settings {
|
||||
...CustomCSSConfigContainer_settings
|
||||
...PermittedDomainsConfigContainer_settings
|
||||
...CommentStreamLiveUpdatesContainer_settings
|
||||
...CommentStreamLiveUpdatesContainer_settingsReadOnly
|
||||
}
|
||||
`,
|
||||
})(AdvancedConfigContainer);
|
||||
|
||||
@@ -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<Props> = ({ disabled }) => (
|
||||
<FormField>
|
||||
<HorizontalGutter size="full">
|
||||
<Localized id="configure-advanced-liveUpdates">
|
||||
<Header container={<label htmlFor="configure-advanced-liveUpdates" />}>
|
||||
Comment Stream Live Updates
|
||||
</Header>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="configure-advanced-liveUpdates-explanation"
|
||||
strong={<strong />}
|
||||
>
|
||||
<Typography variant="detail">
|
||||
When enabled, there will be real-time loading and updating of comments
|
||||
as new comments and replies are published
|
||||
</Typography>
|
||||
</Localized>
|
||||
<OnOffField name="live.enabled" disabled={disabled} />
|
||||
</HorizontalGutter>
|
||||
</FormField>
|
||||
);
|
||||
|
||||
export default CommentStreamLiveUpdates;
|
||||
+56
@@ -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<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
props.onInitValues(props.settings);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
disabled,
|
||||
settingsReadOnly: {
|
||||
live: { configurable },
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
if (!configurable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <CommentStreamLiveUpdates disabled={disabled} />;
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment CommentStreamLiveUpdatesContainer_settings on Settings {
|
||||
live {
|
||||
enabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
settingsReadOnly: graphql`
|
||||
fragment CommentStreamLiveUpdatesContainer_settingsReadOnly on Settings {
|
||||
live {
|
||||
configurable
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(CommentStreamLiveUpdatesContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -144,6 +144,75 @@ exports[`renders configure advanced 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<label
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary Header-root"
|
||||
htmlFor="configure-advanced-liveUpdates"
|
||||
>
|
||||
Comment Stream Live Updates
|
||||
</label>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextPrimary"
|
||||
>
|
||||
When enabled, there will be real-time loading and updating of comments as new comments and replies are published
|
||||
</p>
|
||||
<div>
|
||||
<div
|
||||
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<input
|
||||
checked={true}
|
||||
className="RadioButton-input"
|
||||
disabled={false}
|
||||
id="live.enabled-true"
|
||||
name="live.enabled"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="radio"
|
||||
value={true}
|
||||
/>
|
||||
<label
|
||||
className="RadioButton-label"
|
||||
htmlFor="live.enabled-true"
|
||||
>
|
||||
<span>
|
||||
On
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
className="RadioButton-input"
|
||||
disabled={false}
|
||||
id="live.enabled-false"
|
||||
name="live.enabled"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="radio"
|
||||
value={false}
|
||||
/>
|
||||
<label
|
||||
className="RadioButton-label"
|
||||
htmlFor="live.enabled-false"
|
||||
>
|
||||
<span>
|
||||
Off
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
>
|
||||
|
||||
@@ -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<GQLResolver>({
|
||||
Query: {
|
||||
settings: () =>
|
||||
pureMerge<typeof settings>(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<GQLResolver>({
|
||||
Mutation: {
|
||||
|
||||
@@ -23,6 +23,10 @@ export const settings = createFixture<GQLSettings>({
|
||||
id: "settings",
|
||||
moderation: GQLMODERATION_MODE.POST,
|
||||
premodLinksEnable: false,
|
||||
live: {
|
||||
enabled: true,
|
||||
configurable: true,
|
||||
},
|
||||
wordList: {
|
||||
suspect: ["idiot", "stupid"],
|
||||
banned: ["fuck"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,7 @@ it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof ReplyListContainerN> = {
|
||||
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<typeof ReplyListContainerN> = {
|
||||
story: {
|
||||
isClosed: false,
|
||||
settings: {
|
||||
live: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
comment: {
|
||||
id: "comment-id",
|
||||
@@ -80,6 +86,11 @@ describe("when has more replies", () => {
|
||||
const props: PropTypesOf<typeof ReplyListContainerN> = {
|
||||
story: {
|
||||
isClosed: false,
|
||||
settings: {
|
||||
live: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
comment: {
|
||||
id: "comment-id",
|
||||
|
||||
@@ -61,9 +61,10 @@ export const ReplyListContainer: React.FunctionComponent<Props> = 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 => {
|
||||
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
|
||||
}
|
||||
|
||||
+30
@@ -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}
|
||||
|
||||
+10
-3
@@ -52,9 +52,10 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = 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> = 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 {
|
||||
|
||||
@@ -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<typeof PremodConfigContainer>["storySettings"] &
|
||||
PropTypesOf<typeof PremodLinksConfigContainer>["storySettings"] &
|
||||
PropTypesOf<typeof MessageBoxConfigContainer>["storySettings"];
|
||||
PropTypesOf<typeof MessageBoxConfigContainer>["storySettings"] &
|
||||
PropTypesOf<typeof LiveUpdatesConfigContainer>["storySettings"] &
|
||||
PropTypesOf<typeof LiveUpdatesConfigContainer>["storySettingsReadOnly"];
|
||||
}
|
||||
|
||||
const ConfigureStream: FunctionComponent<Props> = ({
|
||||
@@ -58,6 +61,12 @@ const ConfigureStream: FunctionComponent<Props> = ({
|
||||
</Flex>
|
||||
<HorizontalGutter size="double">
|
||||
{submitError && <CallOut color="error">{submitError}</CallOut>}
|
||||
<LiveUpdatesConfigContainer
|
||||
onInitValues={onInitValues}
|
||||
storySettings={storySettings}
|
||||
storySettingsReadOnly={storySettings}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<PremodConfigContainer
|
||||
onInitValues={onInitValues}
|
||||
storySettings={storySettings}
|
||||
|
||||
@@ -51,8 +51,11 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...PremodConfigContainer_storySettings
|
||||
...PremodLinksConfigContainer_storySettings
|
||||
...MessageBoxConfigContainer_storySettings
|
||||
...LiveUpdatesConfigContainer_storySettings
|
||||
...LiveUpdatesConfigContainer_storySettingsReadOnly
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(withUpdateStorySettingsMutation(ConfigureStreamContainer));
|
||||
|
||||
export default enhanced;
|
||||
|
||||
+41
@@ -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<Props> = ({ disabled }) => (
|
||||
<Field name="live.enabled" type="checkbox" parse={parseBool}>
|
||||
{({ input }) => (
|
||||
<ToggleConfig
|
||||
id={input.name}
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
onFocus={input.onFocus}
|
||||
onBlur={input.onBlur}
|
||||
checked={input.checked}
|
||||
disabled={disabled}
|
||||
title={
|
||||
<Localized id="configure-liveUpdates-title">
|
||||
<span>Enable Live Updates for this Story</span>
|
||||
</Localized>
|
||||
}
|
||||
>
|
||||
<Localized id="configure-liveUpdates-description">
|
||||
<WidthLimitedDescription>
|
||||
When enabled, there will be real-time loading and updating of
|
||||
comments as new comments and replies are published.
|
||||
</WidthLimitedDescription>
|
||||
</Localized>
|
||||
</ToggleConfig>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
|
||||
export default LiveUpdatesConfig;
|
||||
+56
@@ -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<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
props.onInitValues(props.storySettings);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
disabled,
|
||||
storySettingsReadOnly: {
|
||||
live: { configurable },
|
||||
},
|
||||
} = this.props;
|
||||
|
||||
if (!configurable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <LiveUpdatesConfig disabled={disabled} />;
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
storySettings: graphql`
|
||||
fragment LiveUpdatesConfigContainer_storySettings on StorySettings {
|
||||
live {
|
||||
enabled
|
||||
}
|
||||
}
|
||||
`,
|
||||
storySettingsReadOnly: graphql`
|
||||
fragment LiveUpdatesConfigContainer_storySettingsReadOnly on StorySettings {
|
||||
live {
|
||||
configurable
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(LiveUpdatesConfigContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
default,
|
||||
default as LiveUpdatesConfigContainer,
|
||||
} from "./LiveUpdatesConfigContainer";
|
||||
@@ -79,6 +79,48 @@ exports[`renders configure 1`] = `
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="CheckBox-root"
|
||||
>
|
||||
<input
|
||||
checked={true}
|
||||
className="CheckBox-input"
|
||||
disabled={false}
|
||||
id="live.enabled"
|
||||
name="live.enabled"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="CheckBox-label"
|
||||
htmlFor="live.enabled"
|
||||
>
|
||||
<span
|
||||
className="CheckBox-labelSpan"
|
||||
>
|
||||
<span
|
||||
className="Box-root Typography-root Typography-heading3 Typography-colorTextPrimary"
|
||||
>
|
||||
<span>
|
||||
Enable Live Updates for this Story
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="ToggleConfig-details"
|
||||
>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary WidthLimitedDescription-root"
|
||||
>
|
||||
When enabled, there will be real-time loading and updating of comments as new comments and replies are published.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="CheckBox-root"
|
||||
|
||||
@@ -23,6 +23,10 @@ export const settings = createFixture<GQLSettings>({
|
||||
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<GQLStory>({
|
||||
messageBox: {
|
||||
enabled: false,
|
||||
},
|
||||
live: {
|
||||
enabled: true,
|
||||
configurable: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -643,3 +643,11 @@ export class InviteRequiresEmailAddresses extends CoralError {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class LiveUpdatesDisabled extends CoralError {
|
||||
constructor() {
|
||||
super({
|
||||
code: ERROR_CODES.LIVE_UPDATES_DISABLED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
|
||||
JWT_REVOKED: "error-jwtRevoked",
|
||||
INVITE_TOKEN_EXPIRED: "error-inviteTokenExpired",
|
||||
INVITE_REQUIRES_EMAIL_ADDRESSES: "error-inviteRequiresEmailAddresses",
|
||||
LIVE_UPDATES_DISABLED: "error-liveUpdatesDisabled",
|
||||
};
|
||||
|
||||
@@ -8,9 +8,10 @@ export const Settings = ({
|
||||
redis,
|
||||
tenantCache,
|
||||
tenant,
|
||||
config,
|
||||
}: TenantContext) => ({
|
||||
update: (input: GQLUpdateSettingsInput): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, tenant, input.settings),
|
||||
update(mongo, redis, tenantCache, config, tenant, input.settings),
|
||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||
regenerateSSOKey(mongo, redis, tenantCache, tenant),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<GQLLiveConfiguration, "configurable">;
|
||||
|
||||
export interface GlobalModerationSettings {
|
||||
live: LiveConfiguration;
|
||||
moderation: GQLMODERATION_MODE;
|
||||
premodLinksEnable: boolean;
|
||||
}
|
||||
|
||||
@@ -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<T = Story>(mongo: Db) {
|
||||
return mongo.collection<Readonly<T>>("stories");
|
||||
}
|
||||
|
||||
export type StorySettings = DeepPartial<GQLStorySettings>;
|
||||
export type StorySettings = DeepPartial<
|
||||
Pick<GQLStorySettings, "messageBox"> & GlobalModerationSettings
|
||||
>;
|
||||
|
||||
export type StoryMetadata = GQLStoryMetadata;
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,18 +5,13 @@ import {
|
||||
IntermediatePhaseResult,
|
||||
} from "coral-server/services/comments/pipeline";
|
||||
|
||||
const testDisabledCommenting = (settings: Partial<Settings>) =>
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<GlobalModerationSettings>,
|
||||
settings: DeepPartial<GlobalModerationSettings>,
|
||||
comment: Pick<Comment, "metadata">
|
||||
) =>
|
||||
settings.premodLinksEnable && comment.metadata && comment.metadata.linkCount;
|
||||
|
||||
@@ -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<GlobalModerationSettings>) =>
|
||||
const testModerationMode = (settings: DeepPartial<GlobalModerationSettings>) =>
|
||||
settings.moderation === GQLMODERATION_MODE.PRE;
|
||||
|
||||
// This phase checks to see if the settings have premod enabled, if they do,
|
||||
|
||||
@@ -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<Tenant | null> {
|
||||
// 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user