mirror of
https://github.com/wassname/talk.git
synced 2026-07-06 05:17:19 +08:00
[CORL 547] org-wide announcements (#2813)
* CRUD announcements * only show announcement if not disabled * make announcements dismissable * add announcement mutations * update announcement form logic * style announcements on stream * update snap * localize strings * close form if announcement is removed * move announcement config below sitewide commenting config * move date calculation inside useMemo * move announcementconfig code to announcementconfigcontainer * use coralContext for localStorage * fix type of announcement createdAt * move announcement form to modal * remove payload pruning from configure route * simplify announcement display logic * make validation message full width Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import { DateTime } from "luxon";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import {
|
||||
FormField,
|
||||
FormFieldFooter,
|
||||
FormFieldHeader,
|
||||
Label,
|
||||
Textarea,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const Announcement: FunctionComponent<Props> = ({
|
||||
content,
|
||||
createdAt,
|
||||
duration,
|
||||
}) => {
|
||||
const { locales } = useCoralContext();
|
||||
const formattedDate = useMemo(() => {
|
||||
const disableAt = DateTime.fromISO(createdAt)
|
||||
.plus({ seconds: duration })
|
||||
.toJSDate();
|
||||
return new Intl.DateTimeFormat(locales, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(disableAt));
|
||||
}, [createdAt, duration]);
|
||||
return (
|
||||
<FormField>
|
||||
<FormFieldHeader>
|
||||
<Localized id="configure-general-announcements-current-label">
|
||||
<Label htmlFor="configure-general-announcements-current-content">
|
||||
Current announcement
|
||||
</Label>
|
||||
</Localized>
|
||||
</FormFieldHeader>
|
||||
<Textarea
|
||||
fullwidth
|
||||
disabled
|
||||
name="configure-general-announcements-current-content"
|
||||
value={content}
|
||||
/>
|
||||
<Localized
|
||||
id="configure-general-announcements-current-duration"
|
||||
$timestamp={formattedDate}
|
||||
>
|
||||
<FormFieldFooter>
|
||||
This announcement will automatically end on:{" "}
|
||||
<strong>{formattedDate}</strong>
|
||||
</FormFieldFooter>
|
||||
</Localized>
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
|
||||
export default Announcement;
|
||||
@@ -0,0 +1,2 @@
|
||||
.root {
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import {
|
||||
graphql,
|
||||
useMutation,
|
||||
withFragmentContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import {
|
||||
Button,
|
||||
CallOut,
|
||||
FieldSet,
|
||||
FormFieldDescription,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import { AnnouncementConfigContainer_settings as SettingsData } from "coral-admin/__generated__/AnnouncementConfigContainer_settings.graphql";
|
||||
|
||||
import ConfigBox from "../../ConfigBox";
|
||||
import Header from "../../Header";
|
||||
import Announcement from "./Announcement";
|
||||
import AnnouncementFormModal from "./AnnouncementFormModal";
|
||||
import CreateAnnouncementMutaiton from "./CreateAnnouncementMutation";
|
||||
import DeleteAnnouncementMutaiton from "./DeleteAnnouncementMutation";
|
||||
|
||||
interface Props {
|
||||
settings: SettingsData;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const AnnouncementConfigContainer: FunctionComponent<Props> = ({
|
||||
settings,
|
||||
disabled,
|
||||
}) => {
|
||||
const createAnnouncement = useMutation(CreateAnnouncementMutaiton);
|
||||
const deleteAnnouncement = useMutation(DeleteAnnouncementMutaiton);
|
||||
const [showForm, setShowForm] = useState<boolean>(false);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
const onClose = useCallback(() => {
|
||||
setShowForm(false);
|
||||
}, [showForm]);
|
||||
const onCreate = useCallback(values => {
|
||||
try {
|
||||
setSubmitError(null);
|
||||
createAnnouncement(values);
|
||||
setShowForm(false);
|
||||
} catch (error) {
|
||||
setSubmitError(error.message);
|
||||
}
|
||||
}, []);
|
||||
const onDelete = useCallback(() => {
|
||||
try {
|
||||
setSubmitError(null);
|
||||
deleteAnnouncement();
|
||||
} catch (error) {
|
||||
setSubmitError(error.message);
|
||||
setShowForm(false);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<ConfigBox
|
||||
title={
|
||||
<Localized id="configure-general-announcements-title">
|
||||
<Header container={<legend />}>Community announcement</Header>
|
||||
</Localized>
|
||||
}
|
||||
container={<FieldSet />}
|
||||
>
|
||||
<Localized id="configure-general-announcements-description">
|
||||
<FormFieldDescription>
|
||||
Add a temporary announcement that will appear at the top of all of
|
||||
your organization’s comment streams for a specific amount of time.
|
||||
</FormFieldDescription>
|
||||
</Localized>
|
||||
{!settings.announcement && (
|
||||
<Localized id="configure-general-announcements-add">
|
||||
<Button disabled={disabled} onClick={() => setShowForm(true)}>
|
||||
Add announcement
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
<AnnouncementFormModal
|
||||
open={!settings.announcement && showForm}
|
||||
onSubmit={onCreate}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{submitError && (
|
||||
<CallOut fullWidth color="error">
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
{settings.announcement && settings.announcement.createdAt && (
|
||||
<>
|
||||
<Announcement
|
||||
content={settings.announcement.content}
|
||||
createdAt={settings.announcement.createdAt}
|
||||
duration={settings.announcement.duration}
|
||||
/>
|
||||
<Localized id="configure-general-announcements-delete">
|
||||
<Button color="alert" onClick={onDelete}>
|
||||
Remove announcement
|
||||
</Button>
|
||||
</Localized>
|
||||
</>
|
||||
)}
|
||||
</ConfigBox>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment AnnouncementConfigContainer_settings on Settings {
|
||||
announcement {
|
||||
content
|
||||
duration
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(AnnouncementConfigContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,15 @@
|
||||
$announcement-modal-text: var(--v2-colors-mono-500);
|
||||
|
||||
.root {
|
||||
min-width: 500px;
|
||||
padding: var(--v2-spacing-2) var(--v2-spacing-3) var(--v2-spacing-3)
|
||||
var(--v2-spacing-3);
|
||||
}
|
||||
.title {
|
||||
font-size: var(--v2-font-size-5);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
line-height: var(--v2-line-height-title);
|
||||
color: $announcement-modal-text;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import { colorFromMeta, parseEmptyAsNull } from "coral-framework/lib/form";
|
||||
import {
|
||||
composeValidators,
|
||||
required,
|
||||
validateWholeNumberGreaterThan,
|
||||
} from "coral-framework/lib/validation";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardCloseButton,
|
||||
DURATION_UNIT,
|
||||
DurationField,
|
||||
FieldSet,
|
||||
Flex,
|
||||
FormField,
|
||||
FormFieldHeader,
|
||||
HorizontalGutter,
|
||||
Label,
|
||||
Modal,
|
||||
ModalProps,
|
||||
Textarea,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import ValidationMessage from "../../ValidationMessage";
|
||||
|
||||
import styles from "./AnnouncementFormModal.css";
|
||||
|
||||
interface Props extends Partial<ModalProps> {
|
||||
onSubmit: (values: any) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AnnouncementForm: FunctionComponent<Props> = ({
|
||||
onSubmit,
|
||||
onClose,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<Modal {...rest}>
|
||||
{({ firstFocusableRef }) => (
|
||||
<Card className={styles.root}>
|
||||
<Flex justifyContent="flex-end">
|
||||
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
|
||||
</Flex>
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
initialValues={{ content: "", duration: 86400 }}
|
||||
>
|
||||
{({ handleSubmit, submitting, submitError }) => (
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={handleSubmit}
|
||||
id="announcements-form"
|
||||
>
|
||||
<HorizontalGutter spacing={4}>
|
||||
<HorizontalGutter spacing={3}>
|
||||
<FormField>
|
||||
<FormFieldHeader>
|
||||
<Localized id="configure-general-announcements-title">
|
||||
<Label htmlFor="configure-general-announcements-content">
|
||||
Announcement text
|
||||
</Label>
|
||||
</Localized>
|
||||
</FormFieldHeader>
|
||||
|
||||
<Field
|
||||
name="content"
|
||||
parse={parseEmptyAsNull}
|
||||
validate={required}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<>
|
||||
<Textarea
|
||||
{...input}
|
||||
fullwidth
|
||||
id="configure-general-announcements-content"
|
||||
/>
|
||||
<ValidationMessage meta={meta} fullWidth />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</FormField>
|
||||
<FormField container={<FieldSet />}>
|
||||
<Localized id="configure-general-announcements-duration">
|
||||
<Label component="legend">
|
||||
Show this announcement for
|
||||
</Label>
|
||||
</Localized>
|
||||
|
||||
<Field
|
||||
name="duration"
|
||||
validate={composeValidators(
|
||||
required,
|
||||
validateWholeNumberGreaterThan(0)
|
||||
)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<>
|
||||
<DurationField
|
||||
{...input}
|
||||
units={[
|
||||
DURATION_UNIT.HOUR,
|
||||
DURATION_UNIT.DAY,
|
||||
DURATION_UNIT.WEEK,
|
||||
]}
|
||||
color={colorFromMeta(meta)}
|
||||
/>
|
||||
<ValidationMessage meta={meta} fullWidth />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</FormField>
|
||||
</HorizontalGutter>
|
||||
<Flex itemGutter justifyContent="flex-end">
|
||||
<Localized id="configure-general-announcements-cancel">
|
||||
<Button color="mono" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="configure-general-announcements-start">
|
||||
<Button type="submit">Start announcement</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementForm;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { CreateAnnouncementMutation as MutationTypes } from "coral-admin/__generated__/CreateAnnouncementMutation.graphql";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const CreateAnnouncementMutation = createMutation(
|
||||
"createAnnouncement",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) =>
|
||||
commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation CreateAnnouncementMutation($input: CreateAnnouncementInput!) {
|
||||
createAnnouncement(input: $input) {
|
||||
settings {
|
||||
announcement {
|
||||
id
|
||||
content
|
||||
createdAt
|
||||
duration
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
content: input.content,
|
||||
duration: input.duration,
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default CreateAnnouncementMutation;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { DeleteAnnouncementMutation as MutationTypes } from "coral-admin/__generated__/DeleteAnnouncementMutation.graphql";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const DeleteAnnouncementMutation = createMutation(
|
||||
"deleteAnnouncement",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) =>
|
||||
commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation DeleteAnnouncementMutation($input: DeleteAnnouncementInput!) {
|
||||
deleteAnnouncement(input: $input) {
|
||||
clientMutationId
|
||||
settings {
|
||||
announcement {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default DeleteAnnouncementMutation;
|
||||
@@ -10,6 +10,7 @@ import { HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
import { GeneralConfigContainer_settings as SettingsData } from "coral-admin/__generated__/GeneralConfigContainer_settings.graphql";
|
||||
|
||||
import AnnouncementConfigContainer from "./AnnouncementConfigContainer";
|
||||
import ClosedStreamMessageConfig from "./ClosedStreamMessageConfig";
|
||||
import ClosingCommentStreamsConfig from "./ClosingCommentStreamsConfig";
|
||||
import CommentEditingConfig from "./CommentEditingConfig";
|
||||
@@ -41,6 +42,7 @@ const GeneralConfigContainer: React.FunctionComponent<Props> = ({
|
||||
>
|
||||
<LocaleConfig disabled={submitting} />
|
||||
<SitewideCommentingConfig disabled={submitting} />
|
||||
<AnnouncementConfigContainer disabled={submitting} settings={settings} />
|
||||
<GuidelinesConfig disabled={submitting} />
|
||||
<CommentLengthConfig disabled={submitting} />
|
||||
<CommentEditingConfig disabled={submitting} />
|
||||
@@ -55,6 +57,7 @@ const GeneralConfigContainer: React.FunctionComponent<Props> = ({
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment GeneralConfigContainer_settings on Settings {
|
||||
...AnnouncementConfigContainer_settings
|
||||
...LocaleConfig_formValues @relay(mask: false)
|
||||
...GuidelinesConfig_formValues @relay(mask: false)
|
||||
...CommentLengthConfig_formValues @relay(mask: false)
|
||||
|
||||
@@ -359,6 +359,50 @@ reactions, be reported, and be shared.
|
||||
</fieldset>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root ConfigBox-root"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
|
||||
>
|
||||
<div>
|
||||
<legend
|
||||
className="Header-root"
|
||||
>
|
||||
Community announcement
|
||||
</legend>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
className="ConfigBox-content"
|
||||
>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
|
||||
>
|
||||
<p
|
||||
className="FormFieldDescription-root"
|
||||
>
|
||||
Add a temporary announcement that will appear at the top of all of your organization’s comment streams for a specific amount of time.
|
||||
</p>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase"
|
||||
data-color="regular"
|
||||
data-variant="regular"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Add announcement
|
||||
</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root ConfigBox-root"
|
||||
>
|
||||
|
||||
@@ -10,3 +10,9 @@ export const COUNT_SELECTOR = ".coral-count";
|
||||
* `document.currentScript` is not available (for legacy browsers).
|
||||
*/
|
||||
export const ORIGIN_FALLBACK_ID = "coral-script";
|
||||
|
||||
/**
|
||||
* ANNOUNEMENT_DISMISSED_KEY is the localStorage key to store the ID of the
|
||||
* most recently dismissed announcement
|
||||
*/
|
||||
export const ANNOUNEMENT_DISMISSED_KEY = "coral:lastAnnouncementDismissed";
|
||||
|
||||
@@ -9,6 +9,11 @@ const CLASSES = {
|
||||
*/
|
||||
guidelines: "coral coral-guidelines",
|
||||
|
||||
/**
|
||||
* guidlines represents the box containing the guidlines.
|
||||
*/
|
||||
announcement: "coral coral-announcement",
|
||||
|
||||
/**
|
||||
* closedSitewide represents the box containing the message when comments
|
||||
* are closed sitewide.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.root {
|
||||
background-color: #2b7eb5;
|
||||
color: white;
|
||||
padding: var(--v2-spacing-3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.text {
|
||||
@mixin bodyCopy;
|
||||
color: white;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import CLASSES from "coral-stream/classes";
|
||||
import { Button, Flex, Icon } from "coral-ui/components";
|
||||
|
||||
import styles from "./Announcement.css";
|
||||
|
||||
interface Props {
|
||||
children: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const Announcement: FunctionComponent<Props> = props => {
|
||||
return (
|
||||
<div className={cn(styles.root, CLASSES.announcement)}>
|
||||
<Flex justifyContent="space-between" alignItems="center">
|
||||
<Flex itemGutter>
|
||||
<Icon size="lg">notifications</Icon>
|
||||
<span className={styles.text}>{props.children}</span>
|
||||
</Flex>
|
||||
<Button color="light" onClick={props.onClose}>
|
||||
<Icon>close</Icon>
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Announcement;
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ANNOUNEMENT_DISMISSED_KEY } from "coral-framework/constants";
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
|
||||
import { AnnouncementContainer_settings as SettingsData } from "coral-stream/__generated__/AnnouncementContainer_settings.graphql";
|
||||
|
||||
import Announcement from "./Announcement";
|
||||
|
||||
interface Props {
|
||||
settings: SettingsData;
|
||||
}
|
||||
|
||||
export const AnnouncementContainer: FunctionComponent<Props> = ({
|
||||
settings,
|
||||
}) => {
|
||||
const { localStorage } = useCoralContext();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
useEffect(() => {
|
||||
async function getDismissedStatus() {
|
||||
if (settings.announcement) {
|
||||
const key = await localStorage.getItem(ANNOUNEMENT_DISMISSED_KEY);
|
||||
if (key && key === settings.announcement.id) {
|
||||
setDismissed(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
getDismissedStatus();
|
||||
}, [settings]);
|
||||
const dismissAnnouncement = useCallback(async () => {
|
||||
if (settings.announcement) {
|
||||
await localStorage.setItem(
|
||||
ANNOUNEMENT_DISMISSED_KEY,
|
||||
settings.announcement.id
|
||||
);
|
||||
setDismissed(true);
|
||||
}
|
||||
}, [settings]);
|
||||
if (!settings.announcement || dismissed) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Announcement onClose={dismissAnnouncement}>
|
||||
{settings.announcement.content}
|
||||
</Announcement>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment AnnouncementContainer_settings on Settings {
|
||||
announcement {
|
||||
id
|
||||
content
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(AnnouncementContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
default,
|
||||
default as AnnouncementContainer,
|
||||
} from "./AnnouncementContainer";
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "coral-stream/__generated__/StreamContainerLocal.graphql";
|
||||
|
||||
import AllCommentsTab from "./AllCommentsTab";
|
||||
import AnnouncementContainer from "./Announcement";
|
||||
import BannedInfo from "./BannedInfo";
|
||||
import { CommunityGuidelinesContainer } from "./CommunityGuidelines";
|
||||
import StreamDeletionRequestCalloutContainer from "./DeleteAccount/StreamDeletionRequestCalloutContainer";
|
||||
@@ -152,6 +153,7 @@ export const StreamContainer: FunctionComponent<Props> = props => {
|
||||
size="double"
|
||||
>
|
||||
<UserBoxContainer viewer={props.viewer} settings={props.settings} />
|
||||
<AnnouncementContainer settings={props.settings} />
|
||||
{props.viewer && (
|
||||
<StreamDeletionRequestCalloutContainer viewer={props.viewer} />
|
||||
)}
|
||||
@@ -296,6 +298,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...UserBoxContainer_settings
|
||||
...CommunityGuidelinesContainer_settings
|
||||
...SuspendedInfoContainer_settings
|
||||
...AnnouncementContainer_settings
|
||||
}
|
||||
`,
|
||||
})(StreamContainer);
|
||||
|
||||
@@ -32,6 +32,7 @@ const DURATION_UNIT_MAP = {
|
||||
|
||||
interface Props extends Pick<TextFieldProps, "color" | "name" | "disabled"> {
|
||||
value: string;
|
||||
defaultValue?: string;
|
||||
onChange: (v: string) => void;
|
||||
/** Specifiy units to include */
|
||||
units?: ReadonlyArray<TIME>;
|
||||
@@ -79,10 +80,14 @@ const DurationField: FunctionComponent<Props> = ({
|
||||
value,
|
||||
units = [TIME.HOUR, TIME.DAY, TIME.WEEK],
|
||||
onChange,
|
||||
defaultValue = "",
|
||||
disabled,
|
||||
name,
|
||||
color,
|
||||
}) => {
|
||||
if (value.length < 1) {
|
||||
value = defaultValue;
|
||||
}
|
||||
const [selectedUnit, setSelectedUnit] = useState(
|
||||
convertFromSeconds(value, units).unit
|
||||
);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.root {
|
||||
color: var(--v2-colors-mono-100);
|
||||
font-size: var(--v2-font-size-2);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, ReactNode } from "react";
|
||||
|
||||
import { withStyles } from "coral-ui/hocs";
|
||||
import { Omit, PropTypesOf } from "coral-ui/types";
|
||||
|
||||
import HorizontalGutter from "coral-ui/components/HorizontalGutter";
|
||||
|
||||
import styles from "./FormFieldFooter.css";
|
||||
|
||||
interface Props extends Omit<PropTypesOf<typeof HorizontalGutter>, "ref"> {
|
||||
children: ReactNode;
|
||||
classes: typeof styles;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FormFieldFooter: FunctionComponent<Props> = props => {
|
||||
const { classes, className, children, ...rest } = props;
|
||||
|
||||
return (
|
||||
<HorizontalGutter
|
||||
className={cn(classes.root, className)}
|
||||
spacing={1}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withStyles(styles)(FormFieldFooter);
|
||||
export default enhanced;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./FormFieldFooter";
|
||||
@@ -31,6 +31,7 @@ export { default as Flex } from "./Flex";
|
||||
export { default as FormField } from "./FormField";
|
||||
export { default as FormFieldDescription } from "./FormFieldDescription";
|
||||
export { default as FormFieldHeader } from "./FormFieldHeader";
|
||||
export { default as FormFieldFooter } from "./FormFieldFooter";
|
||||
export { default as HelperText } from "./HelperText";
|
||||
export { default as HorizontalGutter } from "./HorizontalGutter";
|
||||
export { default as Icon } from "./Icon";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
createAnnouncement,
|
||||
deleteAnnouncement,
|
||||
disableFeatureFlag,
|
||||
enableFeatureFlag,
|
||||
regenerateSSOKey,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
} from "coral-server/services/tenant";
|
||||
|
||||
import {
|
||||
GQLCreateAnnouncementInput,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLUpdateSettingsInput,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
@@ -28,4 +31,8 @@ export const Settings = ({
|
||||
enableFeatureFlag(mongo, redis, tenantCache, tenant, flag),
|
||||
disableFeatureFlag: (flag: GQLFEATURE_FLAG) =>
|
||||
disableFeatureFlag(mongo, redis, tenantCache, tenant, flag),
|
||||
createAnnouncement: (input: GQLCreateAnnouncementInput) =>
|
||||
createAnnouncement(mongo, redis, tenantCache, tenant, input, now),
|
||||
deleteAnnouncement: () =>
|
||||
deleteAnnouncement(mongo, redis, tenantCache, tenant),
|
||||
});
|
||||
|
||||
@@ -237,4 +237,12 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
flags: await ctx.mutators.Settings.disableFeatureFlag(input.flag),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
createAnnouncement: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.createAnnouncement(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
deleteAnnouncement: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.deleteAnnouncement(),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
retrieveAnnouncementIfEnabled,
|
||||
Tenant,
|
||||
} from "coral-server/models/tenant";
|
||||
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
@@ -18,4 +21,6 @@ export const Settings: GQLSettingsTypeResolver<Tenant> = {
|
||||
slack: ({ slack = {} }) => slack,
|
||||
featureFlags: ({ featureFlags = [] }) =>
|
||||
featureFlags.filter(filterValidFeatureFlags()),
|
||||
announcement: ({ announcement }) =>
|
||||
retrieveAnnouncementIfEnabled(announcement),
|
||||
};
|
||||
|
||||
@@ -1215,6 +1215,37 @@ type NewCommentersConfiguration {
|
||||
approvedCommentsThreshold: Int!
|
||||
}
|
||||
|
||||
"""
|
||||
Announcement is an organization-wide announcement displayed above the stream
|
||||
for a set duration
|
||||
"""
|
||||
type Announcement {
|
||||
"""
|
||||
id is a canonical identifier for this Annoucnement, used to dismiss on front-end.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
createdAt is the creation date.
|
||||
"""
|
||||
createdAt: Time!
|
||||
|
||||
"""
|
||||
disableAt is the computed date at which announcement will be invalid.
|
||||
"""
|
||||
disableAt: Time!
|
||||
|
||||
"""
|
||||
duration determines how long the announcement will be valid for.
|
||||
"""
|
||||
duration: Int!
|
||||
|
||||
"""
|
||||
content is the content displayed for the announcement.
|
||||
"""
|
||||
content: String!
|
||||
}
|
||||
|
||||
"""
|
||||
Settings stores the global settings for a given Tenant.
|
||||
"""
|
||||
@@ -1365,6 +1396,8 @@ type Settings {
|
||||
newCommenters is the configuration for how new commenters comments are treated.
|
||||
"""
|
||||
newCommenters: NewCommentersConfiguration! @auth(roles: [ADMIN])
|
||||
|
||||
announcement: Announcement
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -3353,6 +3386,44 @@ input RecentCommentHistoryConfigurationInput {
|
||||
triggerRejectionRate: Float
|
||||
}
|
||||
|
||||
input CreateAnnouncementInput {
|
||||
"""
|
||||
content of the announcement.
|
||||
"""
|
||||
content: String!
|
||||
|
||||
duration: Int!
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
input DeleteAnnouncementInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type CreateAnnouncementPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
settings: Settings!
|
||||
}
|
||||
|
||||
type DeleteAnnouncementPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
settings: Settings!
|
||||
}
|
||||
|
||||
input SettingsCommunityGuidelinesInput {
|
||||
"""
|
||||
enable set to true will show the guidelines above the message box.
|
||||
@@ -5763,6 +5834,14 @@ type Mutation {
|
||||
disableFeatureFlag(
|
||||
input: DisableFeatureFlagInput!
|
||||
): DisableFeatureFlagPayload! @auth(roles: [ADMIN])
|
||||
|
||||
createAnnouncement(
|
||||
input: CreateAnnouncementInput!
|
||||
): CreateAnnouncementPayload! @auth(roles: [ADMIN])
|
||||
|
||||
deleteAnnouncement(
|
||||
input: DeleteAnnouncementInput!
|
||||
): DeleteAnnouncementPayload! @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
##################
|
||||
|
||||
@@ -146,6 +146,7 @@ export type Settings = GlobalModerationSettings &
|
||||
| "stories"
|
||||
| "createdAt"
|
||||
| "slack"
|
||||
| "announcement"
|
||||
> & {
|
||||
/**
|
||||
* auth is the set of configured authentication integrations.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isEmpty } from "lodash";
|
||||
import { DateTime } from "luxon";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
@@ -6,12 +7,14 @@ import { DEFAULT_SESSION_DURATION } from "coral-common/constants";
|
||||
import { LanguageCode } from "coral-common/helpers/i18n/locales";
|
||||
import TIME from "coral-common/time";
|
||||
import { DeepPartial, Omit, Sub } from "coral-common/types";
|
||||
import { isBeforeDate } from "coral-common/utils";
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import { Settings } from "coral-server/models/settings";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { tenants as collection } from "coral-server/services/mongodb/collections";
|
||||
|
||||
import {
|
||||
GQLAnnouncement,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLMODERATION_MODE,
|
||||
GQLSettings,
|
||||
@@ -396,3 +399,67 @@ export async function disableTenantFeatureFlag(
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export interface CreateAnnouncementInput {
|
||||
content: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export async function createTenantAnnouncement(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
input: CreateAnnouncementInput,
|
||||
now = new Date()
|
||||
) {
|
||||
const announcement = {
|
||||
id: uuid.v4(),
|
||||
...input,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
announcement,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function deleteTenantAnnouncement(mongo: Db, id: string) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$unset: {
|
||||
announcement: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export function retrieveAnnouncementIfEnabled(
|
||||
announcement?: GQLAnnouncement | null
|
||||
) {
|
||||
if (!announcement) {
|
||||
return null;
|
||||
}
|
||||
const disableAt = DateTime.fromJSDate(announcement.createdAt)
|
||||
.plus({ seconds: announcement.duration })
|
||||
.toJSDate();
|
||||
if (isBeforeDate(disableAt)) {
|
||||
return {
|
||||
...announcement,
|
||||
disableAt,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ import { Config } from "coral-server/config";
|
||||
import { TenantInstalledAlreadyError } from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
CreateAnnouncementInput,
|
||||
createTenant,
|
||||
createTenantAnnouncement,
|
||||
CreateTenantInput,
|
||||
createTenantSSOKey,
|
||||
deleteTenantAnnouncement,
|
||||
disableTenantFeatureFlag,
|
||||
enableTenantFeatureFlag,
|
||||
rotateTenantSSOKey,
|
||||
@@ -253,3 +256,33 @@ export async function disableFeatureFlag(
|
||||
// begin with).
|
||||
return updated.featureFlags || [];
|
||||
}
|
||||
|
||||
export async function createAnnouncement(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
input: CreateAnnouncementInput,
|
||||
now = new Date()
|
||||
) {
|
||||
const updated = await createTenantAnnouncement(mongo, tenant.id, input);
|
||||
if (!updated) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
await cache.update(redis, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteAnnouncement(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant
|
||||
) {
|
||||
const updated = await deleteTenantAnnouncement(mongo, tenant.id);
|
||||
if (!updated) {
|
||||
throw new Error("tenant not found");
|
||||
}
|
||||
await cache.update(redis, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,18 @@ configure-general-sitewideCommenting-message = Sitewide closed comments message
|
||||
configure-general-sitewideCommenting-messageExplanation =
|
||||
Write a message that will be displayed when comment streams are closed sitewide
|
||||
|
||||
configure-general-announcements-title = Community announcement
|
||||
configure-general-announcements-description =
|
||||
Add a temporary announcement that will appear at the top of all of your organization’s comment streams for a specific amount of time.
|
||||
configure-general-announcements-delete = Remove announcement
|
||||
configure-general-announcements-add = Add announcement
|
||||
configure-general-announcements-start = Start announcement
|
||||
configure-general-announcements-cancel = Cancel
|
||||
configure-general-announcements-current-label = Current announcement
|
||||
configure-general-announcements-current-duration =
|
||||
This announcement will automatically end on: { $timestamp }
|
||||
configure-general-announcements-duration = Show this announcement for
|
||||
|
||||
#### Closing Comment Streams
|
||||
configure-general-closingCommentStreams-title = Closing comment streams
|
||||
configure-general-closingCommentStreams-explanation = Set comment streams to close after a defined period of time after a story’s publication
|
||||
|
||||
Reference in New Issue
Block a user