Compare commits

...
29 Commits
Author SHA1 Message Date
Vinh ca811a3496 Bring back deprecated events temporarily (#2727)
* fix: bring back deprecated events

* chore: update version number
2019-11-21 20:04:42 +00:00
Wyatt Johnson 10719bd874 [CORL-793] Scheduled Indexes (#2723)
* fix: modified digesting to use index

* fix: added index for deletion

* review: cleaning up

* review: fixed log copy
2019-11-21 17:24:09 +00:00
Wyatt Johnson bb23c80004 feat: generalized rate limiting for graphql (#2709) 2019-11-19 14:20:36 -07:00
Wyatt Johnson 2491445579 feat: improved logging (#2719) 2019-11-19 19:36:46 +00:00
Wyatt Johnson 3642c642d6 fix: fixed migration script bugs (#2718) 2019-11-19 19:18:28 +00:00
Wyatt Johnson fce7b08ca0 chore: version bump (#2717) 2019-11-18 23:27:25 +00:00
Wyatt Johnson aa9dcb4e04 [CORL-750] SSO Migration Script Bug (#2715)
* fix: fixed bug with sso migration script

* feat: refactored sso schema

* fix: resolved issue with data race and migration/install ordering
2019-11-18 23:11:20 +00:00
Wyatt Johnson 9e9c016f4b fix: adjusted i18n keys (#2708) 2019-11-13 17:50:40 +00:00
Nick Funk 40b247350b [CORL-766] Update copy on SSO key regeneration and account deletion (#2707)
* Update acknowledgment copy for downloading user comment data

CORL-766

* Update copy for 30 day leniency on SSO tokens

CORL-766
2019-11-13 09:50:56 -07:00
Nick Funk 2945184b07 Stop propagation of Modal content click events (#2706)
This prevents the click event from propagating out to
the backdrop parent and auto-closing the modal on any
content click.

CORL-765
2019-11-13 11:00:55 -05:00
Wyatt Johnson a55472e086 fix: adjusted onsubmit handlers (#2705) 2019-11-13 10:42:32 -05:00
Wyatt Johnson 033433549a fix: fixed docs, graphql type (#2704) 2019-11-12 14:00:51 -05:00
Wyatt Johnson 2ef07429c2 fix: improvements to scraping (#2702)
- fixes #2690
2019-11-12 17:25:41 +00:00
Wyatt Johnson b83d737530 chore: version bump (#2700) 2019-11-08 17:00:41 -05:00
Vinh 18346d1683 [CORL-666] Viewer Events (#2681)
* feat: viewer event system

* feat: more events

* feat: MORE events

* fix: tests

* fix: rte focus events

* chore: add comments

* fix: remove listening to events

* chore: update RTE

* fix: tests

* feature: generate event docs

* fix: remove obsolete line in docs

* chore: improve docs

* chore: improve formatting

* feature: protect events.md from getting out of sync

* chore: small improvements

* fix: removing redundant lambda
2019-11-08 17:17:01 +00:00
Wyatt Johnson ce4a3408fc [CORL-628] CLI Support (#2646)
* feat: improved api for reloading tenants for cli

* fix: cleaned up merge beheviour

* feat: added support for stream/story edge

* feat: support tenant install

* fix: updated snapshot

* fix: fixed tests
2019-11-08 00:40:56 +00:00
Wyatt Johnson 2dbba52fbd feat: added initial server sso key rotation semantics (#2696) 2019-11-07 16:53:28 -05:00
Wyatt Johnson 59b8dfccda fix: improved search field serialization (#2697) 2019-11-07 16:45:57 -05:00
Wyatt Johnson 3394628f27 [CORL-744] Index Optimizations (#2694)
* fix: added comment moderation index

* fix: improved user indexing
2019-11-07 16:32:38 -05:00
Vinh 8ee74b7d24 [CORL-743] Fix sort menu on small screens (#2698)
* fix: sort menu on small screens

* chore: add comments
2019-11-07 15:32:49 -05:00
Vinh f85af8e88f [CORL-684] Fix scrolling bug in overflowing modal (#2682)
* fix: scrolling bug in overflowing modal

* fix: tests
2019-11-07 13:44:10 -05:00
Kim Gardner 9d6592feef Update PR template (#2685) 2019-11-01 14:00:04 -04:00
Kim Gardner 53c87118db Update Feature Request template (#2684) 2019-11-01 13:53:08 -04:00
Kim Gardner bdc9c5f05c Update Bug Report template (#2683) 2019-11-01 13:31:18 -04:00
Tessa Thornton e334c3266a update hotkeys legend to match implemented hotkeys (#2679) 2019-10-29 10:13:34 -04:00
Wyatt Johnson a31ee3bc96 Version Bumps (#2677)
* chore: upgraded versions

* chore: linting

* fix: updated snap
2019-10-28 23:45:21 +00:00
Wyatt Johnson a7eb757be7 fix: fixed issue with directive resolver (#2674) 2019-10-25 15:53:30 +00:00
Wyatt Johnson 4038fcbcd6 fix: handle redirection loop (#2662) 2019-10-24 17:15:53 +00:00
Wyatt Johnson 5714f441bb [CORL-735] Scraper support for JSON Linked Data (#2671)
* feat: add support for application/json+ld parsing

- upgraded scraper packages to use new jsonld parser
- waiting on https://github.com/microlinkhq/metascraper/pull/225
  for merging
- fixes #2670

* chore: metascraper upgrades
2019-10-24 16:08:15 +00:00
250 changed files with 6785 additions and 2573 deletions
+6 -4
View File
@@ -1,15 +1,17 @@
---
name: Bug report
about: Create a report to help us improve
about: Create a bug report to help us improve Coral
labels: "bug"
---
<!--
Please provide as much detail as possible, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. The more information you can give the better.
Please note that but submitting a bug report, you agree to our Code of Conduct: http://code-of-conduct.voxmedia.com/
When submitting a bug report, provide as much detail as possible, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. The more information you can give the better.
Please help us by doing the following steps before logging an issue:
* Search: https://github.com/coralproject/talk/search
* Read the docs: https://docs.coralproject.net
* Search for other similar issues: https://github.com/coralproject/talk/search
* Read our docs: https://docs.coralproject.net
Please fill in the *entire* template below.
-->
+4 -2
View File
@@ -1,13 +1,15 @@
---
name: Feature request
about: Suggest an idea for this project
about: Suggest a feature idea for Coral
labels: "feature idea"
---
<!--
Please note that but submitting a feature request, you agree to our Code of Conduct: http://code-of-conduct.voxmedia.com/
Please help us by doing the following steps before logging an issue:
* Search: https://github.com/coralproject/talk/search
* Search to see if there are similar requests and ideas that are already logged: https://github.com/coralproject/talk/search
-->
+7 -3
View File
@@ -1,10 +1,14 @@
<!--
Thank you for submitting a pull request!
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/
Please verify that:
* [ ] Code is up-to-date with the base branch
Before submitting your 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?
## How do I test this PR?
+1 -1
View File
@@ -1,4 +1,4 @@
# Coral ![CircleCI](https://img.shields.io/circleci/project/github/coralproject/talk/next.svg)
# Coral ![CircleCI](https://img.shields.io/circleci/project/github/coralproject/talk.svg)
Online comments are broken. Our open-source commenting platform, Coral, rethinks
how moderation, comment display, and conversation function, creating the
+586
View File
@@ -0,0 +1,586 @@
## 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>
```
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.
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
{
body: "Hello world!",
storyID: "238b95ec-2b80-43f4-ab68-a6ea1f4e2584",
rtt: 307,
success: {
id: "6fecfb11-4d0f-4edc-89b7-878a9928addd"
status: "APPROVED"`
}
}
```
```
createComment.error
{
body: "Hi!",
storyID: "238b95ec-2b80-43f4-ab68-a6ea1f4e2584",
rtt: 229,
error: {
code: "COMMENT_BODY_TOO_SHORT"
message: "Comment body must have at least 10 characters."
}
}
```
## Event List
<!-- START docs:events -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN npm run docs:events -->
### Index
- <a href="#approveComment">approveComment</a>
- <a href="#banUser">banUser</a>
- <a href="#cancelAccountDeletion">cancelAccountDeletion</a>
- <a href="#changeEmail">changeEmail</a>
- <a href="#changePassword">changePassword</a>
- <a href="#changeUsername">changeUsername</a>
- <a href="#closeStory">closeStory</a>
- <a href="#copyPermalink">copyPermalink</a>
- <a href="#createComment">createComment</a>
- <a href="#createCommentFocus">createCommentFocus</a>
- <a href="#createCommentReaction">createCommentReaction</a>
- <a href="#createCommentReply">createCommentReply</a>
- <a href="#editComment">editComment</a>
- <a href="#featureComment">featureComment</a>
- <a href="#gotoModeration">gotoModeration</a>
- <a href="#ignoreUser">ignoreUser</a>
- <a href="#loadMoreAllComments">loadMoreAllComments</a>
- <a href="#loadMoreFeaturedComments">loadMoreFeaturedComments</a>
- <a href="#loadMoreHistoryComments">loadMoreHistoryComments</a>
- <a href="#loginPrompt">loginPrompt</a>
- <a href="#openSortMenu">openSortMenu</a>
- <a href="#openStory">openStory</a>
- <a href="#rejectComment">rejectComment</a>
- <a href="#removeCommentReaction">removeCommentReaction</a>
- <a href="#removeUserIgnore">removeUserIgnore</a>
- <a href="#replyCommentFocus">replyCommentFocus</a>
- <a href="#reportComment">reportComment</a>
- <a href="#requestAccountDeletion">requestAccountDeletion</a>
- <a href="#requestDownloadCommentHistory">requestDownloadCommentHistory</a>
- <a href="#resendEmailVerification">resendEmailVerification</a>
- <a href="#setCommentsOrderBy">setCommentsOrderBy</a>
- <a href="#setCommentsTab">setCommentsTab</a>
- <a href="#setMainTab">setMainTab</a>
- <a href="#setProfileTab">setProfileTab</a>
- <a href="#showAbsoluteTimestamp">showAbsoluteTimestamp</a>
- <a href="#showAllReplies">showAllReplies</a>
- <a href="#showAuthPopup">showAuthPopup</a>
- <a href="#showEditEmailDialog">showEditEmailDialog</a>
- <a href="#showEditForm">showEditForm</a>
- <a href="#showEditPasswordDialog">showEditPasswordDialog</a>
- <a href="#showEditUsernameDialog">showEditUsernameDialog</a>
- <a href="#showFeaturedCommentTooltip">showFeaturedCommentTooltip</a>
- <a href="#showIgnoreUserdDialog">showIgnoreUserdDialog</a>
- <a href="#showModerationPopover">showModerationPopover</a>
- <a href="#showMoreOfConversation">showMoreOfConversation</a>
- <a href="#showMoreReplies">showMoreReplies</a>
- <a href="#showReplyForm">showReplyForm</a>
- <a href="#showReportPopover">showReportPopover</a>
- <a href="#showSharePopover">showSharePopover</a>
- <a href="#showUserPopover">showUserPopover</a>
- <a href="#signOut">signOut</a>
- <a href="#unfeatureComment">unfeatureComment</a>
- <a href="#updateNotificationSettings">updateNotificationSettings</a>
- <a href="#updateStorySettings">updateStorySettings</a>
- <a href="#viewConversation">viewConversation</a>
- <a href="#viewFullDiscussion">viewFullDiscussion</a>
- <a href="#viewNewComments">viewNewComments</a>
### Events
- <a id="approveComment">**approveComment.success**, **approveComment.error**</a>: This event is emitted when the viewer approves a comment.
```ts
{
commentID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="banUser">**banUser.success**, **banUser.error**</a>: This event is emitted when the viewer bans a user.
```ts
{
userID: string;
commentID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="cancelAccountDeletion">**cancelAccountDeletion.success**, **cancelAccountDeletion.error**</a>: This event is emitted when the viewer cancels the account deletion.
```ts
{
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="changeEmail">**changeEmail.success**, **changeEmail.error**</a>: This event is emitted when the viewer changes its email.
```ts
{
oldEmail: string;
newEmail: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="changePassword">**changePassword.success**, **changePassword.error**</a>: This event is emitted when the viewer changes its password.
```ts
{
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="changeUsername">**changeUsername.success**, **changeUsername.error**</a>: This event is emitted when the viewer changes its username.
```ts
{
oldUsername: string;
newUsername: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="closeStory">**closeStory.success**, **closeStory.error**</a>: This event is emitted when the viewer closes the story.
```ts
{
storyID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="copyPermalink">**copyPermalink**</a>: This event is emitted when the viewer copies the permalink with the button.
```ts
{
commentID: string;
}
```
- <a id="createComment">**createComment.success**, **createComment.error**</a>: This event is emitted when a top level comment is created.
```ts
{
storyID: string;
body: string;
success: {
id: string;
status: COMMENT_STATUS;
};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="createCommentFocus">**createCommentFocus**</a>: This event is emitted when the viewer focus on the RTE to create a comment.
- <a id="createCommentReaction">**createCommentReaction.success**, **createCommentReaction.error**</a>: This event is emitted when the viewer reacts to a comment.
```ts
{
commentID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="createCommentReply">**createCommentReply.success**, **createCommentReply.error**</a>: This event is emitted when a comment reply is created.
```ts
{
body: string;
parentID: string;
success: {
id: string;
status: COMMENT_STATUS;
};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="editComment">**editComment.success**, **editComment.error**</a>: This event is emitted when the viewer edits a comment.
```ts
{
body: string;
commentID: string;
success: {
status: COMMENT_STATUS;
};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="featureComment">**featureComment.success**, **featureComment.error**</a>: This event is emitted when the viewer features a comment.
```ts
{
commentID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="gotoModeration">**gotoModeration**</a>: This event is emitted when the viewer goes to moderation.
```ts
{
commentID: string;
}
```
- <a id="ignoreUser">**ignoreUser.success**, **ignoreUser.error**</a>: This event is emitted when the viewer ignores a user.
```ts
{
userID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="loadMoreAllComments">**loadMoreAllComments.success**, **loadMoreAllComments.error**</a>: This event is emitted when the viewer loads more top level comments into the comment stream.
```ts
{
storyID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="loadMoreFeaturedComments">**loadMoreFeaturedComments.success**, **loadMoreFeaturedComments.error**</a>: This event is emitted when the viewer loads more featured comments.
```ts
{
storyID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="loadMoreHistoryComments">**loadMoreHistoryComments.success**, **loadMoreHistoryComments.error**</a>: This event is emitted when the viewer loads more top level comments into the history comment stream.
```ts
{
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="loginPrompt">**loginPrompt**</a>: This event is emitted when the viewer does an action that will prompt a login dialog.
- <a id="openSortMenu">**openSortMenu**</a>: This event is emitted when the viewer clicks on the sort menu.
- <a id="openStory">**openStory.success**, **openStory.error**</a>: This event is emitted when the viewer opens the story.
```ts
{
storyID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="rejectComment">**rejectComment.success**, **rejectComment.error**</a>: This event is emitted when the viewer rejects a comment.
```ts
{
commentID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="removeCommentReaction">**removeCommentReaction.success**, **removeCommentReaction.error**</a>: This event is emitted when the viewer removes its reaction from a comment.
```ts
{
commentID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="removeUserIgnore">**removeUserIgnore.success**, **removeUserIgnore.error**</a>: This event is emitted when the viewer remove a user from its ignored users list.
```ts
{
userID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="replyCommentFocus">**replyCommentFocus**</a>: This event is emitted when the viewer focus on the RTE to reply to a comment.
- <a id="reportComment">**reportComment.success**, **reportComment.error**</a>: This event is emitted when the viewer reports a comment.
```ts
{
reason: string;
commentID: string;
additionalDetails?: string | undefined;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="requestAccountDeletion">**requestAccountDeletion.success**, **requestAccountDeletion.error**</a>: This event is emitted when the viewer requests to delete its account.
```ts
{
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="requestDownloadCommentHistory">**requestDownloadCommentHistory.success**, **requestDownloadCommentHistory.error**</a>: This event is emitted when the viewer requests to download its comment history.
```ts
{
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="resendEmailVerification">**resendEmailVerification.success**, **resendEmailVerification.error**</a>: This event is emitted when the viewer request another email verification email.
```ts
{
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="setCommentsOrderBy">**setCommentsOrderBy**</a>: This event is emitted when the viewer changes the sort order of the comments.
```ts
{
orderBy: string;
}
```
- <a id="setCommentsTab">**setCommentsTab**</a>: This event is emitted when the viewer changes the tab of the comments tab bar.
```ts
{
tab: string;
}
```
- <a id="setMainTab">**setMainTab**</a>: This event is emitted when the viewer changes the tab of the main tab bar.
```ts
{
tab: string;
}
```
- <a id="setProfileTab">**setProfileTab**</a>: This event is emitted when the viewer changes the tab of the profile tab bar.
```ts
{
tab: string;
}
```
- <a id="showAbsoluteTimestamp">**showAbsoluteTimestamp**</a>: This event is emitted when the viewer clicks on the relative timestamp to show the absolute time.
- <a id="showAllReplies">**showAllReplies.success**, **showAllReplies.error**</a>: This event is emitted when the viewer reveals all replies of a comment.
```ts
{
commentID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="showAuthPopup">**showAuthPopup**</a>: This event is emitted when the viewer requests the auth popup.
```ts
{
view: string;
}
```
- <a id="showEditEmailDialog">**showEditEmailDialog**</a>: This event is emitted when the viewer opens the edit email dialog.
- <a id="showEditForm">**showEditForm**</a>: This event is emitted when the viewer opens the edit form.
```ts
{
commentID: string;
}
```
- <a id="showEditPasswordDialog">**showEditPasswordDialog**</a>: This event is emitted when the viewer opens the edit password dialog.
- <a id="showEditUsernameDialog">**showEditUsernameDialog**</a>: This event is emitted when the viewer opens the edit username dialog.
- <a id="showFeaturedCommentTooltip">**showFeaturedCommentTooltip**</a>: This event is emitted when the viewer clicks to show the featured comment tooltip.
- <a id="showIgnoreUserdDialog">**showIgnoreUserdDialog**</a>: This event is emitted when the viewer opens the ignore user dialog.
- <a id="showModerationPopover">**showModerationPopover**</a>: This event is emitted when the viewer opens the moderation popover.
```ts
{
commentID: string;
}
```
- <a id="showMoreOfConversation">**showMoreOfConversation.success**, **showMoreOfConversation.error**</a>: This event is emitted when the viewer reveals more of the parent conversation thread.
```ts
{
commentID: string | null;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="showMoreReplies">**showMoreReplies**</a>: This event is emitted when the viewer reveals new live replies.
```ts
{
commentID: string;
count: number;
}
```
- <a id="showReplyForm">**showReplyForm**</a>: This event is emitted when the viewer opens the reply form.
```ts
{
commentID: string;
}
```
- <a id="showReportPopover">**showReportPopover**</a>: This event is emitted when the viewer opens the report popover.
```ts
{
commentID: string;
}
```
- <a id="showSharePopover">**showSharePopover**</a>: This event is emitted when the viewer opens the share popover.
```ts
{
commentID: string;
}
```
- <a id="showUserPopover">**showUserPopover**</a>: This event is emitted when the viewer clicks on a username which shows the user popover.
```ts
{
userID: string;
}
```
- <a id="signOut">**signOut.success**, **signOut.error**</a>: This event is emitted when the viewer signs out.
```ts
{
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="unfeatureComment">**unfeatureComment.success**, **unfeatureComment.error**</a>: This event is emitted when the viewer unfeatures a comment.
```ts
{
commentID: string;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="updateNotificationSettings">**updateNotificationSettings.success**, **updateNotificationSettings.error**</a>: This event is emitted when the viewer updates its notification settings.
```ts
{
onReply?: boolean | null | undefined;
onFeatured?: boolean | null | undefined;
onStaffReplies?: boolean | null | undefined;
onModeration?: boolean | null | undefined;
digestFrequency?: "NONE" | "DAILY" | "HOURLY" | null | undefined;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="updateStorySettings">**updateStorySettings.success**, **updateStorySettings.error**</a>: This event is emitted when the viewer updates the story settings.
```ts
{
storyID: string;
live?: {
enabled?: boolean | null | undefined;
} | null | undefined;
moderation?: "POST" | "PRE" | null | undefined;
premodLinksEnable?: boolean | null | undefined;
messageBox?: {
enabled?: boolean | null | undefined;
icon?: string | null | undefined;
content?: string | null | undefined;
} | null | undefined;
success: {};
error: {
message: string;
code?: string | undefined;
};
}
```
- <a id="viewConversation">**viewConversation**</a>: This event is emitted when the viewer changes to the single conversation view.
```ts
{
from: "FEATURED_COMMENTS" | "COMMENT_STREAM" | "COMMENT_HISTORY";
commentID: string;
}
```
- <a id="viewFullDiscussion">**viewFullDiscussion**</a>: This event is emitted when the viewer exits the single conversation.
```ts
{
commentID: string | null;
}
```
- <a id="viewNewComments">**viewNewComments**</a>: This event is emitted when the viewer reveals new live comments.
```ts
{
storyID: string;
count: number;
}
```
<!-- END docs:events -->
+654 -227
View File
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "5.2.1",
"version": "5.3.2",
"author": "The Coral Project",
"homepage": "https://coralproject.net/",
"sideEffects": [
@@ -26,6 +26,7 @@
"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",
"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/",
@@ -57,6 +58,7 @@
"license": "Apache-2.0",
"dependencies": {
"@coralproject/bunyan-prettystream": "^0.1.4",
"@metascraper/helpers": "^5.7.21",
"akismet-api": "^4.2.0",
"apollo-server-express": "^2.8.1",
"archiver": "^3.0.3",
@@ -74,7 +76,7 @@
"cron": "^1.7.1",
"csv-stringify": "^5.3.0",
"dataloader": "^1.4.0",
"dompurify": "^1.0.8",
"dompurify": "^2.0.7",
"dotenv": "^6.0.0",
"dotenv-expand": "^4.2.0",
"env-rewrite": "^1.0.2",
@@ -104,11 +106,10 @@
"lodash": "^4.17.15",
"lru-cache": "^5.1.1",
"luxon": "^1.12.0",
"metascraper-author": "^3.11.8",
"metascraper-date": "^3.11.4",
"metascraper-description": "^3.11.8",
"metascraper-image": "^3.11.8",
"metascraper-title": "^3.11.8",
"metascraper-author": "^5.8.0",
"metascraper-description": "^5.8.0",
"metascraper-image": "^5.8.0",
"metascraper-title": "^5.8.0",
"mongodb": "^3.2.7",
"mongodb-core": "^3.2.7",
"ms": "^2.1.1",
@@ -125,7 +126,7 @@
"performance-now": "^2.1.0",
"permit": "^0.2.4",
"prom-client": "^11.3.0",
"proxy-agent": "^3.1.0",
"proxy-agent": "^3.1.1",
"querystringify": "^2.1.0",
"react-helmet": "^5.2.1",
"source-map-support": "^0.5.12",
@@ -146,7 +147,7 @@
"@babel/preset-react": "^7.0.0",
"@babel/preset-typescript": "^7.3.3",
"@coralproject/npm-run-all": "^4.1.5",
"@coralproject/rte": "^0.10.15",
"@coralproject/rte": "^0.11.0",
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
"@types/agent-base": "^4.2.0",
"@types/archiver": "^3.0.0",
@@ -167,6 +168,7 @@
"@types/cors": "^2.8.4",
"@types/cron": "^1.7.1",
"@types/cross-spawn": "^6.0.0",
"@types/dompurify": "0.0.33",
"@types/dotenv": "^4.0.3",
"@types/enzyme": "^3.1.15",
"@types/enzyme-adapter-react-16": "^1.0.3",
@@ -379,6 +381,9 @@
],
"src/core/server/graph/tenant/schema/schema.graphql": [
"graphql-schema-linter"
],
"{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,events.md}": [
"npm run docs:events -- --verify"
]
},
"bundlesize": [
+277
View File
@@ -0,0 +1,277 @@
/* eslint-disable no-bitwise */
import { codeBlock, stripIndent } from "common-tags";
import * as fs from "fs";
import * as path from "path";
import ts from "typescript";
interface DocEntry {
name: string;
docs?: string;
type: "ViewerNetworkEvent" | "ViewerEvent";
text?: string;
}
/**
* We use this regexp to find a previous block that we
* are going to update in the readme file.
*/
const BLOCK_REGEXP = /<!-- START docs:events -->(.|\n)*<!-- END docs:events -->/gm;
/** Build flags that affects AST generation */
const buildFlags =
// Do not truncate output.
ts.NodeBuilderFlags.NoTruncation |
// Use multiline object literals format.
ts.NodeBuilderFlags.MultilineObjectLiterals;
/** Generate documentation for all classes in a set of .ts files */
function gatherEntries(
fileNames: string[],
options: ts.CompilerOptions
): DocEntry[] {
// Build a program using the set of root file names in fileNames
const program = ts.createProgram(fileNames, options);
const printer = ts.createPrinter({
noEmitHelpers: true,
omitTrailingSemicolon: true,
removeComments: false,
});
// Get the checker, we will use it to find more about classes
const checker = program.getTypeChecker();
const data: DocEntry[] = [];
/** Hold a pointer to the sourcefile we are currently processing. */
let currentSourceFile: ts.SourceFile;
// Visit every sourceFile in the program
for (const sourceFile of program.getSourceFiles()) {
if (!sourceFile.isDeclarationFile) {
currentSourceFile = sourceFile;
// Walk the tree to search for classes
ts.forEachChild(sourceFile, visit);
}
}
const sorted = data.sort((a, b) => {
if (a.name > b.name) {
return 1;
}
if (b.name > a.name) {
return -1;
}
return 0;
});
return sorted;
/** visit nodes finding exported events */
function visit(node: ts.Node) {
// Only consider exported nodes
if (!isNodeExported(node)) {
return;
}
if (ts.isVariableStatement(node)) {
if (
!node.getFullText().includes("createViewerNetworkEvent") &&
!node.getFullText().includes("createViewerEvent")
) {
return;
}
const firstChild = node.declarationList.declarations[0];
if (ts.isVariableDeclaration(firstChild)) {
const symbol = checker.getSymbolAtLocation(firstChild.name);
if (symbol) {
serializeEventSymbol(symbol);
}
}
}
}
function serializeEventSymbol(symbol: ts.Symbol) {
const type = checker.getTypeOfSymbolAtLocation(
symbol,
symbol.valueDeclaration
);
const typeNode = checker.typeToTypeNode(type, undefined, buildFlags)!;
const typeName = symbol.getName();
const entry: DocEntry = {
name: typeName,
docs: ts.displayPartsToString(symbol.getDocumentationComment(checker)),
type: type.getSymbol()!.getName() as DocEntry["type"],
};
typeNode.forEachChild(ch => {
if (ts.isTypeLiteralNode(ch)) {
const text = printer.printNode(
ts.EmitHint.Unspecified,
ch,
currentSourceFile
);
if (text !== "{}") {
entry.text = text;
}
/*
Go through each parameter.
ch.members.forEach(m => {
if (ts.isPropertySignature(m)) {
if (ts.isIdentifier(m.name)) {
data.parameters[m.name.text] = printer.printNode(
ts.EmitHint.Unspecified,
m.type!,
currentSourceFile
);
}
}
});
*/
}
});
data.push(entry);
}
/** True if this is visible outside this file, false otherwise */
function isNodeExported(node: ts.Node): boolean {
return (
// eslint-disable-next-line no-bitwise, @typescript-eslint/no-unnecessary-type-assertion
(ts.getCombinedModifierFlags(node as ts.Declaration) &
ts.ModifierFlags.Export) !==
0 ||
(!!node.parent && node.parent.kind === ts.SyntaxKind.SourceFile)
);
}
}
function prefixLines(text: string, prefix: string) {
return text.split("\n").join(`\n${prefix}`);
}
function getEventName(typeName: string) {
return (
typeName[0].toLocaleLowerCase() +
typeName.slice(1, typeName.length - "Event".length)
);
}
/**
* Removes "%future added value" from text. This is a placeholder type
* added by Relay to help with future proofness.
*/
function removeFutureAddedValue(text: string) {
return text
.replace(': "%future added value" | ', ": ")
.replace(' | "%future added value"', "");
}
/**
* Append or update previous documention in markdownFile.
*
* @param markdownFile The markdown file we want to inject the docs too.
* @param entries data as returned by gatherEntries.
*/
function emitDocs(markdownFile: string, entries: DocEntry[], verify = false) {
const previousContent = fs.existsSync(markdownFile)
? fs.readFileSync(markdownFile).toString()
: "";
const summary = stripIndent`
- ${entries
.map(
e => `<a href="#${getEventName(e.name)}">${getEventName(e.name)}</a>`
)
.join("\n - ")}
`;
const list = entries
.map(
e =>
codeBlock`
- ${
e.type === "ViewerEvent"
? `<a id="${getEventName(e.name)}">**${getEventName(e.name)}**</a>`
: `<a id="${getEventName(e.name)}">**${getEventName(
e.name
)}.success**, **${getEventName(e.name)}.error**</a>`
}: ${e.docs ? e.docs.replace("\n", " ") : ""}
${
e.text
? codeBlock`
\`\`\`ts
${removeFutureAddedValue(e.text)}
\`\`\`
`
: ""
}
`
)
.join("\n");
const output = stripIndent`
<!-- START docs:events -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN npm run docs:events -->
### Index
${prefixLines(summary, " ")}
### Events
${prefixLines(list, " ")}
<!-- END docs:events -->
`;
let newContent;
// Find previous block.
if (BLOCK_REGEXP.test(previousContent)) {
newContent = previousContent.replace(BLOCK_REGEXP, output);
} else {
newContent = previousContent + "\n" + output;
}
if (previousContent === newContent) {
// eslint-disable-next-line no-console
console.log(`${markdownFile} is up to date`);
return;
}
if (verify) {
// eslint-disable-next-line no-console
console.error(
`${markdownFile} is outdated, please run \`npm run docs:events\``
);
process.exit(1);
return;
}
fs.writeFileSync(markdownFile, newContent);
// eslint-disable-next-line no-console
console.log(`Successfully injected documentation into ${markdownFile}`);
}
function main() {
if (process.argv.length < 4) {
throw new Error("Must provide path to events and a markdown file.");
}
const eventFile = process.argv[2];
const markdownFile = process.argv[3];
// Find tsconfig file.
const configFile = ts.findConfigFile(eventFile, fs.existsSync);
if (!configFile) {
throw new Error("tsconfig file not found");
}
const configText = fs.readFileSync(configFile).toString();
const result = ts.parseConfigFileTextToJson(configFile, configText);
if (result.error) {
throw result.error;
}
// Parse the JSON raw data into actual consumable compiler options.
const config = ts.parseJsonConfigFileContent(
result.config,
ts.sys,
path.dirname(configFile)
);
const entries = gatherEntries([eventFile], config.options);
emitDocs(markdownFile, entries, process.argv[4] === "--verify");
}
main();
@@ -11,8 +11,9 @@ interface Props {
const DownloadForm: FunctionComponent<Props> = ({ token }) => {
const [submitted, setSubmitted] = useState(false);
const onClick = useCallback(() => {
const onSubmit = useCallback(() => {
setSubmitted(true);
return true;
}, [setSubmitted]);
return (
@@ -21,15 +22,15 @@ const DownloadForm: FunctionComponent<Props> = ({ token }) => {
className={styles.form}
method="post"
action="/api/account/download"
onSubmit={onSubmit}
>
<input name="token" type="hidden" value={token} />
<Localized id="download-landingPage-downloadComments ">
<Localized id="download-landingPage-downloadComments">
<Button
type="submit"
variant="filled"
color="primary"
disabled={submitted}
onClick={onClick}
className={styles.downloadButton}
>
Download My Comment History
@@ -47,10 +47,10 @@ const SSOKeyField: FunctionComponent<Props> = ({
<div className={styles.warningSection}>
<Flex direction="row" itemGutter="half">
<Icon className={styles.warnIcon}>warning</Icon>
<Localized id="configure-auth-sso-regenerateWarning">
<Localized id="configure-auth-sso-regenerateHonoredWarning">
<Typography className={styles.warn} variant="bodyShort">
Regenerating a key will invalidate any existing user sessions, and
all signed-in users will be signed out
When regenerating a key, tokens signed with the previous key will be
honored for 30 days.
</Typography>
</Localized>
</Flex>
@@ -98,7 +98,7 @@ const HotkeysModal: FunctionComponent<Props> = ({ open, onClose }) => {
</Localized>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>d</div>
<div className={styles.hotKey}>f</div>
</div>
<Localized id="hotkeysModal-shortcuts-approve">
<Typography>Approve</Typography>
@@ -106,7 +106,7 @@ const HotkeysModal: FunctionComponent<Props> = ({ open, onClose }) => {
</Flex>
<Flex>
<div className={styles.hotKeyContainer}>
<div className={styles.hotKey}>f</div>
<div className={styles.hotKey}>d</div>
</div>
<Localized id="hotkeysModal-shortcuts-reject">
<Typography>Reject</Typography>
@@ -1925,8 +1925,7 @@ exports[`regenerate sso key 1`] = `
<p
className="Box-root Typography-root Typography-bodyShort Typography-colorTextPrimary SSOKeyField-warn"
>
Regenerating a key will invalidate any existing user sessions,
and all signed-in users will be signed out.
When regenerating a key, tokens signed with the previous key will be honored for 30 days.
</p>
</div>
</div>
@@ -3177,8 +3176,7 @@ Invalid Date
<p
className="Box-root Typography-root Typography-bodyShort Typography-colorTextPrimary SSOKeyField-warn"
>
Regenerating a key will invalidate any existing user sessions,
and all signed-in users will be signed out.
When regenerating a key, tokens signed with the previous key will be honored for 30 days.
</p>
</div>
</div>
+17 -13
View File
@@ -53,21 +53,25 @@ const SignIn: FunctionComponent<SignInForm> = ({
subtitle={<Subtitle />}
>
<Bar>
{
"<title>Sign In</title><subtitle>to join the conversation</subtitle>"
}
<Title>Sign In</Title>
<Subtitle>to join the conversation</Subtitle>
</Bar>
</Localized>
<SubBar>
<Localized
id="signIn-noAccountSignUp"
textlink={<TextLink onClick={onGotoSignUp} href={signUpHref} />}
>
<Typography variant="bodyCopy" container={Flex}>
{"Don't have an account? <textlink>Sign Up</textlink>"}
</Typography>
</Localized>
</SubBar>
{emailEnabled && (
<SubBar>
<Localized
id="signIn-noAccountSignUp"
textlink={<TextLink onClick={onGotoSignUp} href={signUpHref} />}
>
<Typography variant="bodyCopy" container={Flex}>
Don't have an account?{" "}
<TextLink onClick={onGotoSignUp} href={signUpHref}>
Sign Up
</TextLink>
</Typography>
</Localized>
</SubBar>
)}
<Main data-testid="signIn-main">
<HorizontalGutter size="oneAndAHalf">
{error && (
@@ -51,8 +51,9 @@ const SignInContainer: FunctionComponent<Props> = ({
// If there's only one enabled auth integration, we should just perform
// the redirect now.
if (
!auth.integrations.local.enabled ||
!auth.integrations.local.targetFilter.stream
!local.error &&
(!auth.integrations.local.enabled ||
!auth.integrations.local.targetFilter.stream)
) {
// Local isn't enabled, so we can look into the rest of the integrations
// now.
@@ -10,7 +10,12 @@ exports[`renders correctly 1`] = `
title={<Title />}
>
<Bar>
&lt;title&gt;Sign In&lt;/title&gt;&lt;subtitle&gt;to join the conversation&lt;/subtitle&gt;
<Title>
Sign In
</Title>
<Subtitle>
to join the conversation
</Subtitle>
</Bar>
</Localized>
<SubBar>
@@ -32,7 +37,14 @@ exports[`renders correctly 1`] = `
}
variant="bodyCopy"
>
Don't have an account? &lt;textlink&gt;Sign Up&lt;/textlink&gt;
Don't have an account?
<withPropsOnChange(TextLinkProps)
href="/signUp"
onClick={[Function]}
>
Sign Up
</withPropsOnChange(TextLinkProps)>
</ForwardRef(forwardRef)>
</Localized>
</SubBar>
@@ -70,7 +82,12 @@ exports[`renders error 1`] = `
title={<Title />}
>
<Bar>
&lt;title&gt;Sign In&lt;/title&gt;&lt;subtitle&gt;to join the conversation&lt;/subtitle&gt;
<Title>
Sign In
</Title>
<Subtitle>
to join the conversation
</Subtitle>
</Bar>
</Localized>
<SubBar>
@@ -92,7 +109,14 @@ exports[`renders error 1`] = `
}
variant="bodyCopy"
>
Don't have an account? &lt;textlink&gt;Sign Up&lt;/textlink&gt;
Don't have an account?
<withPropsOnChange(TextLinkProps)
href="/signUp"
onClick={[Function]}
>
Sign Up
</withPropsOnChange(TextLinkProps)>
</ForwardRef(forwardRef)>
</Localized>
</SubBar>
@@ -136,32 +160,14 @@ exports[`renders without email login 1`] = `
title={<Title />}
>
<Bar>
&lt;title&gt;Sign In&lt;/title&gt;&lt;subtitle&gt;to join the conversation&lt;/subtitle&gt;
<Title>
Sign In
</Title>
<Subtitle>
to join the conversation
</Subtitle>
</Bar>
</Localized>
<SubBar>
<Localized
id="signIn-noAccountSignUp"
textlink={
<withPropsOnChange(TextLinkProps)
href="/signUp"
onClick={[Function]}
/>
}
>
<ForwardRef(forwardRef)
container={
Object {
"$$typeof": Symbol(react.forward_ref),
"render": [Function],
}
}
variant="bodyCopy"
>
Don't have an account? &lt;textlink&gt;Sign Up&lt;/textlink&gt;
</ForwardRef(forwardRef)>
</Localized>
</SubBar>
<Main
data-testid="signIn-main"
>
+17 -13
View File
@@ -50,21 +50,25 @@ const SignUp: FunctionComponent<Props> = ({
subtitle={<Subtitle />}
>
<Bar>
{
"<title>Sign Up</title><subtitle>to join the conversation</subtitle>"
}
<Title>Sign Up</Title>
<Subtitle>to join the conversation</Subtitle>
</Bar>
</Localized>
<SubBar>
<Localized
id="signUp-accountAvailableSignIn"
textlink={<TextLink onClick={onGotoSignIn} href={signInHref} />}
>
<Typography variant="bodyCopy" container={Flex}>
{"Already have an account? <textlink>Sign In</textlink>"}
</Typography>
</Localized>
</SubBar>
{emailEnabled && (
<SubBar>
<Localized
id="signUp-accountAvailableSignIn"
textlink={<TextLink onClick={onGotoSignIn} href={signInHref} />}
>
<Typography variant="bodyCopy" container={Flex}>
Already have an account?{" "}
<TextLink onClick={onGotoSignIn} href={signInHref}>
Sign In
</TextLink>
</Typography>
</Localized>
</SubBar>
)}
<Main data-testid="signUp-main">
<HorizontalGutter size="oneAndAHalf">
{emailEnabled && <SignUpWithEmailContainer />}
@@ -10,7 +10,12 @@ exports[`renders correctly 1`] = `
title={<Title />}
>
<Bar>
&lt;title&gt;Sign Up&lt;/title&gt;&lt;subtitle&gt;to join the conversation&lt;/subtitle&gt;
<Title>
Sign Up
</Title>
<Subtitle>
to join the conversation
</Subtitle>
</Bar>
</Localized>
<SubBar>
@@ -32,7 +37,14 @@ exports[`renders correctly 1`] = `
}
variant="bodyCopy"
>
Already have an account? &lt;textlink&gt;Sign In&lt;/textlink&gt;
Already have an account?
<withPropsOnChange(TextLinkProps)
href="/signIn"
onClick={[Function]}
>
Sign In
</withPropsOnChange(TextLinkProps)>
</ForwardRef(forwardRef)>
</Localized>
</SubBar>
@@ -70,32 +82,14 @@ exports[`renders without email sign up 1`] = `
title={<Title />}
>
<Bar>
&lt;title&gt;Sign Up&lt;/title&gt;&lt;subtitle&gt;to join the conversation&lt;/subtitle&gt;
<Title>
Sign Up
</Title>
<Subtitle>
to join the conversation
</Subtitle>
</Bar>
</Localized>
<SubBar>
<Localized
id="signUp-accountAvailableSignIn"
textlink={
<withPropsOnChange(TextLinkProps)
href="/signIn"
onClick={[Function]}
/>
}
>
<ForwardRef(forwardRef)
container={
Object {
"$$typeof": Symbol(react.forward_ref),
"render": [Function],
}
}
variant="bodyCopy"
>
Already have an account? &lt;textlink&gt;Sign In&lt;/textlink&gt;
</ForwardRef(forwardRef)>
</Localized>
</SubBar>
<Main
data-testid="signUp-main"
>
+140
View File
@@ -0,0 +1,140 @@
import { EventEmitter2 } from "eventemitter2";
import { useCoralContext } from "./bootstrap";
/**
* _Viewer Events_ are emitted when the viewer performs certain actions.
* They can be subscribed to using the `events` parameter in
* `Coral.createStreamEmbed`.
*/
export interface ViewerEvent<T> {
emit: keyof T extends never
? (eventEmitter: EventEmitter2) => void
: (eventEmitter: EventEmitter2, data: T) => void;
}
/**
* A ViewerNetworkEventStarted represents ViewerNetworkEvent that has
* started and is waiting for the response.
*/
export interface ViewerNetworkEventStarted<
T extends { success: object; error: object }
> {
/**
* Emits a success event and include the rtt time.
*/
success: keyof T["success"] extends never
? () => void
: (success: T["success"]) => void;
/**
* Emits an error event and include the rtt time.
*/
error: keyof T["error"] extends never
? () => void
: (error: T["error"]) => void;
}
/**
* _Viewer Network Events_ are _Viewer Events_ that involves a network request and
* thus can succeed or fail. Succeeding events have the suffix `.success`
* while failing events an `.error` suffix.
*
* Moreover _Viewer Network Events_ contain the `rtt` field which indicates
* the time it needed from initiating the request until the _UI_ has been
* updated with the response data.
*/
export interface ViewerNetworkEvent<
T extends { success: object; error: object }
> {
/**
* Mark the network request as started. This will also start tracking the rtt time.
*/
begin: keyof T extends "success" | "error"
? (eventEmitter: EventEmitter2) => ViewerNetworkEventStarted<T>
: (
eventEmitter: EventEmitter2,
data: Pick<T, Exclude<keyof T, "success" | "error">>
) => ViewerNetworkEventStarted<T>;
}
/**
* createViewerEvent creates a ViewerNetworkEvent object.
*
* @param name name of the event
*/
export function createViewerNetworkEvent<
T extends { success: object; error: object }
>(name: string): ViewerNetworkEvent<T> {
return {
begin: ((eventEmitter, data) => {
const ms = Date.now();
return {
success: (success => {
const final: any = {
...data,
rtt: Date.now() - ms,
};
if (success) {
final.success = success;
}
eventEmitter.emit(`${name}.success`, final);
}) as ViewerNetworkEventStarted<T>["success"],
error: (error => {
const final: any = {
...data,
rtt: Date.now() - ms,
};
if (error) {
final.error = error;
}
eventEmitter.emit(`${name}.error`, final);
}) as ViewerNetworkEventStarted<T>["error"],
};
}) as ViewerNetworkEvent<T>["begin"],
};
}
/**
* createViewerEvent creates a ViewerEvent object.
*
* @param name name of the event
*/
export function createViewerEvent<T>(name: string): ViewerEvent<T> {
return {
emit: ((eventEmitter, data) => {
eventEmitter.emit(name, data);
}) as ViewerEvent<T>["emit"],
};
}
/**
* useViewerEvent inject the eventEmitter and returns a simple
* callback to emit the event.
*/
export function useViewerEvent<T>(
viewerEvent: ViewerEvent<T>
): keyof T extends never ? () => void : (data: T) => void {
const { eventEmitter } = useCoralContext();
return ((data?: T) => {
viewerEvent.emit(eventEmitter, data as any);
}) as any;
}
/**
* useViewerNetworkEvent injects the eventEmitter into a ViewNetworkEvent
* and returns a simple callback to begin the event.
*/
export function useViewerNetworkEvent<
T extends { success: object; error: object }
>(
viewerNetworkEvent: ViewerNetworkEvent<T>
): keyof T extends "success" | "error"
? () => ViewerNetworkEventStarted<T>
: (
data: Pick<T, Exclude<keyof T, "success" | "error">>
) => ViewerNetworkEventStarted<T> {
const { eventEmitter } = useCoralContext();
return ((data?: T) => {
return viewerNetworkEvent.begin(eventEmitter, data as any);
}) as any;
}
@@ -38,6 +38,7 @@ function createMutationContainer<T extends string, I, R>(
);
private commit = (input: I) => {
// TODO: (cvle) These events are deprecated.
this.props.context.eventEmitter.emit(`mutation.${propName}`, input);
return commit(
this.props.context.relayEnvironment,
@@ -69,6 +69,7 @@ export function useFetch<V, R>(
const context = useCoralContext();
return useCallback<FetchProp<typeof fetch>>(
((variables: V) => {
// TODO: (cvle) These events are deprecated.
context.eventEmitter.emit(`fetch.${fetch.name}`, variables);
return fetch.fetch(context.relayEnvironment, variables, context);
}) as any,
@@ -94,6 +95,7 @@ export function withFetch<N extends string, V, R>(
public static displayName = wrapDisplayName(BaseComponent, "withFetch");
private fetch = (variables: V) => {
// TODO: (cvle) These events are deprecated.
this.props.context.eventEmitter.emit(
`fetch.${fetch.name}`,
variables
@@ -26,7 +26,6 @@ export function setAccessTokenInLocalState(
localRecord.setValue(accessToken || "", "accessToken");
if (accessToken) {
const { payload } = parseJWT(accessToken);
// TODO: (cvle) maybe a timer to detect when accessToken has expired?
// Set the exp if it's valid.
@@ -71,6 +71,7 @@ export function useMutation<I, R>(
const context = useCoralContext();
return useCallback<MutationProp<typeof mutation>>(
((input: I) => {
// TODO: (cvle) These events are deprecated.
context.eventEmitter.emit(`mutation.${mutation.name}`, input);
return mutation.commit(context.relayEnvironment, input, context);
}) as any,
@@ -99,6 +100,7 @@ export function withMutation<N extends string, I, R>(
);
private commit = (input: I) => {
// TODO: (cvle) These events are deprecated.
this.props.context.eventEmitter.emit(
`mutation.${mutation.name}`,
input
@@ -50,6 +50,7 @@ export function useSubscription<V>(
const context = useCoralContext();
return useCallback<SubscriptionProp<typeof subscription>>(
((variables: V) => {
// TODO: (cvle) These events are deprecated.
context.eventEmitter.emit(`subscription.${subscription.name}`, variables);
return subscription.subscribe(
context.relayEnvironment,
@@ -4,23 +4,29 @@ import { RelayPaginationProp } from "react-relay";
/**
* useLoadMore is a react hook that returns a `loadMore` callback
* and a `isLoadingMore` boolean.
*
* @param relay {RelayPaginationProp}
* @param count {number}
*/
export default function useLoadMore(
relay: RelayPaginationProp,
count: number
): [() => void, boolean] {
): [() => Promise<void>, boolean] {
const [isLoadingMore, setIsLoadingMore] = useState(false);
const loadMore = useCallback(() => {
if (!relay.hasMore() || relay.isLoading()) {
return;
return Promise.resolve();
}
setIsLoadingMore(true);
relay.loadMore(count, error => {
setIsLoadingMore(false);
if (error) {
// eslint-disable-next-line no-console
console.error(error);
}
return new Promise<void>((resolve, reject) => {
relay.loadMore(count, error => {
setIsLoadingMore(false);
if (error) {
reject(error);
} else {
resolve();
}
});
});
}, [relay]);
return [loadMore, isLoadingMore];
+69 -11
View File
@@ -1,21 +1,79 @@
import React, { Component } from "react";
import React, { FunctionComponent, useEffect, useState } from "react";
import { ERROR_CODES } from "coral-common/errors";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useFetch } from "coral-framework/lib/relay";
import { CallOut, Flex, Typography } from "coral-ui/components";
import CheckInstallFetch from "./CheckInstallFetch";
import InstallWizard from "./InstallWizard";
import MainBar from "./MainBar";
import Wizard from "./Wizard";
import styles from "./App.css";
class App extends Component {
public render() {
return (
<div className={styles.root}>
<MainBar />
<div className={styles.container}>
<InstallWizard />
</div>
type State = "loading" | "success" | "failure";
const App: FunctionComponent = () => {
const [error, setError] = useState<string | null>(null);
const [state, setState] = useState<State>("loading");
const checkInstall = useFetch(CheckInstallFetch);
const context = useCoralContext();
useEffect(() => {
async function check() {
try {
await checkInstall({});
setState("success");
} catch (err) {
setError(err.message);
setState("failure");
if (err.code !== ERROR_CODES.RATE_LIMIT_EXCEEDED) {
await context.clearSession("");
}
}
}
check();
}, []);
return (
<div className={styles.root}>
<MainBar />
<div className={styles.container}>
<AppState state={state} error={error} />
</div>
);
}
</div>
);
};
interface AppStateProps {
state: State;
error: string | null;
}
const AppState: FunctionComponent<AppStateProps> = ({ state, error }) => {
switch (state) {
case "loading":
return null;
case "success":
return <InstallWizard />;
default:
return <FailedAppState error={error} />;
}
};
interface FailedAppStateProps {
error: string | null;
}
const FailedAppState: FunctionComponent<FailedAppStateProps> = ({ error }) => (
<Wizard currentStep={0}>
<Flex justifyContent="center">
<CallOut color="error">
<Typography variant="bodyCopy">{error}</Typography>
</CallOut>
</Flex>
</Wizard>
);
export default App;
@@ -0,0 +1,13 @@
import { Environment } from "relay-runtime";
import { createFetch } from "coral-framework/lib/relay";
const CheckInstallFetch = createFetch(
"checkInstallFetch",
async (environment: Environment, variables: any, { rest }) =>
await rest.fetch("/install", {
method: "GET",
})
);
export default CheckInstallFetch;
@@ -8,7 +8,10 @@ exports[`renders correctly 1`] = `
<div
className="App-container"
>
<withContext(createMutationContainer(InstallWizard)) />
<AppState
error={null}
state="loading"
/>
</div>
</div>
`;
+1
View File
@@ -0,0 +1 @@
export const INSTALL_ACCESS_TOKEN_KEY = "coral:install:accessToken";
+2
View File
@@ -4,6 +4,7 @@ import ReactDOM from "react-dom";
import { createManaged } from "coral-framework/lib/bootstrap";
import App from "./App";
import { initLocalState } from "./local";
import localesData from "./locales";
// Import css variables.
@@ -12,6 +13,7 @@ import "coral-ui/theme/variables.css";
async function main() {
const ManagedCoralContextProvider = await createManaged({
localesData,
initLocalState,
});
const Index: FunctionComponent = () => (
+1
View File
@@ -0,0 +1 @@
export { default as initLocalState } from "./initLocalState";
@@ -0,0 +1,33 @@
import { Environment } from "relay-runtime";
import { clearHash, getParamsFromHash } from "coral-framework/helpers";
import { CoralContext } from "coral-framework/lib/bootstrap";
import { initLocalBaseState } from "coral-framework/lib/relay";
import { INSTALL_ACCESS_TOKEN_KEY } from "../constants";
/**
* Initializes the local state, before we start the App.
*/
export default async function initLocalState(
environment: Environment,
context: CoralContext
) {
// Get the access token from the session storage.
let accessToken = await context.sessionStorage.getItem(
INSTALL_ACCESS_TOKEN_KEY
);
// Get all the parameters from the hash.
const params = getParamsFromHash();
if (params && params.accessToken) {
// As there's an access token in the hash, let's clear it.
clearHash();
// Save the token in session storage to override what we found.
accessToken = params.accessToken;
await context.sessionStorage.setItem(INSTALL_ACCESS_TOKEN_KEY, accessToken);
}
await initLocalBaseState(environment, context, accessToken);
}
@@ -1,4 +1,6 @@
import { EventEmitter2 } from "eventemitter2";
import { Environment, RecordSource } from "relay-runtime";
import sinon from "sinon";
import { LOCAL_ID } from "coral-framework/lib/relay";
import { createRelayEnvironment } from "coral-framework/testHelpers";
@@ -16,6 +18,10 @@ beforeAll(() => {
it("Sets activeTab", () => {
const tab = "COMMENTS";
commit(environment, { tab });
const eventEmitter = new EventEmitter2();
const mock = sinon.mock(eventEmitter);
mock.expects("emit").withArgs("setMainTab", { tab });
commit(environment, { tab }, { eventEmitter });
expect(source.get(LOCAL_ID)!.activeTab).toEqual(tab);
mock.verify();
});
@@ -1,6 +1,8 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import { createMutationContainer, LOCAL_ID } from "coral-framework/lib/relay";
import { SetMainTabEvent } from "coral-stream/events";
export interface SetActiveTabInput {
tab: "COMMENTS" | "PROFILE" | "%future added value";
@@ -10,11 +12,15 @@ export type SetActiveTabMutation = (input: SetActiveTabInput) => Promise<void>;
export async function commit(
environment: Environment,
input: SetActiveTabInput
input: SetActiveTabInput,
{ eventEmitter }: Pick<CoralContext, "eventEmitter">
) {
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.tab, "activeTab");
if (record.getValue("activeTab") !== input.tab) {
SetMainTabEvent.emit(eventEmitter, { tab: input.tab });
record.setValue(input.tab, "activeTab");
}
});
}
@@ -1,4 +1,3 @@
import { noop } from "lodash";
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
@@ -28,24 +27,3 @@ it("Broadcasts events to pym", () => {
);
expect(pym.sendMessage.calledOnce).toBe(true);
});
it("emits event aliases", () => {
const eventEmitter: any = {
emit: createSinonStub(
s => s.throws(),
s => s.withArgs("loginPrompt").returns(null)
),
onAny: (cb: (eventName: string, value: any) => void) => {
cb("mutation.showAuthPopup", { view: "SIGN_IN" });
},
};
const pym = {
sendMessage: noop,
};
createRenderer().render(
<OnEvents pym={pym as any} eventEmitter={eventEmitter} />
);
expect(eventEmitter.emit.calledOnce).toBe(true);
});
@@ -2,8 +2,6 @@ import { Component } from "react";
import { CoralContext, withContext } from "coral-framework/lib/bootstrap";
import emitEventAliases from "./emitEventAliases";
interface Props {
pym: CoralContext["pym"];
eventEmitter: CoralContext["eventEmitter"];
@@ -13,8 +11,6 @@ export class OnEvents extends Component<Props> {
constructor(props: Props) {
super(props);
props.eventEmitter.onAny((eventName: string, value: any) => {
// Emit event aliases.
emitEventAliases(props.eventEmitter, eventName, value);
props.pym!.sendMessage(
"event",
JSON.stringify({
@@ -1,17 +0,0 @@
import { EventEmitter2 } from "eventemitter2";
export default function emitEventAliases(
eventEmitter: EventEmitter2,
eventName: string,
value: any
) {
switch (eventName) {
case "mutation.showAuthPopup":
switch (value.view) {
case "SIGN_IN":
eventEmitter.emit("loginPrompt");
break;
}
break;
}
}
@@ -16,7 +16,8 @@ exports[`sanitizes evil html 1`] = `
className="HTMLContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "<span>Hello world</span>
"__html": "
<span>Hello world</span>
",
}
}
@@ -0,0 +1,26 @@
import React, { FunctionComponent, useCallback } from "react";
import { useViewerEvent } from "coral-framework/lib/events";
import { ShowAbsoluteTimestampEvent } from "coral-stream/events";
import { Timestamp as BaseTimestamp } from "coral-ui/components";
import { PropTypesOf } from "coral-ui/types";
const TimeStamp: FunctionComponent<
PropTypesOf<typeof BaseTimestamp>
> = props => {
const emitEvent = useViewerEvent(ShowAbsoluteTimestampEvent);
const handleOnToggle = useCallback(
(absolute: boolean) => {
if (absolute) {
emitEvent();
}
if (props.onToggleAbsolute) {
return props.onToggleAbsolute(absolute);
}
},
[props.onToggleAbsolute, emitEvent]
);
return <BaseTimestamp {...props} onToggleAbsolute={handleOnToggle} />;
};
export default TimeStamp;
@@ -3,15 +3,14 @@ import React, { Component } from "react";
import { urls } from "coral-framework/helpers";
import {
graphql,
MutationProp,
withFragmentContainer,
withLocalStateContainer,
withMutation,
} from "coral-framework/lib/relay";
import {
SignOutMutation,
withSignOutMutation,
} from "coral-framework/mutations";
import {
ShowAuthPopupMutation,
SignOutMutation,
withShowAuthPopupMutation,
} from "coral-stream/mutations";
import { Popup } from "coral-ui/components";
@@ -33,7 +32,7 @@ interface Props {
settings: SettingsData;
showAuthPopup: ShowAuthPopupMutation;
setAuthPopupState: SetAuthPopupStateMutation;
signOut: SignOutMutation;
signOut: MutationProp<typeof SignOutMutation>;
}
export class UserBoxContainer extends Component<Props> {
@@ -118,7 +117,7 @@ export class UserBoxContainer extends Component<Props> {
}
}
const enhanced = withSignOutMutation(
const enhanced = withMutation(SignOutMutation)(
withSetAuthPopupStateMutation(
withShowAuthPopupMutation(
withLocalStateContainer(
+609
View File
@@ -0,0 +1,609 @@
/**
* This file contains Viewer Events of the Embed Stream.
*
* Viewer Events 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>
* ```
*/
import {
createViewerEvent,
createViewerNetworkEvent,
} from "coral-framework/lib/events";
import { COMMENT_STATUS } from "./__generated__/CreateCommentMutation.graphql";
import { DIGEST_FREQUENCY } from "./__generated__/NotificationSettingsContainer_viewer.graphql";
import { MODERATION_MODE } from "./__generated__/UpdateStorySettingsMutation.graphql";
/**
* This event is emitted when a top level comment is created.
*/
export const CreateCommentEvent = createViewerNetworkEvent<{
storyID: string;
body: string;
success: {
id: string;
status: COMMENT_STATUS;
};
error: {
message: string;
code?: string;
};
}>("createComment");
/**
* This event is emitted when a comment reply is created.
*/
export const CreateCommentReplyEvent = createViewerNetworkEvent<{
body: string;
parentID: string;
success: {
id: string;
status: COMMENT_STATUS;
};
error: {
message: string;
code?: string;
};
}>("createCommentReply");
/**
* This event is emitted when the viewer edits a comment.
*/
export const EditCommentEvent = createViewerNetworkEvent<{
body: string;
commentID: string;
success: {
status: COMMENT_STATUS;
};
error: {
message: string;
code?: string;
};
}>("editComment");
/**
* This event is emitted when the viewer reacts to a comment.
*/
export const CreateCommentReactionEvent = createViewerNetworkEvent<{
commentID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("createCommentReaction");
/**
* This event is emitted when the viewer removes its reaction from a comment.
*/
export const RemoveCommentReactionEvent = createViewerNetworkEvent<{
commentID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("removeCommentReaction");
/**
* This event is emitted when the viewer features a comment.
*/
export const FeatureCommentEvent = createViewerNetworkEvent<{
commentID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("featureComment");
/**
* This event is emitted when the viewer unfeatures a comment.
*/
export const UnfeatureCommentEvent = createViewerNetworkEvent<{
commentID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("unfeatureComment");
/**
* This event is emitted when the viewer approves a comment.
*/
export const ApproveCommentEvent = createViewerNetworkEvent<{
commentID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("approveComment");
/**
* This event is emitted when the viewer rejects a comment.
*/
export const RejectCommentEvent = createViewerNetworkEvent<{
commentID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("rejectComment");
/**
* This event is emitted when the viewer bans a user.
*/
export const BanUserEvent = createViewerNetworkEvent<{
userID: string;
commentID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("banUser");
/**
* This event is emitted when the viewer ignores a user.
*/
export const IgnoreUserEvent = createViewerNetworkEvent<{
userID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("ignoreUser");
/**
* This event is emitted when the viewer remove a user from
* its ignored users list.
*/
export const RemoveUserIgnoreEvent = createViewerNetworkEvent<{
userID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("removeUserIgnore");
/**
* This event is emitted when the viewer signs out.
*/
export const SignOutEvent = createViewerNetworkEvent<{
success: {};
error: {
message: string;
code?: string;
};
}>("signOut");
/**
* This event is emitted when the viewer updates its
* notification settings.
*/
export const UpdateNotificationSettingsEvent = createViewerNetworkEvent<{
onReply?: boolean | null;
onFeatured?: boolean | null;
onStaffReplies?: boolean | null;
onModeration?: boolean | null;
digestFrequency?: DIGEST_FREQUENCY | null;
success: {};
error: {
message: string;
code?: string;
};
}>("updateNotificationSettings");
/**
* This event is emitted when the viewer updates the story settings.
*/
export const UpdateStorySettingsEvent = createViewerNetworkEvent<{
storyID: string;
live?: {
enabled?: boolean | null;
} | null;
moderation?: MODERATION_MODE | null;
premodLinksEnable?: boolean | null;
messageBox?: {
enabled?: boolean | null;
icon?: string | null;
content?: string | null;
} | null;
success: {};
error: {
message: string;
code?: string;
};
}>("updateStorySettings");
/**
* This event is emitted when the viewer closes the story.
*/
export const CloseStoryEvent = createViewerNetworkEvent<{
storyID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("closeStoryEvent");
/**
* This event is emitted when the viewer opens the story.
*/
export const OpenStoryEvent = createViewerNetworkEvent<{
storyID: string;
success: {};
error: {
message: string;
code?: string;
};
}>("openStoryEvent");
/**
* This event is emitted when the viewer loads more
* featured comments.
*/
export const LoadMoreFeaturedCommentsEvent = createViewerNetworkEvent<{
storyID: string;
success: {};
error: { message: string; code?: string };
}>("loadMoreFeaturedComments");
/**
* This event is emitted when the viewer loads more
* top level comments into the comment stream.
*/
export const LoadMoreAllCommentsEvent = createViewerNetworkEvent<{
storyID: string;
success: {};
error: { message: string; code?: string };
}>("loadMoreAllComments");
/**
* This event is emitted when the viewer loads more
* top level comments into the history comment stream.
*/
export const LoadMoreHistoryCommentsEvent = createViewerNetworkEvent<{
success: {};
error: { message: string; code?: string };
}>("loadMoreHistoryComments");
/**
* This event is emitted when the viewer reveals
* all replies of a comment.
*/
export const ShowAllRepliesEvent = createViewerNetworkEvent<{
commentID: string;
success: {};
error: { message: string; code?: string };
}>("showAllReplies");
/**
* This event is emitted when the viewer does an
* action that will prompt a login dialog.
*/
export const LoginPromptEvent = createViewerEvent("loginPrompt");
/**
* This event is emitted when the viewer requests the auth popup.
*/
export const ShowAuthPopupEvent = createViewerEvent<{
view: string;
}>("showAuthPopup");
/**
* This event is emitted when the viewer changes the
* tab of the main tab bar.
*/
export const SetMainTabEvent = createViewerEvent<{
tab: string;
}>("setMainTab");
/**
* This event is emitted when the viewer changes the
* tab of the profile tab bar.
*/
export const SetProfileTabEvent = createViewerEvent<{
tab: string;
}>("setProfileTab");
/**
* This event is emitted when the viewer changes the
* tab of the comments tab bar.
*/
export const SetCommentsTabEvent = createViewerEvent<{
tab: string;
}>("setCommentsTab");
/**
* This event is emitted when the viewer changes the
* sort order of the comments.
*/
export const SetCommentsOrderByEvent = createViewerEvent<{
orderBy: string;
}>("setCommentsOrderBy");
/**
* This event is emitted when the viewer changes to
* the single conversation view.
*/
export const ViewConversationEvent = createViewerEvent<{
from: "FEATURED_COMMENTS" | "COMMENT_STREAM" | "COMMENT_HISTORY";
commentID: string;
}>("viewConversation");
/**
* This event is emitted when the viewer clicks
* on a username which shows the user popover.
*/
export const ShowUserPopoverEvent = createViewerEvent<{
userID: string;
}>("showUserPopover");
/**
* This event is emitted when the viewer clicks
* on the relative timestamp to show the absolute time.
*/
export const ShowAbsoluteTimestampEvent = createViewerEvent(
"showAbsoluteTimestamp"
);
/**
* This event is emitted when the viewer clicks to show the
* featured comment tooltip.
*/
export const ShowFeaturedCommentTooltipEvent = createViewerEvent(
"showFeaturedCommentTooltip"
);
/**
* This event is emitted when the viewer clicks on the sort menu.
*/
export const OpenSortMenuEvent = createViewerEvent("openSortMenu");
/**
* This event is emitted when the viewer focus on the RTE to
* create a comment.
*/
export const CreateCommentFocusEvent = createViewerEvent("createCommentFocus");
/**
* This event is emitted when the viewer focus on the RTE to
* reply to a comment.
*/
export const ReplyCommentFocusEvent = createViewerEvent("replyCommentFocus");
/**
* This event is emitted when the viewer exits the single conversation.
*/
export const ViewFullDiscussionEvent = createViewerEvent<{
commentID: string | null;
}>("viewFullDiscussion");
/**
* This event is emitted when the viewer reveals more of
* the parent conversation thread.
*/
export const ShowMoreOfConversationEvent = createViewerNetworkEvent<{
commentID: string | null;
success: {};
error: { message: string; code?: string };
}>("showMoreOfConversation");
/**
* This event is emitted when the viewer opens the share popover.
*/
export const ShowSharePopoverEvent = createViewerEvent<{
commentID: string;
}>("showSharePopover");
/**
* This event is emitted when the viewer copies the permalink with the button.
*/
export const CopyPermalinkEvent = createViewerEvent<{
commentID: string;
}>("copyPermalink");
/**
* This event is emitted when the viewer opens the report popover.
*/
export const ShowReportPopoverEvent = createViewerEvent<{
commentID: string;
}>("showReportPopover");
/**
* This event is emitted when the viewer reports a comment.
*/
export const ReportCommentEvent = createViewerNetworkEvent<{
reason: string;
commentID: string;
additionalDetails?: string;
success: {};
error: {
message: string;
code?: string;
};
}>("reportComment");
/**
* This event is emitted when the viewer opens the reply form.
*/
export const ShowReplyFormEvent = createViewerEvent<{
commentID: string;
}>("showReplyForm");
/**
* This event is emitted when the viewer opens the edit form.
*/
export const ShowEditFormEvent = createViewerEvent<{
commentID: string;
}>("showEditForm");
/**
* This event is emitted when the viewer reveals
* new live comments.
*/
export const ViewNewCommentsEvent = createViewerEvent<{
storyID: string;
count: number;
}>("viewNewComments");
/**
* This event is emitted when the viewer reveals
* new live replies.
*/
export const ShowMoreRepliesEvent = createViewerEvent<{
commentID: string;
count: number;
}>("showMoreReplies");
/**
* This event is emitted when the viewer opens
* the moderation popover.
*/
export const ShowModerationPopoverEvent = createViewerEvent<{
commentID: string;
}>("showModerationPopover");
/**
* This event is emitted when the viewer goes to
* moderation.
*/
export const GotoModerationEvent = createViewerEvent<{
commentID: string;
}>("gotoModeration");
/**
* This event is emitted when the viewer opens the
* edit username dialog.
*/
export const ShowEditUsernameDialogEvent = createViewerEvent(
"showEditUsernameDialog"
);
/**
* This event is emitted when the viewer opens the
* edit email dialog.
*/
export const ShowEditEmailDialogEvent = createViewerEvent(
"showEditEmailDialog"
);
/**
* This event is emitted when the viewer opens the
* edit password dialog.
*/
export const ShowEditPasswordDialogEvent = createViewerEvent(
"showEditPasswordDialog"
);
/**
* This event is emitted when the viewer opens the
* ignore user dialog.
*/
export const ShowIgnoreUserdDialogEvent = createViewerEvent(
"showIgnoreUserdDialog"
);
/**
* This event is emitted when the viewer request another
* email verification email.
*/
export const ResendEmailVerificationEvent = createViewerNetworkEvent<{
success: {};
error: {
message: string;
code?: string;
};
}>("resendEmailVerification");
/**
* This event is emitted when the viewer changes its username.
*/
export const ChangeUsernameEvent = createViewerNetworkEvent<{
oldUsername: string;
newUsername: string;
success: {};
error: {
message: string;
code?: string;
};
}>("changeUsername");
/**
* This event is emitted when the viewer changes its email.
*/
export const ChangeEmailEvent = createViewerNetworkEvent<{
oldEmail: string;
newEmail: string;
success: {};
error: {
message: string;
code?: string;
};
}>("changeEmail");
/**
* This event is emitted when the viewer changes its password.
*/
export const ChangePasswordEvent = createViewerNetworkEvent<{
success: {};
error: {
message: string;
code?: string;
};
}>("changePassword");
/**
* This event is emitted when the viewer requests to download
* its comment history.
*/
export const RequestDownloadCommentHistoryEvent = createViewerNetworkEvent<{
success: {};
error: {
message: string;
code?: string;
};
}>("requestDownloadCommentHistory");
/**
* This event is emitted when the viewer requests to delete
* its account.
*/
export const RequestAccountDeletionEvent = createViewerNetworkEvent<{
success: {};
error: {
message: string;
code?: string;
};
}>("requestAccountDeletionEvent");
/**
* This event is emitted when the viewer cancels the
* account deletion.
*/
export const CancelAccountDeletionEvent = createViewerNetworkEvent<{
success: {};
error: {
message: string;
code?: string;
};
}>("cancelAccountDeletionEvent");
@@ -7,6 +7,7 @@ import {
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { CancelAccountDeletionEvent } from "coral-stream/events";
import { CancelAccountDeletionMutation as MutationTypes } from "coral-stream/__generated__/CancelAccountDeletionMutation.graphql";
@@ -14,35 +15,55 @@ let clientMutationId = 0;
const CancelAccountDeletionMutation = createMutation(
"cancelAccountDeletionMutation",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation CancelAccountDeletionMutation(
$input: CancelAccountDeletionInput!
) {
cancelAccountDeletion(input: $input) {
user {
scheduledDeletionDate
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }
) => {
const cancelAccountDeletionEvent = CancelAccountDeletionEvent.begin(
eventEmitter
);
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation CancelAccountDeletionMutation(
$input: CancelAccountDeletionInput!
) {
cancelAccountDeletion(input: $input) {
user {
scheduledDeletionDate
}
clientMutationId
}
}
clientMutationId
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
const viewer = getViewer(environment)!;
const viewerProxy = store.get(viewer.id);
if (viewerProxy !== null) {
viewerProxy.setValue(null, "scheduledDeletionDate");
}
},
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
const viewer = getViewer(environment)!;
const viewerProxy = store.get(viewer.id);
if (viewerProxy !== null) {
viewerProxy.setValue(null, "scheduledDeletionDate");
}
},
})
);
cancelAccountDeletionEvent.success();
return result;
} catch (error) {
cancelAccountDeletionEvent.error({
message: error.message,
code: error.code,
});
throw error;
}
}
);
export default CancelAccountDeletionMutation;
@@ -1,4 +1,6 @@
import { EventEmitter2 } from "eventemitter2";
import { Environment, RecordSource } from "relay-runtime";
import sinon from "sinon";
import { createRelayEnvironment } from "coral-framework/testHelpers";
@@ -6,9 +8,10 @@ import { AUTH_POPUP_ID, AUTH_POPUP_TYPE } from "../local";
import { commit } from "./ShowAuthPopupMutation";
let environment: Environment;
const source: RecordSource = new RecordSource();
let source: RecordSource;
beforeAll(() => {
beforeEach(() => {
source = new RecordSource();
environment = createRelayEnvironment({
source,
initLocalState: (localRecord, sourceProxy) => {
@@ -20,23 +23,36 @@ beforeAll(() => {
});
});
it("opens popup", () => {
commit(environment, { view: "SIGN_IN" });
it("emits ShowAuthPopupEvent and LoginPromptEvent on SIGN_IN", () => {
const view = "SIGN_IN";
const eventEmitter = new EventEmitter2();
const mock = sinon.mock(eventEmitter);
mock.expects("emit").withArgs("loginPrompt");
mock.expects("emit").withArgs("showAuthPopup", { view });
commit(environment, { view }, { eventEmitter });
mock.verify();
});
it("emits only ShowAuthPopupEvent on other views", () => {
const view = "FORGOT_PASSWORD";
const eventEmitter = new EventEmitter2();
const mock = sinon.mock(eventEmitter);
mock.expects("emit").withArgs("showAuthPopup", { view });
commit(environment, { view }, { eventEmitter });
mock.verify();
});
it("opens popup or focus if already open", () => {
const view = "SIGN_IN";
const context = {
eventEmitter: new EventEmitter2(),
};
commit(environment, { view }, context);
expect(source.get(AUTH_POPUP_ID)!.open).toEqual(true);
expect(source.get(AUTH_POPUP_ID)!.focus).toEqual(false);
expect(source.get(AUTH_POPUP_ID)!.view).toEqual("SIGN_IN");
});
it("focuses popup", () => {
commit(environment, { view: "SIGN_IN" });
commit(environment, { view }, context);
expect(source.get(AUTH_POPUP_ID)!.open).toEqual(true);
expect(source.get(AUTH_POPUP_ID)!.focus).toEqual(true);
expect(source.get(AUTH_POPUP_ID)!.view).toEqual("SIGN_IN");
});
it("only change view when opened and focused", () => {
commit(environment, { view: "FORGOT_PASSWORD" });
expect(source.get(AUTH_POPUP_ID)!.open).toEqual(true);
expect(source.get(AUTH_POPUP_ID)!.focus).toEqual(true);
expect(source.get(AUTH_POPUP_ID)!.view).toEqual("FORGOT_PASSWORD");
});
@@ -1,9 +1,11 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
createMutation,
createMutationContainer,
} from "coral-framework/lib/relay";
import { LoginPromptEvent, ShowAuthPopupEvent } from "coral-stream/events";
import { AUTH_POPUP_ID } from "../local";
@@ -17,8 +19,13 @@ export type ShowAuthPopupMutation = (
export async function commit(
environment: Environment,
input: ShowAuthPopupInput
input: ShowAuthPopupInput,
{ eventEmitter }: Pick<CoralContext, "eventEmitter">
) {
if (input.view === "SIGN_IN") {
LoginPromptEvent.emit(eventEmitter);
}
ShowAuthPopupEvent.emit(eventEmitter, { view: input.view });
return commitLocalUpdate(environment, store => {
const record = store.get(AUTH_POPUP_ID)!;
record.setValue(input.view, "view");
@@ -0,0 +1,23 @@
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import { createMutation } from "coral-framework/lib/relay";
import { commit as signOut } from "coral-framework/mutations/SignOutMutation";
import { SignOutEvent } from "coral-stream/events";
const SignOutMutation = createMutation(
"signOut",
async (environment: Environment, input: undefined, ctx: CoralContext) => {
const signOutEvent = SignOutEvent.begin(ctx.eventEmitter);
try {
await signOut(environment, input, ctx);
signOutEvent.success();
} catch (error) {
signOutEvent.error({ message: error.message, code: error.code });
throw error;
}
}
);
export default SignOutMutation;
@@ -7,3 +7,4 @@ export {
withShowAuthPopupMutation,
ShowAuthPopupMutation,
} from "./ShowAuthPopupMutation";
export { default as SignOutMutation } from "./SignOutMutation";
@@ -3,7 +3,8 @@ import React, { FunctionComponent } from "react";
import CLASSES from "coral-stream/classes";
import HTMLContent from "coral-stream/common/HTMLContent";
import { Flex, HorizontalGutter, Timestamp } from "coral-ui/components";
import Timestamp from "coral-stream/common/Timestamp";
import { Flex, HorizontalGutter } from "coral-ui/components";
import EditedMarker from "./EditedMarker";
import InReplyTo from "./InReplyTo";
@@ -1,3 +1,4 @@
import { EventEmitter2 } from "eventemitter2";
import { noop } from "lodash";
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
@@ -16,6 +17,7 @@ type Props = PropTypesOf<typeof CommentContainerN>;
function createDefaultProps(add: DeepPartial<Props> = {}): Props {
return pureMerge(
{
eventEmitter: new EventEmitter2(),
viewer: null,
story: {
url: "http://localhost/story",
@@ -1,14 +1,21 @@
import cn from "classnames";
import { EventEmitter2 } from "eventemitter2";
import { Localized } from "fluent-react/compat";
import React, { Component, MouseEvent } from "react";
import { graphql } from "react-relay";
import { isBeforeDate } from "coral-common/utils";
import { getURLWithCommentID } from "coral-framework/helpers";
import { withContext } from "coral-framework/lib/bootstrap";
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
import { GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import {
ShowEditFormEvent,
ShowReplyFormEvent,
ViewConversationEvent,
} from "coral-stream/events";
import {
SetCommentIDMutation,
ShowAuthPopupMutation,
@@ -45,6 +52,7 @@ interface Props {
comment: CommentData;
story: StoryData;
settings: SettingsData;
eventEmitter: EventEmitter2;
indentLevel?: number;
showAuthPopup: ShowAuthPopupMutation;
setCommentID: SetCommentIDMutation;
@@ -111,9 +119,16 @@ export class CommentContainer extends Component<Props, State> {
private toggleReplyDialog = () => {
if (this.props.viewer) {
this.setState(state => ({
showReplyDialog: !state.showReplyDialog,
}));
this.setState(state => {
if (!state.showReplyDialog) {
ShowReplyFormEvent.emit(this.props.eventEmitter, {
commentID: this.props.comment.id,
});
}
return {
showReplyDialog: !state.showReplyDialog,
};
});
} else {
this.props.showAuthPopup({ view: "SIGN_IN" });
}
@@ -121,6 +136,9 @@ export class CommentContainer extends Component<Props, State> {
private openEditDialog = () => {
if (this.props.viewer) {
ShowEditFormEvent.emit(this.props.eventEmitter, {
commentID: this.props.comment.id,
});
this.setState(state => ({
showEditDialog: true,
}));
@@ -151,6 +169,10 @@ export class CommentContainer extends Component<Props, State> {
}
private handleShowConversation = (e: MouseEvent) => {
ViewConversationEvent.emit(this.props.eventEmitter, {
commentID: this.props.comment.id,
from: "COMMENT_STREAM",
});
e.preventDefault();
this.props.setCommentID({ id: this.props.comment.id });
return false;
@@ -368,85 +390,87 @@ export class CommentContainer extends Component<Props, State> {
}
}
const enhanced = withSetCommentIDMutation(
withShowAuthPopupMutation(
withFragmentContainer<Props>({
viewer: graphql`
fragment CommentContainer_viewer on User {
id
status {
current
}
ignoredUsers {
const enhanced = withContext(({ eventEmitter }) => ({ eventEmitter }))(
withSetCommentIDMutation(
withShowAuthPopupMutation(
withFragmentContainer<Props>({
viewer: graphql`
fragment CommentContainer_viewer on User {
id
status {
current
}
ignoredUsers {
id
}
badges
role
scheduledDeletionDate
...UsernameWithPopoverContainer_viewer
...ReactionButtonContainer_viewer
...ReportButtonContainer_viewer
...CaretContainer_viewer
}
badges
role
scheduledDeletionDate
...UsernameWithPopoverContainer_viewer
...ReactionButtonContainer_viewer
...ReportButtonContainer_viewer
...CaretContainer_viewer
}
`,
story: graphql`
fragment CommentContainer_story on Story {
url
isClosed
...CaretContainer_story
...ReplyCommentFormContainer_story
...PermalinkButtonContainer_story
...EditCommentFormContainer_story
}
`,
comment: graphql`
fragment CommentContainer_comment on Comment {
id
author {
...UsernameWithPopoverContainer_user
`,
story: graphql`
fragment CommentContainer_story on Story {
url
isClosed
...CaretContainer_story
...ReplyCommentFormContainer_story
...PermalinkButtonContainer_story
...EditCommentFormContainer_story
}
`,
comment: graphql`
fragment CommentContainer_comment on Comment {
id
username
}
parent {
author {
...UsernameWithPopoverContainer_user
id
username
}
parent {
author {
username
}
}
body
createdAt
status
editing {
edited
editableUntil
}
tags {
code
}
pending
lastViewerAction
deleted
...ReplyCommentFormContainer_comment
...EditCommentFormContainer_comment
...ReactionButtonContainer_comment
...ReportButtonContainer_comment
...CaretContainer_comment
...RejectedTombstoneContainer_comment
...AuthorBadgesContainer_comment
...UserTagsContainer_comment
}
body
createdAt
status
editing {
edited
editableUntil
`,
settings: graphql`
fragment CommentContainer_settings on Settings {
disableCommenting {
enabled
}
...ReactionButtonContainer_settings
...ReplyCommentFormContainer_settings
...EditCommentFormContainer_settings
...UserTagsContainer_settings
}
tags {
code
}
pending
lastViewerAction
deleted
...ReplyCommentFormContainer_comment
...EditCommentFormContainer_comment
...ReactionButtonContainer_comment
...ReportButtonContainer_comment
...CaretContainer_comment
...RejectedTombstoneContainer_comment
...AuthorBadgesContainer_comment
...UserTagsContainer_comment
}
`,
settings: graphql`
fragment CommentContainer_settings on Settings {
disableCommenting {
enabled
}
...ReactionButtonContainer_settings
...ReplyCommentFormContainer_settings
...EditCommentFormContainer_settings
...UserTagsContainer_settings
}
`,
})(CommentContainer)
`,
})(CommentContainer)
)
)
);
@@ -6,6 +6,7 @@ import { Field, Form } from "react-final-form";
import { OnSubmit } from "coral-framework/lib/form";
import CLASSES from "coral-stream/classes";
import Timestamp from "coral-stream/common/Timestamp";
import ValidationMessage from "coral-stream/common/ValidationMessage";
import {
AriaInfo,
@@ -16,7 +17,6 @@ import {
Message,
MessageIcon,
RelativeTime,
Timestamp,
} from "coral-ui/components";
import { cleanupRTEEmptyHTML, getCommentBodyValidators } from "../../helpers";
@@ -13,6 +13,7 @@ import {
import { GQLComment } from "coral-framework/schema";
import { EditCommentMutation as MutationTypes } from "coral-stream/__generated__/EditCommentMutation.graphql";
import { EditCommentEvent } from "coral-stream/events";
export type EditCommentInput = MutationInput<MutationTypes>;
@@ -36,39 +37,53 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(
async function commit(
environment: Environment,
input: EditCommentInput,
{ uuidGenerator }: CoralContext
{ uuidGenerator, eventEmitter }: CoralContext
) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...pick(input, ["commentID", "body"]),
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
editComment: {
comment: {
id: input.commentID,
body: input.body,
status: lookup<GQLComment>(environment, input.commentID)!.status,
revision: {
id: uuidGenerator(),
},
editing: {
edited: true,
const editCommentEvent = EditCommentEvent.begin(eventEmitter, {
body: input.body,
commentID: input.commentID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
...pick(input, ["commentID", "body"]),
clientMutationId: clientMutationId.toString(),
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
store.get(input.commentID)!.setValue("EDIT", "lastViewerAction");
},
});
optimisticResponse: {
editComment: {
comment: {
id: input.commentID,
body: input.body,
status: lookup<GQLComment>(environment, input.commentID)!.status,
revision: {
id: uuidGenerator(),
},
editing: {
edited: true,
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
store.get(input.commentID)!.setValue("EDIT", "lastViewerAction");
},
}
);
editCommentEvent.success({ status: result.comment.status });
return result;
} catch (error) {
editCommentEvent.error({ message: error.message, code: error.code });
throw error;
}
}
export const withEditCommentMutation = createMutationContainer(
@@ -1,12 +1,14 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLCOMMENT_STATUS } from "coral-framework/schema";
import { ApproveCommentEvent } from "coral-stream/events";
import { ApproveCommentMutation as MutationTypes } from "coral-stream/__generated__/ApproveCommentMutation.graphql";
@@ -14,37 +16,55 @@ let clientMutationId = 0;
const ApproveCommentMutation = createMutation(
"approveComment",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation ApproveCommentMutation($input: ApproveCommentInput!) {
approveComment(input: $input) {
comment {
status
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }: CoralContext
) => {
const approveCommentEvent = ApproveCommentEvent.begin(eventEmitter, {
commentID: input.commentID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation ApproveCommentMutation($input: ApproveCommentInput!) {
approveComment(input: $input) {
comment {
status
}
clientMutationId
}
}
clientMutationId
}
}
`,
optimisticResponse: {
approveComment: {
comment: {
id: input.commentID,
status: GQLCOMMENT_STATUS.APPROVED,
`,
optimisticResponse: {
approveComment: {
comment: {
id: input.commentID,
status: GQLCOMMENT_STATUS.APPROVED,
},
clientMutationId: clientMutationId.toString(),
},
},
clientMutationId: clientMutationId.toString(),
},
},
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
store.get(input.commentID)!.setValue("APPROVE", "lastViewerAction");
},
})
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
store.get(input.commentID)!.setValue("APPROVE", "lastViewerAction");
},
}
);
approveCommentEvent.success();
return result;
} catch (error) {
approveCommentEvent.error({ message: error.message, code: error.code });
throw error;
}
}
);
export default ApproveCommentMutation;
@@ -8,6 +8,7 @@ import {
MutationInput,
} from "coral-framework/lib/relay";
import { GQLCOMMENT_STATUS, GQLTAG } from "coral-framework/schema";
import { FeatureCommentEvent } from "coral-stream/events";
import { FeatureCommentMutation as MutationTypes } from "coral-stream/__generated__/FeatureCommentMutation.graphql";
@@ -30,47 +31,61 @@ function incrementCount(store: RecordSourceSelectorProxy, storyID: string) {
const FeatureCommentMutation = createMutation(
"featureComment",
(
async (
environment: Environment,
input: MutationInput<MutationTypes> & { storyID: string },
{ uuidGenerator }: CoralContext
) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation FeatureCommentMutation($input: FeatureCommentInput!) {
featureComment(input: $input) {
comment {
tags {
code
{ uuidGenerator, eventEmitter }: CoralContext
) => {
const featuredCommentEvent = FeatureCommentEvent.begin(eventEmitter, {
commentID: input.commentID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation FeatureCommentMutation($input: FeatureCommentInput!) {
featureComment(input: $input) {
comment {
tags {
code
}
status
}
clientMutationId
}
status
}
clientMutationId
}
`,
optimisticUpdater: store => {
const comment = store.get(input.commentID)!;
const tags = comment.getLinkedRecords("tags");
if (tags) {
const newTag = store.create(uuidGenerator(), "Tag");
newTag.setValue(GQLTAG.FEATURED, "code");
comment.setLinkedRecords(tags.concat(newTag), "tags");
comment.setValue(GQLCOMMENT_STATUS.APPROVED, "status");
}
incrementCount(store, input.storyID);
},
updater: store => {
incrementCount(store, input.storyID);
},
variables: {
input: {
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
clientMutationId: (clientMutationId++).toString(),
},
},
}
`,
optimisticUpdater: store => {
const comment = store.get(input.commentID)!;
const tags = comment.getLinkedRecords("tags");
if (tags) {
const newTag = store.create(uuidGenerator(), "Tag");
newTag.setValue(GQLTAG.FEATURED, "code");
comment.setLinkedRecords(tags.concat(newTag), "tags");
comment.setValue(GQLCOMMENT_STATUS.APPROVED, "status");
}
incrementCount(store, input.storyID);
},
updater: store => {
incrementCount(store, input.storyID);
},
variables: {
input: {
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
featuredCommentEvent.success();
return result;
} catch (error) {
featuredCommentEvent.error({ message: error.message, code: error.code });
throw error;
}
}
);
export default FeatureCommentMutation;
@@ -3,8 +3,10 @@ import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { graphql } from "react-relay";
import { useViewerEvent } from "coral-framework/lib/events";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import { GotoModerationEvent } from "coral-stream/events";
import { DropdownButton, DropdownDivider, Icon } from "coral-ui/components";
import { ModerationActionsContainer_comment } from "coral-stream/__generated__/ModerationActionsContainer_comment.graphql";
@@ -34,11 +36,16 @@ const ModerationActionsContainer: FunctionComponent<Props> = ({
onDismiss,
onBan,
}) => {
const emitGotoModerationEvent = useViewerEvent(GotoModerationEvent);
const approve = useMutation(ApproveCommentMutation);
const feature = useMutation(FeatureCommentMutation);
const unfeature = useMutation(UnfeatureCommentMutation);
const reject = useMutation(RejectCommentMutation);
const onGotoModerate = useCallback(() => {
emitGotoModerationEvent({ commentID: comment.id });
}, [emitGotoModerationEvent, comment.id]);
const onApprove = useCallback(() => {
if (!comment.revision) {
return;
@@ -52,7 +59,6 @@ const ModerationActionsContainer: FunctionComponent<Props> = ({
await reject({
commentID: comment.id,
commentRevisionID: comment.revision.id,
storyID: story.id,
});
}, [approve, comment, story]);
const onFeature = useCallback(() => {
@@ -193,6 +199,7 @@ const ModerationActionsContainer: FunctionComponent<Props> = ({
className={CLASSES.moderationDropdown.goToModerateButton}
href={`/admin/moderate/comment/${comment.id}`}
target="_blank"
onClick={onGotoModerate}
anchor
>
Go to Moderate
@@ -1,8 +1,15 @@
import React, { FunctionComponent, useCallback, useState } from "react";
import React, {
FunctionComponent,
useCallback,
useEffect,
useState,
} from "react";
import { graphql } from "react-relay";
import { useViewerEvent } from "coral-framework/lib/events";
import { withFragmentContainer } from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import { ShowModerationPopoverEvent } from "coral-stream/events";
import { Dropdown } from "coral-ui/components";
import { ModerationDropdownContainer_comment } from "coral-stream/__generated__/ModerationDropdownContainer_comment.graphql";
@@ -29,12 +36,18 @@ const ModerationDropdownContainer: FunctionComponent<Props> = ({
onDismiss,
scheduleUpdate,
}) => {
const emitShowEvent = useViewerEvent(ShowModerationPopoverEvent);
const [view, setView] = useState<View>("MODERATE");
const onBan = useCallback(() => {
setView("BAN");
scheduleUpdate();
}, [setView, scheduleUpdate]);
// run once.
useEffect(() => {
emitShowEvent({ commentID: comment.id });
}, []);
return (
<div>
{view === "MODERATE" ? (
@@ -1,12 +1,14 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLCOMMENT_STATUS } from "coral-framework/schema";
import { RejectCommentEvent } from "coral-stream/events";
import { RejectCommentMutation as MutationTypes } from "coral-stream/__generated__/RejectCommentMutation.graphql";
@@ -14,58 +16,82 @@ let clientMutationId = 0;
const RejectCommentMutation = createMutation(
"rejectComment",
(
async (
environment: Environment,
input: MutationInput<MutationTypes> & { storyID: string }
) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RejectCommentMutation($input: RejectCommentInput!) {
rejectComment(input: $input) {
comment {
status
tags {
code
}
story {
commentCounts {
input: MutationInput<MutationTypes> & { noEmit?: boolean },
{ eventEmitter }: CoralContext
) => {
let rejectCommentEvent: ReturnType<
typeof RejectCommentEvent.begin
> | null = null;
if (!input.noEmit) {
rejectCommentEvent = RejectCommentEvent.begin(eventEmitter, {
commentID: input.commentID,
});
}
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation RejectCommentMutation($input: RejectCommentInput!) {
rejectComment(input: $input) {
comment {
status
tags {
FEATURED
code
}
story {
commentCounts {
tags {
FEATURED
}
}
}
}
clientMutationId
}
}
clientMutationId
}
}
`,
variables: {
input: {
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticResponse: {
rejectComment: {
comment: {
id: input.commentID,
status: GQLCOMMENT_STATUS.REJECTED,
story: {
commentCounts: {
tags: {
FEATURED: 0,
},
},
`,
variables: {
input: {
commentID: input.commentID,
commentRevisionID: input.commentRevisionID,
clientMutationId: (clientMutationId++).toString(),
},
},
clientMutationId: clientMutationId.toString(),
},
},
updater: store => {
store.get(input.commentID)!.setValue("REJECT", "lastViewerAction");
},
})
optimisticResponse: {
rejectComment: {
comment: {
id: input.commentID,
status: GQLCOMMENT_STATUS.REJECTED,
story: {
commentCounts: {
tags: {
FEATURED: 0,
},
},
},
},
clientMutationId: clientMutationId.toString(),
},
},
updater: store => {
store.get(input.commentID)!.setValue("REJECT", "lastViewerAction");
},
}
);
if (rejectCommentEvent) {
rejectCommentEvent.success();
}
return result;
} catch (error) {
if (rejectCommentEvent) {
rejectCommentEvent.error({ message: error.message, code: error.code });
}
throw error;
}
}
);
export default RejectCommentMutation;
@@ -1,12 +1,14 @@
import { graphql } from "react-relay";
import { Environment, RecordSourceSelectorProxy } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { GQLTAG } from "coral-framework/schema";
import { UnfeatureCommentEvent } from "coral-stream/events";
import { UnfeatureCommentMutation as MutationTypes } from "coral-stream/__generated__/UnfeatureCommentMutation.graphql";
@@ -29,42 +31,60 @@ function decrementCount(store: RecordSourceSelectorProxy, storyID: string) {
const UnfeatureCommentMutation = createMutation(
"unfeatureComment",
(
async (
environment: Environment,
input: MutationInput<MutationTypes> & { storyID: string }
) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UnfeatureCommentMutation($input: UnfeatureCommentInput!) {
unfeatureComment(input: $input) {
comment {
tags {
code
input: MutationInput<MutationTypes> & { storyID: string },
{ eventEmitter }: CoralContext
) => {
const unfeaturedCommentEvent = UnfeatureCommentEvent.begin(eventEmitter, {
commentID: input.commentID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation UnfeatureCommentMutation($input: UnfeatureCommentInput!) {
unfeatureComment(input: $input) {
comment {
tags {
code
}
}
clientMutationId
}
}
clientMutationId
}
`,
optimisticUpdater: store => {
const comment = store.get(input.commentID)!;
const tags = comment.getLinkedRecords("tags")!;
comment.setLinkedRecords(
tags.filter(t => t!.getValue("code") === GQLTAG.FEATURED),
"tags"
);
decrementCount(store, input.storyID);
},
updater: store => {
decrementCount(store, input.storyID);
},
variables: {
input: {
commentID: input.commentID,
clientMutationId: (clientMutationId++).toString(),
},
},
}
`,
optimisticUpdater: store => {
const comment = store.get(input.commentID)!;
const tags = comment.getLinkedRecords("tags")!;
comment.setLinkedRecords(
tags.filter(t => t!.getValue("code") === GQLTAG.FEATURED),
"tags"
);
decrementCount(store, input.storyID);
},
updater: store => {
decrementCount(store, input.storyID);
},
variables: {
input: {
commentID: input.commentID,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
unfeaturedCommentEvent.success();
return result;
} catch (error) {
unfeaturedCommentEvent.error({
message: error.message,
code: error.code,
});
throw error;
}
}
);
export default UnfeatureCommentMutation;
@@ -34,7 +34,7 @@ const Permalink: FunctionComponent<PermalinkProps> = ({
classes={{ popover: styles.popover }}
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<PermalinkPopover permalinkURL={url} />
<PermalinkPopover permalinkURL={url} commentID={commentID} />
</ClickOutside>
)}
>
@@ -1,36 +1,50 @@
import cn from "classnames";
import React from "react";
import React, { FunctionComponent, useCallback, useEffect } from "react";
import { CopyButton } from "coral-framework/components";
import { useViewerEvent } from "coral-framework/lib/events";
import CLASSES from "coral-stream/classes";
import { CopyPermalinkEvent, ShowSharePopoverEvent } from "coral-stream/events";
import { Flex, TextField } from "coral-ui/components";
import styles from "./PermalinkPopover.css";
interface Props {
permalinkURL: string;
commentID: string;
}
class PermalinkPopover extends React.Component<Props> {
public render() {
const { permalinkURL } = this.props;
return (
<Flex
itemGutter="half"
className={cn(styles.root, CLASSES.sharePopover.$root)}
>
<TextField
defaultValue={permalinkURL}
className={styles.textField}
readOnly
/>
<CopyButton
text={permalinkURL}
className={CLASSES.sharePopover.copyButton}
/>
</Flex>
);
}
}
const PermalinkPopover: FunctionComponent<Props> = ({
permalinkURL,
commentID,
}) => {
const emitShowEvent = useViewerEvent(ShowSharePopoverEvent);
const emitCopyEvent = useViewerEvent(CopyPermalinkEvent);
const onButtonClick = useCallback(() => emitCopyEvent({ commentID }), [
emitCopyEvent,
commentID,
]);
// Run once.
useEffect(() => {
emitShowEvent({ commentID });
}, []);
return (
<Flex
itemGutter="half"
className={cn(styles.root, CLASSES.sharePopover.$root)}
>
<TextField
defaultValue={permalinkURL}
className={styles.textField}
readOnly
/>
<CopyButton
onClick={onButtonClick}
text={permalinkURL}
className={CLASSES.sharePopover.copyButton}
/>
</Flex>
);
};
export default PermalinkPopover;
@@ -2,6 +2,7 @@ import { pick } from "lodash";
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutationContainer,
@@ -12,6 +13,7 @@ import {
import { GQLComment } from "coral-framework/schema";
import { CreateCommentReactionMutation as MutationTypes } from "coral-stream/__generated__/CreateCommentReactionMutation.graphql";
import { CreateCommentReactionEvent } from "coral-stream/events";
export type CreateCommentReactionInput = MutationInput<MutationTypes>;
@@ -28,43 +30,67 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(environment: Environment, input: CreateCommentReactionInput) {
async function commit(
environment: Environment,
input: CreateCommentReactionInput,
{ eventEmitter }: Pick<CoralContext, "eventEmitter">
) {
const source = environment.getStore().getSource();
const currentCount = source.get(
source.get(source.get(input.commentID)!.actionCounts.__ref)!.reaction.__ref
)!.total;
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...pick(input, ["commentID", "commentRevisionID"]),
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
createCommentReaction: {
comment: {
id: input.commentID,
viewerActionPresence: {
reaction: true,
const createCommentReactionEvent = CreateCommentReactionEvent.begin(
eventEmitter,
{
commentID: input.commentID,
}
);
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
...pick(input, ["commentID", "commentRevisionID"]),
clientMutationId: clientMutationId.toString(),
},
revision: {
// comment revision should not be null since we just
// reacted to it, revision can only be null when user
// deletes their account and thus all their comments
id: lookup<GQLComment>(environment, input.commentID)!.revision!.id,
},
optimisticResponse: {
createCommentReaction: {
comment: {
id: input.commentID,
viewerActionPresence: {
reaction: true,
},
revision: {
// comment revision should not be null since we just
// reacted to it, revision can only be null when user
// deletes their account and thus all their comments
id: lookup<GQLComment>(environment, input.commentID)!.revision!
.id,
},
actionCounts: {
reaction: {
total: currentCount + 1,
},
},
} as any,
clientMutationId: (clientMutationId++).toString(),
},
actionCounts: {
reaction: {
total: currentCount + 1,
},
},
} as any,
clientMutationId: (clientMutationId++).toString(),
},
},
});
},
}
);
createCommentReactionEvent.success();
return result;
} catch (error) {
createCommentReactionEvent.error({
message: error.message,
code: error.code,
});
throw error;
}
}
export const withCreateCommentReactionMutation = createMutationContainer(
@@ -2,6 +2,7 @@ import { pick } from "lodash";
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutationContainer,
@@ -10,6 +11,7 @@ import {
MutationResponsePromise,
} from "coral-framework/lib/relay";
import { GQLComment } from "coral-framework/schema";
import { RemoveCommentReactionEvent } from "coral-stream/events";
import { RemoveCommentReactionMutation as MutationTypes } from "coral-stream/__generated__/RemoveCommentReactionMutation.graphql";
@@ -28,41 +30,66 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(environment: Environment, input: RemoveCommentReactionInput) {
async function commit(
environment: Environment,
input: RemoveCommentReactionInput,
{ eventEmitter }: Pick<CoralContext, "eventEmitter">
) {
const source = environment.getStore().getSource();
const currentCount = source.get(
source.get(source.get(input.commentID)!.actionCounts.__ref)!.reaction.__ref
)!.total;
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...pick(input, ["commentID"]),
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticResponse: {
removeCommentReaction: {
comment: {
id: input.commentID,
viewerActionPresence: {
reaction: false,
const removeCommentReactionEvent = RemoveCommentReactionEvent.begin(
eventEmitter,
{
commentID: input.commentID,
}
);
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
...pick(input, ["commentID"]),
clientMutationId: (clientMutationId++).toString(),
},
revision: {
// Can assume revision exists since we just selected
// to remove the reaction to it.
id: lookup<GQLComment>(environment, input.commentID)!.revision!.id,
},
optimisticResponse: {
removeCommentReaction: {
comment: {
id: input.commentID,
viewerActionPresence: {
reaction: false,
},
revision: {
// Can assume revision exists since we just selected
// to remove the reaction to it.
id: lookup<GQLComment>(environment, input.commentID)!.revision!
.id,
},
actionCounts: {
reaction: {
total: currentCount - 1,
},
},
} as any,
clientMutationId: clientMutationId.toString(),
},
actionCounts: {
reaction: {
total: currentCount - 1,
},
},
} as any,
clientMutationId: clientMutationId.toString(),
},
},
});
},
}
);
removeCommentReactionEvent.success();
return result;
} catch (error) {
removeCommentReactionEvent.error({
message: error.message,
code: error.code,
});
throw error;
}
}
export const withRemoveCommentReactionMutation = createMutationContainer(
@@ -17,6 +17,7 @@ import {
MutationResponsePromise,
} from "coral-framework/lib/relay";
import { GQLComment, GQLStory, GQLUSER_ROLE } from "coral-framework/schema";
import { CreateCommentReplyEvent } from "coral-stream/events";
import { CreateCommentReplyMutation as MutationTypes } from "coral-stream/__generated__/CreateCommentReplyMutation.graphql";
@@ -142,10 +143,10 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(
async function commit(
environment: Environment,
input: CreateCommentReplyInput,
{ uuidGenerator, relayEnvironment }: CoralContext
{ uuidGenerator, relayEnvironment, eventEmitter }: CoralContext
) {
const parentComment = lookup<GQLComment>(environment, input.parentID)!;
const viewer = getViewer(environment)!;
@@ -162,82 +163,100 @@ function commit(
!roleIsAtLeast(viewer.role, GQLUSER_ROLE.STAFF) &&
storySettings.moderation === "PRE";
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
storyID: input.storyID,
parentID: input.parentID,
parentRevisionID: input.parentRevisionID,
body: input.body,
nudge: input.nudge,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
createCommentReply: {
edge: {
cursor: currentDate,
node: {
id,
createdAt: currentDate,
status: "NONE",
author: {
id: viewer.id,
username: viewer.username,
createdAt: viewer.createdAt,
badges: viewer.badges,
ignoreable: false,
},
const createCommentReplyEvent = CreateCommentReplyEvent.begin(eventEmitter, {
body: input.body,
parentID: input.parentID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
storyID: input.storyID,
parentID: input.parentID,
parentRevisionID: input.parentRevisionID,
body: input.body,
revision: {
id: uuidGenerator(),
},
parent: {
id: parentComment.id,
author: parentComment.author
? pick(parentComment.author, "username", "id")
: null,
},
editing: {
editableUntil: new Date(Date.now() + 10000).toISOString(),
edited: false,
},
actionCounts: {
reaction: {
total: 0,
},
},
tags: roleIsAtLeast(viewer.role, GQLUSER_ROLE.STAFF)
? [{ code: "STAFF" }]
: [],
viewerActionPresence: {
reaction: false,
dontAgree: false,
flag: false,
},
replies: {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
deleted: false,
nudge: input.nudge,
clientMutationId: clientMutationId.toString(),
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
// Skip optimistic update if comment is probably premoderated.
if (expectPremoderation) {
return;
optimisticResponse: {
createCommentReply: {
edge: {
cursor: currentDate,
node: {
id,
createdAt: currentDate,
status: "NONE",
author: {
id: viewer.id,
username: viewer.username,
createdAt: viewer.createdAt,
badges: viewer.badges,
ignoreable: false,
},
body: input.body,
revision: {
id: uuidGenerator(),
},
parent: {
id: parentComment.id,
author: parentComment.author
? pick(parentComment.author, "username", "id")
: null,
},
editing: {
editableUntil: new Date(Date.now() + 10000).toISOString(),
edited: false,
},
actionCounts: {
reaction: {
total: 0,
},
},
tags: roleIsAtLeast(viewer.role, GQLUSER_ROLE.STAFF)
? [{ code: "STAFF" }]
: [],
viewerActionPresence: {
reaction: false,
dontAgree: false,
flag: false,
},
replies: {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
deleted: false,
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
// Skip optimistic update if comment is probably premoderated.
if (expectPremoderation) {
return;
}
sharedUpdater(environment, store, input);
store.get(id)!.setValue(true, "pending");
},
updater: store => {
sharedUpdater(environment, store, input);
},
}
sharedUpdater(environment, store, input);
store.get(id)!.setValue(true, "pending");
},
updater: store => {
sharedUpdater(environment, store, input);
},
});
);
createCommentReplyEvent.success({
id: result.edge.node.id,
status: result.edge.node.status,
});
return result;
} catch (error) {
createCommentReplyEvent.error({ message: error.message, code: error.code });
throw error;
}
}
export const withCreateCommentReplyMutation = createMutationContainer(
@@ -2,12 +2,20 @@ import { CoralRTE } from "@coralproject/rte";
import cn from "classnames";
import { FormApi, FormState } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { EventHandler, FunctionComponent, MouseEvent, Ref } from "react";
import React, {
EventHandler,
FunctionComponent,
MouseEvent,
Ref,
useCallback,
} from "react";
import { Field, Form, FormSpy } from "react-final-form";
import { useViewerEvent } from "coral-framework/lib/events";
import { OnSubmit } from "coral-framework/lib/form";
import CLASSES from "coral-stream/classes";
import ValidationMessage from "coral-stream/common/ValidationMessage";
import { ReplyCommentFocusEvent } from "coral-stream/events";
import {
AriaInfo,
Button,
@@ -42,6 +50,10 @@ export interface ReplyCommentFormProps {
const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = props => {
const inputID = `comments-replyCommentForm-rte-${props.id}`;
const emitFocusEvent = useViewerEvent(ReplyCommentFocusEvent);
const onFocus = useCallback(() => {
emitFocusEvent();
}, [emitFocusEvent]);
return (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, form, submitError }) => (
@@ -78,6 +90,7 @@ const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = props => {
>
<RTE
inputId={inputID}
onFocus={onFocus}
onChange={({ html }) =>
input.onChange(cleanupRTEEmptyHTML(html))
}
@@ -2,12 +2,14 @@ import { pick } from "lodash";
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutationContainer,
MutationInput,
MutationResponsePromise,
} from "coral-framework/lib/relay";
import { ReportCommentEvent } from "coral-stream/events";
import { CreateCommentDontAgreeMutation as MutationTypes } from "coral-stream/__generated__/CreateCommentDontAgreeMutation.graphql";
@@ -31,16 +33,39 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(environment: Environment, input: CreateCommentDontAgreeInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...pick(input, ["commentID", "commentRevisionID", "additionalDetails"]),
clientMutationId: (clientMutationId++).toString(),
},
},
async function commit(
environment: Environment,
input: CreateCommentDontAgreeInput,
{ eventEmitter }: CoralContext
) {
const reportCommentEvent = ReportCommentEvent.begin(eventEmitter, {
reason: "DONT_AGREE",
additionalDetails: input.additionalDetails || undefined,
commentID: input.commentID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
...pick(input, [
"commentID",
"commentRevisionID",
"additionalDetails",
]),
clientMutationId: (clientMutationId++).toString(),
},
},
}
);
reportCommentEvent.success();
return result;
} catch (error) {
reportCommentEvent.error({ message: error.message, code: error.code });
throw error;
}
}
export const withCreateCommentDontAgreeMutation = createMutationContainer(
@@ -2,12 +2,14 @@ import { pick } from "lodash";
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutationContainer,
MutationInput,
MutationResponsePromise,
} from "coral-framework/lib/relay";
import { ReportCommentEvent } from "coral-stream/events";
import { CreateCommentFlagMutation as MutationTypes } from "coral-stream/__generated__/CreateCommentFlagMutation.graphql";
@@ -29,21 +31,39 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(environment: Environment, input: CreateCommentFlagInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...pick(input, [
"commentID",
"commentRevisionID",
"reason",
"additionalDetails",
]),
clientMutationId: (clientMutationId++).toString(),
},
},
async function commit(
environment: Environment,
input: CreateCommentFlagInput,
{ eventEmitter }: CoralContext
) {
const reportCommentEvent = ReportCommentEvent.begin(eventEmitter, {
reason: input.reason,
commentID: input.commentID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
...pick(input, [
"commentID",
"commentRevisionID",
"reason",
"additionalDetails",
]),
clientMutationId: (clientMutationId++).toString(),
},
},
}
);
reportCommentEvent.success();
return result;
} catch (error) {
reportCommentEvent.error({ message: error.message, code: error.code });
throw error;
}
}
export const withCreateCommentFlagMutation = createMutationContainer(
@@ -1,9 +1,12 @@
import { EventEmitter2 } from "eventemitter2";
import React, { Component } from "react";
import { graphql } from "react-relay";
import { withContext } from "coral-framework/lib/bootstrap";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { PropTypesOf } from "coral-framework/types";
import { ShowReportPopoverEvent } from "coral-stream/events";
import { ReportCommentFormContainer_comment as CommentData } from "coral-stream/__generated__/ReportCommentFormContainer_comment.graphql";
@@ -19,6 +22,7 @@ import ReportCommentForm from "./ReportCommentForm";
import ThankYou from "./ThankYou";
interface Props {
eventEmitter: EventEmitter2;
comment: CommentData;
createCommentFlag: CreateCommentFlagMutation;
createCommentDontAgree: CreateCommentDontAgreeMutation;
@@ -66,6 +70,12 @@ export class ReportCommentFormContainer extends Component<Props, State> {
return undefined;
};
public componentDidMount() {
ShowReportPopoverEvent.emit(this.props.eventEmitter, {
commentID: this.props.comment.id,
});
}
public componentDidUpdate(prevProps: Props, prevState: State) {
// Reposition popper after switching view.
if (this.state.done && !prevState.done) {
@@ -88,18 +98,22 @@ export class ReportCommentFormContainer extends Component<Props, State> {
}
}
const enhanced = withCreateCommentDontAgreeMutation(
withCreateCommentFlagMutation(
withFragmentContainer<Props>({
comment: graphql`
fragment ReportCommentFormContainer_comment on Comment {
id
revision {
const enhanced = withContext(({ eventEmitter }) => ({
eventEmitter,
}))(
withCreateCommentDontAgreeMutation(
withCreateCommentFlagMutation(
withFragmentContainer<Props>({
comment: graphql`
fragment ReportCommentFormContainer_comment on Comment {
id
revision {
id
}
}
}
`,
})(ReportCommentFormContainer)
`,
})(ReportCommentFormContainer)
)
)
);
export type ReportCommentFormContainerProps = PropTypesOf<typeof enhanced>;
@@ -1,5 +1,5 @@
import cn from "classnames";
import React from "react";
import React, { FunctionComponent } from "react";
import CLASSES from "coral-stream/classes";
import { BaseButton, Icon } from "coral-ui/components";
@@ -15,26 +15,27 @@ interface Props {
onResize: () => void;
}
class ReportPopover extends React.Component<Props> {
public render() {
const { onClose, onResize, comment } = this.props;
return (
<div className={cn(styles.root, CLASSES.reportPopover.$root)}>
<BaseButton
onClick={onClose}
className={cn(styles.close, CLASSES.reportPopover.closeButton)}
aria-label="Close Popover"
>
<Icon>close</Icon>
</BaseButton>
<ReportCommentFormContainer
comment={comment}
onClose={onClose}
onResize={onResize}
/>
</div>
);
}
}
const ReportPopover: FunctionComponent<Props> = ({
onClose,
onResize,
comment,
}) => {
return (
<div className={cn(styles.root, CLASSES.reportPopover.$root)}>
<BaseButton
onClick={onClose}
className={cn(styles.close, CLASSES.reportPopover.closeButton)}
aria-label="Close Popover"
>
<Icon>close</Icon>
</BaseButton>
<ReportCommentFormContainer
comment={comment}
onClose={onClose}
onResize={onResize}
/>
</div>
);
};
export default ReportPopover;
@@ -13,7 +13,7 @@ exports[`renders correctly 1`] = `
close
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(ReportCommentFormContainer)))))
<withContext(withContext(createMutationContainer(withContext(createMutationContainer(Relay(ReportCommentFormContainer))))))
comment={Object {}}
onClose={[Function]}
onResize={[Function]}
@@ -1,7 +1,8 @@
import React, { FunctionComponent } from "react";
import CLASSES from "coral-stream/classes";
import { Flex, Timestamp } from "coral-ui/components";
import Timestamp from "coral-stream/common/Timestamp";
import { Flex } from "coral-ui/components";
import TopBarLeft from "./TopBarLeft";
import Username from "./Username";
@@ -1,11 +1,13 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { BanUserEvent } from "coral-stream/events";
import { BanUserMutation } from "coral-stream/__generated__/BanUserMutation.graphql";
@@ -13,43 +15,62 @@ let clientMutationId = 0;
const BanUserMutation = createMutation(
"banUser",
(environment: Environment, input: MutationInput<BanUserMutation>) => {
return commitMutationPromiseNormalized<BanUserMutation>(environment, {
mutation: graphql`
mutation BanUserMutation($input: BanUserInput!) {
banUser(input: $input) {
user {
id
status {
ban {
active
async (
environment: Environment,
input: MutationInput<BanUserMutation> & { commentID: string },
{ eventEmitter }: CoralContext
) => {
const banUserEvent = BanUserEvent.begin(eventEmitter, {
commentID: input.commentID,
userID: input.userID,
});
try {
const result = await commitMutationPromiseNormalized<BanUserMutation>(
environment,
{
mutation: graphql`
mutation BanUserMutation($input: BanUserInput!) {
banUser(input: $input) {
user {
id
status {
ban {
active
}
}
}
clientMutationId
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
banUser: {
user: {
id: input.userID,
status: {
ban: {
active: true,
},
`,
variables: {
input: {
message: input.message,
userID: input.userID,
clientMutationId: clientMutationId.toString(),
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
optimisticResponse: {
banUser: {
user: {
id: input.userID,
status: {
ban: {
active: true,
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
}
);
banUserEvent.success();
return result;
} catch (error) {
banUserEvent.error({ message: error.message, code: error.code });
throw error;
}
}
);
@@ -37,6 +37,7 @@ const UserBanPopoverContainer: FunctionComponent<Props> = ({
const onBan = useCallback(() => {
banUser({
userID: user.id,
commentID: comment.id,
message: getMessage(
localeBundles,
"common-banEmailTemplate",
@@ -48,7 +49,7 @@ const UserBanPopoverContainer: FunctionComponent<Props> = ({
reject({
commentID: comment.id,
commentRevisionID: comment.revision.id,
storyID: story.id,
noEmit: true,
});
}
onDismiss();
@@ -2,11 +2,13 @@ import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { getViewer } from "coral-framework/helpers";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { IgnoreUserEvent } from "coral-stream/events";
import { IgnoreUserMutation as MutationTypes } from "coral-stream/__generated__/IgnoreUserMutation.graphql";
@@ -14,33 +16,53 @@ let clientMutationId = 0;
const IgnoreUserMutation = createMutation(
"ignoreUser",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation IgnoreUserMutation($input: IgnoreUserInput!) {
ignoreUser(input: $input) {
clientMutationId
}
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }: CoralContext
) => {
const ignoreUserEvent = IgnoreUserEvent.begin(eventEmitter, {
userID: input.userID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation IgnoreUserMutation($input: IgnoreUserInput!) {
ignoreUser(input: $input) {
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const viewer = getViewer(environment)!;
const viewerProxy = store.get(viewer.id)!;
const ignoredUserRecords = viewerProxy.getLinkedRecords(
"ignoredUsers"
);
if (ignoredUserRecords) {
viewerProxy.setLinkedRecords(
ignoredUserRecords.concat(store.get(input.userID)),
"ignoredUsers"
);
}
},
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const viewer = getViewer(environment)!;
const viewerProxy = store.get(viewer.id)!;
const ignoredUserRecords = viewerProxy.getLinkedRecords("ignoredUsers");
if (ignoredUserRecords) {
viewerProxy.setLinkedRecords(
ignoredUserRecords.concat(store.get(input.userID)),
"ignoredUsers"
);
}
},
})
);
ignoreUserEvent.success();
return result;
} catch (error) {
ignoreUserEvent.error({ message: error.message, code: error.code });
throw error;
}
}
);
export default IgnoreUserMutation;
@@ -1,7 +1,14 @@
import React, { FunctionComponent, useCallback, useState } from "react";
import React, {
FunctionComponent,
useCallback,
useEffect,
useState,
} from "react";
import { graphql } from "react-relay";
import { useViewerEvent } from "coral-framework/lib/events";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { ShowUserPopoverEvent } from "coral-stream/events";
import { UserPopoverContainer_user as UserData } from "coral-stream/__generated__/UserPopoverContainer_user.graphql";
import { UserPopoverContainer_viewer as ViewerData } from "coral-stream/__generated__/UserPopoverContainer_viewer.graphql";
@@ -22,6 +29,10 @@ export const UserPopoverContainer: FunctionComponent<Props> = ({
viewer,
onDismiss,
}) => {
const emitShowUserPopover = useViewerEvent(ShowUserPopoverEvent);
useEffect(() => {
emitShowUserPopover({ userID: user.id });
}, []);
const [view, setView] = useState<View>("OVERVIEW");
const onIgnore = useCallback(() => setView("IGNORE"), [setView]);
return (
@@ -47,6 +58,7 @@ const enhanced = withFragmentContainer<Props>({
`,
user: graphql`
fragment UserPopoverContainer_user on User {
id
...UserPopoverOverviewContainer_user
...UserIgnorePopoverContainer_user
}
@@ -24,12 +24,11 @@ exports[`renders username and body 1`] = `
direction="row"
itemGutter={true}
>
<Timestamp
<TimeStamp
className="coral coral-timestamp coral-comment-timestamp"
toggleAbsolute={true}
>
1995-12-17T03:24:00.000Z
</Timestamp>
</TimeStamp>
<EditedMarker
className="coral coral-comment-edited"
/>
@@ -1,13 +1,18 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import { Child as PymChild } from "pym.js";
import React from "react";
import React, { FunctionComponent, useCallback } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { withContext } from "coral-framework/lib/bootstrap";
import { withPaginationContainer } from "coral-framework/lib/relay";
import { useViewerNetworkEvent } from "coral-framework/lib/events";
import {
useLoadMore,
withPaginationContainer,
} from "coral-framework/lib/relay";
import CLASSES from "coral-stream/classes";
import Counter from "coral-stream/common/Counter";
import { ShowMoreOfConversationEvent } from "coral-stream/events";
import {
SetCommentIDMutation,
withSetCommentIDMutation,
@@ -30,7 +35,7 @@ import { Circle, Line } from "./Timeline";
import styles from "./ConversationThreadContainer.css";
interface ConversationThreadContainerProps {
interface Props {
comment: CommentData;
story: StoryData;
settings: SettingsData;
@@ -40,133 +45,126 @@ interface ConversationThreadContainerProps {
relay: RelayPaginationProp;
}
class ConversationThreadContainer extends React.Component<
ConversationThreadContainerProps
> {
public state = {
disableLoadMore: false,
};
private loadMore = () => {
if (!this.props.relay.hasMore() || this.props.relay.isLoading()) {
return;
const ConversationThreadContainer: FunctionComponent<Props> = ({
comment,
story,
viewer,
settings,
relay,
}) => {
const [loadMore, isLoadingMore] = useLoadMore(relay, 5);
const beginLoadMoreEvent = useViewerNetworkEvent(ShowMoreOfConversationEvent);
const loadMoreAndEmit = useCallback(async () => {
const loadMoreEvent = beginLoadMoreEvent({ commentID: comment.id });
try {
await loadMore();
loadMoreEvent.success();
} catch (error) {
loadMoreEvent.error({ message: error.message, code: error.code });
// eslint-disable-next-line no-console
console.error(error);
}
this.setState({ disableLoadMore: true });
this.props.relay.loadMore(
5, // Fetch the next 5 feed items
error => {
this.setState({ disableLoadMore: false });
if (error) {
// eslint-disable-next-line no-console
console.error(error);
}
}
}, [loadMore, beginLoadMoreEvent]);
const parents = comment.parents.edges.map(edge => edge.node);
const remaining = comment.parentCount - comment.parents.edges.length;
const hasMore = relay.hasMore();
const rootParent = hasMore && comment && comment.rootParent;
const dataTestID = "comments-permalinkView-conversationThread";
if (remaining === 0 && parents.length === 0) {
return (
<div className={styles.root} data-testid={dataTestID}>
<CommentContainer
comment={comment}
story={story}
settings={settings}
viewer={viewer}
highlight
/>
</div>
);
};
public render() {
const { comment, story, viewer, settings } = this.props;
const parents = comment.parents.edges.map(edge => edge.node);
const remaining = comment.parentCount - comment.parents.edges.length;
const hasMore = this.props.relay.hasMore();
const rootParent = hasMore && comment && comment.rootParent;
const dataTestID = "comments-permalinkView-conversationThread";
if (remaining === 0 && parents.length === 0) {
return (
<div className={styles.root} data-testid={dataTestID}>
}
return (
<div
className={cn(CLASSES.conversationThread.$root, styles.root)}
data-testid={dataTestID}
>
<HorizontalGutter container={<Line dotted />}>
{rootParent && (
<Circle>
<RootParent
id={rootParent.id}
username={rootParent.author && rootParent.author.username}
createdAt={rootParent.createdAt}
tags={
<UserTagsContainer
className={CLASSES.conversationThread.rootParent.userTag}
comment={rootParent}
settings={settings}
/>
}
/>
</Circle>
)}
{remaining > 0 && (
<Circle hollow className={styles.loadMore}>
<Flex alignItems="center" itemGutter="half">
<Localized
id="comments-conversationThread-showMoreOfThisConversation"
$count={remaining}
>
<Button
className={cn(
CLASSES.conversationThread.showMore,
styles.showMoreButton
)}
onClick={loadMoreAndEmit}
disabled={isLoadingMore}
variant="underlined"
>
Show more of this conversation
</Button>
</Localized>
{remaining > 1 && <Counter color="dark">{remaining}</Counter>}
</Flex>
</Circle>
)}
</HorizontalGutter>
<HorizontalGutter container={Line}>
{parents.map((parent, i) => (
<Circle key={parent.id} hollow={!!remaining || i > 0}>
<CommentContainer
comment={parent}
story={story}
viewer={viewer}
settings={settings}
localReply
/>
{viewer && (
<LocalReplyListContainer
story={story}
viewer={viewer}
settings={settings}
comment={parent}
indentLevel={1}
/>
)}
</Circle>
))}
<Circle end>
<CommentContainer
className={CLASSES.conversationThread.hightlighted}
comment={comment}
story={story}
settings={settings}
viewer={viewer}
highlight
/>
</div>
);
}
return (
<div
className={cn(CLASSES.conversationThread.$root, styles.root)}
data-testid={dataTestID}
>
<HorizontalGutter container={<Line dotted />}>
{rootParent && (
<Circle>
<RootParent
id={rootParent.id}
username={rootParent.author && rootParent.author.username}
createdAt={rootParent.createdAt}
tags={
<UserTagsContainer
className={CLASSES.conversationThread.rootParent.userTag}
comment={rootParent}
settings={settings}
/>
}
/>
</Circle>
)}
{remaining > 0 && (
<Circle hollow className={styles.loadMore}>
<Flex alignItems="center" itemGutter="half">
<Localized
id="comments-conversationThread-showMoreOfThisConversation"
$count={remaining}
>
<Button
className={cn(
CLASSES.conversationThread.showMore,
styles.showMoreButton
)}
onClick={this.loadMore}
disabled={this.state.disableLoadMore}
variant="underlined"
>
Show more of this conversation
</Button>
</Localized>
{remaining > 1 && <Counter color="dark">{remaining}</Counter>}
</Flex>
</Circle>
)}
</HorizontalGutter>
<HorizontalGutter container={Line}>
{parents.map((parent, i) => (
<Circle key={parent.id} hollow={!!remaining || i > 0}>
<CommentContainer
comment={parent}
story={story}
viewer={viewer}
settings={settings}
localReply
/>
{viewer && (
<LocalReplyListContainer
story={story}
viewer={viewer}
settings={settings}
comment={parent}
indentLevel={1}
/>
)}
</Circle>
))}
<Circle end>
<CommentContainer
className={CLASSES.conversationThread.hightlighted}
comment={comment}
story={story}
settings={settings}
viewer={viewer}
highlight
/>
</Circle>
</HorizontalGutter>
</div>
);
}
}
</Circle>
</HorizontalGutter>
</div>
);
};
// TODO: (cvle) This should be autogenerated.
interface FragmentVariables {
@@ -179,7 +177,7 @@ const enhanced = withContext(ctx => ({
}))(
withSetCommentIDMutation(
withPaginationContainer<
ConversationThreadContainerProps,
Props,
ConversationThreadContainerPaginationQueryVariables,
FragmentVariables
>(
@@ -1,3 +1,4 @@
import { EventEmitter2 } from "eventemitter2";
import { Child as PymChild } from "pym.js";
import React, { MouseEvent } from "react";
import { graphql } from "react-relay";
@@ -5,6 +6,7 @@ import { graphql } from "react-relay";
import { getURLWithCommentID } from "coral-framework/helpers";
import { withContext } from "coral-framework/lib/bootstrap";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { ViewFullDiscussionEvent } from "coral-stream/events";
import {
SetCommentIDMutation,
withSetCommentIDMutation,
@@ -24,12 +26,16 @@ interface PermalinkViewContainerProps {
viewer: ViewerData | null;
setCommentID: SetCommentIDMutation;
pym: PymChild | undefined;
eventEmitter: EventEmitter2;
}
class PermalinkViewContainer extends React.Component<
PermalinkViewContainerProps
> {
private showAllComments = (e: MouseEvent<any>) => {
ViewFullDiscussionEvent.emit(this.props.eventEmitter, {
commentID: this.props.comment && this.props.comment.id,
});
this.props.setCommentID({ id: null });
e.preventDefault();
};
@@ -62,6 +68,7 @@ class PermalinkViewContainer extends React.Component<
const enhanced = withContext(ctx => ({
pym: ctx.pym,
eventEmitter: ctx.eventEmitter,
}))(
withSetCommentIDMutation(
withFragmentContainer<PermalinkViewContainerProps>({
@@ -5,7 +5,7 @@ exports[`renders comment not found 1`] = `
className="PermalinkView-root coral coral-permalink coral-authenticated"
size="double"
>
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(UserBoxContainer)))))))))
<withContext(withMutation(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(UserBoxContainer)))))))))
settings={Object {}}
viewer={Object {}}
/>
@@ -67,7 +67,7 @@ exports[`renders correctly 1`] = `
className="PermalinkView-root coral coral-permalink coral-authenticated"
size="double"
>
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(UserBoxContainer)))))))))
<withContext(withMutation(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(UserBoxContainer)))))))))
settings={Object {}}
viewer={Object {}}
/>
@@ -1,7 +1,7 @@
import { Blockquote, Bold, CoralRTE, Italic } from "@coralproject/rte";
import cn from "classnames";
import { Localized as LocalizedOriginal } from "fluent-react/compat";
import React, { FunctionComponent, Ref } from "react";
import React, { EventHandler, FocusEvent, FunctionComponent, Ref } from "react";
import CLASSES from "coral-stream/classes";
import { Icon } from "coral-ui/components";
@@ -67,6 +67,8 @@ export interface RTEProps {
* onChange
*/
onChange?: (data: { html: string; text: string }) => void;
onFocus?: EventHandler<FocusEvent>;
onBlur?: EventHandler<FocusEvent>;
disabled?: boolean;
@@ -109,6 +111,8 @@ const RTE: FunctionComponent<RTEProps> = props => {
contentClassName,
placeholderClassName,
toolbarClassName,
onFocus,
onBlur,
...rest
} = props;
return (
@@ -138,6 +142,8 @@ const RTE: FunctionComponent<RTEProps> = props => {
features={features}
ref={forwardRef}
toolbarPosition="bottom"
onBlur={onBlur}
onFocus={onFocus}
{...rest}
/>
</div>
@@ -1,4 +1,5 @@
import { shallow, ShallowWrapper } from "enzyme";
import { EventEmitter2 } from "eventemitter2";
import { noop } from "lodash";
import React from "react";
@@ -11,6 +12,10 @@ import { ReplyListContainer } from "./ReplyListContainer";
// Remove relay refs so we can stub the props.
const ReplyListContainerN = removeFragmentRefs(ReplyListContainer);
/* Mock useContext */
const context = { eventEmitter: new EventEmitter2() };
jest.spyOn(React, "useContext").mockImplementation(() => context);
it("renders correctly", () => {
const props: PropTypesOf<typeof ReplyListContainerN> = {
story: {
@@ -2,6 +2,7 @@ import React, { FunctionComponent, useCallback, useEffect } from "react";
import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay";
import { withProps } from "recompose";
import { useViewerNetworkEvent } from "coral-framework/lib/events";
import {
useLoadMore,
useMutation,
@@ -10,6 +11,7 @@ import {
} from "coral-framework/lib/relay";
import { FragmentKeys } from "coral-framework/lib/relay/types";
import { Omit, PropTypesOf } from "coral-framework/types";
import { ShowAllRepliesEvent } from "coral-stream/events";
import { ReplyListContainer1_comment as CommentData } from "coral-stream/__generated__/ReplyListContainer1_comment.graphql";
import { ReplyListContainer1_settings as SettingsData } from "coral-stream/__generated__/ReplyListContainer1_settings.graphql";
@@ -58,6 +60,19 @@ type FragmentVariables = Omit<
export const ReplyListContainer: React.FunctionComponent<Props> = props => {
const [showAll, isLoadingShowAll] = useLoadMore(props.relay, 999999999);
const beginShowAllEvent = useViewerNetworkEvent(ShowAllRepliesEvent);
const showAllAndEmit = useCallback(async () => {
const showAllEvent = beginShowAllEvent({ commentID: props.comment.id });
try {
await showAll();
showAllEvent.success();
} catch (error) {
showAllEvent.error({ message: error.message, code: error.code });
// eslint-disable-next-line no-console
console.error(error);
}
}, [showAll, beginShowAllEvent, props.comment.id]);
const subcribeToCommentReplyCreated = useSubscription(
CommentReplyCreatedSubscription
);
@@ -129,7 +144,7 @@ export const ReplyListContainer: React.FunctionComponent<Props> = props => {
comments={comments}
story={props.story}
settings={props.settings}
onShowAll={showAll}
onShowAll={showAllAndEmit}
hasMore={props.relay.hasMore()}
disableShowAll={isLoadingShowAll}
indentLevel={props.indentLevel}
@@ -1,9 +1,11 @@
import { ConnectionHandler, Environment, RecordProxy } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitLocalUpdatePromisified,
createMutation,
} from "coral-framework/lib/relay";
import { ShowMoreRepliesEvent } from "coral-stream/events";
interface ReplyListViewNewInput {
commentID: string;
@@ -11,7 +13,11 @@ interface ReplyListViewNewInput {
const QueueViewNewMutation = createMutation(
"viewNew",
async (environment: Environment, input: ReplyListViewNewInput) => {
async (
environment: Environment,
input: ReplyListViewNewInput,
{ eventEmitter }: CoralContext
) => {
await commitLocalUpdatePromisified(environment, async store => {
const parentProxy = store.get(input.commentID);
if (!parentProxy) {
@@ -37,6 +43,10 @@ const QueueViewNewMutation = createMutation(
ConnectionHandler.insertEdgeAfter(connection, edge);
});
connection.setLinkedRecords([], "viewNewEdges");
ShowMoreRepliesEvent.emit(eventEmitter, {
commentID: input.commentID,
count: viewNewEdges.length,
});
});
}
);
@@ -21,7 +21,7 @@ exports[`renders correctly 1`] = `
<ForwardRef(forwardRef)
key="comment-1"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
<withContext(withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer))))))
comment={
Object {
"id": "comment-1",
@@ -66,7 +66,7 @@ exports[`renders correctly 1`] = `
<ForwardRef(forwardRef)
key="comment-2"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
<withContext(withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer))))))
comment={
Object {
"id": "comment-2",
@@ -120,7 +120,7 @@ exports[`when there is more disables load more button 1`] = `
<ForwardRef(forwardRef)
key="comment-1"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
<withContext(withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer))))))
comment={
Object {
"id": "comment-1",
@@ -162,7 +162,7 @@ exports[`when there is more disables load more button 1`] = `
<ForwardRef(forwardRef)
key="comment-2"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
<withContext(withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer))))))
comment={
Object {
"id": "comment-2",
@@ -233,7 +233,7 @@ exports[`when there is more renders a load more button 1`] = `
<ForwardRef(forwardRef)
key="comment-1"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
<withContext(withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer))))))
comment={
Object {
"id": "comment-1",
@@ -275,7 +275,7 @@ exports[`when there is more renders a load more button 1`] = `
<ForwardRef(forwardRef)
key="comment-2"
>
<withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer)))))
<withContext(withContext(createMutationContainer(withContext(createMutationContainer(Relay(CommentContainer))))))
comment={
Object {
"id": "comment-2",
@@ -3,6 +3,7 @@ import React, { FunctionComponent, useCallback, useEffect } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import FadeInTransition from "coral-framework/components/FadeInTransition";
import { useViewerNetworkEvent } from "coral-framework/lib/events";
import {
useLoadMore,
useLocal,
@@ -13,6 +14,7 @@ import {
import { GQLCOMMENT_SORT } from "coral-framework/schema";
import { Omit, PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import { LoadMoreAllCommentsEvent } from "coral-stream/events";
import { Box, Button, CallOut, HorizontalGutter } from "coral-ui/components";
import { AllCommentsTabContainer_settings } from "coral-stream/__generated__/AllCommentsTabContainer_settings.graphql";
@@ -104,6 +106,18 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = props => {
props.story.settings.live.enabled,
]);
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const beginLoadMoreEvent = useViewerNetworkEvent(LoadMoreAllCommentsEvent);
const loadMoreAndEmit = useCallback(async () => {
const loadMoreEvent = beginLoadMoreEvent({ storyID: props.story.id });
try {
await loadMore();
loadMoreEvent.success();
} catch (error) {
loadMoreEvent.error({ message: error.message, code: error.code });
// eslint-disable-next-line no-console
console.error(error);
}
}, [loadMore, beginLoadMoreEvent, props.story.id]);
const viewMore = useMutation(AllCommentsTabViewNewMutation);
const onViewMore = useCallback(() => viewMore({ storyID: props.story.id }), [
props.story.id,
@@ -174,7 +188,7 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = props => {
{props.relay.hasMore() && (
<Localized id="comments-loadMore">
<Button
onClick={loadMore}
onClick={loadMoreAndEmit}
variant="outlineFilled"
fullWidth
disabled={isLoadingMore}
@@ -1,3 +1,4 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { ReadyState } from "react-relay";
@@ -25,12 +26,20 @@ export const render = (data: ReadyState<QueryTypes["response"]>) => {
return <div>{data.error.message}</div>;
}
if (data.props) {
if (!data.props.story) {
return (
<Localized id="comments-streamQuery-storyNotFound">
<div>Story not found</div>
</Localized>
);
}
return (
<SpinnerWhileRendering>
<AllCommentsTabContainer
settings={data.props.settings}
viewer={data.props.viewer}
story={data.props.story!}
story={data.props.story}
/>
</SpinnerWhileRendering>
);
@@ -57,7 +66,7 @@ const AllCommentsTabQuery: FunctionComponent<Props> = props => {
viewer {
...AllCommentsTabContainer_viewer
}
story(id: $storyID, url: $storyURL) {
story: stream(id: $storyID, url: $storyURL) {
...AllCommentsTabContainer_story
@arguments(orderBy: $commentsOrderBy)
}
@@ -1,10 +1,12 @@
import { ConnectionHandler, Environment, RecordProxy } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitLocalUpdatePromisified,
createMutation,
} from "coral-framework/lib/relay";
import { GQLCOMMENT_SORT } from "coral-framework/schema";
import { ViewNewCommentsEvent } from "coral-stream/events";
interface Input {
storyID: string;
@@ -12,7 +14,11 @@ interface Input {
const AllCommentsTabViewNewMutation = createMutation(
"viewNew",
async (environment: Environment, input: Input) => {
async (
environment: Environment,
input: Input,
{ eventEmitter }: CoralContext
) => {
await commitLocalUpdatePromisified(environment, async store => {
const story = store.get(input.storyID)!;
const connection = ConnectionHandler.getConnection(
@@ -29,6 +35,10 @@ const AllCommentsTabViewNewMutation = createMutation(
viewNewEdges.forEach(edge => {
ConnectionHandler.insertEdgeBefore(connection, edge);
});
ViewNewCommentsEvent.emit(eventEmitter, {
storyID: input.storyID,
count: viewNewEdges.length,
});
connection.setLinkedRecords([], "viewNewEdges");
});
}
@@ -1,6 +1,8 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useEffect } from "react";
import { useViewerEvent } from "coral-framework/lib/events";
import { ShowFeaturedCommentTooltipEvent } from "coral-stream/events";
import { Tooltip, TooltipButton } from "coral-ui/components";
interface Props {
@@ -8,6 +10,18 @@ interface Props {
active?: boolean;
}
const FeaturedCommentTooltipContent: FunctionComponent = props => {
const emitShowTooltipEvent = useViewerEvent(ShowFeaturedCommentTooltipEvent);
useEffect(() => {
emitShowTooltipEvent();
}, []);
return (
<Localized id="comments-featuredCommentTooltip-handSelectedComments">
<span>Comments are hand selected by our team as worth reading.</span>
</Localized>
);
};
export const FeaturedCommentTooltip: FunctionComponent<Props> = props => {
return (
<Tooltip
@@ -18,12 +32,8 @@ export const FeaturedCommentTooltip: FunctionComponent<Props> = props => {
<span>How is a comment featured?</span>
</Localized>
}
body={
<Localized id="comments-featuredCommentTooltip-handSelectedComments">
<span>Comments are hand selected by our team as worth reading.</span>
</Localized>
}
button={({ toggleVisibility, ref }) => (
body={<FeaturedCommentTooltipContent />}
button={({ toggleVisibility, ref, visible }) => (
<Localized
id="comments-featuredCommentTooltip-toggleButton"
attrs={{ "aria-label": true }}
@@ -4,15 +4,18 @@ import React, { FunctionComponent, MouseEvent, useCallback } from "react";
import { graphql } from "react-relay";
import { getURLWithCommentID } from "coral-framework/helpers";
import { useViewerEvent } from "coral-framework/lib/events";
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
import { GQLUSER_STATUS } from "coral-framework/schema";
import CLASSES from "coral-stream/classes";
import HTMLContent from "coral-stream/common/HTMLContent";
import Timestamp from "coral-stream/common/Timestamp";
import { ViewConversationEvent } from "coral-stream/events";
import {
SetCommentIDMutation,
withSetCommentIDMutation,
} from "coral-stream/mutations";
import { Box, Flex, Icon, TextLink, Timestamp } from "coral-ui/components";
import { Box, Flex, Icon, TextLink } from "coral-ui/components";
import { FeaturedCommentContainer_comment as CommentData } from "coral-stream/__generated__/FeaturedCommentContainer_comment.graphql";
import { FeaturedCommentContainer_settings as SettingsData } from "coral-stream/__generated__/FeaturedCommentContainer_settings.graphql";
@@ -38,9 +41,14 @@ const FeaturedCommentContainer: FunctionComponent<Props> = props => {
const banned = Boolean(
viewer && viewer.status.current.includes(GQLUSER_STATUS.BANNED)
);
const emitViewConversationEvent = useViewerEvent(ViewConversationEvent);
const onGotoConversation = useCallback(
(e: MouseEvent) => {
e.preventDefault();
emitViewConversationEvent({
from: "FEATURED_COMMENTS",
commentID: comment.id,
});
setCommentID({ id: comment.id });
return false;
},
@@ -1,13 +1,15 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useCallback } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { useViewerNetworkEvent } from "coral-framework/lib/events";
import {
useLoadMore,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { Omit, PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import { LoadMoreFeaturedCommentsEvent } from "coral-stream/events";
import { Button, HorizontalGutter } from "coral-ui/components";
import { FeaturedCommentsContainer_settings as SettingsData } from "coral-stream/__generated__/FeaturedCommentsContainer_settings.graphql";
@@ -27,6 +29,20 @@ interface Props {
export const FeaturedCommentsContainer: FunctionComponent<Props> = props => {
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const beginLoadMoreEvent = useViewerNetworkEvent(
LoadMoreFeaturedCommentsEvent
);
const loadMoreAndEmit = useCallback(async () => {
const loadMoreEvent = beginLoadMoreEvent({ storyID: props.story.id });
try {
await loadMore();
loadMoreEvent.success();
} catch (error) {
loadMoreEvent.error({ message: error.message, code: error.code });
// eslint-disable-next-line no-console
console.error(error);
}
}, [loadMore, beginLoadMoreEvent, props.story.id]);
const comments = props.story.featuredComments.edges.map(edge => edge.node);
return (
<>
@@ -54,7 +70,7 @@ export const FeaturedCommentsContainer: FunctionComponent<Props> = props => {
{props.relay.hasMore() && (
<Localized id="comments-loadMore">
<Button
onClick={loadMore}
onClick={loadMoreAndEmit}
variant="outlined"
fullWidth
disabled={isLoadingMore}
@@ -1,3 +1,4 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import { ReadyState } from "react-relay";
@@ -23,6 +24,7 @@ export const render = (data: ReadyState<QueryTypes["response"]>) => {
if (data.error) {
return <div>{data.error.message}</div>;
}
if (!data.props) {
return (
<Flex justifyContent="center">
@@ -30,12 +32,21 @@ export const render = (data: ReadyState<QueryTypes["response"]>) => {
</Flex>
);
}
if (data.props) {
if (!data.props.story) {
return (
<Localized id="comments-streamQuery-storyNotFound">
<div>Story not found</div>
</Localized>
);
}
return (
<FeaturedCommentsContainer
settings={data.props.settings}
viewer={data.props.viewer}
story={data.props.story!}
story={data.props.story}
/>
);
}
@@ -64,7 +75,7 @@ const FeaturedCommentsQuery: FunctionComponent<Props> = props => {
viewer {
...FeaturedCommentsContainer_viewer
}
story(id: $storyID, url: $storyURL) {
story: stream(id: $storyID, url: $storyURL) {
...FeaturedCommentsContainer_story
@arguments(orderBy: $commentsOrderBy)
}
@@ -16,6 +16,7 @@ import {
MutationResponsePromise,
} from "coral-framework/lib/relay";
import { GQLStory, GQLUSER_ROLE } from "coral-framework/schema";
import { CreateCommentEvent } from "coral-stream/events";
import { CreateCommentMutation as MutationTypes } from "coral-stream/__generated__/CreateCommentMutation.graphql";
@@ -114,10 +115,10 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(
async function commit(
environment: Environment,
input: CreateCommentInput,
{ uuidGenerator, relayEnvironment }: CoralContext
{ uuidGenerator, relayEnvironment, eventEmitter }: CoralContext
) {
const viewer = getViewer(environment)!;
const currentDate = new Date().toISOString();
@@ -134,75 +135,93 @@ function commit(
!roleIsAtLeast(viewer.role, GQLUSER_ROLE.STAFF) &&
storySettings.moderation === "PRE";
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
storyID: input.storyID,
body: input.body,
nudge: input.nudge,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
createComment: {
edge: {
cursor: currentDate,
node: {
id,
createdAt: currentDate,
status: "NONE",
author: {
id: viewer.id,
username: viewer.username,
createdAt: viewer.createdAt,
badges: viewer.badges,
ignoreable: false,
},
revision: {
id: uuidGenerator(),
},
parent: null,
const createCommentEvent = CreateCommentEvent.begin(eventEmitter, {
body: input.body,
storyID: input.storyID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
storyID: input.storyID,
body: input.body,
editing: {
editableUntil: new Date(Date.now() + 10000).toISOString(),
edited: false,
},
actionCounts: {
reaction: {
total: 0,
},
},
tags: roleIsAtLeast(viewer.role, GQLUSER_ROLE.STAFF)
? [{ code: "STAFF" }]
: [],
viewerActionPresence: {
reaction: false,
dontAgree: false,
flag: false,
},
replies: {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
deleted: false,
nudge: input.nudge,
clientMutationId: clientMutationId.toString(),
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
// Skip optimistic update if comment is probably premoderated.
if (expectPremoderation) {
return;
optimisticResponse: {
createComment: {
edge: {
cursor: currentDate,
node: {
id,
createdAt: currentDate,
status: "NONE",
author: {
id: viewer.id,
username: viewer.username,
createdAt: viewer.createdAt,
badges: viewer.badges,
ignoreable: false,
},
revision: {
id: uuidGenerator(),
},
parent: null,
body: input.body,
editing: {
editableUntil: new Date(Date.now() + 10000).toISOString(),
edited: false,
},
actionCounts: {
reaction: {
total: 0,
},
},
tags: roleIsAtLeast(viewer.role, GQLUSER_ROLE.STAFF)
? [{ code: "STAFF" }]
: [],
viewerActionPresence: {
reaction: false,
dontAgree: false,
flag: false,
},
replies: {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
deleted: false,
},
},
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
// Skip optimistic update if comment is probably premoderated.
if (expectPremoderation) {
return;
}
sharedUpdater(environment, store, input);
store.get(id)!.setValue(true, "pending");
},
updater: store => {
sharedUpdater(environment, store, input);
},
}
sharedUpdater(environment, store, input);
store.get(id)!.setValue(true, "pending");
},
updater: store => {
sharedUpdater(environment, store, input);
},
});
);
createCommentEvent.success({
id: result.edge.node.id,
status: result.edge.node.status,
});
return result;
} catch (error) {
createCommentEvent.error({ message: error.message, code: error.code });
throw error;
}
}
export const withCreateCommentMutation = createMutationContainer(
@@ -1,13 +1,15 @@
import cn from "classnames";
import { FormApi, FormState } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form, FormSpy } from "react-final-form";
import { useViewerEvent } from "coral-framework/lib/events";
import { OnSubmit } from "coral-framework/lib/form";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import ValidationMessage from "coral-stream/common/ValidationMessage";
import { CreateCommentFocusEvent } from "coral-stream/events";
import { AriaInfo, Button, Flex, HorizontalGutter } from "coral-ui/components";
import { cleanupRTEEmptyHTML, getCommentBodyValidators } from "../../helpers";
@@ -35,116 +37,124 @@ interface Props {
story: PropTypesOf<typeof MessageBoxContainer>["story"];
}
const PostCommentForm: FunctionComponent<Props> = props => (
<div className={CLASSES.createComment.$root}>
{props.showMessageBox && (
<MessageBoxContainer
story={props.story}
className={cn(CLASSES.createComment.message, styles.messageBox)}
/>
)}
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, submitError, form }) => (
<form
autoComplete="off"
onSubmit={handleSubmit}
id="comments-postCommentForm-form"
>
<FormSpy
onChange={state => props.onChange && props.onChange(state, form)}
/>
<HorizontalGutter>
<Field
name="body"
validate={getCommentBodyValidators(props.min, props.max)}
>
{/* FIXME: (wyattjoh) reorganize this */}
{({ input, meta }) => (
<>
<HorizontalGutter size="half">
<Localized id="comments-postCommentForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-postCommentForm-field"
const PostCommentForm: FunctionComponent<Props> = props => {
const emitFocusEvent = useViewerEvent(CreateCommentFocusEvent);
const onFocus = useCallback(() => {
emitFocusEvent();
}, [emitFocusEvent]);
return (
<div className={CLASSES.createComment.$root}>
{props.showMessageBox && (
<MessageBoxContainer
story={props.story}
className={cn(CLASSES.createComment.message, styles.messageBox)}
/>
)}
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, submitError, form }) => (
<form
autoComplete="off"
onSubmit={handleSubmit}
id="comments-postCommentForm-form"
>
<FormSpy
onChange={state => props.onChange && props.onChange(state, form)}
/>
<HorizontalGutter>
<Field
name="body"
validate={getCommentBodyValidators(props.min, props.max)}
>
{({ input, meta }) => (
<>
<HorizontalGutter size="half">
<Localized id="comments-postCommentForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-postCommentForm-field"
>
Post a comment
</AriaInfo>
</Localized>
<Localized
id="comments-postCommentForm-rte"
attrs={{ placeholder: true }}
>
Post a comment
</AriaInfo>
</Localized>
<Localized
id="comments-postCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTE
inputId="comments-postCommentForm-field"
onChange={({ html }) =>
input.onChange(cleanupRTEEmptyHTML(html))
}
contentClassName={
props.showMessageBox
? styles.rteBorderless
: undefined
}
value={input.value}
placeholder="Post a comment"
disabled={submitting || props.disabled}
/>
</Localized>
{props.disabled ? (
<>
{props.disabledMessage && (
<ValidationMessage fullWidth>
{props.disabledMessage}
</ValidationMessage>
)}
</>
) : (
<>
{meta.touched &&
(meta.error ||
(meta.submitError &&
!meta.dirtySinceLastSubmit)) && (
<RTE
inputId="comments-postCommentForm-field"
onFocus={onFocus}
onChange={({ html }) =>
input.onChange(cleanupRTEEmptyHTML(html))
}
contentClassName={
props.showMessageBox
? styles.rteBorderless
: undefined
}
value={input.value}
placeholder="Post a comment"
disabled={submitting || props.disabled}
/>
</Localized>
{props.disabled ? (
<>
{props.disabledMessage && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
{props.disabledMessage}
</ValidationMessage>
)}
{submitError && (
<ValidationMessage fullWidth>
{submitError}
</ValidationMessage>
)}
<PostCommentSubmitStatusContainer
status={props.submitStatus}
/>
{props.max && (
<RemainingCharactersContainer
value={input.value}
max={props.max}
</>
) : (
<>
{meta.touched &&
(meta.error ||
(meta.submitError &&
!meta.dirtySinceLastSubmit)) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
{submitError && (
<ValidationMessage fullWidth>
{submitError}
</ValidationMessage>
)}
<PostCommentSubmitStatusContainer
status={props.submitStatus}
/>
)}
</>
)}
</HorizontalGutter>
<Flex direction="column" alignItems="flex-end">
<Localized id="comments-postCommentForm-submit">
<Button
color="primary"
variant="filled"
className={CLASSES.createComment.submit}
disabled={submitting || !input.value || props.disabled}
type="submit"
>
Submit
</Button>
</Localized>
</Flex>
</>
)}
</Field>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
{props.max && (
<RemainingCharactersContainer
value={input.value}
max={props.max}
/>
)}
</>
)}
</HorizontalGutter>
<Flex direction="column" alignItems="flex-end">
<Localized id="comments-postCommentForm-submit">
<Button
color="primary"
variant="filled"
className={CLASSES.createComment.submit}
disabled={
submitting || !input.value || props.disabled
}
type="submit"
>
Submit
</Button>
</Localized>
</Flex>
</>
)}
</Field>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
export default PostCommentForm;
@@ -2,8 +2,10 @@ import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { useViewerEvent } from "coral-framework/lib/events";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import { CreateCommentFocusEvent } from "coral-stream/events";
import { Button, HorizontalGutter } from "coral-ui/components";
import RTE from "../../RTE";
@@ -20,6 +22,10 @@ interface Props {
}
const PostCommentFormFake: FunctionComponent<Props> = props => {
const emitFocusEvent = useViewerEvent(CreateCommentFocusEvent);
const onFocus = useCallback(() => {
emitFocusEvent();
}, [emitFocusEvent]);
const onChange = useCallback(
(data: { html: string; text: string }) => props.onDraftChange(data.html),
[props.onDraftChange]
@@ -42,6 +48,7 @@ const PostCommentFormFake: FunctionComponent<Props> = props => {
placeholder="Post a comment"
value={props.draft}
onChange={onChange}
onFocus={onFocus}
/>
</Localized>
</div>
@@ -24,6 +24,7 @@ exports[`renders correctly 1`] = `
>
<RTE
onChange={[Function]}
onFocus={[Function]}
placeholder="Post a comment"
value=""
/>
@@ -1,8 +1,18 @@
.mobileSelect {
padding: 11px;
width: 0px;
font-size: 0;
/* This adds left padding to Chrome on windows.. */
padding: 3px;
font-size: 16px;
line-height: 0;
height: 24px;
width: 24px;
/* Make the native select invisible */
opacity: 0;
}
.mobileAfterWrapper {
right: 5px;
height: 24px;
width: 24px;
right: 0px;
border: 1px solid var(--palette-text-primary);
box-sizing: border-box;
border-radius: var(--round-corners);
}
@@ -1,8 +1,10 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useCallback } from "react";
import { useViewerEvent } from "coral-framework/lib/events";
import CLASSES from "coral-stream/classes";
import { OpenSortMenuEvent } from "coral-stream/events";
import {
Flex,
Icon,
@@ -26,48 +28,55 @@ interface Props {
reactionSortLabel: string;
}
const SortMenu: FunctionComponent<Props> = props => (
<MatchMedia ltWidth="sm">
{matches => (
<Flex
className={cn(props.className, CLASSES.sortMenu)}
justifyContent="flex-end"
alignItems="center"
itemGutter
>
{!matches && (
<Localized id="comments-sortMenu-sortBy">
<Typography
variant="bodyCopyBold"
container={<label htmlFor="coral-comments-sortMenu" />}
>
Sort By
</Typography>
</Localized>
)}
<SelectField
id="coral-comments-sortMenu"
value={props.orderBy}
onChange={props.onChange}
afterWrapper={(matches && <Icon>sort</Icon>) || undefined}
classes={{
select: (matches && styles.mobileSelect) || undefined,
afterWrapper: (matches && styles.mobileAfterWrapper) || undefined,
}}
const SortMenu: FunctionComponent<Props> = props => {
const emitOpenSortMenuEvent = useViewerEvent(OpenSortMenuEvent);
const onClickSelectField = useCallback(() => emitOpenSortMenuEvent(), [
emitOpenSortMenuEvent,
]);
return (
<MatchMedia ltWidth="sm">
{matches => (
<Flex
className={cn(props.className, CLASSES.sortMenu)}
justifyContent="flex-end"
alignItems="center"
itemGutter
>
<Localized id="comments-sortMenu-newest">
<Option value="CREATED_AT_DESC">Newest</Option>
</Localized>
<Localized id="comments-sortMenu-oldest">
<Option value="CREATED_AT_ASC">Oldest</Option>
</Localized>
<Localized id="comments-sortMenu-mostReplies">
<Option value="REPLIES_DESC">Most Replies</Option>
</Localized>
<Option value="REACTION_DESC">{props.reactionSortLabel}</Option>
</SelectField>
</Flex>
)}
</MatchMedia>
);
{!matches && (
<Localized id="comments-sortMenu-sortBy">
<Typography
variant="bodyCopyBold"
container={<label htmlFor="coral-comments-sortMenu" />}
>
Sort By
</Typography>
</Localized>
)}
<SelectField
id="coral-comments-sortMenu"
value={props.orderBy}
onChange={props.onChange}
onClick={onClickSelectField}
afterWrapper={(matches && <Icon>sort</Icon>) || undefined}
classes={{
select: (matches && styles.mobileSelect) || undefined,
afterWrapper: (matches && styles.mobileAfterWrapper) || undefined,
}}
>
<Localized id="comments-sortMenu-newest">
<Option value="CREATED_AT_DESC">Newest</Option>
</Localized>
<Localized id="comments-sortMenu-oldest">
<Option value="CREATED_AT_ASC">Oldest</Option>
</Localized>
<Localized id="comments-sortMenu-mostReplies">
<Option value="REPLIES_DESC">Most Replies</Option>
</Localized>
<Option value="REACTION_DESC">{props.reactionSortLabel}</Option>
</SelectField>
</Flex>
)}
</MatchMedia>
);
};
export default SortMenu;
@@ -3,11 +3,16 @@ import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback, useEffect } from "react";
import { graphql } from "react-relay";
import { useViewerEvent } from "coral-framework/lib/events";
import { useLocal, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLUSER_STATUS } from "coral-framework/schema";
import CLASSES from "coral-stream/classes";
import Counter from "coral-stream/common/Counter";
import { UserBoxContainer } from "coral-stream/common/UserBox";
import {
SetCommentsOrderByEvent,
SetCommentsTabEvent,
} from "coral-stream/events";
import {
Flex,
HorizontalGutter,
@@ -71,6 +76,8 @@ const TabWithFeaturedTooltip: FunctionComponent<PropTypesOf<typeof Tab>> = ({
);
export const StreamContainer: FunctionComponent<Props> = props => {
const emitSetCommentsTabEvent = useViewerEvent(SetCommentsTabEvent);
const emitSetCommentsOrderByEvent = useViewerEvent(SetCommentsOrderByEvent);
const [local, setLocal] = useLocal<StreamContainerLocal>(
graphql`
fragment StreamContainerLocal on Local {
@@ -80,13 +87,26 @@ export const StreamContainer: FunctionComponent<Props> = props => {
`
);
const onChangeOrder = useCallback(
(order: React.ChangeEvent<HTMLSelectElement>) =>
setLocal({ commentsOrderBy: order.target.value as any }),
[setLocal]
(order: React.ChangeEvent<HTMLSelectElement>) => {
if (local.commentsOrderBy === order.target.value) {
return;
}
setLocal({ commentsOrderBy: order.target.value as any });
emitSetCommentsOrderByEvent({ orderBy: order.target.value });
},
[setLocal, local.commentsOrderBy]
);
const onChangeTab = useCallback(
(tab: COMMENTS_TAB) => setLocal({ commentsTab: tab }),
[setLocal]
(tab: COMMENTS_TAB, emit = true) => {
if (local.commentsTab === tab) {
return;
}
setLocal({ commentsTab: tab });
if (emit) {
emitSetCommentsTabEvent({ tab });
}
},
[setLocal, local.commentsTab]
);
const banned = Boolean(
props.viewer && props.viewer.status.current.includes(GQLUSER_STATUS.BANNED)
@@ -110,9 +130,9 @@ export const StreamContainer: FunctionComponent<Props> = props => {
// If the selected tab is FEATURED_COMMENTS, but there aren't any featured
// comments, then switch it to the all comments tab.
if (featuredCommentsCount === 0) {
onChangeTab("ALL_COMMENTS");
onChangeTab("ALL_COMMENTS", false);
} else {
onChangeTab("FEATURED_COMMENTS");
onChangeTab("FEATURED_COMMENTS", false);
}
}
}, [featuredCommentsCount, local.commentsTab, onChangeTab]);
@@ -75,7 +75,7 @@ const StreamQuery: FunctionComponent<Props> = props => {
viewer {
...StreamContainer_viewer
}
story(id: $storyID, url: $storyURL) {
story: stream(id: $storyID, url: $storyURL) {
...StreamContainer_story
}
settings {
@@ -18,6 +18,7 @@ exports[`renders correctly on big screens 1`] = `
id="coral-comments-sortMenu"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
value="CREATED_AT_ASC"
>
@@ -69,6 +70,7 @@ exports[`renders correctly on small screens 1`] = `
id="coral-comments-sortMenu"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
value="CREATED_AT_ASC"
>
@@ -1,6 +1,7 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutationContainer,
@@ -9,6 +10,7 @@ import {
} from "coral-framework/lib/relay";
import { UpdateStorySettingsMutation as MutationTypes } from "coral-stream/__generated__/UpdateStorySettingsMutation.graphql";
import { UpdateStorySettingsEvent } from "coral-stream/events";
export type UpdateStorySettingsInput = MutationInput<MutationTypes>;
@@ -25,16 +27,44 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(environment: Environment, input: UpdateStorySettingsInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
async function commit(
environment: Environment,
input: UpdateStorySettingsInput,
{ eventEmitter }: CoralContext
) {
const updateStorySettings = UpdateStorySettingsEvent.begin(eventEmitter, {
storyID: input.id,
live: input.settings.live ? { enabled: input.settings.live.enabled } : null,
messageBox: input.settings.messageBox
? {
content: input.settings.messageBox.content,
enabled: input.settings.messageBox.enabled,
icon: input.settings.messageBox.icon,
}
: null,
moderation: input.settings.moderation,
premodLinksEnable: input.settings.premodLinksEnable,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
id: input.id,
settings: input.settings,
clientMutationId: (clientMutationId++).toString(),
},
},
}
);
updateStorySettings.success();
return result;
} catch (error) {
updateStorySettings.error({ message: error.message, code: error.code });
throw error;
}
}
export const withUpdateStorySettingsMutation = createMutationContainer(
@@ -1,12 +1,14 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutationContainer,
MutationInput,
MutationResponsePromise,
} from "coral-framework/lib/relay";
import { CloseStoryEvent } from "coral-stream/events";
import { CloseStoryMutation as MutationTypes } from "coral-stream/__generated__/CloseStoryMutation.graphql";
@@ -25,16 +27,33 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(environment: Environment, input: CloseStoryInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
async function commit(
environment: Environment,
input: CloseStoryInput,
{ eventEmitter }: CoralContext
) {
const closeStoryEvent = CloseStoryEvent.begin(eventEmitter, {
storyID: input.id,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
}
);
closeStoryEvent.success();
return result;
} catch (error) {
closeStoryEvent.error({ message: error.message, code: error.code });
throw error;
}
}
export const withCloseStoryMutation = createMutationContainer(
@@ -1,12 +1,14 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutationContainer,
MutationInput,
MutationResponsePromise,
} from "coral-framework/lib/relay";
import { OpenStoryEvent } from "coral-stream/events";
import { OpenStoryMutation as MutationTypes } from "coral-stream/__generated__/OpenStoryMutation.graphql";
@@ -25,16 +27,33 @@ const mutation = graphql`
let clientMutationId = 0;
function commit(environment: Environment, input: OpenStoryInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
async function commit(
environment: Environment,
input: OpenStoryInput,
{ eventEmitter }: CoralContext
) {
const openStoryEvent = OpenStoryEvent.begin(eventEmitter, {
storyID: input.id,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation,
variables: {
input: {
id: input.id,
clientMutationId: (clientMutationId++).toString(),
},
},
}
);
openStoryEvent.success();
return result;
} catch (error) {
openStoryEvent.error({ message: error.message, code: error.code });
throw error;
}
}
export const withOpenStoryMutation = createMutationContainer(

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