Compare commits

..
83 Commits
Author SHA1 Message Date
Kim Gardner 721b2e7d67 [CORL-527] English copy tweaks (#2546)
* English copy tweaks

* fix: unit tests

* Use reactions to be more general

* fix: snapshots
2019-09-10 20:55:34 +07:00
Nick Funk f9e1bf144e [CORL-523] Document GraphQL mutations for managing accounts (#2544)
* Create preliminary endpoint for updating user names via API

CORL-523

* Create preliminary endpoint for updating user emails via API

CORL-523

* Create preliminary endpoint for deleting users via the API

CORL-523

* Hook up the delete, update email, and update user name endpoints

CORL-523

* Update readme to include preliminary GDPR endpoint instructions

CORL-523

* Uncomment limiters on updateEmail endpoint

My mistake leaving these commented while testing, should not have been committed.

CORL-523

* Run DocToc on the README to generate table of contents

CORL-523

* feat: enhanced documentation on account management edges

* fix: prevent double delete of user account

* fix: swapped order of params

* Update README.md

* Update README.md

* fix: remove unknown field title

* fix: remove unknown field title on story
2019-09-10 20:16:40 +07:00
Tessa Thornton e86e9d2c68 [CORL-586] update logos to match new branding (#2540)
* update logos to match new branding

* update styles of logo

* move version number to bottom of admin

* update specs
2019-09-10 09:02:35 -04:00
Kim Gardner d031df38f6 Add new da translations (#2543) 2019-09-10 10:27:53 +00:00
Nick Funk 8d368b03ec Add call out placeholder for comment stream empty state (#2545)
CORL-567
2019-09-10 10:18:36 +00:00
Tessa Thornton 3e64aa1193 [CORL-591] add reactions to my comments history (#2541)
* add reactions to my comments history

* remove unused field

* fix lanaguage for reactions
2019-09-10 09:52:05 +00:00
Tessa Thornton f84c3e4a60 [CORL-595] move notification settings update button (#2538)
* move notification settings update button

* move settings save button

* fix: version bump in package lock file
2019-09-10 08:13:14 +00:00
Tessa Thornton 36225e443b [CORL-556] Allow session length configuration (#2530)
* make session duration configurable by tenant

* fix expiration date calculation

* add form field for session length

* use constant for default session

* update spec

* update fixtures

* add missing translation

* update snap

* fix: use shared variable instead of hardcoded value for tests

* fix: adjusted missing expires in parameter
2019-09-10 08:06:07 +00:00
Tessa Thornton ed8be75774 fix styling on radio inputs for long labels (#2539) 2019-09-09 12:06:25 -04:00
Tessa Thornton f8cf4e94d3 change button style for change username box (#2537)
* change button style for change username box

* fix lints
2019-09-09 11:53:28 -04:00
Tessa Thornton 1a69f48087 show cancel button for ignore commenters config (#2536) 2019-09-09 11:39:55 -04:00
Nick Funk e0ab799c84 Add hover state to navigation links in Admin > Config (#2533) 2019-09-09 09:03:30 -06:00
Nick Funk aa8e1e2d7a [CORL-560] Update Admin > Config area to match new designs (#2514)
* Add solid background to Moderate > Config area headers

CORL-560

* Update Config > General section text and font weight

CORL-560

* Update ordering of Config > Moderation page items

CORL-560

* Update text and font weights in Configure > Moderation page

CORL-560

* Update text and font weight on Configure > Organization page

CORL-560

* Update text and font weight on Configure > Banned and Suspect Words page

CORL-560

* Update text and font weight on Configure > Authentication page

CORL-560

* Update text and font weights on Configure > Email page

CORL-560

* Update text and font weights on Configure > Advanced page

CORL-560

* Repair tests around the styling changes made to the moderation config area

CORL-560

* Fix capitalization on the Config > General page

CORL-560

* Make email checkbox light themed so its text shows up on new headers

CORL-560

* Update Header width to account for padding

Prevents it from sticking out over the edge of the page layout

CORL-560

* Align "Sort by" text to the drop down in Admin > Config > General > Reactions

CORL-560

* Update Admin > Config > General snapshot to adjust for vertical aligned text

Vertical flex aligned the "Sort by" text to a combo box in a previous changed

CORL-560

* Rename "Perspective Toxic Comment Filter" header to "Toxic Comment Filter"

CORL-560

* Use overlay scrolling if available to prevent scroll bar width popping

CORL-560

* Remove overflow-y: overlay

CORL-560

* Rename Akismet Spam Detection to Spam Detection

CORL-560

* Make all non-complex text fields full width in Admin > Config

CORL-560

* Remove Authentication Integrations header

Area underneath was empty

CORL-560

* Re-arrange SSO Key Field layout

CORL-560

* Create new typography types for bodyShort and fieldDescription

CORL-560

* Update Admin > Config to use bodyShort, fieldDescription typography

CORL-560

* Add a slight padding on InputLabel

CORL-560

* Add padding on either side of Admin > Config section content

CORL-560
2019-09-06 12:29:27 -06:00
Tessa Thornton d2af78d3d7 [CORL 568] more user drawer links (#2532)
* add drawer links for in reply to and moderated by

* fix ts

* update spec
2019-09-06 14:01:19 -04:00
Tessa Thornton 9bd38db61a allow organization url config (#2531) 2019-09-06 13:52:48 -04:00
Tessa Thornton ac203607ae [CORL-561, CORL-562] Comment stream and account profile styling cleanup (#2517)
* update styles for comment stream

* more comment styling fixes

* reconfigure settings tab and account settings in stream

* add new button variant

* style settings tab

* style change username/email/password

* clean up settings tab

* integrate account deletion to account settings

* update comment history styles

* more history comment styles

* update snaps

* move userbox

* fix tests

* fix delete account tests

* update message box styling

* update snap

* update snaps

* fix lints

* update snaps again

* userbox styles

* history comment styling

* update snaps

* fix ts

* Integrate notifications settings

* clean up account setting styles

* update snaps and tests

* fix lints

* fix: PENDING_DELETION permissions

* chore: sync stable classnames, rename settings to account

* simplify css

* update snaps
2019-09-06 13:41:55 -04:00
Nick Funk e49905c5ad [CORL-571] Filter empty strings from banned and suspect word lists (#2521)
* Filter empty strings from banned and suspect word lists

CORL-571

* fix: changed form
2019-09-05 21:36:09 +00:00
Kim Gardner 15d41f1ae8 Typo fixes and bump Coral version to 5.0.0 (#2528)
* Typo fixes and bump package version

* fix: tests
2019-09-05 17:15:40 -04:00
Wyatt Johnson 60a1f43d51 fix: wrap queries in quotes (#2527) 2019-09-05 18:06:12 +00:00
Wyatt Johnson 3c9ebcf1a1 feat: added moderation email notifications (#2525) 2019-09-05 17:33:49 +00:00
Wyatt Johnson e35978096f feat: added featured comment notifications (#2524) 2019-09-05 17:10:03 +00:00
Wyatt Johnson 04c56b3fb5 [CORL-406] Tenant Locale Selection (#2450)
* feat: added preload config

* feat: support changing locale

* fix: name case

* fix: removed unused code

* feat: added translations for default reactions

* fix: do not translate icon

* fix: shorter i18n keys
2019-09-05 17:02:06 +00:00
Tessa Thornton 5bf4f22931 [CORL-392] add settings config for story configuration on advanced panel (#2499)
* add settings config for story configuration on advanced panel

* include external link in proxy description

* update copy

* update copy

* fix lints

* update snap
2019-09-05 11:35:16 -04:00
Tessa Thornton 1550377f39 update durationfield component to not use lifecycle methods (#2502)
* update durationfield component to not use lifecycle methods

* fix: default param cleanup
2019-09-05 09:51:51 -04:00
Wyatt Johnson efea0e8e1c [CORL-498, CORL-495, CORL-539, CORL-496, CORL-494] Email Notifications Support & Framework (#2498)
* chore: renamed old templates

* feat: initial notifications support

* feat: email enhancements

* fix: linting

* feat: initial digesting beheviour

* feat: added notification configuration

* feat: added unsubscribe routes

* fix: fixed failing snapshots/tests bc random ids

* feat: adjusted the save beheviour, added tests

* feat: added tests

* feat: added staff replies

* feat: renamed E-Mail to Email

* feat: enhanced cron processing

* fix: linting + updating tests

* feat: enhanced cron context

* fix: added staff replies back in
2019-09-05 07:02:26 +00:00
Wyatt Johnson 0fad1070a6 fix: addressed issue with count doubling (#2518) 2019-09-04 17:33:42 -04:00
Wyatt Johnson ac1ed45e4b [CORL-588] Download and token fixes (#2519)
* fix: fixed up download handler to improve error handling

* fix: fixed invalid expiry date being set

* fix: nick found bug
2019-09-04 17:23:59 -04:00
Nick Funk a696ef9be5 [CORL-572] Filter rejected comments from reply counts on user comment history (#2516)
* Filter rejected comments from reply counts on user comment history

CORL-572

* fix: use cache to improve performance

* fix: added TODO
2019-09-04 12:43:00 -04:00
Vinh aa32fcaea6 [CORL-550] More stable css classes (#2515)
* feat: more stable css classes

* chore: renamee ValidationMessageHelper to FormValidationMessage

* chore: Rename FormValidationMessage to FieldValidationMessage

* feat: more css classes, icons use <i> tag

* feat: stable classnames for gdpr features

* feat: more css stable classnames
2019-09-04 12:34:42 -04:00
Tessa Thornton fcf3640adc [CORL-439] Allow admins to configure GDPR features (#2497)
* allow tenant admins to modify commenter account features

* style account features configuration

* prevent changing username or downloading comments based on tenant settings

* update fixtures and snaps

* add i18n strings

* update snap
2019-09-03 16:56:36 -06:00
Nick Funk 46f0d08cf7 [CORL-236] Allow user to delete their account (#2495)
* Create preliminary account deletion program flow

Does not validate password as of yet.
Does not submit a mutation to begin deletion scheduling.

CORL-236

* Create preliminary account deletion confirmation form and schema mutation

Hooks up validation and error handling to confirm the user's
password during account deletion.

Scheduling the account deletion is not implemented yet.

CORL-236

* Update requestAccountDeletion to set the user's scheduled deletion date

CORL-236

* Create preliminary cron job for scheduled account deletions

CORL-236

* Show tombstoned comments when the user has deleted their account and comments

CORL-236

* Pull in client locales for date formatting on account deletion steps

CORL-236

* Update more areas due to comment revision being nullable

CORL-236

* Allow users to cancel their account deletion request

CORL-236

* Show account deletion callout at the top of the profile stream tab

CORL-236

* Further comments and edge case handling for deleted comments site wide

CORL-236

* Show account deletion callout in the comments stream tab

CORL-236

* Send out emails during relevant account deletion events

- Confirm deletion requested
- Cancellation of deletion request
- Account deletion completed

CORL-236

* Unset emails when deleting user accounts instead of setting to null

Fixes email index collision error from setting multiple emails to null.

CORL-236

* Show organization email in account deletion steps

CORL-236

* Mark comments as deleted so that we can properly tombstone them in the stream

CORL-236

* Fix improper templating for account deletion completed emails

Sub organization name in where previously we were using the account username.

CORL-236

* Disable user drawer and display deleted flag on users in community

CORL-236

* Check for author before passing inReplyTo in ModerateCardContainer

Sometimes null when user is deleted.

CORL-236

* Tombstone moderate cards when the underlying comment is deleted

CORL-236

* Re-add deleted field to CommentContainer fragment

CORL-236

* Disable commenting or replying when user is scheduled for deletion

CORL-236

* Add snapshot tests around deletion states for comment cards and forms

CORL-236

* Add tests around the account deletion steps

CORL-236

* Set account deletion to run every 30 minutes

CORL-236

* Rename DELETION authorization to PENDING_DELETION

CORL-236

* Change log message for when account deletion tasks finds no more users to delete

CORL-236

* Update snapshots to handle proper deleted flag for scheduled deleting users

CORL-236

* Fix import ordering on various files to match coding standards

CORL-236

* Specify tenantID when querying for user during deletion

CORL-236

* Add back missing clientMutationId on cancelAccountDeletion mutation

CORL-236

* Use $unset to clear the scheduledDeletionDate

CORL-236

* Simplify create of scheduledAccountDeletion CRON function

CORL-236

* Rename and organize scheduledTask creation logic

- Rename startCronJobs to startScheduledTasks
- Return an object of named ScheduledTask items instead of an array of tasks

CORL-236

* Add comment about returning updated user when clearing scheduledDeletionDate

CORL-236

* Remove unnecessary array around tenant.locale in dateTime formatter

CORL-236

* Remove unnecessary non-null enforcement on tenant.id

CORL-236

* Remove string templated React element content

CORL-236

* Move english translated validation error to correct language file

CORL-236

* Simplify rendering of DeletionRequestCallout when deletionDate isn't available

CORL-236

* Use ternary expression instead of multiple {bool && (...)} checks

CORL-236

* Use classes object with line child style to style the step bar

CORL-236

* Update StepBar so that children of Step nodes can be undefined

Allows us to not have to insert empty placeholder strings/elements when
we want to have an empty step node.

CORL-236

* Remove use of luxon from account deletion request callouts for now

We need to decide if the extra 15KB from luxon is worth it in our bundles
before making this change.

CORL-236

* Remove unnecessary embed/deleteAccount endpoint

CORL-236

* Move DeleteAccountModal contents into a separate component

Cleans up the state management when user closes the modal and we
need the modal steps to restart.

CORL-236

* Remove localization around delete account button icon

CORL-236

* Use inline `collection(mongo)` in clearing and setting deletion date

CORL-236

* Simplify form state validation on account deletion confirm page

CORL-236

* Add todo to iterate over tenants in account deletion

CORL-236

* Remove punctuation and add context to logger messages during account deletion

CORL-236

* Break out mongo collection initialization into a shared object

Also adds a try catch error handling around the scheduled deletion
operations. Prevents errors from taking the server down.

CORL-236

* Add TODO to extract out a common formatter for date times

CORL-236

* Update translations for account deletion details

CORL-236

* Update translations for account deletion completion page

CORL-236

* During scheduled deletion, when locking deletion date, return modified user

CORL-236

* Remove un-necessary fragment container around deletion steps component

CORL-236

* Move preventSubmit outside of render function on ConfirmPage

CORL-236

* Convert collections into object of Mongo db collection initializers

Allows us to only initialize the collections we need with our
Mongo instance.

CORL-236

* Remove un-necessary lambda wrapping around collections initializer functions

CORL-236
2019-09-03 16:38:43 -06:00
Nick Funk fc039f8186 Filter null comments coming into moderation queues to prevent white screening (#2509)
Wyatt and I are not sure why comments are coming back null, but this should
keep the moderation area from blanking out when it errors handling nulled out
comments crashing the queue.

FIXME tag added with Wyatt/Nick as this is only a patching fix and does
not resolve the underlying issue.
2019-08-28 14:21:35 -04:00
Nick Funk 7e7ce9fbd0 Ensure user drawer status is padded properly when a user selects themselves (#2506)
CORL-531
2019-08-26 11:51:54 -04:00
Tessa Thornton 0e37a474fa downloads by user id (#2500) 2019-08-23 16:05:53 -04:00
Tessa Thornton ffecd5e981 change routing for auth and auth/callback (#2493) 2019-08-23 15:00:36 -04:00
Tessa Thornton 07df7ac182 show email verification resend (#2492)
* always show users with unverified email address the prompt and resend option

* udpate snaps

* update snap
2019-08-23 14:16:08 -04:00
Wyatt Johnson 2edabf44c7 feat: added closedAt to createStory mutation (#2496) 2019-08-23 14:01:43 -04:00
Wyatt Johnson 1c75b7659d fix: added more strict handling of input for URL config (#2478) 2019-08-23 13:51:13 -04:00
Nick Funk 6d803b0179 [CORL-382] Allow moderators to ban a commenter from the comment stream (#2385)
* Add a ban user action to the stream moderation drop down

CORL-382

* Show banned stated for user in stream moderation drop down

If the user is actively banned, the ban user option will be
disabled and show a status of "banned".

CORL-382

* Create utility for generating random stories, comments, and users

CORL-382

* Add ban and suspension values to baseUser in fixtures

CORL-382

* Updated banned.spec.tsx to use new test utilities for generating fixture data

CORL-382

* Prevent users from being able to ban themselves in the moderation dropdown

CORL-382

* Kill optimistic response errors for comment mutations

Set the author.status.current to an empty array so it stops
complaining about it being unused.

CORL-382

* Rename util in tests to helpers/fixture.ts

CORL-382

* Remove unused import from CreateCommentReplyMutation.ts

CORL-382

* Put back the optimistic ban responses into comment mutations

The warnings spewing out during tests are false, for further
detail please see: https://github.com/facebook/relay/pull/2760

CORL-382

* Denormalize generated stories and comments

CORL-382

* Clean up import ordering in ModerationActionsContainer.tsx

CORL-382

* Inject appropriate scoped items into callbacks for moderation dropdown

CORL-382

* Set optimistic response author status from known viewer status

CORL-382

* fix: Make in stream banning work

* feat: Send translated email + reject comment + fix tests

* test: add feature test

* feat: add copy mentioning comment rejection

* chore: improve loading state

* chore: add tiny comment
2019-08-22 22:47:30 +07:00
Vinh f3e615a610 [CORL-503] More stable CSS classNames (#2477)
* feat: add more css classnames

* fix: snapshots
2019-08-20 19:22:58 +00:00
Vinh b9f9e9b2d4 [CORL-491] Support event alias loginPrompt (#2474)
* feat: support event alias loginPrompt

* lint: import ordering
2019-08-21 00:58:44 +07:00
Tessa ThorntonandWyatt Johnson 7809cd3d68 [CORL-476] Add badges to user from SSO token (#2470)
* fix: bug in lookup not checking object properly before accessing

* fix: Recursive types not handling optional arrays

* add user badges component

* create user badges from sso token

* update badges type

* revert src/core/client/embed/index.html

* remove duplicated line

* add user badges component

* create user badges from sso token

* revert src/core/client/embed/index.html

* remove duplicated line

* fix types

* fix tests and snaps

* add user badges to user drawer

* update snaps

* update readme

* [CORL-476] add user role from SSO token (#2475)

* add role from token

* use joi to validate role values

Co-Authored-By: Wyatt Johnson <wyattjoh@gmail.com>

* simplify sso.role validation

* add test for invalid role in sso token
2019-08-20 13:15:59 -04:00
Tessa Thornton add5224338 [CORL-504] Configure reaction language (#2490)
* add reaction config to settings

* fix reaction preview styles

* update snaps and fixtures

* add i18n strings

* update spec

* ensure dynamic value shows in reaction config preview
2019-08-20 13:03:25 -04:00
Kim Gardner 8dae0feb52 Updates version number (#2488)
* Updates version number

* fix: updated version in package-lock.json
2019-08-20 12:47:30 -04:00
Tessa Thornton ee93267d97 [CORL-220] Change email address (#2461)
* change email form

* add update email mutation

* validate new email addresses

* add email verification callout

* add translation strings

* fix submit button logic

* rename model methods

* style email verifcation box

* resend email verificatoin button

* show success message on email resend

* update user profile email

* fix duplicate import

* add change email spec

* fix profile spacing

* update snapshots

* update snaps

* add preventSubmit function to email change component

* update business logic for profile updating

* update logic for when users can update email or username

* check for less specific duplicate email error

* prevent sso-only users from editing email

* only allow email and username edit if enabeld in local profile

* use generic server error for cannot update profile

* remove merge conflict

* fix tests

* extract logic to get auth integrations

* fix merge error
2019-08-20 12:28:14 -04:00
Tessa Thornton 0f02b05689 update readme with info about watchman (#2486)
* update readme with info about watchman

* run doctoc

* fix: reordered sections

* run doctoc
2019-08-20 12:14:49 -04:00
Tessa Thornton 3baf4e96f5 prevent improper sort menu styling on ios (#2468) 2019-08-20 11:37:09 -04:00
Wyatt Johnson 8d91bd7dd1 fix: corrected invalid copy (#2479) 2019-08-19 12:57:03 -04:00
Vinh d400c1518c fix: temporary use min-height for single comment view to not cutoff report dialog (#2476) 2019-08-19 12:34:39 -04:00
Wyatt Johnson d9ad1b2ee4 [CORL-529] Proxy (#2473)
* fix: implemented trust configuration

* fix: removed debugging code

* fix: increase hash size (#2484)
2019-08-16 21:03:20 +00:00
Wyatt Johnson 035ee73edd [CORL-543] Persisted Query Fix (#2482)
* fix: addressed query loading bug

* fix: refine logging
2019-08-16 16:09:59 +00:00
Vinh 43b6a2cdcd [CORL-149] Persisted Queries (#2445)
* feat: enable persisted queries on the client

* fix: use `id` inside websocket message

* feat: initial server support for PQ

* feat: deeper server support

* feat: abstracted persisted query replacing logic
2019-08-15 21:03:32 +00:00
Vinh 635e740fc0 [next] Fix lookup and recursive types (#2472)
* fix: bug in lookup not checking object properly before accessing

* fix: Recursive types not handling optional arrays
2019-08-15 01:07:49 +07:00
Tessa Thornton c37ee12e57 Bugfix/userdrawer changestatus (#2469)
* fix date formatting breaking user details popvoer

* update mutation return fields for user status changes
2019-08-14 08:32:03 -04:00
Wyatt Johnson e76fddd13c [next] Added trust proxy configuration (#2471)
* feat: added trust proxy configuration

* chore: version bump
2019-08-13 15:41:50 -04:00
Tessa Thornton abb793b16a fix date formatting breaking user details popvoer (#2465) 2019-08-12 19:39:55 -04:00
Wyatt Johnson aa873559d0 fix: fixed forgot password button (#2467) 2019-08-12 19:32:50 -04:00
Wyatt Johnson 0296041ce8 fix: build sourcemaps in production (#2466) 2019-08-12 19:12:34 -04:00
Wyatt Johnson 1a62203d45 fix: adjusted build to also compile locales (#2464) 2019-08-12 17:47:30 -04:00
Nick Funk 0eddf9619f Center sign in popovers over the browser window so they are easily visible (#2457)
* Center sign in popovers in the browser window so they are always visible

* Set popover window feature by default using interfaced parameters

* Update snapshots to account for feature parameters on popup windows

* Lowercase the bar in menubar for Popup so it matches window api

* fix: externalized feature reconciliation
2019-08-12 17:03:09 -04:00
Kim Gardner fda9f40616 Bump version to beta 6 (#2462) 2019-08-12 16:52:31 -04:00
Wyatt Johnson 8d78677154 [CORL-219] Common Tweaks (#2460)
* fix: i18n fixes

* fix: figma tweaks

* fix: tweaked date manipulation logic

* fix: linting

* fix: linting

* fix: tests
2019-08-12 16:44:35 -04:00
Wyatt Johnson 7900b02c40 fix: moved url-regex to deps not devDeps (#2463) 2019-08-12 16:36:46 -04:00
Wyatt Johnson 956014b53f [next] i18n cleanup (#2456)
* fix: removed english translations from es files

* feat: improved github messaging
2019-08-12 14:55:29 -04:00
Wyatt Johnson 86bba73919 [CORL-513] Permitted Domain Fixes (#2455)
* fix: adjusted allowed domains configuration

* fix: removed unused logic around allowed domains prefixing

* fix: removed unused localizations

* fix: fixed signup flow
2019-08-12 18:02:15 +00:00
Tessa Thornton c245e9ba74 [CORL-219] Change username (#2452)
* add types for username updates

* add update username methods

* add types and resolvers for username history

* connect frontend to username history and change history mutation

* style update username box

* show username changes in user history drawer

* new users have 1 username entry

* add translations for username change

* allow new users to change username once

* add tests for change username ui

* send email to users after username change

* add types to tests

* treat status.username.history as mandatory

* remove hardcoded 14 day username update frequency

* use framework translation directives

* add account history action component

* clean up strings

* fix imports in change username container

* fix templates

* rename update username methods

* fix nunjucks formatting

* add warning if user missing email

* add fixme

* fix spacing
2019-08-12 10:45:49 -04:00
Nick Funk e3f24811fc [CORL-235] Allow a user to download their comment history (#2449)
* Create preliminary UI for requesting a user's comment history

CORL-235

* Create preliminary download handler

CORL-235

* Create preliminary CSV exporter for downloading user's comments

CORL-235

* Create preliminary download comments flow with email and landing page

User can download their comments after requesting via an email.
Does not include limiters preventing users from spamming the download flow.

CORL-235

* Style the download comments landing page to match the spec

CORL-235

* Update the profile download comments to respect 14 day download limit

CORL-235

* Hook up request limiter on the comment download end points.

CORL-235

* Create sorry dialogue for when the download comments link is invalid/expired

CORL-235

* Create loading spinner for comment download landing page

CORL-235

* Create an optimistic updater for requesting a comment download

CORL-235

* Add localization for download comments landing page's sorry text

CORL-235

* Adjust downloaded comment format and contents

- Format the date to match tenant local
- Format dates to be using numeric values with date and time
- Add comment URL to exported CSV record

CORL-235

* Rename `createCSV` to `createExportedContent` in the download handler

CORL-235

* Make comment download callout full width and left justified

CORL-235

* Update stream snapshots to account for the download comments changes

CORL-235

* Update snapshots to correspond to download comments changes

CORL-235

* Add date filter to the comment download

Will filter by date of the issued token to only include comments before
that time.

CORL-235

* Clean up the layout of the list items in the download comments landing page

CORL-235

* fix: localization fixes

* fix: formatting

* fix: download file enhancements

* fix: addressed download request loophole

* Update snapshots to address button changes on download comments landing page

CORL-235

* Simplify download comment landing page components

CORL-235

* Remove max-width calc's on account main layout and set them in child views

Sets the max-width settings on the download route since it's custom
set on all the othere routes (email and password).

CORL-235

* Bump npm version in package.json

CORL-235
2019-08-09 21:17:24 +00:00
Wyatt Johnson 11fc3697ed fix: addressed chrome browser issues (#2453) 2019-08-09 20:24:54 +00:00
Kim Gardner 22188e7c31 [CORL-478] Porting over Danish translations to v5 (#2448)
* WIP add missing Spanish translations

* WIP porting over Danish

* Update

* WIP

* Complete stream translations

* Admin translations WIP

* Admin WIP

* Fix lint issue

* Typo

* Fix

* Fix lint errors

* More Admin translations

* Fix es translation

* Add Account translations

* Add translations for Framework

* Admin WIP continued

* Finished Admin translations

* Remove unncessary translations for now

* Missed merge conflict

* Missing translations

* Translation cleanup
2019-08-08 15:22:55 -04:00
Wyatt Johnson 4c65d43954 [CORL-404] Recent Comment History (#2354)
* feat: initial support for auto pre-moderation

* chore: refactor collection access

* fix: linting

* fix: rebasing issue

* fix: exported helpers

* feat: added extensions, lintd

* fix: rebase fix

* feat: renamed automaticPreModeration to recentCommentHistory

* feat: initial implementation of admin config

* feat: support recent history markers

* feat: rename visible to published

* feat: reworked history drawer

* chore: extracted tooltip

* feat: implemented user drawer

* fix: fixed translation key

* fix: resolved issue with NaN
2019-08-08 18:18:18 +00:00
Tessa Thornton ff964b54a3 [CORL-97] Add user status details popover to user drawer (#2431)
* show tooltip for user status details

* localize strings

* fix merge conflict

* fix date formatting in user details popover

* update snapshot

* fix rebase
2019-08-07 16:22:47 -04:00
Vinh 3f307002e2 [CORL-391] Fix jankiness when opening reply box of last comment in Stream (#2447)
* feat: add useResizeObserver hook

* fix: stream jumps when clicking on the last reply button when scrolled to the bottom

* chore: use resize observer in auth popup and simplify resize handling

* fix: snapshots and remove resize observer from stream

* fix: focus unmounted rte
2019-08-07 15:26:30 -04:00
Vinh b41ddbc22f [next] Add new typography design tokens (#2451)
* feat: add new typography design tokens

* fix: revert renaming fontFamilySansSerif
2019-08-07 14:09:07 -04:00
Kim Gardner c979dfd7d5 [CORL-487] Update Spanish translations for v5 (#2444)
* WIP add missing Spanish translations

* Add more stream translations

* More stream translations

* Add rest of translations for stream

* fix: accessing undefined message
2019-08-07 02:58:59 +07:00
Bogdan Deren 1f07c23dea [next] Dutch locale for stream (#2439)
* stream dutch translation

* added nl-NL locale

* missed line

* Dutch translation for framework (date/time)
2019-08-05 16:39:06 -04:00
Tessa Thornton 3694794b01 [CORL-458] open user drawer when flagger username clicked (#2436)
* open user drawer when flagger useranme clicked

* fix comment author click

* update style of flagger button

* update snapshots

* chore: use round-corners design token
2019-08-05 18:02:13 +07:00
Tessa Thornton 5df2de6afc [CORL-156] Manage user suspension status (#2419)
* wire up suspension modal

* show user suspended callout on stream

* prevent comment actions for suspended users

* set default to 3 hour suspension

* add message field to suspension

* allow custom message for suspension

* show suspend success modal

* fix type errors

* remove unused code

* update styles

* fix fixture for streams

* add suspension ui tests

* fix types

* remove warnings?

* remove snapshot

* fix merge conflicts

* allow custom email message when banning users

* fix typo

* correct message type

* use final-form in suspend modal

* refactor suspend modal to use final-form

* refactor ban modal to use final-form

* refactor userStatusChangeContainer to use useCallback

* feat: improve translated form

* fix: addressed issue caused by i18n refactor

* update getMessage to accept arguments, remove format method

* translate suspend info

* change hour format

* make message a mandatory input for suspend and ban user

* fix types in user table

* Revert "fix types in user table"

This reverts commit d396e90b88bb1bd354c5cdbdd72b6d8f1ab72929.

* fix types for user table

* fix: small review tweaks
2019-08-02 21:16:21 +00:00
Wyatt Johnson 4e548e8fbf fix: addresses issue with creating stories (#2442) 2019-08-02 17:02:47 -04:00
Tessa Thornton fdc6afec42 prevent zooming on filter select (#2434) 2019-08-02 16:55:13 -04:00
Wyatt Johnson 836a2267bf [CORL-445] Change Password (#2426)
* fix: reversed `new-password` autocomplete option

* feat: initial implementation

* fix: localization and testing

* fix: updated snapshot
2019-08-01 22:08:14 +00:00
Wyatt Johnson 290ceee8e9 chore: form standardization (#2425) 2019-08-01 21:59:05 +00:00
Wyatt Johnson 1c5c82286e [CORL-474] Harmonize SSO User ID's (#2440)
* feat: harmonized user id's

* fix: doctoc
2019-08-01 17:30:33 -04:00
Tessa Thornton 3d9c94d36a [CORL-456] add empty state for comment history (#2432)
* add empty state for comment history

* localize strings

* fix string casing
2019-08-01 17:18:12 -04:00
862 changed files with 35634 additions and 12371 deletions
+30
View File
@@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help us improve
labels: "bug"
---
<!--
Please provide as much detail as possible, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. The more information you can give the better.
Please help us by doing the following steps before logging an issue:
* Search: https://github.com/coralproject/talk/search
* Read the docs: https://docs.coralproject.net
Please fill in the *entire* template below.
-->
**Expected behavior:**
**Actual behavior:**
**Related Issues:** <!-- Did you find other bugs that looked similar? -->
**Versions:**
- NodeJS:
- NPM:
- MongoDB:
- Redis:
- Browser:
- OS:
+27
View File
@@ -0,0 +1,27 @@
---
name: Feature request
about: Suggest an idea for this project
labels: "feature idea"
---
<!--
Please help us by doing the following steps before logging an issue:
* Search: https://github.com/coralproject/talk/search
-->
## Search Terms
<!-- List of keywords you searched for before creating this issue. Write them down here so that others can find this suggestion more easily -->
## Suggestion
<!-- A summary of what you'd like to see added or changed -->
## Use Cases
<!--
What do you want to use this for?
What shortcomings exist with current approaches?
-->
+10
View File
@@ -0,0 +1,10 @@
<!--
Thank you for submitting a pull request!
Please verify that:
* [ ] Code is up-to-date with the base branch
* [ ] You've successfully run `npm run test` locally
Refer to CONTRIBUTING.MD for more details.
https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md
-->
+1
View File
@@ -21,3 +21,4 @@ dist
*.css.d.ts
__generated__
README.md.orig
persisted-queries.json
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"ms-vscode.vscode-typescript-tslint-plugin",
"kumar-harsh.graphql-for-vscode",
"editorconfig.editorconfig",
"ms-azuretools.vscode-cosmosdb"
]
}
+5 -4
View File
@@ -1,5 +1,4 @@
{
"editor.formatOnSave": true,
"files.associations": {
"*.css": "postcss"
},
@@ -12,9 +11,11 @@
".vs": true
},
"tslint.exclude": "**/node_modules/**",
"tslint.autoFixOnSave": true,
"tslint.jsEnable": true,
"tslint.nodePath": "node_modules/.bin/tslint",
"typescript.tsdk": "node_modules/typescript/lib",
"postcss.validate": false
"postcss.validate": false,
"javascript.preferences.importModuleSpecifier": "non-relative",
"editor.codeActionsOnSave": {
"source.fixAll.tslint": true
}
}
+7
View File
@@ -0,0 +1,7 @@
# Vox Media Code of Conduct
## Introduction
This code of conduct governs the environment of the Vox Product team. We created it not because we anticipate bad behavior, but because we believe that articulating our values and obligations to one another reinforces the already exceptional level of respect among the team and because having a code provides us with clear avenues to correct our culture should it ever stray from that course. We make this code public in the hopes of contributing to the ongoing conversation about inclusion in the tech, design, and media communities and encourage other teams to fork it and make it their own. To our team, we commit to enforce and evolve this code as our team grows.
Read the rest here: http://code-of-conduct.voxmedia.com/
+66
View File
@@ -0,0 +1,66 @@
# Contributor's Guide
Welcome! We are very excited that you are interested in contributing to Coral.
This document is a companion to help you approach contributing. If it does not
do so, please [let us know how we can improve it](https://github.com/coralproject/talk/issues)!
By contributing to this project you agree to the
[Code of Conduct](CODE_OF_CONDUCT.md).
## What should I Contribute?
There are at least three ways to contribute to Coral:
- Writing Code
- Providing Translations
## Writing Code
Conversation surrounding contributions begins in
[issues](https://github.com/coralproject/talk/issues).
### When should I create an issue?
File an issue as soon as you have an idea of something you'd like to contribute.
We would love to hear what you're thinking and help refine the idea to make it
into the Coral ecosystem.
Please file issues if you would like to contribute to Coral.
### What should I include?
Coral has adopted an iterative, agile development philosophy. All contributions
that make it into the Coral repository should start with a user story in this
form:
> As a [type of Coral user] I'd like to [do something] so that I can [get some result/value].
This exercise does two things:
- allows us to ground our technical choices in a clear, simple product need.
- expresses that product need in a way that doesn't imply a specific technical
solution allowing for debate as to the best way to solve the problem.
Please feel free to provide as much detail as possible when filing the issue but
please do keep the initial issue specific to one need and try to avoid including
technical or design solutions.
If you have a specific technical or design solution in mind, please submit it as
the first comment on the thread.
## Localization
We use the [fluent](http://projectfluent.org/) library and store our
translations in [FTL](http://projectfluent.org/fluent/guide/) files in
`src/locales/` and `src/core/server/locales/`.
Strings are added or removed from localization bundles in the translation files
as needed. Strings **MUST NOT** be _changed_ after they've been committed and
pushed to master. Changing a string requires creating a new ID with a new name
(preferably descriptive instead of incremented) and deletion of the obsolete ID.
It's often useful to add a comment above the string with info about how and
where the string is used.
Once a language has enough coverage, it should be added to
`src/core/common/helpers/i18n/locales.ts`.
+320 -59
View File
@@ -19,11 +19,20 @@ Preview Coral easily by running Coral via a Heroku App:
- [Running](#running)
- [Docker](#docker)
- [Source](#source)
- [Embed On Your Site](#embed-on-your-site)
- [Single Sign On](#single-sign-on)
- [External Integrations](#external-integrations)
- [Login Prompts](#login-prompts)
- [Development](#development)
- [Embed On Your Site](#embed-on-your-site)
- [Single Sign On](#single-sign-on)
- [Email](#email)
- [Design Language System (UI Components)](#design-language-system-ui-components)
- [GraphQL API](#graphql-api)
- [Making your first request](#making-your-first-request)
- [Understanding the response](#understanding-the-response)
- [Authorizing a request](#authorizing-a-request)
- [Bearer Token](#bearer-token)
- [Cookie](#cookie)
- [Persisted Queries](#persisted-queries)
- [Configuration](#configuration)
- [License](#license)
@@ -132,60 +141,7 @@ npm run start
Then head on over to http://localhost:3000 to install Coral!
### Development
Running Coral for development is very similar to installing Coral via Source as
described above.
Coral requires NodeJS >=10, we recommend using `nvm` to help manage node
versions: https://github.com/creationix/nvm.
```bash
# Clone and cd into the Coral directory.
git clone https://github.com/coralproject/talk.git
cd talk
# Install dependencies.
npm install
```
Running Coral with default settings assumes that you have:
- MongoDB >=3.6 running on `127.0.0.1:27017`
- Redis >=3.2 running on `127.0.0.1:6379`
If you don't already have these databases running, you can execute the following
assuming you have Docker installed on your local machine:
```bash
docker run -d -p 27017:27017 --restart always --name mongo mongo:3.6
docker run -d -p 6379:6379 --restart always --name redis redis:3.2
```
Then start Coral with:
```bash
# Run the server in development mode in order to facilitate auto-restarting and
# rebuilding when file changes are detected. This might take a while to fully run.
npm run watch
```
When the client code has been built, navigate to http://localhost:8080/install
to start the installation wizard. **Note: Ensure `localhost:8080` is used in the permitted domains list.**
To see the comment stream goto http://localhost:8080/.
To run linting and tests use the following commands:
```bash
# Run the linters.
npm run lint
# Run our unit and integration tests.
npm run test
```
#### Embed On Your Site
### Embed On Your Site
With Coral setup and running locally you can test embeding the comment stream with this sample embed script:
@@ -210,7 +166,7 @@ With Coral setup and running locally you can test embeding the comment stream wi
> **NOTE:** Replace the value of `{{ CORAL_DOMAIN_NAME }}` with the location of your running instance of Coral.
#### Single Sign On
### Single Sign On
In order to allow seamless connection to an existing authentication system,
Coral utilizes the industry standard [JWT Token](https://jwt.io/) to connect. To
@@ -249,6 +205,11 @@ You will then have to generate a JWT with the following claims:
about status changes on a user account such as bans or suspensions.
- `user.username` (**required**) - the username that should be used when being
presented inside Coral to moderators and other users.
- `user.badges` (_optional_) - array of strings to be displayed as badges beside
username inside Coral, visible to other users and moderators. For example, to indicate
a user's subscription status.
- `user.role` (_optional_) - one of "COMMENTER", "STAFF", "MODERATOR", "ADMIN". Will create/update
Coral user with this role.
An example of the claims for this token would be:
@@ -293,6 +254,105 @@ embed.login("{{ SSO_TOKEN }}");
embed.logout();
```
#### External Integrations
You can integrate directly with the Coral GraphQL API in order to facilitate
account updates for your users when using Coral SSO. The relevant mutations are
as follows:
- `updateUserUsername` lets you update a given user with a new username using
an admin token.
- `updateUserEmail` lets you update a given user with a new email address
using an admin token.
- `deleteUser` lets you delete a given account using an admin token.
Note that even with an admin token, you may not delete yourself via
this method, and instead must use the `requestAccountDeletion`
mutation instead. This differs from the `requestAccountDeletion` as
it does the operation immediately instead of scheduling it as
`requestAccountDeletion` does.
- `requestUserCommentsDownload` lets you retrieve a given account's comments download. This mutation will provide you with a `archiveURL` that can be used to download a ZIP file containing the user's comment export.
If you're unsure on how to call GraphQL API's, refer to the section here on [Making your first GraphQL request](#making-your-first-request).
#### Login Prompts
In order to handle login prompts (e.g. a user clicks on the sign in button) you can listen to the `loginPrompt` event.
```js
var embed = Coral.createStreamEmbed({
// Don't forget to include the parameters from the
// "Embed On Your Site" section.
events: function(events) {
events.on("loginPrompt", function() {
// Redirect user to a login page.
location.href = "http://example.com/login";
});
},
});
```
### Development
Running Coral for development is very similar to installing Coral via Source as
described above.
Coral requires NodeJS >=10, we recommend using `nvm` to help manage node
versions: https://github.com/creationix/nvm.
```bash
# Clone and cd into the Coral directory.
git clone https://github.com/coralproject/talk.git
cd talk
# Install dependencies.
npm install
```
Running Coral with default settings assumes that you have:
- MongoDB >=3.6 running on `127.0.0.1:27017`
- Redis >=3.2 running on `127.0.0.1:6379`
If you don't already have these databases running, you can execute the following
assuming you have Docker installed on your local machine:
```bash
docker run -d -p 27017:27017 --restart always --name mongo mongo:3.6
docker run -d -p 6379:6379 --restart always --name redis redis:3.2
```
We recommend installing [watchman](https://facebook.github.io/watchman/docs/install.html) for better watch
performance.
```bash
# On macOS, you can run the following with Homebrew.
brew update
brew install watchman
```
Then start Coral with:
```bash
# Run the server in development mode in order to facilitate auto-restarting and
# rebuilding when file changes are detected. This might take a while to fully run.
npm run watch
```
When the client code has been built, navigate to http://localhost:8080/install
to start the installation wizard. **Note: Ensure `localhost:8080` is used in the permitted domains list.**
To see the comment stream goto http://localhost:8080/.
To run linting and tests use the following commands:
```bash
# Run the linters.
npm run lint
# Run our unit and integration tests.
npm run test
```
#### Email
To test out the email sending functionality, you can run [inbucket](https://www.inbucket.org/)
@@ -332,6 +392,207 @@ npm run docz -- dev
After compilation has finished you can access docz at http://localhost:3030/.
## GraphQL API
Our API is generally served via GraphQL at `/api/graphql` on your Coral installation. If you're running Coral locally, this would be https://localhost:8080/api/graphql.
You can enable the GraphiQL interface at https://localhost:3000/graphiql (Note the port number here is not 8080, this is because this route is directly served by the server, and not the webpack development server) when running in development to access a GraphQL playground to use with documentation provided in the sidebar on what edges are available to you. You can do this by setting `ENABLE_GRAPHIQL=true`. **(🚨 Note 🚨) we do not recommend using this in production environments as it disables many safety features used by the application**.
### Making your first request
To learn a bit about how to interact with Coral, we'll query for comments on a
page of Coral.
The GraphQL endpoint we have can be used with any HTTP client available, but our
examples below will use the common `curl` tool:
```sh
curl --request POST \
--url "http://localhost:8080/api/graphql" \
--header "content-type: application/json" \
--data '{"query":"query GetComments($url: String!) {story(url: $url) { id metadata { title } url comments { nodes { id body author { id username } } } } }","variables":{"url":"http://localhost:8080/"},"operationName":"GetComments"}'
```
When you unpack that, it's really quite simple. We're executing a `POST` request
to the `/api/graphql` route of the local Talk server with the GraphQL
request we want to make. It's composed of the `query`, `variables`, and
`operationName`.
```graphql
query GetComments($url: String!) {
story(url: $url) {
metadata {
title
}
url
comments {
nodes {
body
author {
username
}
}
}
}
}
```
We are grabbing the asset with the specified `$url`, and grabbing it's title and the comments under it.
We can then also specify our variables to the query being executed (in this
case, the url for the page where we have comments on our local install of Coral):
```json
{
"url": "http://localhost:8080/"
}
```
It's also sometimes common to have multiple queries within a query, which is
where the `operationName` comes into play, where we simply specify the named
query that we want to execute (in this case, `GetComments`).
To get a deeper understanding of GraphQL queries, read up on
[GraphQL Queries and Mutations](http://graphql.org/learn/queries/).
### Understanding the response
Once you completed the above GraphQL query with `curl`, you'll get a response
sort of like this:
```json
{
"data": {
"story": {
"metadata": {
"title": "Coral 5.0 Embed Stream"
},
"url": "http://localhost:8080/",
"comments": {
"nodes": [
{
"body": "First comment!",
"author": {
"username": "wyatt.johnson"
}
}
]
}
}
}
}
```
All of the parameters you requested should be available under the `data`
property. Any errors that you get would appear in a `errors` array at the top
level, like this:
```json
{
"errors": [
{
"message": "The specified story URL does not exist in the permitted domains list.",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": ["story"],
"extensions": {
"code": "STORY_URL_NOT_PERMITTED",
"id": "e255e860-d3ab-11e9-acdf-b9e9700f06fa",
"type": "INVALID_REQUEST_ERROR",
"message": "The specified story URL does not exist in the permitted domains list."
}
}
],
"data": {
"story": null
}
}
```
You should know that any property that is marked with a `!` is considered
required, and non-nullable, which means you can always guarantee on it being
there in your request if there were no errors.
### Authorizing a request
Some queries you may notice seem to return an error of
`USER_NOT_ENTITLED`. It's likely the case that you are making a request to a
route that requires authorization. You can perform authorization a few ways in
Talk.
Essentially, you need to get access to a JWT token that you can use to authorize
your requests.
```sh
curl --request POST \
--url http://localhost:3000/api/auth/local \
--header 'content-type: application/json' \
--data '{ "email": "${EMAIL}", "password": "${PASSWORD}"}'
```
Which returns a response similar to:
```json
{
"token": "${TOKEN}"
}
```
Where `${EMAIL}` is the email address of an admin user, and `${PASSWORD}` is the password for that admin user. This will generate a short term token (valid for 90 days). To generate a long lived access token (or Personal Access Token), you have to exchange the token generated above to create a new long lived token:
```sh
curl --request POST \
--url "http://localhost:3000/api/graphql" \
--header 'authorization: Bearer ${TOKEN}' \
--header 'content-type: application/json' \
--data '{"query":"mutation CreateAccessToken { createToken(input: { clientMutationId: \"\", name: \"My PAT\" }) { signedToken }}","operationName":"CreateAccessToken"}'
```
Which returns a response similar to:
```json
{
"data": {
"createToken": {
"signedToken": "${TOKEN}"
}
}
}
```
Where `${TOKEN}` in the request being from the previous set of login steps and returning a new `signedToken` as `${TOKEN}` which can be used instead of the previous `${TOKEN}` value in the below examples.
Once you have your access token, you can substitute it as `${TOKEN}` in your
`curl` request as follows:
#### Bearer Token
```sh
curl --request POST \
--url "http://localhost:8080/api/graphql" \
--header "content-type: application/json" \
--header "Authorization: Bearer ${TOKEN}" \
--data '{"query":"query GetComments($url: String!) {story(url: $url) { id metadata { title } url comments { nodes { id body author { id username } } } } }","variables":{"url":"http://localhost:8080/"},"operationName":"GetComments"}'
```
#### Cookie
```sh
curl --request POST \
--url "http://localhost:8080/api/graphql" \
--header "content-type: application/json" \
--cookie "authorization=${TOKEN}"
--data '{"query":"query GetComments($url: String!) {story(url: $url) { id metadata { title } url comments { nodes { id body author { id username } } } } }","variables":{"url":"http://localhost:8080/"},"operationName":"GetComments"}'
```
### Persisted Queries
You might see an error like `RAW_QUERY_NOT_AUTHORIZED`. This means that you attempted to use either no token, or a token from a user without admin privileges. In Coral, we whitelist the GraphQL mutations and queries that are performed by the applications for security reasons meaning that only admin users can make arbitrary queries against the GraphQL API.
## Configuration
The following environment variables can be set to configure the Coral Server. You
@@ -371,8 +632,7 @@ the variables in a `.env` file in the root of the project in a simple
(Default `false`)
- `LOCALE` - Specify the default locale to use for all requests without a locale
specified. (Default `en-US`)
- `ENABLE_GRAPHIQL` - When `true`, it will enable the `/graphiql` even in
production, use with care. (Default `false`)
- `ENABLE_GRAPHIQL` - When `true`, it will enable the GraphiQL interface at `/graphiql`. **(🚨 Note 🚨) we do not recommend using this in production environments as it disables many safety features used by the application**. (Default `false`)
- `CONCURRENCY` - The number of worker nodes to spawn to handle web traffic,
this should be tied to the number of CPU's available. (Default
`os.cpus().length`)
@@ -388,6 +648,7 @@ the variables in a `.env` file in the root of the project in a simple
- `WEBSOCKET_KEEP_ALIVE_TIMEOUT` - A duration in a parsable format (e.g. `30 seconds`
, `1 minute`) that should be used to send keep alive messages through the
websocket to keep the socket alive (Default `30 seconds`)
- `TRUST_PROXY` - When provided, it configures the "trust proxy" settings for Express (See https://expressjs.com/en/guide/behind-proxies.html)
## License
+5 -5
View File
@@ -27,7 +27,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run --silent generate:relay-stream", {
executor: new CommandExecutor("npm run --silent generate:relay:stream", {
runOnInit: true,
}),
},
@@ -44,7 +44,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run generate:relay-account", {
executor: new CommandExecutor("npm run generate:relay:account", {
runOnInit: true,
}),
},
@@ -61,7 +61,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run --silent generate:relay-admin", {
executor: new CommandExecutor("npm run --silent generate:relay:admin", {
runOnInit: true,
}),
},
@@ -78,7 +78,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run --silent generate:relay-auth", {
executor: new CommandExecutor("npm run --silent generate:relay:auth", {
runOnInit: true,
}),
},
@@ -95,7 +95,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run --silent generate:relay-install", {
executor: new CommandExecutor("npm run --silent generate:relay:install", {
runOnInit: true,
}),
},
+1 -1
View File
@@ -71,7 +71,7 @@ export default function({
{ from: /^\/account/, to: "/account.html" },
{ from: /^\/admin/, to: "/admin.html" },
{ from: /^\/embed\/stream/, to: "/stream.html" },
{ from: /^\/embed\/auth/, to: "/auth.html" },
{ from: /^\/embed\/auth$/, to: "/auth.html" },
{ from: /^\/embed\/auth\/callback/, to: "/auth-callback.html" },
{ from: /^\/install/, to: "/install.html" },
],
+10 -1
View File
@@ -67,9 +67,18 @@ gulp.task("server:static", () =>
.pipe(gulp.dest(resolveDistFolder() + "/core/server"))
);
gulp.task("locales", () =>
gulp
.src(["./src/locales/**/*"])
.pipe(gulp.dest(resolveDistFolder() + "/locales"))
);
gulp.task(
"server",
gulp.series("server:schema", gulp.parallel("server:scripts", "server:static"))
gulp.series(
"server:schema",
gulp.parallel("server:scripts", "server:static", "locales")
)
);
gulp.task("default", gulp.series("clean", "server"));
+993 -328
View File
File diff suppressed because it is too large Load Diff
+28 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "5.0.0-beta.5",
"version": "5.0.0",
"author": "The Coral Project",
"homepage": "https://coralproject.net/",
"sideEffects": [
@@ -12,7 +12,7 @@
},
"engines": {
"node": ">=10.0.0",
"npm": ">=6.7.0"
"npm": ">=6.9.0"
},
"bugs": "https://github.com/coralproject/talk/issues",
"contributors": [
@@ -20,18 +20,21 @@
],
"description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.",
"scripts": {
"build": "NODE_ENV=production WEBPACK_DISABLE_SOURCEMAPS=1 npm-run-all generate --parallel lint:client build:client build:server",
"build": "NODE_ENV=production npm-run-all generate-persist --parallel lint:client build:client build:server",
"build:development": "NODE_ENV=development npm-run-all generate --parallel lint:client build:client build:server",
"build:client": "ts-node --transpile-only ./scripts/build.ts",
"build:server": "gulp server",
"doctoc": "doctoc --title='## Table of Contents' --github README.md",
"generate": "npm-run-all --parallel generate:*",
"generate": "npm-run-all generate:css-types generate:schema generate:relay",
"generate-persist": "npm-run-all generate:css-types generate:schema generate:relay-persist",
"generate:css-types": "tcm src/core/client/",
"generate:relay-stream": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
"generate:relay-account": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/account --schema tenant",
"generate:relay-auth": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
"generate:relay-install": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
"generate:relay-admin": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
"generate:relay": "npm-run-all --parallel generate:relay:*",
"generate:relay-persist": "npm-run-all --parallel 'generate:relay:* -- --persist'",
"generate:relay:stream": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
"generate:relay:account": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/account --schema tenant",
"generate:relay:auth": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
"generate:relay:install": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
"generate:relay:admin": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
"generate:schema": "node ./scripts/generateSchemaTypes.js",
"docz": "docz",
"start": "NODE_ENV=production node dist/index.js",
@@ -53,8 +56,11 @@
"license": "Apache-2.0",
"dependencies": {
"@coralproject/bunyan-prettystream": "^0.1.4",
"@types/archiver": "^3.0.0",
"@types/cron": "^1.7.1",
"akismet-api": "^4.2.0",
"apollo-server-express": "^2.1.0",
"apollo-server-express": "^2.8.1",
"archiver": "^3.0.3",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"bull": "^3.8.1",
@@ -67,6 +73,8 @@
"cookie": "^0.4.0",
"cookie-parser": "^1.4.4",
"cors": "^2.8.4",
"cron": "^1.7.1",
"csv-stringify": "^5.3.0",
"dataloader": "^1.4.0",
"dompurify": "^1.0.8",
"dotenv": "^6.0.0",
@@ -98,7 +106,8 @@
"jwks-rsa": "^1.3.0",
"linkify-it": "^2.1.0",
"linkifyjs": "^2.1.8",
"lodash": "^4.17.10",
"lodash": "^4.17.15",
"lru-cache": "^5.1.1",
"luxon": "^1.12.0",
"metascraper-author": "^3.11.8",
"metascraper-date": "^3.11.4",
@@ -123,13 +132,14 @@
"prom-client": "^11.3.0",
"proxy-agent": "^3.1.0",
"querystringify": "^2.1.0",
"react-relay-network-modern": "^3.0.4",
"react-relay-network-modern": "^4.0.4",
"source-map-support": "^0.5.12",
"stack-utils": "^1.0.2",
"striptags": "^3.1.1",
"throng": "^4.0.0",
"tlds": "^1.203.1",
"tsscmp": "^1.0.6",
"url-regex": "^5.0.0",
"uuid": "^3.3.2",
"verror": "^1.10.0"
},
@@ -160,6 +170,7 @@
"@types/cookie": "^0.3.3",
"@types/cookie-parser": "^1.4.1",
"@types/cors": "^2.8.4",
"@types/cron": "^1.7.1",
"@types/cross-spawn": "^6.0.0",
"@types/dotenv": "^4.0.3",
"@types/enzyme": "^3.1.15",
@@ -180,6 +191,7 @@
"@types/linkify-it": "^2.1.0",
"@types/linkifyjs": "^2.1.1",
"@types/lodash": "^4.14.118",
"@types/lru-cache": "^5.1.0",
"@types/luxon": "^1.12.0",
"@types/marked": "^0.6.0",
"@types/mini-css-extract-plugin": "^0.2.0",
@@ -189,6 +201,7 @@
"@types/node-fetch": "^2.3.3",
"@types/nodemailer": "^4.6.2",
"@types/nunjucks": "^3.1.1",
"@types/object-diff": "0.0.0",
"@types/on-finished": "^2.3.1",
"@types/passport": "^0.4.6",
"@types/passport-facebook": "^2.1.8",
@@ -276,9 +289,10 @@
"lint-staged": "^8.1.6",
"loader-utils": "^1.2.3",
"lodash-es": "^4.17.11",
"marked": "^0.6.0",
"marked": "^0.7.0",
"material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.6.0",
"object-diff": "0.0.4",
"postcss-advanced-variables": "^3.0.0",
"postcss-css-variables": "^0.11.0",
"postcss-flexbugs-fixes": "^4.1.0",
@@ -313,6 +327,7 @@
"relay-compiler-language-typescript": "^4.1.0",
"relay-local-schema": "^0.8.0",
"relay-runtime": "^4.0.0",
"resize-observer-polyfill": "^1.5.1",
"sane": "^4.1.0",
"shallow-equals": "^1.0.0",
"simplemde": "^1.11.2",
+26 -3
View File
@@ -2,7 +2,7 @@
import program from "commander";
import spawn from "cross-spawn";
import fs from "fs";
import fs from "fs-extra";
import path from "path";
const config = JSON.parse(
@@ -14,6 +14,7 @@ program
.usage("--src ./src/core/client/stream --schema tenant")
.option("--src <folder>", "Find gql recursively in this folder")
.option("--schema <schema>", "Identifier of schema")
.option("--persist", "Use persisted queries")
.description("Compile relay gql data")
.parse(process.argv);
@@ -57,8 +58,30 @@ const args = [
`${program.src}/__generated__`,
"--schema",
config.projects[program.schema].schemaPath,
// "--persist-output",
// `${program.src}/persisted-queries.json`,
];
// Set the persisted query path.
const persist = program.persist
? `${program.src}/persisted-queries.json`
: null;
if (persist) {
args.push("--persist-output", persist);
}
spawn.sync("relay-compiler", args, { stdio: "inherit" });
if (persist) {
if (fs.existsSync(persist)) {
// Create the new filename.
const name = path.basename(program.src);
const generated = "./src/core/server/graph/tenant/persisted/__generated__";
// Create the generated directory if it doesn't exist.
fs.ensureDirSync(generated);
// Copy the file over to the destination directory.
fs.copySync(persist, `${generated}/${name}.json`, {
overwrite: true,
});
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ async function main() {
contextType: "TenantContext",
importStatements: [
'import TenantContext from "coral-server/graph/tenant/context";',
'import { Cursor } from "coral-server/models/helpers/connection";',
'import { Cursor } from "coral-server/models/helpers";',
],
customScalarType: { Cursor: "Cursor", Time: "Date" },
},
+8
View File
@@ -1,5 +1,7 @@
import convict from "convict";
import { LOCALES } from "../common/helpers/i18n/locales";
const config = convict({
env: {
doc: "The application environment.",
@@ -21,6 +23,12 @@ const config = convict({
env: "DEV_PORT",
arg: "dev-port",
},
defaultLocale: {
doc: "Specify the default locale to use",
format: LOCALES,
default: "en-US",
env: "LOCALE",
},
generateReport: {
doc: "Generate a report using webpack-bundle-analyzer",
format: Boolean,
+29 -18
View File
@@ -1,4 +1,5 @@
import OptimizeCssnanoPlugin from "@intervolga/optimize-cssnano-plugin";
import bunyan from "bunyan";
import CaseSensitivePathsPlugin from "case-sensitive-paths-webpack-plugin";
import CompressionPlugin from "compression-webpack-plugin";
import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin";
@@ -30,6 +31,12 @@ import paths from "./paths";
const filterPlugins = (plugins: Array<Plugin | null>): Plugin[] =>
plugins.filter(identity) as Plugin[];
// Create the build logger.
const logger = bunyan.createLogger({
name: "coral",
level: "debug",
});
interface CreateWebpackOptions {
appendPlugins?: any[];
watch?: boolean;
@@ -41,6 +48,8 @@ export default function createWebpackConfig(
config: Config,
{ appendPlugins = [], watch = false }: CreateWebpackOptions = {}
): Configuration[] {
logger.debug({ config: config.toString() }, "loaded configuration");
const maxCores = config.get("maxCores");
const env = createClientEnv(config);
const disableSourcemaps = config.get("disableSourcemaps");
@@ -83,23 +92,23 @@ export default function createWebpackConfig(
const localesOptions = {
pathToLocales: paths.appLocales,
// Default locale if non could be negotiated.
defaultLocale: "en-US",
// Default locale if none was specified.
defaultLocale: config.get("defaultLocale"),
// Fallback locale if a translation was not found.
// If not set, will use the text that is already
// in the code base.
fallbackLocale: "en-US",
fallbackLocale: config.get("defaultLocale"),
// Common fluent files are always included in the locale bundles.
commonFiles: ["framework.ftl", "common.ftl", "ui.ftl"],
// Locales that come with the main bundle. Others are loaded on demand.
bundled: ["en-US"],
bundled: [config.get("defaultLocale")],
// All available locales can be loadable on demand.
// To restrict available locales set:
// availableLocales: ["en-US"],
// availableLocales: [config.get("defaultLocale")],
};
const additionalPlugins = [
@@ -144,6 +153,16 @@ export default function createWebpackConfig(
),
];
const devtool = disableSourcemaps
? false
: isProduction
? // We generate sourcemaps in production. This is slow but gives good results.
// You can exclude the *.map files from the build during deployment.
"source-map"
: // You may want 'eval' instead if you prefer to see the compiled output in DevTools.
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
"cheap-module-source-map";
const baseConfig: Configuration = {
stats: {
// https://github.com/TypeStrong/ts-loader#transpileonly-boolean-defaultfalse
@@ -192,15 +211,7 @@ export default function createWebpackConfig(
}),
],
},
devtool: disableSourcemaps
? false
: isProduction
? // We generate sourcemaps in production. This is slow but gives good results.
// You can exclude the *.map files from the build during deployment.
"source-map"
: // You may want 'eval' instead if you prefer to see the compiled output in DevTools.
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
"cheap-module-source-map",
devtool,
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
// The first two entry points enable "hot" CSS and auto-refreshes for JS.
@@ -212,10 +223,10 @@ export default function createWebpackConfig(
// Generated JS file names (with nested folders).
// There will be one main bundle, and one file per asynchronous chunk.
filename: isProduction
? "assets/js/[name].[chunkhash:8].js"
? "assets/js/[name].[chunkhash].js"
: "assets/js/[name].js",
chunkFilename: isProduction
? "assets/js/[name].[chunkhash:8].chunk.js"
? "assets/js/[name].[chunkhash].chunk.js"
: "assets/js/[name].chunk.js",
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath,
@@ -346,7 +357,7 @@ export default function createWebpackConfig(
options: {
limit: 10000,
name: isProduction
? "assets/media/[name].[hash:8].[ext]"
? "assets/media/[name].[hash].[ext]"
: "assets/media/[name].[ext]",
},
},
@@ -359,7 +370,7 @@ export default function createWebpackConfig(
options: {
modules: true,
importLoaders: 2,
localIdentName: "[name]-[local]-[hash:base64:5]",
localIdentName: "[name]-[local]-[contenthash]",
sourceMap: !disableSourcemaps,
},
},
+1 -1
View File
@@ -1,2 +1,2 @@
__webpack_public_path__ =
JSON.parse(document.getElementById("config").innerText) || "/";
JSON.parse(document.getElementById("config").innerText).staticURI || "/";
+1 -6
View File
@@ -4,12 +4,7 @@
}
.centered {
max-width: calc(70 * var(--mini-unit));
margin: 10% auto;
padding: 0 calc(0.5 * var(--mini-unit));
box-sizing: border-box;
@media (min-width: $breakpoints-xs) {
max-width: calc(42 * var(--mini-unit));
}
}
}
-1
View File
@@ -13,7 +13,6 @@ async function main() {
const ManagedCoralContextProvider = await createManaged({
initLocalState,
localesData,
userLocales: navigator.languages,
});
const Index: FunctionComponent = () => (
+6
View File
@@ -1,7 +1,9 @@
import { makeRouteConfig, Route } from "found";
import React from "react";
import DownloadRoute from "./routes/download/Download";
import ConfirmRoute from "./routes/email/Confirm";
import UnsubscribeRoute from "./routes/notifications/Unsubscribe";
import ResetRoute from "./routes/password/Reset";
export default makeRouteConfig(
@@ -12,5 +14,9 @@ export default makeRouteConfig(
<Route path="email">
<Route path="confirm" {...ConfirmRoute.routeConfig} />
</Route>
<Route path="download" {...DownloadRoute.routeConfig} />
<Route path="notifications">
<Route path="unsubscribe" {...UnsubscribeRoute.routeConfig} />
</Route>
</Route>
);
@@ -0,0 +1,48 @@
.title {
font-family: var(--font-family-serif);
font-weight: 600;
font-style: normal;
font-size: calc(48rem / var(--rem-base));
line-height: calc(42em / 48);
text-align: center;
color: var(--palette-common-black);
}
.content {
padding: var(--spacing-2) var(--spacing-3) var(--spacing-3) var(--spacing-3);
}
.section {
margin-bottom: var(--spacing-4);
}
.sectionText {
font-family: var(--font-family-sans-serif);
font-weight: normal;
font-style: normal;
font-size: calc(24rem / var(--rem-base));
line-height: calc(34em / 24);
letter-spacing: 0.3px;
color: var(--palette-text-primary);
}
.list {
list-style: none;
margin-left: calc(-1 * var(--spacing-5));
}
.list li {
margin-bottom: calc(16em / 24);
}
.listContent {
display: inline-flex;
}
.bullet {
display: inline-block;
min-width: 24px;
margin-right: var(--spacing-2);
}
@@ -0,0 +1,101 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { Flex, HorizontalGutter, Icon, Typography } from "coral-ui/components";
import styles from "./DownloadDescription.css";
const DownloadDescription: FunctionComponent = () => {
return (
<HorizontalGutter size="double">
<Localized id="download-landingPage-title">
<div className={styles.title}>Download Your Comment History</div>
</Localized>
<div className={styles.content}>
<div className={styles.section}>
<Localized id="download-landingPage-description">
<Typography variant="bodyCopy" className={styles.sectionText}>
Your comment history will be downloaded into a .zip file. After
your comment history is unzipped you will have a comma separated
value (or .csv) file that you can easily import into your favorite
spreadsheet application.
</Typography>
</Localized>
</div>
<div className={styles.section}>
<Localized id="download-landingPage-contentsDescription">
<Typography variant="bodyCopy" className={styles.sectionText}>
For each of your comments the following information is included:
</Typography>
</Localized>
<ul className={styles.list}>
<li>
<Flex alignItems="center">
<Icon size="lg" className={styles.bullet}>
check
</Icon>
<Localized id="download-landingPage-contentsDate">
<Typography
variant="bodyCopy"
className={cn(styles.sectionText, styles.listContent)}
>
When you wrote the comment
</Typography>
</Localized>
</Flex>
</li>
<li>
<Flex alignItems="center">
<Icon size="lg" className={styles.bullet}>
check
</Icon>
<Localized id="download-landingPage-contentsUrl">
<Typography
variant="bodyCopy"
className={cn(styles.sectionText, styles.listContent)}
>
The permalink URL for the comment
</Typography>
</Localized>
</Flex>
</li>
<li>
<Flex alignItems="center">
<Icon size="lg" className={styles.bullet}>
check
</Icon>
<Localized id="download-landingPage-contentsText">
<Typography
variant="bodyCopy"
className={cn(styles.sectionText, styles.listContent)}
>
The comment text
</Typography>
</Localized>
</Flex>
</li>
<li>
<Flex alignItems="center">
<Icon size="lg" className={styles.bullet}>
check
</Icon>
<Localized id="download-landingPage-contentsStoryUrl">
<Typography
variant="bodyCopy"
className={cn(styles.sectionText, styles.listContent)}
>
The URL on the article or story where the comment appears
</Typography>
</Localized>
</Flex>
</li>
</ul>
</div>
</div>
</HorizontalGutter>
);
};
export default DownloadDescription;
@@ -0,0 +1,8 @@
.form {
text-align: center;
}
.downloadButton {
font-size: calc(18rem / var(--rem-base));
line-height: calc(23em / 18);
}
@@ -0,0 +1,43 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Button, HorizontalGutter } from "coral-ui/components";
import styles from "./DownloadForm.css";
interface Props {
token: string;
}
const DownloadForm: FunctionComponent<Props> = ({ token }) => {
const [submitted, setSubmitted] = useState(false);
const onClick = useCallback(() => {
setSubmitted(true);
}, [setSubmitted]);
return (
<HorizontalGutter size="double">
<form
className={styles.form}
method="post"
action="/api/account/download"
>
<input name="token" type="hidden" value={token} />
<Localized id="download-landingPage-downloadComments ">
<Button
type="submit"
variant="filled"
color="primary"
disabled={submitted}
onClick={onClick}
className={styles.downloadButton}
>
Download My Comment History
</Button>
</Localized>
</form>
</HorizontalGutter>
);
};
export default DownloadForm;
@@ -0,0 +1,16 @@
.container {
width: 100%;
text-align: center;
}
.root {
display: inline-block;
max-width: calc(70 * var(--mini-unit));
@media (min-width: $breakpoints-xs) {
max-width: calc(90 * var(--mini-unit));
}
text-align: left;
}
@@ -0,0 +1,73 @@
import React, { FunctionComponent } from "react";
import { Environment } from "relay-runtime";
import Loading from "coral-account/components/Loading";
import { useToken } from "coral-framework/hooks";
import { createFetch } from "coral-framework/lib/relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { parseHashQuery } from "coral-framework/utils";
import { HorizontalGutter } from "coral-ui/components";
import DownloadDescription from "./DownloadDescription";
import DownloadForm from "./DownloadForm";
import Sorry from "./Sorry";
import styles from "./DownloadRoute.css";
const fetcher = createFetch(
"downloadToken",
async (environment: Environment, variables: { token: string }, { rest }) =>
await rest.fetch<void>("/account/download", {
method: "GET",
token: variables.token,
})
);
interface Props {
token: string | undefined;
}
const DownloadRoute: FunctionComponent<Props> = ({ token }) => {
const [state, error] = useToken(fetcher, token);
if (state === "UNCHECKED") {
return (
<div className={styles.container}>
<div className={styles.root}>
<HorizontalGutter size="double">
<Loading />
</HorizontalGutter>
</div>
</div>
);
}
if (state !== "VALID" || error) {
return (
<div className={styles.container}>
<div className={styles.root}>
<HorizontalGutter size="double">
<DownloadDescription />
<Sorry />
</HorizontalGutter>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.root}>
<DownloadDescription />
<DownloadForm token={token!} />
</div>
</div>
);
};
const enhanced = withRouteConfig<Props>({
render: ({ match, Component }) => (
<Component token={parseHashQuery(match.location.hash).downloadToken} />
),
})(DownloadRoute);
export default enhanced;
@@ -0,0 +1,7 @@
.icon {
margin-right: var(--spacing-2);
}
.callout {
justify-content: left;
}
@@ -0,0 +1,23 @@
import React, { FunctionComponent } from "react";
import { CallOut, Flex, Icon } from "coral-ui/components";
import { Localized } from "fluent-react/compat";
import styles from "./Sorry.css";
const Sorry: FunctionComponent = () => {
return (
<CallOut color="error" fullWidth className={styles.callout}>
<Flex>
<Icon size="md" className={styles.icon}>
error
</Icon>
<Localized id="download-landingPage-sorry">
Your download link is invalid.
</Localized>
</Flex>
</CallOut>
);
};
export default Sorry;
@@ -0,0 +1 @@
export { default, default as DownloadRoute } from "./DownloadRoute";
@@ -30,39 +30,37 @@ const ConfirmForm: React.FunctionComponent<Props> = ({ onSuccess, token }) => {
return;
}, [token]);
return (
<div>
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="double">
<HorizontalGutter>
<Localized id="confirmEmail-emailConfirmation">
<Typography variant="heading1">Email Confirmation</Typography>
</Localized>
<Localized id="confirmEmail-pleaseClickToConfirm">
<Typography variant="bodyCopy">
Click below to confirm your email address.
</Typography>
</Localized>
</HorizontalGutter>
<HorizontalGutter>
<Localized id="confirmEmail-confirmEmail">
<Button
type="submit"
variant="filled"
color="primary"
disabled={submitting}
fullWidth
>
Confirm email
</Button>
</Localized>
</HorizontalGutter>
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="double">
<HorizontalGutter>
<Localized id="confirmEmail-emailConfirmation">
<Typography variant="heading1">Email Confirmation</Typography>
</Localized>
<Localized id="confirmEmail-pleaseClickToConfirm">
<Typography variant="bodyCopy">
Click below to confirm your email address.
</Typography>
</Localized>
</HorizontalGutter>
</form>
)}
</Form>
</div>
<HorizontalGutter>
<Localized id="confirmEmail-confirmEmail">
<Button
type="submit"
variant="filled"
color="primary"
disabled={submitting}
fullWidth
>
Confirm email
</Button>
</Localized>
</HorizontalGutter>
</HorizontalGutter>
</form>
)}
</Form>
);
};
@@ -0,0 +1,16 @@
.container {
width: 100%;
text-align: center;
}
.root {
display: inline-block;
max-width: calc(70 * var(--mini-unit));
@media (min-width: $breakpoints-xs) {
max-width: calc(42 * var(--mini-unit));
}
text-align: left;
}
@@ -11,6 +11,8 @@ import ConfirmForm from "./ConfirmForm";
import Sorry from "./Sorry";
import Success from "./Success";
import styles from "./ConfirmRoute.css";
const fetcher = createFetch(
"confirmToken",
async (environment: Environment, variables: { token: string }, { rest }) =>
@@ -32,17 +34,37 @@ const ConfirmRoute: React.FunctionComponent<Props> = ({ token }) => {
const [state, error] = useToken(fetcher, token);
if (state === "UNCHECKED") {
return <Loading />;
return (
<div className={styles.container}>
<div className={styles.root}>
<Loading />
</div>
</div>
);
}
if (state !== "VALID" || error) {
return <Sorry reason={error} />;
return (
<div className={styles.container}>
<div className={styles.root}>
<Sorry reason={error} />
</div>
</div>
);
}
return !finished ? (
<ConfirmForm token={token!} onSuccess={onSuccess} />
<div className={styles.container}>
<div className={styles.root}>
<ConfirmForm token={token!} onSuccess={onSuccess} />
</div>
</div>
) : (
<Success />
<div className={styles.container}>
<div className={styles.root}>
<Success />
</div>
</div>
);
};
@@ -0,0 +1,32 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import { CallOut, HorizontalGutter, Typography } from "coral-ui/components";
interface Props {
reason: React.ReactNode;
}
const Sorry: React.FunctionComponent<Props> = ({ reason }) => {
return (
<HorizontalGutter size="double">
<Localized id="unsubscribe-oopsSorry">
<Typography variant="heading1">Oops Sorry!</Typography>
</Localized>
<CallOut color="error" fullWidth>
{reason ? (
reason
) : (
<Localized id="account-tokenNotFound">
<span data-testid="invalid-link">
The specified link is invalid, check to see if it was copied
correctly.
</span>
</Localized>
)}
</CallOut>
</HorizontalGutter>
);
};
export default Sorry;
@@ -0,0 +1,18 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import { HorizontalGutter, Typography } from "coral-ui/components";
const Success: React.FunctionComponent = () => {
return (
<HorizontalGutter data-testid="success" size="double">
<Localized id="unsubscribe-successfullyUnsubscribed">
<Typography variant="heading1">
You are now unsubscribed from all notifications
</Typography>
</Localized>
</HorizontalGutter>
);
};
export default Success;
@@ -0,0 +1,76 @@
import { FORM_ERROR } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { useCallback } from "react";
import { Form } from "react-final-form";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useMutation } from "coral-framework/lib/relay";
import {
Button,
CallOut,
HorizontalGutter,
Typography,
} from "coral-ui/components";
import UnsubscribeNotificationsMutation from "./UnsubscribeNotificationsMutation";
interface Props {
token: string;
disabled?: boolean;
onSuccess: () => void;
}
const UnsubscribeForm: React.FunctionComponent<Props> = ({
onSuccess,
token,
}) => {
const unsubscribe = useMutation(UnsubscribeNotificationsMutation);
const onSubmit = useCallback(async () => {
try {
await unsubscribe({ token });
onSuccess();
} catch (error) {
if (error instanceof InvalidRequestError) {
return error.invalidArgs;
}
return { [FORM_ERROR]: error.message };
}
return;
}, [token]);
return (
<div data-testid="unsubscribe-form">
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting, submitError }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter>
<Localized id="unsubscribe-clickToConfirm">
<Typography variant="heading1">
Click below to confirm that you want to unsubscribe from all
notifications.
</Typography>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Localized id="unsubscribe-confirm">
<Button
type="submit"
variant="filled"
color="primary"
disabled={submitting}
fullWidth
>
Confirm
</Button>
</Localized>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
export default UnsubscribeForm;
@@ -0,0 +1,14 @@
import { Environment } from "relay-runtime";
import { createMutation } from "coral-framework/lib/relay";
const UnsubscribeNotificationsMutation = createMutation(
"unsubscribeNotifications",
async (environment: Environment, variables: { token: string }, { rest }) =>
await rest.fetch<void>("/account/notifications/unsubscribe", {
method: "DELETE",
token: variables.token,
})
);
export default UnsubscribeNotificationsMutation;
@@ -0,0 +1,16 @@
.container {
width: 100%;
text-align: center;
}
.root {
display: inline-block;
max-width: calc(70 * var(--mini-unit));
@media (min-width: $breakpoints-xs) {
max-width: calc(42 * var(--mini-unit));
}
text-align: left;
}
@@ -0,0 +1,77 @@
import React, { useCallback, useState } from "react";
import { Environment } from "relay-runtime";
import Loading from "coral-account/components/Loading";
import { useToken } from "coral-framework/hooks";
import { createFetch } from "coral-framework/lib/relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { parseHashQuery } from "coral-framework/utils";
import Sorry from "./Sorry";
import Success from "./Success";
import UnsubscribeForm from "./UnsubscribeForm";
import styles from "./UnsubscribeRoute.css";
const fetcher = createFetch(
"unsubscribeToken",
async (environment: Environment, variables: { token: string }, { rest }) =>
await rest.fetch<void>("/account/notifications/unsubscribe", {
method: "GET",
token: variables.token,
})
);
interface Props {
token: string | undefined;
}
const UnsubscribeRoute: React.FunctionComponent<Props> = ({ token }) => {
const [finished, setFinished] = useState(false);
const onSuccess = useCallback(() => {
setFinished(true);
}, []);
const [state, error] = useToken(fetcher, token);
if (state === "UNCHECKED") {
return (
<div className={styles.container}>
<div className={styles.root}>
<Loading />
</div>
</div>
);
}
if (state !== "VALID" || error) {
return (
<div className={styles.container}>
<div className={styles.root}>
<Sorry reason={error} />
</div>
</div>
);
}
return !finished ? (
<div className={styles.container}>
<div className={styles.root}>
<UnsubscribeForm token={token!} onSuccess={onSuccess} />
</div>
</div>
) : (
<div className={styles.container}>
<div className={styles.root}>
<Success />
</div>
</div>
);
};
const enhanced = withRouteConfig<Props>({
render: ({ match, Component }) => (
<Component token={parseHashQuery(match.location.hash).unsubscribeToken} />
),
})(UnsubscribeRoute);
export default enhanced;
@@ -0,0 +1 @@
export { default, default as UnsubscribeRoute } from "./UnsubscribeRoute";
@@ -4,6 +4,7 @@ import React, { useCallback } from "react";
import { Field, Form } from "react-final-form";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { colorFromMeta, ValidationMessage } from "coral-framework/lib/form";
import { useMutation } from "coral-framework/lib/relay";
import {
composeValidators,
@@ -19,7 +20,6 @@ import {
InputLabel,
PasswordField,
Typography,
ValidationMessage,
} from "coral-ui/components";
import ResetPasswordMutation from "./ResetPasswordMutation";
@@ -99,32 +99,23 @@ const ResetPasswordForm: React.FunctionComponent<Props> = ({
>
<PasswordField
id={input.name}
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Password"
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={disabled}
color={colorFromMeta(meta)}
autoComplete="new-password"
disabled={disabled || meta.submitting}
fullWidth
{...input}
/>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
{meta.touched && (meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Localized id="resetPassword-resetPassword">
<Button
type="submit"
@@ -0,0 +1,16 @@
.container {
width: 100%;
text-align: center;
}
.root {
display: inline-block;
max-width: calc(70 * var(--mini-unit));
@media (min-width: $breakpoints-xs) {
max-width: calc(42 * var(--mini-unit));
}
text-align: left;
}
@@ -11,6 +11,8 @@ import ResetPasswordForm from "./ResetPasswordForm";
import Sorry from "./Sorry";
import Success from "./Success";
import styles from "./ResetRoute.css";
const fetcher = createFetch(
"resetToken",
async (environment: Environment, variables: { token: string }, { rest }) =>
@@ -32,17 +34,37 @@ const ResetRoute: React.FunctionComponent<Props> = ({ token }) => {
const [state, error] = useToken(fetcher, token);
if (state === "UNCHECKED") {
return <Loading />;
return (
<div className={styles.container}>
<div className={styles.root}>
<Loading />
</div>
</div>
);
}
if (state !== "VALID" || error) {
return <Sorry reason={error} />;
return (
<div className={styles.container}>
<div className={styles.root}>
<Sorry reason={error} />
</div>
</div>
);
}
return !finished ? (
<ResetPasswordForm token={token!} onSuccess={onSuccess} />
<div className={styles.container}>
<div className={styles.root}>
<ResetPasswordForm token={token!} onSuccess={onSuccess} />
</div>
</div>
) : (
<Success />
<div className={styles.container}>
<div className={styles.root}>
<Success />
</div>
</div>
);
};
@@ -10,46 +10,54 @@ exports[`renders form 1`] = `
<div
className="MainLayout-centered"
>
<div>
<form
autoComplete="off"
onSubmit={[Function]}
<div
className="ConfirmRoute-container"
>
<div
className="ConfirmRoute-root"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
className="Box-root HorizontalGutter-root HorizontalGutter-double"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
Email Confirmation
</h1>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Email Confirmation
</h1>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Click below to confirm your email address.
</p>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
Click below to confirm your email address.
</p>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Confirm email
</button>
</div>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Confirm email
</button>
</div>
</div>
</form>
</form>
</div>
</div>
</div>
</div>
@@ -66,22 +74,30 @@ exports[`renders missing confirm token 1`] = `
className="MainLayout-centered"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
className="ConfirmRoute-container"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Oops Sorry!
</h1>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
className="ConfirmRoute-root"
>
<div>
<span
data-testid="invalid-link"
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
The specified link is invalid, check to see if it was copied correctly.
</span>
Oops Sorry!
</h1>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
>
<div>
<span
data-testid="invalid-link"
>
The specified link is invalid, check to see if it was copied correctly.
</span>
</div>
</div>
</div>
</div>
</div>
@@ -10,97 +10,110 @@ exports[`renders form 1`] = `
<div
className="MainLayout-centered"
>
<div>
<form
autoComplete="off"
onSubmit={[Function]}
<div
className="ResetRoute-container"
>
<div
className="ResetRoute-root"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Reset your password
</h1>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Please enter a new password to use to sign in to your account.
Make sure it is unique and be sure to keep it secure.
</p>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
<div>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
className="Box-root HorizontalGutter-root HorizontalGutter-double"
>
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
Password
</label>
<p
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary"
>
Must be at least 8 characters
</p>
<div
className="PasswordField-fullWidth PasswordField-root"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Reset your password
</h1>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Please enter a new password to use to sign in to your account.
Make sure it is unique and be sure to keep it secure.
</p>
</div>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<div
className="PasswordField-wrapper"
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
id="password"
name="password"
onChange={[Function]}
placeholder="Password"
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
role="button"
tabIndex={0}
title="Hide password"
<label
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
htmlFor="password"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
Password
</label>
<p
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
>
Must be at least 8 characters
</p>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
visibility
</span>
<input
autoCapitalize="off"
autoComplete="new-password"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
disabled={false}
id="password"
name="password"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder="Password"
spellCheck={false}
type="password"
value=""
/>
<div
className="PasswordField-icon"
onClick={[Function]}
role="button"
tabIndex={0}
title="Hide password"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</i>
</div>
</div>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Reset Password
</button>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Reset Password
</button>
</div>
</form>
</div>
</form>
</div>
</div>
</div>
</div>
@@ -117,22 +130,30 @@ exports[`renders missing reset token 1`] = `
className="MainLayout-centered"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
className="ResetRoute-container"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Oops Sorry!
</h1>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
className="ResetRoute-root"
>
<div>
<span
data-testid="invalid-link"
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
The specified link is invalid, check to see if it was copied correctly.
</span>
Oops Sorry!
</h1>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
>
<div>
<span
data-testid="invalid-link"
>
The specified link is invalid, check to see if it was copied correctly.
</span>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,96 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders form 1`] = `
<div
data-testid="main-layout"
>
<div
className="MainLayout-bar"
/>
<div
className="MainLayout-centered"
>
<div
className="UnsubscribeRoute-container"
>
<div
className="UnsubscribeRoute-root"
>
<div
data-testid="unsubscribe-form"
>
<form
onSubmit={[Function]}
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Click below to confirm that you want to unsubscribe from all notifications.
</h1>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
data-color="primary"
data-variant="filled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Confirm
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
`;
exports[`renders missing confirm token 1`] = `
<div
data-testid="main-layout"
>
<div
className="MainLayout-bar"
/>
<div
className="MainLayout-centered"
>
<div
className="UnsubscribeRoute-container"
>
<div
className="UnsubscribeRoute-root"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
>
<h1
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
>
Oops Sorry!
</h1>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
>
<div>
<span
data-testid="invalid-link"
>
The specified link is invalid, check to see if it was copied correctly.
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,134 @@
import sinon from "sinon";
import { ERROR_CODES } from "coral-common/errors";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createAccessToken,
CreateTestRendererParams,
replaceHistoryLocation,
waitForElement,
within,
} from "coral-framework/testHelpers";
import create from "./create";
const token = createAccessToken();
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
const { testRenderer, context } = create();
return {
context,
testRenderer,
root: testRenderer.root,
};
}
it("renders missing confirm token", async () => {
replaceHistoryLocation("http://localhost/account/notifications/unsubscribe");
const { root } = await createTestRenderer();
await waitForElement(() => within(root).getByTestID("invalid-link"));
expect(within(root).toJSON()).toMatchSnapshot();
});
it("renders form", async () => {
replaceHistoryLocation(
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
);
const { root, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "GET",
token,
})
.once();
await act(async () => {
await waitForElement(() => within(root).getByTestID("unsubscribe-form"));
});
expect(within(root).toJSON()).toMatchSnapshot();
restMock.verify();
});
it("renders error from server", async () => {
replaceHistoryLocation(
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
);
const codes = [
ERROR_CODES.RATE_LIMIT_EXCEEDED,
ERROR_CODES.INTEGRATION_DISABLED,
ERROR_CODES.USER_NOT_FOUND,
ERROR_CODES.TOKEN_INVALID,
];
for (const code of codes) {
const { root, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "GET",
token,
})
.once()
.throwsException(
new InvalidRequestError({
code,
})
);
await act(async () => {
await waitForElement(() =>
within(root).getByText(code, {
exact: false,
})
);
});
restMock.verify();
}
});
it("submits form", async () => {
replaceHistoryLocation(
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
);
const { root, context } = await createTestRenderer();
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "GET",
token,
})
.once();
restMock
.expects("fetch")
.withArgs("/account/notifications/unsubscribe", {
method: "DELETE",
token,
})
.once();
await act(async () => {
await waitForElement(() => within(root).getByTestID("unsubscribe-form"));
});
const form = within(root).getByType("form");
// Submit valid form.
await act(async () => {
form.props.onSubmit();
await waitForElement(() => within(root).getByTestID("success"));
});
restMock.verify();
});
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { FunctionComponent } from "react";
import { PropTypesOf } from "coral-framework/types";
import { Logo } from "coral-ui/components";
import { LogoHorizontal } from "coral-ui/components";
import { AppBar, Begin, Divider, End } from "coral-ui/components/AppBar";
import { DecisionHistoryButton } from "./DecisionHistory";
@@ -22,8 +22,7 @@ const Main: FunctionComponent<Props> = ({ children, viewer }) => (
<AppBar gutterBegin gutterEnd>
<Begin itemGutter="double">
<div className={styles.logoContainer}>
<Logo />
<Version />
<LogoHorizontal />
</div>
<NavigationContainer viewer={viewer} />
</Begin>
@@ -34,6 +33,7 @@ const Main: FunctionComponent<Props> = ({ children, viewer }) => (
</End>
</AppBar>
{children}
<Version />
</div>
);
+3 -3
View File
@@ -1,6 +1,6 @@
.version {
position: absolute;
right: 0;
top: 23px;
font-size: calc(10rem / var(--rem-base));
color: var(--palette-text-secondary);
text-align: center;
padding-bottom: var(--spacing-3);
}
@@ -14,8 +14,7 @@ exports[`renders correctly 1`] = `
<div
className="Main-logoContainer"
>
<withPropsOnChange(Logo) />
<Version />
<withPropsOnChange(LogoHorizontal) />
</div>
<withContext(createMutationContainer(Relay(NavigationContainer)))
viewer={Object {}}
@@ -30,5 +29,6 @@ exports[`renders correctly 1`] = `
</withPropsOnChange(End)>
</withPropsOnChange(AppBar)>
child
<Version />
</div>
`;
+2 -2
View File
@@ -1,7 +1,7 @@
import React, { FunctionComponent } from "react";
import {
BrandIcon,
BrandMark,
BrandName,
Flex,
HorizontalGutter,
@@ -22,7 +22,7 @@ const AuthBox: FunctionComponent<Props> = ({ title, children }) => {
<HorizontalGutter className={styles.container} size="double">
<Flex justifyContent="center">
<div className={styles.brandIcon}>
<BrandIcon size="lg" />
<BrandMark size="lg" />
</div>
</Flex>
<div>
@@ -1,7 +1,7 @@
.root {
border: 1px solid var(--palette-success-main);
box-sizing: border-box;
border-radius: 2px;
border-radius: var(--round-corners);
width: 65px;
height: 50px;
display: flex;
@@ -14,7 +14,7 @@ const purify = createPurify(window, false);
interface Props {
className?: string;
children: string;
children: string | React.ReactElement;
suspectWords: ReadonlyArray<string>;
bannedWords: ReadonlyArray<string>;
}
@@ -117,25 +117,29 @@ const CommentContent: FunctionComponent<Props> = ({
className,
children,
}) => {
// We create a Shadow DOM Tree with the HTML body content and
// use it as a parser.
const node = document.createElement("div");
node.innerHTML = purify.sanitize(children);
if (typeof children === "string") {
// We create a Shadow DOM Tree with the HTML body content and
// use it as a parser.
const node = document.createElement("div");
node.innerHTML = purify.sanitize(children);
if (suspectWords.length || bannedWords.length) {
// Then we traverse it recursively and manipulate it to highlight suspect words
// and banned words.
markHTMLNode(node, suspectWords, bannedWords);
if (suspectWords.length || bannedWords.length) {
// Then we traverse it recursively and manipulate it to highlight suspect words
// and banned words.
markHTMLNode(node, suspectWords, bannedWords);
}
// Finally we render the content of the Shadow DOM Tree
return (
<Typography
className={cn(className, styles.root)}
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
container="div"
/>
);
}
// Finally we render the content of the Shadow DOM Tree
return (
<Typography
className={cn(className, styles.root)}
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
container="div"
/>
);
return <>{children}</>;
};
export default CommentContent;
@@ -9,10 +9,12 @@ import styles from "./FeatureButton.css";
interface Props extends PropTypesOf<typeof BaseButton> {
featured: boolean;
enabled?: boolean;
}
const FeatureButton: FunctionComponent<Props> = ({
featured,
enabled = true,
className,
...rest
}) => (
@@ -21,6 +23,7 @@ const FeatureButton: FunctionComponent<Props> = ({
className={cn(className, styles.root, {
[styles.invert]: featured,
})}
disabled={!enabled}
>
{featured ? (
<Localized id="moderate-comment-featuredText">
@@ -17,15 +17,17 @@ import ToxicityLabel from "./ToxicityLabel";
interface Props {
comment: FlagDetailsContainer_comment;
settings: FlagDetailsContainer_settings;
onUsernameClick: (id?: string) => void;
}
const FlagDetailsContainer: FunctionComponent<Props> = ({
comment: {
revision: { metadata },
flags: { nodes },
},
comment,
onUsernameClick,
settings,
}) => {
const metadata = comment.revision ? comment.revision.metadata : null;
const nodes = comment.flags.nodes;
const offensive = nodes.filter(
({ reason }) => reason === GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_OFFENSIVE
);
@@ -35,7 +37,7 @@ const FlagDetailsContainer: FunctionComponent<Props> = ({
return (
<HorizontalGutter size="oneAndAHalf">
{metadata.perspective && (
{metadata && metadata.perspective && (
<FlagDetailsCategory
category={
<Localized id="moderate-flagDetails-toxicityScore">
@@ -63,6 +65,9 @@ const FlagDetailsContainer: FunctionComponent<Props> = ({
{offensive.map((flag, i) => (
<FlagDetailsEntry
key={i}
onClick={() =>
flag.flagger ? onUsernameClick(flag.flagger.id) : null
}
user={flag.flagger ? flag.flagger.username : <NotAvailable />}
details={flag.additionalDetails}
/>
@@ -79,6 +84,9 @@ const FlagDetailsContainer: FunctionComponent<Props> = ({
>
{spam.map((flag, i) => (
<FlagDetailsEntry
onClick={() =>
flag.flagger ? onUsernameClick(flag.flagger.id) : null
}
key={i}
user={flag.flagger ? flag.flagger.username : <NotAvailable />}
details={flag.additionalDetails}
@@ -97,6 +105,7 @@ const enhanced = withFragmentContainer<Props>({
nodes {
flagger {
username
id
}
reason
additionalDetails
@@ -16,3 +16,22 @@
color: var(--palette-text-primary);
font-style: italic;
}
.flagger {
margin-right: var(--mini-unit);
padding: var(--spacing-1);
margin-left: calc(-1 * var(--spacing-1));
line-height: calc(16rem / var(--rem-base));
&:hover {
background-color: var(--palette-grey-lightest);
border-radius: var(--round-corners);
border-style: none;
}
&:active {
background-color: var(--palette-grey-lighter);
border-radius: var(--round-corners);
border-style: none;
}
}
@@ -1,19 +1,35 @@
import React, { FunctionComponent } from "react";
import { BaseButton } from "coral-ui/components";
import styles from "./FlagDetailsEntry.css";
interface Props {
user: React.ReactNode;
details?: React.ReactNode;
onClick?: () => void;
}
const FlagDetailsEntry: FunctionComponent<Props> = ({ user, details }) => {
const FlagDetailsEntry: FunctionComponent<Props> = ({
user,
details,
onClick,
}) => {
return (
<div>
<span className={styles.user}>
{user}
{details && ":"}
</span>
{onClick && (
<BaseButton className={styles.flagger} onClick={onClick}>
<span className={styles.user}>
{user}
{details && ":"}
</span>
</BaseButton>
)}
{!onClick && (
<span className={styles.user}>
{user}
{details && ":"}
</span>
)}
{details && <span className={styles.details}>{details}</span>}
</div>
);
@@ -7,3 +7,7 @@
.username {
color: var(--palette-grey-main);
}
.usernameButton {
display: inline-flex;
}
@@ -19,7 +19,7 @@ it("renders all markers", () => {
reasons: {
COMMENT_DETECTED_TOXIC: 1,
COMMENT_DETECTED_SPAM: 1,
COMMENT_DETECTED_TRUST: 1,
COMMENT_DETECTED_RECENT_HISTORY: 1,
COMMENT_DETECTED_LINKS: 1,
COMMENT_DETECTED_BANNED_WORD: 1,
COMMENT_DETECTED_SUSPECT_WORD: 1,
@@ -42,6 +42,7 @@ it("renders all markers", () => {
},
},
},
onUsernameClick: () => null,
};
const renderer = createRenderer();
renderer.render(<MarkersContainerN {...props} />);
@@ -58,7 +59,7 @@ it("renders some markers", () => {
reasons: {
COMMENT_DETECTED_TOXIC: 1,
COMMENT_DETECTED_SPAM: 0,
COMMENT_DETECTED_TRUST: 1,
COMMENT_DETECTED_RECENT_HISTORY: 1,
COMMENT_DETECTED_LINKS: 0,
COMMENT_DETECTED_BANNED_WORD: 1,
COMMENT_DETECTED_SUSPECT_WORD: 0,
@@ -81,6 +82,7 @@ it("renders some markers", () => {
},
},
},
onUsernameClick: () => null,
};
const renderer = createRenderer();
renderer.render(<MarkersContainerN {...props} />);
@@ -11,15 +11,16 @@ import Markers from "./Markers";
interface MarkersContainerProps {
comment: MarkersContainer_comment;
onUsernameClick: (id?: string) => void;
settings: MarkersContainer_settings;
}
function hasDetails(c: MarkersContainer_comment) {
return (
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE +
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM >
0 || c.revision.metadata.perspective
);
return c.revision
? c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE +
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM >
0 || c.revision.metadata.perspective
: false;
}
let keyCounter = 0;
@@ -34,72 +35,80 @@ const markers: Array<
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_LINKS && (
<Localized id="moderate-marker-link" key={keyCounter++}>
<Marker color="primary">Link</Marker>
</Localized>
)) ||
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_LINKS && (
<Localized id="moderate-marker-link" key={keyCounter++}>
<Marker color="primary">Link</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
<Marker color="error">Banned Word</Marker>
</Localized>
)) ||
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
<Marker color="error">Banned Word</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
<Marker color="error" variant="filled">
Suspect Word
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
<Marker color="error" variant="filled">
Suspect Word
</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
<Localized id="moderate-marker-spam" key={keyCounter++}>
<Marker color="error">Spam</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TOXIC && (
<Localized id="moderate-marker-toxic" key={keyCounter++}>
<Marker color="error">Toxic</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && (
<Localized id="moderate-marker-recentHistory" key={keyCounter++}>
<Marker color="error">Recent History</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE && (
<Marker key={keyCounter++} color="error">
<Localized id="moderate-marker-offensive">
<span>Offensive</span>
</Localized>{" "}
<MarkerCount>
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE}
</MarkerCount>
</Marker>
</Localized>
)) ||
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
<Localized id="moderate-marker-spam" key={keyCounter++}>
<Marker color="error">Spam</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TOXIC && (
<Localized id="moderate-marker-toxic" key={keyCounter++}>
<Marker color="error">Toxic</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TRUST && (
<Localized id="moderate-marker-karma" key={keyCounter++}>
<Marker color="error">Karma</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE && (
<Marker key={keyCounter++} color="error">
<Localized id="moderate-marker-offensive">
<span>Offensive</span>
</Localized>{" "}
<MarkerCount>
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE}
</MarkerCount>
</Marker>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM && (
<Marker key={keyCounter++} color="error">
<Localized id="moderate-marker-spam">
<span>Spam</span>
</Localized>{" "}
<MarkerCount>
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM}
</MarkerCount>
</Marker>
)) ||
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM && (
<Marker key={keyCounter++} color="error">
<Localized id="moderate-marker-spam">
<span>Spam</span>
</Localized>{" "}
<MarkerCount>
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM}
</MarkerCount>
</Marker>
)) ||
null,
];
@@ -116,6 +125,7 @@ export class MarkersContainer extends React.Component<MarkersContainerProps> {
details={
doesHaveDetails ? (
<FlagDetailsContainer
onUsernameClick={this.props.onUsernameClick}
comment={this.props.comment}
settings={this.props.settings}
/>
@@ -139,7 +149,7 @@ const enhanced = withFragmentContainer<MarkersContainerProps>({
reasons {
COMMENT_DETECTED_TOXIC
COMMENT_DETECTED_SPAM
COMMENT_DETECTED_TRUST
COMMENT_DETECTED_RECENT_HISTORY
COMMENT_DETECTED_LINKS
COMMENT_DETECTED_BANNED_WORD
COMMENT_DETECTED_SUSPECT_WORD
@@ -15,13 +15,13 @@
&:hover {
background-color: var(--palette-grey-lightest);
border-radius: 2px;
border-radius: var(--round-corners);
border-style: none;
}
&:active {
background-color: var(--palette-grey-lighter);
border-radius: 2px;
border-radius: var(--round-corners);
border-style: none;
}
}
@@ -58,8 +58,8 @@
}
.asideMiniWithReplyTo {
/*
We have reply to field, so we align below the fattest
/*
We have reply to field, so we align below the fattest
element plus the reply to field.
*/
padding-top: calc(2 * (14rem / var(--rem-base)) + 5px + var(--spacing-4));
@@ -151,4 +151,8 @@
.miniButton {
width: 40px;
height: 40px;
}
}
.deleted {
background: var(--palette-grey-lightest);
}
@@ -42,7 +42,10 @@ it("renders correctly", () => {
it("renders reply correctly", () => {
const props: PropTypesOf<typeof ModerateCardN> = {
...baseProps,
inReplyTo: "Julian",
inReplyTo: {
username: "Julian",
id: "1234",
},
};
const renderer = createRenderer();
renderer.render(<ModerateCardN {...props} />);
@@ -91,3 +94,13 @@ it("renders story info", () => {
renderer.render(<ModerateCardN {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
it("renders tombstoned when comment is deleted", () => {
const props: PropTypesOf<typeof ModerateCardN> = {
...baseProps,
deleted: true,
};
const renderer = createRenderer();
renderer.render(<ModerateCardN {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -1,6 +1,6 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useCallback } from "react";
import { PropTypesOf } from "coral-framework/types";
import {
@@ -9,6 +9,7 @@ import {
Flex,
HorizontalGutter,
TextLink,
Typography,
} from "coral-ui/components";
import ApproveButton from "./ApproveButton";
@@ -27,7 +28,10 @@ interface Props {
username: string;
createdAt: string;
body: string;
inReplyTo: string | null;
inReplyTo?: {
id: string;
username: string | null;
} | null;
comment: PropTypesOf<typeof MarkersContainer>["comment"];
settings: PropTypesOf<typeof MarkersContainer>["settings"];
status: "approved" | "rejected" | "undecided";
@@ -43,7 +47,7 @@ interface Props {
onApprove: () => void;
onReject: () => void;
onFeature: () => void;
onUsernameClick: () => void;
onUsernameClick: (id?: string) => void;
mini?: boolean;
hideUsername?: boolean;
/**
@@ -52,6 +56,7 @@ interface Props {
* reflect that
*/
dangling?: boolean;
deleted?: boolean;
}
const ModerateCard: FunctionComponent<Props> = ({
@@ -79,115 +84,154 @@ const ModerateCard: FunctionComponent<Props> = ({
moderatedBy,
mini = false,
hideUsername = false,
}) => (
<Card
className={cn(
styles.root,
{ [styles.borderless]: mini },
{ [styles.dangling]: dangling }
)}
data-testid={`moderate-comment-${id}`}
>
<Flex>
<div className={styles.mainContainer}>
<div
className={cn(styles.topBar, {
[styles.topBarMini]: mini && !inReplyTo,
})}
>
<Flex alignItems="center">
{!hideUsername && (
<BaseButton onClick={onUsernameClick} className={styles.username}>
<Username>{username}</Username>
</BaseButton>
)}
<Timestamp>{createdAt}</Timestamp>
<FeatureButton featured={featured} onClick={onFeature} />
</Flex>
{inReplyTo && (
<div>
<InReplyTo>{inReplyTo}</InReplyTo>
</div>
)}
</div>
<CommentContent
suspectWords={suspectWords}
bannedWords={bannedWords}
className={styles.content}
>
{body}
</CommentContent>
<div className={styles.footer}>
<HorizontalGutter>
<div>
<Localized id="moderate-comment-viewContext">
<TextLink
className={styles.link}
href={viewContextHref}
target="_blank"
deleted = false,
}) => {
const commentBody = deleted ? (
<Localized id="moderate-comment-deleted-body">
<Typography>
This comment is no longer available. The commenter has deleted their
account.
</Typography>
</Localized>
) : (
body
);
const commentAuthorClick = useCallback(() => {
onUsernameClick();
}, [onUsernameClick]);
const commentParentAuthorClick = useCallback(() => {
if (inReplyTo) {
onUsernameClick(inReplyTo.id);
}
}, [onUsernameClick, inReplyTo]);
return (
<Card
className={cn(
styles.root,
{ [styles.borderless]: mini },
{ [styles.dangling]: dangling },
{ [styles.deleted]: deleted }
)}
data-testid={`moderate-comment-${id}`}
>
<Flex>
<div className={styles.mainContainer}>
<div
className={cn(styles.topBar, {
[styles.topBarMini]: mini && !inReplyTo,
})}
>
<Flex alignItems="center">
{!hideUsername && (
<BaseButton
onClick={commentAuthorClick}
className={styles.username}
>
View Context
</TextLink>
</Localized>
</div>
{showStory && (
<Username>{username}</Username>
</BaseButton>
)}
<Timestamp>{createdAt}</Timestamp>
<FeatureButton
featured={featured}
onClick={onFeature}
enabled={!deleted}
/>
</Flex>
{inReplyTo && inReplyTo.username && (
<div>
<Localized id="moderate-comment-story">
<span className={styles.story}>Story</span>
</Localized>
{": "}
<span className={styles.storyTitle}>{storyTitle}</span>{" "}
<Localized id="moderate-comment-moderateStory">
<BaseButton
onClick={commentParentAuthorClick}
className={styles.username}
>
<InReplyTo>{inReplyTo.username}</InReplyTo>
</BaseButton>
</div>
)}
</div>
<CommentContent
suspectWords={suspectWords}
bannedWords={bannedWords}
className={styles.content}
>
{commentBody}
</CommentContent>
<div className={styles.footer}>
<HorizontalGutter>
<div>
<Localized id="moderate-comment-viewContext">
<TextLink
className={styles.link}
href={storyHref}
onClick={onModerateStory}
href={viewContextHref}
target="_blank"
>
Moderate Story
View Context
</TextLink>
</Localized>
</div>
)}
<MarkersContainer comment={comment} settings={settings} />
</HorizontalGutter>
{showStory && (
<div>
<Localized id="moderate-comment-story">
<span className={styles.story}>Story</span>
</Localized>
{": "}
<span className={styles.storyTitle}>{storyTitle}</span>{" "}
<Localized id="moderate-comment-moderateStory">
<TextLink
className={styles.link}
href={storyHref}
onClick={onModerateStory}
>
Moderate Story
</TextLink>
</Localized>
</div>
)}
<MarkersContainer
onUsernameClick={onUsernameClick}
comment={comment}
settings={settings}
/>
</HorizontalGutter>
</div>
</div>
</div>
<div
className={cn(styles.separator, { [styles.ruledSeparator]: !mini })}
/>
<Flex
className={cn(styles.aside, {
[styles.asideWithoutReplyTo]: !inReplyTo,
[styles.asideMini]: mini && !inReplyTo,
[styles.asideMiniWithReplyTo]: mini && inReplyTo,
})}
alignItems="center"
direction="column"
itemGutter
>
{!mini && (
<Localized id="moderate-comment-decision">
<div className={styles.decision}>DECISION</div>
</Localized>
)}
<Flex itemGutter>
<RejectButton
onClick={onReject}
invert={status === "rejected"}
disabled={status === "rejected" || dangling}
className={cn({ [styles.miniButton]: mini })}
/>
<ApproveButton
onClick={onApprove}
invert={status === "approved"}
disabled={status === "approved" || dangling}
className={cn({ [styles.miniButton]: mini })}
/>
<div
className={cn(styles.separator, { [styles.ruledSeparator]: !mini })}
/>
<Flex
className={cn(styles.aside, {
[styles.asideWithoutReplyTo]: !inReplyTo,
[styles.asideMini]: mini && !inReplyTo,
[styles.asideMiniWithReplyTo]:
mini && inReplyTo && inReplyTo.username,
})}
alignItems="center"
direction="column"
itemGutter
>
{!mini && (
<Localized id="moderate-comment-decision">
<div className={styles.decision}>DECISION</div>
</Localized>
)}
<Flex itemGutter>
<RejectButton
onClick={onReject}
invert={status === "rejected"}
disabled={status === "rejected" || dangling || deleted}
className={cn({ [styles.miniButton]: mini })}
/>
<ApproveButton
onClick={onApprove}
invert={status === "approved"}
disabled={status === "approved" || dangling || deleted}
className={cn({ [styles.miniButton]: mini })}
/>
</Flex>
{moderatedBy}
</Flex>
{moderatedBy}
</Flex>
</Flex>
</Card>
);
</Card>
);
};
export default ModerateCard;
@@ -74,6 +74,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
onUsernameClicked: usernameClicked,
}) => {
const handleApprove = useCallback(() => {
if (!comment.revision) {
return;
}
approveComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
@@ -82,6 +86,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
}, [approveComment, comment, match]);
const handleReject = useCallback(() => {
if (!comment.revision) {
return;
}
rejectComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
@@ -90,6 +98,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
}, [rejectComment, comment, match]);
const handleFeature = useCallback(() => {
if (!comment.revision) {
return;
}
featureComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
@@ -114,12 +126,15 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
}
}, [comment]);
const onUsernameClicked = useCallback(() => {
if (!usernameClicked) {
return;
}
usernameClicked(comment.author!.id);
}, [usernameClicked, comment]);
const onUsernameClicked = useCallback(
(id?: string) => {
if (!usernameClicked) {
return;
}
usernameClicked(id || comment.author!.id);
},
[usernameClicked, comment]
);
const handleModerateStory = useCallback(
(e: React.MouseEvent) => {
@@ -136,10 +151,14 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
<FadeInTransition active={Boolean(comment.enteredLive)}>
<ModerateCard
id={comment.id}
username={comment.author!.username!}
username={
comment.author && comment.author.username
? comment.author.username
: ""
}
createdAt={comment.createdAt}
body={comment.body!}
inReplyTo={comment.parent && comment.parent.author!.username!}
inReplyTo={comment.parent && comment.parent.author}
comment={comment}
settings={settings}
dangling={danglingLogic(comment.status)}
@@ -153,7 +172,11 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
onFeature={onFeature}
onUsernameClick={onUsernameClicked}
moderatedBy={
<ModeratedByContainer viewer={viewer} comment={comment} />
<ModeratedByContainer
onUsernameClicked={onUsernameClicked}
viewer={viewer}
comment={comment}
/>
}
showStory={showStoryInfo}
storyTitle={
@@ -165,6 +188,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
onModerateStory={handleModerateStory}
mini={mini}
hideUsername={hideUsername}
deleted={comment.deleted ? comment.deleted : false}
/>
</FadeInTransition>
</>
@@ -191,6 +215,7 @@ const enhanced = withFragmentContainer<Props>({
}
parent {
author {
id
username
}
}
@@ -202,6 +227,7 @@ const enhanced = withFragmentContainer<Props>({
}
permalink
enteredLive
deleted
...MarkersContainer_comment
...ModeratedByContainer_comment
}
@@ -1,24 +1,28 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import React, { useCallback } from "react";
import { graphql } from "react-relay";
import { ModeratedByContainer_comment } from "coral-admin/__generated__/ModeratedByContainer_comment.graphql";
import { ModeratedByContainer_viewer } from "coral-admin/__generated__/ModeratedByContainer_viewer.graphql";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { BaseButton } from "coral-ui/components";
import styles from "./ModeratedByContainer.css";
interface Props {
viewer: ModeratedByContainer_viewer;
comment: ModeratedByContainer_comment;
onUsernameClicked: (id?: string | null) => void;
}
const ModeratedByContainer: React.FunctionComponent<Props> = ({
comment,
viewer,
onUsernameClicked,
}) => {
let moderatedBy: React.ReactElement | null;
if (!comment.statusLiveUpdated || comment.statusHistory.edges.length === 0) {
let id: string | null = null;
if (comment.statusHistory.edges.length === 0) {
moderatedBy = null;
} else if (comment.statusHistory.edges[0].node.moderator === null) {
moderatedBy = (
@@ -28,19 +32,24 @@ const ModeratedByContainer: React.FunctionComponent<Props> = ({
moderatedBy = null;
} else {
moderatedBy = <>{comment.statusHistory.edges[0].node.moderator.username}</>;
id = comment.statusHistory.edges[0].node.moderator.id;
}
if (!moderatedBy) {
return null;
}
const onClick = useCallback(() => {
onUsernameClicked(id);
}, [onUsernameClicked, comment]);
return (
<div>
<BaseButton onClick={onClick}>
<Localized id="moderate-comment-moderatedBy">
<div className={styles.moderatedBy}>Moderated By</div>
</Localized>
<div className={styles.moderatedByUsername}>{moderatedBy}</div>
</div>
</BaseButton>
);
};
@@ -1,7 +1,7 @@
.root {
border: 1px solid var(--palette-error-main);
box-sizing: border-box;
border-radius: 2px;
border-radius: var(--round-corners);
width: 65px;
height: 50px;
display: flex;
@@ -12,10 +12,10 @@ exports[`renders all markers 1`] = `
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 1,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
"COMMENT_DETECTED_SPAM": 1,
"COMMENT_DETECTED_SUSPECT_WORD": 1,
"COMMENT_DETECTED_TOXIC": 1,
"COMMENT_DETECTED_TRUST": 1,
"COMMENT_REPORTED_OFFENSIVE": 2,
"COMMENT_REPORTED_SPAM": 3,
},
@@ -30,6 +30,7 @@ exports[`renders all markers 1`] = `
"status": "PREMOD",
}
}
onUsernameClick={[Function]}
settings={
Object {
"integrations": Object {
@@ -98,12 +99,12 @@ exports[`renders all markers 1`] = `
</withPropsOnChange(Marker)>
</Localized>
<Localized
id="moderate-marker-karma"
id="moderate-marker-recentHistory"
>
<withPropsOnChange(Marker)
color="error"
>
Karma
Recent History
</withPropsOnChange(Marker)>
</Localized>
<withPropsOnChange(Marker)
@@ -151,10 +152,10 @@ exports[`renders some markers 1`] = `
"reasons": Object {
"COMMENT_DETECTED_BANNED_WORD": 1,
"COMMENT_DETECTED_LINKS": 0,
"COMMENT_DETECTED_RECENT_HISTORY": 1,
"COMMENT_DETECTED_SPAM": 0,
"COMMENT_DETECTED_SUSPECT_WORD": 0,
"COMMENT_DETECTED_TOXIC": 1,
"COMMENT_DETECTED_TRUST": 1,
"COMMENT_REPORTED_OFFENSIVE": 2,
"COMMENT_REPORTED_SPAM": 0,
},
@@ -169,6 +170,7 @@ exports[`renders some markers 1`] = `
"status": "PREMOD",
}
}
onUsernameClick={[Function]}
settings={
Object {
"integrations": Object {
@@ -209,12 +211,12 @@ exports[`renders some markers 1`] = `
</withPropsOnChange(Marker)>
</Localized>
<Localized
id="moderate-marker-karma"
id="moderate-marker-recentHistory"
>
<withPropsOnChange(Marker)
color="error"
>
Karma
Recent History
</withPropsOnChange(Marker)>
</Localized>
<withPropsOnChange(Marker)
@@ -27,6 +27,7 @@ exports[`renders approved correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -66,6 +67,7 @@ exports[`renders approved correctly 1`] = `
</div>
<Relay(MarkersContainer)
comment={Object {}}
onUsernameClick={[Function]}
settings={Object {}}
/>
</ForwardRef(forwardRef)>
@@ -94,6 +96,7 @@ exports[`renders approved correctly 1`] = `
>
<RejectButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
@@ -136,6 +139,7 @@ exports[`renders correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -175,6 +179,7 @@ exports[`renders correctly 1`] = `
</div>
<Relay(MarkersContainer)
comment={Object {}}
onUsernameClick={[Function]}
settings={Object {}}
/>
</ForwardRef(forwardRef)>
@@ -203,11 +208,13 @@ exports[`renders correctly 1`] = `
>
<RejectButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
@@ -244,6 +251,7 @@ exports[`renders dangling correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -283,6 +291,7 @@ exports[`renders dangling correctly 1`] = `
</div>
<Relay(MarkersContainer)
comment={Object {}}
onUsernameClick={[Function]}
settings={Object {}}
/>
</ForwardRef(forwardRef)>
@@ -354,6 +363,7 @@ exports[`renders rejected correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -393,6 +403,7 @@ exports[`renders rejected correctly 1`] = `
</div>
<Relay(MarkersContainer)
comment={Object {}}
onUsernameClick={[Function]}
settings={Object {}}
/>
</ForwardRef(forwardRef)>
@@ -427,6 +438,7 @@ exports[`renders rejected correctly 1`] = `
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
@@ -463,14 +475,20 @@ exports[`renders reply correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
</ForwardRef(forwardRef)>
<div>
<InReplyTo>
Julian
</InReplyTo>
<ForwardRef(forwardRef)
className="ModerateCard-username"
onClick={[Function]}
>
<InReplyTo>
Julian
</InReplyTo>
</ForwardRef(forwardRef)>
</div>
</div>
<CommentContent
@@ -507,6 +525,7 @@ exports[`renders reply correctly 1`] = `
</div>
<Relay(MarkersContainer)
comment={Object {}}
onUsernameClick={[Function]}
settings={Object {}}
/>
</ForwardRef(forwardRef)>
@@ -535,11 +554,13 @@ exports[`renders reply correctly 1`] = `
>
<RejectButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
@@ -576,6 +597,7 @@ exports[`renders story info 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -644,6 +666,7 @@ exports[`renders story info 1`] = `
</div>
<Relay(MarkersContainer)
comment={Object {}}
onUsernameClick={[Function]}
settings={Object {}}
/>
</ForwardRef(forwardRef)>
@@ -672,11 +695,131 @@ exports[`renders story info 1`] = `
>
<RejectButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
`;
exports[`renders tombstoned when comment is deleted 1`] = `
<withPropsOnChange(Card)
className="ModerateCard-root ModerateCard-deleted"
data-testid="moderate-comment-comment-id"
>
<ForwardRef(forwardRef)>
<div
className="ModerateCard-mainContainer"
>
<div
className="ModerateCard-topBar"
>
<ForwardRef(forwardRef)
alignItems="center"
>
<ForwardRef(forwardRef)
className="ModerateCard-username"
onClick={[Function]}
>
<Username>
Theon
</Username>
</ForwardRef(forwardRef)>
<Timestamp>
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={false}
featured={false}
onClick={[Function]}
/>
</ForwardRef(forwardRef)>
</div>
<CommentContent
bannedWords={
Array [
"banned",
]
}
className="ModerateCard-content"
suspectWords={
Array [
"suspect",
]
}
>
<Localized
id="moderate-comment-deleted-body"
>
<ForwardRef(forwardRef)>
This comment is no longer available. The commenter has deleted their account.
</ForwardRef(forwardRef)>
</Localized>
</CommentContent>
<div
className="ModerateCard-footer"
>
<ForwardRef(forwardRef)>
<div>
<Localized
id="moderate-comment-viewContext"
>
<withPropsOnChange(TextLinkProps)
className="ModerateCard-link"
href="http://localhost/comment"
target="_blank"
>
View Context
</withPropsOnChange(TextLinkProps)>
</Localized>
</div>
<Relay(MarkersContainer)
comment={Object {}}
onUsernameClick={[Function]}
settings={Object {}}
/>
</ForwardRef(forwardRef)>
</div>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
/>
<ForwardRef(forwardRef)
alignItems="center"
className="ModerateCard-aside ModerateCard-asideWithoutReplyTo"
direction="column"
itemGutter={true}
>
<Localized
id="moderate-comment-decision"
>
<div
className="ModerateCard-decision"
>
DECISION
</div>
</Localized>
<ForwardRef(forwardRef)
itemGutter={true}
>
<RejectButton
className=""
disabled={true}
invert={false}
onClick={[Function]}
/>
<ApproveButton
className=""
disabled={true}
invert={false}
onClick={[Function]}
/>
@@ -0,0 +1,30 @@
import React, { FunctionComponent } from "react";
import BanAction, { BanActionProps } from "./BanAction";
import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction";
import UsernameChangeAction, {
UsernameChangeActionProps,
} from "./UsernameChangeAction";
export interface HistoryActionProps {
kind: "username" | "suspension" | "ban";
action: UsernameChangeActionProps | SuspensionActionProps | BanActionProps;
}
const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
kind,
action,
}) => {
switch (kind) {
case "username":
return <UsernameChangeAction {...action as UsernameChangeActionProps} />;
case "suspension":
return <SuspensionAction {...action as SuspensionActionProps} />;
case "ban":
return <BanAction {...action as BanActionProps} />;
default:
return null;
}
};
export default AccountHistoryAction;
@@ -0,0 +1,29 @@
.title {
text-transform: uppercase;
}
.subTitle {
color: var(--palette-grey-main);
font-size: calc(14rem / var(--rem-base));
}
.info {
display: inline-block;
}
.amount {
font-size: calc(24rem / var(--rem-base));
}
.amountLabel {
font-size: calc(14rem / var(--rem-base));
}
.triggered {
color: var(--palette-error-main);
}
.tooltip {
color: var(--palette-text-primary);
padding: 3px;
}
@@ -0,0 +1,116 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { reduceSeconds, UNIT } from "coral-common/helpers/i18n";
import {
Box,
Flex,
Tooltip,
TooltipButton,
Typography,
} from "coral-ui/components";
import styles from "./RecentHistory.css";
interface Props {
triggered: boolean;
timeFrame: number;
rejectionRate: number;
submitted: number;
}
const RecentHistory: FunctionComponent<Props> = ({
triggered,
timeFrame,
rejectionRate,
submitted,
}) => {
const { scaled, unit } = reduceSeconds(timeFrame, [UNIT.DAYS]);
return (
<Box mt={3}>
<Localized id="moderate-user-drawer-recent-history-title">
<Typography className={styles.title} variant="bodyCopyBold">
Recent comment history
</Typography>
</Localized>
<Localized
id="moderate-user-drawer-recent-history-calculated"
$unit={unit}
$value={scaled}
>
<Typography className={styles.subTitle} variant="bodyCopy">
Calculated over the last {scaled} {unit}
</Typography>
</Localized>
<Box mt={1}>
<Box className={styles.info} mr={4}>
<Typography
className={cn(styles.amount, {
[styles.triggered]: triggered,
})}
variant="bodyCopyBold"
>
{Math.round(rejectionRate * 100)}%
</Typography>
<Flex alignItems="center">
<Localized id="moderate-user-drawer-recent-history-rejected">
<Typography
className={cn(styles.amountLabel, {
[styles.triggered]: triggered,
})}
variant="bodyCopy"
container="span"
>
Rejected
</Typography>
</Localized>
<Tooltip
id="recentCommentHistory-rejectionPopover"
title={
<Localized id="moderate-user-drawer-recent-history-tooltip-title">
<span>How is this calculated?</span>
</Localized>
}
body={
<Localized id="moderate-user-drawer-recent-history-tooltip-body">
<span>
Rejected comments divided by the sum of rejected and
published comments, during the recent comment history time
frame.
</span>
</Localized>
}
button={({ toggleVisibility, ref }) => (
<Localized
id="moderate-user-drawer-recent-history-tooltip-button"
attrs={{ "aria-label": true }}
>
<TooltipButton
className={styles.tooltip}
aria-label="Toggle recent comment history tooltip"
toggleVisibility={toggleVisibility}
ref={ref}
/>
</Localized>
)}
/>
</Flex>
</Box>
<Box className={styles.info}>
<Typography className={styles.amount} variant="bodyCopyBold">
{submitted}
</Typography>
<Localized id="moderate-user-drawer-recent-history-tooltip-submitted">
<Typography className={styles.amountLabel} variant="bodyCopy">
Submitted
</Typography>
</Localized>
</Box>
</Box>
</Box>
);
};
export default RecentHistory;
@@ -0,0 +1,82 @@
import React, { FunctionComponent, useMemo } from "react";
import { RecentHistoryContainer_settings } from "coral-admin/__generated__/RecentHistoryContainer_settings.graphql";
import { RecentHistoryContainer_user } from "coral-admin/__generated__/RecentHistoryContainer_user.graphql";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLCOMMENT_STATUS } from "coral-framework/schema";
import RecentHistory from "./RecentHistory";
const PUBLISHED_STATUSES = [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.APPROVED];
interface Props {
user: RecentHistoryContainer_user;
settings: RecentHistoryContainer_settings;
}
const RecentHistoryContainer: FunctionComponent<Props> = ({
user,
settings,
}) => {
const submitted = useMemo(
() =>
Object.keys(user.recentCommentHistory.statuses).reduce(
(acc, key: keyof typeof user.recentCommentHistory.statuses) =>
user.recentCommentHistory.statuses[key] + acc,
0
),
[user]
);
const rejectionRate = useMemo(() => {
const published = PUBLISHED_STATUSES.reduce(
(acc, status) => user.recentCommentHistory.statuses[status] + acc,
0
);
const rejected =
user.recentCommentHistory.statuses[GQLCOMMENT_STATUS.REJECTED];
const total = published + rejected;
if (total === 0) {
return 0;
}
return rejected / total;
}, [user]);
const triggered =
settings.recentCommentHistory.enabled &&
rejectionRate >= settings.recentCommentHistory.triggerRejectionRate;
return (
<RecentHistory
triggered={triggered}
timeFrame={settings.recentCommentHistory.timeFrame}
rejectionRate={rejectionRate}
submitted={submitted}
/>
);
};
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment RecentHistoryContainer_user on User {
recentCommentHistory {
statuses {
NONE
APPROVED
REJECTED
PREMOD
SYSTEM_WITHHELD
}
}
}
`,
settings: graphql`
fragment RecentHistoryContainer_settings on Settings {
recentCommentHistory {
enabled
timeFrame
triggerRejectionRate
}
}
`,
})(RecentHistoryContainer);
export default enhanced;
@@ -1,7 +1,7 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { DURATION_UNIT } from "coral-framework/components";
import { reduceSeconds } from "coral-common/helpers/i18n";
interface From {
start: Date;
@@ -13,48 +13,23 @@ export interface SuspensionActionProps {
from: From;
}
const UNITS = [
DURATION_UNIT.WEEKS,
DURATION_UNIT.DAYS,
DURATION_UNIT.HOURS,
DURATION_UNIT.MINUTES,
DURATION_UNIT.SECONDS,
];
const UNIT_MAP = {
[DURATION_UNIT.SECONDS]: "second",
[DURATION_UNIT.MINUTES]: "minute",
[DURATION_UNIT.HOURS]: "hour",
[DURATION_UNIT.DAYS]: "day",
[DURATION_UNIT.WEEKS]: "week",
};
function reduceValue(value: number) {
// Find the largest match for the smallest number.
const unit: keyof typeof UNIT_MAP =
UNITS.find(compare => value >= compare) || DURATION_UNIT.SECONDS;
return {
value: Math.round((value / unit) * 100) / 100,
unit: UNIT_MAP[unit],
};
}
const SuspensionAction: FunctionComponent<SuspensionActionProps> = ({
action,
from,
}) => {
if (action === "created") {
const seconds = (from.finish.getTime() - from.start.getTime()) / 1000;
const { value, unit } = reduceValue(seconds);
const { scaled, unit } = reduceSeconds(seconds);
return (
<Localized
id="moderate-user-drawer-suspension"
$unit={unit}
$value={value}
$value={scaled}
>
<span>Suspension, {seconds} seconds</span>
<span>
Suspension, {scaled} {unit}
</span>
</Localized>
);
} else if (action === "removed") {
@@ -0,0 +1,41 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { UserBadgesContainer_user as UserData } from "coral-admin/__generated__/UserBadgesContainer_user.graphql";
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
import CLASSES from "coral-stream/classes";
import { Tag } from "coral-ui/components";
interface Props {
user: UserData;
}
const UserBadgesContainer: FunctionComponent<Props> = ({ user }) => {
if (!user.badges) {
return null;
}
return (
<>
{user.badges.map(badge => (
<Tag
key={badge}
color="dark"
className={CLASSES.comment.topBar.userBadge}
>
{badge}
</Tag>
))}
</>
);
};
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserBadgesContainer_user on User {
badges
}
`,
})(UserBadgesContainer);
export default enhanced;
@@ -16,8 +16,9 @@ import {
TableRow,
} from "coral-ui/components";
import BanAction, { BanActionProps } from "./BanAction";
import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction";
import AccountHistoryAction, {
HistoryActionProps,
} from "./AccountHistoryAction";
import styles from "./UserDrawerAccountHistory.css";
@@ -30,21 +31,10 @@ interface From {
finish: any;
}
interface SuspensionHistoryRecord {
kind: "suspension";
action: SuspensionActionProps;
type HistoryRecord = HistoryActionProps & {
date: Date;
takenBy: React.ReactNode;
}
interface BanHistoryRecord {
kind: "ban";
action: BanActionProps;
date: Date;
takenBy: React.ReactNode;
}
type HistoryRecord = SuspensionHistoryRecord | BanHistoryRecord;
};
const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
const system = (
@@ -117,6 +107,20 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
});
});
user.status.username.history.forEach((record, i) => {
history.push({
kind: "username",
action: {
username: record.username,
// grab username at previous index to show what username was changed from
prevUsername:
i >= 1 ? user.status.username.history[i - 1].username : null,
},
date: new Date(record.createdAt),
takenBy: record.createdBy ? record.createdBy.username : system,
});
});
// Sort the history so that it's in the right order.
return history.sort((a, b) => b.date.getTime() - a.date.getTime());
}, [user]);
@@ -153,11 +157,7 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
{formatter.format(history.date)}
</TableCell>
<TableCell className={styles.action}>
{history.kind === "suspension" ? (
<SuspensionAction {...history.action} />
) : (
<BanAction {...history.action} />
)}
<AccountHistoryAction {...history} />
</TableCell>
<TableCell className={styles.user}>{history.takenBy}</TableCell>
</TableRow>
@@ -172,6 +172,15 @@ const enhanced = withFragmentContainer<any>({
user: graphql`
fragment UserDrawerAccountHistory_user on User {
status {
username {
history {
username
createdAt
createdBy {
username
}
}
}
ban {
history {
active
@@ -0,0 +1,14 @@
.root {
position: fixed;
top: 0;
right: 0;
width: 624px;
height: 100%;
display: flex;
flex-flow: column nowrap;
flex-direction: column;
background-color: var(--palette-common-white);
}
@@ -0,0 +1,38 @@
import React, { FunctionComponent } from "react";
import { Card, Modal } from "coral-ui/components";
import UserHistoryDrawerQuery from "./UserHistoryDrawerQuery";
import styles from "./UserHistoryDrawer.css";
interface UserHistoryDrawerProps {
open: boolean;
onClose: () => void;
userID?: string;
}
const UserHistoryDrawer: FunctionComponent<UserHistoryDrawerProps> = ({
open,
onClose,
userID,
}) => {
return (
<Modal open={open} onClose={onClose}>
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.root}>
{userID && (
<UserHistoryDrawerQuery
userID={userID}
onClose={onClose}
firstFocusableRef={firstFocusableRef}
lastFocusableRef={lastFocusableRef}
/>
)}
</Card>
)}
</Modal>
);
};
export default UserHistoryDrawer;
@@ -9,7 +9,7 @@
border-width: 1px;
border-style: solid;
border-color: var(--palette-grey-lighter);
border-radius: 2px;
border-radius: var(--round-corners);
margin-top: var(--spacing-1);
}
@@ -1,14 +1,97 @@
.root {
position: fixed;
top:0;
right: 0;
.comments {
flex: auto;
overflow: hidden;
}
width: 624px;
height: 100%;
.close {
position: absolute;
width: 40px;
height: 40px;
left: -40px;
display: flex;
flex-flow: column nowrap;
flex-direction: column;
padding: 0px;
background-color: var(--palette-common-white);
}
border-width: 2px;
border-right-width: 0px;
border-style: solid;
border-color: var(--palette-grey-main);
border-radius: 4px 0px 0px 4px;
}
.username {
font-family: var(--font-family-serif);
font-size: calc(24rem / var(--rem-base));
font-weight: 500;
font-family: var(--font-family-serif);
line-height: calc(36em / 24);
letter-spacing: calc(0.2em / 24);
margin-bottom: var(--spacing-1);
}
.userDetail {
margin-bottom: var(--spacing-1);
}
.userDetailValue {
margin-right: var(--spacing-2);
}
.icon {
margin-right: var(--spacing-2);
}
.copy {
border: 1px solid var(--palette-primary-main);
background-color: transparent;
color: var(--palette-primary-main);
padding-top: 2px;
padding-bottom: 2px;
&:active {
background-color: var(--palette-primary-lightest);
border-color: var(--palette-primary-main);
color: var(--palette-primary-main);
}
}
hr {
border: 0;
border-bottom: 1px solid var(--palette-divider);
padding-top: 1px;
width: 100%;
}
.divider {
border-bottom: 1px solid var(--palette-grey-lighter);
}
.userStatus {
margin-top: var(--spacing-1);
margin-bottom: var(--spacing-1);
}
.userStatusLabel {
display: inline-block;
margin-right: var(--spacing-1);
font-family: var(--font-family-sans-serif);
font-style: normal;
font-weight: 600;
font-size: 16px;
line-height: 14px;
}
.userStatusChange {
display: inline-block;
border-style: solid;
border-color: var(--palette-grey-lighter);
border-width: 1px;
border-radius: var(--round-corners);
padding-left: var(--spacing-2);
padding-right: var(--spacing-2);
min-height: 21px;
}
@@ -1,35 +1,158 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { Card, Modal } from "coral-ui/components";
import { UserHistoryDrawerContainer_settings } from "coral-admin/__generated__/UserHistoryDrawerContainer_settings.graphql";
import { UserHistoryDrawerContainer_user } from "coral-admin/__generated__/UserHistoryDrawerContainer_user.graphql";
import { UserStatusChangeContainer } from "coral-admin/components/UserStatus";
import { CopyButton } from "coral-framework/components";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Button, Flex, Icon, Typography } from "coral-ui/components";
import RecentHistoryContainer from "./RecentHistoryContainer";
import Tabs from "./Tabs";
import UserBadgesContainer from "./UserBadgesContainer";
import UserStatusDetailsContainer from "./UserStatusDetailsContainer";
import styles from "./UserHistoryDrawerContainer.css";
import UserHistoryDrawerQuery from "./UserHistoryDrawerQuery";
interface UserHistoryDrawerContainerProps {
open: boolean;
interface Props {
user: UserHistoryDrawerContainer_user;
settings: UserHistoryDrawerContainer_settings;
onClose: () => void;
userID?: string;
}
const UserHistoryDrawerContainer: FunctionComponent<
UserHistoryDrawerContainerProps
> = ({ open, onClose, userID }) => {
const UserHistoryDrawerContainer: FunctionComponent<Props> = ({
settings,
user,
onClose,
}) => {
const { locales } = useCoralContext();
const formatter = new Intl.DateTimeFormat(locales, {
month: "long",
day: "numeric",
year: "numeric",
});
return (
<Modal open={open} onClose={onClose}>
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.root}>
{userID && (
<UserHistoryDrawerQuery
userID={userID}
onClose={onClose}
firstFocusableRef={firstFocusableRef}
lastFocusableRef={lastFocusableRef}
/>
)}
</Card>
)}
</Modal>
<>
<Button className={styles.close} onClick={onClose}>
<Icon size="md">close</Icon>
</Button>
<Flex className={styles.username} itemGutter>
<span>{user.username}</span>
<div>
<UserBadgesContainer user={user} />
</div>
</Flex>
<div className={styles.userStatus}>
<Flex alignItems="center" itemGutter="half">
<div className={styles.userStatusLabel}>
<Typography variant="bodyCopyBold" container="div">
<Flex alignItems="center" itemGutter="half">
<Localized id="moderate-user-drawer-status-label">
Status:
</Localized>
</Flex>
</Typography>
</div>
<div className={styles.userStatusChange}>
<UserStatusChangeContainer settings={settings} user={user} />
</div>
<UserStatusDetailsContainer user={user} />
</Flex>
</div>
<div>
<Flex alignItems="center" className={styles.userDetail}>
<Localized id="moderate-user-drawer-email" attrs={{ title: true }}>
<Icon size="sm" className={styles.icon} title="Email address">
mail_outline
</Icon>
</Localized>
<Typography
variant="bodyCopy"
container="span"
className={styles.userDetailValue}
>
{user.email}
</Typography>
<CopyButton
text={user.email!}
variant="regular"
className={styles.copy}
/>
</Flex>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-created-at"
attrs={{ title: true }}
>
<Icon
size="sm"
className={styles.icon}
title="Account creation date"
>
date_range
</Icon>
</Localized>
<Typography variant="bodyCopy" container="span">
{formatter.format(new Date(user.createdAt))}
</Typography>
</Flex>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-member-id"
attrs={{ title: true }}
>
<Icon size="sm" className={styles.icon} title="Member ID">
people_outline
</Icon>
</Localized>
<Typography
variant="bodyCopy"
container="span"
className={styles.userDetailValue}
>
{user.id}
</Typography>
<CopyButton
text={user.id}
variant="regular"
className={styles.copy}
/>
</Flex>
<RecentHistoryContainer user={user} settings={settings} />
</div>
<hr className={styles.divider} />
<div className={styles.comments}>
<Tabs userID={user.id} />
</div>
</>
);
};
export default UserHistoryDrawerContainer;
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserHistoryDrawerContainer_user on User {
...UserBadgesContainer_user
...UserStatusChangeContainer_user
...UserStatusDetailsContainer_user
...RecentHistoryContainer_user
id
username
email
createdAt
}
`,
settings: graphql`
fragment UserHistoryDrawerContainer_settings on Settings {
...RecentHistoryContainer_settings
...UserStatusChangeContainer_settings
organization {
name
}
}
`,
})(UserHistoryDrawerContainer);
export default enhanced;
@@ -1,121 +1,6 @@
.root {
position: fixed;
top:0;
right: 0;
width: 624px;
height: 100%;
display: flex;
flex-flow: column nowrap;
flex-direction: column;
background-color: var(--palette-common-white);
}
.comments {
flex: auto;
overflow: hidden;
}
.close {
position: absolute;
width: 40px;
height: 40px;
left: -40px;
padding: 0px;
background-color: var(--palette-common-white);
border-width: 2px;
border-right-width: 0px;
border-style: solid;
border-color: var(--palette-grey-main);
border-radius: 4px 0px 0px 4px;
}
.username {
font-family: var(--font-family-serif);
font-size: calc(24rem / var(--rem-base));
font-weight: 500;
font-family: var(--font-family-serif);
line-height: calc(36em / 24);
letter-spacing: calc(0.2em / 24);
margin-bottom: var(--spacing-1);
}
.userDetails {
margin-bottom: var(--spacing-3);
}
.userDetail {
margin-bottom: var(--spacing-1);
}
.userDetailValue {
margin-right: var(--spacing-2);
}
.icon {
margin-right: var(--spacing-2);
}
.copy {
border: 1px solid var(--palette-primary-main);
background-color: transparent;
color: var(--palette-primary-main);
padding-top: 2px;
padding-bottom: 2px;
&:active {
background-color: var(--palette-primary-lightest);
border-color: var(--palette-primary-main);
color: var(--palette-primary-main);
}
}
.callout {
width: 100%;
font-family: var(--font-family-sans-serif);
align-content: center;
text-align: center;
}
hr {
border: 0;
border-bottom: 1px solid var(--palette-divider);
padding-top: 1px;
width: 100%;
}
.divider {
border-bottom: 1px solid var(--palette-grey-lighter);
}
.userStatus {
margin-top: var(--spacing-1);
margin-bottom: var(--spacing-1);
}
.userStatusLabel {
display: inline-block;
margin-right: var(--spacing-1);
font-family: var(--font-family-sans-serif);
font-style: normal;
font-weight: 600;
font-size: 16px;
line-height: 14px;
}
.userStatusChange {
display: inline-block;
border-style: solid;
border-color: var(--palette-grey-lighter);
border-width: 1px;
border-radius: 2px;
padding-left: var(--spacing-2);
padding-right: var(--spacing-1);
}
@@ -3,20 +3,10 @@ import React, { FunctionComponent } from "react";
import { ReadyState } from "react-relay";
import { UserHistoryDrawerQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerQuery.graphql";
import { UserStatusChangeContainer } from "coral-admin/components/UserStatus";
import { CopyButton } from "coral-framework/components";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
import {
Button,
CallOut,
Flex,
Icon,
Spinner,
Typography,
} from "coral-ui/components";
import { CallOut, Spinner } from "coral-ui/components";
import Tabs from "./Tabs";
import UserHistoryDrawerContainer from "./UserHistoryDrawerContainer";
import styles from "./UserHistoryDrawerQuery.css";
@@ -31,29 +21,21 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
userID,
onClose,
}) => {
const { locales } = useCoralContext();
const formatter = new Intl.DateTimeFormat(locales, {
month: "long",
day: "numeric",
year: "numeric",
});
return (
<QueryRenderer<QueryTypes>
query={graphql`
query UserHistoryDrawerQuery($userID: ID!) {
user(id: $userID) {
id
username
email
createdAt
...UserStatusChangeContainer_user
...UserHistoryDrawerContainer_user
}
settings {
...UserHistoryDrawerContainer_settings
}
}
`}
variables={{ userID }}
cacheConfig={{ force: true }}
render={({ error, props }: ReadyState<QueryTypes["response"]>) => {
render={({ props }: ReadyState<QueryTypes["response"]>) => {
if (!props) {
return (
<div className={styles.root}>
@@ -64,9 +46,9 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
if (!props.user) {
return (
<div className={styles.callout}>
<div className={styles.root}>
<CallOut>
<Localized id="moderate-user-drawer-user-not-found ">
<Localized id="moderate-user-drawer-user-not-found">
User not found.
</Localized>
</CallOut>
@@ -74,90 +56,12 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
);
}
const user = props.user;
return (
<>
<Button className={styles.close} onClick={onClose}>
<Icon size="md">close</Icon>
</Button>
<Flex className={styles.username}>
<span>{user.username}</span>
</Flex>
<div className={styles.userStatus}>
<div className={styles.userStatusLabel}>Status:</div>
<div className={styles.userStatusChange}>
<UserStatusChangeContainer user={user} />
</div>
</div>
<div className={styles.userDetails}>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-email"
attrs={{ title: true }}
>
<Icon size="sm" className={styles.icon} title="Email address">
mail_outline
</Icon>
</Localized>
<Typography
variant="bodyCopy"
container="span"
className={styles.userDetailValue}
>
{user.email}
</Typography>
<CopyButton
text={user.email!}
variant="regular"
className={styles.copy}
/>
</Flex>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-created-at"
attrs={{ title: true }}
>
<Icon
size="sm"
className={styles.icon}
title="Account creation date"
>
date_range
</Icon>
</Localized>
<Typography variant="bodyCopy" container="span">
{formatter.format(new Date(user.createdAt))}
</Typography>
</Flex>
<Flex alignItems="center" className={styles.userDetail}>
<Localized
id="moderate-user-drawer-member-id"
attrs={{ title: true }}
>
<Icon size="sm" className={styles.icon} title="Member ID">
people_outline
</Icon>
</Localized>
<Typography
variant="bodyCopy"
container="span"
className={styles.userDetailValue}
>
{user.id}
</Typography>
<CopyButton
text={user.id}
variant="regular"
className={styles.copy}
/>
</Flex>
</div>
<hr className={styles.divider} />
<div className={styles.comments}>
<Tabs userID={user.id} />
</div>
</>
<UserHistoryDrawerContainer
onClose={onClose}
user={props.user}
settings={props.settings}
/>
);
}}
/>
@@ -9,7 +9,7 @@
border-width: 1px;
border-style: solid;
border-color: var(--palette-grey-lighter);
border-radius: 2px;
border-radius: var(--round-corners);
margin-top: var(--spacing-1);
}
@@ -0,0 +1,173 @@
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useMemo } from "react";
import { UserStatusDetailsContainer_user as UserData } from "coral-admin/__generated__/UserStatusDetailsContainer_user.graphql";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import {
BaseButton,
Box,
ClickOutside,
Icon,
Popover,
Typography,
} from "coral-ui/components";
interface Props {
user: UserData;
}
const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
if (!user.status.ban.active && !user.status.suspension.active) {
return null;
}
const activeBan = useMemo(() => {
return user.status.ban.history.find(item => item.active);
}, [user]);
const activeSuspension = useMemo(() => {
return user.status.suspension.history.find(item => item.active);
}, [user]);
const { locales } = useCoralContext();
const formatter = new Intl.DateTimeFormat(locales, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<div>
<Popover
placement="bottom"
id="userStatus-details-popover"
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Box p={2}>
{activeBan && (
<div>
<Localized
id="userDetails-banned-on"
$timestamp={formatter.format(new Date(activeBan.createdAt))}
strong={<strong />}
>
<Typography>
<strong>Banned on </strong>{" "}
{formatter.format(new Date(activeBan.createdAt))}
</Typography>
</Localized>
{activeBan.createdBy && (
<Localized
id="userDetails-banned-by"
strong={<strong />}
$username={activeBan.createdBy.username}
>
<Typography>
<strong>by </strong>
{activeBan.createdBy.username}
</Typography>
</Localized>
)}
</div>
)}
{activeSuspension && (
<div>
{activeSuspension.createdBy && (
<Localized
id="userDetails-suspended-by"
strong={<strong />}
$username={activeSuspension.createdBy.username}
>
<Typography>
<strong>Suspended by </strong>
{activeSuspension.createdBy.username}
</Typography>
</Localized>
)}
<Localized
id="userDetails-suspension-start"
strong={<strong />}
$timestamp={formatter.format(
new Date(activeSuspension.from.start)
)}
>
<Typography>
<strong>Start: </strong>
{formatter.format(new Date(activeSuspension.from.start))}
</Typography>
</Localized>
<Localized
strong={<strong />}
$timestamp={formatter.format(
new Date(activeSuspension.from.finish)
)}
id="userDetails-suspension-finish"
>
<Typography>
<strong>End: </strong>
{formatter.format(new Date(activeSuspension.from.finish))}
</Typography>
</Localized>
</div>
)}
</Box>
</ClickOutside>
)}
>
{({ toggleVisibility, ref }) => (
<BaseButton
onClick={evt => {
evt.stopPropagation();
toggleVisibility();
}}
aria-label="View user status details"
ref={ref}
>
<Icon size="md" color="inherit">
info
</Icon>
</BaseButton>
)}
</Popover>
</div>
);
};
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserStatusDetailsContainer_user on User {
status {
ban {
active
history {
active
createdAt
createdBy {
username
}
}
}
suspension {
until
active
history {
active
from {
start
finish
}
createdBy {
username
}
}
}
}
}
`,
})(UserStatusDetailsContainer);
export default enhanced;
@@ -0,0 +1,7 @@
.tableLight {
font-weight: var(--font-weight-regular);
}
.usernameCell {
line-height: calc(18em / 14);
}
@@ -0,0 +1,38 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import styles from "./UsernameChangeAction.css";
export interface UsernameChangeActionProps {
username: string;
prevUsername: string | null;
}
const SuspensionAction: FunctionComponent<UsernameChangeActionProps> = ({
username,
prevUsername,
}) => {
return (
<div className={styles.usernameCell}>
<Localized id="moderate-user-drawer-username-change">
<div>Username change</div>
</Localized>
<div>
<Localized id="moderate-user-drawer-username-change-new">
<span className={styles.tableLight}>New: </span>
</Localized>{" "}
{username}
</div>
{prevUsername && (
<div>
<Localized id="moderate-user-drawer-username-change-old">
<span className={styles.tableLight}>Old: </span>
</Localized>{" "}
{prevUsername}
</div>
)}
</div>
);
};
export default SuspensionAction;
@@ -0,0 +1 @@
export { default } from "./UserHistoryDrawer";
@@ -1,3 +1,10 @@
.card {
max-width: 500px;
}
.textArea {
width: 100%;
box-sizing: border-box;
height: calc(12 * var(--mini-unit));
padding: calc(0.5 * var(--mini-unit));
}
@@ -1,16 +1,19 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
import {
Button,
Card,
CardCloseButton,
CheckBox,
Flex,
HorizontalGutter,
Modal,
Typography,
} from "coral-ui/components";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { Field, Form } from "react-final-form";
import NotAvailable from "coral-admin/components/NotAvailable";
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
import styles from "./BanModal.css";
@@ -18,7 +21,8 @@ interface Props {
username: string | null;
open: boolean;
onClose: () => void;
onConfirm: () => void;
onConfirm: (message?: string) => void;
getMessage: GetMessage;
}
const BanModal: FunctionComponent<Props> = ({
@@ -26,51 +30,107 @@ const BanModal: FunctionComponent<Props> = ({
onClose,
onConfirm,
username,
}) => (
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.card}>
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
<HorizontalGutter size="double">
<HorizontalGutter>
<Localized
id="community-banModal-areYouSure"
strong={<strong />}
$username={username || <NotAvailable />}
>
<Typography variant="header2" id="banModal-title">
Are you sure you want to ban{" "}
<strong>{username || <NotAvailable />}</strong>?
</Typography>
</Localized>
<Localized id="community-banModal-consequence">
<Typography>
Once banned, this user will no longer be able to comment, use
reactions, or report comments.
</Typography>
</Localized>
</HorizontalGutter>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-banModal-cancel">
<Button variant="outlined" onClick={onClose}>
Cancel
</Button>
</Localized>
<Localized id="community-banModal-banUser">
<Button
variant="filled"
color="primary"
onClick={onConfirm}
ref={lastFocusableRef}
>
Ban User
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</Card>
)}
</Modal>
);
getMessage,
}) => {
const getDefaultMessage = useMemo((): string => {
return getMessage(
"common-banEmailTemplate",
"Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments",
{
username,
}
);
}, [getMessage, username]);
export default BanModal;
const onFormSubmit = useCallback(
({ emailMessage }) => {
onConfirm(emailMessage);
},
[onConfirm]
);
return (
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.card}>
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
<HorizontalGutter size="double">
<HorizontalGutter>
<Localized
id="community-banModal-areYouSure"
strong={<strong />}
$username={username || <NotAvailable />}
>
<Typography variant="header2" id="banModal-title">
Are you sure you want to ban{" "}
<strong>{username || <NotAvailable />}</strong>?
</Typography>
</Localized>
<Localized id="community-banModal-consequence">
<Typography>
Once banned, this user will no longer be able to comment, use
reactions, or report comments.
</Typography>
</Localized>
</HorizontalGutter>
<Form
onSubmit={onFormSubmit}
initialValues={{
showMessage: false,
emailMessage: getDefaultMessage,
}}
>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<Field name="showMessage">
{({ input }) => (
<Localized id="community-banModal-customize">
<CheckBox {...input} id="banModal-showMessage">
Customize ban email message
</CheckBox>
</Localized>
)}
</Field>
<Field name="showMessage" subscription={{ value: true }}>
{({ input: { value } }) =>
value ? (
<Field
className={styles.textArea}
id="banModal-message"
component="textarea"
name="emailMessage"
/>
) : null
}
</Field>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-banModal-cancel">
<Button variant="outlined" onClick={onClose}>
Cancel
</Button>
</Localized>
<Localized id="community-banModal-banUser">
<Button
variant="filled"
color="primary"
type="submit"
ref={lastFocusableRef}
>
Ban User
</Button>
</Localized>
</Flex>
</form>
)}
</Form>
</HorizontalGutter>
</Card>
)}
</Modal>
);
};
const enhanced = withGetMessage(BanModal);
export default enhanced;
@@ -2,6 +2,7 @@ import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { BanUserMutation as MutationTypes } from "coral-admin/__generated__/BanUserMutation.graphql";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
@@ -15,6 +16,7 @@ let clientMutationId = 0;
const BanUserMutation = createMutation(
"banUser",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const viewer = getViewer(environment)!;
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation BanUserMutation($input: BanUserInput!) {
@@ -25,6 +27,14 @@ const BanUserMutation = createMutation(
current
ban {
active
history {
active
createdAt
createdBy {
id
username
}
}
}
}
}
@@ -49,6 +59,16 @@ const BanUserMutation = createMutation(
)!.status.current.concat(GQLUSER_STATUS.BANNED),
ban: {
active: true,
history: [
{
active: true,
createdAt: new Date(),
createdBy: {
id: viewer.id,
username: viewer.username,
},
},
],
},
},
},
@@ -25,6 +25,13 @@ const RemoveUserBanMutation = createMutation(
current
ban {
active
history {
active
createdAt
createdBy {
username
}
}
}
}
}
@@ -49,6 +56,7 @@ const RemoveUserBanMutation = createMutation(
)!.status.current.filter(s => s !== GQLUSER_STATUS.BANNED),
ban: {
active: false,
history: [],
},
},
},
@@ -0,0 +1,93 @@
import { pick } from "lodash";
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { RemoveUserSuspensionMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserSuspensionMutation.graphql";
import { DeepWritable } from "coral-common/types";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
let clientMutationId = 0;
const RemoveUserSuspensionMutation = createMutation(
"removeUserSuspension",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const user = lookup<GQLUser>(environment, input.userID)!;
let newHistory: DeepWritable<
MutationTypes["response"]["removeUserSuspension"]["user"]["status"]["suspension"]["history"]
> = [];
if (user.status.suspension.history) {
newHistory = user.status.suspension.history.map(h =>
pick(h, [
"active",
"from.start",
"from.finish",
"createdBy.id",
"createdBy.username",
])
) as any;
newHistory[newHistory.length - 1].active = false;
newHistory[newHistory.length - 1].from.finish = new Date().toISOString();
}
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RemoveUserSuspensionMutation(
$input: RemoveUserSuspensionInput!
) {
removeUserSuspension(input: $input) {
user {
id
status {
current
suspension {
active
history {
active
from {
start
finish
}
createdBy {
id
username
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
removeUserSuspension: {
user: {
id: input.userID,
status: {
current: user.status.current.concat(GQLUSER_STATUS.SUSPENDED),
suspension: {
active: false,
history: newHistory,
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default RemoveUserSuspensionMutation;
@@ -0,0 +1,203 @@
import { Mutator } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form } from "react-final-form";
import { ScaledUnit } from "coral-common/helpers/i18n";
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
import {
Button,
CheckBox,
Flex,
HorizontalGutter,
RadioButton,
} from "coral-ui/components";
import styles from "./SuspendModal.css";
interface Props {
username: string | null;
onCancel: () => void;
getMessage: GetMessage;
organizationName: string;
onSubmit: (duration: ScaledUnit, message: string) => void;
}
const DURATIONS: ScaledUnit[] = [
{ original: 3600, value: "3600", unit: "hour", scaled: 1 }, // 1 hour
{ original: 10800, value: "10800", unit: "hour", scaled: 3 }, // 3 hours
{ original: 86400, value: "86400", unit: "hour", scaled: 24 }, // 24 hours
{ original: 604800, value: "604800", unit: "day", scaled: 7 }, // 7 days
];
const DEFAULT_DURATION = DURATIONS[0]; // 1 hour
const SuspendForm: FunctionComponent<Props> = ({
onCancel,
username,
getMessage,
onSubmit,
organizationName,
}) => {
const getMessageWithDuration = useCallback(
({ scaled, unit }: Pick<ScaledUnit, "scaled" | "unit">): string => {
return getMessage(
"community-suspendModal-emailTemplate",
`your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation in ${scaled} ${unit}`,
{
username,
organizationName,
value: scaled,
unit,
}
);
},
[username, organizationName]
);
const onFormSubmit = useCallback(
({ duration, emailMessage }) => {
const unit = DURATIONS.find(d => d.value === duration);
onSubmit(unit!, emailMessage);
},
[onSubmit]
);
const setMessageValue: Mutator = useCallback(
([name, newValue], state, { changeValue }) => {
if (state.lastFormState) {
const { duration, emailMessage } = state.lastFormState.values;
const unit = DURATIONS.find(d => d.value === duration);
const expectedEmailMessage = getMessageWithDuration(unit!);
if (expectedEmailMessage === emailMessage) {
changeValue(state, name, () => newValue);
}
}
},
[getMessageWithDuration]
);
const resetMessageValue: Mutator = (
[name, checked],
state,
{ changeValue }
) => {
if (state.lastFormState && !checked) {
const { duration, emailMessage } = state.lastFormState.values;
const unit = DURATIONS.find(d => d.value === duration);
const expectedEmailMessage = getMessageWithDuration(unit!);
if (expectedEmailMessage !== emailMessage) {
changeValue(state, name, () => expectedEmailMessage);
}
}
};
return (
<>
<Form
onSubmit={onFormSubmit}
mutators={{
setMessageValue,
resetMessageValue,
}}
initialValues={{
duration: DEFAULT_DURATION.value,
emailMessage: getMessageWithDuration(DEFAULT_DURATION),
}}
>
{({ handleSubmit, form }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={2}>
<div>
{DURATIONS.map(({ original, value, scaled, unit }) => (
<Field
key={value}
name="duration"
type="radio"
component="input"
value={value}
>
{({ input }) => (
<Localized
id="framework-timeago-time"
$value={scaled}
$unit={unit}
>
<RadioButton
id={`duration-${value}`}
{...input}
onChange={event => {
form.mutators.setMessageValue(
"emailMessage",
getMessageWithDuration({ scaled, unit })
);
input.onChange(event);
}}
>
<span>
{scaled} {unit}
</span>
</RadioButton>
</Localized>
)}
</Field>
))}
</div>
<Field type="checkbox" name="editMessage">
{({ input }) => (
<Localized id="community-suspendModal-customize">
<CheckBox
id="suspendModal-editMessage"
{...input}
onChange={event => {
form.mutators.resetMessageValue(
"emailMessage",
!input.checked
);
input.onChange(event);
}}
>
Customize suspension email message
</CheckBox>
</Localized>
)}
</Field>
<Field name="editMessage" subscription={{ value: true }}>
{({ input: { value } }) =>
value ? (
<Field
className={styles.textArea}
id="suspendModal-message"
component="textarea"
name="emailMessage"
/>
) : null
}
</Field>
</HorizontalGutter>
<Flex
className={styles.footer}
justifyContent="flex-end"
itemGutter="half"
>
<Localized id="community-suspendModal-cancel">
<Button variant="outlined" onClick={onCancel}>
Cancel
</Button>
</Localized>
<Localized id="community-suspendModal-suspendUser">
<Button variant="filled" color="primary" type="submit">
Suspend User
</Button>
</Localized>
</Flex>
</form>
)}
</Form>
</>
);
};
const enhanced = withGetMessage(SuspendForm);
export default enhanced;
@@ -0,0 +1,22 @@
.card {
max-width: 500px;
}
.radioButton {
margin: 0 var(--spacing-1) 0 0 !important;
}
.textArea {
width: 100%;
box-sizing: border-box;
height: calc(12 * var(--mini-unit));
padding: calc(0.5 * var(--mini-unit));
}
.header {
margin: 0 0 var(--spacing-3) 0 !important;
}
.footer {
margin-top: var(--spacing-3);
}
@@ -0,0 +1,130 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback, useState } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
import { ScaledUnit } from "coral-common/helpers/i18n";
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
import {
Button,
Card,
CardCloseButton,
Flex,
HorizontalGutter,
Modal,
Typography,
} from "coral-ui/components";
import SuspendForm from "./SuspendForm";
import styles from "./SuspendModal.css";
interface Props {
username: string | null;
getMessage: GetMessage;
open: boolean;
onClose: () => void;
onConfirm: (timeout: number, message: string) => void;
organizationName: string;
success: boolean;
}
const SuspendModal: FunctionComponent<Props> = ({
open,
onClose,
onConfirm,
getMessage,
username,
success,
organizationName,
}) => {
const [successDuration, setSuccessDuration] = useState("");
const onFormSubmit = useCallback(
({ original, scaled, unit }: ScaledUnit, message: string) => {
setSuccessDuration(
getMessage("framework-timeago-time", `${scaled} ${unit}`, {
value: scaled,
unit,
})
);
onConfirm(original, message);
},
[onConfirm]
);
return (
<>
<Modal open={open} onClose={onClose} aria-labelledby="suspendModal-title">
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.card}>
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
{success && (
<HorizontalGutter spacing={2}>
<HorizontalGutter>
<Localized
id="community-suspendModal-success"
$username={username}
strong={<strong />}
$duration={successDuration}
>
<Typography>
<strong>{username}</strong> has been suspended for{" "}
<strong>{successDuration}</strong>
</Typography>
</Localized>
</HorizontalGutter>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-suspendModal-success-close">
<Button variant="filled" color="primary" onClick={onClose}>
Ok
</Button>
</Localized>
</Flex>
</HorizontalGutter>
)}
{!success && (
<HorizontalGutter spacing={2}>
<Localized
id="community-suspendModal-areYouSure"
strong={<strong />}
$username={username || <NotAvailable />}
>
<Typography
className={styles.header}
variant="header2"
id="suspendModal-title"
>
Suspend <strong>{username || <NotAvailable />}</strong>?
</Typography>
</Localized>
<Localized id="community-suspendModal-consequence">
<Typography>
While suspended, this user will no longer be able to
comment, use reactions, or report comments.
</Typography>
</Localized>
<Localized id="community-suspendModal-selectDuration">
<Typography variant="header3">
Select suspension length
</Typography>
</Localized>
<SuspendForm
username={username}
onCancel={onClose}
organizationName={organizationName}
onSubmit={onFormSubmit}
/>
</HorizontalGutter>
)}
</Card>
)}
</Modal>
</>
);
};
const enhanced = withGetMessage(SuspendModal);
export default enhanced;
@@ -0,0 +1,93 @@
import { DateTime } from "luxon";
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { SuspendUserMutation as MutationTypes } from "coral-admin/__generated__/SuspendUserMutation.graphql";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
let clientMutationId = 0;
const SuspendUserMutation = createMutation(
"suspendUser",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const viewer = getViewer(environment)!;
const now = new Date();
const finish = DateTime.fromJSDate(now).plus({
seconds: input.timeout,
});
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation SuspendUserMutation($input: SuspendUserInput!) {
suspendUser(input: $input) {
user {
id
status {
current
suspension {
active
history {
active
from {
start
finish
}
createdBy {
id
username
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
suspendUser: {
user: {
id: input.userID,
status: {
current: lookup<GQLUser>(
environment,
input.userID
)!.status.current.concat(GQLUSER_STATUS.SUSPENDED),
suspension: {
active: true,
history: [
{
active: true,
from: {
start: now.toISOString(),
finish: finish.toISODate(),
},
createdBy: {
id: viewer.id,
username: viewer.username,
},
},
],
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default SuspendUserMutation;
@@ -1,5 +1,6 @@
import React, { FunctionComponent, useCallback, useState } from "react";
import { UserStatusChangeContainer_settings as SettingsData } from "coral-admin/__generated__/UserStatusChangeContainer_settings.graphql";
import { UserStatusChangeContainer_user as UserData } from "coral-admin/__generated__/UserStatusChangeContainer_user.graphql";
import {
graphql,
@@ -12,21 +13,27 @@ import ButtonPadding from "../ButtonPadding";
import BanModal from "./BanModal";
import BanUserMutation from "./BanUserMutation";
import RemoveUserBanMutation from "./RemoveUserBanMutation";
import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation";
import SuspendModal from "./SuspendModal";
import SuspendUserMutation from "./SuspendUserMutation";
import UserStatusChange from "./UserStatusChange";
import UserStatusContainer from "./UserStatusContainer";
interface Props {
user: UserData;
fullWidth?: boolean;
settings: SettingsData;
}
const UserStatusChangeContainer: FunctionComponent<Props> = ({
user,
fullWidth = false,
}) => {
const UserStatusChangeContainer: FunctionComponent<Props> = props => {
const { user, settings, fullWidth } = props;
const banUser = useMutation(BanUserMutation);
const suspendUser = useMutation(SuspendUserMutation);
const removeUserBan = useMutation(RemoveUserBanMutation);
const removeUserSuspension = useMutation(RemoveUserSuspensionMutation);
const [showBanned, setShowBanned] = useState<boolean>(false);
const [showSuspend, setShowSuspend] = useState<boolean>(false);
const [showSuspendSuccess, setShowSuspendSuccess] = useState<boolean>(false);
const handleBan = useCallback(() => {
if (user.status.ban.active) {
return;
@@ -43,14 +50,43 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
if (user.status.suspension.active) {
return;
}
// TODO: (cvle)
}, [user]);
setShowSuspend(true);
}, [user, setShowSuspend]);
const handleRemoveSuspension = useCallback(() => {
if (!user.status.suspension.active) {
return;
}
// TODO: (cvle)
}, [user]);
removeUserSuspension({ userID: user.id });
}, [user, removeUserSuspension]);
const handleSuspendModalClose = useCallback(() => {
setShowSuspend(false);
setShowSuspendSuccess(false);
}, [setShowBanned, setShowSuspendSuccess]);
const handleBanModalClose = useCallback(() => {
setShowBanned(false);
}, [setShowBanned]);
const handleSuspendConfirm = useCallback(
(timeout, message) => {
suspendUser({
userID: user.id,
timeout,
message,
});
setShowSuspendSuccess(true);
},
[user, suspendUser, setShowSuspendSuccess]
);
const handleBanConfirm = useCallback(
message => {
banUser({ userID: user.id, message });
setShowBanned(false);
},
[user, setShowBanned]
);
if (user.role !== GQLUSER_ROLE.COMMENTER) {
return (
@@ -73,14 +109,19 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
>
<UserStatusContainer user={user} />
</UserStatusChange>
<SuspendModal
username={user.username}
open={showSuspend || showSuspendSuccess}
success={showSuspendSuccess}
onClose={handleSuspendModalClose}
organizationName={settings.organization.name}
onConfirm={handleSuspendConfirm}
/>
<BanModal
username={user.username}
open={showBanned}
onClose={() => setShowBanned(false)}
onConfirm={() => {
banUser({ userID: user.id });
setShowBanned(false);
}}
onClose={handleBanModalClose}
onConfirm={handleBanConfirm}
/>
</>
);
@@ -89,7 +130,6 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserStatusChangeContainer_user on User {
...UserStatusContainer_user
id
role
username
@@ -101,6 +141,14 @@ const enhanced = withFragmentContainer<Props>({
active
}
}
...UserStatusContainer_user
}
`,
settings: graphql`
fragment UserStatusChangeContainer_settings on Settings {
organization {
name
}
}
`,
})(UserStatusChangeContainer);
@@ -17,7 +17,7 @@ exports[`renders correctly 1`] = `
<div
className="AuthBox-brandIcon"
>
<withPropsOnChange(BrandIcon)
<withPropsOnChange(BrandMark)
size="lg"
/>
</div>

Some files were not shown because too many files have changed in this diff Show More