[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
This commit is contained in:
Vinh
2019-11-09 00:17:01 +07:00
committed by Wyatt Johnson
parent ce4a3408fc
commit 18346d1683
99 changed files with 3839 additions and 1330 deletions
+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 -->
+3 -3
View File
@@ -2194,9 +2194,9 @@
}
},
"@coralproject/rte": {
"version": "0.10.15",
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-0.10.15.tgz",
"integrity": "sha512-w8UWmjZxEQIW1zTMAchsmy1lzklqH2EjoyDqr9ZBed0GN6gfWfU1duTDQKc7K2igdGNRTyYfHbfXhKRIdOC6oA==",
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-0.11.0.tgz",
"integrity": "sha512-c/m2pdxIb2lyDicX5U2l3uFoUFYuetZpxVaPVzWhTclWszDGEolYzMexKnUW6W5cqabjly4yVZYx/5SNA0vW/w==",
"dev": true,
"requires": {
"bowser": "^1.0.0",
+5 -1
View File
@@ -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/",
@@ -147,7 +148,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",
@@ -381,6 +382,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();
+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,7 +38,6 @@ function createMutationContainer<T extends string, I, R>(
);
private commit = (input: I) => {
this.props.context.eventEmitter.emit(`mutation.${propName}`, input);
return commit(
this.props.context.relayEnvironment,
input,
@@ -69,7 +69,6 @@ export function useFetch<V, R>(
const context = useCoralContext();
return useCallback<FetchProp<typeof fetch>>(
((variables: V) => {
context.eventEmitter.emit(`fetch.${fetch.name}`, variables);
return fetch.fetch(context.relayEnvironment, variables, context);
}) as any,
[context]
@@ -94,10 +93,6 @@ export function withFetch<N extends string, V, R>(
public static displayName = wrapDisplayName(BaseComponent, "withFetch");
private fetch = (variables: V) => {
this.props.context.eventEmitter.emit(
`fetch.${fetch.name}`,
variables
);
return fetch.fetch(
this.props.context.relayEnvironment,
variables,
@@ -71,7 +71,6 @@ export function useMutation<I, R>(
const context = useCoralContext();
return useCallback<MutationProp<typeof mutation>>(
((input: I) => {
context.eventEmitter.emit(`mutation.${mutation.name}`, input);
return mutation.commit(context.relayEnvironment, input, context);
}) as any,
[context]
@@ -99,10 +98,6 @@ export function withMutation<N extends string, I, R>(
);
private commit = (input: I) => {
this.props.context.eventEmitter.emit(
`mutation.${mutation.name}`,
input
);
return mutation.commit(
this.props.context.relayEnvironment,
input,
@@ -50,7 +50,6 @@ export function useSubscription<V>(
const context = useCoralContext();
return useCallback<SubscriptionProp<typeof subscription>>(
((variables: V) => {
context.eventEmitter.emit(`subscription.${subscription.name}`, variables);
return subscription.subscribe(
context.relayEnvironment,
variables,
@@ -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];
@@ -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;
}
}
@@ -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,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}
@@ -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,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]);
@@ -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(
@@ -1,7 +1,12 @@
import React from "react";
import React, { FunctionComponent, useCallback } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { withPaginationContainer } from "coral-framework/lib/relay";
import { useViewerNetworkEvent } from "coral-framework/lib/events";
import {
useLoadMore,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { LoadMoreHistoryCommentsEvent } from "coral-stream/events";
import { CommentHistoryContainer_settings as SettingsData } from "coral-stream/__generated__/CommentHistoryContainer_settings.graphql";
import { CommentHistoryContainer_story as StoryData } from "coral-stream/__generated__/CommentHistoryContainer_story.graphql";
@@ -10,57 +15,47 @@ import { CommentHistoryContainerPaginationQueryVariables } from "coral-stream/__
import CommentHistory from "./CommentHistory";
interface CommentHistoryContainerProps {
interface Props {
viewer: ViewerData;
story: StoryData;
settings: SettingsData;
relay: RelayPaginationProp;
}
export class CommentHistoryContainer extends React.Component<
CommentHistoryContainerProps
> {
public state = {
disableLoadMore: false,
};
public render() {
const comments = this.props.viewer.comments.edges.map(edge => edge.node);
return (
<CommentHistory
story={this.props.story}
settings={this.props.settings}
comments={comments}
onLoadMore={this.loadMore}
hasMore={this.props.relay.hasMore()}
disableLoadMore={this.state.disableLoadMore}
/>
);
}
private loadMore = () => {
if (!this.props.relay.hasMore() || this.props.relay.isLoading()) {
return;
export const CommentHistoryContainer: FunctionComponent<Props> = props => {
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const beginLoadMoreEvent = useViewerNetworkEvent(
LoadMoreHistoryCommentsEvent
);
const loadMoreAndEmit = useCallback(async () => {
const loadMoreEvent = beginLoadMoreEvent();
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(
10, // Fetch the next 10 feed items
error => {
this.setState({ disableLoadMore: false });
if (error) {
// eslint-disable-next-line no-console
console.error(error);
}
}
);
};
}
}, [loadMore, beginLoadMoreEvent]);
const comments = props.viewer.comments.edges.map(edge => edge.node);
return (
<CommentHistory
story={props.story}
settings={props.settings}
comments={comments}
onLoadMore={loadMoreAndEmit}
hasMore={props.relay.hasMore()}
disableLoadMore={isLoadingMore}
/>
);
};
// TODO: (cvle) If this could be autogenerated.
type FragmentVariables = CommentHistoryContainerPaginationQueryVariables;
const enhanced = withPaginationContainer<
CommentHistoryContainerProps,
Props,
CommentHistoryContainerPaginationQueryVariables,
FragmentVariables
>(
@@ -4,13 +4,13 @@ import React, { FunctionComponent } from "react";
import CLASSES from "coral-stream/classes";
import HTMLContent from "coral-stream/common/HTMLContent";
import Timestamp from "coral-stream/common/Timestamp";
import InReplyTo from "coral-stream/tabs/Comments/Comment/InReplyTo";
import {
Flex,
HorizontalGutter,
Icon,
TextLink,
Timestamp,
Typography,
} from "coral-ui/components";
@@ -1,8 +1,10 @@
import React from "react";
import React, { FunctionComponent, 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";
import { ViewConversationEvent } from "coral-stream/events";
import {
SetCommentIDMutation,
withSetCommentIDMutation,
@@ -14,45 +16,50 @@ import { HistoryCommentContainer_story as StoryData } from "coral-stream/__gener
import HistoryComment from "./HistoryComment";
interface HistoryCommentContainerProps {
interface Props {
setCommentID: SetCommentIDMutation;
story: StoryData;
comment: CommentData;
settings: SettingsData;
}
export class HistoryCommentContainer extends React.Component<
HistoryCommentContainerProps
> {
private handleGoToConversation = (e: React.MouseEvent) => {
if (this.props.story.id === this.props.comment.story.id) {
this.props.setCommentID({ id: this.props.comment.id });
e.preventDefault();
}
};
public render() {
return (
<HistoryComment
{...this.props.comment}
reactionCount={this.props.comment.actionCounts.reaction.total}
reactionSettings={this.props.settings.reaction}
parentAuthorName={
this.props.comment.parent &&
this.props.comment.parent.author &&
this.props.comment.parent.author.username
}
conversationURL={getURLWithCommentID(
this.props.comment.story.url,
this.props.comment.id
)}
onGotoConversation={this.handleGoToConversation}
/>
);
}
}
const HistoryCommentContainer: FunctionComponent<Props> = props => {
const emitViewConversationEvent = useViewerEvent(ViewConversationEvent);
const handleGotoConversation = useCallback(
(e: React.MouseEvent) => {
if (props.story.id === props.comment.story.id) {
props.setCommentID({ id: props.comment.id });
emitViewConversationEvent({
from: "COMMENT_HISTORY",
commentID: props.comment.id,
});
e.preventDefault();
}
},
[props.story.id, props.comment.story.id]
);
return (
<HistoryComment
{...props.comment}
reactionCount={props.comment.actionCounts.reaction.total}
reactionSettings={props.settings.reaction}
parentAuthorName={
props.comment.parent &&
props.comment.parent.author &&
props.comment.parent.author.username
}
conversationURL={getURLWithCommentID(
props.comment.story.url,
props.comment.id
)}
onGotoConversation={handleGotoConversation}
/>
);
};
const enhanced = withSetCommentIDMutation(
withFragmentContainer<HistoryCommentContainerProps>({
withFragmentContainer<Props>({
story: graphql`
fragment HistoryCommentContainer_story on Story {
id
@@ -25,12 +25,11 @@ exports[`renders correctly 1`] = `
</ForwardRef(forwardRef)>
</div>
<div>
<Timestamp
<TimeStamp
className="coral coral-myComment-timestamp"
toggleAbsolute={true}
>
2018-07-06T18:24:00.000Z
</Timestamp>
</TimeStamp>
<ForwardRef(forwardRef)
container="div"
variant="bodyCopy"
@@ -1,10 +1,12 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { useViewerEvent } from "coral-framework/lib/events";
import { graphql, useLocal } from "coral-framework/lib/relay";
import { PropTypesOf } from "coral-framework/types";
import CLASSES from "coral-stream/classes";
import UserBoxContainer from "coral-stream/common/UserBox";
import { SetProfileTabEvent } from "coral-stream/events";
import {
HorizontalGutter,
Tab,
@@ -35,14 +37,20 @@ export interface ProfileProps {
}
const Profile: FunctionComponent<ProfileProps> = props => {
const emitSetProfileTabEvent = useViewerEvent(SetProfileTabEvent);
const [local, setLocal] = useLocal<ProfileLocal>(graphql`
fragment ProfileLocal on Local {
profileTab
}
`);
const onTabClick = useCallback(
(tab: ProfileLocal["profileTab"]) => setLocal({ profileTab: tab }),
[setLocal]
(tab: ProfileLocal["profileTab"]) => {
if (local.profileTab !== tab) {
emitSetProfileTabEvent({ tab });
setLocal({ profileTab: tab });
}
},
[setLocal, local.profileTab]
);
return (
<HorizontalGutter size="double">
@@ -13,6 +13,7 @@ import { Environment } from "relay-runtime";
import { PasswordField } from "coral-framework/components";
import getAuthenticationIntegrations from "coral-framework/helpers/getAuthenticationIntegrations";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useViewerEvent } from "coral-framework/lib/events";
import { colorFromMeta } from "coral-framework/lib/form";
import {
createFetch,
@@ -28,6 +29,10 @@ import {
} from "coral-framework/lib/validation";
import CLASSES from "coral-stream/classes";
import FieldValidationMessage from "coral-stream/common/FieldValidationMessage";
import {
ResendEmailVerificationEvent,
ShowEditEmailDialogEvent,
} from "coral-stream/events";
import {
Button,
ButtonIcon,
@@ -50,10 +55,23 @@ import styles from "./ChangeEmailContainer.css";
const fetcher = createFetch(
"resendConfirmation",
(environment: Environment, variables, context) => {
return context.rest.fetch<void>("/account/confirm", {
method: "POST",
});
async (environment: Environment, variables, { eventEmitter, rest }) => {
const resendEmailVerificationEvent = ResendEmailVerificationEvent.begin(
eventEmitter
);
try {
const result = await rest.fetch<void>("/account/confirm", {
method: "POST",
});
resendEmailVerificationEvent.success();
return result;
} catch (error) {
resendEmailVerificationEvent.error({
message: error.message,
code: error.code,
});
throw error;
}
}
);
@@ -71,6 +89,7 @@ const changeEmailContainer: FunctionComponent<Props> = ({
viewer,
settings,
}) => {
const emitShowEvent = useViewerEvent(ShowEditEmailDialogEvent);
const updateEmail = useMutation(UpdateEmailMutation);
const [showEditForm, setShowEditForm] = useState(false);
@@ -82,6 +101,9 @@ const changeEmailContainer: FunctionComponent<Props> = ({
}, [fetcher]);
const toggleEditForm = useCallback(() => {
if (!showEditForm) {
emitShowEvent();
}
setShowEditForm(!showEditForm);
}, [setShowEditForm, showEditForm]);
const onSubmit = useCallback(
@@ -7,6 +7,7 @@ import {
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { ChangeEmailEvent } from "coral-stream/events";
import { UpdateEmailMutation as MutationTypes } from "coral-stream/__generated__/UpdateEmailMutation.graphql";
@@ -14,40 +15,59 @@ let clientMutationId = 0;
const UpdateEmailMutation = createMutation(
"updateEmail",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateEmailMutation($input: UpdateEmailInput!) {
updateEmail(input: $input) {
clientMutationId
user {
id
email
emailVerified
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }
) => {
const changeEmailEvent = ChangeEmailEvent.begin(eventEmitter, {
oldEmail: getViewer(environment)!.email!,
newEmail: input.email,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation UpdateEmailMutation($input: UpdateEmailInput!) {
updateEmail(input: $input) {
clientMutationId
user {
id
email
emailVerified
}
}
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticResponse: {
updateEmail: {
clientMutationId: (clientMutationId++).toString(),
user: {
// Only a logged in user will be able to change its email
// and access this mutation, so the viewer is always available
// in the cache when calling this mutation.
id: getViewer(environment)!.id,
email: input.email,
emailVerified: false,
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
},
},
})
optimisticResponse: {
updateEmail: {
clientMutationId: (clientMutationId++).toString(),
user: {
// Only a logged in user will be able to change its email
// and access this mutation, so the viewer is always available
// in the cache when calling this mutation.
id: getViewer(environment)!.id,
email: input.email,
emailVerified: false,
},
},
},
}
);
changeEmailEvent.success();
return result;
} catch (error) {
changeEmailEvent.error({ message: error.message, code: error.code });
throw error;
}
}
);
export default UpdateEmailMutation;
@@ -5,6 +5,7 @@ import React, { FunctionComponent, useCallback, useState } from "react";
import { Field, Form } from "react-final-form";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useViewerEvent } from "coral-framework/lib/events";
import { colorFromMeta } from "coral-framework/lib/form";
import { useMutation } from "coral-framework/lib/relay";
import {
@@ -14,6 +15,7 @@ import {
} from "coral-framework/lib/validation";
import CLASSES from "coral-stream/classes";
import FieldValidationMessage from "coral-stream/common/FieldValidationMessage";
import { ShowEditPasswordDialogEvent } from "coral-stream/events";
import {
Button,
CallOut,
@@ -40,6 +42,7 @@ interface FormProps {
}
const ChangePassword: FunctionComponent<Props> = ({ onResetPassword }) => {
const emitShowEvent = useViewerEvent(ShowEditPasswordDialogEvent);
const updatePassword = useMutation(UpdatePasswordMutation);
const onSubmit = useCallback(
async (input: FormProps, form: FormApi) => {
@@ -64,10 +67,12 @@ const ChangePassword: FunctionComponent<Props> = ({ onResetPassword }) => {
);
const [showForm, setShowForm] = useState(false);
const toggleForm = useCallback(() => setShowForm(!showForm), [
showForm,
setShowForm,
]);
const toggleForm = useCallback(() => {
if (!showForm) {
emitShowEvent();
}
setShowForm(!showForm);
}, [showForm, setShowForm]);
return (
<div
@@ -14,6 +14,7 @@ import { reduceSeconds, UNIT } from "coral-common/helpers/i18n";
import getAuthenticationIntegrations from "coral-framework/helpers/getAuthenticationIntegrations";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useViewerEvent } from "coral-framework/lib/events";
import {
graphql,
useMutation,
@@ -27,6 +28,7 @@ import {
} from "coral-framework/lib/validation";
import CLASSES from "coral-stream/classes";
import FieldValidationMessage from "coral-stream/common/FieldValidationMessage";
import { ShowEditUsernameDialogEvent } from "coral-stream/events";
import {
Box,
Button,
@@ -66,9 +68,15 @@ const ChangeUsernameContainer: FunctionComponent<Props> = ({
viewer,
settings,
}) => {
const emitShowEditUsernameDialog = useViewerEvent(
ShowEditUsernameDialogEvent
);
const [showEditForm, setShowEditForm] = useState(false);
const [showSuccessMessage, setShowSuccessMessage] = useState(false);
const toggleEditForm = useCallback(() => {
if (!showEditForm) {
emitShowEditUsernameDialog();
}
setShowEditForm(!showEditForm);
}, [setShowEditForm, showEditForm]);
const updateUsername = useMutation(UpdateUsernameMutation);
@@ -9,58 +9,78 @@ import {
} from "coral-framework/lib/relay";
import { UpdateUsernameMutation as MutationTypes } from "coral-stream/__generated__/UpdateUsernameMutation.graphql";
import { ChangeUsernameEvent } from "coral-stream/events";
let clientMutationId = 0;
const UpdateUsernameMutation = createMutation(
"updateUsername",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateUsernameMutation($input: UpdateUsernameInput!) {
updateUsername(input: $input) {
clientMutationId
user {
username
status {
username {
history {
username
createdAt
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }
) => {
const changeUsernameEvent = ChangeUsernameEvent.begin(eventEmitter, {
oldUsername: getViewer(environment)!.username!,
newUsername: input.username,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation UpdateUsernameMutation($input: UpdateUsernameInput!) {
updateUsername(input: $input) {
clientMutationId
user {
username
status {
username {
history {
username
createdAt
}
}
}
}
}
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticResponse: {
updateUsername: {
clientMutationId: (clientMutationId++).toString(),
user: {
id: getViewer(environment)!.id,
username: input.username,
status: {
username: {
// FIXME: (tessalt) merge in existing history
history: [
{
username: input.username,
createdAt: Date.now(),
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticResponse: {
updateUsername: {
clientMutationId: (clientMutationId++).toString(),
user: {
id: getViewer(environment)!.id,
username: input.username,
status: {
username: {
// FIXME: (tessalt) merge in existing history
history: [
{
username: input.username,
createdAt: Date.now(),
},
],
},
],
},
},
},
},
},
},
})
}
);
changeUsernameEvent.success();
return result;
} catch (error) {
changeUsernameEvent.error({ message: error.message, code: error.code });
throw error;
}
}
);
export default UpdateUsernameMutation;
@@ -9,6 +9,7 @@ import {
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { RequestAccountDeletionEvent } from "coral-stream/events";
import { RequestAccountDeletionMutation as MutationTypes } from "coral-stream/__generated__/RequestAccountDeletionMutation.graphql";
@@ -16,39 +17,58 @@ let clientMutationId = 0;
const RequestAccountDeletionMutation = createMutation(
"requestAccountDeletion",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RequestAccountDeletionMutation(
$input: RequestAccountDeletionInput!
) {
requestAccountDeletion(input: $input) {
user {
scheduledDeletionDate
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }
) => {
const requestAccountDeletionEvent = RequestAccountDeletionEvent.begin(
eventEmitter
);
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation RequestAccountDeletionMutation(
$input: RequestAccountDeletionInput!
) {
requestAccountDeletion(input: $input) {
user {
scheduledDeletionDate
}
clientMutationId
}
}
clientMutationId
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
const viewer = getViewer(environment)!;
const deletionDate = DateTime.fromJSDate(new Date())
.plus({ days: SCHEDULED_DELETION_TIMESPAN_DAYS })
.toISO();
const viewerProxy = store.get(viewer.id);
if (viewerProxy !== null) {
viewerProxy.setValue(deletionDate, "scheduledDeletionDate");
}
},
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
const viewer = getViewer(environment)!;
const deletionDate = DateTime.fromJSDate(new Date())
.plus({ days: SCHEDULED_DELETION_TIMESPAN_DAYS })
.toISO();
const viewerProxy = store.get(viewer.id);
if (viewerProxy !== null) {
viewerProxy.setValue(deletionDate, "scheduledDeletionDate");
}
},
})
);
requestAccountDeletionEvent.success();
return result;
} catch (error) {
requestAccountDeletionEvent.error({
message: error.message,
code: error.code,
});
throw error;
}
}
);
export default RequestAccountDeletionMutation;
@@ -2,8 +2,10 @@ import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback, useState } 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 { ShowIgnoreUserdDialogEvent } from "coral-stream/events";
import {
Button,
Flex,
@@ -24,12 +26,15 @@ interface Props {
}
const IgnoreUserSettingsContainer: FunctionComponent<Props> = ({ viewer }) => {
const emitShow = useViewerEvent(ShowIgnoreUserdDialogEvent);
const removeUserIgnore = useMutation(RemoveUserIgnoreMutation);
const [showManage, setShowManage] = useState(false);
const toggleManage = useCallback(() => setShowManage(!showManage), [
showManage,
setShowManage,
]);
const toggleManage = useCallback(() => {
if (!showManage) {
emitShow();
}
setShowManage(!showManage);
}, [showManage, setShowManage]);
return (
<div
data-testid="profile-account-ignoredCommenters"
@@ -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 { RemoveUserIgnoreEvent } from "coral-stream/events";
import { RemoveUserIgnoreMutation as MutationTypes } from "coral-stream/__generated__/RemoveUserIgnoreMutation.graphql";
@@ -14,37 +16,55 @@ let clientMutationId = 0;
const RemoveUserIgnoreMutation = createMutation(
"removeUserIgnore",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RemoveUserIgnoreMutation($input: RemoveUserIgnoreInput!) {
removeUserIgnore(input: $input) {
clientMutationId
}
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }: CoralContext
) => {
const removeUserIgnore = RemoveUserIgnoreEvent.begin(eventEmitter, {
userID: input.userID,
});
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation RemoveUserIgnoreMutation($input: RemoveUserIgnoreInput!) {
removeUserIgnore(input: $input) {
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const viewer = getViewer(environment)!;
const viewerProxy = store.get(viewer.id)!;
const removeIgnoredUserRecords = viewerProxy.getLinkedRecords(
"ignoredUsers"
);
if (removeIgnoredUserRecords) {
viewerProxy.setLinkedRecords(
removeIgnoredUserRecords.filter(
r => r!.getValue("id") !== input.userID
),
"ignoredUsers"
);
}
},
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const viewer = getViewer(environment)!;
const viewerProxy = store.get(viewer.id)!;
const removeIgnoredUserRecords = viewerProxy.getLinkedRecords(
"ignoredUsers"
);
if (removeIgnoredUserRecords) {
viewerProxy.setLinkedRecords(
removeIgnoredUserRecords.filter(
r => r!.getValue("id") !== input.userID
),
"ignoredUsers"
);
}
},
})
);
removeUserIgnore.success();
return result;
} catch (error) {
removeUserIgnore.error({ message: error.message, code: error.code });
throw error;
}
}
);
export default RemoveUserIgnoreMutation;
@@ -7,6 +7,7 @@ import {
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { RequestDownloadCommentHistoryEvent } from "coral-stream/events";
import { RequestCommentsDownloadMutation as MutationTypes } from "coral-stream/__generated__/RequestCommentsDownloadMutation.graphql";
@@ -14,7 +15,11 @@ let clientMutationId = 0;
const RequestCommentsDownloadMutation = createMutation(
"requestCommentsDownload",
(environment: Environment, input: MutationInput<MutationTypes>) => {
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }
) => {
const updater = (store: RecordSourceSelectorProxy) => {
const viewer = getViewer(environment)!;
const user = store.get(viewer.id);
@@ -24,26 +29,41 @@ const RequestCommentsDownloadMutation = createMutation(
user.setValue(now.toISOString(), "lastDownloadedAt");
}
};
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RequestCommentsDownloadMutation(
$input: RequestCommentsDownloadInput!
) {
requestCommentsDownload(input: $input) {
clientMutationId
}
const requestDownloadCommentHistoryEvent = RequestDownloadCommentHistoryEvent.begin(
eventEmitter
);
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation RequestCommentsDownloadMutation(
$input: RequestCommentsDownloadInput!
) {
requestCommentsDownload(input: $input) {
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: updater,
updater,
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: updater,
updater,
});
);
requestDownloadCommentHistoryEvent.success();
return result;
} catch (error) {
requestDownloadCommentHistoryEvent.error({
message: error.message,
code: error.code,
});
throw error;
}
}
);
@@ -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 { UpdateNotificationSettingsEvent } from "coral-stream/events";
import { UpdateNotificationSettingsMutation as MutationTypes } from "coral-stream/__generated__/UpdateNotificationSettingsMutation.graphql";
@@ -13,34 +15,62 @@ let clientMutationId = 0;
const UpdateNotificationSettingsMutation = createMutation(
"updateNotificationSettings",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateNotificationSettingsMutation(
$input: UpdateNotificationSettingsInput!
) {
updateNotificationSettings(input: $input) {
user {
id
notifications {
onReply
onFeatured
onStaffReplies
onModeration
digestFrequency
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }: CoralContext
) => {
const updateNofitificationSettings = UpdateNotificationSettingsEvent.begin(
eventEmitter,
{
digestFrequency: input.digestFrequency,
onFeatured: input.onFeatured,
onModeration: input.onModeration,
onStaffReplies: input.onStaffReplies,
onReply: input.onReply,
}
);
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation UpdateNotificationSettingsMutation(
$input: UpdateNotificationSettingsInput!
) {
updateNotificationSettings(input: $input) {
user {
id
notifications {
onReply
onFeatured
onStaffReplies
onModeration
digestFrequency
}
}
clientMutationId
}
}
clientMutationId
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
updateNofitificationSettings.success();
return result;
} catch (error) {
updateNofitificationSettings.error({
message: error.message,
code: error.code,
});
throw error;
}
}
);
export default UpdateNotificationSettingsMutation;
@@ -6,6 +6,7 @@ import {
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { ChangePasswordEvent } from "coral-stream/events";
import { UpdatePasswordMutation as MutationTypes } from "coral-stream/__generated__/UpdatePasswordMutation.graphql";
@@ -13,22 +14,38 @@ let clientMutationId = 0;
const UpdatePasswordMutation = createMutation(
"updatePassword",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdatePasswordMutation($input: UpdatePasswordInput!) {
updatePassword(input: $input) {
clientMutationId
}
async (
environment: Environment,
input: MutationInput<MutationTypes>,
{ eventEmitter }
) => {
const changePasswordEvent = ChangePasswordEvent.begin(eventEmitter);
try {
const result = await commitMutationPromiseNormalized<MutationTypes>(
environment,
{
mutation: graphql`
mutation UpdatePasswordMutation($input: UpdatePasswordInput!) {
updatePassword(input: $input) {
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
changePasswordEvent.success();
return result;
} catch (error) {
changePasswordEvent.error({ message: error.message, code: error.code });
throw error;
}
}
);
export default UpdatePasswordMutation;
@@ -86,6 +86,8 @@ exports[`renders comment stream 1`] = `
<div>
<div
className="$root content placeholder toolbar"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -200,6 +202,7 @@ exports[`renders comment stream 1`] = `
id="coral-comments-sortMenu"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
value="CREATED_AT_DESC"
>
@@ -299,6 +299,8 @@ exports[`edit a comment and handle server error: edit form 1`] = `
<div>
<div
className="$root content placeholder toolbar"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -487,6 +489,8 @@ exports[`edit a comment: edit form 1`] = `
<div>
<div
className="$root content placeholder toolbar"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -675,6 +679,8 @@ exports[`edit a comment: optimistic response 1`] = `
<div>
<div
className="$root content placeholder toolbar "
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -1604,6 +1610,8 @@ exports[`shows expiry message: edit time expired 1`] = `
<div>
<div
className="$root content placeholder toolbar "
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -314,6 +314,8 @@ exports[`post a reply: open reply form 1`] = `
<div>
<div
className="$root content placeholder toolbar"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -270,6 +270,8 @@ exports[`post a reply: open reply form 1`] = `
<div>
<div
className="$root content placeholder toolbar"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -70,6 +70,8 @@ exports[`renders comment stream with community guidelines 1`] = `
<div>
<div
className="$root content placeholder toolbar"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -184,6 +186,7 @@ exports[`renders comment stream with community guidelines 1`] = `
id="coral-comments-sortMenu"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
value="CREATED_AT_DESC"
>
@@ -105,6 +105,7 @@ exports[`renders message box when commenting disabled 1`] = `
id="coral-comments-sortMenu"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
value="CREATED_AT_DESC"
>
@@ -303,6 +304,8 @@ exports[`renders message box when logged in 1`] = `
<div>
<div
className="$root content placeholder toolbar"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -424,6 +427,7 @@ exports[`renders message box when logged in 1`] = `
id="coral-comments-sortMenu"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
value="CREATED_AT_DESC"
>
@@ -603,6 +607,8 @@ exports[`renders message box when not logged in 1`] = `
<div>
<div
className="$root content placeholder toolbar"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -717,6 +723,7 @@ exports[`renders message box when not logged in 1`] = `
id="coral-comments-sortMenu"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
value="CREATED_AT_DESC"
>
@@ -917,6 +924,7 @@ exports[`renders message box when story isClosed 1`] = `
id="coral-comments-sortMenu"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
value="CREATED_AT_DESC"
>
@@ -86,6 +86,8 @@ exports[`renders comment stream 1`] = `
<div>
<div
className="$root content placeholder toolbar"
onBlur={[Function]}
onFocus={[Function]}
>
<div
className="RTE-contentEditableContainer"
@@ -200,6 +202,7 @@ exports[`renders comment stream 1`] = `
id="coral-comments-sortMenu"
onBlur={[Function]}
onChange={[Function]}
onClick={[Function]}
onFocus={[Function]}
value="CREATED_AT_DESC"
>
@@ -4,6 +4,7 @@ import React, {
EventHandler,
FocusEvent,
FunctionComponent,
MouseEvent,
} from "react";
import { withKeyboardFocus, withStyles } from "coral-ui/hocs";
@@ -37,6 +38,7 @@ export interface SelectFieldProps {
autofocus?: boolean;
name?: string;
onChange?: EventHandler<ChangeEvent<HTMLSelectElement>>;
onClick?: EventHandler<MouseEvent>;
disabled?: boolean;
// These handlers are passed down by the `withKeyboardFocus` HOC.
@@ -1,5 +1,11 @@
import cn from "classnames";
import React, { FunctionComponent, useCallback, useState } from "react";
import React, {
EventHandler,
FunctionComponent,
MouseEvent,
useCallback,
useState,
} from "react";
import { AbsoluteTime, BaseButton, RelativeTime } from "coral-ui/components";
@@ -9,17 +15,28 @@ export interface TimestampProps {
className?: string;
children: string;
toggleAbsolute?: boolean;
onToggleAbsolute?: (absolute: boolean) => void;
onClick?: EventHandler<MouseEvent>;
}
const Timestamp: FunctionComponent<TimestampProps> = props => {
const [showAbsolute, setShowAbsolute] = useState(false);
const toggleShowAbsolute = useCallback(() => {
if (props.toggleAbsolute) {
setShowAbsolute(!showAbsolute);
}
}, [showAbsolute, setShowAbsolute]);
const handleOnClick = useCallback(
(event: MouseEvent) => {
if (props.toggleAbsolute) {
if (props.onToggleAbsolute) {
props.onToggleAbsolute(!showAbsolute);
}
setShowAbsolute(!showAbsolute);
}
if (props.onClick) {
return props.onClick(event);
}
},
[showAbsolute, setShowAbsolute, props.onClick]
);
return (
<BaseButton className={styles.root} onClick={toggleShowAbsolute}>
<BaseButton className={styles.root} onClick={handleOnClick}>
{showAbsolute ? (
<AbsoluteTime
date={props.children}