diff --git a/CLIENT_EVENTS.md b/CLIENT_EVENTS.md index be19f5f03..2fef553af 100644 --- a/CLIENT_EVENTS.md +++ b/CLIENT_EVENTS.md @@ -133,6 +133,7 @@ createComment.error - unfeatureComment - updateNotificationSettings - updateStorySettings +- updateUserMediaSettings - viewConversation - viewFullDiscussion - viewNewComments @@ -587,6 +588,17 @@ createComment.error }; } ``` +- **updateUserMediaSettings.success**, **updateUserMediaSettings.error**: + ```ts + { + unfurlEmbeds?: boolean | null | undefined; + success: {}; + error: { + message: string; + code?: string | undefined; + }; + } + ``` - **viewConversation**: This event is emitted when the viewer changes to the single conversation view. ```ts { diff --git a/src/core/client/stream/classes.ts b/src/core/client/stream/classes.ts index 72b0a2cd5..00c94244e 100644 --- a/src/core/client/stream/classes.ts +++ b/src/core/client/stream/classes.ts @@ -968,6 +968,10 @@ const CLASSES = { updateButton: "coral coral-emailNotifications-updateButton", }, + mediaPreferences: { + updateButton: "coral coral-mediaPreferences-updateButton", + }, + /** * spinner is the loading indicator. */ diff --git a/src/core/client/stream/events.ts b/src/core/client/stream/events.ts index 8e5a04d0c..07bba3847 100644 --- a/src/core/client/stream/events.ts +++ b/src/core/client/stream/events.ts @@ -217,6 +217,15 @@ export const UpdateNotificationSettingsEvent = createViewerNetworkEvent<{ }; }>("updateNotificationSettings"); +export const UpdateUserMediaSettingsEvent = createViewerNetworkEvent<{ + unfurlEmbeds?: boolean | null; + success: {}; + error: { + message: string; + code?: string; + }; +}>("updateUserMediaSettings"); + /** * This event is emitted when the viewer updates the story settings. */ diff --git a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx index 0ffb977b1..de9ec9eac 100644 --- a/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/CommentContainer.tsx @@ -409,7 +409,11 @@ export const CommentContainer: FunctionComponent = ({ } media={ - + } footer={ <> @@ -533,6 +537,9 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))( badges role scheduledDeletionDate + mediaSettings { + unfurlEmbeds + } ...UsernameWithPopoverContainer_viewer ...ReactionButtonContainer_viewer ...ReportFlowContainer_viewer diff --git a/src/core/client/stream/tabs/Comments/Comment/MediaSection/MediaSectionContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/MediaSection/MediaSectionContainer.tsx index 42d9b6b66..8b585e44e 100644 --- a/src/core/client/stream/tabs/Comments/Comment/MediaSection/MediaSectionContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/MediaSection/MediaSectionContainer.tsx @@ -18,14 +18,16 @@ import styles from "./MediaSectionContainer.css"; interface Props { comment: MediaSectionContainer_comment; settings: MediaSectionContainer_settings; + defaultExpanded: boolean | null | undefined; } const MediaSectionContainer: FunctionComponent = ({ comment, settings, + defaultExpanded, }) => { const { revision } = comment; - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(defaultExpanded ? true : false); const onToggleExpand = useCallback(() => { setExpanded((v) => !v); }, []); diff --git a/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentContainer.tsx index 602e36f72..21fb65db1 100644 --- a/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/FeaturedComments/FeaturedCommentContainer.tsx @@ -68,7 +68,11 @@ const FeaturedCommentContainer: FunctionComponent = (props) => { > {comment.body || ""} - + = (props) => { } /> diff --git a/src/core/client/stream/tabs/Profile/Preferences/MediaSettingsContainer.css b/src/core/client/stream/tabs/Profile/Preferences/MediaSettingsContainer.css new file mode 100644 index 000000000..afff9a857 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Preferences/MediaSettingsContainer.css @@ -0,0 +1,42 @@ +.form { + padding-bottom: var(--spacing-1); +} + +.title { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-4); + line-height: 1.11; + + color: var(--palette-text-900); + + padding-bottom: var(--spacing-4); +} + +.options { + padding-bottom: var(--spacing-3); +} + +.checkBoxDescription { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-regular); + font-size: var(--font-size-2); + + color: var(--palette-grey-500); + + padding-left: calc(var(--spacing-3) + 14px); +} + +.updateButton { + padding-top: var(--spacing-2); + padding-bottom: var(--spacing-1); +} + +.updateButtonNotification { + padding-top: var(--spacing-2); + padding-bottom: var(--spacing-3); +} + +.callOut { + padding-bottom: var(--spacing-2); +} diff --git a/src/core/client/stream/tabs/Profile/Preferences/MediaSettingsContainer.tsx b/src/core/client/stream/tabs/Profile/Preferences/MediaSettingsContainer.tsx new file mode 100644 index 000000000..cb6370042 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Preferences/MediaSettingsContainer.tsx @@ -0,0 +1,156 @@ +import { Localized } from "@fluent/react/compat"; +import cn from "classnames"; +import { FORM_ERROR } from "final-form"; +import React, { FunctionComponent, useCallback, useState } from "react"; +import { Field, Form } from "react-final-form"; +import { graphql } from "react-relay"; + +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; +import CLASSES from "coral-stream/classes"; +import { + CheckBox, + FieldSet, + FormField, + HorizontalGutter, + Icon, +} from "coral-ui/components/v2"; +import { Button, CallOut } from "coral-ui/components/v3"; + +import { MediaSettingsContainer_viewer } from "coral-stream/__generated__/MediaSettingsContainer_viewer.graphql"; + +import UpdateUserMediaSettingsMutation from "./UpdateUserMediaSettingsMutation"; + +import styles from "./MediaSettingsContainer.css"; + +interface Props { + viewer: MediaSettingsContainer_viewer; +} + +const MediaSettingsContainer: FunctionComponent = ({ viewer }) => { + const updateMediaSettings = useMutation(UpdateUserMediaSettingsMutation); + const [showSuccess, setShowSuccess] = useState(false); + const [showError, setShowError] = useState(false); + const closeSuccess = useCallback(() => { + setShowSuccess(false); + }, [setShowSuccess]); + const closeError = useCallback(() => { + setShowError(false); + }, [setShowError]); + const onSubmit = useCallback( + async (values) => { + try { + await updateMediaSettings(values); + setShowSuccess(true); + } catch (err) { + if (err instanceof InvalidRequestError) { + return err.invalidArgs; + } + + setShowError(true); + return { + [FORM_ERROR]: err.message, + }; + } + + return; + }, + [updateMediaSettings, setShowSuccess, setShowError] + ); + + return ( + +
+ {({ + handleSubmit, + submitting, + submitError, + pristine, + submitSucceeded, + }) => ( + + +
Media Preferences
+
+
+
+ + + {({ input }) => ( + + +
Always show GIFs, Tweets, YouTube, etc.
+
+
+ )} +
+
+
+ +
+ This may make the comments slower to load +
+
+
+
+ + + +
+ {((submitError && showError) || + (submitSucceeded && showSuccess)) && ( +
+ {submitError && showError && ( + warning} + titleWeight="semiBold" + title={{submitError}} + /> + )} + {submitSucceeded && showSuccess && ( + check_circle} + titleWeight="semiBold" + title={ + + Your media preferences have been updated + + } + /> + )} +
+ )} +
+ )} + +
+ ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment MediaSettingsContainer_viewer on User { + id + mediaSettings { + unfurlEmbeds + } + } + `, +})(MediaSettingsContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Profile/Preferences/PreferencesContainer.tsx b/src/core/client/stream/tabs/Profile/Preferences/PreferencesContainer.tsx index eb1a7fd4c..0d8a6d2fb 100644 --- a/src/core/client/stream/tabs/Profile/Preferences/PreferencesContainer.tsx +++ b/src/core/client/stream/tabs/Profile/Preferences/PreferencesContainer.tsx @@ -2,11 +2,12 @@ import React, { FunctionComponent } from "react"; import { graphql } from "react-relay"; import { withFragmentContainer } from "coral-framework/lib/relay"; -import { HorizontalGutter } from "coral-ui/components/v2"; +import { HorizontalGutter, HorizontalRule } from "coral-ui/components/v2"; import { PreferencesContainer_viewer } from "coral-stream/__generated__/PreferencesContainer_viewer.graphql"; import IgnoreUserSettingsContainer from "./IgnoreUserSettingsContainer"; +import MediaSettingsContainer from "./MediaSettingsContainer"; import NotificationSettingsContainer from "./NotificationSettingsContainer"; interface Props { @@ -17,6 +18,8 @@ const PreferencesContainer: FunctionComponent = (props) => { return ( + + ); @@ -27,6 +30,7 @@ const enhanced = withFragmentContainer({ fragment PreferencesContainer_viewer on User { ...NotificationSettingsContainer_viewer ...IgnoreUserSettingsContainer_viewer + ...MediaSettingsContainer_viewer } `, })(PreferencesContainer); diff --git a/src/core/client/stream/tabs/Profile/Preferences/UpdateUserMediaSettingsMutation.ts b/src/core/client/stream/tabs/Profile/Preferences/UpdateUserMediaSettingsMutation.ts new file mode 100644 index 000000000..bc0fa74ca --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Preferences/UpdateUserMediaSettingsMutation.ts @@ -0,0 +1,68 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { CoralContext } from "coral-framework/lib/bootstrap"; +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; +import { UpdateUserMediaSettingsEvent } from "coral-stream/events"; + +import { UpdateUserMediaSettingsMutation as MutationTypes } from "coral-stream/__generated__/UpdateUserMediaSettingsMutation.graphql"; + +let clientMutationId = 0; + +const UpdateUserMediaSettingsMutation = createMutation( + "updateUserMediaSettings", + async ( + environment: Environment, + input: MutationInput, + { eventEmitter }: CoralContext + ) => { + const updateMediaSettings = UpdateUserMediaSettingsEvent.begin( + eventEmitter, + { + unfurlEmbeds: input.unfurlEmbeds, + } + ); + try { + const result = await commitMutationPromiseNormalized( + environment, + { + mutation: graphql` + mutation UpdateUserMediaSettingsMutation( + $input: UpdateUserMediaSettingsInput! + ) { + updateUserMediaSettings(input: $input) { + user { + id + mediaSettings { + unfurlEmbeds + } + } + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + } + ); + updateMediaSettings.success(); + return result; + } catch (error) { + updateMediaSettings.error({ + message: error.message, + code: error.code, + }); + throw error; + } + } +); + +export default UpdateUserMediaSettingsMutation; diff --git a/src/core/server/graph/mutators/Users.ts b/src/core/server/graph/mutators/Users.ts index b71f59932..f35a077f7 100644 --- a/src/core/server/graph/mutators/Users.ts +++ b/src/core/server/graph/mutators/Users.ts @@ -25,6 +25,7 @@ import { updateAvatar, updateEmail, updateEmailByID, + updateMediaSettings, updateModerationScopes, updateNotificationSettings, updatePassword, @@ -62,6 +63,7 @@ import { GQLUpdatePasswordInput, GQLUpdateUserAvatarInput, GQLUpdateUserEmailInput, + GQLUpdateUserMediaSettingsInput, GQLUpdateUserModerationScopesInput, GQLUpdateUsernameInput, GQLUpdateUserRoleInput, @@ -206,6 +208,9 @@ export const Users = (ctx: GraphContext) => ({ updateNotificationSettings: async ( input: WithoutMutationID ) => updateNotificationSettings(ctx.mongo, ctx.tenant, ctx.user!, input), + updateUserMediaSettings: async ( + input: WithoutMutationID + ) => updateMediaSettings(ctx.mongo, ctx.tenant, ctx.user!, input), updateUserAvatar: async (input: GQLUpdateUserAvatarInput) => updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar), updateUserRole: async (input: GQLUpdateUserRoleInput) => diff --git a/src/core/server/graph/resolvers/Mutation.ts b/src/core/server/graph/resolvers/Mutation.ts index 012b48135..dd118cad5 100644 --- a/src/core/server/graph/resolvers/Mutation.ts +++ b/src/core/server/graph/resolvers/Mutation.ts @@ -31,6 +31,14 @@ export const Mutation: Required> = { user: await ctx.mutators.Users.updateNotificationSettings(input), clientMutationId, }), + updateUserMediaSettings: async ( + source, + { input: { clientMutationId, ...input } }, + ctx + ) => ({ + user: await ctx.mutators.Users.updateUserMediaSettings(input), + clientMutationId, + }), updateSettings: async ( source, { input: { clientMutationId, ...input } }, diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index c76cf2959..94bd5f043 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -2184,6 +2184,17 @@ enum DIGEST_FREQUENCY { HOURLY } +""" +UserMediaSettings are the user's preferences around embed stream behaviour. +""" +type UserMediaSettings { + """ + unfurlEmbeds is whether the user has chosen to immediately show embed contents + without having to click "Show Tweet", "Show GIF", etc. + """ + unfurlEmbeds: Boolean +} + """ UserNotificationSettings stores the notification settings for a given User. """ @@ -2458,6 +2469,11 @@ type User { """ ssoURL: String @auth(userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION]) + + """ + mediaSettings are the user's preferences around media stream behaviour. + """ + mediaSettings: UserMediaSettings } """ @@ -3625,6 +3641,16 @@ type UpdateNotificationSettingsPayload { clientMutationId: String! } +input UpdateUserMediaSettingsInput { + unfurlEmbeds: Boolean + clientMutationId: String! +} + +type UpdateUserMediaSettingsPayload { + user: User! + clientMutationId: String! +} + ################## ## createComment ################## @@ -7335,6 +7361,14 @@ type Mutation { ): UpdateNotificationSettingsPayload! @auth(permit: [SUSPENDED, BANNED, PENDING_DELETION]) + """ + updateUserMediaSettings can be used to update the media preferences for the + current logged in user. + """ + updateUserMediaSettings( + input: UpdateUserMediaSettingsInput! + ): UpdateUserMediaSettingsPayload! @auth + """ updateUserEmail allows administrators to update a given User's email address to the one provided. diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 2113c5ac6..a2e2dea1d 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -37,6 +37,7 @@ import { GQLPremodStatus, GQLSuspensionStatus, GQLTimeRange, + GQLUpdateUserMediaSettingsInput, GQLUSER_ROLE, GQLUsernameStatus, GQLUserNotificationSettings, @@ -2319,6 +2320,45 @@ export async function updateUserNotificationSettings( return result.value; } +export type UpdateUserMediaSettingsInput = Partial< + GQLUpdateUserMediaSettingsInput +>; + +export async function updateUserMediaSettings( + mongo: Db, + tenantID: string, + id: string, + settings: UpdateUserMediaSettingsInput +) { + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + }, + { + $set: dotize({ + mediaSettings: settings, + }), + }, + { + // False to return the updated document instead of the original + // document. + returnOriginal: false, + } + ); + if (!result.value) { + // Get the user so we can figure out why the update operation failed. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} + /** * insertUserNotificationDigests will push the notification contexts onto the * User so that notifications can now be queued. diff --git a/src/core/server/services/users/users.ts b/src/core/server/services/users/users.ts index 0afcd7666..26af2d3d3 100644 --- a/src/core/server/services/users/users.ts +++ b/src/core/server/services/users/users.ts @@ -68,6 +68,8 @@ import { suspendUser, updateUserAvatar, updateUserEmail, + updateUserMediaSettings, + UpdateUserMediaSettingsInput, updateUserModerationScopes, updateUserNotificationSettings, updateUserPassword, @@ -1275,6 +1277,15 @@ export async function updateNotificationSettings( return updateUserNotificationSettings(mongo, tenant.id, user.id, settings); } +export async function updateMediaSettings( + mongo: Db, + tenant: Tenant, + user: User, + settings: UpdateUserMediaSettingsInput +) { + return updateUserMediaSettings(mongo, tenant.id, user.id, settings); +} + function userLastCommentIDKey( tenant: Pick, user: Pick diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 3abfd8be6..ef740e4a9 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -338,6 +338,15 @@ profile-commentHistory-loadMore = Load More profile-commentHistory-empty = You have not written any comments profile-commentHistory-empty-subheading = A history of your comments will appear here +### Preferences + +profile-preferences-mediaPreferences = Media Preferences +profile-preferences-mediaPreferences-alwaysShow = Always show GIFs, Tweets, YouTube, etc. +profile-preferences-mediaPreferences-thisMayMake = This may make the comments slower to load +profile-preferences-mediaPreferences-update = Update +profile-preferences-mediaPreferences-preferencesUpdated = + Your media preferences have been updated + ### Account profile-account-ignoredCommenters = Ignored Commenters profile-account-ignoredCommenters-description =