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 |
@@ -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
+1
-1
@@ -54,5 +54,5 @@
|
||||
"search.exclude": {
|
||||
"package-lock.json": true
|
||||
},
|
||||
"debug.node.autoAttach": "off"
|
||||
"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;
|
||||
}
|
||||
```
|
||||
Generated
+276
-50
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coralproject/talk",
|
||||
"version": "5.5.0",
|
||||
"version": "6.1.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -3217,7 +3217,6 @@
|
||||
"version": "7.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.8.3.tgz",
|
||||
"integrity": "sha512-lrIU4aVbmlM/wQPzhEvzvNJskKyYptuXb0fGC0lTQTupTOYtR2Vqbu6/jf8vTr4M8Wt1nIzxVrSvPI5qESa/xA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"core-js-pure": "^3.0.0",
|
||||
"regenerator-runtime": "^0.13.2"
|
||||
@@ -5760,6 +5759,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/xregexp": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/xregexp/-/xregexp-4.3.0.tgz",
|
||||
"integrity": "sha512-3gJTS9gt27pS7U9q5IVqo4YvKSlkf2ck8ish6etuDj6LIRxkL/2Y8RMUtK/QzvE1Yv2zwWV5yemI2BS0GGGFnA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "12.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-12.0.12.tgz",
|
||||
@@ -8680,20 +8685,95 @@
|
||||
"dev": true
|
||||
},
|
||||
"body-parser": {
|
||||
"version": "1.18.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz",
|
||||
"integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=",
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
|
||||
"integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
|
||||
"requires": {
|
||||
"bytes": "3.0.0",
|
||||
"bytes": "3.1.0",
|
||||
"content-type": "~1.0.4",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~1.1.1",
|
||||
"http-errors": "~1.6.2",
|
||||
"iconv-lite": "0.4.19",
|
||||
"depd": "~1.1.2",
|
||||
"http-errors": "1.7.2",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "~2.3.0",
|
||||
"qs": "6.5.1",
|
||||
"raw-body": "2.3.2",
|
||||
"type-is": "~1.6.15"
|
||||
"qs": "6.7.0",
|
||||
"raw-body": "2.4.0",
|
||||
"type-is": "~1.6.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"bytes": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
|
||||
"integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
|
||||
},
|
||||
"http-errors": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
|
||||
"integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
|
||||
"requires": {
|
||||
"depd": "~1.1.2",
|
||||
"inherits": "2.0.3",
|
||||
"setprototypeof": "1.1.1",
|
||||
"statuses": ">= 1.5.0 < 2",
|
||||
"toidentifier": "1.0.0"
|
||||
}
|
||||
},
|
||||
"iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"requires": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
}
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz",
|
||||
"integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ=="
|
||||
},
|
||||
"mime-types": {
|
||||
"version": "2.1.26",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz",
|
||||
"integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==",
|
||||
"requires": {
|
||||
"mime-db": "1.43.0"
|
||||
}
|
||||
},
|
||||
"qs": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
|
||||
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
|
||||
},
|
||||
"raw-body": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
|
||||
"integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
|
||||
"requires": {
|
||||
"bytes": "3.1.0",
|
||||
"http-errors": "1.7.2",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
}
|
||||
},
|
||||
"setprototypeof": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
|
||||
},
|
||||
"statuses": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
|
||||
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
|
||||
},
|
||||
"type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"requires": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bonjour": {
|
||||
@@ -10373,7 +10453,8 @@
|
||||
"content-disposition": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
|
||||
"integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ="
|
||||
"integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=",
|
||||
"dev": true
|
||||
},
|
||||
"content-security-policy-builder": {
|
||||
"version": "2.0.0",
|
||||
@@ -10645,8 +10726,7 @@
|
||||
"core-js-pure": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz",
|
||||
"integrity": "sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw=="
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -15674,46 +15754,182 @@
|
||||
"integrity": "sha512-6SK3MG/Bbhm8MsgyJAylg+ucIOU71/FzyFalcfu5nY19dH8y/z0tBJU0wrNBXD4B27EoQtqPF/9wqH0iYAd04g=="
|
||||
},
|
||||
"express": {
|
||||
"version": "4.16.3",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.16.3.tgz",
|
||||
"integrity": "sha1-avilAjUNsyRuzEvs9rWjTSL37VM=",
|
||||
"version": "4.17.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
|
||||
"integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
|
||||
"requires": {
|
||||
"accepts": "~1.3.5",
|
||||
"accepts": "~1.3.7",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.18.2",
|
||||
"content-disposition": "0.5.2",
|
||||
"body-parser": "1.19.0",
|
||||
"content-disposition": "0.5.3",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.3.1",
|
||||
"cookie": "0.4.0",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "~1.1.2",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.1.1",
|
||||
"finalhandler": "~1.1.2",
|
||||
"fresh": "0.5.2",
|
||||
"merge-descriptors": "1.0.1",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.3.0",
|
||||
"parseurl": "~1.3.2",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.7",
|
||||
"proxy-addr": "~2.0.3",
|
||||
"qs": "6.5.1",
|
||||
"range-parser": "~1.2.0",
|
||||
"safe-buffer": "5.1.1",
|
||||
"send": "0.16.2",
|
||||
"serve-static": "1.13.2",
|
||||
"setprototypeof": "1.1.0",
|
||||
"statuses": "~1.4.0",
|
||||
"type-is": "~1.6.16",
|
||||
"proxy-addr": "~2.0.5",
|
||||
"qs": "6.7.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.1.2",
|
||||
"send": "0.17.1",
|
||||
"serve-static": "1.14.1",
|
||||
"setprototypeof": "1.1.1",
|
||||
"statuses": "~1.5.0",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
|
||||
"integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
|
||||
"accepts": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
|
||||
"integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
|
||||
"requires": {
|
||||
"mime-types": "~2.1.24",
|
||||
"negotiator": "0.6.2"
|
||||
}
|
||||
},
|
||||
"content-disposition": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
|
||||
"integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
|
||||
"requires": {
|
||||
"safe-buffer": "5.1.2"
|
||||
}
|
||||
},
|
||||
"finalhandler": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
|
||||
"integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
|
||||
"requires": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.3.0",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~1.5.0",
|
||||
"unpipe": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"http-errors": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
|
||||
"integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==",
|
||||
"requires": {
|
||||
"depd": "~1.1.2",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.1.1",
|
||||
"statuses": ">= 1.5.0 < 2",
|
||||
"toidentifier": "1.0.0"
|
||||
}
|
||||
},
|
||||
"inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
|
||||
},
|
||||
"mime-db": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz",
|
||||
"integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ=="
|
||||
},
|
||||
"mime-types": {
|
||||
"version": "2.1.26",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz",
|
||||
"integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==",
|
||||
"requires": {
|
||||
"mime-db": "1.43.0"
|
||||
}
|
||||
},
|
||||
"negotiator": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
|
||||
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
|
||||
},
|
||||
"parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
|
||||
},
|
||||
"qs": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
|
||||
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
|
||||
},
|
||||
"range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
|
||||
},
|
||||
"safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"send": {
|
||||
"version": "0.17.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
|
||||
"integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
|
||||
"requires": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "~1.1.2",
|
||||
"destroy": "~1.0.4",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "~1.7.2",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.1",
|
||||
"on-finished": "~2.3.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~1.5.0"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
|
||||
"integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
|
||||
"requires": {
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.17.1"
|
||||
}
|
||||
},
|
||||
"setprototypeof": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
|
||||
},
|
||||
"statuses": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
|
||||
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
|
||||
},
|
||||
"type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"requires": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -16084,6 +16300,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
|
||||
"integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~1.0.2",
|
||||
@@ -20265,9 +20482,9 @@
|
||||
"integrity": "sha512-pKnZpbgCTfH/1NLIlOduP/V+WRXzC2MOz3Qo8xmxk8C5GudJLgK5QyLVXOSWy3ParAH7Eemurl3xjv/WXYFvMA=="
|
||||
},
|
||||
"ipaddr.js": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.6.0.tgz",
|
||||
"integrity": "sha1-4/o1e3c9phnybpXwSdBVxyeW+Gs="
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
|
||||
},
|
||||
"is-absolute": {
|
||||
"version": "1.0.0",
|
||||
@@ -29819,12 +30036,12 @@
|
||||
}
|
||||
},
|
||||
"proxy-addr": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.3.tgz",
|
||||
"integrity": "sha512-jQTChiCJteusULxjBp8+jftSQE5Obdl3k4cnmLA6WXtK6XFuWRnvVL7aCiBqaLPM8c4ph0S4tKna8XvmIwEnXQ==",
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
|
||||
"integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==",
|
||||
"requires": {
|
||||
"forwarded": "~0.1.2",
|
||||
"ipaddr.js": "1.6.0"
|
||||
"ipaddr.js": "1.9.1"
|
||||
}
|
||||
},
|
||||
"proxy-agent": {
|
||||
@@ -31864,8 +32081,7 @@
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.3",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
|
||||
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw=="
|
||||
},
|
||||
"regenerator-transform": {
|
||||
"version": "0.13.4",
|
||||
@@ -37925,6 +38141,14 @@
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"xregexp": "4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"xregexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz",
|
||||
"integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"del": {
|
||||
@@ -39152,10 +39376,12 @@
|
||||
"integrity": "sha512-tGkGJkN8XqCod7OT+EvGYK5Z4SfDQGD30zAa58OcnAa0RRWgzUEK72tkXhsX1FZd+rgnhRxFtmO+ihkp8LHSkw=="
|
||||
},
|
||||
"xregexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz",
|
||||
"integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==",
|
||||
"dev": true
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.3.0.tgz",
|
||||
"integrity": "sha512-7jXDIFXh5yJ/orPn4SXjuVrWWoi4Cr8jfV1eHv9CixKSbU+jY4mxfrBwAuDvupPNKpMUY+FeIqsVw/JLT9+B8g==",
|
||||
"requires": {
|
||||
"@babel/runtime-corejs3": "^7.8.3"
|
||||
}
|
||||
},
|
||||
"xtend": {
|
||||
"version": "4.0.1",
|
||||
|
||||
+13
-7
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "@coralproject/talk",
|
||||
"version": "5.5.0",
|
||||
"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,7 +128,6 @@
|
||||
"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",
|
||||
@@ -140,7 +140,8 @@
|
||||
"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.8.3",
|
||||
@@ -240,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",
|
||||
@@ -400,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": [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -501,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]",
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,10 @@ exports[`renders correctly 1`] = `
|
||||
/>
|
||||
</withPropsOnChange(End)>
|
||||
</withPropsOnChange(AppBar)>
|
||||
child
|
||||
<NotificationProvider>
|
||||
<NotificationContainer />
|
||||
child
|
||||
</NotificationProvider>
|
||||
<Version />
|
||||
</div>
|
||||
`;
|
||||
|
||||
+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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,26 +15,6 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
|
||||
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: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inReplyTo {
|
||||
}
|
||||
|
||||
@@ -142,13 +122,23 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
|
||||
|
||||
.storyTitle {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
-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",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
+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",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
.root {
|
||||
margin-top: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
max-height: 15em;
|
||||
width: calc(20 * var(--mini-unit));
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
overflow-x: hidden;
|
||||
/* adjust for button line-height being > 1 */
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.button {
|
||||
color: var(--v2-colors-mono-500) !important;
|
||||
border-width: 0;
|
||||
width: calc(20 * var(--mini-unit));
|
||||
margin-right: calc(var(--v2-spacing-1) / 2);
|
||||
font-size: var(--v2-font-size-3);
|
||||
line-height: var(--v2-line-height-min);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.buttonIconLeft {
|
||||
width: 20px;
|
||||
margin-right: calc(var(--v2-spacing-1) / 2);
|
||||
}
|
||||
|
||||
.buttonIconRight {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.buttonIconLeft,
|
||||
.buttonIconRight {
|
||||
/* adjust for button line-height being > 1 */
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.buttonText {
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
|
||||
import { IntersectionProvider } from "coral-framework/lib/intersection";
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
ClickOutside,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Popover,
|
||||
Spinner,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import styles from "./PaginatedSelect.css";
|
||||
|
||||
interface Props {
|
||||
onLoadMore: () => void;
|
||||
icon?: string;
|
||||
hasMore: boolean;
|
||||
disableLoadMore: boolean;
|
||||
loading: boolean;
|
||||
selected: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PaginatedSelect: FunctionComponent<Props> = ({
|
||||
loading,
|
||||
onLoadMore,
|
||||
disableLoadMore,
|
||||
hasMore,
|
||||
children,
|
||||
icon,
|
||||
selected,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<Popover
|
||||
id=""
|
||||
placement="bottom-end"
|
||||
modifiers={{ arrow: { enabled: false }, offset: { offset: "0, 4" } }}
|
||||
body={({ toggleVisibility }) => (
|
||||
<ClickOutside onClickOutside={toggleVisibility}>
|
||||
<IntersectionProvider>
|
||||
<Dropdown className={styles.dropdown}>
|
||||
{children}
|
||||
{loading && (
|
||||
<Flex justifyContent="center">
|
||||
<Spinner />
|
||||
</Flex>
|
||||
)}
|
||||
{hasMore && (
|
||||
<Flex justifyContent="center">
|
||||
<AutoLoadMore
|
||||
disableLoadMore={disableLoadMore}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
</Dropdown>
|
||||
</IntersectionProvider>
|
||||
</ClickOutside>
|
||||
)}
|
||||
>
|
||||
{({ toggleVisibility, ref, visible }) => (
|
||||
<Button
|
||||
className={cn(styles.button, className)}
|
||||
variant="flat"
|
||||
adornmentLeft
|
||||
color="mono"
|
||||
onClick={toggleVisibility}
|
||||
ref={ref}
|
||||
uppercase={false}
|
||||
>
|
||||
{icon && (
|
||||
<ButtonIcon className={styles.buttonIconLeft}>{icon}</ButtonIcon>
|
||||
)}
|
||||
<Flex alignItems="center" className={styles.wrapper}>
|
||||
{selected}
|
||||
</Flex>
|
||||
{!visible && (
|
||||
<ButtonIcon className={styles.buttonIconRight}>
|
||||
keyboard_arrow_down
|
||||
</ButtonIcon>
|
||||
)}
|
||||
{visible && (
|
||||
<ButtonIcon className={styles.buttonIconRight}>
|
||||
keyboard_arrow_up
|
||||
</ButtonIcon>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginatedSelect;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./PaginatedSelect";
|
||||
@@ -0,0 +1,29 @@
|
||||
.sizeReg {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.sizeSmall {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.colorReg {
|
||||
color: var(--v2-colors-grey-500);
|
||||
}
|
||||
|
||||
.colorLight {
|
||||
color: var(--v2-colors-grey-400);
|
||||
}
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.circle {
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import styles from "./Circle.css";
|
||||
|
||||
export interface CircleProps {
|
||||
className?: string;
|
||||
hollow?: boolean;
|
||||
size?: "small" | "regular";
|
||||
color?: "light" | "regular";
|
||||
}
|
||||
|
||||
const Circle: FunctionComponent<CircleProps> = props => {
|
||||
return (
|
||||
<div
|
||||
className={cn(styles.root, props.className, {
|
||||
[styles.sizeReg]: props.size === "regular",
|
||||
[styles.sizeSmall]: props.size === "small",
|
||||
[styles.colorReg]: props.color === "regular",
|
||||
[styles.colorLight]: props.color === "light",
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
className={cn(styles.circle, {})}
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{props.hollow && (
|
||||
<circle cx="50%" cy="50%" r="45" strokeWidth="10" fill="none" />
|
||||
)}
|
||||
{!props.hollow && <circle cx="50%" cy="50%" r="50" />}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Circle.defaultProps = {
|
||||
size: "regular",
|
||||
color: "regular",
|
||||
};
|
||||
|
||||
export default Circle;
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
border-left: 1px solid var(--palette-grey-main);
|
||||
border-right: 1px solid var(--palette-grey-main);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dotted {
|
||||
border-left-style: dotted;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
|
||||
import Line from "./Line";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof Line> = {
|
||||
className: "root",
|
||||
dotted: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Line {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import styles from "./Line.css";
|
||||
|
||||
interface LineProps {
|
||||
className?: string;
|
||||
dotted?: boolean;
|
||||
}
|
||||
|
||||
const Line: FunctionComponent<LineProps> = props => {
|
||||
return (
|
||||
<div
|
||||
className={cn(styles.root, props.className, {
|
||||
[styles.dotted]: props.dotted,
|
||||
})}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Line;
|
||||
@@ -0,0 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="Line-root root Line-dotted"
|
||||
/>
|
||||
`;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Circle } from "./Circle";
|
||||
export { default as Line } from "./Line";
|
||||
@@ -11,21 +11,18 @@ import { Button, CallOut, Divider } from "coral-ui/components/v2";
|
||||
|
||||
import { UserHistoryDrawerAllComments_settings } from "coral-admin/__generated__/UserHistoryDrawerAllComments_settings.graphql";
|
||||
import { UserHistoryDrawerAllComments_user } from "coral-admin/__generated__/UserHistoryDrawerAllComments_user.graphql";
|
||||
import { UserHistoryDrawerAllComments_viewer } from "coral-admin/__generated__/UserHistoryDrawerAllComments_viewer.graphql";
|
||||
import { UserHistoryDrawerAllCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryDrawerAllCommentsPaginationQuery.graphql";
|
||||
|
||||
import styles from "./UserHistoryDrawerAllComments.css";
|
||||
|
||||
interface Props {
|
||||
user: UserHistoryDrawerAllComments_user;
|
||||
viewer: UserHistoryDrawerAllComments_viewer;
|
||||
settings: UserHistoryDrawerAllComments_settings;
|
||||
relay: RelayPaginationProp;
|
||||
}
|
||||
|
||||
const UserHistoryDrawerAllComments: FunctionComponent<Props> = ({
|
||||
user,
|
||||
viewer,
|
||||
settings,
|
||||
relay,
|
||||
}) => {
|
||||
@@ -61,7 +58,6 @@ const UserHistoryDrawerAllComments: FunctionComponent<Props> = ({
|
||||
<div key={c.id}>
|
||||
<ModerateCardContainer
|
||||
comment={c}
|
||||
viewer={viewer}
|
||||
settings={settings}
|
||||
danglingLogic={status => false}
|
||||
hideUsername
|
||||
@@ -91,11 +87,6 @@ const enhanced = withPaginationContainer<
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
viewer: graphql`
|
||||
fragment UserHistoryDrawerAllComments_viewer on User {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment UserHistoryDrawerAllComments_settings on Settings {
|
||||
...ModerateCardContainer_settings
|
||||
|
||||
-5
@@ -24,9 +24,6 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent<Props> = ({
|
||||
user(id: $userID) {
|
||||
...UserHistoryDrawerAllComments_user
|
||||
}
|
||||
viewer {
|
||||
...UserHistoryDrawerAllComments_viewer
|
||||
}
|
||||
settings {
|
||||
...UserHistoryDrawerAllComments_settings
|
||||
}
|
||||
@@ -57,8 +54,6 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent<Props> = ({
|
||||
|
||||
return (
|
||||
<UserHistoryDrawerAllComments
|
||||
// We can never get to this part of the UI without being logged in.
|
||||
viewer={props.viewer!}
|
||||
settings={props.settings}
|
||||
user={props.user}
|
||||
/>
|
||||
|
||||
-9
@@ -11,21 +11,18 @@ import { Button, CallOut, Divider } from "coral-ui/components/v2";
|
||||
|
||||
import { UserHistoryDrawerRejectedComments_settings } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_settings.graphql";
|
||||
import { UserHistoryDrawerRejectedComments_user } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_user.graphql";
|
||||
import { UserHistoryDrawerRejectedComments_viewer } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_viewer.graphql";
|
||||
import { UserHistoryDrawerRejectedCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryDrawerRejectedCommentsPaginationQuery.graphql";
|
||||
|
||||
import styles from "./UserHistoryDrawerRejectedComments.css";
|
||||
|
||||
interface Props {
|
||||
user: UserHistoryDrawerRejectedComments_user;
|
||||
viewer: UserHistoryDrawerRejectedComments_viewer;
|
||||
settings: UserHistoryDrawerRejectedComments_settings;
|
||||
relay: RelayPaginationProp;
|
||||
}
|
||||
|
||||
const UserHistoryDrawerRejectedComments: FunctionComponent<Props> = ({
|
||||
user,
|
||||
viewer,
|
||||
settings,
|
||||
relay,
|
||||
}) => {
|
||||
@@ -63,7 +60,6 @@ const UserHistoryDrawerRejectedComments: FunctionComponent<Props> = ({
|
||||
<div key={c.id}>
|
||||
<ModerateCardContainer
|
||||
comment={c}
|
||||
viewer={viewer}
|
||||
settings={settings}
|
||||
danglingLogic={status => false}
|
||||
hideUsername
|
||||
@@ -93,11 +89,6 @@ const enhanced = withPaginationContainer<
|
||||
FragmentVariables
|
||||
>(
|
||||
{
|
||||
viewer: graphql`
|
||||
fragment UserHistoryDrawerRejectedComments_viewer on User {
|
||||
...ModerateCardContainer_viewer
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment UserHistoryDrawerRejectedComments_settings on Settings {
|
||||
...ModerateCardContainer_settings
|
||||
|
||||
-5
@@ -22,9 +22,6 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent<Props> = ({
|
||||
user(id: $userID) {
|
||||
...UserHistoryDrawerRejectedComments_user
|
||||
}
|
||||
viewer {
|
||||
...UserHistoryDrawerRejectedComments_viewer
|
||||
}
|
||||
settings {
|
||||
...UserHistoryDrawerRejectedComments_settings
|
||||
}
|
||||
@@ -55,8 +52,6 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent<Props> = ({
|
||||
|
||||
return (
|
||||
<UserHistoryDrawerRejectedComments
|
||||
// We can never get to this part of the UI without being logged in.
|
||||
viewer={props.viewer!}
|
||||
settings={props.settings}
|
||||
user={props.user}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ interface Props {
|
||||
username: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (message?: string) => void;
|
||||
onConfirm: (rejectExistingComments: boolean, message?: string) => void;
|
||||
getMessage: GetMessage;
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ const BanModal: FunctionComponent<Props> = ({
|
||||
}, [getMessage, username]);
|
||||
|
||||
const onFormSubmit = useCallback(
|
||||
({ emailMessage }) => {
|
||||
onConfirm(emailMessage);
|
||||
({ emailMessage, rejectExistingComments }) => {
|
||||
onConfirm(rejectExistingComments, emailMessage);
|
||||
},
|
||||
[onConfirm]
|
||||
);
|
||||
@@ -83,12 +83,22 @@ const BanModal: FunctionComponent<Props> = ({
|
||||
onSubmit={onFormSubmit}
|
||||
initialValues={{
|
||||
showMessage: false,
|
||||
rejectExistingComments: false,
|
||||
emailMessage: getDefaultMessage,
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HorizontalGutter spacing={3}>
|
||||
<Field type="checkbox" name="rejectExistingComments">
|
||||
{({ input }) => (
|
||||
<Localized id="community-banModal-reject-existing">
|
||||
<CheckBox {...input} id="banModal-rejectExisting">
|
||||
Reject all comments by this user
|
||||
</CheckBox>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
<Field type="checkbox" name="showMessage">
|
||||
{({ input }) => (
|
||||
<Localized id="community-banModal-customize">
|
||||
|
||||
@@ -111,8 +111,8 @@ const UserStatusChangeContainer: FunctionComponent<Props> = props => {
|
||||
);
|
||||
|
||||
const handleBanConfirm = useCallback(
|
||||
message => {
|
||||
banUser({ userID: user.id, message });
|
||||
(rejectExistingComments, message) => {
|
||||
banUser({ userID: user.id, message, rejectExistingComments });
|
||||
setShowBanned(false);
|
||||
},
|
||||
[user, setShowBanned]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { urls } from "coral-framework/helpers";
|
||||
|
||||
export default function getEndpointLink(endpointID: string) {
|
||||
return `${urls.admin.configureWebhookEndpoint}/${endpointID}`;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
const basePath = "/admin/moderate";
|
||||
|
||||
export default function getModerationLink(
|
||||
queue?: "default" | "reported" | "pending" | "unmoderated" | "rejected",
|
||||
storyID?: string | null
|
||||
) {
|
||||
const queuePart = queue && queue !== "default" ? `/${queue}` : "";
|
||||
const storyPart = storyID ? `/${encodeURIComponent(storyID)}` : "";
|
||||
return `${basePath}${queuePart}${storyPart}`;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { lowerCase, uniqBy } from "lodash";
|
||||
|
||||
import { LanguageCode } from "coral-common/helpers";
|
||||
import createWordListRegExp from "coral-common/utils/createWordListRegExp";
|
||||
|
||||
export interface GetPhrasesRegExpOptions {
|
||||
locale: string;
|
||||
wordList: {
|
||||
banned: ReadonlyArray<string>;
|
||||
suspect: ReadonlyArray<string>;
|
||||
};
|
||||
}
|
||||
|
||||
export function getPhrasesRegExp({
|
||||
locale,
|
||||
wordList: { banned, suspect },
|
||||
}: GetPhrasesRegExpOptions) {
|
||||
if (banned.length === 0 && suspect.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Because the banned and suspect word lists may sometimes overlap, we should
|
||||
// make this list as short as possible before compiling it into a RegExp.
|
||||
const phrases = uniqBy<string>([...banned, ...suspect], lowerCase);
|
||||
|
||||
// The locale is passed down to us from the Graph, we can cast it to a
|
||||
// LanguageCode.
|
||||
return createWordListRegExp(locale as LanguageCode, phrases);
|
||||
}
|
||||
|
||||
// Cache is used as a global validator to the cached RegExp used by the
|
||||
// application. We expect that generally, there is only ever one word list used
|
||||
// by the client at a time, so this ensures that we only re-create the word list
|
||||
// if we must.
|
||||
interface Cache {
|
||||
keys: {
|
||||
locale: string;
|
||||
suspect: ReadonlyArray<string>;
|
||||
banned: ReadonlyArray<string>;
|
||||
};
|
||||
value: RegExp | null;
|
||||
}
|
||||
|
||||
const cache: Cache = {
|
||||
keys: {
|
||||
locale: "",
|
||||
suspect: [],
|
||||
banned: [],
|
||||
},
|
||||
value: null,
|
||||
};
|
||||
|
||||
export default function(options: GetPhrasesRegExpOptions) {
|
||||
// We assume that the cache is valid unless one of the below checks fails.
|
||||
let expired = false;
|
||||
|
||||
// Check the locale.
|
||||
if (cache.keys.locale !== options.locale) {
|
||||
cache.keys.locale = options.locale;
|
||||
expired = true;
|
||||
}
|
||||
|
||||
// Check the banned words list.
|
||||
if (cache.keys.banned !== options.wordList.banned) {
|
||||
cache.keys.banned = options.wordList.banned;
|
||||
expired = true;
|
||||
}
|
||||
|
||||
// Check the suspect words list.
|
||||
if (cache.keys.suspect !== options.wordList.suspect) {
|
||||
cache.keys.suspect = options.wordList.suspect;
|
||||
expired = true;
|
||||
}
|
||||
|
||||
// If the cache is expired, or the value doesn't exist, regenerate it.
|
||||
if (expired) {
|
||||
try {
|
||||
cache.value = getPhrasesRegExp(options);
|
||||
} catch (err) {
|
||||
window.console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return cache.value;
|
||||
}
|
||||
@@ -12,17 +12,29 @@ import {
|
||||
|
||||
export default function getQueueConnection(
|
||||
store: RecordSourceSelectorProxy | RecordSourceProxy,
|
||||
queue: GQLMODERATION_QUEUE_RL | "REJECTED",
|
||||
storyID?: string | null
|
||||
queue: GQLMODERATION_QUEUE_RL | "REJECTED" | "APPROVED",
|
||||
storyID?: string | null,
|
||||
siteID?: string | null
|
||||
): RecordProxy | null | undefined {
|
||||
const root = store.getRoot();
|
||||
if (queue === "REJECTED") {
|
||||
return ConnectionHandler.getConnection(root, "RejectedQueue_comments", {
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
storyID,
|
||||
siteID,
|
||||
});
|
||||
}
|
||||
const queuesRecord = root.getLinkedRecord("moderationQueues", { storyID })!;
|
||||
if (queue === "APPROVED") {
|
||||
return ConnectionHandler.getConnection(root, "ApprovedQueue_comments", {
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
storyID,
|
||||
siteID,
|
||||
});
|
||||
}
|
||||
const queuesRecord = root.getLinkedRecord("moderationQueues", {
|
||||
storyID,
|
||||
siteID,
|
||||
})!;
|
||||
if (!queuesRecord) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export { default as getQueueConnection } from "./getQueueConnection";
|
||||
export { default as getModerationLink } from "./getModerationLink";
|
||||
export {
|
||||
default as getPhrasesRegExp,
|
||||
GetPhrasesRegExpOptions,
|
||||
} from "./getPhrasesRegExp";
|
||||
export { default as markHTMLNode } from "./markHTMLNode";
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// markPhrasesHTML looks for `suspect` and `banned` words inside `text` given
|
||||
// the settings applied for the locale and highlights them by returning an HTML
|
||||
// string.
|
||||
function markPhrasesHTML(text: string, expression: RegExp) {
|
||||
const tokens = text.split(expression);
|
||||
|
||||
// If there were less than two matches, then there was no matched word
|
||||
// associated with the passed in text.
|
||||
if (tokens.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tokens
|
||||
.map((token, i) =>
|
||||
// Using our Regexp patterns it returns tokens arranged this way:
|
||||
//
|
||||
// - STRING_WITH_NO_MATCH
|
||||
// - NEW_WORD_DELIMITER
|
||||
// - MATCHED_WORD
|
||||
// - NEW_WORD_DELIMITER
|
||||
// - ...
|
||||
//
|
||||
// This pattern repeats throughout. Next line will mark MATCHED_WORD.
|
||||
i % 4 === 2 ? "<mark>" + token + "</mark>" : token
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
// markHTMLNode manipulates the node by looking for #text nodes and adding
|
||||
// markers.
|
||||
export default function markHTMLNode(parentNode: Node, expression: RegExp) {
|
||||
parentNode.childNodes.forEach(node => {
|
||||
// Anchor links are already marked by default, skip them now.
|
||||
if (node.nodeName === "A") {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the node isn't of text type then we can't mark it directly.
|
||||
if (node.nodeName !== "#text") {
|
||||
return markHTMLNode(node, expression);
|
||||
}
|
||||
|
||||
// If the node doesn't have any text content, then we can't mark it either.
|
||||
if (!node.textContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We've encountered a text node with text content that isn't in an anchor
|
||||
// link. We should try to mark and replace it's content.
|
||||
const replacement = markPhrasesHTML(node.textContent, expression);
|
||||
if (replacement) {
|
||||
// Create the new span node to replace the old node with.
|
||||
const newNode = document.createElement("span");
|
||||
newNode.innerHTML = replacement;
|
||||
parentNode.replaceChild(newNode, node);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`get access token from url 1`] = `
|
||||
"{
|
||||
\\"__id\\": \\"client:root.local\\",
|
||||
\\"__typename\\": \\"Local\\",
|
||||
\\"accessToken\\": \\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==\\",
|
||||
\\"accessTokenExp\\": null,
|
||||
\\"accessTokenJTI\\": \\"31b26591-4e9a-4388-a7ff-e1bdc5d97cce\\",
|
||||
\\"redirectPath\\": null,
|
||||
\\"authView\\": \\"SIGN_IN\\",
|
||||
\\"authError\\": null
|
||||
}"
|
||||
`;
|
||||
|
||||
exports[`init local state 1`] = `
|
||||
"{
|
||||
\\"client:root\\": {
|
||||
\\"__id\\": \\"client:root\\",
|
||||
\\"__typename\\": \\"__Root\\",
|
||||
\\"local\\": {
|
||||
\\"__ref\\": \\"client:root.local\\"
|
||||
}
|
||||
},
|
||||
\\"client:root.local\\": {
|
||||
\\"__id\\": \\"client:root.local\\",
|
||||
\\"__typename\\": \\"Local\\",
|
||||
\\"accessToken\\": \\"\\",
|
||||
\\"accessTokenExp\\": null,
|
||||
\\"accessTokenJTI\\": null,
|
||||
\\"redirectPath\\": null,
|
||||
\\"authView\\": \\"SIGN_IN\\",
|
||||
\\"authError\\": null
|
||||
}
|
||||
}"
|
||||
`;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Environment, RecordSource } from "relay-runtime";
|
||||
|
||||
import { LOCAL_ID } from "coral-framework/lib/relay";
|
||||
import {
|
||||
createAccessToken,
|
||||
createRelayEnvironment,
|
||||
replaceHistoryLocation,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import initLocalState from "./initLocalState";
|
||||
|
||||
let environment: Environment;
|
||||
let source: RecordSource;
|
||||
|
||||
const context = {
|
||||
localStorage: window.localStorage,
|
||||
sessionStorage: window.sessionStorage,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
source = new RecordSource();
|
||||
environment = createRelayEnvironment({
|
||||
source,
|
||||
initLocalState: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("init local state", async () => {
|
||||
await initLocalState(environment, context as any);
|
||||
expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("get access token from url", async () => {
|
||||
const restoreHistoryLocation = replaceHistoryLocation(
|
||||
`http://localhost/#accessToken=${createAccessToken()}`
|
||||
);
|
||||
await initLocalState(environment, context as any);
|
||||
expect(JSON.stringify(source.get(LOCAL_ID), null, 2)).toMatchSnapshot();
|
||||
restoreHistoryLocation();
|
||||
});
|
||||
|
||||
it("get error from url", async () => {
|
||||
const restoreHistoryLocation = replaceHistoryLocation(
|
||||
`http://localhost/#error=error`
|
||||
);
|
||||
await initLocalState(environment, context as any);
|
||||
expect(source.get(LOCAL_ID)!.authError).toBe("error");
|
||||
restoreHistoryLocation();
|
||||
});
|
||||
@@ -8,6 +8,7 @@ enum View {
|
||||
CREATE_USERNAME
|
||||
CREATE_PASSWORD
|
||||
ADD_EMAIL_ADDRESS
|
||||
LINK_ACCOUNT
|
||||
}
|
||||
|
||||
extend type Comment {
|
||||
@@ -16,6 +17,9 @@ extend type Comment {
|
||||
|
||||
# If true then Comment came in live.
|
||||
enteredLive: Boolean
|
||||
|
||||
# If true then the Comment status was updated by the viewer.
|
||||
viewerDidModerate: Boolean
|
||||
}
|
||||
|
||||
extend type CommentsConnection {
|
||||
@@ -31,6 +35,9 @@ type Local {
|
||||
redirectPath: String
|
||||
authView: View
|
||||
authError: String
|
||||
# Duplicate email found when adding email during auth.
|
||||
authDuplicateEmail: String
|
||||
siteID: String
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
|
||||
@@ -40,15 +40,7 @@ const ApproveCommentMutation = createMutation(
|
||||
}
|
||||
}
|
||||
}
|
||||
statusHistory(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
moderator {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
...ModeratedByContainer_comment
|
||||
}
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
@@ -74,7 +66,9 @@ const ApproveCommentMutation = createMutation(
|
||||
storyID: input.storyID,
|
||||
},
|
||||
optimisticUpdater: store => {
|
||||
store.get(input.commentID)!.setValue("APPROVED", "status");
|
||||
const proxy = store.get(input.commentID)!;
|
||||
proxy.setValue("APPROVED", "status");
|
||||
proxy.setValue(true, "viewerDidModerate");
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
|
||||
@@ -40,15 +40,7 @@ const RejectCommentMutation = createMutation(
|
||||
}
|
||||
}
|
||||
}
|
||||
statusHistory(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
moderator {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
...ModeratedByContainer_comment
|
||||
}
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
@@ -74,13 +66,16 @@ const RejectCommentMutation = createMutation(
|
||||
storyID: input.storyID,
|
||||
},
|
||||
optimisticUpdater: store => {
|
||||
store.get(input.commentID)!.setValue("REJECTED", "status");
|
||||
const proxy = store.get(input.commentID)!;
|
||||
proxy.setValue("REJECTED", "status");
|
||||
proxy.setValue(true, "viewerDidModerate");
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
getQueueConnection(store, "REPORTED", input.storyID),
|
||||
getQueueConnection(store, "PENDING", input.storyID),
|
||||
getQueueConnection(store, "UNMODERATED", input.storyID),
|
||||
getQueueConnection(store, "APPROVED", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con!, input.commentID)
|
||||
|
||||
@@ -9,20 +9,28 @@ import { createAuthCheckRoute } from "./routes/AuthCheck";
|
||||
import CommunityRoute from "./routes/Community";
|
||||
import ConfigureRoute from "./routes/Configure";
|
||||
import {
|
||||
AddWebhookEndpointRoute,
|
||||
AdvancedConfigRoute,
|
||||
AuthConfigRoute,
|
||||
ConfigureWebhookEndpointRoute,
|
||||
EmailConfigRoute,
|
||||
GeneralConfigRoute,
|
||||
ModerationConfigRoute,
|
||||
OrganizationConfigRoute,
|
||||
SlackConfigRoute,
|
||||
WebhookEndpointsConfigRoute,
|
||||
WordListConfigRoute,
|
||||
} from "./routes/Configure/sections";
|
||||
import { Sites } from "./routes/Configure/sections/Sites";
|
||||
import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute";
|
||||
import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute";
|
||||
import WebhookEndpointsLayout from "./routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout";
|
||||
import ForgotPasswordRoute from "./routes/ForgotPassword";
|
||||
import InviteRoute from "./routes/Invite";
|
||||
import LoginRoute from "./routes/Login";
|
||||
import ModerateRoute from "./routes/Moderate";
|
||||
import {
|
||||
ApprovedQueueRoute,
|
||||
PendingQueueRoute,
|
||||
RejectedQueueRoute,
|
||||
ReportedQueueRoute,
|
||||
@@ -45,17 +53,58 @@ export default makeRouteConfig(
|
||||
<Route path="moderate" {...ModerateRoute.routeConfig}>
|
||||
<Redirect from="/" to="/admin/moderate/reported" />
|
||||
<Route path="reported" {...ReportedQueueRoute.routeConfig} />
|
||||
<Route path="reported/:storyID" {...ReportedQueueRoute.routeConfig} />
|
||||
<Route
|
||||
path="reported/stories/:storyID"
|
||||
{...ReportedQueueRoute.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="reported/sites/:siteID"
|
||||
{...ReportedQueueRoute.routeConfig}
|
||||
/>
|
||||
<Route path="pending" {...PendingQueueRoute.routeConfig} />
|
||||
<Route path="pending/:storyID" {...PendingQueueRoute.routeConfig} />
|
||||
<Route
|
||||
path="pending/stories/:storyID"
|
||||
{...PendingQueueRoute.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="pending/sites/:siteID"
|
||||
{...PendingQueueRoute.routeConfig}
|
||||
/>
|
||||
<Route path="unmoderated" {...UnmoderatedQueueRoute.routeConfig} />
|
||||
<Route
|
||||
path="unmoderated/:storyID"
|
||||
path="unmoderated/stories/:storyID"
|
||||
{...UnmoderatedQueueRoute.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="unmoderated/sites/:siteID"
|
||||
{...UnmoderatedQueueRoute.routeConfig}
|
||||
/>
|
||||
<Route path="approved" {...ApprovedQueueRoute.routeConfig} />
|
||||
<Route path="rejected" {...RejectedQueueRoute.routeConfig} />
|
||||
<Route path="rejected/:storyID" {...RejectedQueueRoute.routeConfig} />
|
||||
<Redirect from=":storyID" to="/admin/moderate/reported/:storyID" />
|
||||
<Route
|
||||
path="rejected/stories/:storyID"
|
||||
{...RejectedQueueRoute.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="rejected/sites/:siteID"
|
||||
{...RejectedQueueRoute.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="approved/stories/:storyID"
|
||||
{...ApprovedQueueRoute.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="approved/sites/:siteID"
|
||||
{...ApprovedQueueRoute.routeConfig}
|
||||
/>
|
||||
<Redirect
|
||||
from="stories/:storyID"
|
||||
to="/admin/moderate/reported/stories/:storyID"
|
||||
/>
|
||||
<Redirect
|
||||
from="sites/:siteID"
|
||||
to="/admin/moderate/reported/sites/:siteID"
|
||||
/>
|
||||
</Route>
|
||||
<Route path="stories" {...StoriesRoute.routeConfig} />
|
||||
<Route path="community" {...CommunityRoute.routeConfig} />
|
||||
@@ -78,6 +127,19 @@ export default makeRouteConfig(
|
||||
<Route path="email" {...EmailConfigRoute.routeConfig} />
|
||||
<Route path="slack" {...SlackConfigRoute.routeConfig} />
|
||||
</Route>
|
||||
<Route path="configure/webhooks" Component={WebhookEndpointsLayout}>
|
||||
<Route path="/" {...WebhookEndpointsConfigRoute.routeConfig} />
|
||||
<Route path="add" {...AddWebhookEndpointRoute.routeConfig} />
|
||||
<Route
|
||||
path="endpoint/:webhookEndpointID"
|
||||
{...ConfigureWebhookEndpointRoute.routeConfig}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="configure/organization/sites" Component={Sites}>
|
||||
<Redirect from="/" to="/admin/configure/organization/sites/new" />
|
||||
<Route path="new" {...AddSiteRoute.routeConfig} />
|
||||
<Route path=":siteID" {...SiteRoute.routeConfig} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -50,7 +50,7 @@ function createAuthCheckRoute(check: CheckParams) {
|
||||
}
|
||||
|
||||
private shouldRedirectTo(props: Props = this.props) {
|
||||
if (!props.data || props.data.viewer) {
|
||||
if (!props.data || (props.data.viewer && props.data.viewer.email)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -7,10 +7,9 @@ import { Form, FormSpy } from "react-final-form";
|
||||
import MainLayout from "coral-admin/components/MainLayout";
|
||||
import { Button, CallOut, HorizontalGutter } from "coral-ui/components/v2";
|
||||
|
||||
import ConfigureLinks from "./ConfigureLinks";
|
||||
import Layout from "./Layout";
|
||||
import Link from "./Link";
|
||||
import Main from "./Main";
|
||||
import Navigation from "./Navigation";
|
||||
import SideBar from "./SideBar";
|
||||
|
||||
interface Props {
|
||||
@@ -32,34 +31,7 @@ const Configure: FunctionComponent<Props> = ({
|
||||
<Layout>
|
||||
<SideBar>
|
||||
<HorizontalGutter size="double">
|
||||
<Navigation>
|
||||
<Localized id="configure-sideBarNavigation-general">
|
||||
<Link to="/admin/configure/general">General</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-organization">
|
||||
<Link to="/admin/configure/organization">Organization</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-moderation">
|
||||
<Link to="/admin/configure/moderation">Moderation</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-bannedAndSuspectWords">
|
||||
<Link to="/admin/configure/wordList">
|
||||
Banned and Suspect Words
|
||||
</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-authentication">
|
||||
<Link to="/admin/configure/auth">Authentication</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-email">
|
||||
<Link to="/admin/configure/email">Email</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-slack">
|
||||
<Link to="/admin/configure/slack">Slack</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-advanced">
|
||||
<Link to="/admin/configure/advanced">Advanced</Link>
|
||||
</Localized>
|
||||
</Navigation>
|
||||
<ConfigureLinks />
|
||||
</HorizontalGutter>
|
||||
<HorizontalGutter size="double">
|
||||
<Localized id="configure-sideBar-saveChanges">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import Link from "./Link";
|
||||
import Navigation from "./Navigation";
|
||||
|
||||
const ConfigureLinks: FunctionComponent<{}> = () => {
|
||||
return (
|
||||
<Navigation>
|
||||
<Localized id="configure-sideBarNavigation-general">
|
||||
<Link to="/admin/configure/general">General</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-organization">
|
||||
<Link to="/admin/configure/organization">Organization</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-moderation">
|
||||
<Link to="/admin/configure/moderation">Moderation</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-bannedAndSuspectWords">
|
||||
<Link to="/admin/configure/wordList">Banned and Suspect Words</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-authentication">
|
||||
<Link to="/admin/configure/auth">Authentication</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-email">
|
||||
<Link to="/admin/configure/email">Email</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-slack">
|
||||
<Link to="/admin/configure/slack">Slack</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-webhooks">
|
||||
<Link to="/admin/configure/webhooks">Webhooks</Link>
|
||||
</Localized>
|
||||
<Localized id="configure-sideBarNavigation-advanced">
|
||||
<Link to="/admin/configure/advanced">Advanced</Link>
|
||||
</Localized>
|
||||
</Navigation>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigureLinks;
|
||||
@@ -24,7 +24,7 @@ class NavigationWarningContainer extends React.Component<Props> {
|
||||
);
|
||||
|
||||
this.removeTransitionHook = props.router.addTransitionHook(() =>
|
||||
this.props.active ? warningMessage : true
|
||||
this.props.active ? warningMessage : undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ import { AdvancedConfigContainer_settings } from "coral-admin/__generated__/Adva
|
||||
|
||||
import CommentStreamLiveUpdatesContainer from "./CommentStreamLiveUpdatesContainer";
|
||||
import CustomCSSConfig from "./CustomCSSConfig";
|
||||
import EmbedCodeContainer from "./EmbedCodeContainer";
|
||||
import PermittedDomainsConfig from "./PermittedDomainsConfig";
|
||||
import StoryCreationConfig from "./StoryCreationConfig";
|
||||
|
||||
interface Props {
|
||||
@@ -29,13 +27,11 @@ const AdvancedConfigContainer: React.FunctionComponent<Props> = ({
|
||||
useMemo(() => form.initialize(purgeMetadata(settings)), []);
|
||||
return (
|
||||
<HorizontalGutter size="double" data-testid="configure-advancedContainer">
|
||||
<EmbedCodeContainer settings={settings} />
|
||||
<CustomCSSConfig disabled={submitting} />
|
||||
<CommentStreamLiveUpdatesContainer
|
||||
disabled={submitting}
|
||||
settings={settings}
|
||||
/>
|
||||
<PermittedDomainsConfig disabled={submitting} />
|
||||
<StoryCreationConfig disabled={submitting} />
|
||||
</HorizontalGutter>
|
||||
);
|
||||
@@ -45,11 +41,8 @@ const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment AdvancedConfigContainer_settings on Settings {
|
||||
...CustomCSSConfig_formValues @relay(mask: false)
|
||||
...PermittedDomainsConfig_formValues @relay(mask: false)
|
||||
...CommentStreamLiveUpdates_formValues @relay(mask: false)
|
||||
...StoryCreationConfig_formValues @relay(mask: false)
|
||||
|
||||
...EmbedCodeContainer_settings
|
||||
...CommentStreamLiveUpdatesContainer_settings
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
|
||||
import { EmbedCodeContainer_settings } from "coral-admin/__generated__/EmbedCodeContainer_settings.graphql";
|
||||
|
||||
import EmbedCode from "./EmbedCode";
|
||||
|
||||
interface Props {
|
||||
settings: EmbedCodeContainer_settings;
|
||||
}
|
||||
|
||||
const EmbedCodeContainer: FunctionComponent<Props> = ({ settings }) => {
|
||||
return <EmbedCode staticURI={settings.staticURI} />;
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment EmbedCodeContainer_settings on Settings {
|
||||
staticURI
|
||||
}
|
||||
`,
|
||||
})(EmbedCodeContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Field } from "react-final-form";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { formatStringList, parseStringList } from "coral-framework/lib/form";
|
||||
import { validateStrictURLList } from "coral-framework/lib/validation";
|
||||
import { FormField, FormFieldDescription } from "coral-ui/components/v2";
|
||||
|
||||
import ConfigBox from "../../ConfigBox";
|
||||
import Header from "../../Header";
|
||||
import TextFieldWithValidation from "../../TextFieldWithValidation";
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
graphql`
|
||||
fragment PermittedDomainsConfig_formValues on Settings {
|
||||
allowedDomains
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const PermittedDomainsConfig: FunctionComponent<Props> = ({ disabled }) => (
|
||||
<ConfigBox
|
||||
title={
|
||||
<Localized id="configure-advanced-permittedDomains">
|
||||
<Header htmlFor="configure-advanced-allowedDomains">
|
||||
Permitted domains
|
||||
</Header>
|
||||
</Localized>
|
||||
}
|
||||
>
|
||||
<FormField>
|
||||
<Localized
|
||||
id="configure-advanced-permittedDomains-description"
|
||||
strong={<strong />}
|
||||
>
|
||||
<FormFieldDescription>
|
||||
The domains you would like to permit for Coral, e.g. your local,
|
||||
staging and production environments including the scheme (ex.
|
||||
http://localhost:3000, https://staging.domain.com,
|
||||
https://domain.com).
|
||||
</FormFieldDescription>
|
||||
</Localized>
|
||||
<Field
|
||||
name="allowedDomains"
|
||||
parse={parseStringList}
|
||||
format={formatStringList}
|
||||
validate={validateStrictURLList}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<TextFieldWithValidation
|
||||
{...input}
|
||||
id={`configure-advanced-${input.name}`}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
meta={meta}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormField>
|
||||
</ConfigBox>
|
||||
);
|
||||
|
||||
export default PermittedDomainsConfig;
|
||||
@@ -33,6 +33,7 @@ const ClientSecretField: FunctionComponent<Props> = ({
|
||||
{...input}
|
||||
id={input.name}
|
||||
disabled={disabled || meta.submitting}
|
||||
autoComplete="new-password"
|
||||
// TODO: (wyattjoh) figure out how to add translations to these props
|
||||
hidePasswordTitle="Show Client Secret"
|
||||
showPasswordTitle="Hide Client Secret"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user