mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e180cf87d7 | ||
|
|
de976a5397 | ||
|
|
257830a6c5 | ||
|
|
518056f382 | ||
|
|
84936ca35c | ||
|
|
4194b08a12 | ||
|
|
98e6a3ccc7 | ||
|
|
64f5171ff2 | ||
|
|
6711f09a79 | ||
|
|
8966a8201b | ||
|
|
7fc0e2e489 | ||
|
|
7c3c510bbc | ||
|
|
94ece10284 | ||
|
|
ef01f427de | ||
|
|
5db096942a | ||
|
|
4645ed4cd7 | ||
|
|
7fd524cd6a | ||
|
|
3cb5d0ff9f | ||
|
|
44216221a4 | ||
|
|
0f9b2ba114 | ||
|
|
071666e8b8 | ||
|
|
2a9a5099dc | ||
|
|
d0f51d8815 | ||
|
|
b228725370 | ||
|
|
58f2f71743 | ||
|
|
8fd266deeb | ||
|
|
1b73a5fcd7 | ||
|
|
9a58673545 | ||
|
|
2dc63d3a27 | ||
|
|
1503b735e5 | ||
|
|
6db72db837 | ||
|
|
18b274a6a9 | ||
|
|
78cfb4cac8 | ||
|
|
d513e3807a | ||
|
|
faf4ad9480 | ||
|
|
e5ea1ee179 | ||
|
|
75c0bad073 | ||
|
|
8b92878e9b | ||
|
|
2b2ff7b9c4 | ||
|
|
2057b750f3 | ||
|
|
7abd703f72 | ||
|
|
6f1014289b | ||
|
|
45b778c522 | ||
|
|
7d967fc93b | ||
|
|
ae7b0fbb37 | ||
|
|
2478e9a250 | ||
|
|
fe8b6535a9 | ||
|
|
7989f87d02 | ||
|
|
769ab2a910 | ||
|
|
c052d37a6f | ||
|
|
a059e70d05 | ||
|
|
1a3401710f | ||
|
|
184f145c4a | ||
|
|
dcb2a10e72 | ||
|
|
7497e33046 | ||
|
|
330c30f1b3 | ||
|
|
e2185a33f5 | ||
|
|
d6db287c55 | ||
|
|
ca52cc3253 | ||
|
|
c2dadcc034 | ||
|
|
6865a71684 | ||
|
|
33556044a8 | ||
|
|
d883ab029c | ||
|
|
4b637a2dd5 | ||
|
|
e42c2b925d | ||
|
|
34ba2da88d | ||
|
|
cec76e0046 | ||
|
|
707d65a119 | ||
|
|
014aa2d86a | ||
|
|
94e1c75c0b | ||
|
|
3014017976 | ||
|
|
1388e0bc5e | ||
|
|
86191e388a | ||
|
|
2bf3e34218 | ||
|
|
75818cf808 | ||
|
|
503eb0b06a | ||
|
|
9cc3a57295 | ||
|
|
e42cb59661 | ||
|
|
a1a8652f7e | ||
|
|
a7b2af85fc | ||
|
|
516611e289 | ||
|
|
190010c898 | ||
|
|
2b1c9aea78 | ||
|
|
9c4a9753bb | ||
|
|
e43a3ddf75 | ||
|
|
6364eced63 | ||
|
|
62ce3a2c49 | ||
|
|
48d815aebb | ||
|
|
38ccbd0ef8 | ||
|
|
cfebe7733e | ||
|
|
e7b83c6b39 | ||
|
|
5c46884a97 | ||
|
|
4bd929f3f0 | ||
|
|
785735d36a | ||
|
|
20da16331e |
@@ -13,7 +13,7 @@ job_defaults: &job_defaults
|
||||
environment:
|
||||
<<: *job_environment
|
||||
|
||||
version: 2
|
||||
version: 2.1
|
||||
jobs:
|
||||
# npm_dependencies will install the dependencies used by all other steps.
|
||||
npm_dependencies:
|
||||
@@ -65,11 +65,13 @@ jobs:
|
||||
name: Lint Source Code
|
||||
command: npm run lint
|
||||
- run:
|
||||
name: Lint README.md
|
||||
name: Lint Markdown
|
||||
command: |
|
||||
cp README.md README.md.orig
|
||||
npm run doctoc
|
||||
diff -q README.md README.md.orig
|
||||
git diff --exit-code
|
||||
- run:
|
||||
name: Lint Versions
|
||||
command: npx @coralproject/package-version-lint
|
||||
|
||||
# unit_tests will run the unit tests.
|
||||
unit_tests:
|
||||
@@ -142,6 +144,9 @@ jobs:
|
||||
<<: *job_defaults
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Verify release version
|
||||
command: npx @coralproject/package-version-lint --expect ${CIRCLE_TAG/#v}
|
||||
- setup_remote_docker
|
||||
- deploy:
|
||||
name: Deploy the code
|
||||
@@ -168,7 +173,6 @@ filter_develop: &filter_develop
|
||||
- next
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
build-test:
|
||||
jobs:
|
||||
- docker_tests:
|
||||
|
||||
@@ -1,14 +1,48 @@
|
||||
<!--
|
||||
Thank you for submitting a pull request! Please note that by contributing to Coral, you agree to our Code of Conduct: http://code-of-conduct.voxmedia.com/
|
||||
|
||||
Before submitting your PR, please verify that:
|
||||
Thank you for submitting a pull request! Please note that by contributing to
|
||||
Coral, you agree to our Code of Conduct: http://code-of-conduct.voxmedia.com/
|
||||
|
||||
Before submitting your Pull Request (or PR), please verify that:
|
||||
|
||||
* [ ] Your code is up-to-date with the base branch
|
||||
* [ ] You've successfully run `npm run test` locally
|
||||
|
||||
Refer to CONTRIBUTING.MD for more details.
|
||||
|
||||
https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md
|
||||
|
||||
-->
|
||||
|
||||
## What does this PR do?
|
||||
|
||||
<!--
|
||||
|
||||
In this section, you should be describing what other Github issues or tickets
|
||||
that this PR is designed to addressed.
|
||||
|
||||
Any related Github issue should be linked by adding its URL to this section.
|
||||
|
||||
-->
|
||||
|
||||
## What changes to the GraphQL/Database Schema does this PR introduce?
|
||||
|
||||
<!--
|
||||
|
||||
In this section, you should describe any changes to be made to the GraphQL
|
||||
schema file (located https://github.com/coralproject/talk/blob/master/src/core/server/graph/schema/schema.graphql) or any
|
||||
database model (located as types in the https://github.com/coralproject/talk/blob/master/src/core/server/models directory).
|
||||
|
||||
If no changes were added to the GraphQL/Database Schema as a part of this PR,
|
||||
simply write "None".
|
||||
|
||||
-->
|
||||
|
||||
## How do I test this PR?
|
||||
|
||||
<!--
|
||||
|
||||
In this section, you should be describing any manual testing that can be used to
|
||||
verify features introduced or bugs fixed in this PR.
|
||||
|
||||
-->
|
||||
|
||||
Vendored
+7
-10
@@ -12,10 +12,10 @@
|
||||
},
|
||||
"tslint.enable": false,
|
||||
"eslint.validate": [
|
||||
{ "language": "javascript", "autoFix": true },
|
||||
{ "language": "typescript", "autoFix": true },
|
||||
{ "language": "typescriptreact", "autoFix": true }
|
||||
],
|
||||
{ "language": "javascript", "autoFix": true },
|
||||
{ "language": "typescript", "autoFix": true },
|
||||
{ "language": "typescriptreact", "autoFix": true }
|
||||
],
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"postcss.validate": false,
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
@@ -44,18 +44,15 @@
|
||||
{
|
||||
"regex": "^[.]",
|
||||
"orderLevel": 50
|
||||
},
|
||||
}
|
||||
],
|
||||
"importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.type": "newLineEachExpressionAfterCountLimitExceptIfOnlyOne",
|
||||
"importSorter.importStringConfiguration.trailingComma": "multiLine",
|
||||
"importSorter.importStringConfiguration.tabSize": 2,
|
||||
"eslint.enable": true,
|
||||
"importSorter.generalConfiguration.exclude": [
|
||||
"d\\.ts$",
|
||||
"__generated__"
|
||||
],
|
||||
"debug.node.autoAttach": "on",
|
||||
"importSorter.generalConfiguration.exclude": ["d\\.ts$", "__generated__"],
|
||||
"search.exclude": {
|
||||
"package-lock.json": true
|
||||
},
|
||||
"debug.node.autoAttach": "on"
|
||||
}
|
||||
|
||||
@@ -1,31 +1,52 @@
|
||||
# Client Events Guide
|
||||
|
||||
This serves as a guide to events emitted by the javascript via the embed events
|
||||
hook, as described below in [Viewer Events](#viewer-events).
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
## Table of Contents
|
||||
|
||||
- [Viewer Events](#viewer-events)
|
||||
- [Viewer Network Events](#viewer-network-events)
|
||||
- [Event List](#event-list)
|
||||
- [Index](#index)
|
||||
- [Events](#events)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
## Viewer Events
|
||||
|
||||
_Viewer Events_ are emitted when the viewer performs certain actions.
|
||||
They can be subscribed to using the `events` parameter in
|
||||
`Coral.createStreamEmbed`.
|
||||
|
||||
```html
|
||||
<script>
|
||||
const CoralStreamEmbed = Coral.createStreamEmbed({
|
||||
events: function(events) {
|
||||
events.onAny(function(eventName, data) {
|
||||
console.log(eventName, data);
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
const CoralStreamEmbed = Coral.createStreamEmbed({
|
||||
events: function(events) {
|
||||
events.onAny(function(eventName, data) {
|
||||
console.log(eventName, data);
|
||||
});
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Example events:
|
||||
|
||||
- `setMainTab {tab: "PROFILE"}`
|
||||
- `showFeaturedCommentTooltip`
|
||||
- `viewConversation {from: "FEATURED_COMMENTS", commentID: "c45fb5f5-03f9-49a3-a755-488c698ca0df"}`
|
||||
|
||||
### Viewer Network Events
|
||||
|
||||
_Viewer Network Events_ are events that involves a network request and thus can succeed or fail. Succeeding events will have a `.success` appended to the event name while failing events have an `.error` appended to the event name.
|
||||
_Viewer Network Events_ are events that involves a network request and thus can succeed or fail. Succeeding events will have a `.success` appended to the event name while failing events have an `.error` appended to the event name.
|
||||
|
||||
Moreover _Viewer Network Events_ contains the `rtt` field which indicates the time it needed from initiating the request until the _UI_ has been updated with the response data.
|
||||
|
||||
Example events:
|
||||
|
||||
```
|
||||
createComment.success
|
||||
{
|
||||
@@ -53,6 +74,7 @@ createComment.error
|
||||
```
|
||||
|
||||
## Event List
|
||||
|
||||
<!-- START docs:events -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN npm run docs:events -->
|
||||
### Index
|
||||
+492
-12
@@ -5,20 +5,60 @@ Welcome! We are very excited that you are interested in contributing to Coral.
|
||||
This document is a companion to help you approach contributing. If it does not
|
||||
do so, please [let us know how we can improve it](https://github.com/coralproject/talk/issues)!
|
||||
|
||||
By contributing to this project you agree to the
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
By contributing to this project you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
## Table of Contents
|
||||
|
||||
- [What should I Contribute?](#what-should-i-contribute)
|
||||
- [Writing Code](#writing-code)
|
||||
- [When should I create an issue?](#when-should-i-create-an-issue)
|
||||
- [What should I include in my issue?](#what-should-i-include-in-my-issue)
|
||||
- [When should I create a pull request?](#when-should-i-create-a-pull-request)
|
||||
- [What should I include in my pull request?](#what-should-i-include-in-my-pull-request)
|
||||
- [Reviewing pull requests](#reviewing-pull-requests)
|
||||
- [Ensure contributions are linted and tested](#ensure-contributions-are-linted-and-tested)
|
||||
- [Review the feature/fixes](#review-the-featurefixes)
|
||||
- [Review architectural decisions](#review-architectural-decisions)
|
||||
- [Verify localizations](#verify-localizations)
|
||||
- [Localization](#localization)
|
||||
- [Documentation](#documentation)
|
||||
- [Design Principles](#design-principles)
|
||||
- [GraphQL](#graphql)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
## What should I Contribute?
|
||||
|
||||
There are at least three ways to contribute to Coral:
|
||||
There are at least three different ways to contribute to Coral:
|
||||
|
||||
- Writing Code
|
||||
- Providing Translations
|
||||
- [Writing Code](#writing-code)
|
||||
- [Reviewing pull requests](#reviewing-pull-requests)
|
||||
- [Localization](#localization)
|
||||
- [Documentation](#documentation)
|
||||
|
||||
Typically these take the form of creating a Pull Request for Coral, and
|
||||
submitting it to be reviewed by a member of our team and the greater Coral
|
||||
community.
|
||||
|
||||
Working on your first Pull Request? You can learn how from this free video
|
||||
series:
|
||||
|
||||
[How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github)
|
||||
|
||||
If you decide to fix an issue, please be sure to check the comment thread in
|
||||
case somebody is already working on a fix. If nobody is working on it at the
|
||||
moment, please leave a comment stating that you intend to work on it so other
|
||||
people don’t accidentally duplicate your effort.
|
||||
|
||||
If somebody claims an issue but doesn’t follow up for more than two weeks, it’s
|
||||
fine to take it over but you should still leave a comment.
|
||||
|
||||
## Writing Code
|
||||
|
||||
Conversation surrounding contributions begins in
|
||||
[issues](https://github.com/coralproject/talk/issues).
|
||||
Conversation surrounding contributions begins when you can create an issue
|
||||
describing your issue or suggestion.
|
||||
|
||||
### When should I create an issue?
|
||||
|
||||
@@ -26,9 +66,7 @@ File an issue as soon as you have an idea of something you'd like to contribute.
|
||||
We would love to hear what you're thinking and help refine the idea to make it
|
||||
into the Coral ecosystem.
|
||||
|
||||
Please file issues if you would like to contribute to Coral.
|
||||
|
||||
### What should I include?
|
||||
### What should I include in my issue?
|
||||
|
||||
Coral has adopted an iterative, agile development philosophy. All contributions
|
||||
that make it into the Coral repository should start with a user story in this
|
||||
@@ -38,8 +76,8 @@ form:
|
||||
|
||||
This exercise does two things:
|
||||
|
||||
- allows us to ground our technical choices in a clear, simple product need.
|
||||
- expresses that product need in a way that doesn't imply a specific technical
|
||||
- Allows us to ground our technical choices in a clear, simple product need.
|
||||
- Expresses that product need in a way that doesn't imply a specific technical
|
||||
solution allowing for debate as to the best way to solve the problem.
|
||||
|
||||
Please feel free to provide as much detail as possible when filing the issue but
|
||||
@@ -49,6 +87,72 @@ technical or design solutions.
|
||||
If you have a specific technical or design solution in mind, please submit it as
|
||||
the first comment on the thread.
|
||||
|
||||
### When should I create a pull request?
|
||||
|
||||
File a pull request if you've created an issue in our [issues](https://github.com/coralproject/talk/issues)
|
||||
page and have heard back from a member or contributor to Coral. This allows our
|
||||
team to review the proposed changes prior to time being spent if the team
|
||||
already has the feature/fix in the road map.
|
||||
|
||||
### What should I include in my pull request?
|
||||
|
||||
When you create a pull request, the template will describe the required
|
||||
components needed for it to be reviewed by a member of the Coral team. You
|
||||
should end up filling out:
|
||||
|
||||
- What does this PR (pull request) do?
|
||||
- How do I test this PR?
|
||||
|
||||
You should describe what Github issue or ticket that the PR is associated with
|
||||
to assist the review process. If this PR is resolving a particular bug, a
|
||||
testing strategy should be described in the testing section. If this PR is
|
||||
contributing a new feature, a description should describe a scenario to test or
|
||||
verify the new functionality.
|
||||
|
||||
## Reviewing pull requests
|
||||
|
||||
Reviewing pull requests in Coral is generally completed by the core Coral team
|
||||
that is composed of developers employed by Vox Media Inc, but external reviews
|
||||
or suggestions are also welcomed.
|
||||
|
||||
Our review process generally follows a few core principles:
|
||||
|
||||
### Ensure contributions are linted and tested
|
||||
|
||||
It is the job of CI linting and tests to notify of style issues within the
|
||||
codebase. If it is not possible for style issues to be encapsulated as a
|
||||
linting rule, it shouldn't be concretely enforced during the review process.
|
||||
This can ensure that code reviews contain more meaningful feedback tied to the
|
||||
contribution rather than nit-picking on stylistic choices.
|
||||
|
||||
Reviewers must ensure that linting and tests pass in CI and locally prior to a
|
||||
review taking place. You can do this by running `npm run generate` followed by
|
||||
`npm run lint` and `npm run test`.
|
||||
|
||||
### Review the feature/fixes
|
||||
|
||||
Any new features added to Coral should be reviewed for bugs through a manual
|
||||
verification process to ensure that they function on your machine. If possible
|
||||
you should review any automated tests that were added (or not added) related to
|
||||
the feature.
|
||||
|
||||
While the Coral team is not strict on test driven development (or TDD), any
|
||||
contributions that include tests are greatly appreciated, and preferred over
|
||||
those that do not.
|
||||
|
||||
### Review architectural decisions
|
||||
|
||||
Any substantial changes made to the codebase should be reviewed to ensure
|
||||
that they conform to the current way code/services are laid out.
|
||||
|
||||
Architecture Decision Records (or [ADR](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions)) are now being used to describe architectural decisions and can be found in the `src/docs/architecture/decisions` directory.
|
||||
|
||||
### Verify localizations
|
||||
|
||||
While we don't have any automated tools at the time of writing that will
|
||||
verify this in CI, any strings being added that are presented to the end user
|
||||
should be wrapped in localization components to support other languages.
|
||||
|
||||
## Localization
|
||||
|
||||
We use the [fluent](http://projectfluent.org/) library and store our
|
||||
@@ -69,3 +173,379 @@ The [Perspective API](https://github.com/conversationai/perspectiveapi/blob/mast
|
||||
also supports comments in specific languages. When the language is supported in
|
||||
Coral and supported by the Perspective API, the language should be added to the
|
||||
language map in `src/core/server/services/comments/pipeline/phases/toxic.ts`.
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation that is publicly shown on
|
||||
[docs.coralproject.net](https://docs.coralproject.net/coral/) is stored under
|
||||
the `release/4` branch as it contains information for versions 4.0 onwards of
|
||||
Coral.
|
||||
|
||||
To contribute new docs, you can either click the "Suggest Edits" in the top
|
||||
right of each page, or you can edit directly via source. We suggest that for
|
||||
individual fixes or contributions to the documentation.
|
||||
|
||||
If you want to contribute via source files, you can follow the procedure
|
||||
outlined below:
|
||||
|
||||
1. Clone the Talk repository via `git clone https://github.com/coralproject/talk.git`
|
||||
2. Switch to the `release/4` branch via `git checkout release/4`
|
||||
3. Follow the procedure outlined on that branch's [CONTRIBUTING.md](https://github.com/coralproject/talk/blob/release/4/CONTRIBUTING.md#contributing-documentation) file for contributing documentation changes
|
||||
4. Create a pull request to merge your changes back into the `release/4` branch
|
||||
|
||||
## Design Principles
|
||||
|
||||
### GraphQL
|
||||
|
||||
Coral relies heavily on [GraphQL](https://graphql.org) as the query language for
|
||||
the API and the runtime on the server that powers resolving data from data
|
||||
sources. This heavily influences a lot of the decisions around how we create and
|
||||
consume it's API internally and how we expose it to others to interact with.
|
||||
|
||||
There are many GraphQL types in our [`schema.graphql`](https://github.com/coralproject/talk/blob/master/src/core/server/graph/schema/schema.graphql)
|
||||
that define the way we handle data in our API. We'll try to outline a few of
|
||||
them here with examples to help you understand their uses.
|
||||
|
||||
#### Types
|
||||
|
||||
Similar to defining an interface or a _struct_ definition, GraphQL has flexible
|
||||
types that can be used to define data types that are used for querying data from
|
||||
the API. This retrieval can happen directly via a query, or after executing an
|
||||
action using a mutation and querying its response result.
|
||||
|
||||
An example of these types is the `Comment` and its nested `CommentRevision`
|
||||
type:
|
||||
|
||||
```graphql
|
||||
"""
|
||||
Comment is a comment left by a User on an Story or another Comment as a reply.
|
||||
"""
|
||||
type Comment {
|
||||
"""
|
||||
id is the identifier of the Comment.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
body is the content of the Comment, and is an alias to the body of the
|
||||
`revision.body`.
|
||||
"""
|
||||
body: String
|
||||
|
||||
"""
|
||||
revision is the current revision of the Comment's body.
|
||||
"""
|
||||
revision: CommentRevision
|
||||
|
||||
"""
|
||||
revisionHistory stores the previous CommentRevision's, with the most recent
|
||||
edit last.
|
||||
"""
|
||||
revisionHistory: [CommentRevision!]!
|
||||
@auth(
|
||||
roles: [MODERATOR, ADMIN]
|
||||
userIDField: "author_id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
)
|
||||
|
||||
"""
|
||||
status represents the Comment's current status.
|
||||
"""
|
||||
status: COMMENT_STATUS!
|
||||
}
|
||||
```
|
||||
|
||||
Notice how the `Comment` type can nest more custom defined types. A `Comment`
|
||||
can have a current `CommentRevision` named `revision`. It also has a list of its
|
||||
historical `revisionHistory`:
|
||||
|
||||
```graphql
|
||||
type CommentRevision {
|
||||
"""
|
||||
id is the identifier of the CommentRevision.
|
||||
"""
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
comment is the reference to the original Comment associated with the current
|
||||
Comment.
|
||||
"""
|
||||
comment: Comment!
|
||||
|
||||
"""
|
||||
actionCounts stores the counts of all the actions for the CommentRevision
|
||||
specifically.
|
||||
"""
|
||||
actionCounts: ActionCounts! @auth(roles: [MODERATOR, ADMIN])
|
||||
|
||||
"""
|
||||
body is the content of the CommentRevision. If null, it indicates that the
|
||||
body text was deleted.
|
||||
"""
|
||||
body: String
|
||||
}
|
||||
```
|
||||
|
||||
Another thing to note, see how `CommentRevision` is not only referenced by
|
||||
`revision` and `revisionHistory` on the `Comment` type. The `CommentRevision`
|
||||
also references back to its parent `Comment` via the `comment: Comment!`
|
||||
property. This is how defined types interact between each other in the GraphQL
|
||||
schema.
|
||||
|
||||
Our naming scheme is upper camel case (also known as Pascal Case) for these
|
||||
types:
|
||||
|
||||
- Start with a capital letter
|
||||
- Following characters are lower case
|
||||
- Every new word in the type name begins with a new capital letter
|
||||
- Acronyms are always capitalized (with the only exception being the
|
||||
`clientMutationId: String!` field in mutation input/payload types)
|
||||
|
||||
Some of the properties have an `!` beside their type (i.e `id: ID!`) which
|
||||
indicates that this property is required and is non-nullable. GraphQL will
|
||||
validate the input request for these properties and ensure they are provided
|
||||
during the GraphQL request.
|
||||
|
||||
You can learn more about GraphQL types in their documentation:
|
||||
|
||||
[Learn GraphQL: Schemas and Types](https://graphql.org/learn/schema/)
|
||||
|
||||
#### Enumeration Types
|
||||
|
||||
In the previous example with the `Comment` type. We also had a property called
|
||||
`status` which was of type `COMMENT_STATUS`.
|
||||
|
||||
This is another kind of defined type, an [Enumeration Type](https://graphql.org/learn/schema/#enumeration-types),
|
||||
also called _enums_.
|
||||
|
||||
```graphql
|
||||
enum COMMENT_STATUS {
|
||||
NONE
|
||||
APPROVED
|
||||
REJECTED
|
||||
PREMOD
|
||||
SYSTEM_WITHHELD
|
||||
}
|
||||
```
|
||||
|
||||
Like all enumeration types this definition enumerates out typed, named values
|
||||
that are reusable for state elsewhere on other types.
|
||||
|
||||
Our naming scheme for enumeration types and values in those types as:
|
||||
|
||||
- All capital letters
|
||||
- Spaces delimited with underscores
|
||||
|
||||
This is because they are treated as shared constant values across the schema.
|
||||
Rather than storing strings or numbers to capture selected state, we prefer
|
||||
using enumeration types because they are much more stricter in terms of value.
|
||||
|
||||
You can learn more about GraphQL Enumeration types in their documentation:
|
||||
|
||||
[Learn GraphQL: Enumeration Types](https://graphql.org/learn/schema/#enumeration-types)
|
||||
|
||||
#### Mutation Types
|
||||
|
||||
Mutations are a request to GraphQL to initiate an action which will result in a response. As such,they're broken up into an `Input` and `Payload` pair that matches a mutation's request and response pair.
|
||||
|
||||
An example is the `CreateCommentInput` and `CreateCommentPayload`:
|
||||
|
||||
```graphql
|
||||
"""
|
||||
CreateCommentInput provides the input for the createComment Mutation.
|
||||
"""
|
||||
input CreateCommentInput {
|
||||
"""
|
||||
storyID is the ID of the Story where we are creating a comment on.
|
||||
"""
|
||||
storyID: ID!
|
||||
|
||||
"""
|
||||
nudge when true will instead return an error related to recoverable moderation
|
||||
faults such as a toxic comment or spam comment to provide user feedback to
|
||||
nudge the user to correct the comment.
|
||||
"""
|
||||
nudge: Boolean = false
|
||||
|
||||
"""
|
||||
body is the Comment body, the content of the Comment.
|
||||
"""
|
||||
body: String!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
"""
|
||||
CreateCommentPayload contains the created Comment after the createComment
|
||||
mutation.
|
||||
"""
|
||||
type CreateCommentPayload {
|
||||
"""
|
||||
edge is the possibly created comment edge.
|
||||
"""
|
||||
edge: CommentEdge!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
```
|
||||
|
||||
The `CreateCommentInput` type contains some parameters that allow us to execute
|
||||
the mutation. These are:
|
||||
|
||||
- `storyID` - the story we are submitting the comment to.
|
||||
- `body` - the body of our comment.
|
||||
- `nudge` - whether we should return validation prompts to the user about
|
||||
improper comment language.
|
||||
- `clientMutationId` - the identifier used by Relay to identify this mutation,
|
||||
our front-end state cache to process the mutation request. This is the only
|
||||
place in the code-base that we do not capitalize the acronym when written in
|
||||
camel-case, this is unfortunately due to legacy reasons from within Relay.
|
||||
|
||||
The returned response for a mutation is a what we call a Payload, in this case
|
||||
it's `CreateCommentPayload`. This usually has a response that is the full data
|
||||
type of whatever was modified by the earlier called mutation action. The
|
||||
properties on this type are:
|
||||
|
||||
- `edge` - we return the full comment edge that was created by the previous
|
||||
input. This is done so that the mutation request can query whatever it needs
|
||||
to from the returned input to update state on the client.
|
||||
- `clientMutationId` - an identifier used by Relay (our front-end client state
|
||||
cache) to process the mutation request.
|
||||
|
||||
#### Access Permissions On Types
|
||||
|
||||
Sometimes, you only want specific users to be allowed to view certain
|
||||
information. For instance, we have roles that are defined in our schema so we
|
||||
can filter who can have access to what.
|
||||
|
||||
These roles are used with directives on our schema that GraphQL then enforces
|
||||
when trying to resolve requests a user makes. If the user has the specified role
|
||||
associated with their signed-in account, they are given access to the requested
|
||||
data.
|
||||
|
||||
An example of this is the `revisionHistory` on the `Comment` type:
|
||||
|
||||
```graphql
|
||||
fragment on Comment {
|
||||
"""
|
||||
revisionHistory stores the previous CommentRevision's, with the most recent
|
||||
edit last.
|
||||
"""
|
||||
revisionHistory: [CommentRevision!]!
|
||||
@auth(
|
||||
roles: [MODERATOR, ADMIN]
|
||||
userIDField: "author_id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Here we see the `@auth` directive. It has documentation describing the various
|
||||
parameters allowed located in the [`schema.graphql`](https://github.com/coralproject/talk/blob/master/src/core/server/graph/schema/schema.graphql)
|
||||
file, we'll discuss below what this particular set of parameters can be read as:
|
||||
|
||||
- The roles that are allowed to access this information are `MODERATOR` and
|
||||
`ADMIN` as defined by the `roles` argument.
|
||||
- We let the directive know that the author of the comment is `author_id` from
|
||||
the `Comment` by defining the `userIDField`. It's a rule of thumb in Coral if
|
||||
the Author created the document, they have permission to view it. A Comment
|
||||
for example is authored by a user, with the underlying field associated with
|
||||
the id of that author living on the `author_id` field. You can see how this
|
||||
is related if you look at the resolver for the `Comment` type to see that it
|
||||
is based off of the `Comment` interface from `src/core/server/models/comment`.
|
||||
- We permit returning comments when the author has the following conditions
|
||||
associated with their account: `SUSPENDED`, `BANNED`, or `PENDING_DELETION`.
|
||||
|
||||
These directives can be simpler, for example the `metadata` property on the
|
||||
`CommentRevision`:
|
||||
|
||||
```graphql
|
||||
fragment on CommentRevision {
|
||||
"""
|
||||
metadata stores details on a CommentRevision.
|
||||
"""
|
||||
metadata: CommentRevisionMetadata! @auth(roles: [ADMIN, MODERATOR])
|
||||
}
|
||||
```
|
||||
|
||||
Here we see an auth directive with only roles defined. This is sufficient to
|
||||
make sure that the metadata property is only accessible to `ADMIN` and
|
||||
`MODERATOR` user roles.
|
||||
|
||||
Note: Wondering how the user roles are defined? They're simply an enumeration
|
||||
type that is also defined in the schema.
|
||||
|
||||
```graphql
|
||||
enum USER_ROLE {
|
||||
COMMENTER
|
||||
STAFF
|
||||
MODERATOR
|
||||
ADMIN
|
||||
}
|
||||
```
|
||||
|
||||
#### Arrays of Items
|
||||
|
||||
Sometimes you don't want a singular property, your property is instead a
|
||||
collection of items.
|
||||
|
||||
The `revisionHistory` from the `Comment` is again useful as an example:
|
||||
|
||||
```graphql
|
||||
fragment on Comment {
|
||||
"""
|
||||
revisionHistory stores the previous CommentRevision's, with the most recent
|
||||
edit last.
|
||||
"""
|
||||
revisionHistory: [CommentRevision!]!
|
||||
}
|
||||
```
|
||||
|
||||
The interior type `CommentRevision` is required using the `!` and the outer
|
||||
array is also required using `!`.
|
||||
|
||||
We do this for a couple of reasons:
|
||||
|
||||
- This ensures that we do not return null/undefined values within the array.
|
||||
- Why would we return a null when we can just return nothing for null values?
|
||||
- We want the array to always be defined, if empty, we return and empty array (i.e. `[]`).
|
||||
- This can be handled nicely in our resolvers. We simply check if the retrieved values is null or undefined and simply return an empty array in its stead.
|
||||
|
||||
These little tweaks aren't necessary, but they ease the use of our API by making
|
||||
the results for arrays predictable and strongly typed.
|
||||
|
||||
#### Documenting
|
||||
|
||||
As you may have noticed, there is quite a bit of documentation in the schema examples listed here.
|
||||
|
||||
We typically follow these two rules in commenting our GraphQL types:
|
||||
|
||||
- Always comment the property within a type describing its purpose on its parent type
|
||||
|
||||
i.e. `createdAt` on our `Setting` type:
|
||||
|
||||
```graphql
|
||||
fragment on Setting {
|
||||
"""
|
||||
createdAt is the time that the Settings was created at.
|
||||
"""
|
||||
createdAt: Time! @auth(roles: [ADMIN])
|
||||
}
|
||||
```
|
||||
|
||||
- Always comment the purpose of each type
|
||||
|
||||
i.e. the `Comment` type:
|
||||
|
||||
```graphql
|
||||
"""
|
||||
Comment is a comment left by a User on an Story or another Comment as a reply.
|
||||
"""
|
||||
type Comment { }
|
||||
```
|
||||
|
||||
@@ -5,9 +5,9 @@ how moderation, comment display, and conversation function, creating the
|
||||
opportunity for safer, smarter discussions around your work.
|
||||
[Read more about Coral here](https://coralproject.net/talk).
|
||||
|
||||
Built with <3 by The Coral Project, a part of [Vox Media](https://product.voxmedia.com/).
|
||||
Built with <3 by the Coral team, a part of [Vox Media](https://product.voxmedia.com/).
|
||||
|
||||
Preview Coral easily by running Coral via a Heroku App:
|
||||
Preview Coral easily by running it via a Heroku App:
|
||||
|
||||
[](https://heroku.com/deploy?template=https://github.com/coralproject/talk)
|
||||
|
||||
@@ -28,13 +28,11 @@ You can get started with Coral using our [Documentation](https://docs.coralproje
|
||||
|
||||
## Pre-Launch Guide
|
||||
|
||||
You’ve installed Talk on your server, and you’re preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Talk Community Guide](https://coralproject.net/blog/youve-installed-talk-now-what/).
|
||||
You’ve installed Coral on your server, and you’re preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Community Guides](https://guides.coralproject.net/start-here/) to learn more.
|
||||
|
||||
## More Resources
|
||||
|
||||
- [Our Blog](https://coralproject.net/blog)
|
||||
- [Community Guides for Journalism](https://guides.coralproject.net/)
|
||||
- [More About Us](https://coralproject.net/)
|
||||
- [Learn more about Coral and sign up for a webinar](https://coralproject.net/)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
# Webhooks Guide
|
||||
|
||||
This document is in reference to webhooks emitted by Coral. You can configure
|
||||
webhooks on your installation of Coral by visiting `/admin/configure/webhooks`.
|
||||
|
||||
Once you've configured a webhook endpoint in Coral, you will receive updates
|
||||
from Coral when those events occur. These will be in the form of `POST` requests
|
||||
with a `JSON` payload consisting of the schema represented below.
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
## Table of Contents
|
||||
|
||||
- [Webhook Signing](#webhook-signing)
|
||||
- [How to verify the signature(s)](#how-to-verify-the-signatures)
|
||||
- [Schema](#schema)
|
||||
- [Events Listing](#events-listing)
|
||||
- [Events](#events)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
## Webhook Signing
|
||||
|
||||
Each webhook sent by Coral is signed by your webhook endpoint signing secret.
|
||||
The signature method closely resembles the signing method used by Stripe for
|
||||
their `v1` signing method. The `X-Coral-Signature` header contains one or more
|
||||
signatures prefixed by `sha256=`.
|
||||
|
||||
If you receive a signature containing multiple signatures, it is typically when
|
||||
you have rolled the signing secret from the administrative panel, and chosen to
|
||||
keep the previous secret active for a duration of time.
|
||||
|
||||
### How to verify the signature(s)
|
||||
|
||||
```js
|
||||
// Set your signing secret here from the administration panel.
|
||||
const SIGNING_SECRET = "< YOUR SIGNING SECRET HERE >";
|
||||
|
||||
// We're using crypto to verify the signatures.
|
||||
const crypto = require("crypto");
|
||||
|
||||
// We're using express to receive webhooks here.
|
||||
const app = require("express")();
|
||||
|
||||
// Use the body-parser to get the raw body as a buffer so we can use it with the
|
||||
// hashing functions.
|
||||
const parser = require("body-parser");
|
||||
|
||||
function extractEvent(body, sig) {
|
||||
// Step 1: Extract signatures from the header.
|
||||
const signatures = sig
|
||||
// Split the header by `,` to get a list of elements.
|
||||
.split(",")
|
||||
// Split each element by `=` to get a prefix and value pair.
|
||||
.map(element => element.split("="))
|
||||
// Grab all the elements with the prefix of `sha256`.
|
||||
.filter(([prefix]) => prefix === "sha256")
|
||||
// Grab the value from the prefix and value pair.
|
||||
.map(([, value]) => value);
|
||||
|
||||
// Step 2: Prepare the `signed_payload`.
|
||||
const signed_payload = body;
|
||||
|
||||
// Step 3: Calculate the expected signature.
|
||||
const expected = crypto
|
||||
.createHmac("sha256", SIGNING_SECRET)
|
||||
.update(signed_payload)
|
||||
.digest()
|
||||
.toString("hex");
|
||||
|
||||
// Step 4: Compare signatures.
|
||||
if (
|
||||
// For each of the signatures on the request...
|
||||
!signatures.some(signature =>
|
||||
// Compare the expected signature to the signature on in the header. If at
|
||||
// least one of the match, we should continue to process the event.
|
||||
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid signature");
|
||||
}
|
||||
|
||||
// Parse the JSON for the event.
|
||||
return JSON.parse(body.toString());
|
||||
}
|
||||
|
||||
app.post("/webhook", parser.raw({ type: "application/json" }), (req, res) => {
|
||||
const sig = req.headers["x-coral-signature"];
|
||||
|
||||
let event;
|
||||
|
||||
try {
|
||||
// Parse the JSON for the event.
|
||||
event = extractEvent(req.body, sig);
|
||||
} catch (err) {
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
|
||||
// Handle the event.
|
||||
switch (event.type) {
|
||||
case "STORY_CREATED":
|
||||
const data = event.data;
|
||||
console.log(
|
||||
`A Story with ID ${data.storyID} and URL ${data.storyURL} was created!`
|
||||
);
|
||||
break;
|
||||
// ... handle other event types.
|
||||
default:
|
||||
// Unexpected event type
|
||||
return response.status(400).end();
|
||||
}
|
||||
|
||||
// Return a response to acknowledge receipt of the event
|
||||
res.json({ received: true });
|
||||
});
|
||||
|
||||
app.listen(4242, () => console.log("Running on port 4242"));
|
||||
```
|
||||
|
||||
The procedure of how to verify the signatures follows.
|
||||
|
||||
#### **Step 1**: Extract signatures from the header
|
||||
|
||||
Split the header using `,` as the separator, to get a list of elements. Then
|
||||
split each of these elements using `=` as the separator, to get a prefix and
|
||||
value pair. The value for the prefix `sha256` corresponds to the signature(s).
|
||||
|
||||
#### **Step 2**: Prepare the `signed_payload` string
|
||||
|
||||
You can do this by taking the string contents of the body (before parsing or the
|
||||
request body).
|
||||
|
||||
#### **Step 3**: Calculate the expected signature
|
||||
|
||||
Compute an HMAC signature using the SHA256 hash function. You can use the
|
||||
webhook endpoint's signing secret as the key, and the above calculated
|
||||
`signed_payload` as the message.
|
||||
|
||||
#### **Step 4**: Compare signatures
|
||||
|
||||
Compare the signature(s) in the header to the expected signature. To protect
|
||||
against timing attacks, ensure you use a constant-time string comparison
|
||||
function when comparing signatures.
|
||||
|
||||
## Schema
|
||||
|
||||
```ts
|
||||
{
|
||||
/**
|
||||
* id is the identifier for this event, each event
|
||||
* will have a unique id.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* type is the name of this event, this indicates
|
||||
* what is stored in the following `data` property.
|
||||
* Refer to the `Events List` below to see what the
|
||||
* type is for each event.
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* data is the object representing this particular
|
||||
* event. Each type of event has a different shape
|
||||
* to the data property. Refer to the `Events List`
|
||||
* below to see what the data looks like for each
|
||||
* event.
|
||||
*/
|
||||
data: object;
|
||||
|
||||
/**
|
||||
* createdAt is the ISO 8601 representation of the
|
||||
* date when this event was created.
|
||||
*/
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* tenantID is the ID of the Tenant that this event originated at.
|
||||
*/
|
||||
tenantID: string;
|
||||
|
||||
/**
|
||||
* tenantDomain is the domain that is associated with this Tenant that this event originated at.
|
||||
*/
|
||||
tenantDomain: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Events Listing
|
||||
|
||||
- [`STORY_CREATED`](#story-created-event)
|
||||
|
||||
## Events
|
||||
|
||||
- <a id="story-created-event">**STORY_CREATED**</a>
|
||||
|
||||
```ts
|
||||
{
|
||||
id: string;
|
||||
type: "STORY_CREATED";
|
||||
data: {
|
||||
/**
|
||||
* storyID is the ID of the newly created Story.
|
||||
*/
|
||||
storyID: string;
|
||||
|
||||
/**
|
||||
* storyURL is the URL of the newly created Story.
|
||||
*/
|
||||
storyURL: string;
|
||||
|
||||
/**
|
||||
* siteID is the Site that the newly created Story was created on.
|
||||
*/
|
||||
siteID: string;
|
||||
}
|
||||
createdAt: string;
|
||||
}
|
||||
```
|
||||
+10
-18
@@ -1,28 +1,20 @@
|
||||
/**
|
||||
* This is a project wide babel configuration.
|
||||
* https://babeljs.io/docs/en/config-files#project-wide-configuration
|
||||
*
|
||||
* We use this file to apply babel configuration to packages in `node_modules`
|
||||
*/
|
||||
|
||||
const lodashOptimizations =
|
||||
// Note: If Webpack env is set, we are building for the client.
|
||||
|
||||
const plugins =
|
||||
process.env.WEBPACK === "true" ? ["use-lodash-es", "lodash"] : [];
|
||||
|
||||
const environment =
|
||||
process.env.WEBPACK === "true"
|
||||
? { targets: "IE 11", modules: false }
|
||||
: { targets: { node: "current" }, modules: "commonjs" };
|
||||
|
||||
module.exports = {
|
||||
babelrcRoots: ["./src/core/client/*"],
|
||||
env: {
|
||||
production: {
|
||||
plugins: [...lodashOptimizations],
|
||||
},
|
||||
development: {
|
||||
plugins: [...lodashOptimizations],
|
||||
},
|
||||
test: {
|
||||
presets: [
|
||||
["@babel/env", { targets: { node: "current" } }],
|
||||
"@babel/react",
|
||||
],
|
||||
plugins: ["dynamic-import-node"],
|
||||
},
|
||||
},
|
||||
plugins,
|
||||
presets: [["@babel/env", environment]],
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ module.exports = {
|
||||
collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"],
|
||||
coveragePathIgnorePatterns: ["/node_modules/"],
|
||||
setupFiles: [
|
||||
"<rootDir>/src/core/build/polyfills.js",
|
||||
"<rootDir>/src/core/client/test/polyfills.ts",
|
||||
"<rootDir>/src/core/client/test/setup.ts",
|
||||
],
|
||||
setupFilesAfterEnv: ["<rootDir>/src/core/client/test/setupTestFramework.ts"],
|
||||
|
||||
@@ -58,7 +58,7 @@ module.exports = {
|
||||
"@babel/typescript",
|
||||
[
|
||||
"@babel/env",
|
||||
{ targets: { node: "12.0.0" }, modules: "commonjs" },
|
||||
{ targets: { node: "current" }, modules: "commonjs" },
|
||||
],
|
||||
],
|
||||
// This is a feature of `babel-loader` for webpack (not Babel itself).
|
||||
|
||||
Generated
+2819
-1550
File diff suppressed because it is too large
Load Diff
+41
-33
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "@coralproject/talk",
|
||||
"version": "5.4.2",
|
||||
"version": "6.1.0",
|
||||
"author": "The Coral Project",
|
||||
"homepage": "https://coralproject.net/",
|
||||
"sideEffects": [
|
||||
"*.css.ts",
|
||||
"*.css"
|
||||
],
|
||||
"repository": {
|
||||
@@ -25,8 +26,8 @@
|
||||
"build:client": "ts-node --transpile-only ./scripts/build.ts",
|
||||
"build:server": "gulp server",
|
||||
"migration:create": "ts-node --transpile-only ./scripts/migration/create.ts",
|
||||
"doctoc": "doctoc --title='## Table of Contents' --github README.md",
|
||||
"docs:events": "ts-node ./scripts/generateEventDocs.ts ./src/core/client/stream/events.ts ./events.md",
|
||||
"docs:events": "ts-node ./scripts/generateEventDocs.ts ./src/core/client/stream/events.ts ./CLIENT_EVENTS.md",
|
||||
"doctoc": "doctoc --maxlevel=3 --title '## Table of Contents' README.md CLIENT_EVENTS.md CONTRIBUTING.md WEBHOOKS.md",
|
||||
"generate": "npm-run-all generate:css-types generate:schema generate:relay",
|
||||
"generate-persist": "npm-run-all generate:css-types generate:schema generate:relay-persist",
|
||||
"generate:css-types": "tcm src/core/client/",
|
||||
@@ -83,7 +84,7 @@
|
||||
"dotenv": "^6.0.0",
|
||||
"dotenv-expand": "^4.2.0",
|
||||
"env-rewrite": "^1.0.2",
|
||||
"express": "^4.16.3",
|
||||
"express": "^4.17.1",
|
||||
"express-enforces-ssl": "^1.1.0",
|
||||
"express-static-gzip": "^0.3.2",
|
||||
"fs-extra": "^6.0.1",
|
||||
@@ -127,11 +128,10 @@
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-oauth2": "^1.4.0",
|
||||
"passport-strategy": "^1.0.0",
|
||||
"performance-now": "^2.1.0",
|
||||
"permit": "^0.2.4",
|
||||
"prom-client": "^11.3.0",
|
||||
"proxy-agent": "^3.1.1",
|
||||
"querystringify": "^2.1.0",
|
||||
"querystringify": "^2.1.1",
|
||||
"react-helmet": "^5.2.1",
|
||||
"source-map-support": "^0.5.12",
|
||||
"stack-utils": "^1.0.2",
|
||||
@@ -140,18 +140,17 @@
|
||||
"tsscmp": "^1.0.6",
|
||||
"url-regex": "^5.0.0",
|
||||
"uuid": "^3.3.3",
|
||||
"verror": "^1.10.0"
|
||||
"verror": "^1.10.0",
|
||||
"xregexp": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.4.5",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.4.4",
|
||||
"@babel/polyfill": "^7.4.4",
|
||||
"@babel/preset-env": "^7.4.5",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@babel/preset-typescript": "^7.3.3",
|
||||
"@babel/core": "^7.8.3",
|
||||
"@babel/preset-env": "^7.8.3",
|
||||
"@babel/preset-react": "^7.8.3",
|
||||
"@babel/preset-typescript": "^7.8.3",
|
||||
"@babel/runtime-corejs3": "^7.8.3",
|
||||
"@coralproject/npm-run-all": "^4.1.5",
|
||||
"@coralproject/rte": "^0.11.0",
|
||||
"@coralproject/rte": "^0.11.1",
|
||||
"@fluent/react": "^0.10.0",
|
||||
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
|
||||
"@types/agent-base": "^4.2.0",
|
||||
@@ -220,12 +219,12 @@
|
||||
"@types/react-copy-to-clipboard": "^4.2.6",
|
||||
"@types/react-dom": "^16.8.4",
|
||||
"@types/react-helmet": "^5.0.10",
|
||||
"@types/react-relay": "^1.3.14",
|
||||
"@types/react-relay": "^5.0.1",
|
||||
"@types/react-responsive": "^3.0.1",
|
||||
"@types/react-test-renderer": "^16.8.1",
|
||||
"@types/react-transition-group": "^2.0.14",
|
||||
"@types/recompose": "^0.26.5",
|
||||
"@types/relay-runtime": "^1.3.12",
|
||||
"@types/relay-runtime": "^5.0.5",
|
||||
"@types/sane": "^2.0.0",
|
||||
"@types/shallow-equals": "^1.0.0",
|
||||
"@types/simplemde": "^1.11.7",
|
||||
@@ -242,6 +241,7 @@
|
||||
"@types/webpack-bundle-analyzer": "^2.13.1",
|
||||
"@types/webpack-dev-server": "^3.1.5",
|
||||
"@types/ws": "^5.1.2",
|
||||
"@types/xregexp": "^4.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "2.3.3",
|
||||
"@typescript-eslint/eslint-plugin-tslint": "2.3.3",
|
||||
"@typescript-eslint/parser": "2.3.3",
|
||||
@@ -249,14 +249,12 @@
|
||||
"ansi-styles": "^3.2.0",
|
||||
"autoprefixer": "^9.5.1",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-jest": "^24.8.0",
|
||||
"babel-loader": "^8.0.5",
|
||||
"babel-plugin-dynamic-import-node": "^2.2.0",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-module-resolver": "^3.2.0",
|
||||
"babel-plugin-relay": "^4.0.0",
|
||||
"babel-plugin-relay": "^5.0.0",
|
||||
"babel-plugin-use-lodash-es": "^0.2.0",
|
||||
"babel-preset-react-optimize": "^1.0.1",
|
||||
"bowser": "^1.9.4",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.2.0",
|
||||
"chalk": "^2.4.2",
|
||||
@@ -267,8 +265,10 @@
|
||||
"common-tags": "^1.8.0",
|
||||
"compression-webpack-plugin": "^2.0.0",
|
||||
"copy-webpack-plugin": "^5.0.3",
|
||||
"core-js": "^3.6.4",
|
||||
"cross-spawn": "^6.0.5",
|
||||
"css-loader": "^1.0.1",
|
||||
"css-vars-ponyfill": "^2.1.2",
|
||||
"del": "^4.1.1",
|
||||
"doctoc": "^1.4.0",
|
||||
"docz": "^0.13.7",
|
||||
@@ -289,8 +289,8 @@
|
||||
"flat": "^4.1.0",
|
||||
"fluent-intl-polyfill": "^0.1.0",
|
||||
"fork-ts-checker-webpack-plugin": "^1.5.0",
|
||||
"found": "^0.4.0-alpha.17",
|
||||
"found-relay": "^0.4.0-alpha.8",
|
||||
"found": "^0.4.9",
|
||||
"found-relay": "^0.4.3",
|
||||
"graphql-schema-linter": "^0.2.0",
|
||||
"graphql-schema-typescript": "^1.2.9",
|
||||
"gulp": "^4.0.2",
|
||||
@@ -298,7 +298,7 @@
|
||||
"gulp-cli": "^2.2.0",
|
||||
"gulp-sourcemaps": "^2.6.5",
|
||||
"gulp-typescript": "^5.0.1",
|
||||
"html-webpack-plugin": "^4.0.0-beta.5",
|
||||
"html-webpack-plugin": "^4.0.0-beta.11",
|
||||
"husky": "^2.2.0",
|
||||
"intersection-observer": "^0.6.0",
|
||||
"jest": "^24.8.0",
|
||||
@@ -314,6 +314,7 @@
|
||||
"mini-css-extract-plugin": "^0.6.0",
|
||||
"object-diff": "0.0.4",
|
||||
"postcss-advanced-variables": "^3.0.0",
|
||||
"postcss-calc-function": "^1.1.0",
|
||||
"postcss-css-variables": "^0.11.0",
|
||||
"postcss-flexbugs-fixes": "^4.1.0",
|
||||
"postcss-font-magician": "^2.2.1",
|
||||
@@ -326,32 +327,35 @@
|
||||
"postcss-preset-env": "^6.5.0",
|
||||
"prettier": "^1.18.2",
|
||||
"prop-types": "^15.6.2",
|
||||
"proxy-polyfill": "^0.3.0",
|
||||
"pstree.remy": "^1.1.6",
|
||||
"pym.js": "^1.3.2",
|
||||
"raw-loader": "^0.5.1",
|
||||
"react": "^16.9.0-alpha.0",
|
||||
"react": "^16.9.0",
|
||||
"react-axe": "^3.3.0",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"react-dev-utils": "^9.0.0",
|
||||
"react-dom": "^16.9.0-alpha.0",
|
||||
"react-dom": "^16.9.0",
|
||||
"react-error-overlay": "^5.1.6",
|
||||
"react-final-form": "6.3.0",
|
||||
"react-final-form-arrays": "3.1.0",
|
||||
"react-popper": "^1.3.2",
|
||||
"react-relay": "^4.0.0",
|
||||
"react-relay": "^5.0.0",
|
||||
"react-relay-network-modern": "^4.0.4",
|
||||
"react-responsive": "^7.0.0",
|
||||
"react-test-renderer": "^16.9.0-alpha.0",
|
||||
"react-test-renderer": "^16.9.0",
|
||||
"react-timeago": "^4.1.9",
|
||||
"react-transition-group": "^2.9.0",
|
||||
"react-with-state-props": "^2.0.4",
|
||||
"recompose": "0.27.1",
|
||||
"relay-compiler": "^4.0.0",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"relay-compiler": "^5.0.0",
|
||||
"relay-compiler-language-typescript": "^4.1.0",
|
||||
"relay-local-schema": "^0.8.0",
|
||||
"relay-runtime": "^4.0.0",
|
||||
"relay-runtime": "^5.0.0",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sane": "^4.1.0",
|
||||
"scheduler": "^0.15.0",
|
||||
"shallow-equals": "^1.0.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"simulant": "^0.2.2",
|
||||
@@ -379,7 +383,7 @@
|
||||
"typescript": "3.3.4000",
|
||||
"typescript-snapshots-plugin": "^1.6.0",
|
||||
"wait-for-expect": "^1.1.1",
|
||||
"webpack": "^4.30.0",
|
||||
"webpack": "^4.41.5",
|
||||
"webpack-assets-manifest": "^3.1.1",
|
||||
"webpack-bundle-analyzer": "^3.3.2",
|
||||
"webpack-cli": "^3.3.2",
|
||||
@@ -398,8 +402,12 @@
|
||||
"src/core/server/graph/schema/schema.graphql": [
|
||||
"graphql-schema-linter"
|
||||
],
|
||||
"{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,events.md}": [
|
||||
"{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,CLIENT_EVENTS.md}": [
|
||||
"npm run docs:events -- --verify"
|
||||
],
|
||||
"{README,CLIENT_EVENTS,CONTRIBUTING,WEBHOOKS}.md": [
|
||||
"npm run doctoc",
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"bundlesize": [
|
||||
|
||||
@@ -52,6 +52,9 @@ const args = [
|
||||
"--language",
|
||||
"typescript",
|
||||
"--no-watchman",
|
||||
"--customScalars.Time=String",
|
||||
"--customScalars.Cursor=unknown",
|
||||
"--customScalars.Locale=string",
|
||||
"--src",
|
||||
program.src,
|
||||
"--artifactDirectory",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable no-bitwise */
|
||||
|
||||
import { codeBlock, stripIndent } from "common-tags";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import ts from "typescript";
|
||||
|
||||
interface DocEntry {
|
||||
|
||||
@@ -24,7 +24,8 @@ var url = require("url");
|
||||
var launchEditorEndpoint = require("react-dev-utils/launchEditorEndpoint");
|
||||
var formatWebpackMessages = require("react-dev-utils/formatWebpackMessages");
|
||||
var ErrorOverlay = require("react-error-overlay");
|
||||
var { debounce } = require("lodash");
|
||||
var lodash = require("lodash");
|
||||
var debounce = lodash.debounce;
|
||||
|
||||
ErrorOverlay.setEditorHandler(function editorHandler(errorLocation) {
|
||||
// Keep this sync with errorOverlayMiddleware.js
|
||||
@@ -124,7 +125,7 @@ function handleWarnings(warnings) {
|
||||
|
||||
// TODO: remove this workaround when we can upgrade to WebpackDevServer >= v3.3.0,
|
||||
// which includes proper `warningsFilter` support.
|
||||
warnings = warnings.filter(w => !/export .* was not found in/.test(w));
|
||||
warnings = warnings.filter(function(w){ return !/export .* was not found in/.test(w)});
|
||||
|
||||
function printWarnings() {
|
||||
// Print warnings to the console.
|
||||
@@ -198,7 +199,7 @@ function handleAvailableHash(hash) {
|
||||
mostRecentCompilationHash = hash;
|
||||
}
|
||||
|
||||
const debouncedReload = debounce(() => {
|
||||
const debouncedReload = debounce(function() {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
|
||||
// TODO: import form coral-common/version, for some reason this fails currently.
|
||||
// Try again when we have a chance to upgrade typescript.
|
||||
import { version } from "../common/version";
|
||||
import { Config } from "./config";
|
||||
import { createClientEnv } from "./config";
|
||||
import { Config, createClientEnv } from "./config";
|
||||
import paths from "./paths";
|
||||
|
||||
/**
|
||||
@@ -392,7 +391,7 @@ export default function createWebpackConfig(
|
||||
"@babel/typescript",
|
||||
[
|
||||
"@babel/env",
|
||||
{ targets: { node: "12.0.0" }, modules: "commonjs" },
|
||||
{ targets: { node: "current" }, modules: "commonjs" },
|
||||
],
|
||||
],
|
||||
// This is a feature of `babel-loader` for webpack (not Babel itself).
|
||||
@@ -450,19 +449,11 @@ export default function createWebpackConfig(
|
||||
{
|
||||
test: /\.js$/,
|
||||
include: /node_modules\//,
|
||||
exclude: /node_modules\/(@babel|babel|core-js|regenerator-runtime)/,
|
||||
use: [
|
||||
{
|
||||
loader: require.resolve("babel-loader"),
|
||||
options: {
|
||||
// This will ensure that all packages in node_modules that
|
||||
// import lodash do so in a way that supports tree shaking.
|
||||
plugins: ["lodash"],
|
||||
presets: [
|
||||
[
|
||||
"@babel/env",
|
||||
{ targets: "last 2 versions", modules: false },
|
||||
],
|
||||
],
|
||||
cacheDirectory: true,
|
||||
},
|
||||
},
|
||||
@@ -510,11 +501,6 @@ export default function createWebpackConfig(
|
||||
exclude: [/\.(js|ts|tsx)$/, /\.html$/, /\.json$/],
|
||||
loader: require.resolve("file-loader"),
|
||||
options: {
|
||||
// Because the resources loaded via CSS can sometimes be loaded
|
||||
// directly from a CSS file, this will ensure that they are
|
||||
// relative to those referencing files.
|
||||
publicPath: (loaderPublicPath: string) =>
|
||||
"../../" + loaderPublicPath,
|
||||
name: isProduction
|
||||
? "assets/media/[name].[hash:8].[ext]"
|
||||
: "assets/media/[name].[ext]",
|
||||
@@ -712,8 +698,7 @@ export default function createWebpackConfig(
|
||||
sideEffects: true,
|
||||
},
|
||||
entry: [
|
||||
/* Use minimal amount of polyfills (for IE) */
|
||||
"intersection-observer", // also for Safari
|
||||
// Polyfills are in the index.ts file.
|
||||
paths.appEmbedIndex,
|
||||
],
|
||||
output: {
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
appLoaders: resolveSrc("core/build/loaders"),
|
||||
appSrc: resolveSrc("."),
|
||||
appTsconfig: resolveSrc("core/client/tsconfig.json"),
|
||||
appPolyfill: resolveSrc("core/build/polyfills.js"),
|
||||
appPolyfill: resolveSrc("core/build/polyfills.ts"),
|
||||
appPublicPath: resolveSrc("core/build/publicPath.js"),
|
||||
appLocales: resolveSrc("locales"),
|
||||
appThemeVariables: resolveSrc("core/client/ui/theme/variables.ts"),
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
require("@babel/polyfill");
|
||||
require("intersection-observer");
|
||||
@@ -0,0 +1,6 @@
|
||||
import polyfillNodeListForEach from "../client/framework/helpers/polyfillNodeListForEach";
|
||||
|
||||
import "core-js/stable";
|
||||
import "regenerator-runtime/runtime";
|
||||
|
||||
polyfillNodeListForEach();
|
||||
@@ -7,6 +7,7 @@ const pickBy = require("lodash/pickBy");
|
||||
const flat = require("flat");
|
||||
const paths = require("./paths").default;
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const postcssCalcFunction = require("postcss-calc-function").default;
|
||||
const postcssFontMagician = require("postcss-font-magician");
|
||||
const postcssFlexbugsFixes = require("postcss-flexbugs-fixes");
|
||||
const postcssPresetEnv = require("postcss-preset-env");
|
||||
@@ -52,6 +53,8 @@ module.exports = {
|
||||
postcssNested(),
|
||||
// Sass style variables to be used in media queries.
|
||||
postcssAdvancedVariables({ variables: mediaQueryVariables }),
|
||||
// Reduce some calc()
|
||||
postcssCalcFunction(),
|
||||
// Provides a modern CSS environment.
|
||||
postcssPresetEnv(),
|
||||
// Does all the font handling logic.
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
const lodashOptimizations = ["use-lodash-es", "lodash"];
|
||||
|
||||
module.exports = {
|
||||
presets: ["@babel/react"],
|
||||
plugins: ["@babel/syntax-dynamic-import"],
|
||||
env: {
|
||||
production: {
|
||||
presets: [["@babel/env", { targets: "last 2 versions", modules: false }]],
|
||||
plugins: [...lodashOptimizations],
|
||||
},
|
||||
development: {
|
||||
presets: [["@babel/env", { targets: "last 2 versions", modules: false }]],
|
||||
plugins: [...lodashOptimizations],
|
||||
},
|
||||
test: {
|
||||
presets: [["@babel/env", { targets: { node: "current" } }]],
|
||||
plugins: ["@babel/transform-modules-commonjs"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
input::-ms-clear, input::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import injectConditionalPolyfills from "coral-framework/helpers/injectConditionalPolyfills";
|
||||
import potentiallyInjectAxe from "coral-framework/helpers/potentiallyInjectAxe";
|
||||
import { createManaged } from "coral-framework/lib/bootstrap";
|
||||
|
||||
@@ -12,6 +13,7 @@ import localesData from "./locales";
|
||||
import "coral-ui/theme/variables.css";
|
||||
|
||||
async function main() {
|
||||
await injectConditionalPolyfills();
|
||||
// Potentially inject react-axe for runtime a11y checks.
|
||||
await potentiallyInjectAxe();
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
|
||||
@@ -90,7 +90,9 @@ exports[`renders missing confirm token 1`] = `
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="CallOut-inner"
|
||||
>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
|
||||
@@ -147,7 +147,9 @@ exports[`renders missing reset token 1`] = `
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="CallOut-inner"
|
||||
>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
|
||||
@@ -80,7 +80,9 @@ exports[`renders missing confirm token 1`] = `
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="CallOut-inner"
|
||||
>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
$decisionHistoryBorderColor: var(--v2-colors-grey-300);
|
||||
|
||||
.root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
|
||||
& > * {
|
||||
border-bottom: 1px solid var(--v2-palette-decision-history-border);
|
||||
border-bottom: 1px solid $decisionHistoryBorderColor;
|
||||
}
|
||||
& > *:last-child {
|
||||
border-bottom: 0;
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
height: 100%;
|
||||
font-size: var(--v2-font-size-1);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: var(--v2-colors-mono-100);
|
||||
padding: 0 var(--v2-spacing-1);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
$moderateCardDecisionHistoryLinkTextColor: var(--v2-colors-teal-700);
|
||||
|
||||
.root {
|
||||
line-height: var(--v2-line-height-reset);
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-size: var(--v2-font-size-1);
|
||||
color: var(--v2-palette-link-text);
|
||||
color: $moderateCardDecisionHistoryLinkTextColor;
|
||||
font-style: var(--v2-font-style-underline);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
$moderateCardDecisionHistoryLinkTextColor: var(--v2-colors-teal-700);
|
||||
$moderateCardDecisionHistoryBorderColor: var(--v2-colors-grey-300);
|
||||
|
||||
.root {
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
text-transform: uppercase;
|
||||
color: var(--v2-palette-link-text);
|
||||
color: $moderateCardDecisionHistoryLinkTextColor;
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--v2-palette-decision-history-border);
|
||||
border-top: 1px solid $moderateCardDecisionHistoryBorderColor;
|
||||
text-align: center;
|
||||
padding-top: var(--v2-spacing-1);
|
||||
padding-bottom: var(--v2-spacing-1);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
$timestampColor: var(--v2-colors-mono-100);
|
||||
|
||||
.root {
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: $timestampColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
font-size: var(--v2-font-size-1);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
$decisionHistoryBorderColor: var(--v2-colors-grey-300);
|
||||
$decisionHistoryTitleBackgroundColor: var(--v2-colors-grey-200);
|
||||
|
||||
.root {
|
||||
background: var(--v2-palette-decision-history-title-background);
|
||||
background: $decisionHistoryTitleBackgroundColor;
|
||||
padding: var(--v2-spacing-1);
|
||||
color: var(--v2-palette-text-primary);
|
||||
border-bottom: 1px solid var(--v2-palette-decision-history-border);
|
||||
border-bottom: 1px solid $decisionHistoryBorderColor;
|
||||
font-size: var(--v2-font-size-icon-medium);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
$moderateCardDecisionHistoryColor: var(--v2-colors-mono-900);
|
||||
|
||||
.root {
|
||||
color: var(--v2-palette-username-emphasis);
|
||||
color: $moderateCardDecisionHistoryColor;
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
line-height: var(--v2-line-height-body-short);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { createContext, ReactNode, useMemo, useReducer } from "react";
|
||||
|
||||
interface State {
|
||||
message: ReactNode | null;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: "SET_MESSAGE";
|
||||
} & State
|
||||
| {
|
||||
type: "CLEAR_MESSAGE";
|
||||
};
|
||||
|
||||
// TODO (tessalt) can't figure out types for this
|
||||
const NotificationContext = createContext<State | any>({
|
||||
message: null,
|
||||
visible: false,
|
||||
});
|
||||
|
||||
function notificationReducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_MESSAGE": {
|
||||
return { message: action.message, visible: true };
|
||||
}
|
||||
case "CLEAR_MESSAGE": {
|
||||
return { message: null, visible: false };
|
||||
}
|
||||
default: {
|
||||
throw new Error("unsupported action");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function NotificationProvider(props: any) {
|
||||
const [state, dispatch] = useReducer(notificationReducer, {
|
||||
message: null,
|
||||
visible: false,
|
||||
});
|
||||
const value = useMemo(() => [state, dispatch], [state]);
|
||||
return <NotificationContext.Provider value={value} {...props} />;
|
||||
}
|
||||
|
||||
export { NotificationProvider, NotificationContext };
|
||||
@@ -0,0 +1,12 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import useNotification from "./useNotification";
|
||||
|
||||
const NotificationContainer: FunctionComponent<{}> = () => {
|
||||
const { state } = useNotification();
|
||||
if (!state.visible) {
|
||||
return null;
|
||||
}
|
||||
return <div role="status">{state.message}</div>;
|
||||
};
|
||||
|
||||
export default NotificationContainer;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { NotificationProvider } from "./GlobalNotificationContext";
|
||||
export { default as NotificationContainer } from "./NotificationContainer";
|
||||
export { default as useNotification } from "./useNotification";
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useRouter } from "found";
|
||||
import { ReactNode, useContext } from "react";
|
||||
|
||||
import { NotificationContext } from "./GlobalNotificationContext";
|
||||
|
||||
function useNotification() {
|
||||
const context = useContext(NotificationContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useNotification must be used within a NotificationProvider"
|
||||
);
|
||||
}
|
||||
const [state, dispatch] = context;
|
||||
|
||||
const { router } = useRouter();
|
||||
|
||||
const setMessage = (message: ReactNode, timeout?: number) => {
|
||||
dispatch({ type: "SET_MESSAGE", message });
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
dispatch({ type: "CLEAR_MESSAGE" });
|
||||
}, timeout);
|
||||
}
|
||||
router.addTransitionHook(() => {
|
||||
dispatch({ type: "CLEAR_MESSAGE" });
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const clearMessage = () => dispatch({ type: "CLEAR_MESSAGE" });
|
||||
return {
|
||||
state,
|
||||
dispatch,
|
||||
setMessage,
|
||||
clearMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export default useNotification;
|
||||
@@ -3,6 +3,9 @@
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
input::-ms-clear, input::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
|
||||
@@ -5,6 +5,10 @@ import { LogoHorizontal } from "coral-ui/components";
|
||||
import { AppBar, Begin, Divider, End } from "coral-ui/components/v2/AppBar";
|
||||
|
||||
import { DecisionHistoryButton } from "./DecisionHistory";
|
||||
import {
|
||||
NotificationContainer,
|
||||
NotificationProvider,
|
||||
} from "./GlobalNotification";
|
||||
import NavigationContainer from "./Navigation";
|
||||
import UserMenuContainer from "./UserMenu";
|
||||
import Version from "./Version";
|
||||
@@ -32,7 +36,10 @@ const Main: FunctionComponent<Props> = ({ children, viewer }) => (
|
||||
<UserMenuContainer viewer={viewer} />
|
||||
</End>
|
||||
</AppBar>
|
||||
{children}
|
||||
<NotificationProvider>
|
||||
<NotificationContainer />
|
||||
{children}
|
||||
</NotificationProvider>
|
||||
<Version />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const NavigationLink: FunctionComponent<Props> = props => (
|
||||
<Link to={props.to} as={AppBarNavigationItem} activePropName="active">
|
||||
<Link to={props.to} as={AppBarNavigationItem as any} activePropName="active">
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
$userMenuButtonColor: var(--v2-colors-mono-100);
|
||||
|
||||
.button {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
@@ -5,12 +7,12 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--v2-palette-user-menu-button);
|
||||
color: $userMenuButtonColor;
|
||||
}
|
||||
|
||||
.buttonText {
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
color: var(--v2-palette-user-menu-button);
|
||||
color: $userMenuButtonColor;
|
||||
font-size: var(--v2-font-size-4);
|
||||
margin-left: 2px;
|
||||
margin-right: 1px;
|
||||
|
||||
@@ -28,7 +28,10 @@ exports[`renders correctly 1`] = `
|
||||
/>
|
||||
</withPropsOnChange(End)>
|
||||
</withPropsOnChange(AppBar)>
|
||||
child
|
||||
<NotificationProvider>
|
||||
<NotificationContainer />
|
||||
child
|
||||
</NotificationProvider>
|
||||
<Version />
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
.container {
|
||||
width: 350px;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid var(--palette-grey-lighter);
|
||||
width: 385px;
|
||||
background-color: var(--v2-colors-grey-100);
|
||||
border: 1px solid var(--v2-colors-grey-300);
|
||||
margin-top: 70px;
|
||||
padding: 0 calc(3 * var(--mini-unit)) calc(4 * var(--mini-unit))
|
||||
calc(3 * var(--mini-unit));
|
||||
padding: var(--v2-spacing-6);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
display: inline-flex;
|
||||
background: var(--palette-common-white);
|
||||
padding: calc(0.5 * var(--mini-unit));
|
||||
border: 1px solid var(--palette-grey-lighter);
|
||||
margin-top: -42px;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
BrandMark,
|
||||
BrandName,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
import { BrandName, Flex, HorizontalGutter } from "coral-ui/components/v2";
|
||||
|
||||
import styles from "./AuthBox.css";
|
||||
|
||||
@@ -20,15 +14,10 @@ const AuthBox: FunctionComponent<Props> = ({ title, children }) => {
|
||||
<div data-testid="authBox">
|
||||
<Flex justifyContent="center">
|
||||
<HorizontalGutter className={styles.container} size="double">
|
||||
<Flex justifyContent="center">
|
||||
<div className={styles.brandIcon}>
|
||||
<BrandMark size="lg" />
|
||||
</div>
|
||||
</Flex>
|
||||
<div>
|
||||
<Typography align="center" variant="heading3">
|
||||
{title}
|
||||
</Typography>
|
||||
<Flex justifyContent="center">
|
||||
<div className="adminSignInTitle">{title}</div>
|
||||
</Flex>
|
||||
<BrandName size="lg" align="center" />
|
||||
</div>
|
||||
{children}
|
||||
|
||||
+8
-4
@@ -12,12 +12,12 @@ $comment-link-active: var(--v2-palette-primary-darkest);
|
||||
color: $comment-content;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
& * bold,
|
||||
& * strong {
|
||||
b,
|
||||
strong {
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
}
|
||||
& * italic,
|
||||
& * em {
|
||||
i,
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
blockquote {
|
||||
@@ -50,3 +50,7 @@ $comment-link-active: var(--v2-palette-primary-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.highlight {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
|
||||
import CommentContent from "./CommentContent";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof CommentContent> = {
|
||||
phrases: {
|
||||
locale: "en-US",
|
||||
wordList: {
|
||||
suspect: ["worse"],
|
||||
banned: ["bad"],
|
||||
},
|
||||
},
|
||||
className: "custom",
|
||||
children: "Hello <b>Bob</b>, you bad guy",
|
||||
highlight: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<CommentContent {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders empty words correctly", () => {
|
||||
const props: PropTypesOf<typeof CommentContent> = {
|
||||
phrases: {
|
||||
locale: "en-US",
|
||||
wordList: {
|
||||
suspect: [],
|
||||
banned: [],
|
||||
},
|
||||
},
|
||||
className: "custom",
|
||||
children: "Hello <b>Bob</b>, you bad guy",
|
||||
highlight: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<CommentContent {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders correctly even if it has consecutive banned words on comments", () => {
|
||||
const props: PropTypesOf<typeof CommentContent> = {
|
||||
phrases: {
|
||||
locale: "en-US",
|
||||
wordList: {
|
||||
suspect: ["worse"],
|
||||
banned: ["bad"],
|
||||
},
|
||||
},
|
||||
className: "custom",
|
||||
children:
|
||||
"This is a very long comment with bad words. Let's try bad and bad. Now bad bad.\nBad BAD bad.\n",
|
||||
highlight: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<CommentContent {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import striptags from "striptags";
|
||||
|
||||
import {
|
||||
getPhrasesRegExp,
|
||||
GetPhrasesRegExpOptions,
|
||||
markHTMLNode,
|
||||
} from "coral-admin/helpers";
|
||||
import { createPurify } from "coral-common/utils/purify";
|
||||
|
||||
import styles from "./CommentContent.css";
|
||||
|
||||
/**
|
||||
* Create a purify instance that will be used to handle HTML content.
|
||||
*/
|
||||
const purify = createPurify(window, false);
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: string | React.ReactElement;
|
||||
phrases: GetPhrasesRegExpOptions;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
const CommentContent: FunctionComponent<Props> = ({
|
||||
phrases,
|
||||
className,
|
||||
children,
|
||||
highlight = false,
|
||||
}) => {
|
||||
// Cache the expression used via memo. This will reduce duplicate renders of
|
||||
// this comment content when the children change but the phrase configuration
|
||||
// does not change. The regExp is already cached on a deeper level
|
||||
// automatically, this is just lessening that impact further.
|
||||
const expression = useMemo(() => {
|
||||
// If we aren't in highlight mode for this comment, don't even attempt to
|
||||
// generate the expression.
|
||||
if (!highlight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPhrasesRegExp(phrases);
|
||||
}, [phrases, highlight]);
|
||||
|
||||
// Cache the parsed comment node. If the children cannot be parsed, this will
|
||||
// be null.
|
||||
const parsed = useMemo(() => {
|
||||
if (typeof children !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sanitize the input for display.
|
||||
let html = purify.sanitize(children);
|
||||
if (highlight) {
|
||||
html = striptags(html, ["a"]);
|
||||
}
|
||||
|
||||
// We create a Shadow DOM Tree with the HTML body content and use it as a
|
||||
// parser.
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = html;
|
||||
|
||||
// If the expression is available, then mark the nodes.
|
||||
if (expression) {
|
||||
markHTMLNode(node, expression);
|
||||
}
|
||||
|
||||
return node;
|
||||
}, [children, expression, highlight]);
|
||||
|
||||
if (parsed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(className, styles.root, highlight && styles.highlight)}
|
||||
dangerouslySetInnerHTML={{ __html: parsed.innerHTML }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default CommentContent;
|
||||
+10
-5
@@ -1,15 +1,20 @@
|
||||
$moderateCardIconColor: var(--v2-colors-mono-100);
|
||||
$moderateCardInReplyToColor: var(--v2-colors-mono-100);
|
||||
$moderateCardInReplyToUsernameColor: var(--v2-colors-mono-500);
|
||||
$moderateCardUsernameBackgroundHover: var(--v2-colors-grey-200);
|
||||
|
||||
.icon {
|
||||
color: var(--v2-palette-label-regular);
|
||||
color: $moderateCardIconColor;
|
||||
}
|
||||
.inReplyTo {
|
||||
color: var(--v2-palette-label-regular);
|
||||
color: $moderateCardInReplyToColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-2);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
}
|
||||
.username {
|
||||
color: var(--v2-palette-username-regular);
|
||||
color: $moderateCardInReplyToUsernameColor;
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-size: var(--v2-font-size-3);
|
||||
@@ -18,13 +23,13 @@
|
||||
padding: var(--spacing-1);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--v2-palette-username-background-hover);
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--v2-palette-username-background-hover);
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
$moderateCardUsernameColor: var(--v2-colors-mono-900);
|
||||
|
||||
.root {
|
||||
font-size: var(--v2-font-size-3);
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
color: var(--v2-palette-username-emphasis);
|
||||
color: $moderateCardUsernameColor;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
$moderateCardUsernameBackgroundHover: var(--v2-colors-grey-200);
|
||||
|
||||
.root {
|
||||
margin-right: var(--mini-unit);
|
||||
padding: var(--spacing-1);
|
||||
margin-left: calc(-1 * var(--v2-spacing-1));
|
||||
|
||||
line-height: var(--v2-line-height-reset);
|
||||
|
||||
&:hover {
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { BaseButton } from "coral-ui/components/v2";
|
||||
import Username from "./Username";
|
||||
|
||||
import styles from "./UsernameButton.css";
|
||||
|
||||
interface Props {
|
||||
username: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const UsernameButton: FunctionComponent<Props> = ({
|
||||
username,
|
||||
onClick,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<BaseButton onClick={onClick} className={cn(styles.root, className)}>
|
||||
<Username>{username}</Username>
|
||||
</BaseButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsernameButton;
|
||||
@@ -0,0 +1,36 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root CommentContent-highlight"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<span>Hello Bob, you <mark>bad</mark> guy</span>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renders correctly even if it has consecutive banned words on comments 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root CommentContent-highlight"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "<span>This is a very long comment with <mark>bad</mark> words. Let's try <mark>bad</mark> and <mark>bad</mark>. Now <mark>bad</mark> bad.
|
||||
<mark>Bad</mark> BAD <mark>bad</mark>.
|
||||
</span>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renders empty words correctly 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root CommentContent-highlight"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "Hello Bob, you bad guy",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as CommentContent } from "./CommentContent";
|
||||
export { default as InReplyTo } from "./InReplyTo";
|
||||
export { default as Username } from "./Username";
|
||||
export { default as UsernameButton } from "./UsernameButton";
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Modal } from "coral-ui/components";
|
||||
|
||||
import ConversationModalQuery from "./ConversationModalQuery";
|
||||
|
||||
interface ConversationModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
commentID?: string;
|
||||
onUsernameClicked: (userID: string) => void;
|
||||
}
|
||||
|
||||
const ConversationModal: FunctionComponent<ConversationModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
commentID,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
if (!commentID) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<ConversationModalQuery
|
||||
commentID={commentID}
|
||||
onClose={onClose}
|
||||
firstFocusableRef={firstFocusableRef}
|
||||
lastFocusableRef={lastFocusableRef}
|
||||
onUsernameClicked={onUsernameClicked}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationModal;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
$conversationModalHighlightBackground: var(--v2-colors-teal-100);
|
||||
$conversationModalCommentText: var(--v2-colors-mono-500);
|
||||
.root {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.line {
|
||||
border-color: var(--v2-colors-grey-400);
|
||||
}
|
||||
|
||||
.adornments {
|
||||
margin-right: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.circle {
|
||||
margin-top: var(--v2-spacing-2);
|
||||
margin-bottom: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.highlightedCircle {
|
||||
margin-top: var(--v2-spacing-1);
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
background-color: $conversationModalHighlightBackground;
|
||||
padding: var(--v2-spacing-2);
|
||||
padding-top: var(--v2-spacing-1);
|
||||
margin-bottom: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.commentText {
|
||||
color: $conversationModalCommentText;
|
||||
}
|
||||
|
||||
.showReplies {
|
||||
padding-left: var(--v2-spacing-2);
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Timestamp,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import ConversationModalRepliesQuery from "./ConversationModalRepliesQuery";
|
||||
|
||||
import { ConversationModalCommentContainer_comment } from "coral-admin/__generated__/ConversationModalCommentContainer_comment.graphql";
|
||||
import { ConversationModalCommentContainer_settings } from "coral-admin/__generated__/ConversationModalCommentContainer_settings.graphql";
|
||||
|
||||
import { CommentContent, InReplyTo, UsernameButton } from "../Comment";
|
||||
import { Circle, Line } from "../Timeline";
|
||||
|
||||
import styles from "./ConversationModalCommentContainer.css";
|
||||
|
||||
interface Props {
|
||||
comment: ConversationModalCommentContainer_comment;
|
||||
isHighlighted: boolean;
|
||||
onUsernameClick: (id?: string) => void;
|
||||
isParent?: boolean;
|
||||
isReply?: boolean;
|
||||
settings: ConversationModalCommentContainer_settings;
|
||||
}
|
||||
|
||||
const ConversationModalCommentContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
isHighlighted,
|
||||
isParent,
|
||||
isReply,
|
||||
onUsernameClick,
|
||||
settings,
|
||||
}) => {
|
||||
const commentAuthorClick = useCallback(() => {
|
||||
if (comment.author) {
|
||||
onUsernameClick(comment.author.id);
|
||||
}
|
||||
}, [onUsernameClick, comment.author]);
|
||||
const commentParentAuthorClick = useCallback(() => {
|
||||
if (comment.parent && comment.parent.author) {
|
||||
onUsernameClick(comment.parent.author.id);
|
||||
}
|
||||
}, [onUsernameClick, comment.parent]);
|
||||
const [showReplies, setShowReplies] = useState(false);
|
||||
const onShowReplies = useCallback(() => {
|
||||
setShowReplies(true);
|
||||
}, []);
|
||||
return (
|
||||
<HorizontalGutter>
|
||||
<Flex>
|
||||
<Flex
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
className={cn(styles.adornments, {
|
||||
[styles.highlightedCircle]: isHighlighted,
|
||||
})}
|
||||
>
|
||||
{!isReply && <Circle className={styles.circle} />}
|
||||
{(isParent || isReply) && <Line className={styles.line} />}
|
||||
</Flex>
|
||||
<HorizontalGutter
|
||||
spacing={1}
|
||||
className={cn(styles.root, {
|
||||
[styles.highlighted]: isHighlighted,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Flex alignItems="center">
|
||||
{comment.author && comment.author.username && (
|
||||
<UsernameButton
|
||||
onClick={commentAuthorClick}
|
||||
username={comment.author.username}
|
||||
/>
|
||||
)}
|
||||
<Timestamp>{comment.createdAt}</Timestamp>
|
||||
</Flex>
|
||||
{comment.parent &&
|
||||
comment.parent.author &&
|
||||
comment.parent.author.username && (
|
||||
<InReplyTo onUsernameClick={commentParentAuthorClick}>
|
||||
{comment.parent.author.username}
|
||||
</InReplyTo>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{comment.body && (
|
||||
<CommentContent className={styles.commentText} phrases={settings}>
|
||||
{comment.body}
|
||||
</CommentContent>
|
||||
)}
|
||||
</div>
|
||||
</HorizontalGutter>
|
||||
</Flex>
|
||||
{isReply && comment.replyCount > 0 && (
|
||||
<div className={styles.showReplies}>
|
||||
{!showReplies && (
|
||||
<Localized id="conversation-modal-reply-show-replies">
|
||||
<Button
|
||||
color="mono"
|
||||
variant="outline"
|
||||
onClick={onShowReplies}
|
||||
fullWidth
|
||||
>
|
||||
Show replies
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
{showReplies && (
|
||||
<ConversationModalRepliesQuery
|
||||
onUsernameClicked={onUsernameClick}
|
||||
commentID={comment.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment ConversationModalCommentContainer_settings on Settings {
|
||||
locale
|
||||
wordList {
|
||||
banned
|
||||
suspect
|
||||
}
|
||||
multisite
|
||||
featureFlags
|
||||
...MarkersContainer_settings
|
||||
}
|
||||
`,
|
||||
comment: graphql`
|
||||
fragment ConversationModalCommentContainer_comment on Comment {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
author {
|
||||
username
|
||||
id
|
||||
}
|
||||
replyCount
|
||||
parent {
|
||||
author {
|
||||
username
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(ConversationModalCommentContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,10 @@
|
||||
.root {
|
||||
}
|
||||
.topCircle {
|
||||
margin-right: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.bottomCircleContainer {
|
||||
width: 8px;
|
||||
padding-top: var(--v2-spacing-1);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { graphql, RelayPaginationProp } from "react-relay";
|
||||
|
||||
import {
|
||||
useLoadMore,
|
||||
withPaginationContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import {
|
||||
Button,
|
||||
Counter,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalContainer_comment } from "coral-admin/__generated__/ConversationModalContainer_comment.graphql";
|
||||
import { ConversationModalContainer_settings } from "coral-admin/__generated__/ConversationModalContainer_settings.graphql";
|
||||
import { ConversationModalContainerPaginationQueryVariables } from "coral-admin/__generated__/ConversationModalContainerPaginationQuery.graphql";
|
||||
|
||||
import { Circle } from "../Timeline";
|
||||
import ConversationModalComment from "./ConversationModalCommentContainer";
|
||||
|
||||
import styles from "./ConversationModalContainer.css";
|
||||
|
||||
interface Props {
|
||||
relay: RelayPaginationProp;
|
||||
comment: ConversationModalContainer_comment;
|
||||
settings: ConversationModalContainer_settings;
|
||||
onClose: () => void;
|
||||
onUsernameClicked: (id?: string) => void;
|
||||
}
|
||||
|
||||
const ConversationModalContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
relay,
|
||||
settings,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
const [loadMore] = useLoadMore(relay, 5);
|
||||
const parents = comment.parents.edges.map(edge => edge.node);
|
||||
return (
|
||||
<HorizontalGutter className={styles.root}>
|
||||
{comment.parentCount > parents.length && (
|
||||
<div>
|
||||
<Flex alignItems="center">
|
||||
<Circle hollow={true} className={styles.topCircle} />
|
||||
<Button underline variant="text" onClick={loadMore}>
|
||||
<Localized id="conversation-modal-show-more-parents">
|
||||
<span>Show more of this conversation</span>
|
||||
</Localized>
|
||||
<Counter>{comment.parentCount}</Counter>
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
className={styles.bottomCircleContainer}
|
||||
>
|
||||
<Circle color="light" size="small" />
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
{parents.map(parent => (
|
||||
<ConversationModalComment
|
||||
key={parent.id}
|
||||
isParent={true}
|
||||
comment={parent}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
settings={settings}
|
||||
isHighlighted={false}
|
||||
/>
|
||||
))}
|
||||
<ConversationModalComment
|
||||
comment={comment}
|
||||
settings={settings}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
isHighlighted={true}
|
||||
/>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: (cvle) If this could be autogenerated.
|
||||
type FragmentVariables = ConversationModalContainerPaginationQueryVariables;
|
||||
|
||||
const enhanced = withPaginationContainer<
|
||||
Props,
|
||||
ConversationModalContainerPaginationQueryVariables,
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
settings: graphql`
|
||||
fragment ConversationModalContainer_settings on Settings {
|
||||
...ConversationModalCommentContainer_settings
|
||||
}
|
||||
`,
|
||||
comment: graphql`
|
||||
fragment ConversationModalContainer_comment on Comment
|
||||
@argumentDefinitions(
|
||||
count: { type: "Int!", defaultValue: 1 }
|
||||
cursor: { type: "Cursor" }
|
||||
) {
|
||||
id
|
||||
...ConversationModalCommentContainer_comment
|
||||
rootParent {
|
||||
id
|
||||
}
|
||||
parents(last: $count, before: $cursor)
|
||||
@connection(key: "ConversationModal_parents") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ConversationModalCommentContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
parentCount
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
direction: "backward",
|
||||
getConnectionFromProps(props) {
|
||||
return props.comment && props.comment.parents;
|
||||
},
|
||||
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
|
||||
getFragmentVariables(prevVars, totalCount) {
|
||||
return {
|
||||
...prevVars,
|
||||
count: totalCount,
|
||||
};
|
||||
},
|
||||
getVariables(props, { count, cursor }) {
|
||||
return {
|
||||
count,
|
||||
cursor,
|
||||
// commentID isn't specified as an @argument for the fragment, but it should be a
|
||||
// variable available for the fragment under the query root.
|
||||
commentID: props.comment.id,
|
||||
};
|
||||
},
|
||||
query: graphql`
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query ConversationModalContainerPaginationQuery(
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
$commentID: ID!
|
||||
) {
|
||||
comment(id: $commentID) {
|
||||
...ConversationModalContainer_comment
|
||||
@arguments(count: $count, cursor: $cursor)
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
)(ConversationModalContainer);
|
||||
|
||||
export default enhanced;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
$modal-header-text: var(--v2-colors-mono-900);
|
||||
|
||||
.title {
|
||||
font-size: var(--v2-font-size-4);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
line-height: var(--v2-line-height-title);
|
||||
color: $modal-header-text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.conversationTitle {
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
text-transform: uppercase;
|
||||
display: block;
|
||||
margin-bottom: var(--v2-spacing-2);
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import { useRouter } from "found";
|
||||
import React, { FunctionComponent, RefObject, useCallback } from "react";
|
||||
|
||||
import { getModerationLink } from "coral-framework/helpers";
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { Button, HorizontalGutter, ModalHeader } from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalHeaderContainer_comment } from "coral-admin/__generated__/ConversationModalHeaderContainer_comment.graphql";
|
||||
|
||||
import styles from "./ConversationModalHeaderContainer.css";
|
||||
|
||||
interface Props {
|
||||
comment: ConversationModalHeaderContainer_comment;
|
||||
onClose: () => void;
|
||||
focusableRef: RefObject<any>;
|
||||
}
|
||||
|
||||
const ConversationModalHeaderContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
onClose,
|
||||
focusableRef,
|
||||
}) => {
|
||||
const { router } = useRouter();
|
||||
const onModerate = useCallback(() => {
|
||||
const link = getModerationLink({ storyID: comment.story.id });
|
||||
router.push(link);
|
||||
}, [comment.id]);
|
||||
return (
|
||||
<ModalHeader onClose={onClose} focusableRef={focusableRef}>
|
||||
<HorizontalGutter spacing={3}>
|
||||
<h1 className={styles.title}>
|
||||
<Localized id="conversation-modal-header-title">
|
||||
<span className={styles.conversationTitle}>Conversation on:</span>
|
||||
</Localized>
|
||||
{comment.story.metadata
|
||||
? comment.story.metadata.title
|
||||
: comment.story.url}
|
||||
</h1>
|
||||
<Localized id="conversation-modal-header-moderate-link">
|
||||
<Button variant="outline" color="mono" uppercase onClick={onModerate}>
|
||||
Moderate story
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</ModalHeader>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment ConversationModalHeaderContainer_comment on Comment {
|
||||
story {
|
||||
id
|
||||
metadata {
|
||||
title
|
||||
}
|
||||
url
|
||||
}
|
||||
id
|
||||
}
|
||||
`,
|
||||
})(ConversationModalHeaderContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,8 @@
|
||||
.root {
|
||||
padding: 0;
|
||||
width: 635px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: var(--v2-spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderData,
|
||||
QueryRenderer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { CallOut, Card, Spinner } from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalQuery as QueryTypes } from "coral-admin/__generated__/ConversationModalQuery.graphql";
|
||||
|
||||
import ConversationModalContainer from "./ConversationModalContainer";
|
||||
import ConversationModalHeaderContainer from "./ConversationModalHeaderContainer";
|
||||
import ConversationModalRepliesContainer from "./ConversationModalRepliesContainer";
|
||||
|
||||
import styles from "./ConversationModalQuery.css";
|
||||
|
||||
interface Props {
|
||||
commentID: string;
|
||||
onClose: () => void;
|
||||
firstFocusableRef: React.RefObject<any>;
|
||||
onUsernameClicked: (userID: string) => void;
|
||||
lastFocusableRef: React.RefObject<any>;
|
||||
}
|
||||
|
||||
const ConversationModalQuery: FunctionComponent<Props> = ({
|
||||
commentID,
|
||||
onClose,
|
||||
lastFocusableRef,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query ConversationModalQuery($commentID: ID!) {
|
||||
settings {
|
||||
...ConversationModalContainer_settings
|
||||
...ConversationModalRepliesContainer_settings
|
||||
}
|
||||
comment(id: $commentID) {
|
||||
...ConversationModalContainer_comment
|
||||
...ConversationModalRepliesContainer_comment
|
||||
...ConversationModalHeaderContainer_comment
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{ commentID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ props }: QueryRenderData<QueryTypes>) => {
|
||||
if (!props) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!props.comment) {
|
||||
return (
|
||||
<div>
|
||||
<CallOut>
|
||||
<Localized id="conversation-modal-comment-not-found">
|
||||
Comment not found.
|
||||
</Localized>
|
||||
</CallOut>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={styles.root}>
|
||||
<ConversationModalHeaderContainer
|
||||
onClose={onClose}
|
||||
comment={props.comment}
|
||||
focusableRef={lastFocusableRef}
|
||||
/>
|
||||
<div className={styles.body}>
|
||||
<ConversationModalContainer
|
||||
onClose={onClose}
|
||||
settings={props.settings}
|
||||
comment={props.comment}
|
||||
onUsernameClicked={onUsernameClicked}
|
||||
/>
|
||||
<ConversationModalRepliesContainer
|
||||
onClose={onClose}
|
||||
comment={props.comment}
|
||||
settings={props.settings}
|
||||
onUsernameClicked={onUsernameClicked}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationModalQuery;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.comment {
|
||||
padding-left: calc(var(--v2-spacing-4));
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-left: calc(var(--v2-spacing-4));
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
import { graphql, RelayPaginationProp } from "react-relay";
|
||||
|
||||
import {
|
||||
useLoadMore,
|
||||
withPaginationContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { Omit } from "coral-framework/types";
|
||||
import { Button, HorizontalGutter } from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalRepliesContainer_comment } from "coral-admin/__generated__/ConversationModalRepliesContainer_comment.graphql";
|
||||
import { ConversationModalRepliesContainer_settings } from "coral-admin/__generated__/ConversationModalRepliesContainer_settings.graphql";
|
||||
import { ConversationModalRepliesContainerPaginationQueryVariables } from "coral-admin/__generated__/ConversationModalRepliesContainerPaginationQuery.graphql";
|
||||
import ConversationModalCommentContainer from "./ConversationModalCommentContainer";
|
||||
|
||||
import styles from "./ConversationModalRepliesContainer.css";
|
||||
|
||||
interface Props {
|
||||
relay: RelayPaginationProp;
|
||||
comment: ConversationModalRepliesContainer_comment;
|
||||
settings: ConversationModalRepliesContainer_settings;
|
||||
onClose: () => void;
|
||||
onUsernameClicked: (id?: string) => void;
|
||||
}
|
||||
|
||||
const ConversationModalRepliesContainer: FunctionComponent<Props> = ({
|
||||
comment,
|
||||
relay,
|
||||
settings,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
const [loadMore] = useLoadMore(relay, 5);
|
||||
const replies = comment.replies.edges.map(edge => edge.node);
|
||||
const [showReplies, setShowReplies] = useState(false);
|
||||
const onShowReplies = useCallback(() => {
|
||||
setShowReplies(true);
|
||||
}, []);
|
||||
return (
|
||||
<HorizontalGutter>
|
||||
{showReplies &&
|
||||
replies.map(reply => (
|
||||
<div key={reply.id} className={styles.comment}>
|
||||
<ConversationModalCommentContainer
|
||||
key={reply.id}
|
||||
settings={settings}
|
||||
comment={reply}
|
||||
isHighlighted={false}
|
||||
isReply={true}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.footer}>
|
||||
{!showReplies && comment.replyCount > 0 && (
|
||||
<Localized id="conversation-modal-replies-show">
|
||||
<Button variant="outline" fullWidth onClick={onShowReplies}>
|
||||
Show replies
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
{showReplies &&
|
||||
comment.replyCount > replies.length &&
|
||||
replies.length > 0 && (
|
||||
<Localized id="conversation-modal-replies-show-more">
|
||||
<Button variant="outline" fullWidth onClick={loadMore}>
|
||||
Show more replies
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
</div>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO: (cvle) If this could be autogenerated.
|
||||
type FragmentVariables = Omit<
|
||||
ConversationModalRepliesContainerPaginationQueryVariables,
|
||||
"commentID"
|
||||
>;
|
||||
|
||||
const enhanced = withPaginationContainer<
|
||||
Props,
|
||||
ConversationModalRepliesContainerPaginationQueryVariables,
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
settings: graphql`
|
||||
fragment ConversationModalRepliesContainer_settings on Settings {
|
||||
...ConversationModalCommentContainer_settings
|
||||
}
|
||||
`,
|
||||
comment: graphql`
|
||||
fragment ConversationModalRepliesContainer_comment on Comment
|
||||
@argumentDefinitions(
|
||||
count: { type: "Int!", defaultValue: 5 }
|
||||
cursor: { type: "Cursor" }
|
||||
orderBy: { type: "COMMENT_SORT!", defaultValue: CREATED_AT_ASC }
|
||||
) {
|
||||
id
|
||||
replies(first: $count, after: $cursor, orderBy: $orderBy)
|
||||
@connection(key: "ConversationModalReplies_replies") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ConversationModalCommentContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
replyCount
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
direction: "forward",
|
||||
getConnectionFromProps(props) {
|
||||
return props.comment && props.comment.replies;
|
||||
},
|
||||
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
|
||||
getFragmentVariables(prevVars, totalCount) {
|
||||
return {
|
||||
...prevVars,
|
||||
count: totalCount,
|
||||
};
|
||||
},
|
||||
getVariables(props, { count, cursor }, fragmentVariables) {
|
||||
return {
|
||||
count,
|
||||
cursor,
|
||||
orderBy: fragmentVariables.orderBy,
|
||||
commentID: props.comment.id,
|
||||
};
|
||||
},
|
||||
query: graphql`
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query ConversationModalRepliesContainerPaginationQuery(
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
$orderBy: COMMENT_SORT!
|
||||
$commentID: ID!
|
||||
) {
|
||||
comment(id: $commentID) {
|
||||
...ConversationModalRepliesContainer_comment
|
||||
@arguments(count: $count, cursor: $cursor, orderBy: $orderBy)
|
||||
}
|
||||
}
|
||||
`,
|
||||
}
|
||||
)(ConversationModalRepliesContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderData,
|
||||
QueryRenderer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { CallOut, Spinner } from "coral-ui/components/v2";
|
||||
|
||||
import { ConversationModalRepliesQuery as QueryTypes } from "coral-admin/__generated__/ConversationModalRepliesQuery.graphql";
|
||||
import ConversationModalCommentContainer from "./ConversationModalCommentContainer";
|
||||
|
||||
interface Props {
|
||||
commentID: string;
|
||||
onUsernameClicked: (userID: string) => void;
|
||||
}
|
||||
|
||||
const ConversationModalRepliesQuery: FunctionComponent<Props> = ({
|
||||
commentID,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query ConversationModalRepliesQuery($commentID: ID!) {
|
||||
settings {
|
||||
...ConversationModalCommentContainer_settings
|
||||
}
|
||||
comment(id: $commentID) {
|
||||
replies {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ConversationModalCommentContainer_comment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{ commentID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ props }: QueryRenderData<QueryTypes>) => {
|
||||
if (!props) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!props.comment) {
|
||||
return (
|
||||
<div>
|
||||
<CallOut>
|
||||
<Localized id="conversation-modal-comment-not-found">
|
||||
Comment not found.
|
||||
</Localized>
|
||||
</CallOut>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const replies = props.comment.replies.edges.map(edge => edge.node);
|
||||
return (
|
||||
<div>
|
||||
{replies.map(reply => (
|
||||
<div key={reply.id}>
|
||||
<ConversationModalCommentContainer
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
settings={props.settings}
|
||||
isHighlighted={false}
|
||||
isReply={true}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationModalRepliesQuery;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./ConversationModal";
|
||||
@@ -0,0 +1,10 @@
|
||||
.icon {
|
||||
margin-right: var(--spacing-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { CallOut, Flex, Icon } from "coral-ui/components/v2";
|
||||
|
||||
import styles from "./ExperimentalCallOut.css";
|
||||
|
||||
const ExperimentalCallOut: FunctionComponent = ({ children }) => (
|
||||
<CallOut color="primary" fullWidth>
|
||||
<Flex>
|
||||
<Icon size="md" className={styles.icon}>
|
||||
new_releases
|
||||
</Icon>
|
||||
<Localized id="configure-experimentalFeature">
|
||||
<span className={styles.title}>Experimental Feature</span>
|
||||
</Localized>
|
||||
</Flex>
|
||||
<span>{children}</span>
|
||||
</CallOut>
|
||||
);
|
||||
|
||||
export default ExperimentalCallOut;
|
||||
@@ -1,5 +1,7 @@
|
||||
$moderateCardButtonOutlineApproveColor: var(--v2-colors-green-500);
|
||||
|
||||
.root {
|
||||
border: 1px solid var(--v2-palette-button-outline-approve);
|
||||
border: 1px solid $moderateCardButtonOutlineApproveColor;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--v2-round-corners);
|
||||
width: 65px;
|
||||
@@ -7,15 +9,15 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--v2-palette-button-outline-approve);
|
||||
color: $moderateCardButtonOutlineApproveColor;
|
||||
&:not(:disabled):active {
|
||||
background-color: var(--v2-palette-button-outline-approve);
|
||||
background-color: $moderateCardButtonOutlineApproveColor;
|
||||
color: var(--v2-colors-pure-white);
|
||||
}
|
||||
}
|
||||
|
||||
.invert {
|
||||
background-color: var(--v2-palette-button-outline-approve);
|
||||
background-color: $moderateCardButtonOutlineApproveColor;
|
||||
color: var(--v2-colors-pure-white);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,16 +13,39 @@ interface Props {
|
||||
}
|
||||
|
||||
const CommentAuthorContainer: FunctionComponent<Props> = ({ comment }) => {
|
||||
if (!comment.author || !comment.author.status.ban.active) {
|
||||
if (
|
||||
!comment.author ||
|
||||
!(
|
||||
comment.author.status.ban.active ||
|
||||
comment.author.status.premod.active ||
|
||||
comment.author.status.suspension.active
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles.authorStatus}>
|
||||
<Localized id="commentAuthor-status-banned">
|
||||
<Tag color="error">BANNED</Tag>
|
||||
</Localized>
|
||||
</div>
|
||||
{comment.author.status.ban.active && (
|
||||
<div className={styles.authorStatus}>
|
||||
<Localized id="commentAuthor-status-banned">
|
||||
<Tag color="error">BANNED</Tag>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
{comment.author.status.suspension.active && (
|
||||
<div className={styles.authorStatus}>
|
||||
<Localized id="commentAuthor-status-suspended">
|
||||
<Tag color="error">SUSPENDED</Tag>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
{comment.author.status.premod.active && (
|
||||
<div className={styles.authorStatus}>
|
||||
<Localized id="commentAuthor-status-premod">
|
||||
<Tag color="grey">PRE-MOD</Tag>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -34,6 +57,12 @@ const enhanced = withFragmentContainer<Props>({
|
||||
id
|
||||
username
|
||||
status {
|
||||
suspension {
|
||||
active
|
||||
}
|
||||
premod {
|
||||
active
|
||||
}
|
||||
ban {
|
||||
active
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
|
||||
import CommentContent from "./CommentContent";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof CommentContent> = {
|
||||
suspectWords: ["worse"],
|
||||
bannedWords: ["bad"],
|
||||
className: "custom",
|
||||
children: "Hello <b>Bob</b>, you bad guy",
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<CommentContent {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders empty words correctly", () => {
|
||||
const props: PropTypesOf<typeof CommentContent> = {
|
||||
suspectWords: [],
|
||||
bannedWords: [],
|
||||
className: "custom",
|
||||
children: "Hello <b>Bob</b>, you bad guy",
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<CommentContent {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import cn from "classnames";
|
||||
import { memoize } from "lodash";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { createPurify } from "coral-common/utils/purify";
|
||||
|
||||
import styles from "./CommentContent.css";
|
||||
|
||||
/**
|
||||
* Create a purify instance that will be used to handle HTML content.
|
||||
*/
|
||||
const purify = createPurify(window, false);
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: string | React.ReactElement;
|
||||
suspectWords: ReadonlyArray<string>;
|
||||
bannedWords: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
function escapeHTML(unsafe: string) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
|
||||
}
|
||||
|
||||
// generate a regulare expression that catches the `phrases`.
|
||||
function generateRegExp(phrases: ReadonlyArray<string>) {
|
||||
const inner = phrases
|
||||
.map(phrase =>
|
||||
phrase
|
||||
.split(/\s+/)
|
||||
.map(word => escapeRegExp(word))
|
||||
.join('[\\s"?!.]+')
|
||||
)
|
||||
.join("|");
|
||||
|
||||
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
|
||||
try {
|
||||
return new RegExp(pattern, "iu");
|
||||
} catch (_err) {
|
||||
// IE does not support unicode support, so we'll create one without.
|
||||
return new RegExp(pattern, "i");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
|
||||
function getPhrasesRegexp(
|
||||
suspectWords: ReadonlyArray<string>,
|
||||
bannedWords: ReadonlyArray<string>
|
||||
) {
|
||||
return generateRegExp([...suspectWords, ...bannedWords]);
|
||||
}
|
||||
|
||||
// Memoized version as arguments rarely change.
|
||||
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
|
||||
|
||||
// markPhrasesHTML looks for `supsectWords` and `bannedWords` inside `text` and highlights them by returning
|
||||
// a HTML string.
|
||||
function markPhrasesHTML(
|
||||
text: string,
|
||||
suspectWords: ReadonlyArray<string>,
|
||||
bannedWords: ReadonlyArray<string>
|
||||
) {
|
||||
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
|
||||
const tokens = text.split(regexp);
|
||||
if (tokens.length === 1) {
|
||||
return text;
|
||||
}
|
||||
return tokens
|
||||
.map((token, i) =>
|
||||
// Using our Regexp patterns it returns tokens arranged this way
|
||||
// [STRING_WITH_NO_MATCH, NEW_WORD_DELIMITER, MATCHED_WORD, ...].
|
||||
// This pattern repeats throughout. Next line will mark MATCHED_WORD
|
||||
// and escape all tokens.
|
||||
i % 3 === 2 ? `<mark>${escapeHTML(token)}</mark>` : escapeHTML(token)
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// markHTMLNode manipulates the node by looking for #text nodes and adding markers
|
||||
// for `supsectWords` and `bannedWords`.
|
||||
function markHTMLNode(
|
||||
parentNode: Node,
|
||||
suspectWords: ReadonlyArray<string>,
|
||||
bannedWords: ReadonlyArray<string>
|
||||
) {
|
||||
parentNode.childNodes.forEach(node => {
|
||||
if (node.nodeName === "#text") {
|
||||
const newContent = markPhrasesHTML(
|
||||
node.textContent!,
|
||||
suspectWords,
|
||||
bannedWords
|
||||
);
|
||||
if (newContent !== node.textContent) {
|
||||
const newNode = document.createElement("span");
|
||||
newNode.innerHTML = newContent;
|
||||
parentNode.replaceChild(newNode, node);
|
||||
}
|
||||
} else {
|
||||
markHTMLNode(node, suspectWords, bannedWords);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const CommentContent: FunctionComponent<Props> = ({
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
if (typeof children === "string") {
|
||||
// We create a Shadow DOM Tree with the HTML body content and
|
||||
// use it as a parser.
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = purify.sanitize(children);
|
||||
|
||||
if (suspectWords.length || bannedWords.length) {
|
||||
// Then we traverse it recursively and manipulate it to highlight suspect words
|
||||
// and banned words.
|
||||
markHTMLNode(node, suspectWords, bannedWords);
|
||||
}
|
||||
|
||||
// Finally we render the content of the Shadow DOM Tree
|
||||
return (
|
||||
<div
|
||||
className={cn(className, styles.root)}
|
||||
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default CommentContent;
|
||||
@@ -6,7 +6,7 @@ import { HorizontalGutter, Timestamp } from "coral-ui/components/v2";
|
||||
import { CommentRevisionContainer_comment as CommentData } from "coral-admin/__generated__/CommentRevisionContainer_comment.graphql";
|
||||
import { CommentRevisionContainer_settings as SettingsData } from "coral-admin/__generated__/CommentRevisionContainer_settings.graphql";
|
||||
|
||||
import CommentContent from "./CommentContent";
|
||||
import { CommentContent } from "../Comment";
|
||||
|
||||
interface Props {
|
||||
comment: CommentData;
|
||||
@@ -30,10 +30,7 @@ const CommentRevisionContainer: FunctionComponent<Props> = ({
|
||||
.map(c => (
|
||||
<div key={c.id}>
|
||||
<Timestamp>{c.createdAt}</Timestamp>
|
||||
<CommentContent
|
||||
suspectWords={settings.wordList.suspect}
|
||||
bannedWords={settings.wordList.banned}
|
||||
>
|
||||
<CommentContent phrases={settings}>
|
||||
{c.body ? c.body : ""}
|
||||
</CommentContent>
|
||||
</div>
|
||||
@@ -57,6 +54,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment CommentRevisionContainer_settings on Settings {
|
||||
locale
|
||||
wordList {
|
||||
banned
|
||||
suspect
|
||||
|
||||
@@ -22,10 +22,24 @@
|
||||
border-color: var(--v2-palette-primary-main);
|
||||
color: var(--v2-palette-primary-main);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
|
||||
background-color: transparent;
|
||||
border-color: var(--v2-colors-grey-300);
|
||||
color: var(--v2-colors-grey-300);
|
||||
}
|
||||
}
|
||||
|
||||
.invert {
|
||||
background-color: var(--v2-palette-primary-main);
|
||||
border-color: var(--v2-palette-primary-main);
|
||||
color: var(--v2-palette-text-light);
|
||||
|
||||
&:disabled {
|
||||
color: var(--v2-colors-pure-white);
|
||||
border-color: var(--v2-colors-grey-300);
|
||||
background-color: var(--v2-colors-grey-300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ const FeatureCommentMutation = createMutation(
|
||||
getQueueConnection(store, "REJECTED", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
ConnectionHandler.deleteNode(con!, input.commentID)
|
||||
);
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
$moderateCardFlagsCategoryColor: var(--v2-colors-mono-500);
|
||||
|
||||
.category {
|
||||
font-size: var(--v2-font-size-4);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-body-short);
|
||||
color: var(--v2-palette-accordion-label);
|
||||
color: $moderateCardFlagsCategoryColor;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
$moderateCardFlagUsernameColor: var(--v2-colors-mono-900);
|
||||
$moderateCardReasonTextColor: var(--v2-colors-mono-500);
|
||||
|
||||
.user {
|
||||
color: var(--v2-palette-username-emphasis);
|
||||
color: $moderateCardFlagUsernameColor;
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
@@ -8,7 +11,7 @@
|
||||
}
|
||||
|
||||
.details {
|
||||
color: var(--v2-palette-reason-text);
|
||||
color: $moderateCardReasonTextColor;
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
|
||||
@@ -9,8 +9,3 @@
|
||||
display: inline-block;
|
||||
padding-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.button {
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import CopyToClipboard from "react-copy-to-clipboard";
|
||||
import { getURLWithCommentID } from "coral-framework/helpers";
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { getLocationOrigin } from "coral-framework/utils";
|
||||
import { Button, Icon } from "coral-ui/components";
|
||||
import { Button, Icon } from "coral-ui/components/v2";
|
||||
|
||||
import { LinkDetailsContainer_comment } from "coral-admin/__generated__/LinkDetailsContainer_comment.graphql";
|
||||
import { LinkDetailsContainer_settings } from "coral-admin/__generated__/LinkDetailsContainer_settings.graphql";
|
||||
@@ -32,12 +32,7 @@ const LinkDetailsContainer: FunctionComponent<Props> = ({
|
||||
<CopyToClipboard
|
||||
text={getURLWithCommentID(comment.story.url, comment.id)}
|
||||
>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="filled"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Button>
|
||||
<Icon size="md">link</Icon>
|
||||
<Localized id="moderate-in-stream-link-copy">
|
||||
<span>In Stream</span>
|
||||
@@ -49,12 +44,7 @@ const LinkDetailsContainer: FunctionComponent<Props> = ({
|
||||
<CopyToClipboard
|
||||
text={`${getLocationOrigin()}/admin/moderate/comment/${comment.id}`}
|
||||
>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="filled"
|
||||
size="small"
|
||||
className={styles.button}
|
||||
>
|
||||
<Button>
|
||||
<Icon size="md">link</Icon>
|
||||
<Localized id="moderate-in-moderation-link-copy">
|
||||
<span>In Moderation</span>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
$moderateCardDetailsHeaderColor: var(--v2-colors-mono-500);
|
||||
$moderateCardDetailsDividerColor: var(--v2-colors-grey-100);
|
||||
|
||||
.detailsButton {
|
||||
font-size: var(--v2-font-size-1);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
@@ -12,7 +15,7 @@
|
||||
}
|
||||
|
||||
.detailsButtonColorRegular {
|
||||
border-color: var(--v2-palette-details-header);
|
||||
border-color: $moderateCardDetailsHeaderColor;
|
||||
}
|
||||
|
||||
.detailsText {
|
||||
@@ -20,5 +23,5 @@
|
||||
}
|
||||
|
||||
.detailsDivider {
|
||||
border-color: var(--v2-palette-details-divider);
|
||||
border-color: $moderateCardDetailsDividerColor;
|
||||
}
|
||||
|
||||
@@ -56,9 +56,7 @@ const markers: Array<
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
|
||||
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
|
||||
<Marker color="reported" variant="filled">
|
||||
Suspect Word
|
||||
</Marker>
|
||||
<Marker color="reported">Suspect Word</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
$moderateCardTimestampColor: var(--v2-colors-mono-100);
|
||||
$moderateCardEditedColor: var(--v2-colors-mono-100);
|
||||
$moderateCardAuthorStatusColor: var(--v2-colors-mono-100);
|
||||
$moderateCardDecisionColor: var(--v2-colors-mono-500);
|
||||
$moderateCardStoryTitleColor: var(--v2-colors-mono-500);
|
||||
$moderateCardUsernameBackgroundHover: var(--v2-colors-grey-200);
|
||||
$moderateCardLinkTextColor: var(--v2-colors-teal-700);
|
||||
|
||||
.topBar {
|
||||
position: relative;
|
||||
margin-bottom: var(--v2-spacing-1);
|
||||
@@ -7,26 +15,6 @@
|
||||
margin-bottom: var(--v2-spacing-4);
|
||||
}
|
||||
|
||||
.usernameButton {
|
||||
margin-right: var(--mini-unit);
|
||||
padding: var(--spacing-1);
|
||||
margin-left: calc(-1 * var(--v2-spacing-1));
|
||||
|
||||
line-height: var(--v2-line-height-reset);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--v2-palette-username-background-hover);
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--v2-palette-username-background-hover);
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inReplyTo {
|
||||
}
|
||||
|
||||
@@ -76,7 +64,7 @@
|
||||
}
|
||||
|
||||
.decision {
|
||||
color: var(--v2-palette-label-emphasis);
|
||||
color: $moderateCardDecisionColor;
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
@@ -114,7 +102,7 @@
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
color: var(--v2-palette-link-text);
|
||||
color: $moderateCardLinkTextColor;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -133,14 +121,24 @@
|
||||
}
|
||||
|
||||
.storyTitle {
|
||||
color: var(--v2-palette-story-title);
|
||||
color: $moderateCardStoryTitleColor;
|
||||
}
|
||||
|
||||
.commentOn {
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
margin-bottom: var(--v2-spacing-1);
|
||||
}
|
||||
|
||||
.siteName {
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
}
|
||||
|
||||
.storyTitle {
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
}
|
||||
|
||||
.borderless {
|
||||
border-width: 0px;
|
||||
box-shadow: none;
|
||||
@@ -157,7 +155,7 @@
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: $moderateCardTimestampColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-1);
|
||||
@@ -165,7 +163,7 @@
|
||||
}
|
||||
|
||||
.edited {
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: $moderateCardEditedColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-1);
|
||||
@@ -179,7 +177,7 @@
|
||||
|
||||
.authorStatus {
|
||||
padding-right: var(--spacing-2);
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: $moderateCardAuthorStatusColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-1);
|
||||
|
||||
@@ -21,14 +21,21 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
|
||||
status: "undecided",
|
||||
featured: false,
|
||||
viewContextHref: "http://localhost/comment",
|
||||
suspectWords: ["suspect"],
|
||||
bannedWords: ["banned"],
|
||||
phrases: {
|
||||
locale: "en-US",
|
||||
wordList: {
|
||||
suspect: ["suspect"],
|
||||
banned: ["banned"],
|
||||
},
|
||||
},
|
||||
siteName: null,
|
||||
onApprove: noop,
|
||||
onReject: noop,
|
||||
onFeature: noop,
|
||||
onBan: noop,
|
||||
onUsernameClick: noop,
|
||||
onFocusOrClick: noop,
|
||||
onConversationClick: noop,
|
||||
showStory: false,
|
||||
moderatedBy: null,
|
||||
};
|
||||
|
||||
@@ -10,24 +10,25 @@ import React, {
|
||||
} from "react";
|
||||
|
||||
import { HOTKEYS } from "coral-admin/constants";
|
||||
import { GetPhrasesRegExpOptions } from "coral-admin/helpers";
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
import {
|
||||
BaseButton,
|
||||
Button,
|
||||
ButtonIcon,
|
||||
Card,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Icon,
|
||||
TextLink,
|
||||
Timestamp,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import { CommentContent, InReplyTo, UsernameButton } from "../Comment";
|
||||
import ApproveButton from "./ApproveButton";
|
||||
import CommentAuthorContainer from "./CommentAuthorContainer";
|
||||
import CommentContent from "./CommentContent";
|
||||
import FeatureButton from "./FeatureButton";
|
||||
import InReplyTo from "./InReplyTo";
|
||||
import MarkersContainer from "./MarkersContainer";
|
||||
import RejectButton from "./RejectButton";
|
||||
import Username from "./Username";
|
||||
|
||||
import styles from "./ModerateCard.css";
|
||||
|
||||
@@ -36,6 +37,7 @@ interface Props {
|
||||
username: string;
|
||||
createdAt: string;
|
||||
body: string;
|
||||
highlight?: boolean;
|
||||
inReplyTo?: {
|
||||
id: string;
|
||||
username: string | null;
|
||||
@@ -47,16 +49,17 @@ interface Props {
|
||||
featured: boolean;
|
||||
moderatedBy: React.ReactNode | null;
|
||||
viewContextHref: string;
|
||||
suspectWords: ReadonlyArray<string>;
|
||||
bannedWords: ReadonlyArray<string>;
|
||||
phrases: GetPhrasesRegExpOptions;
|
||||
showStory: boolean;
|
||||
storyTitle?: React.ReactNode;
|
||||
storyHref?: string;
|
||||
siteName: string | null;
|
||||
onModerateStory?: React.EventHandler<React.MouseEvent>;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onFeature: () => void;
|
||||
onUsernameClick: (id?: string) => void;
|
||||
onConversationClick: (() => void) | null;
|
||||
onFocusOrClick: () => void;
|
||||
mini?: boolean;
|
||||
hideUsername?: boolean;
|
||||
@@ -72,6 +75,7 @@ interface Props {
|
||||
selectPrev?: () => void;
|
||||
selectNext?: () => void;
|
||||
onBan: () => void;
|
||||
isQA?: boolean;
|
||||
}
|
||||
|
||||
const ModerateCard: FunctionComponent<Props> = ({
|
||||
@@ -79,14 +83,14 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
username,
|
||||
createdAt,
|
||||
body,
|
||||
highlight = false,
|
||||
inReplyTo,
|
||||
comment,
|
||||
settings,
|
||||
viewContextHref,
|
||||
status,
|
||||
featured,
|
||||
suspectWords,
|
||||
bannedWords,
|
||||
phrases,
|
||||
onApprove,
|
||||
onReject,
|
||||
onFeature,
|
||||
@@ -96,9 +100,11 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
storyTitle,
|
||||
storyHref,
|
||||
onModerateStory,
|
||||
siteName,
|
||||
moderatedBy,
|
||||
selected,
|
||||
onFocusOrClick,
|
||||
onConversationClick,
|
||||
mini = false,
|
||||
hideUsername = false,
|
||||
deleted = false,
|
||||
@@ -106,6 +112,7 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
selectNext,
|
||||
selectPrev,
|
||||
onBan,
|
||||
isQA,
|
||||
}) => {
|
||||
const div = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -186,12 +193,10 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
<Flex alignItems="center">
|
||||
{!hideUsername && username && (
|
||||
<>
|
||||
<BaseButton
|
||||
<UsernameButton
|
||||
username={username}
|
||||
onClick={commentAuthorClick}
|
||||
className={styles.usernameButton}
|
||||
>
|
||||
<Username>{username}</Username>
|
||||
</BaseButton>
|
||||
/>
|
||||
<CommentAuthorContainer comment={comment} />
|
||||
</>
|
||||
)}
|
||||
@@ -204,7 +209,7 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
<FeatureButton
|
||||
featured={featured}
|
||||
onClick={onFeature}
|
||||
enabled={!deleted}
|
||||
enabled={!deleted && !isQA}
|
||||
/>
|
||||
</Flex>
|
||||
{inReplyTo && inReplyTo.username && (
|
||||
@@ -217,23 +222,22 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
</div>
|
||||
<div className={styles.contentArea}>
|
||||
<CommentContent
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
highlight={highlight}
|
||||
phrases={phrases}
|
||||
className={styles.content}
|
||||
>
|
||||
{commentBody}
|
||||
</CommentContent>
|
||||
<div className={styles.viewContext}>
|
||||
<Localized id="moderate-comment-viewContext">
|
||||
<TextLink
|
||||
className={styles.link}
|
||||
href={viewContextHref}
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
</TextLink>
|
||||
</Localized>
|
||||
</div>
|
||||
{onConversationClick && (
|
||||
<div className={styles.viewContext}>
|
||||
<Button iconLeft variant="text" onClick={onConversationClick}>
|
||||
<ButtonIcon>question_answer</ButtonIcon>
|
||||
<Localized id="moderate-comment-viewConversation">
|
||||
<span>View conversation</span>
|
||||
</Localized>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(styles.separator, {
|
||||
[styles.ruledSeparator]: !mini,
|
||||
@@ -250,7 +254,15 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
</Localized>
|
||||
<span>:</span>
|
||||
</div>
|
||||
<div className={styles.storyTitle}>{storyTitle}</div>
|
||||
<div className={styles.commentOn}>
|
||||
{siteName && (
|
||||
<span className={styles.siteName}>
|
||||
{siteName}
|
||||
<Icon>keyboard_arrow_right</Icon>
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.storyTitle}>{storyTitle}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Localized id="moderate-comment-moderateStory">
|
||||
<TextLink
|
||||
|
||||
@@ -4,25 +4,24 @@ import { graphql } from "react-relay";
|
||||
|
||||
import NotAvailable from "coral-admin/components/NotAvailable";
|
||||
import BanModal from "coral-admin/components/UserStatus/BanModal";
|
||||
import { getModerationLink } from "coral-admin/helpers";
|
||||
import {
|
||||
ApproveCommentMutation,
|
||||
RejectCommentMutation,
|
||||
} from "coral-admin/mutations";
|
||||
import FadeInTransition from "coral-framework/components/FadeInTransition";
|
||||
import { getModerationLink } from "coral-framework/helpers";
|
||||
import {
|
||||
MutationProp,
|
||||
withFragmentContainer,
|
||||
withMutation,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
import { GQLSTORY_MODE, GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
|
||||
import {
|
||||
COMMENT_STATUS,
|
||||
ModerateCardContainer_comment,
|
||||
} from "coral-admin/__generated__/ModerateCardContainer_comment.graphql";
|
||||
import { ModerateCardContainer_settings } from "coral-admin/__generated__/ModerateCardContainer_settings.graphql";
|
||||
import { ModerateCardContainer_viewer } from "coral-admin/__generated__/ModerateCardContainer_viewer.graphql";
|
||||
|
||||
import BanCommentUserMutation from "./BanCommentUserMutation";
|
||||
import FeatureCommentMutation from "./FeatureCommentMutation";
|
||||
@@ -32,7 +31,6 @@ import UnfeatureCommentMutation from "./UnfeatureCommentMutation";
|
||||
|
||||
interface Props {
|
||||
comment: ModerateCardContainer_comment;
|
||||
viewer: ModerateCardContainer_viewer;
|
||||
settings: ModerateCardContainer_settings;
|
||||
approveComment: MutationProp<typeof ApproveCommentMutation>;
|
||||
rejectComment: MutationProp<typeof RejectCommentMutation>;
|
||||
@@ -46,6 +44,7 @@ interface Props {
|
||||
mini?: boolean;
|
||||
hideUsername?: boolean;
|
||||
onUsernameClicked?: (userID: string) => void;
|
||||
onConversationClicked?: (commentID: string) => void;
|
||||
onSetSelected?: () => void;
|
||||
selected?: boolean;
|
||||
selectPrev?: () => void;
|
||||
@@ -73,7 +72,6 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
settings,
|
||||
danglingLogic,
|
||||
showStoryInfo,
|
||||
viewer,
|
||||
match,
|
||||
router,
|
||||
approveComment,
|
||||
@@ -86,6 +84,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
selectPrev,
|
||||
selectNext,
|
||||
onUsernameClicked: usernameClicked,
|
||||
onConversationClicked: conversationClicked,
|
||||
onSetSelected: setSelected,
|
||||
banUser,
|
||||
loadNext,
|
||||
@@ -160,9 +159,16 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
[usernameClicked, comment]
|
||||
);
|
||||
|
||||
const onConversationClicked = useCallback(() => {
|
||||
if (!conversationClicked) {
|
||||
return;
|
||||
}
|
||||
conversationClicked(comment.id);
|
||||
}, [conversationClicked, comment]);
|
||||
|
||||
const handleModerateStory = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
router.push(getModerationLink("default", comment.story.id));
|
||||
router.push(getModerationLink({ storyID: comment.story.id }));
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -191,14 +197,28 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
}, [comment]);
|
||||
|
||||
const handleBanConfirm = useCallback(
|
||||
async (message: string) => {
|
||||
async (rejectExistingComments: boolean, message: string) => {
|
||||
if (comment.author) {
|
||||
await banUser({ userID: comment.author.id, message });
|
||||
await banUser({
|
||||
userID: comment.author.id,
|
||||
message,
|
||||
rejectExistingComments,
|
||||
});
|
||||
}
|
||||
setShowBanModal(false);
|
||||
},
|
||||
[comment]
|
||||
);
|
||||
|
||||
// Only highlight comments that have been flagged for containing a banned or
|
||||
// suspect word.
|
||||
const highlight = comment.revision
|
||||
? comment.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD +
|
||||
comment.revision.actionCounts.flag.reasons
|
||||
.COMMENT_DETECTED_SUSPECT_WORD >
|
||||
0
|
||||
: false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FadeInTransition active={Boolean(comment.enteredLive)}>
|
||||
@@ -211,6 +231,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
}
|
||||
createdAt={comment.createdAt}
|
||||
body={comment.body!}
|
||||
highlight={highlight}
|
||||
inReplyTo={comment.parent && comment.parent.author}
|
||||
comment={comment}
|
||||
settings={settings}
|
||||
@@ -218,20 +239,22 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
status={getStatus(comment)}
|
||||
featured={isFeatured(comment)}
|
||||
viewContextHref={comment.permalink}
|
||||
suspectWords={settings.wordList.suspect}
|
||||
bannedWords={settings.wordList.banned}
|
||||
phrases={settings}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onFeature={onFeature}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
onConversationClick={
|
||||
conversationClicked ? onConversationClicked : null
|
||||
}
|
||||
selected={selected}
|
||||
selectPrev={selectPrev}
|
||||
selectNext={selectNext}
|
||||
siteName={settings.multisite ? comment.site.name : null}
|
||||
onBan={openBanModal}
|
||||
moderatedBy={
|
||||
<ModeratedByContainer
|
||||
onUsernameClicked={onUsernameClicked}
|
||||
viewer={viewer}
|
||||
comment={comment}
|
||||
/>
|
||||
}
|
||||
@@ -242,12 +265,13 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
<NotAvailable />
|
||||
)
|
||||
}
|
||||
storyHref={getModerationLink("default", comment.story.id)}
|
||||
storyHref={getModerationLink({ storyID: comment.story.id })}
|
||||
onModerateStory={handleModerateStory}
|
||||
mini={mini}
|
||||
hideUsername={hideUsername}
|
||||
deleted={comment.deleted ? comment.deleted : false}
|
||||
edited={comment.editing.edited}
|
||||
isQA={comment.story.settings.mode === GQLSTORY_MODE.QA}
|
||||
/>
|
||||
</FadeInTransition>
|
||||
<BanModal
|
||||
@@ -278,6 +302,16 @@ const enhanced = withFragmentContainer<Props>({
|
||||
statusLiveUpdated
|
||||
createdAt
|
||||
body
|
||||
revision {
|
||||
actionCounts {
|
||||
flag {
|
||||
reasons {
|
||||
COMMENT_DETECTED_BANNED_WORD
|
||||
COMMENT_DETECTED_SUSPECT_WORD
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tags {
|
||||
code
|
||||
}
|
||||
@@ -299,6 +333,13 @@ const enhanced = withFragmentContainer<Props>({
|
||||
metadata {
|
||||
title
|
||||
}
|
||||
settings {
|
||||
mode
|
||||
}
|
||||
}
|
||||
site {
|
||||
id
|
||||
name
|
||||
}
|
||||
permalink
|
||||
enteredLive
|
||||
@@ -310,18 +351,16 @@ const enhanced = withFragmentContainer<Props>({
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment ModerateCardContainer_settings on Settings {
|
||||
locale
|
||||
wordList {
|
||||
banned
|
||||
suspect
|
||||
}
|
||||
multisite
|
||||
featureFlags
|
||||
...MarkersContainer_settings
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment ModerateCardContainer_viewer on User {
|
||||
...ModeratedByContainer_viewer
|
||||
}
|
||||
`,
|
||||
})(
|
||||
withRouter(
|
||||
withMutation(BanCommentUserMutation)(
|
||||
|
||||
@@ -24,10 +24,6 @@ it("viewer's username shows on moderation cards moderated by viewer", () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
viewer: {
|
||||
id: "viewer",
|
||||
username: "viewer",
|
||||
},
|
||||
onUsernameClicked: (id?: string | null) => {
|
||||
return;
|
||||
},
|
||||
|
||||
@@ -1,55 +1,88 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { BaseButton } from "coral-ui/components/v2";
|
||||
|
||||
import { ModeratedByContainer_comment } from "coral-admin/__generated__/ModeratedByContainer_comment.graphql";
|
||||
import { ModeratedByContainer_viewer } from "coral-admin/__generated__/ModeratedByContainer_viewer.graphql";
|
||||
|
||||
import styles from "./ModeratedByContainer.css";
|
||||
|
||||
interface Props {
|
||||
viewer: ModeratedByContainer_viewer;
|
||||
comment: ModeratedByContainer_comment;
|
||||
onUsernameClicked: (id?: string | null) => void;
|
||||
}
|
||||
|
||||
const MODERATION_STATUS = ["APPROVED", "REJECTED"];
|
||||
|
||||
interface ModeratedBy {
|
||||
id?: string;
|
||||
username?: string | null;
|
||||
system?: boolean;
|
||||
}
|
||||
|
||||
const system: ModeratedBy = { system: true };
|
||||
|
||||
const ModeratedByContainer: React.FunctionComponent<Props> = ({
|
||||
comment,
|
||||
viewer,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
let moderatedBy: React.ReactElement | null;
|
||||
let id: string | null = null;
|
||||
if (comment.statusHistory.edges.length === 0) {
|
||||
moderatedBy = null;
|
||||
} else if (comment.statusHistory.edges[0].node.moderator === null) {
|
||||
moderatedBy = (
|
||||
<Localized id="moderate-comment-moderatedBySystem">System</Localized>
|
||||
);
|
||||
} else if (viewer.id === comment.statusHistory.edges[0].node.moderator.id) {
|
||||
moderatedBy = viewer.username ? <>{viewer.username}</> : null;
|
||||
} else {
|
||||
moderatedBy = <>{comment.statusHistory.edges[0].node.moderator.username}</>;
|
||||
id = comment.statusHistory.edges[0].node.moderator.id;
|
||||
}
|
||||
const moderatedBy: ModeratedBy | null = useMemo(() => {
|
||||
// If the comment was just moderated by the viewer, don't display anything.
|
||||
// This will update once the mutation returns.
|
||||
if (comment.viewerDidModerate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the comment has not been approved or rejected, don't render anything.
|
||||
if (!MODERATION_STATUS.includes(comment.status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (comment.statusHistory.edges.length === 0) {
|
||||
return system;
|
||||
}
|
||||
|
||||
// Get the node that was the last moderated by element.
|
||||
const {
|
||||
node: { status, moderator },
|
||||
} = comment.statusHistory.edges[0];
|
||||
if (!MODERATION_STATUS.includes(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!moderator) {
|
||||
return system;
|
||||
}
|
||||
|
||||
return moderator;
|
||||
}, [comment]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (!moderatedBy || !moderatedBy.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUsernameClicked(moderatedBy.id);
|
||||
}, [onUsernameClicked, moderatedBy]);
|
||||
|
||||
if (!moderatedBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
onUsernameClicked(id);
|
||||
}, [onUsernameClicked, comment]);
|
||||
|
||||
return (
|
||||
<BaseButton onClick={onClick}>
|
||||
<Localized id="moderate-comment-moderatedBy">
|
||||
<div className={styles.moderatedBy}>Moderated By</div>
|
||||
</Localized>
|
||||
<div className={styles.moderatedByUsername}>{moderatedBy}</div>
|
||||
<div className={styles.moderatedByUsername}>
|
||||
{moderatedBy.system ? (
|
||||
<Localized id="moderate-comment-moderatedBySystem">System</Localized>
|
||||
) : (
|
||||
moderatedBy.username
|
||||
)}
|
||||
</div>
|
||||
</BaseButton>
|
||||
);
|
||||
};
|
||||
@@ -58,10 +91,12 @@ const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment ModeratedByContainer_comment on Comment {
|
||||
id
|
||||
statusLiveUpdated
|
||||
status
|
||||
viewerDidModerate
|
||||
statusHistory(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
moderator {
|
||||
id
|
||||
username
|
||||
@@ -71,12 +106,6 @@ const enhanced = withFragmentContainer<Props>({
|
||||
}
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment ModeratedByContainer_viewer on User {
|
||||
id
|
||||
username
|
||||
}
|
||||
`,
|
||||
})(ModeratedByContainer);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
$moderateCardButtonOutlineRejectColor: var(--v2-colors-red-500);
|
||||
|
||||
.root {
|
||||
border: 1px solid var(--v2-palette-button-outline-reject);
|
||||
border: 1px solid $moderateCardButtonOutlineRejectColor;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--v2-round-corners);
|
||||
width: 65px;
|
||||
@@ -7,15 +9,15 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--v2-palette-button-outline-reject);
|
||||
color: $moderateCardButtonOutlineRejectColor;
|
||||
&:not(:disabled):active {
|
||||
background-color: var(--v2-palette-button-outline-reject);
|
||||
background-color: $moderateCardButtonOutlineRejectColor;
|
||||
color: var(--v2-colors-pure-white);
|
||||
}
|
||||
}
|
||||
|
||||
.invert {
|
||||
background-color: var(--v2-palette-button-outline-reject);
|
||||
background-color: $moderateCardButtonOutlineRejectColor;
|
||||
color: var(--v2-colors-pure-white);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
$moderateCardCounterBadgeBackgroundAlert: var(--v2-colors-red-500);
|
||||
$moderateCardToxicityColor: var(--v2-colors-mono-500);
|
||||
|
||||
.root {
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-body-short);
|
||||
color: var(--v2-palette-accordion-label);
|
||||
color: $moderateCardToxicityColor;
|
||||
}
|
||||
|
||||
.likely {
|
||||
color: var(--v2-palette-counter-badge-background-alert);
|
||||
color: $moderateCardCounterBadgeBackgroundAlert;
|
||||
}
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "Hello <b>Bob</b><span>, you <mark>bad</mark> guy</span>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`renders empty words correctly 1`] = `
|
||||
<div
|
||||
className="custom CommentContent-root"
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "Hello <b>Bob</b>, you bad guy",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
-1
@@ -82,7 +82,6 @@ exports[`renders all markers 1`] = `
|
||||
>
|
||||
<withPropsOnChange(Marker)
|
||||
color="reported"
|
||||
variant="filled"
|
||||
>
|
||||
Suspect Word
|
||||
</withPropsOnChange(Marker)>
|
||||
|
||||
+216
-177
@@ -19,14 +19,10 @@ exports[`renders approved correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -47,16 +43,20 @@ exports[`renders approved correctly 1`] = `
|
||||
className="ModerateCard-contentArea"
|
||||
>
|
||||
<CommentContent
|
||||
bannedWords={
|
||||
Array [
|
||||
"banned",
|
||||
]
|
||||
}
|
||||
className="ModerateCard-content"
|
||||
suspectWords={
|
||||
Array [
|
||||
"suspect",
|
||||
]
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
"wordList": Object {
|
||||
"banned": Array [
|
||||
"banned",
|
||||
],
|
||||
"suspect": Array [
|
||||
"suspect",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
content
|
||||
@@ -64,17 +64,22 @@ exports[`renders approved correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -149,14 +154,10 @@ exports[`renders correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -177,16 +178,20 @@ exports[`renders correctly 1`] = `
|
||||
className="ModerateCard-contentArea"
|
||||
>
|
||||
<CommentContent
|
||||
bannedWords={
|
||||
Array [
|
||||
"banned",
|
||||
]
|
||||
}
|
||||
className="ModerateCard-content"
|
||||
suspectWords={
|
||||
Array [
|
||||
"suspect",
|
||||
]
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
"wordList": Object {
|
||||
"banned": Array [
|
||||
"banned",
|
||||
],
|
||||
"suspect": Array [
|
||||
"suspect",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
content
|
||||
@@ -194,17 +199,22 @@ exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -279,14 +289,10 @@ exports[`renders dangling correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -307,16 +313,20 @@ exports[`renders dangling correctly 1`] = `
|
||||
className="ModerateCard-contentArea"
|
||||
>
|
||||
<CommentContent
|
||||
bannedWords={
|
||||
Array [
|
||||
"banned",
|
||||
]
|
||||
}
|
||||
className="ModerateCard-content"
|
||||
suspectWords={
|
||||
Array [
|
||||
"suspect",
|
||||
]
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
"wordList": Object {
|
||||
"banned": Array [
|
||||
"banned",
|
||||
],
|
||||
"suspect": Array [
|
||||
"suspect",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
content
|
||||
@@ -324,17 +334,22 @@ exports[`renders dangling correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -409,14 +424,10 @@ exports[`renders rejected correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -437,16 +448,20 @@ exports[`renders rejected correctly 1`] = `
|
||||
className="ModerateCard-contentArea"
|
||||
>
|
||||
<CommentContent
|
||||
bannedWords={
|
||||
Array [
|
||||
"banned",
|
||||
]
|
||||
}
|
||||
className="ModerateCard-content"
|
||||
suspectWords={
|
||||
Array [
|
||||
"suspect",
|
||||
]
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
"wordList": Object {
|
||||
"banned": Array [
|
||||
"banned",
|
||||
],
|
||||
"suspect": Array [
|
||||
"suspect",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
content
|
||||
@@ -454,17 +469,22 @@ exports[`renders rejected correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -539,14 +559,10 @@ exports[`renders reply correctly 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -576,16 +592,20 @@ exports[`renders reply correctly 1`] = `
|
||||
className="ModerateCard-contentArea"
|
||||
>
|
||||
<CommentContent
|
||||
bannedWords={
|
||||
Array [
|
||||
"banned",
|
||||
]
|
||||
}
|
||||
className="ModerateCard-content"
|
||||
suspectWords={
|
||||
Array [
|
||||
"suspect",
|
||||
]
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
"wordList": Object {
|
||||
"banned": Array [
|
||||
"banned",
|
||||
],
|
||||
"suspect": Array [
|
||||
"suspect",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
content
|
||||
@@ -593,17 +613,22 @@ exports[`renders reply correctly 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -678,14 +703,10 @@ exports[`renders story info 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -706,16 +727,20 @@ exports[`renders story info 1`] = `
|
||||
className="ModerateCard-contentArea"
|
||||
>
|
||||
<CommentContent
|
||||
bannedWords={
|
||||
Array [
|
||||
"banned",
|
||||
]
|
||||
}
|
||||
className="ModerateCard-content"
|
||||
suspectWords={
|
||||
Array [
|
||||
"suspect",
|
||||
]
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
"wordList": Object {
|
||||
"banned": Array [
|
||||
"banned",
|
||||
],
|
||||
"suspect": Array [
|
||||
"suspect",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
content
|
||||
@@ -723,17 +748,22 @@ exports[`renders story info 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
@@ -761,9 +791,13 @@ exports[`renders story info 1`] = `
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-storyTitle"
|
||||
className="ModerateCard-commentOn"
|
||||
>
|
||||
Cancer cured!
|
||||
<span
|
||||
className="ModerateCard-storyTitle"
|
||||
>
|
||||
Cancer cured!
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Localized
|
||||
@@ -842,14 +876,10 @@ exports[`renders tombstoned when comment is deleted 1`] = `
|
||||
alignItems="center"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-usernameButton"
|
||||
<UsernameButton
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
username="Theon"
|
||||
/>
|
||||
<Relay(CommentAuthorContainer)
|
||||
comment={Object {}}
|
||||
/>
|
||||
@@ -870,16 +900,20 @@ exports[`renders tombstoned when comment is deleted 1`] = `
|
||||
className="ModerateCard-contentArea"
|
||||
>
|
||||
<CommentContent
|
||||
bannedWords={
|
||||
Array [
|
||||
"banned",
|
||||
]
|
||||
}
|
||||
className="ModerateCard-content"
|
||||
suspectWords={
|
||||
Array [
|
||||
"suspect",
|
||||
]
|
||||
highlight={false}
|
||||
phrases={
|
||||
Object {
|
||||
"locale": "en-US",
|
||||
"wordList": Object {
|
||||
"banned": Array [
|
||||
"banned",
|
||||
],
|
||||
"suspect": Array [
|
||||
"suspect",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<Localized
|
||||
@@ -895,17 +929,22 @@ exports[`renders tombstoned when comment is deleted 1`] = `
|
||||
<div
|
||||
className="ModerateCard-viewContext"
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
<ForwardRef(forwardRef)
|
||||
iconLeft={true}
|
||||
onClick={[Function]}
|
||||
variant="text"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
<ForwardRef(forwardRef)>
|
||||
question_answer
|
||||
</ForwardRef(forwardRef)>
|
||||
<Localized
|
||||
id="moderate-comment-viewConversation"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
<span>
|
||||
View conversation
|
||||
</span>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
|
||||
-6
@@ -19,11 +19,5 @@ exports[`viewer's username shows on moderation cards moderated by viewer 1`] = `
|
||||
}
|
||||
}
|
||||
onUsernameClicked={[Function]}
|
||||
viewer={
|
||||
Object {
|
||||
"id": "viewer",
|
||||
"username": "viewer",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user