mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85947af74 | ||
|
|
abfcdcc933 | ||
|
|
d001fcd456 | ||
|
|
a49b0d6fa7 | ||
|
|
f47c8661da | ||
|
|
d67c3d2907 | ||
|
|
3398bd12c4 | ||
|
|
fc6de50dde | ||
|
|
6c32316595 | ||
|
|
47abd030ef | ||
|
|
5ffb53d0eb | ||
|
|
af842e98ee | ||
|
|
281f19d21c | ||
|
|
45cd0e6628 | ||
|
|
adc508c7dc | ||
|
|
88a77f8712 | ||
|
|
5d2ab3d061 | ||
|
|
df6f666447 | ||
|
|
e412b27c1e | ||
|
|
0fe0be832e | ||
|
|
288809ee14 | ||
|
|
b97ec638b2 | ||
|
|
2bbfbc7ddd | ||
|
|
b029a01bd9 | ||
|
|
6fabeb2755 | ||
|
|
459e11a5fa | ||
|
|
ce7d6969dd | ||
|
|
6a8d6b07d6 | ||
|
|
8ede245a8d | ||
|
|
88eb9555c7 | ||
|
|
ce53134736 | ||
|
|
0f1dd7d707 | ||
|
|
9d02391062 | ||
|
|
8aac2d33fc | ||
|
|
3eddf5f155 | ||
|
|
94ff8ff880 | ||
|
|
7dfc064c9f | ||
|
|
d602cf873c | ||
|
|
79627af8d6 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+77
-29
@@ -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
@@ -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"
|
||||
|
||||
+1
-3
@@ -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
@@ -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";
|
||||
|
||||
+10
@@ -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 commenter’s 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");
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -400,6 +400,10 @@ export const baseUser = createFixture<GQLUser>({
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
warning: {
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
+7
-3
@@ -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}
|
||||
|
||||
+11
-10
@@ -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 >
|
||||
|
||||
@@ -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>
|
||||
|
||||
+375
-935
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@ exports[`renders correctly 1`] = `
|
||||
level={1}
|
||||
>
|
||||
<ForwardRef(forwardRef)
|
||||
alignItems="flex-start"
|
||||
alignItems="baseline"
|
||||
spacing={1}
|
||||
>
|
||||
<Comment
|
||||
|
||||
+3
-3
@@ -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
Reference in New Issue
Block a user