Compare commits

..
39 Commits
Author SHA1 Message Date
e85947af74 [CORL-1211] Upgrade RTE (#3106)
* chore: upgrade coral rte

* chore: upgrade

* chore: upgrade

* chore: upgrade

* fix: updated snapshots

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-14 16:17:13 +00:00
Tessa Thorntonandkodiakhq[bot] abfcdcc933 [CORL-1249] Add "warn" status (#3100)
* schema and backend for warning users

* allow warning and warning acknowledgement

* include warning actions in user history drawer

* fix fixture

* add copy to warn modal

* style warning on stream side

* localize strings

* update warning form

* fix fixtures

* copy updates

* clean up error messaging

* userefetch for users

* ensure user status is refreshed on warn error

* add details about warning to user status details

* update copy for unacknowledged warning

* fix merge conflict

* update copy

* remove unused code

* clean up warning css

* sort imports

* fix copy in comments

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-14 15:44:35 +00:00
Wyatt Johnsonandkodiakhq[bot] d001fcd456 [CORL-1291] Logging Improvements (#3103)
* feat: added settings audit

* fix: added tenantID to audit entry

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-14 15:30:32 +00:00
Tessa Thorntonandkodiakhq[bot] a49b0d6fa7 fix missing top border on comment box (#3102)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-13 20:53:37 +00:00
Tessa Thornton f47c8661da adjust line height for recent history (#3099) 2020-08-13 18:40:10 +02:00
Nick FunkandWyatt Johnson d67c3d2907 Mark and allow word list timed out comments (#3097)
Will mark the comment's metdata as a word list
time out if it happens and also tag them with
suspect or banned word.

Then we show "possible suspect/banned word"
markers on the moderate card so moderators can
manually review them.

CORL-1264

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
2020-08-11 22:05:50 +00:00
Wyatt Johnson 3398bd12c4 fix: export public flag, fix button (#3098) 2020-08-11 21:49:32 +00:00
Wyatt Johnsonandkodiakhq[bot] fc6de50dde fix: move deps (#3095)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-11 20:28:20 +00:00
Nick Funk 6c32316595 6.3.2 mobile and responsive UI fixes (#3096)
* Remove vertical padding and line height on badges

Allows them to align with the time and user name
text elements.

* Hide report button text on mobile screens

* Allow read more links to wrap text on mobile

* Wrap comment tags when on mobile screens

* Fix collapse alignment on mobile screens

* Update top bar responsive scaling

* Use MatchMedia inside ReportButton

* Fix justification on comment TopBarRight
2020-08-11 20:13:11 +00:00
bowser2010andkodiakhq[bot] 47abd030ef Adding new translation (#3093)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-10 21:13:22 +00:00
Nick Funkandkodiakhq[bot] 5ffb53d0eb Add media to comment data downloads (#3089)
This will update the admin and user CSV
downloads to include a "Media" column that
will specify the media type and the URL
for the media content.

CORL-1188

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-10 20:59:30 +00:00
Nick Funkandkodiakhq[bot] af842e98ee Move wordlist check after staff approval check (#3094)
This allows staff members to comment anything
they want, even if it's on the banned word list.

CORL-1255

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-10 20:42:47 +00:00
Kim Gardnerandkodiakhq[bot] 281f19d21c Bump version 6.3.2 (#3092)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-10 20:27:44 +00:00
Nick Funkandkodiakhq[bot] 45cd0e6628 [CORL-1248] Allow users to auto-show media in stream (#3086)
* Create preliminary media preferences

Allows user to select whether they want to
automatically show media or not.

CORL-1248

* Fix up the copy around media preferences

CORL-1248

* Rename EmbedPreferences to UserMediaSettings

CORL-1248

* Add error/validation messages to media prefs

CORL-1248

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-10 20:12:56 +00:00
Tessa Thornton adc508c7dc remove duplicate code and add comment to wrapper component (#3076)
* remove duplicate code and add comment to wrapper component:

* use react hooks recommended settings
2020-08-10 12:30:25 -04:00
Nick Funk 88a77f8712 Show media in the moderate view conversation modal (#3088) 2020-08-10 09:29:17 -06:00
Tessa Thornton 5d2ab3d061 adjust spacing on configure tab on mobile (#3087) 2020-08-07 12:02:46 -04:00
Tessa Thorntonandkodiakhq[bot] df6f666447 make spacing between discussions and headers explicit (#3084)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-06 19:20:57 +00:00
Vinhandkodiakhq[bot] e412b27c1e fix: auto focus on reply (#3083)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-06 19:03:04 +00:00
Vinhandkodiakhq[bot] 0fe0be832e fix: use same focus styling (#3078)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-06 18:48:08 +00:00
Tessa Thorntonandkodiakhq[bot] 288809ee14 add PERMIT argunment to auth directives that should be accessible to suspended or banned users (#3085)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-06 17:49:49 +00:00
Wyatt Johnsonandkodiakhq[bot] b97ec638b2 fix: ensure anonymousId or userId is added to the payload (#3075)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-06 16:35:02 +00:00
Wyatt JohnsonandVinh 2bbfbc7ddd [CORL-1242] Ensure SSO Access Tokens are not persisted (#3080)
* fix: ensure access tokens from sso are not persisted

* fix: increment storage key field to bust existing usage

Co-authored-by: Vinh <vinh@vinh.tech>
2020-08-06 15:20:46 +00:00
b029a01bd9 setTimeout Fixes (#3082)
* fix: fixed types and some unmanaged timeouts

* fix: refetch type

Co-authored-by: Chi Vinh Le <vinh@vinh.tech>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-06 14:56:57 +00:00
Wyatt Johnsonandkodiakhq[bot] 6fabeb2755 [CORL-1243] Subscription Errors (#3074)
* fix: fixed subscription event error

* fix: further subscription refinements

* fix: review fixes

* fix: exhastive deps

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-06 14:42:04 +00:00
Vinhandkodiakhq[bot] 459e11a5fa fix: correctly cleanup timeout (#3081)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-05 23:32:14 +00:00
Nick Funkandkodiakhq[bot] ce7d6969dd Add read more opens new tab feature flag (#3079)
If the feature flag READ_MORE_NEW_TAB is
set, then clicking on "Read more of this
conversation" will open a new tab.

CORL-1246

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-05 21:10:55 +00:00
Vinhandkodiakhq[bot] 6a8d6b07d6 [CORL-1240] Check existing data before handling subscriptions (#3077)
* fix: check existing data before handling subscriptions

* fix: typo

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-05 20:56:11 +00:00
Wyatt JohnsonandVinh 8ede245a8d feat: added data-coral-count to count elements (#3055)
Co-authored-by: Vinh <vinh@vinh.tech>
2020-08-05 19:10:13 +00:00
Nick Funkandkodiakhq[bot] 88eb9555c7 [CORL-1227] Fix scroll-able modal position popping (#3069)
* Disable scrolling on site moderators modal

The site moderator modal already has a scroll bar
inside it and its height is less than 480px. We
don't need a scrollbar on the modal background since
the modal window will always be fully visible on
even the smallest screens.

This eliminates the bug of the modal sliding away
when there are enough sites in the org to cause the
background scroll to be activated.

CORL-1227

* Stop checkboxes from shoving input element down

The checkbox input element that we style away
using ::before css was being shoved down the
dom non-relative to the checkbox root.

Now it's relative to the checkbox root and positioned
accordingly.

CORL-1227

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-05 15:49:33 +00:00
ce53134736 [CORL-794] Create a common date formatter (#3073)
* Create a common date formatter

Updates all uses of Intl.DateTimeFormat(...)
with re-usable date time formatter functions.

The formatter functions have default formatting
options, but these can be overridden.

CORL-794

* Include options in useMemo

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>

* Make date formatting accept single locale string

Makes formatDate and createDateFormatter accept
string | string[] | undefined for the locales
argument.

CORL-794

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-05 15:34:42 +00:00
0f1dd7d707 add react hooks eslint plugin (#3059)
* add eslint react hooks

* fix code breaking the rules-of-hooks eslint rule

* fix spinnerWhileRendering for tests

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-05 14:42:50 +00:00
Wyatt Johnsonandkodiakhq[bot] 9d02391062 [CORL-1239] Swap setTimeout for setLongTimeout (#3071)
* fix: ensure user editable time use setLongTimeout

* fix: fixed wrong type

* fix: fixed snapshots

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-04 18:24:31 +00:00
Wyatt Johnsonandkodiakhq[bot] 8aac2d33fc [CORL-1238] Duplicate User Race (#3068)
* fix: added a single retry to user create

* feat: added additional error context

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-04 18:09:55 +00:00
Tessa Thorntonandkodiakhq[bot] 3eddf5f155 [CORL-1233] Mobile display fixes (#3067)
* mobile display fixes

* show button full text at larger breakpoint

* update snaps

* hardcode in media query for reaction buttons

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2020-08-04 17:55:30 +00:00
Nick Funk 94ff8ff880 fix: replace outdated keys with signingSecrets (#3070)
Looks like this was missed during the migration
of `auth.integrations.sso.keys` renamed to
`auth.integrations.sso.signingSecrets`.

CORL-1159
2020-08-04 11:09:24 -06:00
Nick FunkandWyatt Johnson 7dfc064c9f Better use flex to align callout contents (#3060)
CORL-1201

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
2020-08-03 17:25:47 -06:00
Wyatt Johnson d602cf873c fix: support mobile twitter links (#3066) 2020-08-03 16:51:15 +00:00
Wyatt Johnson 79627af8d6 fix: fixed incorrect connection access (#3064) 2020-08-03 15:21:09 +00:00
225 changed files with 4608 additions and 2566 deletions
+2
View File
@@ -21,6 +21,7 @@ const typescriptOverrides = {
"@typescript-eslint/tslint",
"react",
"jsx-a11y",
"react-hooks",
],
settings: {
react: {
@@ -175,6 +176,7 @@ module.exports = {
"eslint:recommended",
"plugin:jsdoc/recommended",
"plugin:prettier/recommended",
"plugin:react-hooks/recommended",
],
parserOptions: {
ecmaVersion: 2018,
+12
View File
@@ -133,6 +133,7 @@ createComment.error
- <a href="#unfeatureComment">unfeatureComment</a>
- <a href="#updateNotificationSettings">updateNotificationSettings</a>
- <a href="#updateStorySettings">updateStorySettings</a>
- <a href="#updateUserMediaSettings">updateUserMediaSettings</a>
- <a href="#viewConversation">viewConversation</a>
- <a href="#viewFullDiscussion">viewFullDiscussion</a>
- <a href="#viewNewComments">viewNewComments</a>
@@ -587,6 +588,17 @@ createComment.error
};
}
```
- <a id="updateUserMediaSettings">**updateUserMediaSettings.success**, **updateUserMediaSettings.error**</a>:
```ts
{
unfurlEmbeds?: boolean | null | undefined;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="viewConversation">**viewConversation**</a>: This event is emitted when the viewer changes to the single conversation view.
```ts
{
-38
View File
@@ -1,38 +0,0 @@
{
"name": "Coral",
"description": "A better commenting experience from Vox Media.",
"env": {
"REWRITE_ENV": {
"description": "Used to rewrite the environment variables set by Heroku.",
"value": "REDIS_URI:REDIS_URL,MONGODB_URI:MONGO_URI"
},
"SIGNING_SECRET": {
"description": "The shared secret to use to sign JSON Web Tokens (JWT) with the selected signing algorithm.",
"generator": "secret"
},
"LOCALE": {
"description": "Specify the default locale to use for all requests without a locale specified",
"value": "en-US"
},
"ENABLE_GRAPHIQL": {
"description": "When true, this will enable the GraphiQL routes",
"value": "false"
},
"TRUST_PROXY": {
"description": "When set to 1, it instructs Coral to trust up to one proxy, this is needed for authentication support",
"value": "1"
}
},
"addons": [
{
"plan": "mongolab:sandbox",
"as": "MONGO"
},
{
"plan": "rediscloud:30",
"as": "REDIS"
}
],
"success_url": "/install",
"website": "https://github.com/coralproject/talk"
}
+77 -29
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "6.3.1",
"version": "6.3.2",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -5087,13 +5087,13 @@
}
},
"@coralproject/rte": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-1.1.1.tgz",
"integrity": "sha512-r70xg7arHttiJr4pXCZTTWA86bjT9M+XHDeaYjNpMQrW5rIL+kw7sjoRuiyPLzi88xINMbxffurdKep2Z68bcg==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-1.2.3.tgz",
"integrity": "sha512-1H5HsidtrwS3+1e3oRe7ZQFHi93mAZOmlg9eIi/oOlzLsMszOZbODYL5YL3Exh2KrV2VCbgS69vxvBlGYkXP0A==",
"dev": true,
"requires": {
"classnames": "^2.2.6",
"squire-rte": "^1.9.0"
"squire-rte": "^1.10.2"
}
},
"@csstools/convert-colors": {
@@ -8237,12 +8237,14 @@
"@types/d3-path": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.8.tgz",
"integrity": "sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA=="
"integrity": "sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA==",
"dev": true
},
"@types/d3-shape": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.2.tgz",
"integrity": "sha512-LtD8EaNYCaBRzHzaAiIPrfcL3DdIysc81dkGlQvv7WQP3+YXV7b0JJTtR1U3bzeRieS603KF4wUo+ZkJVenh8w==",
"dev": true,
"requires": {
"@types/d3-path": "*"
}
@@ -8925,7 +8927,8 @@
"@types/prop-types": {
"version": "15.5.8",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.8.tgz",
"integrity": "sha512-3AQoUxQcQtLHsK25wtTWIoIpgYjH3vSDroZOUr7PpCHw/jLY1RB9z9E8dBT/OSmwStVgkRNvdh+ZHNiomRieaw=="
"integrity": "sha512-3AQoUxQcQtLHsK25wtTWIoIpgYjH3vSDroZOUr7PpCHw/jLY1RB9z9E8dBT/OSmwStVgkRNvdh+ZHNiomRieaw==",
"dev": true
},
"@types/q": {
"version": "1.5.1",
@@ -8947,6 +8950,7 @@
"version": "16.9.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.31.tgz",
"integrity": "sha512-NpYJpNMWScFXtx3A2BJMeew2G3+9SEslVWMdxNJ6DLvxIuxWjY1bizK9q5Y1ujhln31vtjmhjOAYDr9Xx3k9FQ==",
"dev": true,
"requires": {
"@types/prop-types": "*",
"csstype": "^2.2.0"
@@ -9039,6 +9043,7 @@
"version": "1.8.11",
"resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.11.tgz",
"integrity": "sha512-1E1+3uKo7EyNk3LZ6J2cqfayIdQEUt1YOq2Y/9eRSXUzasf100ncF4f0iARnaXnED6DyFgTPRomwtChcwLYvhA==",
"dev": true,
"requires": {
"@types/d3-shape": "*",
"@types/react": "*",
@@ -9048,7 +9053,8 @@
"@types/recharts-scale": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/recharts-scale/-/recharts-scale-1.0.0.tgz",
"integrity": "sha512-HR/PrCcxYb2YHviTqH7CMdL1TUhUZLTUKzfrkMhxm1HTa5mg/QtP8XMiuSPz6dZ6wecazAOu8aYZ5DqkNlgHHQ=="
"integrity": "sha512-HR/PrCcxYb2YHviTqH7CMdL1TUhUZLTUKzfrkMhxm1HTa5mg/QtP8XMiuSPz6dZ6wecazAOu8aYZ5DqkNlgHHQ==",
"dev": true
},
"@types/recompose": {
"version": "0.30.7",
@@ -14000,7 +14006,8 @@
"classnames": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz",
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==",
"dev": true
},
"clean-css": {
"version": "4.2.1",
@@ -16442,27 +16449,32 @@
"d3-array": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz",
"integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="
"integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==",
"dev": true
},
"d3-collection": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz",
"integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A=="
"integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==",
"dev": true
},
"d3-color": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
"integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==",
"dev": true
},
"d3-format": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.4.tgz",
"integrity": "sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw=="
"integrity": "sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw==",
"dev": true
},
"d3-interpolate": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
"integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
"dev": true,
"requires": {
"d3-color": "1"
}
@@ -16470,12 +16482,14 @@
"d3-path": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
"dev": true
},
"d3-scale": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz",
"integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==",
"dev": true,
"requires": {
"d3-array": "^1.2.0",
"d3-collection": "1",
@@ -16489,6 +16503,7 @@
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
"dev": true,
"requires": {
"d3-path": "1"
}
@@ -16496,12 +16511,14 @@
"d3-time": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
"integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="
"integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==",
"dev": true
},
"d3-time-format": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.2.3.tgz",
"integrity": "sha512-RAHNnD8+XvC4Zc4d2A56Uw0yJoM7bsvOlJR33bclxq399Rak/b9bhvu/InjxdWhPtkgU53JJcleJTGkNRnN6IA==",
"dev": true,
"requires": {
"d3-time": "1"
}
@@ -16815,7 +16832,8 @@
"decimal.js-light": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.0.tgz",
"integrity": "sha512-b3VJCbd2hwUpeRGG3Toob+CRo8W22xplipNhP3tN7TSVB/cyMX71P1vM2Xjc9H74uV6dS2hDDmo/rHq8L87Upg=="
"integrity": "sha512-b3VJCbd2hwUpeRGG3Toob+CRo8W22xplipNhP3tN7TSVB/cyMX71P1vM2Xjc9H74uV6dS2hDDmo/rHq8L87Upg==",
"dev": true
},
"decode-uri-component": {
"version": "0.2.0",
@@ -18437,6 +18455,7 @@
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
"dev": true,
"requires": {
"@babel/runtime": "^7.1.2"
}
@@ -20249,9 +20268,9 @@
}
},
"eslint-plugin-react-hooks": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.7.0.tgz",
"integrity": "sha512-iXTCFcOmlWvw4+TOE8CLWj6yX1GwzT0Y6cUfHHZqWnSk144VmVIRcVGtUAzrLES7C798lmvnt02C7rxaOX1HNA==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.0.8.tgz",
"integrity": "sha512-6SSb5AiMCPd8FDJrzah+Z4F44P2CdOaK026cXFV+o/xSRzfOiV1FNFeLl2z6xm3yqWOQEZ5OfVgiec90qV2xrQ==",
"dev": true
},
"eslint-scope": {
@@ -24051,6 +24070,12 @@
}
}
},
"eslint-plugin-react-hooks": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.7.0.tgz",
"integrity": "sha512-iXTCFcOmlWvw4+TOE8CLWj6yX1GwzT0Y6cUfHHZqWnSk144VmVIRcVGtUAzrLES7C798lmvnt02C7rxaOX1HNA==",
"dev": true
},
"event-source-polyfill": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.15.tgz",
@@ -36107,7 +36132,8 @@
"js-tokens": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=",
"dev": true
},
"js-yaml": {
"version": "3.13.1",
@@ -37376,7 +37402,8 @@
"lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
"dev": true
},
"lodash.deburr": {
"version": "4.1.0",
@@ -37555,7 +37582,8 @@
"lodash.throttle": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=",
"dev": true
},
"lodash.toarray": {
"version": "4.4.0",
@@ -37782,6 +37810,7 @@
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
"integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
"dev": true,
"requires": {
"js-tokens": "^3.0.0"
}
@@ -38051,7 +38080,8 @@
"math-expression-evaluator": {
"version": "1.2.22",
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz",
"integrity": "sha512-L0j0tFVZBQQLeEjmWOvDLoRciIY8gQGWahvkztXUal8jH8R5Rlqo9GCvgqvXcy9LQhEWdQCVvzqAbxgYNt4blQ=="
"integrity": "sha512-L0j0tFVZBQQLeEjmWOvDLoRciIY8gQGWahvkztXUal8jH8R5Rlqo9GCvgqvXcy9LQhEWdQCVvzqAbxgYNt4blQ==",
"dev": true
},
"mathjs": {
"version": "2.7.0",
@@ -44870,6 +44900,7 @@
"version": "15.6.2",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz",
"integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==",
"dev": true,
"requires": {
"loose-envify": "^1.3.1",
"object-assign": "^4.1.1"
@@ -45087,6 +45118,7 @@
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"dev": true,
"requires": {
"performance-now": "^2.1.0"
}
@@ -45913,7 +45945,8 @@
"react-fast-compare": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==",
"dev": true
},
"react-feather": {
"version": "2.0.3",
@@ -45975,6 +46008,7 @@
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.1.tgz",
"integrity": "sha512-CnwD822LU8NDBnjCpZ4ySh8L6HYyngViTZLfBBb3NjtrpN8m49clH8hidHouq20I51Y6TpCTISCBbqiY5GamwA==",
"dev": true,
"requires": {
"object-assign": "^4.1.1",
"prop-types": "^15.5.4",
@@ -46026,7 +46060,8 @@
"react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"dev": true
},
"react-live": {
"version": "2.2.2",
@@ -46253,6 +46288,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.2.0.tgz",
"integrity": "sha512-v1ht1aHg5k/thv56DRcjw+WtojuuDHFUgGfc+bFHOWsF4ZK6C2V57DO0Or0GPsg6+LSTE0M6Ry/gfzhzSwbc5w==",
"dev": true,
"requires": {
"shallowequal": "^1.0.1"
}
@@ -46267,6 +46303,7 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-1.0.5.tgz",
"integrity": "sha512-eW057HT0lFgCKh8ilr0y2JaH2YbNcuEdFpxyg7Gf/qDKk9hqGMyXryZJ8iMGJEuKH0+wxS0ccSsBBB3W8yCn8w==",
"dev": true,
"requires": {
"lodash": "~4.17.4",
"prop-types": "^15.6.0",
@@ -46278,6 +46315,7 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
@@ -46286,6 +46324,7 @@
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
"integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
"dev": true,
"requires": {
"dom-helpers": "^3.4.0",
"loose-envify": "^1.4.0",
@@ -46462,6 +46501,7 @@
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-1.8.5.tgz",
"integrity": "sha512-tM9mprJbXVEBxjM7zHsIy6Cc41oO/pVYqyAsOHLxlJrbNBuLs0PHB3iys2M+RqCF0//k8nJtZF6X6swSkWY3tg==",
"dev": true,
"requires": {
"classnames": "^2.2.5",
"core-js": "^2.6.10",
@@ -46479,12 +46519,14 @@
"core-js": {
"version": "2.6.11",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz",
"integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg=="
"integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==",
"dev": true
},
"react-resize-detector": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-2.3.0.tgz",
"integrity": "sha512-oCAddEWWeFWYH5FAcHdBYcZjAw9fMzRUK9sWSx6WvSSOPVRxcHd5zTIGy/mOus+AhN/u6T4TMiWxvq79PywnJQ==",
"dev": true,
"requires": {
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
@@ -46498,6 +46540,7 @@
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.3.tgz",
"integrity": "sha512-t8p5sccG9Blm7c1JQK/ak9O8o95WGhNXD7TXg/BW5bYbVlr6eCeRBNpgyigD4p6pSSMehC5nSvBUPj6F68rbFA==",
"dev": true,
"requires": {
"decimal.js-light": "^2.4.1"
}
@@ -46556,6 +46599,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz",
"integrity": "sha1-dHyRTgSWFKTJz7umKYca0dKSdxY=",
"dev": true,
"requires": {
"balanced-match": "^0.4.2",
"math-expression-evaluator": "^1.2.14",
@@ -46565,7 +46609,8 @@
"balanced-match": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
"integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg="
"integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=",
"dev": true
}
}
},
@@ -46573,6 +46618,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz",
"integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
}
@@ -47865,7 +47911,8 @@
"resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"dev": true
},
"resolve": {
"version": "1.8.1",
@@ -48377,7 +48424,8 @@
"shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"dev": true
},
"shebang-command": {
"version": "1.2.0",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "6.3.1",
"version": "6.3.2",
"author": "The Coral Project",
"homepage": "https://coralproject.net/",
"sideEffects": [
@@ -67,7 +67,6 @@
"@hapi/joi": "^17.1.1",
"@metascraper/helpers": "^5.11.6",
"@rudderstack/rudder-sdk-node": "0.0.2",
"@types/recharts": "^1.8.9",
"abort-controller": "^3.0.0",
"akismet-api": "^5.0.0",
"apollo-server-core": "^2.14.4",
@@ -100,7 +99,6 @@
"graphql-config": "^2.2.2",
"graphql-extensions": "^0.11.0",
"graphql-fields": "2.0.3",
"graphql-import": "^0.7.1",
"graphql-playground-html": "1.6.13",
"graphql-redis-subscriptions": "^2.2.1",
"graphql-subscriptions": "^1.1.0",
@@ -138,8 +136,6 @@
"prom-client": "^12.0.0",
"proxy-agent": "^3.1.1",
"querystringify": "^2.1.1",
"react-helmet": "^5.2.1",
"recharts": "^1.8.5",
"source-map-support": "^0.5.16",
"stack-utils": "^2.0.1",
"striptags": "^3.1.1",
@@ -156,7 +152,7 @@
"@babel/preset-typescript": "^7.10.1",
"@babel/runtime-corejs3": "^7.10.3",
"@coralproject/npm-run-all": "^4.1.5",
"@coralproject/rte": "^1.1.1",
"@coralproject/rte": "^1.2.3",
"@fluent/react": "^0.11.1",
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
"@types/archiver": "^3.1.0",
@@ -225,6 +221,7 @@
"@types/react-responsive": "^8.0.2",
"@types/react-test-renderer": "^16.9.2",
"@types/react-transition-group": "^4.2.4",
"@types/recharts": "^1.8.9",
"@types/recompose": "^0.30.7",
"@types/relay-runtime": "^8.0.7",
"@types/sane": "^2.0.0",
@@ -279,6 +276,7 @@
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-react-hooks": "^4.0.8",
"eventemitter2": "^6.3.1",
"farce": "^0.2.8",
"final-form": "4.18.6",
@@ -334,6 +332,7 @@
"react-error-overlay": "^6.0.7",
"react-final-form": "6.3.0",
"react-final-form-arrays": "^3.1.1",
"react-helmet": "^5.2.1",
"react-popper": "^1.3.7",
"react-relay": "^9.0.0",
"react-relay-network-modern": "^4.6.1",
@@ -341,6 +340,7 @@
"react-test-renderer": "^16.13.1",
"react-timeago": "^4.4.0",
"react-transition-group": "^4.3.0",
"recharts": "^1.8.5",
"recompose": "^0.30.0",
"regenerator-runtime": "^0.13.5",
"relay-compiler": "^9.0.0",
@@ -82,7 +82,7 @@ exports[`renders missing confirm token 1`] = `
className="CallOut-root CallOut-error CallOut-leftBorder"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignFlexStart"
className="CallOut-container"
>
<div
className="CallOut-content"
@@ -140,7 +140,7 @@ exports[`renders missing reset token 1`] = `
className="CallOut-root CallOut-error CallOut-leftBorder"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignFlexStart"
className="CallOut-container"
>
<div
className="CallOut-content"
@@ -81,7 +81,7 @@ exports[`renders missing confirm token 1`] = `
className="CallOut-root CallOut-error CallOut-leftBorder"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignFlexStart"
className="CallOut-container"
>
<div
className="CallOut-content"
@@ -11,9 +11,7 @@ exports[`renders correctly 1`] = `
className="DecisionHistoryLoading-container"
justifyContent="center"
>
<Delay
ms={500}
>
<Delay>
<withPropsOnChange(Spinner)
size="sm"
/>
@@ -1,4 +1,5 @@
import { useRouter } from "found";
import { setLongTimeout } from "long-settimeout";
import { ReactNode, useContext } from "react";
import { NotificationContext } from "./GlobalNotificationContext";
@@ -17,7 +18,7 @@ function useNotification() {
const setMessage = (message: ReactNode, timeout?: number) => {
dispatch({ type: "SET_MESSAGE", message });
if (timeout) {
setTimeout(() => {
setLongTimeout(() => {
dispatch({ type: "CLEAR_MESSAGE" });
}, timeout);
}
@@ -3,6 +3,7 @@ import cn from "classnames";
import React, { FunctionComponent, useCallback, useState } from "react";
import { graphql } from "react-relay";
import { MediaContainer } from "coral-admin/components/MediaContainer";
import { withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
@@ -95,6 +96,7 @@ const ConversationModalCommentContainer: FunctionComponent<Props> = ({
{comment.body}
</CommentContent>
)}
<MediaContainer comment={comment} />
</div>
</HorizontalGutter>
</Flex>
@@ -153,6 +155,7 @@ const enhanced = withFragmentContainer<Props>({
id
}
}
...MediaContainer_comment
}
`,
})(ConversationModalCommentContainer);
@@ -0,0 +1,4 @@
export { default as MediaContainer } from "./MediaContainer";
export { default as GiphyMedia } from "./GiphyMedia";
export { default as TwitterMedia } from "./TwitterMedia";
export { default as YouTubeMedia } from "./YouTubeMedia";
@@ -32,6 +32,11 @@ it("renders all markers", () => {
},
},
},
metadata: {
wordList: {
timedOut: false,
},
},
},
},
settings: {
@@ -71,6 +76,11 @@ it("renders some markers", () => {
},
},
},
metadata: {
wordList: {
timedOut: false,
},
},
},
},
settings: {
@@ -38,7 +38,17 @@ const markers: Array<(
null,
(c) =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD &&
c.revision.metadata?.wordList?.timedOut && (
<Localized id="moderate-marker-possibleBannedWord" key={keyCounter++}>
<Marker color="reported">Possible Banned Word</Marker>
</Localized>
)) ||
null,
(c) =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD &&
!c.revision.metadata?.wordList?.timedOut && (
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
<Marker color="reported">Banned Word</Marker>
</Localized>
@@ -46,7 +56,17 @@ const markers: Array<(
null,
(c) =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD &&
c.revision.metadata?.wordList?.timedOut && (
<Localized id="moderate-marker-possibleSuspectWord" key={keyCounter++}>
<Marker color="reported">Possible Suspect Word</Marker>
</Localized>
)) ||
null,
(c) =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD &&
!c.revision.metadata?.wordList?.timedOut && (
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
<Marker color="reported">Suspect Word</Marker>
</Localized>
@@ -193,6 +213,11 @@ const enhanced = withFragmentContainer<MarkersContainerProps>({
}
}
}
metadata {
wordList {
timedOut
}
}
}
}
`,
@@ -10,6 +10,7 @@ import React, {
useRef,
} from "react";
import { MediaContainer } from "coral-admin/components/MediaContainer";
import { HOTKEYS } from "coral-admin/constants";
import { GetPhrasesRegExpOptions } from "coral-admin/helpers";
import { PropTypesOf } from "coral-framework/types";
@@ -29,7 +30,6 @@ import ApproveButton from "./ApproveButton";
import CommentAuthorContainer from "./CommentAuthorContainer";
import FeatureButton from "./FeatureButton";
import MarkersContainer from "./MarkersContainer";
import MediaContainer from "./MediaContainer";
import RejectButton from "./RejectButton";
import styles from "./ModerateCard.css";
@@ -25,6 +25,11 @@ exports[`renders all markers 1`] = `
},
},
},
"metadata": Object {
"wordList": Object {
"timedOut": false,
},
},
},
"status": "PREMOD",
}
@@ -172,6 +177,11 @@ exports[`renders some markers 1`] = `
},
},
},
"metadata": Object {
"wordList": Object {
"timedOut": false,
},
},
},
"status": "PREMOD",
}
@@ -6,14 +6,16 @@ import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction";
import UsernameChangeAction, {
UsernameChangeActionProps,
} from "./UsernameChangeAction";
import WarningAction, { WarningActionProps } from "./WarningAction";
export interface HistoryActionProps {
kind: "username" | "suspension" | "ban" | "premod";
kind: "username" | "suspension" | "ban" | "premod" | "warning";
action:
| UsernameChangeActionProps
| SuspensionActionProps
| BanActionProps
| PremodActionProps;
| PremodActionProps
| WarningActionProps;
}
const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
@@ -31,6 +33,8 @@ const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
return <BanAction {...(action as BanActionProps)} />;
case "premod":
return <PremodAction {...(action as PremodActionProps)} />;
case "warning":
return <WarningAction {...(action as WarningActionProps)} />;
default:
return null;
}
@@ -29,7 +29,7 @@ $recent-history-triggered: var(--palette-error-500);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-primary-semi-bold);
font-size: var(--font-size-6);
line-height: 0.67;
line-height: 1;
color: $recent-history-info;
margin: 0;
}
@@ -133,6 +133,30 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
});
});
user.status.warning.history.forEach((record, i) => {
let action: "created" | "acknowledged" | "removed";
if (record.active) {
action = "created";
} else {
if (record.acknowledgedAt) {
action = "acknowledged";
} else {
action = "removed";
}
}
history.push({
kind: "warning",
date: new Date(record.createdAt),
takenBy: record.createdBy.username,
action: {
action,
acknowledgedAt: record.acknowledgedAt
? new Date(record.acknowledgedAt)
: null,
},
});
});
// Sort the history so that it's in the right order.
return history.sort((a, b) => b.date.getTime() - a.date.getTime());
}, [user]);
@@ -193,6 +217,16 @@ const enhanced = withFragmentContainer<any>({
}
}
}
warning {
history {
active
createdBy {
username
}
acknowledgedAt
createdAt
}
}
ban {
history {
active
@@ -1,4 +1,5 @@
import { Localized } from "@fluent/react/compat";
import { last } from "lodash";
import React, { FunctionComponent, useMemo } from "react";
import { graphql } from "react-relay";
@@ -21,18 +22,24 @@ interface Props {
}
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);
if (user.status.ban.active) {
return last(user.status.ban.history);
}
return null;
}, [user]);
const activeSuspension = useMemo(() => {
return user.status.suspension.history.find((item) => item.active);
}, [user]);
const activeWarning = useMemo(() => {
if (user.status.warning.active) {
return last(user.status.warning.history);
}
return null;
}, [user]);
const formatter = useDateTimeFormatter({
day: "2-digit",
month: "2-digit",
@@ -41,6 +48,14 @@ const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
minute: "2-digit",
});
if (
!user.status.ban.active &&
!user.status.suspension.active &&
!user.status.warning.active
) {
return null;
}
return (
<div>
<Popover
@@ -49,6 +64,37 @@ const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Box p={2}>
{activeWarning && (
<div>
<Localized
id="userDetails-warned-on"
$timestamp={formatter(activeWarning.createdAt)}
strong={<strong />}
>
<p className={styles.root}>
<strong>Warned on </strong>{" "}
{formatter(activeWarning.createdAt)}
</p>
</Localized>
{activeWarning.createdBy && (
<Localized
id="userDetails-warned-by"
strong={<strong />}
$username={activeWarning.createdBy.username}
>
<p className={styles.root}>
<strong>by </strong>
{activeWarning.createdBy.username}
</p>
</Localized>
)}
<Localized id="userDetails-warned-explanation">
<p className={styles.root}>
User has not acknowledged the warning.
</p>
</Localized>
</div>
)}
{activeBan && (
<div>
<Localized
@@ -138,6 +184,16 @@ const enhanced = withFragmentContainer<Props>({
user: graphql`
fragment UserStatusDetailsContainer_user on User {
status {
warning {
active
history {
active
createdBy {
username
}
createdAt
}
}
ban {
active
history {
@@ -0,0 +1,43 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
export interface WarningActionProps {
action: "created" | "removed" | "acknowledged";
acknowledgedAt: Date | null;
}
import { useDateTimeFormatter } from "coral-framework/hooks";
const WarningAction: FunctionComponent<WarningActionProps> = ({
action,
acknowledgedAt,
}) => {
const formatter = useDateTimeFormatter({
hour: "numeric",
minute: "2-digit",
});
if (action === "created") {
return (
<Localized id="moderate-user-drawer-account-history-warning-set">
<span>User warned</span>
</Localized>
);
} else if (action === "removed") {
return (
<Localized id="moderate-user-drawer-account-history-warning-removed">
<span>Warning removed</span>
</Localized>
);
} else if (action === "acknowledged") {
return (
<Localized id="moderate-user-drawer-account-history-warning-acknowledged">
<span>
Warning acknowledged at{" "}
{acknowledgedAt ? formatter(acknowledgedAt) : ""}
</span>
</Localized>
);
}
return null;
};
export default WarningAction;
@@ -56,7 +56,7 @@ const SiteModeratorModal: FunctionComponent<Props> = ({
);
return (
<Modal open={open} onClose={onCancel}>
<Modal open={open} onClose={onCancel} disableScroll>
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.root}>
<Flex justifyContent="flex-end">
@@ -0,0 +1,84 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
import { RemoveUserWarningMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserWarningMutation.graphql";
let clientMutationId = 0;
const RemoveUserWarningMutation = createMutation(
"removeUserWarning",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const viewer = getViewer(environment)!;
const now = new Date();
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RemoveUserWarningMutation($input: RemoveUserWarningInput!) {
removeUserWarning(input: $input) {
user {
id
status {
current
warning {
active
history {
active
createdAt
createdBy {
id
username
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
removeUserWarning: {
user: {
id: input.userID,
status: {
current: lookup<GQLUser>(
environment,
input.userID
)!.status.current.filter((s) => s !== GQLUSER_STATUS.WARNED),
warning: {
active: false,
history: [
{
active: false,
createdBy: {
id: viewer.id,
username: viewer.username,
},
createdAt: now.toISOString(),
},
],
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default RemoveUserWarningMutation;
@@ -10,6 +10,7 @@ interface Props {
banned: boolean;
suspended: boolean;
premod: boolean;
warned: boolean;
}
const render = (className: string, content: React.ReactNode) => (
@@ -45,6 +46,14 @@ const UserStatus: FunctionComponent<Props> = (props) => {
</Localized>
);
}
if (props.warned) {
return render(
styles.warning,
<Localized id="userStatus-warned">
<div>Warned</div>
</Localized>
);
}
return render(
styles.success,
<Localized id="userStatus-active">
@@ -32,9 +32,12 @@ interface Props {
onRemoveSuspension: () => void;
onPremod: () => void;
onRemovePremod: () => void;
onWarn: () => void;
onRemoveWarning: () => void;
banned: boolean;
suspended: boolean;
premod: boolean;
warned: boolean;
children: React.ReactNode;
fullWidth?: boolean;
bordered?: boolean;
@@ -47,6 +50,9 @@ const UserStatusChange: FunctionComponent<Props> = ({
onRemoveSuspension,
onPremod,
onRemovePremod,
onWarn,
onRemoveWarning,
warned,
banned,
suspended,
premod,
@@ -143,6 +149,37 @@ const UserStatusChange: FunctionComponent<Props> = ({
</DropdownButton>
</Localized>
)}
{warned ? (
<Localized id="community-userStatus-removeWarning">
<DropdownButton
className={styles.dropdownButton}
disabled={!onRemoveWarning}
onClick={() => {
if (onRemoveWarning) {
onRemoveWarning();
toggleVisibility();
}
}}
>
Remove warning
</DropdownButton>
</Localized>
) : (
<Localized id="community-userStatus-warn">
<DropdownButton
className={styles.dropdownButton}
disabled={!onWarn}
onClick={() => {
if (onWarn) {
onWarn();
toggleVisibility();
}
}}
>
Warn
</DropdownButton>
</Localized>
)}
</Dropdown>
</ClickOutside>
)}
@@ -15,10 +15,13 @@ import PremodUserMutation from "./PremodUserMutation";
import RemoveUserBanMutation from "./RemoveUserBanMutation";
import RemoveUserPremodMudtaion from "./RemoveUserPremodMutation";
import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation";
import RemoveUserWarningMutation from "./RemoveUserWarningMutation";
import SuspendModal from "./SuspendModal";
import SuspendUserMutation from "./SuspendUserMutation";
import UserStatusChange from "./UserStatusChange";
import UserStatusContainer from "./UserStatusContainer";
import WarnModal from "./WarnModal";
import WarnUserMutation from "./WarnUserMutation";
interface Props {
settings: UserStatusChangeContainer_settings;
@@ -41,10 +44,37 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
const removeUserSuspension = useMutation(RemoveUserSuspensionMutation);
const premodUser = useMutation(PremodUserMutation);
const removeUserPremod = useMutation(RemoveUserPremodMudtaion);
const warnUser = useMutation(WarnUserMutation);
const removeUserWarning = useMutation(RemoveUserWarningMutation);
const [showPremod, setShowPremod] = useState<boolean>(false);
const [showBanned, setShowBanned] = useState<boolean>(false);
const [showSuspend, setShowSuspend] = useState<boolean>(false);
const [showWarn, setShowWarn] = useState<boolean>(false);
const [showSuspendSuccess, setShowSuspendSuccess] = useState<boolean>(false);
const [showWarnSuccess, setShowWarnSuccess] = useState<boolean>(false);
const handleWarn = useCallback(() => {
if (user.status.warning.active) {
return;
}
setShowWarn(true);
}, [user, setShowWarn]);
const handleRemoveWarning = useCallback(() => {
if (!user.status.warning.active) {
return;
}
void removeUserWarning({ userID: user.id });
}, [user, removeUserWarning]);
const hideWarn = useCallback(() => {
setShowWarn(false);
setShowWarnSuccess(false);
}, [setShowWarn]);
const handleWarnConfirm = useCallback(
(message: string) => {
void warnUser({ userID: user.id, message });
setShowWarnSuccess(true);
},
[warnUser, user, setShowWarnSuccess]
);
const handleBan = useCallback(() => {
if (user.status.ban.active) {
return;
@@ -91,12 +121,12 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
return;
}
void removeUserPremod({ userID: user.id });
}, [user, premodUser]);
}, [user, removeUserPremod]);
const handleSuspendModalClose = useCallback(() => {
setShowSuspend(false);
setShowSuspendSuccess(false);
}, [setShowBanned, setShowSuspendSuccess]);
}, [setShowSuspendSuccess]);
const handleBanModalClose = useCallback(() => {
setShowBanned(false);
@@ -119,7 +149,7 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
void banUser({ userID: user.id, message, rejectExistingComments });
setShowBanned(false);
},
[user, setShowBanned]
[user, setShowBanned, banUser]
);
if (user.role !== GQLUSER_ROLE.COMMENTER) {
@@ -140,6 +170,9 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
banned={user.status.ban.active}
suspended={user.status.suspension.active}
premod={user.status.premod.active}
warned={user.status.warning.active}
onWarn={handleWarn}
onRemoveWarning={handleRemoveWarning}
fullWidth={fullWidth}
bordered={bordered}
>
@@ -159,6 +192,14 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
onClose={hidePremod}
onConfirm={handlePremodConfirm}
/>
<WarnModal
username={user.username}
organizationName={settings.organization.name}
open={showWarn}
onClose={hideWarn}
onConfirm={handleWarnConfirm}
success={showWarnSuccess}
/>
{!scoped && (
<BanModal
username={user.username}
@@ -187,6 +228,9 @@ const enhanced = withFragmentContainer<Props>({
premod {
active
}
warning {
active
}
}
...UserStatusContainer_user
}
@@ -18,6 +18,7 @@ const UserStatusContainer: FunctionComponent<Props> = (props) => {
banned={props.user.status.current.includes(GQLUSER_STATUS.BANNED)}
suspended={props.user.status.current.includes(GQLUSER_STATUS.SUSPENDED)}
premod={props.user.status.current.includes(GQLUSER_STATUS.PREMOD)}
warned={props.user.status.current.includes(GQLUSER_STATUS.WARNED)}
/>
);
};
@@ -0,0 +1,10 @@
.label {
margin-bottom: 0;
}
.required {
color: var(--palette-text-100);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-primary-regular);
font-size: var(--font-size-2);
}
@@ -0,0 +1,86 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, RefObject, useCallback } from "react";
import { Field, Form } from "react-final-form";
import { required } from "coral-framework/lib/validation";
import {
Button,
Flex,
HelperText,
HorizontalGutter,
Label,
Textarea,
} from "coral-ui/components/v2";
import styles from "./WarnForm.css";
interface Props {
onCancel: () => void;
onSubmit: (message: string) => void;
lastFocusableRef: RefObject<any>;
}
const WarnForm: FunctionComponent<Props> = ({
onCancel,
onSubmit,
lastFocusableRef,
}) => {
const onFormSubmit = useCallback(
({ message }) => {
onSubmit(message);
},
[onSubmit]
);
return (
<>
<Form onSubmit={onFormSubmit}>
{({ handleSubmit, invalid, form }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={3}>
<HorizontalGutter spacing={1}>
<Flex alignItems="baseline" spacing={1}>
<Localized id="community-warnModal-message-label">
<Label className={styles.label}>Message</Label>
</Localized>
<Localized id="community-warnModal-message-required">
<span className={styles.required}>Required</span>
</Localized>
</Flex>
<Localized id="community-warnModal-message-description">
<HelperText>
Explain to this user how they should change their behavior
on your site.
</HelperText>
</Localized>
</HorizontalGutter>
<Field component="textarea" name="message" validate={required}>
{({ input }) => (
<Textarea id="warnModal-message" {...input} fullwidth />
)}
</Field>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-warnModal-cancel">
<Button variant="flat" onClick={onCancel}>
Cancel
</Button>
</Localized>
<Localized id="community-warnModal-warnUser">
<Button
ref={lastFocusableRef}
type="submit"
disabled={invalid}
>
Warn User
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</>
);
};
export default WarnForm;
@@ -0,0 +1,103 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
import { Button, Flex, HorizontalGutter } from "coral-ui/components/v2";
import ModalBodyText from "../ModalBodyText";
import ModalHeader from "../ModalHeader";
import ModalHeaderUsername from "../ModalHeaderUsername";
import ChangeStatusModal from "./ChangeStatusModal";
import WarnForm from "./WarnForm";
interface Props {
username: string | null;
open: boolean;
onClose: () => void;
onConfirm: (message: string) => void;
organizationName: string;
success: boolean;
}
const WarnModal: FunctionComponent<Props> = ({
open,
onClose,
onConfirm,
username,
organizationName,
success,
}) => {
const onFormSubmit = useCallback(
(message: string) => {
onConfirm(message);
},
[onConfirm]
);
return (
<ChangeStatusModal
open={open}
onClose={onClose}
aria-labelledby="warnModal-title"
>
{({ lastFocusableRef }) => (
<>
{success && (
<HorizontalGutter spacing={3}>
<Localized
id="community-warnModal-success"
$username={username}
strong={<ModalHeaderUsername />}
>
<ModalHeader>
A warning has been sent to{" "}
<ModalHeaderUsername>{username}</ModalHeaderUsername>
</ModalHeader>
</Localized>
<Flex justifyContent="flex-end" itemGutter="half">
<Localized id="community-suspendModal-success-close">
<Button ref={lastFocusableRef} onClick={onClose}>
Ok
</Button>
</Localized>
</Flex>
</HorizontalGutter>
)}
{!success && (
<HorizontalGutter spacing={3}>
<Localized
id="community-warnModal-areYouSure"
strong={<ModalHeaderUsername />}
$username={username || <NotAvailable />}
>
<ModalHeader id="warnModal-title">
Warn{" "}
<ModalHeaderUsername>
{username || <NotAvailable />}
</ModalHeaderUsername>
?
</ModalHeader>
</Localized>
<Localized id="community-warnModal-consequence">
<ModalBodyText>
A warning can improve a commenters conduct without a
suspension or ban. The user must acknowledge the warning
before they can continue commenting.
</ModalBodyText>
</Localized>
<WarnForm
onCancel={onClose}
onSubmit={onFormSubmit}
lastFocusableRef={lastFocusableRef}
/>
</HorizontalGutter>
)}
</>
)}
</ChangeStatusModal>
);
};
export default WarnModal;
@@ -0,0 +1,84 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
lookup,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
import { WarnUserMutation as MutationTypes } from "coral-admin/__generated__/WarnUserMutation.graphql";
let clientMutationId = 0;
const WarnUserMutation = createMutation(
"warnUser",
(environment: Environment, input: MutationInput<MutationTypes>) => {
const viewer = getViewer(environment)!;
const now = new Date();
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation WarnUserMutation($input: WarnUserInput!) {
warnUser(input: $input) {
user {
id
status {
current
warning {
active
history {
active
createdAt
createdBy {
id
username
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
warnUser: {
user: {
id: input.userID,
status: {
current: lookup<GQLUser>(
environment,
input.userID
)!.status.current.concat(GQLUSER_STATUS.WARNED),
warning: {
active: true,
history: [
{
active: true,
createdBy: {
id: viewer.id,
username: viewer.username,
},
createdAt: now.toISOString(),
},
],
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default WarnUserMutation;
@@ -100,10 +100,34 @@ const RejectCommentMutation = createMutation(
proxy.setValue(true, "viewerDidModerate");
const connections = [
getQueueConnection(store, "REPORTED", input.storyID),
getQueueConnection(store, "PENDING", input.storyID),
getQueueConnection(store, "UNMODERATED", input.storyID),
getQueueConnection(store, "APPROVED", input.storyID),
getQueueConnection(
store,
"REPORTED",
input.storyID,
input.siteID,
input.section
),
getQueueConnection(
store,
"PENDING",
input.storyID,
input.siteID,
input.section
),
getQueueConnection(
store,
"UNMODERATED",
input.storyID,
input.siteID,
input.section
),
getQueueConnection(
store,
"APPROVED",
input.storyID,
input.siteID,
input.section
),
].filter((c) => c);
connections.forEach((con) =>
ConnectionHandler.deleteNode(con!, input.commentID)
@@ -3,7 +3,7 @@ import React, { Component } from "react";
import { graphql } from "react-relay";
import { SetRedirectPathMutation } from "coral-admin/mutations";
import { timeout } from "coral-common/utils";
import { waitFor } from "coral-common/helpers";
import {
MutationProp,
withFragmentContainer,
@@ -31,7 +31,7 @@ class RestrictedContainer extends Component<Props> {
private handleSignInAs = async () => {
await this.props.signOut();
// Wait for new context to propagate.
await timeout();
await waitFor();
void this.props.setRedirectPath({
path: location.pathname + location.search + location.hash,
});
@@ -17,20 +17,23 @@ interface Props {
}
const AddSiteRoute: FunctionComponent<Props> = ({ data }) => {
const { setMessage, clearMessage } = useNotification();
const onSiteEdit = useCallback(
(name: string) => {
setMessage(
<Localized id="configure-sites-edit-success" $site={name}>
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
Changes to {name} have been saved
</AppNotification>
</Localized>
);
},
[setMessage, clearMessage]
);
if (!data || !data.site) {
return null;
}
const { site } = data;
const { setMessage, clearMessage } = useNotification();
const onSiteEdit = useCallback((name: string) => {
setMessage(
<Localized id="configure-sites-edit-success" $site={name}>
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
Changes to {name} have been saved
</AppNotification>
</Localized>
);
}, []);
return (
<ConfigBox
title={
@@ -38,13 +38,6 @@ interface Props {
site?: Site | null;
}
const DashboardContainer: React.FunctionComponent<Props> = (props) => {
if (!props.site) {
return null;
}
const sites = props.query
? props.query.sites.edges.map((edge) => edge.node)
: [];
const [lastUpdated, setLastUpdated] = useState<string>(new Date().toString());
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const [, isRefetching] = useRefetch<
@@ -53,6 +46,12 @@ const DashboardContainer: React.FunctionComponent<Props> = (props) => {
const onRefetch = useCallback(() => {
setLastUpdated(new Date().toString());
}, []);
if (!props.site) {
return null;
}
const sites = props.query
? props.query.sites.edges.map((edge) => edge.node)
: [];
return (
<MainLayout className={styles.root}>
<Popover
@@ -24,11 +24,16 @@ function handleCommentEnteredModerationQueue(
return;
}
const comment = rootField.getLinkedRecord("comment")!;
const commentID = comment.getValue("id")!;
const edgeID = `edge-${commentID}`;
const edgeInQueue = Boolean(store.get(edgeID));
if (edgeInQueue) {
// Comment edge already in the queue, ignore it as it might be just expected race condition,
// unless the server is sending the same response multiple times.
return;
}
comment.setValue(true, "enteredLive");
const commentsEdge = store.create(
`edge-${comment.getValue("id")!}`,
"CommentsEdge"
);
const commentsEdge = store.create(edgeID, "CommentsEdge");
commentsEdge.setValue(comment.getValue("createdAt"), "cursor");
commentsEdge.setLinkedRecord(comment, "node");
const connection = getQueueConnection(store, queue, storyID, siteID, section);
@@ -23,14 +23,12 @@ function handleCommentLeftModerationQueue(
if (!rootField) {
return;
}
const comment = rootField.getLinkedRecord("comment")!;
const commentID = rootField
.getLinkedRecord("comment")!
.getValue("id")! as string;
const commentInStore = store.get(commentID);
if (commentInStore) {
// Mark that the status of the comment was live updated.
commentInStore.setValue(true, "statusLiveUpdated");
}
// Mark that the status of the comment was live updated.
comment.setValue(true, "statusLiveUpdated");
const connection = getQueueConnection(store, queue, storyID, siteID, section);
if (connection) {
const linked = connection.getLinkedRecords("viewNewEdges") || [];
@@ -30,14 +30,12 @@ const SingleModerateSubscription = createSubscription(
`,
variables,
updater: (store) => {
const commentID = store
const comment = store
.getRootField("commentStatusUpdated")!
.getLinkedRecord("comment")!
.getValue("id")! as string;
const commentInStore = store.get(commentID);
if (commentInStore) {
.getLinkedRecord("comment")!;
if (comment) {
// Mark that the status of the comment was live updated.
commentInStore.setValue(true, "statusLiveUpdated");
comment.setValue(true, "statusLiveUpdated");
}
},
})
+4
View File
@@ -400,6 +400,10 @@ export const baseUser = createFixture<GQLUser>({
active: false,
history: [],
},
warning: {
active: false,
history: [],
},
},
});
+5 -5
View File
@@ -6,7 +6,7 @@ import resizePopup from "../dom/resizePopup";
export default function useResizePopup() {
const polling = useRef(true);
const pollTimeout = useRef<any>(null);
const pollTimeout = useRef<number | null>(null);
const pollPopupHeight = useCallback((interval = 200) => {
if (!polling.current) {
@@ -16,7 +16,7 @@ export default function useResizePopup() {
// Save the reference to the browser timeout we create.
pollTimeout.current =
// Create the timeout to fire after the interval.
setTimeout(() => {
window.setTimeout(() => {
// Using requestAnimationFrame, resize the popup, and reschedule the
// resize timeout again in another interval.
window.requestAnimationFrame(() => {
@@ -31,13 +31,13 @@ export default function useResizePopup() {
pollPopupHeight();
return () => {
if (pollTimeout) {
clearTimeout(pollTimeout.current);
if (pollTimeout.current) {
window.clearTimeout(pollTimeout.current);
pollTimeout.current = null;
polling.current = false;
}
};
}, []);
}, [pollPopupHeight]);
const ref = useResizeObserver(() => {
resizePopup();
@@ -831,7 +831,7 @@ Your email address will be used to:
className="CallOut-root CallOut-error CallOut-leftBorder coral coral-login-error"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignFlexStart"
className="CallOut-container"
>
<div
className="CallOut-icon CallOut-error CallOut-leftIcon"
@@ -346,7 +346,7 @@ we require users to create a password.
className="CallOut-root CallOut-error CallOut-leftBorder coral coral-login-error"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignFlexStart"
className="CallOut-container"
>
<div
className="CallOut-icon CallOut-error CallOut-leftIcon"
@@ -270,7 +270,7 @@ exports[`shows server error 1`] = `
className="CallOut-root CallOut-error CallOut-leftBorder coral coral-login-error"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignFlexStart"
className="CallOut-container"
>
<div
className="CallOut-icon CallOut-error CallOut-leftIcon"
@@ -34,7 +34,6 @@ exports[`renders forgot password view 1`] = `
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Go back to sign in page
</a>
@@ -108,7 +108,6 @@ exports[`auth configuration renders all auth enabled 1`] = `
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Forgot your password?
</a>
@@ -373,7 +372,6 @@ exports[`renders sign in view 1`] = `
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Sign up
</a>
@@ -486,7 +484,6 @@ exports[`renders sign in view 1`] = `
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Forgot your password?
</a>
@@ -390,7 +390,6 @@ exports[`renders sign up form 1`] = `
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Sign in
</a>
+9 -1
View File
@@ -5,13 +5,21 @@ type GetCountFunction = (opts?: { reset?: boolean }) => void;
/** Injects a global CoralCount callback into the window object to be used in JSONP */
function injectJSONPCallback(getCount: GetCountFunction) {
(window as any).CoralCount = {
setCount: (data: { ref: string; html: string }) => {
setCount: (data: { ref: string; html: string; count?: number }) => {
// Find all the elements with ref.
const elements = document.querySelectorAll(
`${COUNT_SELECTOR}[data-coral-ref='${data.ref}']`
);
Array.prototype.forEach.call(elements, (element: HTMLElement) => {
element.innerHTML = data.html;
// Because this involves a new field being added to the JSONP response,
// we check that the field is available and is a number before we add
// it.
// TODO: (wyattjoh) remove after 6.3.1
if (data.count !== undefined && typeof data.count === "number") {
element.dataset.coralCount = data.count.toString();
}
});
},
getCount,
@@ -241,6 +241,7 @@ exports[`Inject counts 1`] = `
/>
<span
class="coral-count"
data-coral-count="5"
data-coral-ref="ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw=="
data-coral-url="http://localhost:8080/"
data-notext="true"
@@ -259,6 +260,7 @@ exports[`Inject counts 1`] = `
</span>
<span
class="coral-count"
data-coral-count="5"
data-coral-ref="ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw=="
data-coral-url="http://localhost:8080/"
>
+1
View File
@@ -76,6 +76,7 @@ it("Inject counts", async () => {
ref: "ZmFsc2U7aHR0cDovL2xvY2FsaG9zdDo4MDgwLw==",
html:
'<span class="coral-count-number">5</span> <span class="coral-count-text">Comments</span>',
count: 5,
});
expect(document.body).toMatchSnapshot();
});
@@ -3,7 +3,6 @@ import React, {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import CopyToClipboard from "react-copy-to-clipboard";
@@ -25,53 +24,44 @@ const CopyButton: FunctionComponent<Props> = ({
innerCopied,
...rest
}) => {
let timeout: any = null;
const [copied, setCopied] = useState(false);
// clear time out when we de-scope
useEffect(() => {
return function cleanup() {
clearTimeout(timeout);
};
}, [timeout]);
const timeoutCallback = useCallback(() => {
setCopied(false);
}, [setCopied]);
const handleCopy = useCallback(() => {
setCopied(true);
clearTimeout(timeout);
timeout = setTimeout(timeoutCallback, 500);
}, [timeout, setCopied]);
}, [setCopied]);
const copyBody = useMemo(() => {
if (inner) {
return inner;
// Handle the animation event associated with toggling the copied state.
useEffect(() => {
if (!copied) {
return;
}
return (
<Localized id="framework-copyButton-copy">
<span>Copy</span>
</Localized>
);
}, [inner]);
const copiedBody = useMemo(() => {
if (innerCopied) {
return innerCopied;
}
const timeout = window.setTimeout(() => {
setCopied(false);
}, 500);
return (
<Localized id="framework-copyButton-copied">
<span>Copied!</span>
</Localized>
);
}, [innerCopied]);
return () => {
window.clearTimeout(timeout);
};
}, [copied]);
return (
<CopyToClipboard text={text} onCopy={handleCopy}>
<Button color={color || "mono"} variant="flat" {...rest}>
{copied ? copiedBody : copyBody}
{copied ? (
innerCopied ? (
innerCopied
) : (
<Localized id="framework-copyButton-copied">
<span>Copied!</span>
</Localized>
)
) : inner ? (
inner
) : (
<Localized id="framework-copyButton-copy">
<span>Copy</span>
</Localized>
)}
</Button>
</CopyToClipboard>
);
@@ -1,10 +1,11 @@
import { useMemo } from "react";
import { createDateFormatter } from "coral-common/date";
import { useCoralContext } from "coral-framework/lib/bootstrap";
export default function useDateTimeFormat(options: Intl.DateTimeFormatOptions) {
const { locales } = useCoralContext();
return useMemo(() => new Intl.DateTimeFormat(locales, options), [
return useMemo(() => createDateFormatter(locales, options), [
locales,
options,
]);
+10 -3
View File
@@ -3,7 +3,7 @@ import { Claims, computeExpiresIn, parseAccessTokenClaims } from "./helpers";
/**
* ACCESS_TOKEN_KEY is the key in storage where the accessToken is stored.
*/
const ACCESS_TOKEN_KEY = "coral:v1:accessToken";
const ACCESS_TOKEN_KEY = "coral:v2:accessToken";
export interface AuthState {
/**
@@ -19,7 +19,7 @@ export interface AuthState {
export type AccessTokenProvider = () => string | undefined;
function parseAccessToken(accessToken: string) {
export function parseAccessToken(accessToken: string) {
// Try to parse the access token claims.
const claims = parseAccessTokenClaims(accessToken);
if (!claims) {
@@ -59,6 +59,13 @@ export function retrieveAccessToken() {
}
export function storeAccessToken(accessToken: string) {
// Parse the access token that's trying to be stored now. If it can't be
// parsed, it can't be stored.
const parsed = parseAccessToken(accessToken);
if (!parsed) {
return;
}
try {
// Update the access token in storage.
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
@@ -69,7 +76,7 @@ export function storeAccessToken(accessToken: string) {
}
// Return the parsed access token.
return parseAccessToken(accessToken);
return parsed;
}
export function deleteAccessToken() {
@@ -63,7 +63,10 @@ export interface CoralContext {
eventEmitter: EventEmitter2;
/** Clear session data. */
clearSession: (nextAccessToken?: string | null) => Promise<void>;
clearSession: (
nextAccessToken?: string | null,
ephemeral?: boolean
) => Promise<void>;
/** Change locale and rerender */
changeLocale: (locale: LanguageCode) => Promise<void>;
@@ -23,6 +23,7 @@ import {
AccessTokenProvider,
AuthState,
deleteAccessToken,
parseAccessToken,
retrieveAccessToken,
storeAccessToken,
} from "../auth";
@@ -160,7 +161,10 @@ function createManagedCoralContextProvider(
}
// This is called every time a user session starts or ends.
private clearSession = async (nextAccessToken?: string) => {
private clearSession = async (
nextAccessToken?: string,
ephemeral?: boolean
) => {
// Clear session storage.
void this.state.context.sessionStorage.clear();
@@ -169,7 +173,9 @@ function createManagedCoralContextProvider(
// Parse the claims/token and update storage.
const auth = nextAccessToken
? storeAccessToken(nextAccessToken)
? ephemeral
? parseAccessToken(nextAccessToken)
: storeAccessToken(nextAccessToken)
: deleteAccessToken();
// Create the new environment.
@@ -92,6 +92,7 @@ export default function createManagedSubscriptionClient(
cacheConfig,
observer,
};
request.subscribe = () => {
if (!subscriptionClient) {
subscriptionClient = new SubscriptionClient(url, {
@@ -165,14 +166,26 @@ export default function createManagedSubscriptionClient(
}
}
// When the subscription is disposed, this will be true.
let disposed = false;
return {
dispose: () => {
// Guard against double disposes. Multiple calls to dispose will now
// result in no-ops.
if (disposed) {
return;
}
disposed = true;
let closed = false;
const i = requests.findIndex((r) => r === request);
if (i !== -1) {
// Unsubscribe if available.
if (request.unsubscribe) {
request.unsubscribe();
}
// Remove from requests list.
requests.splice(i, 1);
@@ -182,6 +195,7 @@ export default function createManagedSubscriptionClient(
(requests.length === 0 || requests.every((r) => !r.unsubscribe))
) {
closeClient();
closed = true;
}
}
@@ -196,6 +210,12 @@ export default function createManagedSubscriptionClient(
"with variables:",
JSON.stringify(request.variables)
);
if (closed) {
window.console.debug(
"subscription client disconnecting, no more subscriptions being tracked"
);
}
}
},
};
+33 -13
View File
@@ -74,29 +74,49 @@ export function useFetch<V, R>(
export function useImmediateFetch<V extends {}, R>(
fetch: Fetch<any, V, Promise<R>>,
variables: V,
refetch?: string
refetch?: any
): [R | null, boolean] {
const fetcher = useFetch(fetch);
const [state, setState] = useState<R | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [state, setState] = useState<{ data: R | null; loading: boolean }>({
data: null,
loading: false,
});
useEffect(() => {
async function doTheFetch() {
setState(null);
setLoading(true);
const value = await fetcher(variables);
let aborted = false;
// TODO: Maybe we don't need this timeout?
setTimeout(() => {
setState(value);
setLoading(false);
}, 100 + 50 * Math.random());
async function doTheFetch() {
// Update the state by setting loading to true.
setState((s) => ({ ...s, loading: true }));
try {
// Perform the fetch.
const data = await fetcher(variables);
if (aborted) {
// If we've aborted, we're either unmounting or a variable has changed,
// so don't bother finishing updating the state because another
// request is about to occur.
return;
}
// Update the state with the data.
setState({ data, loading: false });
} catch (err) {
// eslint-disable-next-line no-console
console.error("could not perform fetch", err);
setState({ data: null, loading: false });
}
}
void doTheFetch();
return () => {
aborted = true;
};
}, Object.values(variables).concat(isUndefined(refetch) ? [] : [refetch]));
return [state, loading];
return [state.data, state.loading];
}
/**
@@ -4,18 +4,28 @@ import { CoralContext } from "coral-framework/lib/bootstrap";
import { createMutation } from "coral-framework/lib/relay";
interface SetAccessTokenInput {
/**
* accessToken is the new access token to use for this session or persisted if
* `ephemeral` is falsy.
*/
accessToken: string | null;
/**
* ephemeral when set to true will ensure that the passed token is not
* persisted to storage.
*/
ephemeral?: boolean;
}
const SetAccessTokenMutation = createMutation(
"setAccessToken",
async (
environment: Environment,
input: SetAccessTokenInput,
{ accessToken, ephemeral }: SetAccessTokenInput,
{ clearSession }: CoralContext
) => {
// Clear current session, as we are starting a new one.
await clearSession(input.accessToken);
await clearSession(accessToken, ephemeral);
}
);
+5
View File
@@ -1,6 +1,11 @@
.smallTab {
padding-top: var(--spacing-3);
padding-bottom: var(--spacing-3);
padding-left: var(--spacing-3);
padding-right: var(--spacing-3);
}
.configureTab {
padding-left: var(--spacing-4);
padding-right: var(--spacing-4);
}
+2 -5
View File
@@ -122,7 +122,7 @@ const AppTabBar: FunctionComponent<Props> = (props) => {
{props.showConfigureTab && (
<Tab
className={cn(CLASSES.tabBar.configure, {
className={cn(CLASSES.tabBar.configure, styles.configureTab, {
[styles.smallTab]: !matches,
})}
tabID="CONFIGURE"
@@ -135,10 +135,7 @@ const AppTabBar: FunctionComponent<Props> = (props) => {
</Localized>
) : (
<div>
<Icon size="lg">settings</Icon>
<Localized id="general-tabBar-configure">
<div className={styles.smallText}>Configure</div>
</Localized>
<Icon size="md">settings</Icon>
</div>
)}
</Tab>
@@ -16,7 +16,7 @@ it("Listens to event and calls setAccessToken", () => {
const setAccessToken = createSinonStub(
(s) => s.throws(),
(s) => s.withArgs({ accessToken }).returns(null)
(s) => s.withArgs({ accessToken, ephemeral: true }).returns(null)
);
createRenderer().render(
@@ -16,7 +16,7 @@ export class OnPymLogin extends Component<Props> {
// Sets comment id through pym.
props.pym.onMessage("login", (accessToken) => {
void this.props.setAccessToken({ accessToken });
void this.props.setAccessToken({ accessToken, ephemeral: true });
});
}
+4
View File
@@ -968,6 +968,10 @@ const CLASSES = {
updateButton: "coral coral-emailNotifications-updateButton",
},
mediaPreferences: {
updateButton: "coral coral-mediaPreferences-updateButton",
},
/**
* spinner is the loading indicator.
*/
@@ -23,7 +23,7 @@ function calculateBottomPadding(width: number, height: number) {
return `${(height / width) * 100}%`;
}
const oEmbed: FunctionComponent<Props> = ({
const OEmbed: FunctionComponent<Props> = ({
url,
type,
className,
@@ -47,7 +47,7 @@ const oEmbed: FunctionComponent<Props> = ({
if (containerRef.current) {
setMaxWidth(containerRef.current.offsetWidth);
}
}, [containerRef.current, maxWidth]);
}, [containerRef, maxWidth]);
const onLoad = useCallback(() => {
if (width && height && containerRef && containerRef.current) {
@@ -96,7 +96,7 @@ const oEmbed: FunctionComponent<Props> = ({
);
}
}, 100);
}, [iframeRef, iframeRef.current, containerRef.current, width, height]);
}, [iframeRef, containerRef, width, height]);
return (
<div className={styles.container} ref={containerRef}>
@@ -117,4 +117,4 @@ const oEmbed: FunctionComponent<Props> = ({
);
};
export default oEmbed;
export default OEmbed;
+9
View File
@@ -217,6 +217,15 @@ export const UpdateNotificationSettingsEvent = createViewerNetworkEvent<{
};
}>("updateNotificationSettings");
export const UpdateUserMediaSettingsEvent = createViewerNetworkEvent<{
unfurlEmbeds?: boolean | null;
success: {};
error: {
message: string;
code?: string;
};
}>("updateUserMediaSettings");
/**
* This event is emitted when the viewer updates the story settings.
*/
@@ -1,6 +1,6 @@
import { Environment, RecordSource } from "relay-runtime";
import { timeout } from "coral-common/utils";
import { waitFor } from "coral-common/helpers";
import { CoralContext } from "coral-framework/lib/bootstrap";
import { LOCAL_ID } from "coral-framework/lib/relay";
import { createPromisifiedStorage } from "coral-framework/lib/storage";
@@ -27,7 +27,7 @@ it("init local state", async () => {
localStorage: createPromisifiedStorage(),
};
await initLocalState(environment, context as any);
await timeout();
await waitFor();
expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot();
});
@@ -1,7 +1,7 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { parseQuery } from "coral-common/utils";
import { AuthState, storeAccessToken } from "coral-framework/lib/auth";
import { AuthState, parseAccessToken } from "coral-framework/lib/auth";
import { CoralContext } from "coral-framework/lib/bootstrap";
import { getExternalConfig } from "coral-framework/lib/externalConfig";
import { createAndRetain, initLocalBaseState } from "coral-framework/lib/relay";
@@ -20,7 +20,8 @@ export default async function initLocalState(
const config = await getExternalConfig(context.pym);
if (config) {
if (config.accessToken) {
auth = storeAccessToken(config.accessToken);
// Access tokens passed via the config should not be persisted.
auth = parseAccessToken(config.accessToken);
}
// append body class name if set in config.
if (config.bodyClassName) {
@@ -1,7 +1,8 @@
import { Environment, RecordSource } from "relay-runtime";
import sinon from "sinon";
import { parseQuery, timeout } from "coral-common/utils";
import { waitFor } from "coral-common/helpers";
import { parseQuery } from "coral-common/utils";
import { LOCAL_ID } from "coral-framework/lib/relay";
import { createRelayEnvironment } from "coral-framework/testHelpers";
@@ -39,7 +40,7 @@ it("Should call setCommentID in pym", async () => {
},
};
await commit(environment, { id }, context as any);
await timeout();
await waitFor();
expect(source.get(LOCAL_ID)!.commentID).toEqual(id);
context.pym.sendMessage.verify();
});
@@ -51,7 +52,7 @@ it("Should call setCommentID in pym with empty id", async () => {
},
};
await commit(environment, { id: null }, context as any);
await timeout();
await waitFor();
expect(source.get(LOCAL_ID)!.commentID).toEqual(null);
expect(parseQuery(location.search).commentID).toBeUndefined();
context.pym.sendMessage.verify();
@@ -11,20 +11,36 @@ interface Props {
className?: string;
}
// The comment param is `any` because relay isn't
// smart enough to see that the nested fragments
// on the comment container are compatible.
export function authorHasBadges(comment: any) {
return (
comment &&
comment.author &&
comment.author.badges &&
comment.author.badges.length !== 0
);
}
const AuthorBadgesContainer: FunctionComponent<Props> = ({
comment,
className,
}) => {
if (!comment.author || !comment.author.badges) {
const hasBadges = authorHasBadges(comment);
if (!hasBadges) {
return null;
}
return (
<>
{comment.author.badges.map((badge) => (
<Tag key={badge} color="dark" className={className}>
{badge}
</Tag>
))}
{comment.author &&
comment.author.badges &&
comment.author.badges.map((badge) => (
<Tag key={badge} color="dark" className={className}>
{badge}
</Tag>
))}
</>
);
};
@@ -2,6 +2,7 @@ $commentTimestampColor: var(--palette-grey-500);
.root {
flex: 1;
overflow: hidden;
}
.highlight {
@@ -20,6 +21,23 @@ $commentTimestampColor: var(--palette-grey-500);
font-style: normal;
font-weight: var(--font-weight-primary-semi-bold);
font-size: var(--font-size-2);
line-height: 1.14;
color: $commentTimestampColor;
margin-right: var(--spacing-1);
}
.tags {
margin-right: var(--spacing-1);
}
.badges {
margin-right: var(--spacing-1);
}
.username {
margin-right: var(--spacing-2);
}
.usernameFullRow {
flex-basis: 100%;
}
@@ -8,6 +8,8 @@ import Comment from "./Comment";
it("renders username and body", () => {
const props: PropTypesOf<typeof Comment> = {
username: "Marvin",
tags: "",
badges: "",
body: "Woof",
createdAt: "1995-12-17T03:24:00.000Z",
topBarRight: "topBarRight",
@@ -4,11 +4,10 @@ import React, { FunctionComponent } from "react";
import CLASSES from "coral-stream/classes";
import HTMLContent from "coral-stream/common/HTMLContent";
import Timestamp from "coral-stream/common/Timestamp";
import { Flex, HorizontalGutter } from "coral-ui/components/v2";
import { Flex, HorizontalGutter, MatchMedia } from "coral-ui/components/v2";
import EditedMarker from "./EditedMarker";
import InReplyTo from "./InReplyTo";
import TopBarLeft from "./TopBarLeft";
import styles from "./Comment.css";
@@ -22,7 +21,8 @@ export interface CommentProps {
showEditedMarker?: boolean;
highlight?: boolean;
parentAuthorName?: string | null;
userTags?: React.ReactNode;
tags?: React.ReactNode | null;
badges?: React.ReactNode | null;
collapsed?: boolean;
media?: React.ReactNode;
}
@@ -39,15 +39,35 @@ const Comment: FunctionComponent<CommentProps> = (props) => {
>
<Flex
direction="row"
alignItems="flex-start"
justifyContent="space-between"
className={CLASSES.comment.topBar.$root}
>
<TopBarLeft>
<Flex direction="row" alignItems="center" itemGutter="half">
{props.username && props.username}
{props.userTags}
</Flex>
<Flex direction="row" alignItems="baseline" itemGutter>
<Flex alignItems="center" wrap>
{props.username && (
<MatchMedia lteWidth="mobile">
{(matches) => (
<div
className={cn(styles.username, {
[styles.usernameFullRow]: matches,
})}
>
{props.username}
</div>
)}
</MatchMedia>
)}
<Flex direction="row" alignItems="center" wrap>
{props.tags && (
<Flex alignItems="center" className={styles.tags}>
{props.tags}
</Flex>
)}
{props.badges && (
<Flex alignItems="center" className={styles.badges}>
{props.badges}
</Flex>
)}
<Timestamp
className={cn(styles.timestamp, CLASSES.comment.topBar.timestamp)}
>
@@ -57,7 +77,7 @@ const Comment: FunctionComponent<CommentProps> = (props) => {
<EditedMarker className={CLASSES.comment.topBar.edited} />
)}
</Flex>
</TopBarLeft>
</Flex>
{props.topBarRight && <div>{props.topBarRight}</div>}
</Flex>
@@ -38,7 +38,10 @@ $commenterActionEditColorActive: var(--palette-primary-300);
}
.actionBar {
min-width: 150px;
min-width: 100px;
@media (min-width: $breakpoints-xs) {
min-width: 150px;
}
}
.actionButton {
@@ -47,4 +50,6 @@ $commenterActionEditColorActive: var(--palette-primary-300);
.staticUsername {
color: var(--palette-text-100);
margin-right: var(--spacing-2);
}
@@ -60,6 +60,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
disableCommenting: {
enabled: false,
},
featureFlags: [],
},
indentLevel: 1,
showAuthPopup: noop as any,
@@ -1,14 +1,27 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import { EventEmitter2 } from "eventemitter2";
import React, { Component, MouseEvent } from "react";
import { clearLongTimeout, LongTimeout, setLongTimeout } from "long-settimeout";
import React, {
FunctionComponent,
MouseEvent,
useCallback,
useEffect,
useState,
} from "react";
import { graphql } from "react-relay";
import { isBeforeDate } from "coral-common/utils";
import { getURLWithCommentID } from "coral-framework/helpers";
import { useToggleState } from "coral-framework/hooks";
import { withContext } from "coral-framework/lib/bootstrap";
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
import { GQLSTORY_MODE, GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
import {
GQLFEATURE_FLAG,
GQLSTORY_MODE,
GQLTAG,
GQLUSER_STATUS,
} from "coral-framework/schema";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import {
@@ -24,6 +37,7 @@ import {
} from "coral-stream/mutations";
import { Ability, can } from "coral-stream/permissions";
import { Button, Flex, HorizontalGutter, Icon } from "coral-ui/components/v2";
import MatchMedia from "coral-ui/components/v2/MatchMedia";
import { CommentContainer_comment as CommentData } from "coral-stream/__generated__/CommentContainer_comment.graphql";
import { CommentContainer_settings as SettingsData } from "coral-stream/__generated__/CommentContainer_settings.graphql";
@@ -32,7 +46,7 @@ import { CommentContainer_viewer as ViewerData } from "coral-stream/__generated_
import { isPublished } from "../helpers";
import AnsweredTag from "./AnsweredTag";
import UserBadgesContainer from "./AuthorBadgesContainer";
import UserBadgesContainer, { authorHasBadges } from "./AuthorBadgesContainer";
import ButtonsBar from "./ButtonsBar";
import EditCommentFormContainer from "./EditCommentForm";
import FeaturedTag from "./FeaturedTag";
@@ -48,7 +62,7 @@ import ReplyCommentFormContainer from "./ReplyCommentForm";
import ReportFlowContainer, { ReportButton } from "./ReportFlow";
import ShowConversationLink from "./ShowConversationLink";
import { UsernameContainer, UsernameWithPopoverContainer } from "./Username";
import UserTagsContainer from "./UserTagsContainer";
import UserTagsContainer, { commentHasTags } from "./UserTagsContainer";
import styles from "./CommentContainer.css";
@@ -81,438 +95,469 @@ interface Props {
toggleCollapsed?: () => void;
}
interface State {
showReplyDialog: boolean;
showEditDialog: boolean;
editable: boolean;
showReportFlow: boolean;
}
export class CommentContainer extends Component<Props, State> {
private uneditableTimer: any;
public state = {
showReplyDialog: false,
showEditDialog: false,
editable: this.isEditable(),
showReportFlow: false,
};
constructor(props: Props) {
super(props);
if (this.isEditable()) {
this.uneditableTimer = this.updateWhenNotEditable();
}
}
public componentWillUnmount() {
clearTimeout(this.uneditableTimer);
}
private isEditable() {
const isMyComment = !!(
this.props.viewer &&
this.props.comment.author &&
this.props.viewer.id === this.props.comment.author.id
);
const banned = Boolean(
this.props.viewer &&
this.props.viewer.status.current.includes(GQLUSER_STATUS.BANNED)
);
const suspended = Boolean(
this.props.viewer &&
this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
);
return (
!banned &&
!suspended &&
isMyComment &&
!!this.props.comment.editing.editableUntil &&
isBeforeDate(this.props.comment.editing.editableUntil)
);
}
private toggleReplyDialog = () => {
if (this.props.viewer) {
this.setState((state) => {
if (!state.showReplyDialog) {
ShowReplyFormEvent.emit(this.props.eventEmitter, {
commentID: this.props.comment.id,
});
}
return {
showReplyDialog: !state.showReplyDialog,
};
export const CommentContainer: FunctionComponent<Props> = ({
className,
collapsed,
comment,
disableReplies,
hideAnsweredTag,
hideModerationCarat,
highlight,
indentLevel,
localReply,
onRemoveAnswered,
settings,
showConversationLink,
hideReportButton,
story,
toggleCollapsed,
eventEmitter,
setCommentID,
viewer,
showAuthPopup,
}) => {
const [showReplyDialog, setShowReplyDialog] = useState(false);
const [
showEditDialog,
setShowEditDialog,
toggleShowEditDialog,
] = useToggleState(false);
const [showReportFlow, , toggleShowReportFlow] = useToggleState(false);
const handleShowConversation = useCallback(
(e: MouseEvent) => {
ViewConversationEvent.emit(eventEmitter, {
commentID: comment.id,
from: "COMMENT_STREAM",
});
} else {
void this.props.showAuthPopup({ view: "SIGN_IN" });
}
};
private openEditDialog = () => {
if (this.props.viewer) {
ShowEditFormEvent.emit(this.props.eventEmitter, {
commentID: this.props.comment.id,
// If the feature for read more new tab is enabled, then redirect the
// user.
// If the feature flag for opening this in a new tab is enabled, then we
// don't need to do anything!
if (settings.featureFlags.includes(GQLFEATURE_FLAG.READ_MORE_NEW_TAB)) {
return;
}
// Prevent the event from acting.
e.preventDefault();
// Update the current view to show the comment.
void setCommentID({ id: comment.id });
return false;
},
[eventEmitter, comment.id, settings.featureFlags, setCommentID]
);
const isLoggedIn = !!viewer;
const openEditDialog = useCallback(() => {
if (isLoggedIn) {
ShowEditFormEvent.emit(eventEmitter, {
commentID: comment.id,
});
this.setState((state) => ({
showEditDialog: true,
}));
setShowEditDialog(true);
} else {
void this.props.showAuthPopup({ view: "SIGN_IN" });
void showAuthPopup({ view: "SIGN_IN" });
}
};
}, [isLoggedIn, eventEmitter, comment.id, setShowEditDialog, showAuthPopup]);
private closeEditDialog = () => {
this.setState((state) => ({
showEditDialog: false,
}));
};
const toggleShowReplyDialog = useCallback(() => {
if (isLoggedIn) {
if (!showReplyDialog) {
ShowReplyFormEvent.emit(eventEmitter, {
commentID: comment.id,
});
}
private closeReplyDialog = () => {
this.setState((state) => ({
showReplyDialog: false,
}));
};
private updateWhenNotEditable() {
const ms =
new Date(this.props.comment.editing.editableUntil!).getTime() -
Date.now();
if (ms > 0) {
return setTimeout(() => this.setState({ editable: false }), ms);
setShowReplyDialog((v) => !v);
} else {
void showAuthPopup({ view: "SIGN_IN" });
}
return;
}
}, [
isLoggedIn,
showReplyDialog,
setShowReplyDialog,
showAuthPopup,
eventEmitter,
comment,
]);
private handleShowConversation = (e: MouseEvent) => {
ViewConversationEvent.emit(this.props.eventEmitter, {
commentID: this.props.comment.id,
from: "COMMENT_STREAM",
});
e.preventDefault();
void this.props.setCommentID({ id: this.props.comment.id });
return false;
};
const isViewerBanned = !!viewer?.status.current.includes(
GQLUSER_STATUS.BANNED
);
const isViewerSuspended = !!viewer?.status.current.includes(
GQLUSER_STATUS.SUSPENDED
);
const isViewerWarned = !!viewer?.status.current.includes(
GQLUSER_STATUS.WARNED
);
const isViewerScheduledForDeletion = !!viewer?.scheduledDeletionDate;
private onReportButtonClicked = () => {
this.setState({
showReportFlow: !this.state.showReportFlow,
});
};
private onCloseReportFlow = () => {
this.setState({
showReportFlow: false,
});
};
const isViewerComment =
// Can't edit a comment if you aren't logged in!
!!viewer &&
// Can't edit a comment if there isn't an author on it!
!!comment.author &&
// Can't edit a comment if the comment isn't the viewers!
viewer.id === comment.author.id;
public render() {
const {
comment,
settings,
story,
indentLevel,
localReply,
disableReplies,
showConversationLink,
highlight,
viewer,
className,
hideAnsweredTag,
collapsed,
} = this.props;
const { showReplyDialog, showEditDialog, editable } = this.state;
const hasFeaturedTag = Boolean(
comment.tags.find((t) => t.code === GQLTAG.FEATURED)
);
// We are in a Q&A if the story mode is set to QA.
const isQA = Boolean(story.settings.mode === GQLSTORY_MODE.QA);
// Author is expert if comment is tagged Expert and the
// story mode is Q&A.
const authorIsExpert =
isQA && comment.tags.find((t) => t.code === GQLTAG.EXPERT);
// Only show a button to clear removed answers if
// this comment is by an expert, reply to a top level
// comment (question) with an answer
const showRemoveAnswered = Boolean(
!comment.deleted &&
isQA &&
authorIsExpert &&
indentLevel === 1 &&
this.props.onRemoveAnswered
);
// When we're in Q&A and we are not un-answered (answered)
// and we're a top level comment (no parent), then we
// are an answered question
const hasAnsweredTag = Boolean(
!hideAnsweredTag &&
isQA &&
comment.tags.every((t) => t.code !== GQLTAG.UNANSWERED) &&
!comment.parent
);
const commentTags = (
<>
{hasFeaturedTag && !isQA && (
<FeaturedTag collapsed={this.props.collapsed} />
)}
{hasAnsweredTag && isQA && (
<AnsweredTag collapsed={this.props.collapsed} />
)}
</>
);
const banned = Boolean(
this.props.viewer &&
this.props.viewer.status.current.includes(GQLUSER_STATUS.BANNED)
);
const suspended = Boolean(
this.props.viewer &&
this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
);
const scheduledForDeletion = Boolean(
this.props.viewer && this.props.viewer.scheduledDeletionDate
);
const showCaret =
this.props.viewer &&
this.props.story.canModerate &&
can(this.props.viewer, Ability.MODERATE) &&
!this.props.hideModerationCarat;
if (showEditDialog) {
return (
<div data-testid={`comment-${comment.id}`}>
<EditCommentFormContainer
settings={settings}
comment={comment}
story={story}
onClose={this.closeEditDialog}
/>
</div>
);
}
// Comment is not published after viewer rejected it.
// The editable initial value is the result of the the funtion here.
const [editable, setEditable] = useState(() => {
// Can't edit a comment that the viewer didn't write! If the user is banned
// or suspended too they can't edit.
if (
comment.lastViewerAction === "REJECT" &&
comment.status === "REJECTED"
!isViewerComment ||
isViewerBanned ||
isViewerSuspended ||
isViewerWarned
) {
return <RejectedTombstoneContainer comment={comment} />;
return false;
}
// Comment is not published after edit, so don't render it anymore.
if (comment.lastViewerAction === "EDIT" && !isPublished(comment.status)) {
return null;
// Can't edit a comment if the editable date is before the current date!
if (
!comment.editing.editableUntil ||
!isBeforeDate(comment.editing.editableUntil)
) {
return false;
}
// Comment is editable!
return true;
});
useEffect(() => {
// If the comment is not editable now, it can't be editable in the future,
// so exit!
if (!editable) {
return;
}
// The comment is editable, we should register a callback to remove that
// status when it is no longer editable. We know that the `editableUntil` is
// available because it was editable!
const editableFor =
new Date(comment.editing.editableUntil!).getTime() - Date.now();
if (editableFor <= 0) {
// Can't schedule a timer for the past! The comment is no longer editable.
setEditable(false);
return;
}
// Setup the timeout.
const timeout: LongTimeout | null = setLongTimeout(() => {
// Mark the comment as not editable.
setEditable(false);
}, editableFor);
return () => {
// When this component is disposed, also clear the timeout.
clearLongTimeout(timeout);
};
}, [comment.editing.editableUntil, editable]);
const hasFeaturedTag = comment.tags.some((t) => t.code === GQLTAG.FEATURED);
// We are in a Q&A if the story mode is set to QA.
const isQA = story.settings.mode === GQLSTORY_MODE.QA;
// Author is expert if comment is tagged Expert and the
// story mode is Q&A.
const authorIsExpert: boolean =
isQA && comment.tags.some((t) => t.code === GQLTAG.EXPERT);
// Only show a button to clear removed answers if this comment is by an
// expert, reply to a top level comment (question) with an answer.
const showRemoveAnswered: boolean =
!comment.deleted &&
isQA &&
authorIsExpert &&
indentLevel === 1 &&
!!onRemoveAnswered;
// When we're in Q&A and we are not un-answered (answered) and we're a top
// level comment (no parent), then we are an answered question.
const hasAnsweredTag: boolean =
!hideAnsweredTag &&
isQA &&
comment.tags.every((t) => t.code !== GQLTAG.UNANSWERED) &&
!comment.parent;
const commentTags = (
<>
{hasFeaturedTag && !isQA && <FeaturedTag collapsed={collapsed} />}
{hasAnsweredTag && isQA && <AnsweredTag collapsed={collapsed} />}
</>
);
const showModerationCaret: boolean =
!!viewer &&
story.canModerate &&
can(viewer, Ability.MODERATE) &&
!hideModerationCarat;
if (showEditDialog) {
return (
<div
className={cn(
CLASSES.comment.$root,
`${CLASSES.comment.reacted}-${comment.actionCounts.reaction.total}`,
className
)}
data-testid={`comment-${comment.id}`}
>
<HorizontalGutter>
{!comment.deleted && (
<IndentedComment
indentLevel={indentLevel}
collapsed={collapsed}
body={comment.body}
createdAt={comment.createdAt}
blur={comment.pending || false}
showEditedMarker={comment.editing.edited}
highlight={highlight}
toggleCollapsed={this.props.toggleCollapsed}
parentAuthorName={
comment.parent &&
comment.parent.author &&
comment.parent.author.username
}
staticUsername={
comment.author && (
<>
<UsernameContainer
className={cn(
styles.staticUsername,
CLASSES.comment.topBar.username
)}
comment={comment}
/>
<UserTagsContainer
className={CLASSES.comment.topBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
<UserBadgesContainer
className={CLASSES.comment.topBar.userBadge}
comment={comment}
/>
</>
)
}
username={
comment.author && (
<>
<UsernameWithPopoverContainer
className={CLASSES.comment.topBar.username}
comment={comment}
viewer={viewer}
/>
<UserTagsContainer
className={CLASSES.comment.topBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
<UserBadgesContainer
className={CLASSES.comment.topBar.userBadge}
comment={comment}
/>
</>
)
}
staticTopBarRight={commentTags}
topBarRight={
<Flex alignItems="center" itemGutter>
{commentTags}
{editable && (
<Button
color="stream"
variant="text"
onClick={this.openEditDialog}
className={cn(
CLASSES.comment.topBar.editButton,
styles.editButton
)}
data-testid="comment-edit-button"
>
<Flex alignItems="center" justifyContent="center">
<Icon className={styles.editIcon}>edit</Icon>
<Localized id="comments-commentContainer-editButton">
Edit
</Localized>
</Flex>
</Button>
)}
{showCaret && (
<CaretContainer
comment={comment}
story={story}
viewer={viewer!}
/>
)}
</Flex>
}
media={
<MediaSectionContainer comment={comment} settings={settings} />
}
footer={
<>
<Flex
justifyContent="space-between"
className={CLASSES.comment.actionBar.$root}
>
<ButtonsBar className={styles.actionBar}>
<ReactionButtonContainer
comment={comment}
settings={settings}
viewer={viewer}
readOnly={banned || suspended}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.reactButton
)}
reactedClassName={cn(
styles.actionButton,
CLASSES.comment.actionBar.reactedButton
)}
isQA={story.settings.mode === GQLSTORY_MODE.QA}
/>
{!disableReplies &&
!banned &&
!suspended &&
!scheduledForDeletion && (
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
onClick={this.toggleReplyDialog}
active={showReplyDialog}
disabled={
settings.disableCommenting.enabled ||
story.isClosed
}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.replyButton
)}
/>
)}
<PermalinkButtonContainer
story={story}
commentID={comment.id}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.shareButton
)}
/>
</ButtonsBar>
<ButtonsBar>
{!banned &&
!suspended &&
!this.props.hideReportButton && (
<ReportButton
onClick={this.onReportButtonClicked}
open={this.state.showReportFlow}
viewer={this.props.viewer}
comment={this.props.comment}
/>
)}
</ButtonsBar>
</Flex>
{showConversationLink && (
<ShowConversationLink
className={CLASSES.comment.readMoreOfConversation}
id={`comments-commentContainer-showConversation-${comment.id}`}
onClick={this.handleShowConversation}
href={getURLWithCommentID(
this.props.story.url,
this.props.comment.id
)}
/>
)}
</>
}
/>
)}
{this.state.showReportFlow && (
<ReportFlowContainer
viewer={viewer}
comment={comment}
onClose={this.onCloseReportFlow}
/>
)}
{showReplyDialog && !comment.deleted && (
<ReplyCommentFormContainer
settings={settings}
comment={comment}
story={story}
onClose={this.closeReplyDialog}
localReply={localReply}
/>
)}
{showRemoveAnswered && (
<Localized id="qa-unansweredTab-doneAnswering">
<Button
variant="regular"
color="regular"
className={styles.removeAnswered}
onClick={this.props.onRemoveAnswered}
>
Done
</Button>
</Localized>
)}
</HorizontalGutter>
<div data-testid={`comment-${comment.id}`}>
<EditCommentFormContainer
settings={settings}
comment={comment}
story={story}
onClose={toggleShowEditDialog}
/>
</div>
);
}
}
const hasTags = commentHasTags(story, comment);
const hasBadges = authorHasBadges(comment);
// Comment is not published after viewer rejected it.
if (comment.lastViewerAction === "REJECT" && comment.status === "REJECTED") {
return <RejectedTombstoneContainer comment={comment} />;
}
// Comment is not published after edit, so don't render it anymore.
if (comment.lastViewerAction === "EDIT" && !isPublished(comment.status)) {
return null;
}
return (
<div
className={cn(
CLASSES.comment.$root,
`${CLASSES.comment.reacted}-${comment.actionCounts.reaction.total}`,
className
)}
data-testid={`comment-${comment.id}`}
>
<HorizontalGutter>
{!comment.deleted && (
<IndentedComment
indentLevel={indentLevel}
collapsed={collapsed}
body={comment.body}
createdAt={comment.createdAt}
blur={!!comment.pending}
showEditedMarker={comment.editing.edited}
highlight={highlight}
toggleCollapsed={toggleCollapsed}
parentAuthorName={comment.parent?.author?.username}
staticUsername={
comment.author && (
<Flex direction="row" alignItems="center">
<UsernameContainer
className={cn(
styles.staticUsername,
CLASSES.comment.topBar.username
)}
comment={comment}
/>
<UserTagsContainer
className={CLASSES.comment.topBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
<UserBadgesContainer
className={CLASSES.comment.topBar.userBadge}
comment={comment}
/>
</Flex>
)
}
username={
comment.author && (
<UsernameWithPopoverContainer
className={CLASSES.comment.topBar.username}
comment={comment}
viewer={viewer}
/>
)
}
tags={
comment.author &&
hasTags && (
<UserTagsContainer
className={CLASSES.comment.topBar.userTag}
story={story}
comment={comment}
settings={settings}
/>
)
}
badges={
comment.author &&
hasBadges && (
<UserBadgesContainer
className={CLASSES.comment.topBar.userBadge}
comment={comment}
/>
)
}
staticTopBarRight={commentTags}
topBarRight={
<>
<MatchMedia gteWidth="mobile">
{(matches) => (
<>
<Flex
alignItems="center"
justifyContent="flex-end"
itemGutter
>
{matches ? commentTags : null}
{editable && (
<Button
color="stream"
variant="text"
onClick={openEditDialog}
className={cn(
CLASSES.comment.topBar.editButton,
styles.editButton
)}
data-testid="comment-edit-button"
>
<Flex alignItems="center" justifyContent="center">
<Icon className={styles.editIcon}>edit</Icon>
<Localized id="comments-commentContainer-editButton">
Edit
</Localized>
</Flex>
</Button>
)}
{showModerationCaret && (
<CaretContainer
comment={comment}
story={story}
viewer={viewer!}
/>
)}
</Flex>
{!matches ? commentTags : null}
</>
)}
</MatchMedia>
</>
}
media={
<MediaSectionContainer
comment={comment}
settings={settings}
defaultExpanded={viewer?.mediaSettings?.unfurlEmbeds}
/>
}
footer={
<>
<Flex
justifyContent="space-between"
className={CLASSES.comment.actionBar.$root}
>
<ButtonsBar className={styles.actionBar}>
<ReactionButtonContainer
comment={comment}
settings={settings}
viewer={viewer}
readOnly={
isViewerBanned || isViewerSuspended || isViewerWarned
}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.reactButton
)}
reactedClassName={cn(
styles.actionButton,
CLASSES.comment.actionBar.reactedButton
)}
isQA={story.settings.mode === GQLSTORY_MODE.QA}
/>
{!disableReplies &&
!isViewerBanned &&
!isViewerSuspended &&
!isViewerWarned &&
!isViewerScheduledForDeletion && (
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
onClick={toggleShowReplyDialog}
active={showReplyDialog}
disabled={
settings.disableCommenting.enabled || story.isClosed
}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.replyButton
)}
/>
)}
<PermalinkButtonContainer
story={story}
commentID={comment.id}
className={cn(
styles.actionButton,
CLASSES.comment.actionBar.shareButton
)}
/>
</ButtonsBar>
<ButtonsBar>
{!isViewerBanned &&
!isViewerSuspended &&
!isViewerWarned &&
!hideReportButton && (
<ReportButton
onClick={toggleShowReportFlow}
open={showReportFlow}
viewer={viewer}
comment={comment}
/>
)}
</ButtonsBar>
</Flex>
{showConversationLink && (
<ShowConversationLink
className={CLASSES.comment.readMoreOfConversation}
id={`comments-commentContainer-showConversation-${comment.id}`}
onClick={handleShowConversation}
href={getURLWithCommentID(story.url, comment.id)}
/>
)}
</>
}
/>
)}
{showReportFlow && (
<ReportFlowContainer
viewer={viewer}
comment={comment}
onClose={toggleShowReportFlow}
/>
)}
{showReplyDialog && !comment.deleted && (
<ReplyCommentFormContainer
settings={settings}
comment={comment}
story={story}
onClose={toggleShowReplyDialog}
localReply={localReply}
/>
)}
{showRemoveAnswered && (
<Localized id="qa-unansweredTab-doneAnswering">
<Button
variant="regular"
color="regular"
className={styles.removeAnswered}
onClick={onRemoveAnswered}
>
Done
</Button>
</Localized>
)}
</HorizontalGutter>
</div>
);
};
const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
withSetCommentIDMutation(
@@ -530,6 +575,9 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
badges
role
scheduledDeletionDate
mediaSettings {
unfurlEmbeds
}
...UsernameWithPopoverContainer_viewer
...ReactionButtonContainer_viewer
...ReportFlowContainer_viewer
@@ -606,6 +654,7 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
disableCommenting {
enabled
}
featureFlags
...ReactionButtonContainer_settings
...ReplyCommentFormContainer_settings
...EditCommentFormContainer_settings
@@ -619,4 +668,5 @@ const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
);
export type CommentContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -22,6 +22,10 @@
color: var(--palette-grey-500);
}
.username {
margin-right: var(--spacing-1);
}
.inner {
flex: 1;
}
@@ -5,7 +5,6 @@ import CLASSES from "coral-stream/classes";
import { BaseButton, Flex, Icon, RelativeTime } from "coral-ui/components/v2";
import EditedMarker from "./EditedMarker";
import TopBarLeft from "./TopBarLeft";
import styles from "./CommentToggle.css";
@@ -37,14 +36,24 @@ const CommentToggle: FunctionComponent<Props> = (props) => {
<Flex
direction="row"
justifyContent="space-between"
alignItems="center"
className={cn(styles.inner, CLASSES.comment.topBar.$root)}
>
<TopBarLeft>
<Flex direction="row" alignItems="center" itemGutter="half">
<Flex
direction="row"
alignItems="center"
justifyContent="space-between"
>
<Flex
className={styles.username}
direction="row"
alignItems="center"
itemGutter="half"
>
{props.username && props.username}
{props.userTags}
</Flex>
<Flex direction="row" alignItems="baseline" itemGutter>
<Flex direction="row" alignItems="baseline" itemGutter wrap>
<RelativeTime
className={cn(
styles.timestamp,
@@ -56,7 +65,7 @@ const CommentToggle: FunctionComponent<Props> = (props) => {
<EditedMarker className={CLASSES.comment.topBar.edited} />
)}
</Flex>
</TopBarLeft>
</Flex>
{props.topBarRight && <div>{props.topBarRight}</div>}
</Flex>
</Flex>
@@ -1,6 +1,3 @@
$commentsBorder: var(--palette-grey-300);
.commentFormBox {
border: 1px solid $commentsBorder;
border-radius: var(--round-corners);
.root {
margin-top: var(--spacing-2);
}
@@ -11,6 +11,8 @@ import CommentForm from "../../Stream/CommentForm";
import TopBarLeft from "../TopBarLeft";
import Username from "../Username";
import styles from "./EditCommentForm.css";
export interface EditCommentFormProps {
id: string;
className?: string;
@@ -63,6 +65,7 @@ const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
expired={props.expired}
placeholder="Edit comment"
placeHolderId="comments-editCommentForm-rte"
topBorder={true}
disabledMessage={
<Localized id="comments-editCommentForm-editTimeExpired">
Edit time has expired. You can no longer edit this comment. Why not
@@ -76,6 +79,7 @@ const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
</AriaInfo>
</Localized>
}
className={styles.root}
rteConfig={props.rteConfig}
/>
</div>
@@ -1,4 +1,5 @@
import { CoralRTE } from "@coralproject/rte";
import { clearLongTimeout, LongTimeout, setLongTimeout } from "long-settimeout";
import React, { Component } from "react";
import { graphql } from "react-relay";
@@ -15,6 +16,7 @@ import CLASSES from "coral-stream/classes";
import { EditCommentFormContainer_comment as CommentData } from "coral-stream/__generated__/EditCommentFormContainer_comment.graphql";
import { EditCommentFormContainer_settings as SettingsData } from "coral-stream/__generated__/EditCommentFormContainer_settings.graphql";
import { EditCommentFormContainer_story as StoryData } from "coral-stream/__generated__/EditCommentFormContainer_story.graphql";
import {
getSubmitStatus,
shouldTriggerSettingsRefresh,
@@ -71,7 +73,7 @@ function getMediaFromComment(comment: CommentData) {
}
export class EditCommentFormContainer extends Component<Props, State> {
private expiredTimer: any;
private expiredTimer?: LongTimeout;
private intitialValues = {
body: this.props.comment.body || "",
media: getMediaFromComment(this.props.comment),
@@ -91,7 +93,9 @@ export class EditCommentFormContainer extends Component<Props, State> {
}
public componentWillUnmount() {
clearTimeout(this.expiredTimer);
if (this.expiredTimer) {
clearLongTimeout(this.expiredTimer);
}
}
private updateWhenExpired() {
@@ -99,7 +103,7 @@ export class EditCommentFormContainer extends Component<Props, State> {
new Date(this.props.comment.editing.editableUntil!).getTime() -
Date.now();
if (ms > 0) {
return setTimeout(() => this.setState({ expired: true }), ms);
return setLongTimeout(() => this.setState({ expired: true }), ms);
}
return;
}
@@ -19,42 +19,40 @@ export interface IndentedCommentProps
toggleCollapsed?: () => void;
staticUsername: React.ReactNode;
staticTopBarRight: React.ReactNode;
tags?: React.ReactNode | null;
badges?: React.ReactNode | null;
}
const IndentedComment: FunctionComponent<IndentedCommentProps> = (props) => {
const {
staticTopBarRight,
staticUsername,
indentLevel,
toggleCollapsed,
...rest
} = props;
const CommentToggleElement = (
<CommentToggle
{...rest}
toggleCollapsed={toggleCollapsed}
username={staticUsername}
topBarRight={staticTopBarRight}
/>
);
const CommentElement = <Comment {...rest} />;
const CommentwithIndent = (
const IndentedComment: FunctionComponent<IndentedCommentProps> = ({
staticTopBarRight,
staticUsername,
indentLevel,
toggleCollapsed,
blur,
...rest
}) => {
return (
<Indent
level={indentLevel}
collapsed={rest.collapsed}
className={cn(
{
[styles.open]: !rest.collapsed,
[styles.blur]: props.blur,
[styles.blur]: blur,
[CLASSES.comment.collapseToggle.collapsed]: rest.collapsed,
},
CLASSES.comment.collapseToggle.indent
)}
>
{rest.collapsed ? (
CommentToggleElement
<CommentToggle
{...rest}
toggleCollapsed={toggleCollapsed}
username={staticUsername}
topBarRight={staticTopBarRight}
/>
) : (
<Flex alignItems="flex-start" spacing={1}>
<Flex alignItems="baseline" spacing={1}>
{toggleCollapsed && (
<Localized
id="comments-collapse-toggle"
@@ -79,12 +77,11 @@ const IndentedComment: FunctionComponent<IndentedCommentProps> = (props) => {
</BaseButton>
</Localized>
)}
{CommentElement}
<Comment {...rest} />
</Flex>
)}
</Indent>
);
return CommentwithIndent;
};
export default IndentedComment;
@@ -18,14 +18,16 @@ import styles from "./MediaSectionContainer.css";
interface Props {
comment: MediaSectionContainer_comment;
settings: MediaSectionContainer_settings;
defaultExpanded: boolean | null | undefined;
}
const MediaSectionContainer: FunctionComponent<Props> = ({
comment,
settings,
defaultExpanded,
}) => {
const { revision } = comment;
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(defaultExpanded ? true : false);
const onToggleExpand = useCallback(() => {
setExpanded((v) => !v);
}, []);
@@ -1,14 +1,9 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import Responsive from "react-responsive";
import CLASSES from "coral-stream/classes";
import {
ClickOutside,
Flex,
Icon,
MatchMedia,
Popover,
} from "coral-ui/components/v2";
import { ClickOutside, Flex, Icon, Popover } from "coral-ui/components/v2";
import { Button } from "coral-ui/components/v3";
import PermalinkPopover from "./PermalinkPopover";
@@ -67,11 +62,11 @@ const Permalink: FunctionComponent<PermalinkProps> = ({
>
<Flex alignItems="center" container="span">
<Icon className={styles.icon}>share</Icon>
<MatchMedia gtWidth="xs">
<Responsive minWidth={400}>
<Localized id="comments-permalinkButton-share">
<span>Share</span>
</Localized>
</MatchMedia>
</Responsive>
</Flex>
</Button>
</Localized>
@@ -1,8 +1,9 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React from "react";
import Responsive from "react-responsive";
import { Flex, Icon, MatchMedia } from "coral-ui/components/v2";
import { Flex, Icon } from "coral-ui/components/v2";
import { Button } from "coral-ui/components/v3";
import styles from "./ReactionButton.css";
@@ -59,7 +60,7 @@ function render(props: ReactionButtonProps) {
{reacted ? (iconActive ? iconActive : icon) : icon}
</Icon>
)}
<MatchMedia gtWidth="xs">
<Responsive minWidth={400}>
{props.isQA ? (
<span>
{reacted ? (
@@ -71,7 +72,7 @@ function render(props: ReactionButtonProps) {
) : (
<span>{reacted ? labelActive : label}</span>
)}
</MatchMedia>
</Responsive>
{!!totalReactions && (
<span className={styles.totalReactions}>{totalReactions}</span>
)}
@@ -1,7 +1,8 @@
import { Localized } from "@fluent/react/compat";
import React, { EventHandler, FunctionComponent, MouseEvent } from "react";
import Responsive from "react-responsive";
import { Flex, Icon, MatchMedia } from "coral-ui/components/v2";
import { Flex, Icon } from "coral-ui/components/v2";
import { Button } from "coral-ui/components/v3";
import styles from "./ReplyButton.css";
@@ -31,11 +32,11 @@ const ReplyButton: FunctionComponent<Props> = (props) => (
>
<Flex alignItems="center" container="span">
<Icon className={styles.icon}>reply</Icon>
<MatchMedia gtWidth="xs">
<Responsive minWidth={400}>
<Localized id="comments-replyButton-reply">
<span>Reply</span>
</Localized>
</MatchMedia>
</Responsive>
</Flex>
</Button>
</Localized>
@@ -47,6 +47,7 @@ const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = (props) => {
return (
<div>
<CommentForm
rteRef={props.rteRef}
siteID={props.siteID}
onSubmit={props.onSubmit}
initialValues={props.initialValues}
@@ -4,7 +4,8 @@ import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import sinon from "sinon";
import { pureMerge, timeout } from "coral-common/utils";
import { waitFor } from "coral-common/helpers";
import { pureMerge } from "coral-common/utils";
import { createPromisifiedStorage } from "coral-framework/lib/storage";
import { removeFragmentRefs, wait } from "coral-framework/testHelpers";
import { DeepPartial, PropTypesOf } from "coral-framework/types";
@@ -75,7 +76,7 @@ it("renders correctly", async () => {
const renderer = createRenderer();
renderer.render(<ReplyCommentFormContainerN {...props} />);
await timeout();
await waitFor();
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -89,7 +90,7 @@ it("renders with initialValues", async () => {
const renderer = createRenderer();
renderer.render(<ReplyCommentFormContainerN {...props} />);
await timeout();
await waitFor();
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -102,7 +103,7 @@ it("save values", async () => {
);
const wrapper = shallow(<ReplyCommentFormContainerN {...props} />);
await timeout();
await waitFor();
wrapper.update();
wrapper
.first()
@@ -131,7 +132,7 @@ it("creates a comment", async () => {
);
const wrapper = shallow(<ReplyCommentFormContainerN {...props} />);
await timeout();
await waitFor();
wrapper.update();
wrapper.first().props().onSubmit(input, form);
expect(
@@ -145,7 +146,7 @@ it("creates a comment", async () => {
...input,
})
).toBeTruthy();
await timeout();
await waitFor();
expect(onCloseStub.calledOnce).toBe(true);
});
@@ -161,7 +162,7 @@ it("closes on cancel", async () => {
);
const wrapper = shallow(<ReplyCommentFormContainerN {...props} />);
await timeout();
await waitFor();
wrapper.update();
wrapper.findWhere((w) => !!w.prop("onCancel")).prop("onCancel")();
@@ -183,7 +184,7 @@ it("autofocuses", async () => {
});
const wrapper = shallow(<ReplyCommentFormContainerN {...props} />);
await timeout();
await waitFor();
wrapper.update();
wrapper
.findWhere((n) => n.prop("rteRef"))
@@ -206,7 +207,7 @@ it("renders when story has been closed", async () => {
const renderer = createRenderer();
renderer.render(<ReplyCommentFormContainerN {...props} />);
await timeout();
await waitFor();
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -221,6 +222,6 @@ it("renders when commenting has been disabled", async () => {
});
const renderer = createRenderer();
renderer.render(<ReplyCommentFormContainerN {...props} />);
await timeout();
await waitFor();
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -2,6 +2,7 @@ import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent, useCallback, useMemo } from "react";
import { graphql } from "react-relay";
import Responsive from "react-responsive";
import { withFragmentContainer } from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
@@ -9,7 +10,7 @@ import {
ShowAuthPopupMutation,
withShowAuthPopupMutation,
} from "coral-stream/mutations";
import { Flex, Icon } from "coral-ui/components/v2";
import { Flex, Icon, MatchMedia } from "coral-ui/components/v2";
import { Button } from "coral-ui/components/v3";
import { ReportButton_comment } from "coral-stream/__generated__/ReportButton_comment.graphql";
@@ -35,7 +36,7 @@ const ReportButton: FunctionComponent<Props> = ({
}) => {
const onClickReport = useCallback(() => {
onClick();
}, []);
}, [onClick]);
const isLoggedIn = useMemo(() => {
return Boolean(viewer);
@@ -55,42 +56,62 @@ const ReportButton: FunctionComponent<Props> = ({
if (isReported) {
return (
<div
className={cn(
CLASSES.comment.actionBar.reportedButton,
styles.reported
)}
data-testid="comment-reported-button"
<Localized
id="comments-reportButton-aria-reported"
attrs={{ "aria-label": true }}
>
<Flex alignItems="center">
<Icon size="sm" className={styles.icon}>
flag
</Icon>
<Localized id="comments-reportButton-reported">Reported</Localized>
</Flex>
</div>
<div
className={cn(
CLASSES.comment.actionBar.reportedButton,
styles.reported
)}
data-testid="comment-reported-button"
>
<Flex alignItems="center">
<Icon size="sm" className={styles.icon}>
flag
</Icon>
<MatchMedia gteWidth="mobile">
{(matches) =>
matches ? (
<Localized id="comments-reportButton-reported">
Reported
</Localized>
) : null
}
</MatchMedia>
</Flex>
</div>
</Localized>
);
}
return (
<Button
className={cn(CLASSES.comment.actionBar.reportButton)}
variant={open ? "filled" : "flat"}
active={Boolean(open)}
color="secondary"
fontSize="small"
fontWeight="semiBold"
paddingSize="extraSmall"
onClick={isLoggedIn ? onClickReport : signIn}
data-testid="comment-report-button"
<Localized
id="comments-reportButton-aria-report"
attrs={{ "aria-label": true }}
>
<Flex alignItems="center" container="span">
<Icon size="sm" className={styles.icon}>
flag
</Icon>
<Localized id="comments-reportButton-report">Report</Localized>
</Flex>
</Button>
<Button
className={cn(CLASSES.comment.actionBar.reportButton)}
variant={open ? "filled" : "flat"}
active={Boolean(open)}
color="secondary"
fontSize="small"
fontWeight="semiBold"
paddingSize="extraSmall"
onClick={isLoggedIn ? onClickReport : signIn}
data-testid="comment-report-button"
>
<Flex alignItems="center" container="span">
<Icon size="sm" className={styles.icon}>
flag
</Icon>
<Responsive minWidth={400}>
<Localized id="comments-reportButton-report">Report</Localized>
</Responsive>
</Flex>
</Button>
</Localized>
);
};
@@ -19,13 +19,12 @@ const ReportFlowContainer: FunctionComponent<Props> = ({
comment,
onClose,
}) => {
if (!viewer) {
return null;
}
const onFormClose = useCallback(() => {
onClose();
}, [onClose]);
if (!viewer) {
return null;
}
return <ReportCommentFormContainer comment={comment} onClose={onFormClose} />;
};
@@ -3,6 +3,12 @@ $commentsConversationLinkColorHovered: var(--palette-primary-700);
$commentsConversationLinkColorActive: var(--palette-primary-800);
.conversationLink {
overflow: hidden;
display: block;
text-align: left;
white-space: break-spaces;
line-height: 1rem;
&.sizeRegular {
padding: 0;
}
@@ -29,7 +29,7 @@ const ShowConversationLink: FunctionComponent<ShowConversationLinkProps> = (
mouseHover: styles.mouseHover,
disabled: styles.disabled,
}),
[styles]
[]
);
return (
@@ -42,7 +42,7 @@ const ShowConversationLink: FunctionComponent<ShowConversationLinkProps> = (
classes={classesOverride}
href={props.href}
onClick={props.onClick}
target="_parent"
target="_blank"
anchor
>
Read More of this Conversation &gt;
@@ -20,15 +20,47 @@ interface Props {
className?: string;
}
function storyIsInQAMode(story: UserTagsContainer_story) {
return story.settings.mode === GQLSTORY_MODE.QA;
}
function hasStaffTag(comment: UserTagsContainer_comment) {
return comment.tags.find((t) => t.code === "STAFF");
}
function hasExpertTag(
story: UserTagsContainer_story,
comment: UserTagsContainer_comment
) {
const isQA = storyIsInQAMode(story);
return isQA && comment.tags.find((t) => t.code === "EXPERT");
}
// The comment and story params are `any` because relay
// isn't smart enough to see that the nested fragments
// on the comment container are compatible.
export function commentHasTags(story: any, comment: any) {
const staffTag = hasStaffTag(comment);
const expertTag = hasExpertTag(story, comment);
const hasTags = staffTag || expertTag;
return hasTags;
}
const UserTagsContainer: FunctionComponent<Props> = ({
story,
settings,
comment,
className,
}) => {
const isQA = story.settings.mode === GQLSTORY_MODE.QA;
const staffTag = comment.tags.find((t) => t.code === "STAFF");
const expertTag = isQA && comment.tags.find((t) => t.code === "EXPERT");
const staffTag = hasStaffTag(comment);
const expertTag = hasExpertTag(story, comment);
const hasTags = commentHasTags(story, comment);
if (!hasTags) {
return null;
}
return (
<Flex alignItems="center">
{expertTag && (
@@ -7,23 +7,27 @@ exports[`renders username and body 1`] = `
size="half"
>
<ForwardRef(forwardRef)
alignItems="flex-start"
className="coral coral-comment-topBar"
direction="row"
justifyContent="space-between"
>
<TopBarLeft>
<ForwardRef(forwardRef)
alignItems="center"
wrap={true}
>
<MatchMediaWithContext
lteWidth="mobile"
>
[Function]
</MatchMediaWithContext>
<ForwardRef(forwardRef)
alignItems="center"
direction="row"
itemGutter="half"
>
Marvin
</ForwardRef(forwardRef)>
<ForwardRef(forwardRef)
alignItems="baseline"
direction="row"
itemGutter={true}
wrap={true}
>
<TimeStamp
className="Comment-timestamp coral coral-timestamp coral-comment-timestamp"
>
@@ -33,7 +37,7 @@ exports[`renders username and body 1`] = `
className="coral coral-comment-edited"
/>
</ForwardRef(forwardRef)>
</TopBarLeft>
</ForwardRef(forwardRef)>
<div>
topBarRight
</div>
@@ -6,7 +6,7 @@ exports[`renders correctly 1`] = `
level={1}
>
<ForwardRef(forwardRef)
alignItems="flex-start"
alignItems="baseline"
spacing={1}
>
<Comment
@@ -29,8 +29,8 @@ exports[`renders correctly 1`] = `
>
reply
</ForwardRef(forwardRef)>
<MatchMediaWithContext
gtWidth="xs"
<MediaQuery
minWidth={400}
>
<Localized
id="comments-replyButton-reply"
@@ -39,7 +39,7 @@ exports[`renders correctly 1`] = `
Reply
</span>
</Localized>
</MatchMediaWithContext>
</MediaQuery>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</Localized>

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