[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:
Tessa Thornton
2020-02-03 13:12:25 -05:00
committed by GitHub
parent a7b2af85fc
commit a1a8652f7e
29 changed files with 855 additions and 1 deletions
@@ -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 {
}
@@ -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 organizations 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 organizations 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"
>
+6
View File
@@ -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";
+5
View File
@@ -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,
}),
};
+6 -1
View File
@@ -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])
}
##################
+1
View File
@@ -146,6 +146,7 @@ export type Settings = GlobalModerationSettings &
| "stories"
| "createdAt"
| "slack"
| "announcement"
> & {
/**
* auth is the set of configured authentication integrations.
+67
View File
@@ -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;
}
+33
View File
@@ -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;
}
+12
View File
@@ -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 organizations 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 storys publication